diff --git a/.changeset/notice-stage4-review-fixes.md b/.changeset/notice-stage4-review-fixes.md new file mode 100644 index 000000000..b5fa6b42e --- /dev/null +++ b/.changeset/notice-stage4-review-fixes.md @@ -0,0 +1,5 @@ +--- +"@agent-bundle/runtime": patch +--- + +Fix two #99 stage-4 review findings: retriable attempted notices past `expiresAt` now expire instead of retaining unused attempts, and `acknowledge()` rejects invocations that started before the notice existed so durable acknowledgement receipts can never predate `createdAt`. The package README's Notices section now describes the current handle surface, states, receipts, retry semantics, and route selector. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 3f5af4417..7d3079203 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -225,14 +225,26 @@ workspace-durable SQLite driver and passes the resulting ledger as subpath and ship no state or notice implementation. Inside an authorized request, `(await agent()).notices` is a request-bound -handle with `publish()` and `read()`. Recipients use only observed -host/session/actor/workspace axes. Publish authorization runs before -persistence, and delivery authorization runs again when a matching event is -admitted. `read()` exposes notices selected for that event while the ledger -records a receipt containing the invocation id and state `attempted`. - -V1 deliberately exposes only `pending | attempted | expired | unavailable | -withdrawn`. It does not claim `delivered`, `read`, or `acknowledged`: observing -the recipient process is not evidence that the agent saw the content. There -is no router, MCP inbox, timer, retry worker, or autonomous work between -invocations in this subpath. +handle with `publish()`, `read()`, `inbox()`, and `acknowledge()`. Recipients +use only observed host/session/actor/workspace axes. Publish authorization +runs before persistence, and delivery authorization runs again when a +matching event is admitted. `read()` exposes notices selected for that event +while the ledger records a receipt containing the invocation id and state +`attempted`. `acknowledge()` is recipient-matched and authorization-gated and +produces the terminal `acknowledged` state — the strongest evidenced outcome. + +`publish()` accepts optional `retryBudget` (default one attempt) and +`nextAttemptAt`; both are evaluated only when a matching event is admitted — +no timer or retry worker is implied, and a retriable notice past `expiresAt` +expires instead of retaining unused attempts. Wire-level +`notifications/resources/updated` signals are recorded through the ledger's +`signalAvailability()` as availability receipts, and MCP inbox reads record +exposure receipts; neither is a delivery claim. + +States are `pending | attempted | expired | unavailable | withdrawn | +acknowledged`. The ledger still does not claim `delivered` or `read` as +states: observing the recipient process is not evidence the agent saw the +content, and the `available`/`read` taxonomy rows from #99 map onto the +availability and exposure receipts. `selectNoticeDeliveryRoutes()` chooses +cross-request routes from a per-host advertisement and returns a typed +unavailable outcome when none is supported; it never fabricates a channel. diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 25a252e86..5e87116ce 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -331,7 +331,8 @@ export const createAgentNoticeLedger = ( let admitted = before.state; const admissionTime = Date.parse(request.invocation.startedAt); const expiring = before.state.notices.filter((notice) => - notice.state === 'pending' + (notice.state === 'pending' + || notice.state === 'attempted' && notice.attempts.length < (notice.retryBudget ?? 1)) && notice.expiresAt !== undefined && Date.parse(notice.expiresAt) <= admissionTime); const candidates = before.state.notices.filter((notice) => @@ -393,6 +394,15 @@ export const createAgentNoticeLedger = ( 'Only the notice recipient may acknowledge it', )); } + // Same eligibility boundary as inbox/event admission: a request + // that started before the notice existed cannot produce a durable + // acknowledgement receipt predating createdAt. + if (Date.parse(target.createdAt) > Date.parse(request.invocation.startedAt)) { + return yield* Effect.fail(new AgentNoticeError( + 'invalid-input', + `Notice ${noticeId} was created after this invocation started`, + )); + } const authorization = yield* authorizeEffect(options.authorize, { noticeId, phase: 'acknowledge', diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index d47aa4ccf..f7cfd01b8 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -180,6 +180,12 @@ const transitionExpiry = (notice: AgentNotice, at: string): AgentNotice => { ? expiredNotice(notice, at) : notice; case 'attempted': + // A retriable notice past its deadline must not linger with unused + // retries; a fully-attempted notice keeps its terminal attempt record. + return notice.attempts.length < (notice.retryBudget ?? 1) + && notice.expiresAt !== undefined && Date.parse(notice.expiresAt) <= Date.parse(at) + ? expiredNotice(notice, at) + : notice; case 'expired': case 'unavailable': case 'withdrawn': diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index ef56fcae1..ee8e2ab77 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -870,3 +870,56 @@ describe('notice delivery route selection', () => { }))).toThrow(/requires a dated reason/u); }); }); + +describe('stage-4 review findings regressions', () => { + it('expires a retriable attempted notice past its deadline instead of retaining retries', async () => { + const { driver, ledger } = await openLedger(); + await run(ledger, { + actorId: 'publisher', + id: 'publish-exp', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('deadline'), + expiresAt: '2026-09-01T19:10:00.000Z', + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + retryBudget: 3, + }, { idempotencyKey: 'publish:expiry' })); + const admit = (id: string, startedAt: string) => run(ledger, { + actorId: 'recipient', + id, + kind: 'event', + startedAt, + }, async () => (await agent()).notices!.read()); + expect(await admit('event-1', '2026-09-01T19:05:00.000Z')).toHaveLength(1); + expect(await admit('event-2', '2026-09-01T19:11:00.000Z')).toHaveLength(0); + expect((await ledger.read()).notices[0]).toMatchObject({ + expiredAt: '2026-09-01T19:11:00.000Z', + state: 'expired', + }); + await driver.close(); + }); + + it('rejects acknowledgements from invocations that started before the notice existed', async () => { + const { driver, ledger } = await openLedger(); + const published = await run(ledger, { + actorId: 'publisher', + id: 'publish-late', + kind: 'tool', + startedAt: '2026-09-01T19:05:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('late'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:late' })); + await expect(run(ledger, { + actorId: 'recipient', + id: 'ack-early', + kind: 'event', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.acknowledge(published.notice.id))) + .rejects.toMatchObject({ code: 'invalid-input' }); + await driver.close(); + }); +});