Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/notice-stage4-review-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 23 additions & 11 deletions packages/rsc-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 11 additions & 1 deletion packages/rsc-runtime/src/notices/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions packages/rsc-runtime/src/notices/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
53 changes: 53 additions & 0 deletions packages/rsc-runtime/tests/notices-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading