diff --git a/.agentworkforce/agents/factory-feature-guardian/agent.test.ts b/.agentworkforce/agents/factory-feature-guardian/agent.test.ts index 05c5ca2..740640d 100644 --- a/.agentworkforce/agents/factory-feature-guardian/agent.test.ts +++ b/.agentworkforce/agents/factory-feature-guardian/agent.test.ts @@ -280,14 +280,16 @@ class DelayedSlackTransport extends IdempotentSlackTransport { } } -class ReceiptlessSlackTransport extends IdempotentSlackTransport { +class LateReceiptReplaySlackTransport extends IdempotentSlackTransport { + private readonly receiptlessKeys = new Set(); + override async write(request: RelayTransportWriteRequest): Promise { - this.attempts.push(request); - return { - path: '/slack/draft.json', - absolutePath: '/slack/draft.json', - receipt: { id: 'mountcmd-without-provider-ts' }, - }; + const result = await super.write(request); + const body = request.body as { idempotencyKey?: string }; + const key = body.idempotencyKey ?? `unkeyed:${this.attempts.length}`; + if (this.receiptlessKeys.has(key)) return result; + this.receiptlessKeys.add(key); + return { ...result, receipt: { id: 'mountcmd-without-provider-ts' } }; } } @@ -1095,19 +1097,51 @@ describe('factory-feature-guardian delayed Slack receipts', () => { } }); - it('rejects and never advances on a receipt-shaped draft without provider ts', async () => { - const transport = new ReceiptlessSlackTransport(); + it('keeps a delayed receipt retryable and replays the stable key without a second Slack post', async () => { + const capabilityFeatures = [ + manifestFeatures[0]!, + { + id: 'capabilities-register', + name: 'Register Capabilities', + cli: 'relay capabilities register', + description: 'Registers broker capabilities.', + tier: 1, + }, + ]; + const capabilityState: ProgressState = { + kind: 'factory-feature-guardian:progress', + version: 3, + generation: 1, + checkedIds: ['broker-up'], + cycleStartedAt: '2026-07-18T10:26:47.981Z', + totalFeatures: capabilityFeatures.length, + lastPost: { featureId: 'broker-up', ts: '17843701.029509' }, + }; + const formerFatalError = + 'Slack post failed: no timestamp returned for feature capabilities-register'; + const transport = new LateReceiptReplaySlackTransport(); const restore = bindPreviewTransport(transport); - const { ctx, files } = exactStateContext(JSON.stringify(progressState(1))); + const { ctx, files } = exactStateContext( + JSON.stringify(capabilityState), + renderManifest(capabilityFeatures) + ); try { - await expect(guardian.handler(ctx, { type: 'cron.tick' } as never)).rejects.toThrow( - 'Slack post failed: no timestamp returned for feature broker-status' - ); + await expect(guardian.handler(ctx, { type: 'cron.tick' } as never)).resolves.toBeUndefined(); expect(JSON.parse(files.get(CYCLE_STATE_PATH) ?? '{}').checkedIds).toEqual(['broker-up']); - expect(ctx.log).toHaveBeenCalledWith('error', 'factory-feature-guardian.post-failed', { + expect(ctx.log).toHaveBeenCalledWith('warn', 'factory-feature-guardian.post-receipt-pending', { channel: 'C0BHWJSF309', - feature: 'broker-status', + feature: 'capabilities-register', + path: expect.any(String), }); + expect(JSON.stringify(vi.mocked(ctx.log).mock.calls)).not.toContain(formerFatalError); + + await expect(guardian.handler(ctx, { type: 'cron.tick' } as never)).resolves.toBeUndefined(); + const state = JSON.parse(files.get(CYCLE_STATE_PATH) ?? '{}') as ProgressState; + expect(state.checkedIds).toEqual(['broker-up', 'capabilities-register']); + expect(state.lastPost?.ts).toBe('1710000001.000100'); + expect(transport.providerCreates).toBe(1); + expect(transport.attempts).toHaveLength(2); + expect(transport.attempts[0]?.body).toMatchObject(transport.attempts[1]?.body as object); } finally { restore(); } diff --git a/.agentworkforce/agents/factory-feature-guardian/agent.ts b/.agentworkforce/agents/factory-feature-guardian/agent.ts index 6d3edbf..432343a 100644 --- a/.agentworkforce/agents/factory-feature-guardian/agent.ts +++ b/.agentworkforce/agents/factory-feature-guardian/agent.ts @@ -808,8 +808,16 @@ export async function runGuardian( } const ts = deliveredSlackTs(result); if (!ts) { - ctx.log('error', 'factory-feature-guardian.post-failed', { channel, feature: feature.id }); - throw new Error(`Slack post failed: no timestamp returned for feature ${feature.id}`); + // A successful helper return means the draft was admitted, not that the + // provider receipt is already visible. Leave the exact checkpoint alone so + // the next tick replays the stable idempotency key instead of turning an + // eventually-consistent receipt into a terminal handler failure. + ctx.log('warn', 'factory-feature-guardian.post-receipt-pending', { + channel, + feature: feature.id, + path: result.path, + }); + return; } // Checkpoint immediately after the confirmed provider receipt. The stable