From c804f6d1cf2fc80c41f20abaa68b6244d0d492c6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:19:26 +0000 Subject: [PATCH 01/18] feat(notices): subscription-aware resources/updated for the notice inbox (#99 stage 4) Generated workspace-durable MCP servers now hold their own handle on the notice store their Flight worker mounts (createGeneratedNoticeRuntime), register resources/subscribe and resources/unsubscribe for the inbox URI only, advertise resources.subscribe exactly when that wiring is active, and after every completed render send at most one notifications/resources/updated to the subscribed connection for its newly eligible pending notices, recording the signal through signalAvailability as an availability receipt (never delivery). Eligibility is recipient-matched, honours nextAttemptAt, is bounded per notice by retryBudget across restarts, and never re-triggers on exposure or availability receipts, so subscribed clients cannot loop. Subscribing fails closed when the durable store is unreadable; volatile lifetimes stay in the worker's heap and advertise no subscribe capability. --- .changeset/notice-inbox-resource-updated.md | 6 + docs/entry-conventions.md | 9 + .../agent-bundle/src/build/entry-shell.ts | 31 ++ .../agent-bundle/src/mcp-server-runtime.ts | 144 ++++++- packages/agent-bundle/src/test/mcp.ts | 37 +- .../agent-bundle/tests/entry-shell.test.ts | 31 ++ .../tests/generated-route-server.test.ts | 91 +++++ .../tests/projection/mcp-in-memory.test.ts | 100 +++++ packages/rsc-runtime/README.md | 16 + packages/rsc-runtime/src/mount/index.ts | 160 +++++--- .../rsc-runtime/src/notices/inbox-route.ts | 7 +- packages/rsc-runtime/src/notices/index.ts | 10 + .../src/notices/resource-updated.ts | 187 ++++++++++ packages/rsc-runtime/tests/mount.test.ts | 120 +++++- .../tests/notices-resource-updated.test.ts | 351 ++++++++++++++++++ 15 files changed, 1237 insertions(+), 63 deletions(-) create mode 100644 .changeset/notice-inbox-resource-updated.md create mode 100644 packages/rsc-runtime/src/notices/resource-updated.ts create mode 100644 packages/rsc-runtime/tests/notices-resource-updated.test.ts diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md new file mode 100644 index 000000000..bd5230118 --- /dev/null +++ b/.changeset/notice-inbox-resource-updated.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e8fb66945..6defd6a3b 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -160,6 +160,15 @@ own `bin/` directory instead, like the MCP worker. Notice authorization is delib in generated mounting v1 (`authorized`); recipient/principal matching remains enforced by the ledger, while application authorization policy is deferred. +For workspace-durable state only, the generated MCP server process also opens +its own SQLite handle on the notice ledger (`createGeneratedNoticeRuntime` +over the same anchor) and advertises `resources.subscribe`: a client that +subscribes to `agent-bundle://notices/inbox` receives one +`notifications/resources/updated` after a render leaves it newly eligible +pending notices, recorded on the ledger as an availability receipt. Volatile +lifetimes keep the store in the worker's heap, so those servers register no +subscription handlers and advertise no subscribe capability. + #### State mutation budgets `defineState({ ... })` accepts an optional `budgets` runtime policy. Omitted diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 8c0b17ebe..280a7276b 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -649,6 +649,34 @@ const noticeInboxRecord = (state: NormalizedStateDefinition | undefined): readon ? [] : [' [noticeInboxRoute.AGENT_NOTICE_INBOX_ROUTE_ID]: noticeInboxRoute.noticeInboxRouteRecord(noticeInboxRoute),']; +/** + * The server process's own handle on the workspace-durable notice store its + * Flight worker mounts (#99 stage 4): SQLite is the one lifetime two threads + * can share, so only durable artifacts wire `resources/subscribe` and + * `notifications/resources/updated` for the inbox; volatile lifetimes live in + * the worker's heap and honestly advertise no subscription capability. The + * anchor resolution matches the worker's so both open the same files. + */ +const noticeDeliveryImports = (state: NormalizedStateDefinition | undefined): readonly string[] => + state?.lifetime === 'workspace-durable' + ? [ + "import { join } from 'node:path';", + "import { fileURLToPath } from 'node:url';", + "import { createGeneratedNoticeRuntime } from '@agent-bundle/runtime/mount';", + "import { createNoticeInboxSignaller } from '@agent-bundle/runtime/notices';", + "import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';", + ] + : []; + +const noticeDeliveryOwner = (state: NormalizedStateDefinition | undefined): readonly string[] => + state?.lifetime === 'workspace-durable' + ? [ + "const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));", + "const noticeDelivery = createNoticeInboxSignaller({ store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable' }) });", + '', + ] + : []; + const eventRouteImports = ( routes: readonly NormalizedHook[], offset: number, @@ -892,6 +920,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ] : []), "import mcpApps from 'agent-bundle/mcp-apps';", + ...noticeDeliveryImports(options.state), ...noticeInboxImport(options.state), ...routeImports(routes), '', @@ -901,6 +930,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ...noticeInboxRecord(options.state), '});', '', + ...noticeDeliveryOwner(options.state), ...(hasEvents ? [ // The endpoint identity is artifact-location dependent, so it stays @@ -925,6 +955,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ' artifactEpoch: ARTIFACT_EPOCH,', ...(hasEvents ? [' events,'] : []), ` host: createFlightWorkerHost(new URL(${JSON.stringify(`./${options.workerFile}`)}, import.meta.url), ARTIFACT_EPOCH),`, + ...(options.state?.lifetime === 'workspace-durable' ? [' notices: noticeDelivery,'] : []), ` plugin: ${stableJson(options.plugin)},`, ' routes,', '});', diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 79063cad5..75ce99f43 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -16,7 +16,7 @@ */ import { Worker } from 'node:worker_threads'; -import { McpServer } from '@modelcontextprotocol/server'; +import { McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server'; import { AgentRuntimeError, agent, @@ -26,6 +26,7 @@ import { createWarmFlightHost, projectMcpRenderStream, runAgentRequest, + unavailable, } from '@agent-bundle/runtime'; import type { createEventRuntimeServer } from './events/ipc.ts'; import type { createCanonicalEventProps, projectEventDocument } from './events/project.ts'; @@ -44,6 +45,7 @@ import type { Observed, WarmFlightHost, } from '@agent-bundle/runtime'; +import type { AgentNoticeInboxSignaller } from '@agent-bundle/runtime/notices'; /** One route the generated server hosts, as the generated module records it. */ export interface GeneratedRouteRecord { @@ -233,12 +235,28 @@ export const advertisedOutputSchema = (schema: unknown): unknown => { return objectRootedJsonSchema(jsonSchema) ? schema : undefined; }; +/** + * Runs after every render the server completes, successful or not: a route + * that published a notice and then failed still advanced the ledger. The hook + * never throws into the request path. + */ +export type GeneratedRenderSettled = () => Promise; + +const settled = async (operation: () => Promise, afterRender: GeneratedRenderSettled | undefined): Promise => { + try { + return await operation(); + } finally { + await afterRender?.(); + } +}; + /** Registers the compiled MCP routes on a server, keyed by route kind. */ export const registerGeneratedRoutes = ( server: McpServer, routes: Readonly>, dispatcher: AgentRenderDispatcher, artifactEpoch: string, + afterRender?: GeneratedRenderSettled, ): void => { for (const route of Object.values(routes)) { switch (route.kind) { @@ -248,7 +266,7 @@ export const registerGeneratedRoutes = ( ...selectedConfig(route.config, ['_meta', 'annotations', 'description', 'icons', 'title']), inputSchema: route.module.inputSchema, ...(outputSchema === undefined ? {} : { outputSchema }), - } as never, (async (input: unknown, context: GeneratedRouteRequestContext) => { + } as never, (async (input: unknown, context: GeneratedRouteRequestContext) => settled(async () => { const clientName = server.server.getClientVersion()?.name; const rendered = await renderGeneratedRoute( dispatcher, @@ -259,7 +277,7 @@ export const registerGeneratedRoutes = ( { clientName }, ); return attachMcpStructuredContent(rendered.toolResult, rendered.result); - }) as never); + }, afterRender)) as never); break; } case 'resource': { @@ -271,7 +289,7 @@ export const registerGeneratedRoutes = ( route.name, uri, selectedConfig(route.config, ['_meta', 'description', 'icons', 'mimeType', 'title']) as never, - (async (resourceUri: URL, context: GeneratedRouteRequestContext) => { + (async (resourceUri: URL, context: GeneratedRouteRequestContext) => settled(async () => { const clientName = server.server.getClientVersion()?.name; return (await renderGeneratedRoute( dispatcher, @@ -281,7 +299,7 @@ export const registerGeneratedRoutes = ( context, { clientName }, )).result; - }) as never, + }, afterRender)) as never, ); break; } @@ -289,7 +307,7 @@ export const registerGeneratedRoutes = ( server.registerPrompt(route.name, { ...selectedConfig(route.config, ['_meta', 'description', 'icons', 'title']), argsSchema: route.module.inputSchema, - } as never, (async (input: unknown, context: GeneratedRouteRequestContext) => { + } as never, (async (input: unknown, context: GeneratedRouteRequestContext) => settled(async () => { const clientName = server.server.getClientVersion()?.name; return (await renderGeneratedRoute( dispatcher, @@ -299,7 +317,7 @@ export const registerGeneratedRoutes = ( context, { clientName }, )).result; - }) as never); + }, afterRender)) as never); break; default: { const unreachable: never = route.kind; @@ -482,6 +500,16 @@ export interface GeneratedEventRuntimeBinding { readonly target: string; } +/** + * The `mcp-resource-updated` delivery route for the notice inbox (#99 stage + * 4): the server process's own handle on the durable notice store its Flight + * worker mounts, wrapped by the runtime's inbox signaller. Present only when + * the artifact's state is workspace-durable — that is the only lifetime two + * processes can share — so `resources.subscribe` is advertised exactly when a + * subscription can be honoured. Closed when the server closes. + */ +export type GeneratedNoticeDeliveryBinding = AgentNoticeInboxSignaller; + export interface CreateGeneratedRouteMcpServerOptions { readonly apps?: readonly GeneratedMcpAppRecord[]; /** Identity every request carries, so a stale worker fails loudly. */ @@ -489,10 +517,91 @@ export interface CreateGeneratedRouteMcpServerOptions { readonly events?: GeneratedEventRuntimeBinding; /** Renders one invocation to Flight bytes. Closed when the server closes. */ readonly host: GeneratedRouteExecutionHost; + readonly notices?: GeneratedNoticeDeliveryBinding; readonly plugin: { readonly name: string; readonly version: string }; readonly routes: Readonly>; } +interface ResourceSubscriptionRequest { + readonly params: { readonly uri: string }; +} + +const noticeDiagnostic = (line: string): void => { + process.stderr.write(`[agent-bundle] notice inbox ${line}\n`); +}; + +const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** + * Installs `resources/subscribe` / `resources/unsubscribe` for the notice + * inbox URI and returns the post-render observation that emits + * `notifications/resources/updated` to the subscribed connection. Only the + * inbox is subscribable: every other generated resource is static per + * request, so accepting a subscription for it would be a promise the server + * never keeps. Subscribing fails closed when the durable store is unreadable. + */ +const installNoticeInboxSubscriptions = ( + server: McpServer, + notices: GeneratedNoticeDeliveryBinding, +): GeneratedRenderSettled => { + const protocol = server.server; + protocol.assertCanSetRequestHandler('resources/subscribe'); + protocol.assertCanSetRequestHandler('resources/unsubscribe'); + protocol.registerCapabilities({ resources: { subscribe: true } }); + const assertInboxUri = (uri: unknown): void => { + if (uri === notices.inboxUri) return; + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Resource ${String(uri)} does not support subscriptions; only ${notices.inboxUri} emits notifications/resources/updated.`, + { uri }, + ); + }; + protocol.setRequestHandler('resources/subscribe', (async ( + request: ResourceSubscriptionRequest, + context: GeneratedRouteRequestContext, + ) => { + assertInboxUri(request.params.uri); + const identity = requestIdentity(context, protocol.getClientVersion()?.name); + try { + await notices.subscribe({ + actor: identity.actor ?? unavailable(), + host: identity.host ?? unavailable(), + session: identity.session ?? unavailable(), + workspace: identity.workspace, + }); + } catch (error) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Notice inbox subscriptions are unavailable: ${describeError(error)}`, + ); + } + return {}; + }) as never); + protocol.setRequestHandler('resources/unsubscribe', ((request: ResourceSubscriptionRequest) => { + assertInboxUri(request.params.uri); + notices.unsubscribe(); + return {}; + }) as never); + const send = async (): Promise => { + await protocol.sendResourceUpdated({ uri: notices.inboxUri }); + }; + return async (): Promise => { + const outcome = await notices.observe(send); + switch (outcome.kind) { + case 'idle': + case 'signalled': + return; + case 'failed': + noticeDiagnostic(`resources/updated ${outcome.stage} failed: ${describeError(outcome.error)}`); + return; + default: { + const unreachable: never = outcome; + throw new TypeError(`Unhandled notice inbox signal outcome ${String(unreachable)}.`); + } + } + }; +}; + const nativeString = ( native: Readonly>, key: string, @@ -520,12 +629,13 @@ const startEventRuntime = async ( events: GeneratedEventRuntimeBinding, dispatcher: AgentRenderDispatcher, host: WarmFlightHost, + afterRender: GeneratedRenderSettled | undefined, ): Promise<{ readonly close: () => Promise }> => { const startedAt = new Date().toISOString(); return events.createEventRuntimeServer({ artifactEpoch: events.artifactEpoch, endpointId: events.endpointId, - handle: async (request, signal) => { + handle: async (request, signal) => settled(async () => { const event = canonicalEvent(request.event); const target = events.allowedTargets.find((candidate) => candidate === request.target); if (target === undefined) { @@ -578,7 +688,7 @@ const startEventRuntime = async ( nativeEvent, props.native, )); - }, + }, afterRender), status: () => ({ artifactEpoch: host.identity.artifactEpoch, availability: host.availability(), @@ -601,15 +711,25 @@ export const createGeneratedRouteMcpServer = async ( ): Promise => { const server = new McpServer(options.plugin); const dispatcher = createAgentRenderDispatcher(options.host); + // The subscribe bit registers before any transport connects; the SDK merges + // its own resources.listChanged into the same capability object when the + // inbox resource route registers below. + const afterRender = options.notices === undefined + ? undefined + : installNoticeInboxSubscriptions(server, options.notices); const events = options.events === undefined ? undefined - : await startEventRuntime(options.events, dispatcher, options.host); - registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch); + : await startEventRuntime(options.events, dispatcher, options.host, afterRender); + registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch, afterRender); registerGeneratedMcpApps(server, options.apps ?? []); const close = server.close.bind(server); server.close = async (): Promise => { await events?.close(); - await options.host.close(); + try { + await options.host.close(); + } finally { + await options.notices?.close(); + } await close(); }; return server; diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 508165018..72f0f0200 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -21,6 +21,7 @@ import type { AgentStateEventSchemas, } from '@agent-bundle/runtime/state'; import type { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount'; +import type { AgentNoticeInboxSignaller, AgentNoticePrincipal } from '@agent-bundle/runtime/notices'; import type { ReactNode } from 'react'; import { createProviderProcessLifetime } from '../routes/provider-execution.ts'; @@ -166,6 +167,7 @@ interface Renderer { readonly agent: typeof import('@agent-bundle/runtime').agent; readonly createElement: typeof import('react').createElement; readonly createGeneratedRuntimeState: typeof createGeneratedRuntimeState; + readonly createNoticeInboxSignaller: typeof import('@agent-bundle/runtime/notices').createNoticeInboxSignaller; readonly createWarmFlightHost: typeof import('@agent-bundle/runtime').createWarmFlightHost; readonly noticeInboxRoute: typeof import('@agent-bundle/runtime/notices/inbox-route'); readonly renderAgentFlight: typeof import('@agent-bundle/runtime/flight/server').renderAgentFlight; @@ -189,10 +191,11 @@ let dependenciesPromise: Promise | undefined; */ const loadDependencies = async (): Promise => { dependenciesPromise ??= (async () => { - const [serverRuntime, runtime, mount, noticeInboxRoute, flight, react, client] = await Promise.all([ + const [serverRuntime, runtime, mount, notices, noticeInboxRoute, flight, react, client] = await Promise.all([ import('../mcp-server-runtime.ts'), import('@agent-bundle/runtime'), import('@agent-bundle/runtime/mount'), + import('@agent-bundle/runtime/notices'), import('@agent-bundle/runtime/notices/inbox-route'), import('@agent-bundle/runtime/flight/server'), import('react'), @@ -205,6 +208,7 @@ const loadDependencies = async (): Promise => { createElement: react.createElement, createGeneratedRuntimeState: mount.createGeneratedRuntimeState, createGeneratedRouteMcpServer: serverRuntime.createGeneratedRouteMcpServer, + createNoticeInboxSignaller: notices.createNoticeInboxSignaller, createWarmFlightHost: runtime.createWarmFlightHost, noticeInboxRoute, renderAgentFlight: flight.renderAgentFlight, @@ -235,6 +239,25 @@ const drain = async (stream: ReadableStream): Promise } }; +const withContextIdentity = ( + signaller: AgentNoticeInboxSignaller, + context: RenderRouteContext, +): AgentNoticeInboxSignaller => Object.freeze({ + inboxUri: signaller.inboxUri, + get subscribed(): boolean { + return signaller.subscribed; + }, + close: () => signaller.close(), + observe: (send: () => Promise) => signaller.observe(send), + subscribe: (principal: AgentNoticePrincipal) => signaller.subscribe({ + actor: context.actor ?? principal.actor, + host: context.host ?? principal.host, + session: context.session ?? principal.session, + workspace: context.workspace ?? principal.workspace, + }), + unsubscribe: () => signaller.unsubscribe(), +}); + const streamOf = (chunks: readonly Uint8Array[]): ReadableStream => new ReadableStream({ start(controller) { @@ -398,9 +421,21 @@ export const openInMemoryMcpServer = async < ...(runtimeState === undefined ? {} : { runtimeState }), }); + // Mirrors the generated entry: only a workspace-durable store can be shared + // between the render side and the server process, so only that lifetime + // advertises `resources.subscribe` for the notice inbox. The warm host owns + // the state's lifetime, so the signaller's store handle does not close it, + // and the harness context seam overrides the subscriber's transport + // identity exactly as it overrides every render's. + const notices = runtimeState === undefined || options.state?.definition.lifetime !== 'workspace-durable' + ? undefined + : withContextIdentity(dependencies.createNoticeInboxSignaller({ + store: { close: async () => undefined, noticeLedger: () => runtimeState.noticeLedger() }, + }), context); const server = await dependencies.createGeneratedRouteMcpServer({ artifactEpoch, host, + ...(notices === undefined ? {} : { notices }), plugin: manifest.plugin, routes: routes as never, }); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 255151fd5..06180023d 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -957,6 +957,36 @@ it('conditionally emits generated state mounting without leaking sqlite into vol }); expect(volatileEntry).toContain('import * as noticeInboxRoute from "@agent-bundle/runtime/notices/inbox-route"'); expect(volatileEntry).toContain('noticeInboxRoute.noticeInboxRouteRecord(noticeInboxRoute)'); + // Volatile stores live in the worker's heap: the server process has no + // handle on them, so it must not advertise inbox subscriptions. + for (const generated of [statelessEntry, volatileEntry]) { + for (const identifier of [ + 'createGeneratedNoticeRuntime', + 'createNoticeInboxSignaller', + '@agent-bundle/runtime/notices\'', + '@agent-bundle/runtime/state/sqlite', + 'notices: noticeDelivery', + ]) { + expect(generated).not.toContain(identifier); + } + } + + const durableEntry = entryShellModule.generatedRouteMcpEntrySource({ + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [route], + serverName: 'curator', + state: state('workspace-durable'), + workerFile: 'mcp-curator-flight.mjs', + }); + expect(durableEntry).toContain("import { createGeneratedNoticeRuntime } from '@agent-bundle/runtime/mount';"); + expect(durableEntry).toContain("import { createNoticeInboxSignaller } from '@agent-bundle/runtime/notices';"); + expect(durableEntry).toContain("import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';"); + expect(durableEntry).toContain("const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));"); + expect(durableEntry).toContain("createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable' })"); + expect(durableEntry).toContain(' notices: noticeDelivery,'); + // The server process never evaluates the project's own state definition. + expect(durableEntry).not.toContain('import stateDefinition from'); + expect(durableEntry).not.toContain('createGeneratedRuntimeState'); const durable = entryShellModule.generatedRouteFlightWorkerSource({ ...base, @@ -1005,6 +1035,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol volatile, statelessEntry, volatileEntry, + durableEntry, durable, renderedWorker, statelessCli, diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 61c77cf78..0ad8be2d7 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -560,6 +560,97 @@ it('observes one process-lifetime provider across consecutive generated tool cal } }); +it('emits notifications/resources/updated for the durable notice inbox to the subscribed stdio client', { retry: 2, timeout: 90_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-inbox-updated-')); + roots.push(root); + await writeGeneratedProject(root, { + 'src/state.ts': [ + "import { defineState } from '@agent-bundle/runtime/state';", + "import { z } from 'zod';", + 'export default defineState({', + " events: { changed: z.object({ value: z.string() }).strict() },", + " id: 'generated-routes/durable-state',", + ' initial: { value: "" },', + " lifetime: 'workspace-durable',", + ' reduce: (_state, event) => ({ value: event.payload.value }),', + ' schema: z.object({ value: z.string() }).strict(),', + '});', + '', + ].join('\n'), + 'src/mcp/curator/tools/notify.tsx': [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "export const config = { description: 'Publish a notice to a host-scoped recipient.' };", + 'export const inputSchema = z.object({ host: z.string(), message: z.string() }).strict();', + "export const resultSchema = z.object({ noticeId: z.string(), state: z.literal('pending') }).strict();", + 'export default async function Notify({ input }) {', + ' const context = await agent();', + " if (context.notices === undefined) throw new TypeError('notices unavailable');", + ' const published = await context.notices.publish({', + " content: { root: { kind: 'text', text: input.message }, status: 'success', version: 1 },", + " priority: 'normal',", + ' recipient: { host: { name: input.host } },', + ' }, { idempotencyKey: `notice:${input.host}:${input.message}` });', + ' const value = { noticeId: published.notice.id, state: published.notice.state };', + " return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `published ${value.noticeId}`));", + '}', + '', + ].join('\n'), + }); + const inboxUri = 'agent-bundle://notices/inbox'; + const session = await connectGeneratedServer(root); + const updates: string[] = []; + session.client.setNotificationHandler('notifications/resources/updated', (notification) => { + updates.push(notification.params.uri); + }); + const settle = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }; + const readInbox = async (): Promise>[]> => { + const read = await session.client.readResource({ uri: inboxUri }); + const content = read.contents[0]; + if (content === undefined || !('text' in content)) throw new TypeError('Expected text inbox content'); + return (JSON.parse(content.text) as { notices: readonly Readonly>[] }).notices; + }; + try { + // The packed server process holds its own SQLite handle on the store the + // Flight worker mounts, so it can honestly advertise inbox subscriptions. + expect(session.client.getServerCapabilities()?.resources).toMatchObject({ subscribe: true }); + await expect(session.client.subscribeResource({ uri: 'ui://curator/missing.html' })).rejects.toThrow(/does not support subscriptions/u); + await session.client.subscribeResource({ uri: inboxUri }); + + // stdio identity is transport-only: the client name is the one observed + // axis, so a host-scoped notice reaches this connection and an + // actor-scoped one cannot. + await session.client.callTool({ arguments: { host: 'someone-else', message: 'not for you' }, name: 'notify' }, { signal: AbortSignal.timeout(10_000) }); + await settle(); + expect(updates).toEqual([]); + await session.client.callTool({ arguments: { host: 'generated-route-test', message: 'for this client' }, name: 'notify' }, { signal: AbortSignal.timeout(10_000) }); + await settle(); + expect(updates).toEqual([inboxUri]); + + const inbox = await readInbox(); + expect(inbox).toEqual([expect.objectContaining({ + availability: expect.objectContaining({ channel: 'mcp-resource-updated', count: 1 }), + content: { root: { kind: 'text', text: 'for this client' }, status: 'success', version: 1 }, + exposure: expect.objectContaining({ channel: 'mcp-inbox', count: 1 }), + state: 'pending', + })]); + // The re-read advanced the ledger without producing a further signal. + await settle(); + expect(updates).toEqual([inboxUri]); + + await session.client.unsubscribeResource({ uri: inboxUri }); + await session.client.callTool({ arguments: { host: 'generated-route-test', message: 'after unsubscribe' }, name: 'notify' }, { signal: AbortSignal.timeout(10_000) }); + await settle(); + expect(updates).toEqual([inboxUri]); + expect(await readInbox()).toHaveLength(2); + } finally { + await session.close(); + } +}); + it('emits MCP progress notifications only when a progress token is supplied', { retry: 2, timeout: 60_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-progress-')); roots.push(root); diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index c0069af58..21705d6e9 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -365,6 +365,106 @@ describe('the in-memory MCP projection level', () => { }; void typedTaskSurfaceSentinel; + it('emits notifications/resources/updated for the notice inbox only to subscribed matching sessions', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-inbox-updated-')); + const inboxUri = 'agent-bundle://notices/inbox'; + const sessionIdentity = (sessionId: string) => ({ + source: 'native' as const, + state: 'available' as const, + value: { sessionId }, + }); + const durable = (sessionId: string) => openInMemoryMcpServer({ + context: { session: sessionIdentity(sessionId) }, + state: { definition: stateDefinition, driver: createSqliteStateDriver({ root }) }, + }); + const settle = async (): Promise => { + // Notifications ride the transport ahead of the request result; one turn + // of the event loop lets the linked in-memory pair deliver them. + await new Promise((resolve) => setTimeout(resolve, 20)); + }; + const readInbox = async (client: (typeof subscribed)['client']) => { + const read = await client.readResource({ uri: inboxUri }); + const content = read.contents[0]; + if (content === undefined || !('text' in content)) throw new TypeError('Expected text inbox content'); + return (JSON.parse(content.text) as { notices: readonly Readonly>[] }).notices; + }; + const subscribed = await durable('s1'); + const bystander = await durable('s2'); + const updates = { s1: [] as string[], s2: [] as string[] }; + subscribed.client.setNotificationHandler('notifications/resources/updated', (notification) => { + updates.s1.push(notification.params.uri); + }); + bystander.client.setNotificationHandler('notifications/resources/updated', (notification) => { + updates.s2.push(notification.params.uri); + }); + try { + expect(subscribed.client.getServerCapabilities()?.resources).toMatchObject({ subscribe: true }); + // Only the inbox is subscribable: static resources never change per session. + await expect(subscribed.client.subscribeResource({ uri: 'harness://notes' })).rejects.toThrow(/does not support subscriptions/u); + await subscribed.client.subscribeResource({ uri: inboxUri }); + + // s1 publishes to itself: the subscribed session gets exactly one signal. + await subscribed.client.callTool({ arguments: { message: 'for s1', recipientSession: 's1' }, name: 'publish-notice' }); + await settle(); + expect(updates).toEqual({ s1: [inboxUri], s2: [] }); + + // Availability is a receipt on the pending notice, not a state change; + // the client's re-read records exposure and triggers no further signal. + const inbox = await readInbox(subscribed.client); + expect(inbox).toEqual([expect.objectContaining({ + availability: expect.objectContaining({ channel: 'mcp-resource-updated', count: 1 }), + exposure: expect.objectContaining({ channel: 'mcp-inbox', count: 1 }), + state: 'pending', + })]); + await subscribed.client.callTool({ arguments: { message: 'unrelated render' }, name: 'echo' }); + await settle(); + expect(updates).toEqual({ s1: [inboxUri], s2: [] }); + + // An unsubscribed session is never signalled, even for its own notice; + // the subscribed session is not signalled for a notice it cannot read. + await bystander.client.callTool({ arguments: { message: 'for s2', recipientSession: 's2' }, name: 'publish-notice' }); + await subscribed.client.callTool({ arguments: { message: 'observe' }, name: 'echo' }); + await settle(); + expect(updates).toEqual({ s1: [inboxUri], s2: [] }); + const bystanderInbox = await readInbox(bystander.client); + expect(bystanderInbox).toEqual([expect.objectContaining({ state: 'pending' })]); + expect(bystanderInbox[0]).not.toHaveProperty('availability'); + + // Unsubscribing stops delivery; the notice stays pending for the inbox route. + await subscribed.client.unsubscribeResource({ uri: inboxUri }); + await subscribed.client.callTool({ arguments: { message: 'for s1 again', recipientSession: 's1' }, name: 'publish-notice' }); + await settle(); + expect(updates).toEqual({ s1: [inboxUri], s2: [] }); + expect(await readInbox(subscribed.client)).toHaveLength(2); + } finally { + await subscribed.close(); + await bystander.close(); + } + + // Volatile state lives in the render side's heap, so the server honestly + // advertises no subscription capability and registers no subscribe handler. + const volatile = await openInMemoryMcpServer({ + state: { + definition: defineState({ + events: { changed: z.object({ value: z.string() }).strict() }, + id: 'mcp-in-memory/volatile', + initial: { value: '' }, + lifetime: 'process', + reduce: (_state, event) => ({ value: event.payload.value }), + schema: z.object({ value: z.string() }).strict(), + }), + driver: createMemoryStateDriver({ lifetime: 'process' }), + }, + }); + try { + expect(volatile.client.getServerCapabilities()?.resources?.subscribe).toBeUndefined(); + await expect(volatile.client.subscribeResource({ uri: inboxUri })).rejects.toThrow(/Method not found/u); + } finally { + await volatile.close(); + await rm(root, { force: true, recursive: true }); + } + }); + it('leaves the browser App surface off the in-memory server', async () => { const surface = await listMcpSurface(); diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index ad1d1a42b..a0ba31244 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -257,6 +257,22 @@ expires instead of retaining unused attempts. Wire-level `signalAvailability()` as availability receipts, and MCP inbox reads record exposure receipts; neither is a delivery claim. +`createNoticeInboxSignaller({ store })` is the `mcp-resource-updated` route +itself: one long-lived MCP connection's subscription to the reserved inbox +resource (`AGENT_NOTICE_INBOX_URI`). The generated server process opens its +own handle on the workspace-durable store its Flight worker mounts +(`createGeneratedNoticeRuntime` from `@agent-bundle/runtime/mount`), and +after every completed render `observe(send)` reads the ledger, sends at most +one `notifications/resources/updated` for the subscriber's newly eligible +pending notices, and records the availability receipt. Eligibility is +recipient-matched against the subscriber's observed identity, respects +`nextAttemptAt`, and is bounded by `retryBudget` (availability signals per +notice, durable across restarts); exposure and availability receipts never +re-trigger a signal, so a subscribed client cannot be driven into a refetch +loop. Subscribing fails closed when the store is unreadable, and only the +workspace-durable lifetime is wired — volatile stores live in the worker's +heap, so those servers honestly advertise no `resources.subscribe`. + 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 diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts index d662b83cc..6862fa79c 100644 --- a/packages/rsc-runtime/src/mount/index.ts +++ b/packages/rsc-runtime/src/mount/index.ts @@ -10,6 +10,7 @@ import { type AgentStateDriver, type AgentStateEventSchemas, type AgentStateHandle, + type AgentStateLifetime, type AgentStateStore, } from '../state/index.js'; @@ -31,21 +32,35 @@ export interface GeneratedRuntimeRequestBindings< close(): Promise; } +/** + * A process-lifetime handle on the notice ledger, outside any request scope. + * The generated MCP server process uses it to observe the ledger its render + * worker mounts (for `resources/updated` delivery); request-lifetime notices + * have no cross-request ledger, so the handle fails typed for that lifetime. + */ +export interface GeneratedNoticeRuntime { + close(): Promise; + noticeLedger(): Promise; +} + export interface GeneratedRuntimeState< TState, TEvents extends AgentStateEventSchemas, -> { - close(): Promise; +> extends GeneratedNoticeRuntime { requestBindings( options?: { readonly signal?: AbortSignal }, ): Promise>; } +export interface CreateGeneratedNoticeRuntimeOptions { + readonly driver: AgentStateDriver; + readonly lifetime: AgentStateLifetime; +} + type OpenResult = | { readonly kind: 'opened'; readonly value: T } | { readonly error: AgentStateError; readonly kind: 'failed' }; -type NoticeStore = Parameters[0]; type ClosableStore = { close(): Promise }; const asStateError = (error: unknown, definitionId: string): AgentStateError => @@ -83,29 +98,39 @@ const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { }; /** - * Owns the state kernel and notice ledger used by generated request scopes. - * * The v1 authorizer admits every publish and delivery request. Recipient * matching remains enforced by the ledger itself; application-specific * authorization is future embedder policy. */ -export const createGeneratedRuntimeState = < - TState, - TEvents extends AgentStateEventSchemas, ->( - options: CreateGeneratedRuntimeStateOptions, -): GeneratedRuntimeState => { - const { definition, driver } = options; - const noticeDefinition = agentNoticeStateDefinition(definition.lifetime); - const shared = definition.lifetime !== 'request'; +const generatedNoticeAuthorizer = { authorize: () => ({ state: 'authorized' as const }) }; + +type NoticeStore = Parameters[0]; + +const ledgerFrom = (result: OpenResult): AgentNoticeLedger => result.kind === 'opened' + ? createAgentNoticeLedger(result.value, generatedNoticeAuthorizer) + : failedLedger(result.error); + +interface StoreSlot { + open(): Promise>>; + readonly pending: Promise>> | undefined; +} + +/** + * Owns one driver, the stores lazily opened from it, and their ordered + * teardown. Each slot owns its cached failure and, when its lifetime is + * shared, its single open; request-lifetime slots open per call and hand the + * store back to the caller to release. + */ +const createStoreOwner = (driver: AgentStateDriver) => { const liveStores = new Set(); + const slots: { readonly pending: Promise | undefined }[] = []; let closing: Promise | undefined; let closed = false; - /** One lazily-opened store slot owning its cached failure and, when shared, its single open. */ const createSlot = ( slotDefinition: AgentStateDefinition, - ) => { + ): StoreSlot => { + const shared = slotDefinition.lifetime !== 'request'; let failure: AgentStateError | undefined; let pending: Promise>> | undefined; @@ -136,7 +161,7 @@ export const createGeneratedRuntimeState = < } }; - return { + const slot: StoreSlot = { open(): Promise>> { if (!shared) return openOnce(); pending ??= openOnce(); @@ -146,37 +171,63 @@ export const createGeneratedRuntimeState = < return pending; }, }; + slots.push(slot); + return slot; }; - const projectSlot = createSlot(definition); - const noticeSlot = createSlot(noticeDefinition); - const closeStore = async (store: ClosableStore): Promise => { if (!liveStores.delete(store)) return; await store.close(); }; + const close = (): Promise => { + if (closing !== undefined) return closing; + closed = true; + closing = (async () => { + await Promise.allSettled(slots.flatMap((slot) => (slot.pending === undefined ? [] : [slot.pending]))); + const storeClosures = await Promise.allSettled([...liveStores].map((store) => closeStore(store))); + let driverFailure: unknown; + try { + await driver.close(); + } catch (error) { + driverFailure = error; + } + const storeFailure = storeClosures.find((result) => result.status === 'rejected'); + if (storeFailure?.status === 'rejected') throw storeFailure.reason; + if (driverFailure !== undefined) throw driverFailure; + })(); + return closing; + }; + + return { close, closeStore, createSlot }; +}; + +const requestLifetimeLedger = (): AgentNoticeLedger => failedLedger(new AgentStateError( + 'lifetime-mismatch', + 'Request-lifetime notices have no ledger outside a request scope', +)); + +/** + * Owns the state kernel and notice ledger used by generated request scopes. + */ +export const createGeneratedRuntimeState = < + TState, + TEvents extends AgentStateEventSchemas, +>( + options: CreateGeneratedRuntimeStateOptions, +): GeneratedRuntimeState => { + const { definition, driver } = options; + const owner = createStoreOwner(driver); + const shared = definition.lifetime !== 'request'; + const projectSlot = owner.createSlot(definition); + const noticeSlot = owner.createSlot(agentNoticeStateDefinition(definition.lifetime)); + return Object.freeze({ - close(): Promise { - if (closing !== undefined) return closing; - closed = true; - closing = (async () => { - await Promise.allSettled([ - ...(projectSlot.pending === undefined ? [] : [projectSlot.pending]), - ...(noticeSlot.pending === undefined ? [] : [noticeSlot.pending]), - ]); - const storeClosures = await Promise.allSettled([...liveStores].map((store) => closeStore(store))); - let driverFailure: unknown; - try { - await driver.close(); - } catch (error) { - driverFailure = error; - } - const storeFailure = storeClosures.find((result) => result.status === 'rejected'); - if (storeFailure?.status === 'rejected') throw storeFailure.reason; - if (driverFailure !== undefined) throw driverFailure; - })(); - return closing; + close: owner.close, + + async noticeLedger(): Promise { + if (!shared) return requestLifetimeLedger(); + return ledgerFrom(await noticeSlot.open()); }, async requestBindings( @@ -190,11 +241,7 @@ export const createGeneratedRuntimeState = < const state = project.kind === 'opened' ? createAgentStateHandle(project.value, bindingOptions) : failedHandle(definition.lifetime, project.error); - const noticeLedger = notices.kind === 'opened' - ? createAgentNoticeLedger(notices.value, { - authorize: () => ({ state: 'authorized' }), - }) - : failedLedger(notices.error); + const noticeLedger = ledgerFrom(notices); let released = false; return Object.freeze({ noticeLedger, @@ -202,9 +249,30 @@ export const createGeneratedRuntimeState = < async close() { if (released) return; released = true; - for (const store of requestStores) await closeStore(store); + for (const store of requestStores) await owner.closeStore(store); }, }); }, }); }; + +/** + * Owns only the notice ledger over a driver: the handle a generated MCP + * server process holds on the durable store its Flight worker mounts, so it + * can observe ledger revisions and emit `resources/updated` to subscribed + * connections without evaluating the project's state definition twice. + */ +export const createGeneratedNoticeRuntime = ( + options: CreateGeneratedNoticeRuntimeOptions, +): GeneratedNoticeRuntime => { + const owner = createStoreOwner(options.driver); + const shared = options.lifetime !== 'request'; + const noticeSlot = owner.createSlot(agentNoticeStateDefinition(options.lifetime)); + return Object.freeze({ + close: owner.close, + async noticeLedger(): Promise { + if (!shared) return requestLifetimeLedger(); + return ledgerFrom(await noticeSlot.open()); + }, + }); +}; diff --git a/packages/rsc-runtime/src/notices/inbox-route.ts b/packages/rsc-runtime/src/notices/inbox-route.ts index b3e2df136..7c74be945 100644 --- a/packages/rsc-runtime/src/notices/inbox-route.ts +++ b/packages/rsc-runtime/src/notices/inbox-route.ts @@ -2,11 +2,11 @@ import { createElement } from 'react'; import { z } from 'zod'; import { Agent, agent, type JsonValue } from '../index.js'; -import { AgentNoticeError, type AgentNotice } from './index.js'; +import { AGENT_NOTICE_INBOX_URI, AgentNoticeError, type AgentNotice } from './index.js'; +export { AGENT_NOTICE_INBOX_URI }; export const AGENT_NOTICE_INBOX_ROUTE_ID = 'agent-bundle:notice-inbox'; export const AGENT_NOTICE_INBOX_ROUTE_NAME = 'notice-inbox'; -export const AGENT_NOTICE_INBOX_URI = 'agent-bundle://notices/inbox'; export const config = Object.freeze({ description: 'Read recipient-scoped pending notices without acknowledging them or marking delivery attempted.', @@ -25,6 +25,9 @@ export const resultSchema = z.object({ }).strict(); const projectNotice = (notice: AgentNotice) => Object.freeze({ + // Receipts, not state claims: `availability` counts resources/updated + // signals sent for this notice; `exposure` counts inbox reads that served it. + availability: notice.availability, content: notice.content, createdAt: notice.createdAt, ...(notice.expiresAt === undefined ? {} : { expiresAt: notice.expiresAt }), diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index 19112e18d..eceb7a7f6 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -54,6 +54,16 @@ export { export type { CreateAgentNoticeLedgerOptions, } from './ledger.js'; +export { + AGENT_NOTICE_INBOX_URI, + createNoticeInboxSignaller, +} from './resource-updated.js'; +export type { + AgentNoticeInboxSignaller, + AgentNoticeInboxSignalOutcome, + AgentNoticeInboxStore, + CreateNoticeInboxSignallerOptions, +} from './resource-updated.js'; export { agentNoticeEventSchemas, agentNoticeStateDefinition, diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts new file mode 100644 index 000000000..dc4c4bdd1 --- /dev/null +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto'; + +import type { + AgentNotice, + AgentNoticeLedger, + AgentNoticePrincipal, +} from './contract.js'; +import { recipientMatchesPrincipal } from './state.js'; + +/** The reserved inbox resource URI every generated stateful MCP server registers. */ +export const AGENT_NOTICE_INBOX_URI = 'agent-bundle://notices/inbox'; + +/** The store a long-lived MCP server process opens to observe the ledger it shares with its render worker. */ +export interface AgentNoticeInboxStore { + close(): Promise; + noticeLedger(): Promise; +} + +export interface CreateNoticeInboxSignallerOptions { + /** Clock injection for deterministic tests. */ + readonly now?: () => Date; + readonly store: AgentNoticeInboxStore; +} + +export type AgentNoticeInboxSignalOutcome = + | { + readonly kind: 'idle'; + readonly reason: 'no-subscription' | 'nothing-eligible'; + readonly revision: number | undefined; + } + | { readonly kind: 'signalled'; readonly noticeIds: readonly string[]; readonly revision: number } + | { readonly error: unknown; readonly kind: 'failed'; readonly stage: 'read' | 'record' | 'send' }; + +/** + * One MCP connection's subscription to the notice inbox resource, and the + * `mcp-resource-updated` delivery route over it (#99 stage 4). + * + * The route is honest by construction: a `notifications/resources/updated` + * signal asks the subscribed client to re-read the inbox, so the ledger + * records it as an availability receipt on the notices the signal was about + * and never as delivery. Signals are evaluated only when the owning server + * finishes a render — an already-connected surface, never an implied timer — + * and every send is bounded: a notice is signalled at most once per + * subscription and at most `retryBudget` times durably, `nextAttemptAt` + * defers it, and expiry, withdrawal, attempts, and acknowledgement remove it. + * Re-reads of the inbox (exposure receipts) and the availability receipts + * themselves advance the ledger without producing a further signal, so a + * subscribed client can never be driven into a refetch loop. + */ +export interface AgentNoticeInboxSignaller { + readonly inboxUri: typeof AGENT_NOTICE_INBOX_URI; + readonly subscribed: boolean; + close(): Promise; + /** + * Runs after one completed render: reads the ledger and, when the + * subscriber has newly eligible pending notices, sends exactly one + * `resources/updated` through `send` and records the availability receipt. + * Never throws; failures are returned so the render path stays unaffected. + */ + observe(send: () => Promise): Promise; + /** + * Records the connection as the inbox subscriber for `principal`. Fails + * closed: the durable store must be readable before a subscription exists, + * so an unavailable store yields a rejected subscribe instead of a + * subscription that could never be honoured. + */ + subscribe(principal: AgentNoticePrincipal): Promise; + unsubscribe(): void; +} + +interface InboxSubscription { + readonly id: string; + readonly principal: AgentNoticePrincipal; + readonly signalled: Set; +} + +const eligibleForSignal = ( + notice: AgentNotice, + principal: AgentNoticePrincipal, + nowMs: number, +): boolean => { + switch (notice.state) { + case 'pending': + break; + case 'attempted': + case 'expired': + case 'unavailable': + case 'withdrawn': + case 'acknowledged': + return false; + default: { + const exhaustive: never = notice.state; + return exhaustive; + } + } + if (Date.parse(notice.createdAt) > nowMs) return false; + if (notice.expiresAt !== undefined && Date.parse(notice.expiresAt) <= nowMs) return false; + // Not due yet: evaluated only on completed renders; V1 never implies a timer. + if (notice.nextAttemptAt !== undefined && Date.parse(notice.nextAttemptAt) > nowMs) return false; + if ((notice.availability?.count ?? 0) >= (notice.retryBudget ?? 1)) return false; + return recipientMatchesPrincipal(notice.recipient, principal); +}; + +export const createNoticeInboxSignaller = ( + options: CreateNoticeInboxSignallerOptions, +): AgentNoticeInboxSignaller => { + const now = options.now ?? ((): Date => new Date()); + let subscription: InboxSubscription | undefined; + let signalSequence = 0; + // Observations serialize so two renders completing together cannot both + // select the same notice and send two signals for one revision. + let queue: Promise = Promise.resolve(); + + const observeOnce = async (send: () => Promise): Promise => { + const current = subscription; + if (current === undefined) { + return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: undefined }); + } + let ledger: AgentNoticeLedger; + let snapshot: Awaited>; + try { + ledger = await options.store.noticeLedger(); + snapshot = await ledger.read(); + } catch (error) { + return Object.freeze({ error, kind: 'failed' as const, stage: 'read' as const }); + } + const at = now().toISOString(); + const nowMs = Date.parse(at); + const noticeIds = Object.freeze(snapshot.notices + .filter((notice) => !current.signalled.has(notice.id) && eligibleForSignal(notice, current.principal, nowMs)) + .map((notice) => notice.id) + .toSorted((left, right) => left.localeCompare(right))); + if (noticeIds.length === 0) { + return Object.freeze({ kind: 'idle', reason: 'nothing-eligible', revision: snapshot.revision }); + } + // The subscription may have been replaced while the ledger was read; a + // signal for a stale subscriber must not be sent or recorded. + if (subscription !== current) { + return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: snapshot.revision }); + } + try { + await send(); + } catch (error) { + return Object.freeze({ error, kind: 'failed' as const, stage: 'send' as const }); + } + for (const id of noticeIds) current.signalled.add(id); + signalSequence += 1; + try { + const committed = await ledger.signalAvailability({ + at, + idempotencyKey: `agent-notices:availability:${current.id}:${String(signalSequence)}`, + noticeIds, + }); + return Object.freeze({ kind: 'signalled', noticeIds, revision: committed.revision }); + } catch (error) { + return Object.freeze({ error, kind: 'failed' as const, stage: 'record' as const }); + } + }; + + return Object.freeze({ + inboxUri: AGENT_NOTICE_INBOX_URI, + get subscribed(): boolean { + return subscription !== undefined; + }, + close(): Promise { + subscription = undefined; + return options.store.close(); + }, + observe(send: () => Promise): Promise { + const run = queue.then(() => observeOnce(send)); + queue = run.catch(() => undefined); + return run; + }, + async subscribe(principal: AgentNoticePrincipal): Promise { + const ledger = await options.store.noticeLedger(); + await ledger.read(); + subscription = Object.freeze({ + id: randomUUID(), + principal, + signalled: new Set(), + }); + }, + unsubscribe(): void { + subscription = undefined; + }, + }); +}; diff --git a/packages/rsc-runtime/tests/mount.test.ts b/packages/rsc-runtime/tests/mount.test.ts index c6d248e48..b774fe325 100644 --- a/packages/rsc-runtime/tests/mount.test.ts +++ b/packages/rsc-runtime/tests/mount.test.ts @@ -7,8 +7,9 @@ import { defineState, type AgentStateDriver, } from '../src/state/index.js'; -import { createGeneratedRuntimeState } from '../src/mount/index.js'; -import { agent, runAgentRequest } from '../src/index.js'; +import { createGeneratedNoticeRuntime, createGeneratedRuntimeState } from '../src/mount/index.js'; +import { agentNoticeStateDefinition } from '../src/notices/index.js'; +import { agent, available, runAgentRequest } from '../src/index.js'; const definition = (lifetime: 'process' | 'request' = 'process') => defineState({ events: { incremented: z.object({ by: z.number() }).strict() }, @@ -126,4 +127,119 @@ describe('createGeneratedRuntimeState', () => { 'driver', ]); }); + + it('hands out a process-lifetime notice ledger over the same store request scopes mount', async () => { + const runtimeState = createGeneratedRuntimeState({ + definition: definition(), + driver: createMemoryStateDriver({ lifetime: 'process' }), + }); + const bindings = await runtimeState.requestBindings(); + await runAgentRequest({ + actor: available({ id: 'publisher' }, 'native'), + invocation: { id: 'publish-1', kind: 'tool', startedAt: '2026-09-02T10:00:00.000Z' }, + noticeLedger: bindings.noticeLedger, + }, async () => (await agent()).notices!.publish({ + content: { root: { kind: 'text', text: 'hello' }, status: 'success', version: 1 }, + priority: 'normal', + recipient: { session: { sessionId: 's1' } }, + }, { idempotencyKey: 'publish:1' })); + + const ledger = await runtimeState.noticeLedger(); + await expect(ledger.read()).resolves.toMatchObject({ notices: [expect.objectContaining({ state: 'pending' })], revision: 1 }); + await runtimeState.close(); + }); + + it('fails the process-lifetime ledger typed for request-lifetime state', async () => { + const runtimeState = createGeneratedRuntimeState({ + definition: definition('request'), + driver: createMemoryStateDriver({ lifetime: 'request' }), + }); + const ledger = await runtimeState.noticeLedger(); + await expect(ledger.read()).rejects.toMatchObject({ code: 'lifetime-mismatch' }); + await runtimeState.close(); + }); +}); + +describe('createGeneratedNoticeRuntime', () => { + it('opens only the notice store, once, and closes it before the driver', async () => { + const order: string[] = []; + const inner = createMemoryStateDriver({ lifetime: 'process' }); + const driver: AgentStateDriver = { + ...inner, + close: async () => { + order.push('driver'); + await inner.close(); + }, + open: async (stateDefinition) => { + order.push(`open:${stateDefinition.id}`); + const store = await inner.open(stateDefinition); + return { + ...store, + close: async () => { + order.push(`close:${stateDefinition.id}`); + await store.close(); + }, + }; + }, + }; + const runtime = createGeneratedNoticeRuntime({ driver, lifetime: 'process' }); + expect(order).toEqual([]); + const first = await runtime.noticeLedger(); + const second = await runtime.noticeLedger(); + await expect(first.read()).resolves.toMatchObject({ notices: [], revision: 0 }); + await expect(second.read()).resolves.toMatchObject({ notices: [], revision: 0 }); + await runtime.close(); + expect(order).toEqual([ + `open:${agentNoticeStateDefinition('process').id}`, + `close:${agentNoticeStateDefinition('process').id}`, + 'driver', + ]); + }); + + it('shares one durable-style store between a request-scope owner and the server-side runtime', async () => { + // The memory driver stands in for SQLite here: two owners over one driver + // instance model the worker and the server process opening the same files. + const shared = createMemoryStateDriver({ lifetime: 'process' }); + const driver: AgentStateDriver = { ...shared, close: async () => undefined }; + const worker = createGeneratedRuntimeState({ definition: definition(), driver }); + const server = createGeneratedNoticeRuntime({ driver, lifetime: 'process' }); + const bindings = await worker.requestBindings(); + await runAgentRequest({ + actor: available({ id: 'publisher' }, 'native'), + invocation: { id: 'publish-1', kind: 'tool', startedAt: '2026-09-02T10:00:00.000Z' }, + noticeLedger: bindings.noticeLedger, + }, async () => (await agent()).notices!.publish({ + content: { root: { kind: 'text', text: 'hello' }, status: 'success', version: 1 }, + priority: 'normal', + recipient: { session: { sessionId: 's1' } }, + }, { idempotencyKey: 'publish:1' })); + + const ledger = await server.noticeLedger(); + await expect(ledger.read()).resolves.toMatchObject({ revision: 1 }); + await server.close(); + await worker.close(); + await shared.close(); + }); + + it('keeps the ledger present but typed-failing when the driver cannot open', async () => { + const failure = new AgentStateError('unavailable', 'storage is offline'); + let closes = 0; + const driver: AgentStateDriver = { + durable: false, + kind: 'unavailable-test', + lifetime: 'process', + close: async () => { + closes += 1; + }, + open: async () => { + throw failure; + }, + }; + const runtime = createGeneratedNoticeRuntime({ driver, lifetime: 'process' }); + const ledger = await runtime.noticeLedger(); + await expect(ledger.read()).rejects.toBe(failure); + await expect(ledger.signalAvailability({ at: '2026-09-02T10:00:00.000Z', idempotencyKey: 'a', noticeIds: ['x'] })).rejects.toBe(failure); + await runtime.close(); + expect(closes).toBe(1); + }); }); diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts new file mode 100644 index 000000000..6563876d3 --- /dev/null +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it } from '@rstest/core'; + +import { + AGENT_NOTICE_INBOX_URI, + agentNoticeStateDefinition, + createAgentNoticeLedger, + createNoticeInboxSignaller, + type AgentNoticeLedger, + type AgentNoticePrincipal, + type AgentNoticePublishInput, +} from '../src/notices/index.js'; +import { agent, available, runAgentRequest, unavailable } from '../src/index.js'; +import { AgentStateError, createMemoryStateDriver } from '../src/state/index.js'; + +const document = (text: string) => ({ + root: { kind: 'text' as const, text }, + status: 'success' as const, + version: 1 as const, +}); + +const T0 = '2026-09-02T10:00:00.000Z'; +const T1 = '2026-09-02T10:01:00.000Z'; +const T2 = '2026-09-02T10:02:00.000Z'; + +const principal = (sessionId: string): AgentNoticePrincipal => Object.freeze({ + actor: unavailable(), + host: available({ name: 'claude' }, 'native'), + session: available({ sessionId }, 'native'), + workspace: available({ root: '/workspace' }, 'native'), +}); + +const openLedger = async () => { + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentNoticeStateDefinition('process')); + const ledger = createAgentNoticeLedger(store, { authorize: () => ({ state: 'authorized' }) }); + return { driver, ledger, store }; +}; + +let publishes = 0; + +const publish = async ( + ledger: AgentNoticeLedger, + input: Partial & { readonly sessionId: string }, + startedAt = T0, +) => { + publishes += 1; + const { sessionId, ...rest } = input; + return runAgentRequest({ + actor: available({ id: 'publisher' }, 'native'), + invocation: { id: `publish-${String(publishes)}`, kind: 'tool', startedAt }, + noticeLedger: ledger, + workspace: available({ root: '/workspace' }, 'native'), + }, async () => (await agent()).notices!.publish({ + content: document(`notice ${String(publishes)}`), + priority: 'normal', + recipient: { session: { sessionId } }, + ...rest, + }, { idempotencyKey: `publish:${String(publishes)}` })); +}; + +const readInbox = async (ledger: AgentNoticeLedger, sessionId: string, startedAt: string) => runAgentRequest({ + host: available({ name: 'claude' }, 'native'), + invocation: { id: `inbox-${sessionId}-${startedAt}`, kind: 'tool', startedAt }, + noticeLedger: ledger, + session: available({ sessionId }, 'native'), + workspace: available({ root: '/workspace' }, 'native'), +}, async () => (await agent()).notices!.inbox()); + +const sender = () => { + const sends: string[] = []; + return { + send: async (): Promise => { + sends.push(AGENT_NOTICE_INBOX_URI); + }, + sends, + }; +}; + +const signallerOver = (ledger: AgentNoticeLedger, now: () => Date = () => new Date(T1)) => + createNoticeInboxSignaller({ + now, + store: { close: async () => undefined, noticeLedger: async () => ledger }, + }); + +describe('notice inbox resources/updated signaller', () => { + it('exposes the reserved inbox URI and starts unsubscribed', async () => { + const { driver, ledger } = await openLedger(); + const signaller = signallerOver(ledger); + const { send, sends } = sender(); + + expect(signaller.inboxUri).toBe('agent-bundle://notices/inbox'); + expect(signaller.subscribed).toBe(false); + await publish(ledger, { sessionId: 's1' }); + await expect(signaller.observe(send)).resolves.toEqual({ kind: 'idle', reason: 'no-subscription', revision: undefined }); + expect(sends).toEqual([]); + await driver.close(); + }); + + it('sends exactly one signal per newly eligible notice set and records availability, never delivery', async () => { + const { driver, ledger } = await openLedger(); + const signaller = signallerOver(ledger); + const { send, sends } = sender(); + await signaller.subscribe(principal('s1')); + expect(signaller.subscribed).toBe(true); + + // Nothing pending yet: a subscribed connection with no matching notice is idle. + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + + const published = await publish(ledger, { sessionId: 's1' }); + const first = await signaller.observe(send); + expect(first).toEqual({ kind: 'signalled', noticeIds: [published.notice.id], revision: 2 }); + expect(sends).toHaveLength(1); + + const afterSignal = (await ledger.read()).notices.find((notice) => notice.id === published.notice.id); + expect(afterSignal).toMatchObject({ + availability: { channel: 'mcp-resource-updated', count: 1, firstAt: T1, lastAt: T1 }, + state: 'pending', + }); + expect(afterSignal?.attempts).toEqual([]); + expect(afterSignal?.acknowledgement).toBeUndefined(); + + // The availability receipt advanced the ledger; that must not re-signal. + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(1); + + // The subscribed client re-reads the inbox (exposure receipt): still no + // further signal, so the client cannot be driven into a refetch loop. + const inbox = await readInbox(ledger, 's1', T2); + expect(inbox).toEqual([expect.objectContaining({ exposure: expect.objectContaining({ count: 1 }), id: published.notice.id })]); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(1); + + // A second notice yields one more signal carrying only the new id. + const second = await publish(ledger, { sessionId: 's1' }); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled', noticeIds: [second.notice.id] }); + expect(sends).toHaveLength(2); + await driver.close(); + }); + + it('never signals a connection whose principal the recipient does not match', async () => { + const { driver, ledger } = await openLedger(); + const signaller = signallerOver(ledger); + const { send, sends } = sender(); + await signaller.subscribe(principal('s2')); + + await publish(ledger, { sessionId: 's1' }); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toEqual([]); + expect((await ledger.read()).notices[0]?.availability).toBeUndefined(); + + // A workspace-only recipient matches any connection observing that workspace. + await publish(ledger, { recipient: { workspace: { root: '/workspace' } }, sessionId: 'ignored' }); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); + expect(sends).toHaveLength(1); + await driver.close(); + }); + + it('stops signalling once unsubscribed and resets tracking on re-subscribe', async () => { + const { driver, ledger } = await openLedger(); + const signaller = signallerOver(ledger); + const { send, sends } = sender(); + await signaller.subscribe(principal('s1')); + signaller.unsubscribe(); + expect(signaller.subscribed).toBe(false); + + await publish(ledger, { retryBudget: 2, sessionId: 's1' }); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'no-subscription' }); + expect(sends).toEqual([]); + + await signaller.subscribe(principal('s1')); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); + // Same connection re-subscribing is a new subscription: the durable budget + // (two) still has one signal left, and the in-memory dedupe restarts. + await signaller.subscribe(principal('s1')); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(2); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 2 }); + await driver.close(); + }); + + it('honours retryBudget durably across a restarted server process', async () => { + const { driver, ledger } = await openLedger(); + const { send, sends } = sender(); + const budgetOne = await publish(ledger, { sessionId: 's1' }); + const budgetTwo = await publish(ledger, { retryBudget: 2, sessionId: 's1' }); + + const first = signallerOver(ledger); + await first.subscribe(principal('s1')); + await expect(first.observe(send)).resolves.toMatchObject({ + kind: 'signalled', + noticeIds: [budgetOne.notice.id, budgetTwo.notice.id].toSorted((left, right) => left.localeCompare(right)), + }); + + // A new process: fresh subscription, no in-memory dedupe. Only the notice + // with budget left is signalled again; the default budget of one is spent. + const restarted = signallerOver(ledger); + await restarted.subscribe(principal('s1')); + await expect(restarted.observe(send)).resolves.toMatchObject({ kind: 'signalled', noticeIds: [budgetTwo.notice.id] }); + const third = signallerOver(ledger); + await third.subscribe(principal('s1')); + await expect(third.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(2); + + const notices = (await ledger.read()).notices; + expect(notices.find((notice) => notice.id === budgetOne.notice.id)?.availability).toMatchObject({ count: 1 }); + expect(notices.find((notice) => notice.id === budgetTwo.notice.id)?.availability).toMatchObject({ count: 2 }); + await driver.close(); + }); + + it('defers a notice until nextAttemptAt without implying a timer', async () => { + const { driver, ledger } = await openLedger(); + let clock = new Date(T1); + const signaller = signallerOver(ledger, () => clock); + const { send, sends } = sender(); + await signaller.subscribe(principal('s1')); + + await publish(ledger, { expiresAt: '2026-09-02T11:00:00.000Z', nextAttemptAt: T2, sessionId: 's1' }); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toEqual([]); + + // The next completed render after the instant is the first evaluation. + clock = new Date(T2); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); + expect(sends).toHaveLength(1); + await driver.close(); + }); + + it('skips expired, withdrawn, attempted, acknowledged, and not-yet-created notices', async () => { + const { driver, ledger } = await openLedger(); + let clock = new Date(T1); + const signaller = signallerOver(ledger, () => clock); + const { send, sends } = sender(); + await signaller.subscribe(principal('s1')); + + const expiring = await publish(ledger, { expiresAt: T1, sessionId: 's1' }); + const withdrawn = await publish(ledger, { sessionId: 's1' }); + await ledger.withdraw(withdrawn.notice.id, { at: T1, idempotencyKey: 'withdraw:1' }); + const future = await publish(ledger, { sessionId: 's1' }, T2); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toEqual([]); + expect((await ledger.read()).notices.map((notice) => notice.state)).toEqual(['pending', 'withdrawn', 'pending']); + expect(expiring.notice.state).toBe('pending'); + + // Delivered on the recipient's next admitted event: `attempted` leaves the inbox route. + const admitted = await publish(ledger, { sessionId: 's1' }); + await runAgentRequest({ + host: available({ name: 'claude' }, 'native'), + invocation: { id: 'event-1', kind: 'event', startedAt: T2 }, + noticeLedger: ledger, + session: available({ sessionId: 's1' }, 'native'), + workspace: available({ root: '/workspace' }, 'native'), + }, async () => { + const deliveries = await (await agent()).notices!.read(); + expect(deliveries.map((delivery) => delivery.notice.id)).toContain(admitted.notice.id); + // The future-dated notice is now eligible, so acknowledge it instead. + await (await agent()).notices!.acknowledge(future.notice.id); + }); + clock = new Date(T2); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toEqual([]); + const states = Object.fromEntries((await ledger.read()).notices.map((notice) => [notice.id, notice.state])); + expect(states[expiring.notice.id]).toBe('expired'); + expect(states[future.notice.id]).toBe('acknowledged'); + expect(states[admitted.notice.id]).toBe('attempted'); + await driver.close(); + }); + + it('serialises concurrent observations so one revision yields one signal', async () => { + const { driver, ledger } = await openLedger(); + const signaller = signallerOver(ledger); + const { send, sends } = sender(); + await signaller.subscribe(principal('s1')); + await publish(ledger, { sessionId: 's1' }); + + const outcomes = await Promise.all([signaller.observe(send), signaller.observe(send), signaller.observe(send)]); + expect(outcomes.filter((outcome) => outcome.kind === 'signalled')).toHaveLength(1); + expect(sends).toHaveLength(1); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + await driver.close(); + }); + + it('records no availability when the wire send fails and retries on the next render', async () => { + const { driver, ledger } = await openLedger(); + const signaller = signallerOver(ledger); + await signaller.subscribe(principal('s1')); + await publish(ledger, { sessionId: 's1' }); + + let fail = true; + let sends = 0; + const send = async (): Promise => { + if (fail) throw new Error('transport closed'); + sends += 1; + }; + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'failed', stage: 'send' }); + expect((await ledger.read()).notices[0]?.availability).toBeUndefined(); + + fail = false; + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); + expect(sends).toBe(1); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + await driver.close(); + }); + + it('fails closed when the durable store is unavailable', async () => { + const failure = new AgentStateError('unavailable', 'storage is offline'); + const reject = async (): Promise => Promise.reject(failure); + const failedLedger: AgentNoticeLedger = Object.freeze({ + expire: reject, + openRequest: reject, + read: reject, + signalAvailability: reject, + withdraw: reject, + }); + let closes = 0; + const signaller = createNoticeInboxSignaller({ + store: { + close: async () => { + closes += 1; + }, + noticeLedger: async () => failedLedger, + }, + }); + const { send, sends } = sender(); + + await expect(signaller.subscribe(principal('s1'))).rejects.toBe(failure); + expect(signaller.subscribed).toBe(false); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'no-subscription' }); + expect(sends).toEqual([]); + + // A store that fails after subscribing yields a typed read failure, not a phantom signal. + let readable = true; + const flaky: AgentNoticeLedger = Object.freeze({ + ...failedLedger, + read: async () => { + if (!readable) throw failure; + return { notices: [], revision: 0 }; + }, + }); + const flakySignaller = createNoticeInboxSignaller({ + store: { close: async () => undefined, noticeLedger: async () => flaky }, + }); + await flakySignaller.subscribe(principal('s1')); + readable = false; + await expect(flakySignaller.observe(send)).resolves.toEqual({ error: failure, kind: 'failed', stage: 'read' }); + expect(sends).toEqual([]); + + await signaller.close(); + expect(closes).toBe(1); + }); +}); From 9a8173b79ae89e23c310ab42042005ecb10c90f8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:57:13 +0000 Subject: [PATCH 02/18] fix(notices): claim the availability budget atomically before the wire send signalAvailability accepts expectedRevision (compare-and-swap) and the inbox signaller now claims the receipt against the revision it computed eligibility from before sending resources/updated, retrying a bounded number of unrelated revision races. Two server processes over one durable store can no longer both spend a notice's single signal, and a transport failure after the claim leaves the notice pending and readable instead of freeing a duplicate. Addresses the Codex P2 review on #376. --- packages/rsc-runtime/README.md | 16 ++- packages/rsc-runtime/src/notices/contract.ts | 6 + packages/rsc-runtime/src/notices/ledger.ts | 9 +- .../src/notices/resource-updated.ts | 114 +++++++++++++----- .../rsc-runtime/tests/notices-ledger.test.ts | 26 ++++ .../tests/notices-resource-updated.test.ts | 84 +++++++++++-- 6 files changed, 210 insertions(+), 45 deletions(-) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index a0ba31244..5edf6fdef 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -262,14 +262,18 @@ itself: one long-lived MCP connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`). The generated server process opens its own handle on the workspace-durable store its Flight worker mounts (`createGeneratedNoticeRuntime` from `@agent-bundle/runtime/mount`), and -after every completed render `observe(send)` reads the ledger, sends at most -one `notifications/resources/updated` for the subscriber's newly eligible -pending notices, and records the availability receipt. Eligibility is +after every completed render `observe(send)` reads the ledger, claims the +availability receipt for the subscriber's newly eligible pending notices as +one compare-and-swap against the revision it read (`expectedRevision`), and +only then sends at most one `notifications/resources/updated`. Eligibility is recipient-matched against the subscriber's observed identity, respects `nextAttemptAt`, and is bounded by `retryBudget` (availability signals per -notice, durable across restarts); exposure and availability receipts never -re-trigger a signal, so a subscribed client cannot be driven into a refetch -loop. Subscribing fails closed when the store is unreadable, and only the +notice, durable across restarts); because the budget is spent before the wire +write, two server processes over one store can never both signal the same +notice, and a transport failure after the claim leaves the notice pending and +readable rather than freeing a duplicate. Exposure and availability receipts +never re-trigger a signal, so a subscribed client cannot be driven into a +refetch loop. Subscribing fails closed when the store is unreadable, and only the workspace-durable lifetime is wired — volatile stores live in the worker's heap, so those servers honestly advertise no `resources.subscribe`. diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index cf6ed350b..82aa86b32 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -179,6 +179,12 @@ export interface AgentNoticeRequestLease { export interface AgentNoticeAvailabilitySignalOptions { readonly at: string; + /** + * Compare-and-swap guard: the receipt commits only if the ledger is still at + * this revision, so concurrent signallers over one durable store cannot both + * spend a notice's budget (typed `revision-conflict` otherwise). + */ + readonly expectedRevision?: number; readonly idempotencyKey: string; readonly noticeIds: readonly string[]; } diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 5e87116ce..f9b4b6fc8 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -481,10 +481,17 @@ export const createAgentNoticeLedger = ( } return options.noticeIds.map((id) => nonEmptyText(id, 'Notice id')); }); + const expectedRevision = yield* noticeEffect(() => { + if (options.expectedRevision !== undefined + && (!Number.isInteger(options.expectedRevision) || options.expectedRevision < 0)) { + throw new AgentNoticeError('invalid-input', 'Notice availability expectedRevision must be a non-negative integer'); + } + return options.expectedRevision; + }); const committed = yield* storeEffect(() => store.dispatch( 'availability-signalled', { at, channel: 'mcp-resource-updated', noticeIds }, - { idempotencyKey }, + { ...(expectedRevision === undefined ? {} : { expectedRevision }), idempotencyKey }, )); return snapshotFrom(committed.revision, committed.state); })); diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index dc4c4bdd1..d0e1548a1 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -1,12 +1,20 @@ import { randomUUID } from 'node:crypto'; -import type { - AgentNotice, - AgentNoticeLedger, - AgentNoticePrincipal, +import { AgentStateError } from '../state/index.js'; +import { + AgentNoticeError, + type AgentNotice, + type AgentNoticeLedger, + type AgentNoticePrincipal, } from './contract.js'; import { recipientMatchesPrincipal } from './state.js'; +/** Consecutive compare-and-swap losses tolerated before a claim reports failure. */ +const MAX_CLAIM_ATTEMPTS = 4; + +const isRevisionConflict = (error: unknown): boolean => + error instanceof AgentStateError && error.code === 'revision-conflict'; + /** The reserved inbox resource URI every generated stateful MCP server registers. */ export const AGENT_NOTICE_INBOX_URI = 'agent-bundle://notices/inbox'; @@ -111,50 +119,94 @@ export const createNoticeInboxSignaller = ( // select the same notice and send two signals for one revision. let queue: Promise = Promise.resolve(); + /** + * Claims the budget for every newly eligible notice as one compare-and-swap + * against the revision the eligibility was computed from. Two server + * processes over one durable store therefore cannot both spend a notice's + * single signal: the loser sees `revision-conflict`, re-reads, and finds the + * count already at budget. A bounded number of conflicts is retried because + * unrelated writers (publishes, exposures) also move the revision. + */ + const claim = async ( + ledger: AgentNoticeLedger, + current: InboxSubscription, + ): Promise< + | { readonly at: string; readonly kind: 'claimed'; readonly noticeIds: readonly string[]; readonly revision: number } + | { readonly kind: 'nothing-eligible'; readonly revision: number } + | { readonly error: unknown; readonly kind: 'failed'; readonly stage: 'read' | 'record' } + > => { + for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) { + let snapshot: Awaited>; + try { + snapshot = await ledger.read(); + } catch (error) { + return { error, kind: 'failed', stage: 'read' }; + } + const at = now().toISOString(); + const nowMs = Date.parse(at); + const noticeIds = Object.freeze(snapshot.notices + .filter((notice) => !current.signalled.has(notice.id) && eligibleForSignal(notice, current.principal, nowMs)) + .map((notice) => notice.id) + .toSorted((left, right) => left.localeCompare(right))); + if (noticeIds.length === 0) return { kind: 'nothing-eligible', revision: snapshot.revision }; + signalSequence += 1; + try { + const committed = await ledger.signalAvailability({ + at, + expectedRevision: snapshot.revision, + idempotencyKey: `agent-notices:availability:${current.id}:${String(signalSequence)}`, + noticeIds, + }); + return { at, kind: 'claimed', noticeIds, revision: committed.revision }; + } catch (error) { + if (!isRevisionConflict(error)) return { error, kind: 'failed', stage: 'record' }; + } + } + return { + error: new AgentNoticeError( + 'invalid-input', + `Notice availability claim lost ${String(MAX_CLAIM_ATTEMPTS)} consecutive revision races`, + ), + kind: 'failed', + stage: 'record', + }; + }; + const observeOnce = async (send: () => Promise): Promise => { const current = subscription; if (current === undefined) { return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: undefined }); } let ledger: AgentNoticeLedger; - let snapshot: Awaited>; try { ledger = await options.store.noticeLedger(); - snapshot = await ledger.read(); } catch (error) { return Object.freeze({ error, kind: 'failed' as const, stage: 'read' as const }); } - const at = now().toISOString(); - const nowMs = Date.parse(at); - const noticeIds = Object.freeze(snapshot.notices - .filter((notice) => !current.signalled.has(notice.id) && eligibleForSignal(notice, current.principal, nowMs)) - .map((notice) => notice.id) - .toSorted((left, right) => left.localeCompare(right))); - if (noticeIds.length === 0) { - return Object.freeze({ kind: 'idle', reason: 'nothing-eligible', revision: snapshot.revision }); - } - // The subscription may have been replaced while the ledger was read; a - // signal for a stale subscriber must not be sent or recorded. - if (subscription !== current) { - return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: snapshot.revision }); + const claimed = await claim(ledger, current); + switch (claimed.kind) { + case 'failed': + return Object.freeze({ error: claimed.error, kind: 'failed' as const, stage: claimed.stage }); + case 'nothing-eligible': + return Object.freeze({ kind: 'idle', reason: 'nothing-eligible', revision: claimed.revision }); + case 'claimed': + break; + default: { + const exhaustive: never = claimed; + return exhaustive; + } } + // The budget is spent durably before the wire write so no other process + // can spend it too; a transport failure here leaves the notice pending and + // readable through the inbox, and the receipt honestly records that this + // connection attempted the signal. + for (const id of claimed.noticeIds) current.signalled.add(id); try { await send(); } catch (error) { return Object.freeze({ error, kind: 'failed' as const, stage: 'send' as const }); } - for (const id of noticeIds) current.signalled.add(id); - signalSequence += 1; - try { - const committed = await ledger.signalAvailability({ - at, - idempotencyKey: `agent-notices:availability:${current.id}:${String(signalSequence)}`, - noticeIds, - }); - return Object.freeze({ kind: 'signalled', noticeIds, revision: committed.revision }); - } catch (error) { - return Object.freeze({ error, kind: 'failed' as const, stage: 'record' as const }); - } + return Object.freeze({ kind: 'signalled', noticeIds: claimed.noticeIds, revision: claimed.revision }); }; return Object.freeze({ diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index ee8e2ab77..213d4e4cd 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -832,6 +832,32 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { })).rejects.toMatchObject({ code: 'invalid-input' }); await driver.close(); }); + + it('guards availability receipts with compare-and-swap so racing signallers cannot both spend a budget', async () => { + const { driver, ledger } = await openLedger(); + const published = await publishTo(ledger); + const stale = (await ledger.read()).revision; + await ledger.signalAvailability({ + at: '2026-09-01T19:04:00.000Z', + expectedRevision: stale, + idempotencyKey: 'availability:a', + noticeIds: [published.notice.id], + }); + await expect(ledger.signalAvailability({ + at: '2026-09-01T19:04:01.000Z', + expectedRevision: stale, + idempotencyKey: 'availability:b', + noticeIds: [published.notice.id], + })).rejects.toMatchObject({ code: 'revision-conflict' }); + await expect(ledger.signalAvailability({ + at: '2026-09-01T19:04:02.000Z', + expectedRevision: -1, + idempotencyKey: 'availability:c', + noticeIds: [published.notice.id], + })).rejects.toMatchObject({ code: 'invalid-input' }); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + await driver.close(); + }); }); describe('notice delivery route selection', () => { diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index 6563876d3..ae4e9c657 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -280,28 +280,98 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); - it('records no availability when the wire send fails and retries on the next render', async () => { + it('claims the durable budget before the wire write, so a failed send never frees a second signal', async () => { const { driver, ledger } = await openLedger(); const signaller = signallerOver(ledger); await signaller.subscribe(principal('s1')); await publish(ledger, { sessionId: 's1' }); - let fail = true; let sends = 0; const send = async (): Promise => { - if (fail) throw new Error('transport closed'); sends += 1; + throw new Error('transport closed'); }; await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'failed', stage: 'send' }); - expect((await ledger.read()).notices[0]?.availability).toBeUndefined(); - - fail = false; - await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); expect(sends).toBe(1); + // The receipt records the attempt this connection made; the notice stays + // pending and readable through the inbox, and the default budget is spent. + expect((await ledger.read()).notices[0]).toMatchObject({ availability: { count: 1 }, state: 'pending' }); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + const restarted = signallerOver(ledger); + await restarted.subscribe(principal('s1')); + await expect(restarted.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toBe(1); + await driver.close(); + }); + + it('lets exactly one of two racing server processes spend a notice budget', async () => { + const { driver, ledger } = await openLedger(); + await publish(ledger, { sessionId: 's1' }); + const { send, sends } = sender(); + + // Process B reads the ledger before process A claims, then stalls until + // A has committed: its compare-and-swap must lose and its re-read must + // find the budget already spent. + let releaseB: () => void = () => undefined; + const aDone = new Promise((resolve) => { + releaseB = resolve; + }); + let lag = false; + const laggingLedger: AgentNoticeLedger = Object.freeze({ + ...ledger, + read: async () => { + const snapshot = await ledger.read(); + if (lag) { + lag = false; + await aDone; + } + return snapshot; + }, + }); + const processA = signallerOver(ledger); + const processB = signallerOver(laggingLedger); + await processA.subscribe(principal('s1')); + await processB.subscribe(principal('s1')); + + lag = true; + const bObserve = processB.observe(send); + await new Promise((resolve) => setTimeout(resolve, 0)); + const aOutcome = await processA.observe(send); + releaseB(); + const bOutcome = await bObserve; + + expect(aOutcome).toMatchObject({ kind: 'signalled' }); + expect(bOutcome).toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(1); expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); await driver.close(); }); + it('gives up a claim after repeated revision races instead of spinning', async () => { + const { driver, ledger } = await openLedger(); + await publish(ledger, { sessionId: 's1' }); + let interference = 0; + // Every read is followed by an unrelated commit before the claim lands. + const contended: AgentNoticeLedger = Object.freeze({ + ...ledger, + read: async () => { + const snapshot = await ledger.read(); + interference += 1; + await publish(ledger, { sessionId: 'someone-else' }); + return snapshot; + }, + }); + const signaller = signallerOver(contended); + await signaller.subscribe(principal('s1')); + const { send, sends } = sender(); + const outcome = await signaller.observe(send); + expect(outcome).toMatchObject({ kind: 'failed', stage: 'record' }); + expect(interference).toBeGreaterThanOrEqual(2); + expect(sends).toEqual([]); + expect((await ledger.read()).notices[0]?.availability).toBeUndefined(); + await driver.close(); + }); + it('fails closed when the durable store is unavailable', async () => { const failure = new AgentStateError('unavailable', 'storage is offline'); const reject = async (): Promise => Promise.reject(failure); From b195a0c0389908848b9c8d3a80a8517b62b9b1fa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 06:57:29 +0000 Subject: [PATCH 03/18] fix(notices): serialize unsubscribe with in-flight inbox observations An unsubscribe that overlaps an observation parked on the durable store now wins: the observation yields before claiming any budget or sending, and the unsubscribe resolves (and the MCP request is acknowledged) only after in-flight observations settle, so a client is never signalled after its unsubscribe succeeded. Subscribe and unsubscribe share the observation queue. --- .../agent-bundle/src/mcp-server-runtime.ts | 6 +- .../src/notices/resource-updated.ts | 59 +++++++++---- .../tests/notices-resource-updated.test.ts | 86 ++++++++++++++++++- 3 files changed, 133 insertions(+), 18 deletions(-) diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 75ce99f43..06a94c70c 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -577,9 +577,11 @@ const installNoticeInboxSubscriptions = ( } return {}; }) as never); - protocol.setRequestHandler('resources/unsubscribe', ((request: ResourceSubscriptionRequest) => { + protocol.setRequestHandler('resources/unsubscribe', (async (request: ResourceSubscriptionRequest) => { assertInboxUri(request.params.uri); - notices.unsubscribe(); + // Acknowledged only once in-flight observations have settled, so the + // client never receives a signal after its unsubscribe succeeded. + await notices.unsubscribe(); return {}; }) as never); const send = async (): Promise => { diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index d0e1548a1..a7ad72ac1 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -73,7 +73,13 @@ export interface AgentNoticeInboxSignaller { * subscription that could never be honoured. */ subscribe(principal: AgentNoticePrincipal): Promise; - unsubscribe(): void; + /** + * Ends the subscription. Resolves only after every observation already in + * flight has settled, so once it resolves no further signal is sent or + * budget spent for the connection — a `resources/unsubscribe` acknowledged + * to the client is honoured even while the store is slow. + */ + unsubscribe(): Promise; } interface InboxSubscription { @@ -115,9 +121,21 @@ export const createNoticeInboxSignaller = ( const now = options.now ?? ((): Date => new Date()); let subscription: InboxSubscription | undefined; let signalSequence = 0; - // Observations serialize so two renders completing together cannot both - // select the same notice and send two signals for one revision. + // Observations and subscription changes serialize on one queue: two renders + // completing together cannot both select the same notice and send two + // signals for one revision, and an unsubscribe (or re-subscribe) that + // overlaps an observation awaiting the store takes effect only after that + // observation settles, never between its eligibility read and its send. let queue: Promise = Promise.resolve(); + // Unsubscribes requested while an observation awaits the store are counted + // synchronously so that observation yields before spending any budget: the + // client asked to stop, so nothing is claimed or sent on its behalf. + let pendingUnsubscribes = 0; + const serialized = (step: () => Promise): Promise => { + const run = queue.then(step); + queue = run.catch(() => undefined); + return run; + }; /** * Claims the budget for every newly eligible notice as one compare-and-swap @@ -133,6 +151,7 @@ export const createNoticeInboxSignaller = ( ): Promise< | { readonly at: string; readonly kind: 'claimed'; readonly noticeIds: readonly string[]; readonly revision: number } | { readonly kind: 'nothing-eligible'; readonly revision: number } + | { readonly kind: 'unsubscribed' } | { readonly error: unknown; readonly kind: 'failed'; readonly stage: 'read' | 'record' } > => { for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) { @@ -142,6 +161,7 @@ export const createNoticeInboxSignaller = ( } catch (error) { return { error, kind: 'failed', stage: 'read' }; } + if (pendingUnsubscribes > 0 || subscription !== current) return { kind: 'unsubscribed' }; const at = now().toISOString(); const nowMs = Date.parse(at); const noticeIds = Object.freeze(snapshot.notices @@ -183,12 +203,17 @@ export const createNoticeInboxSignaller = ( } catch (error) { return Object.freeze({ error, kind: 'failed' as const, stage: 'read' as const }); } + if (pendingUnsubscribes > 0) { + return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: undefined }); + } const claimed = await claim(ledger, current); switch (claimed.kind) { case 'failed': return Object.freeze({ error: claimed.error, kind: 'failed' as const, stage: claimed.stage }); case 'nothing-eligible': return Object.freeze({ kind: 'idle', reason: 'nothing-eligible', revision: claimed.revision }); + case 'unsubscribed': + return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: undefined }); case 'claimed': break; default: { @@ -219,21 +244,25 @@ export const createNoticeInboxSignaller = ( return options.store.close(); }, observe(send: () => Promise): Promise { - const run = queue.then(() => observeOnce(send)); - queue = run.catch(() => undefined); - return run; + return serialized(() => observeOnce(send)); }, - async subscribe(principal: AgentNoticePrincipal): Promise { - const ledger = await options.store.noticeLedger(); - await ledger.read(); - subscription = Object.freeze({ - id: randomUUID(), - principal, - signalled: new Set(), + subscribe(principal: AgentNoticePrincipal): Promise { + return serialized(async () => { + const ledger = await options.store.noticeLedger(); + await ledger.read(); + subscription = Object.freeze({ + id: randomUUID(), + principal, + signalled: new Set(), + }); }); }, - unsubscribe(): void { - subscription = undefined; + unsubscribe(): Promise { + pendingUnsubscribes += 1; + return serialized(async () => { + pendingUnsubscribes -= 1; + subscription = undefined; + }); }, }); }; diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index ae4e9c657..fd468ae84 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -155,12 +155,96 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); + it('honours an unsubscribe that overlaps an observation awaiting the store', async () => { + const { driver, ledger } = await openLedger(); + await publish(ledger, { sessionId: 's1' }); + // A slow ledger: the first read parks until the test releases it, standing + // in for a contended durable store. + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let parkNextRead = false; + const slowLedger: AgentNoticeLedger = Object.freeze({ + ...ledger, + read: async () => { + if (parkNextRead) { + parkNextRead = false; + await gate; + } + return ledger.read(); + }, + }); + const signaller = signallerOver(slowLedger); + const { send, sends } = sender(); + await signaller.subscribe(principal('s1')); + + const events: string[] = []; + parkNextRead = true; + const observing = signaller.observe(async () => { + events.push('send'); + await send(); + }); + // `resources/unsubscribe` arrives while the observation is parked on the read. + const unsubscribing = signaller.unsubscribe().then(() => { + events.push('unsubscribed'); + }); + release!(); + await expect(observing).resolves.toEqual({ kind: 'idle', reason: 'no-subscription', revision: undefined }); + await unsubscribing; + expect(events).toEqual(['unsubscribed']); + expect(sends).toEqual([]); + expect(signaller.subscribed).toBe(false); + // Nothing was claimed on the client's behalf: the durable budget is intact, + // so a later subscriber still receives the signal. + expect((await ledger.read()).notices[0]?.availability).toBeUndefined(); + await signaller.subscribe(principal('s1')); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); + expect(sends).toHaveLength(1); + await driver.close(); + }); + + it('acknowledges unsubscribe only after an in-flight send has settled', async () => { + const { driver, ledger } = await openLedger(); + await publish(ledger, { sessionId: 's1' }); + const signaller = signallerOver(ledger); + await signaller.subscribe(principal('s1')); + let releaseSend: (() => void) | undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + const events: string[] = []; + const observing = signaller.observe(async () => { + await sendGate; + events.push('send'); + }); + // Let the observation pass its eligibility read and claim before the + // unsubscribe arrives mid-send. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + const unsubscribing = signaller.unsubscribe().then(() => { + events.push('unsubscribed'); + }); + releaseSend!(); + await expect(observing).resolves.toMatchObject({ kind: 'signalled' }); + await unsubscribing; + // The wire write that was already committed completes first; the client is + // told it is unsubscribed only afterwards, never the other way round. + expect(events).toEqual(['send', 'unsubscribed']); + await expect(signaller.observe(async () => { + events.push('late'); + })).resolves.toMatchObject({ kind: 'idle', reason: 'no-subscription' }); + expect(events).toEqual(['send', 'unsubscribed']); + await driver.close(); + }); + it('stops signalling once unsubscribed and resets tracking on re-subscribe', async () => { const { driver, ledger } = await openLedger(); const signaller = signallerOver(ledger); const { send, sends } = sender(); await signaller.subscribe(principal('s1')); - signaller.unsubscribe(); + await signaller.unsubscribe(); expect(signaller.subscribed).toBe(false); await publish(ledger, { retryBudget: 2, sessionId: 's1' }); From c6de0fe4216d2fe110bbad2dad9aa1bf03839289 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 07:54:48 +0000 Subject: [PATCH 04/18] fix(notices): reserve the availability slot before the wire write, spend it only on success Codex P2 on #376: signalAvailability() incremented the budget before the resources/updated send, so a failed send still cost a retry slot and the receipt no longer meant the protocol write succeeded. The ledger gains reserveAvailability()/releaseAvailability(): the signaller holds a slot by compare-and-swap, finalizes it into the receipt only after send() resolves, and releases it when send() rejects. Holds expire after AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS so a crashed holder cannot starve a notice. --- .changeset/notice-inbox-resource-updated.md | 2 +- packages/rsc-runtime/README.md | 29 ++++--- packages/rsc-runtime/src/mount/index.ts | 2 + packages/rsc-runtime/src/notices/contract.ts | 42 +++++++++- packages/rsc-runtime/src/notices/index.ts | 4 + packages/rsc-runtime/src/notices/ledger.ts | 75 ++++++++++++++---- .../src/notices/resource-updated.ts | 75 +++++++++++++----- packages/rsc-runtime/src/notices/state.ts | 77 ++++++++++++++++++- .../rsc-runtime/tests/notices-ledger.test.ts | 65 ++++++++++++++++ .../tests/notices-resource-updated.test.ts | 65 +++++++++++++--- 10 files changed, 379 insertions(+), 57 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index bd5230118..465842b8c 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. +Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts. The ledger gains `reserveAvailability()` / `releaseAvailability()` (and `AgentNotice.availabilityReservation`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`): the signaller holds a notice's budget slot by compare-and-swap before the wire write so concurrent processes cannot both send, finalizes the hold into the receipt only when the protocol write succeeds, and releases it when the write fails, so a failed send costs no budget and the receipt always means the write succeeded; `unsubscribe()` resolves only after in-flight observations settle; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 5edf6fdef..aaa52760d 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -262,18 +262,23 @@ itself: one long-lived MCP connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`). The generated server process opens its own handle on the workspace-durable store its Flight worker mounts (`createGeneratedNoticeRuntime` from `@agent-bundle/runtime/mount`), and -after every completed render `observe(send)` reads the ledger, claims the -availability receipt for the subscriber's newly eligible pending notices as -one compare-and-swap against the revision it read (`expectedRevision`), and -only then sends at most one `notifications/resources/updated`. Eligibility is -recipient-matched against the subscriber's observed identity, respects -`nextAttemptAt`, and is bounded by `retryBudget` (availability signals per -notice, durable across restarts); because the budget is spent before the wire -write, two server processes over one store can never both signal the same -notice, and a transport failure after the claim leaves the notice pending and -readable rather than freeing a duplicate. Exposure and availability receipts -never re-trigger a signal, so a subscribed client cannot be driven into a -refetch loop. Subscribing fails closed when the store is unreadable, and only the +after every completed render `observe(send)` reads the ledger, reserves the +budget slot of the subscriber's newly eligible pending notices as one +compare-and-swap against the revision it read (`reserveAvailability()` with +`expectedRevision`), sends at most one `notifications/resources/updated`, and +then finalizes the reservation into the availability receipt +(`signalAvailability()`) — or releases it (`releaseAvailability()`) when the +protocol write failed, so the receipt only ever means the write succeeded and +a failed send costs no budget. Eligibility is recipient-matched against the +subscriber's observed identity, respects `nextAttemptAt`, skips notices whose +slot another signaller currently holds (a hold older than +`AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS` counts as abandoned), and is +bounded by `retryBudget` (availability receipts per notice, durable across +restarts); because the slot is held before the wire write, two server processes +over one store can never both signal the same notice. Exposure and availability +receipts never re-trigger a signal, so a subscribed client cannot be driven into +a refetch loop. Subscribing fails closed when the store is unreadable, +`unsubscribe()` resolves only after in-flight observations settle, and only the workspace-durable lifetime is wired — volatile stores live in the worker's heap, so those servers honestly advertise no `resources.subscribe`. diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts index 6862fa79c..1551b3532 100644 --- a/packages/rsc-runtime/src/mount/index.ts +++ b/packages/rsc-runtime/src/mount/index.ts @@ -92,6 +92,8 @@ const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { }), }), read: reject, + releaseAvailability: reject, + reserveAvailability: reject, signalAvailability: reject, withdraw: reject, }); diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index 82aa86b32..a63fa6061 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -71,6 +71,23 @@ export interface AgentNoticeAvailability { readonly lastAt: string; } +/** + * A signaller's durable hold on one `resources/updated` send that has not yet + * succeeded. It spends no budget: the receipt is recorded only once the + * protocol write succeeds (`signalAvailability`) and released when it fails + * (`releaseAvailability`). It exists so two signallers over one store cannot + * both send for the same budget slot, and it is honoured only for + * `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS` so a crashed holder cannot + * starve the notice. + */ +export interface AgentNoticeAvailabilityReservation { + readonly at: string; + readonly key: string; +} + +/** How long a reservation blocks other signallers before it is treated as abandoned. */ +export const AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS = 30_000; + export interface AgentNoticeAcknowledgement { readonly acknowledgedAt: string; readonly invocationId: string; @@ -82,6 +99,7 @@ export interface AgentNotice { readonly acknowledgement?: AgentNoticeAcknowledgement; readonly attempts: readonly AgentNoticeAttemptReceipt[]; readonly availability?: AgentNoticeAvailability; + readonly availabilityReservation?: AgentNoticeAvailabilityReservation; readonly content: AgentDocumentSnapshot; readonly createdAt: string; readonly dedupeKey?: string; @@ -187,13 +205,35 @@ export interface AgentNoticeAvailabilitySignalOptions { readonly expectedRevision?: number; readonly idempotencyKey: string; readonly noticeIds: readonly string[]; + /** The reservation this signal finalizes; the reservation is cleared with the receipt. */ + readonly reservationKey?: string; +} + +export interface AgentNoticeAvailabilityReservationOptions { + readonly at: string; + /** Compare-and-swap guard against the revision the eligibility was computed from. */ + readonly expectedRevision?: number; + readonly idempotencyKey: string; + readonly noticeIds: readonly string[]; + readonly reservationKey: string; +} + +export interface AgentNoticeAvailabilityReleaseOptions { + readonly idempotencyKey: string; + readonly noticeIds: readonly string[]; + /** Only a reservation with this key is released; a newer holder's is left intact. */ + readonly reservationKey: string; } export interface AgentNoticeLedger { expire(options: AgentNoticeExpiryOptions): Promise; openRequest(request: AgentNoticeRequest): Promise; read(): Promise; - /** Records a wire-level resources/updated signal; availability, never delivery. */ + /** Releases a reservation whose resources/updated send failed; no budget was spent. */ + releaseAvailability(options: AgentNoticeAvailabilityReleaseOptions): Promise; + /** Holds one budget slot for a resources/updated send about to happen; records no receipt. */ + reserveAvailability(options: AgentNoticeAvailabilityReservationOptions): Promise; + /** Records a wire-level resources/updated signal that succeeded; availability, never delivery. */ signalAvailability(options: AgentNoticeAvailabilitySignalOptions): Promise; withdraw(id: string, options: AgentNoticeWithdrawOptions): Promise; } diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index eceb7a7f6..6e3f18082 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -8,6 +8,7 @@ * transactions, revisions, and idempotency. */ export { + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, AGENT_NOTICE_STATES, AgentNoticeError, } from './contract.js'; @@ -19,6 +20,9 @@ export type { AgentNoticeAuthorizationRequest, AgentNoticeAuthorizer, AgentNoticeAvailability, + AgentNoticeAvailabilityReleaseOptions, + AgentNoticeAvailabilityReservation, + AgentNoticeAvailabilityReservationOptions, AgentNoticeAvailabilitySignalOptions, AgentNoticeDelivery, AgentNoticeErrorCode, diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index f9b4b6fc8..e8bab6165 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -18,6 +18,8 @@ import { type AgentNoticeAuthorizationDecision, type AgentNoticeAuthorizationRequest, type AgentNoticeAuthorizer, + type AgentNoticeAvailabilityReleaseOptions, + type AgentNoticeAvailabilityReservationOptions, type AgentNoticeAvailabilitySignalOptions, type AgentNoticeDelivery, type AgentNoticeExpiryOptions, @@ -78,6 +80,20 @@ const timestamp = (value: string, label: string): string => { return value; }; +const availabilityNoticeIds = (noticeIds: readonly string[]): string[] => { + if (noticeIds.length === 0) { + throw new AgentNoticeError('invalid-input', 'Notice availability requires at least one notice id'); + } + return noticeIds.map((id) => nonEmptyText(id, 'Notice id')); +}; + +const availabilityExpectedRevision = (expectedRevision: number | undefined): number | undefined => { + if (expectedRevision !== undefined && (!Number.isInteger(expectedRevision) || expectedRevision < 0)) { + throw new AgentNoticeError('invalid-input', 'Notice availability expectedRevision must be a non-negative integer'); + } + return expectedRevision; +}; + const priority = (value: AgentNoticePublishInput['priority']): AgentNoticePublishInput['priority'] => { switch (value) { case 'low': @@ -470,27 +486,58 @@ export const createAgentNoticeLedger = ( return snapshotFrom(snapshot.revision, snapshot.state); }, + releaseAvailability(options: AgentNoticeAvailabilityReleaseOptions): Promise { + return runPromise(Effect.gen(function*() { + const idempotencyKey = yield* noticeEffect(() => + nonEmptyText(options.idempotencyKey, 'Notice availability idempotency key')); + const noticeIds = yield* noticeEffect(() => availabilityNoticeIds(options.noticeIds)); + const reservationKey = yield* noticeEffect(() => + nonEmptyText(options.reservationKey, 'Notice availability reservation key')); + const committed = yield* storeEffect(() => store.dispatch( + 'availability-released', + { noticeIds, reservationKey }, + { idempotencyKey }, + )); + return snapshotFrom(committed.revision, committed.state); + })); + }, + + reserveAvailability(options: AgentNoticeAvailabilityReservationOptions): Promise { + return runPromise(Effect.gen(function*() { + const at = yield* noticeEffect(() => timestamp(options.at, 'Notice availability time')); + const idempotencyKey = yield* noticeEffect(() => + nonEmptyText(options.idempotencyKey, 'Notice availability idempotency key')); + const noticeIds = yield* noticeEffect(() => availabilityNoticeIds(options.noticeIds)); + const reservationKey = yield* noticeEffect(() => + nonEmptyText(options.reservationKey, 'Notice availability reservation key')); + const expectedRevision = yield* noticeEffect(() => availabilityExpectedRevision(options.expectedRevision)); + const committed = yield* storeEffect(() => store.dispatch( + 'availability-reserved', + { at, noticeIds, reservationKey }, + { ...(expectedRevision === undefined ? {} : { expectedRevision }), idempotencyKey }, + )); + return snapshotFrom(committed.revision, committed.state); + })); + }, + signalAvailability(options: AgentNoticeAvailabilitySignalOptions): Promise { return runPromise(Effect.gen(function*() { const at = yield* noticeEffect(() => timestamp(options.at, 'Notice availability time')); const idempotencyKey = yield* noticeEffect(() => nonEmptyText(options.idempotencyKey, 'Notice availability idempotency key')); - const noticeIds = yield* noticeEffect(() => { - if (options.noticeIds.length === 0) { - throw new AgentNoticeError('invalid-input', 'Notice availability requires at least one notice id'); - } - return options.noticeIds.map((id) => nonEmptyText(id, 'Notice id')); - }); - const expectedRevision = yield* noticeEffect(() => { - if (options.expectedRevision !== undefined - && (!Number.isInteger(options.expectedRevision) || options.expectedRevision < 0)) { - throw new AgentNoticeError('invalid-input', 'Notice availability expectedRevision must be a non-negative integer'); - } - return options.expectedRevision; - }); + const noticeIds = yield* noticeEffect(() => availabilityNoticeIds(options.noticeIds)); + const reservationKey = yield* noticeEffect(() => options.reservationKey === undefined + ? undefined + : nonEmptyText(options.reservationKey, 'Notice availability reservation key')); + const expectedRevision = yield* noticeEffect(() => availabilityExpectedRevision(options.expectedRevision)); const committed = yield* storeEffect(() => store.dispatch( 'availability-signalled', - { at, channel: 'mcp-resource-updated', noticeIds }, + { + at, + channel: 'mcp-resource-updated', + noticeIds, + ...(reservationKey === undefined ? {} : { reservationKey }), + }, { ...(expectedRevision === undefined ? {} : { expectedRevision }), idempotencyKey }, )); return snapshotFrom(committed.revision, committed.state); diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index a7ad72ac1..b1fdbea71 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { AgentStateError } from '../state/index.js'; import { + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, AgentNoticeError, type AgentNotice, type AgentNoticeLedger, @@ -9,7 +10,7 @@ import { } from './contract.js'; import { recipientMatchesPrincipal } from './state.js'; -/** Consecutive compare-and-swap losses tolerated before a claim reports failure. */ +/** Consecutive compare-and-swap losses tolerated before a reservation reports failure. */ const MAX_CLAIM_ATTEMPTS = 4; const isRevisionConflict = (error: unknown): boolean => @@ -112,6 +113,13 @@ const eligibleForSignal = ( // Not due yet: evaluated only on completed renders; V1 never implies a timer. if (notice.nextAttemptAt !== undefined && Date.parse(notice.nextAttemptAt) > nowMs) return false; if ((notice.availability?.count ?? 0) >= (notice.retryBudget ?? 1)) return false; + // Another signaller holds the slot for a send in progress. A hold older than + // the TTL belongs to a holder that never finalized or released (crashed + // mid-send) and no longer blocks anyone. + const reservation = notice.availabilityReservation; + if (reservation !== undefined && Date.parse(reservation.at) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS > nowMs) { + return false; + } return recipientMatchesPrincipal(notice.recipient, principal); }; @@ -138,18 +146,26 @@ export const createNoticeInboxSignaller = ( }; /** - * Claims the budget for every newly eligible notice as one compare-and-swap - * against the revision the eligibility was computed from. Two server - * processes over one durable store therefore cannot both spend a notice's - * single signal: the loser sees `revision-conflict`, re-reads, and finds the - * count already at budget. A bounded number of conflicts is retried because - * unrelated writers (publishes, exposures) also move the revision. + * Reserves the budget slot of every newly eligible notice as one + * compare-and-swap against the revision the eligibility was computed from. + * Two server processes over one durable store therefore cannot both send for + * a notice's single slot: the loser sees `revision-conflict`, re-reads, and + * finds the slot reserved. The reservation spends nothing; the receipt is + * recorded only after the protocol write succeeds. A bounded number of + * conflicts is retried because unrelated writers (publishes, exposures) also + * move the revision. */ const claim = async ( ledger: AgentNoticeLedger, current: InboxSubscription, ): Promise< - | { readonly at: string; readonly kind: 'claimed'; readonly noticeIds: readonly string[]; readonly revision: number } + | { + readonly at: string; + readonly kind: 'claimed'; + readonly noticeIds: readonly string[]; + readonly reservationKey: string; + readonly revision: number; + } | { readonly kind: 'nothing-eligible'; readonly revision: number } | { readonly kind: 'unsubscribed' } | { readonly error: unknown; readonly kind: 'failed'; readonly stage: 'read' | 'record' } @@ -170,14 +186,16 @@ export const createNoticeInboxSignaller = ( .toSorted((left, right) => left.localeCompare(right))); if (noticeIds.length === 0) return { kind: 'nothing-eligible', revision: snapshot.revision }; signalSequence += 1; + const reservationKey = `${current.id}:${String(signalSequence)}`; try { - const committed = await ledger.signalAvailability({ + const committed = await ledger.reserveAvailability({ at, expectedRevision: snapshot.revision, - idempotencyKey: `agent-notices:availability:${current.id}:${String(signalSequence)}`, + idempotencyKey: `agent-notices:availability:reserve:${reservationKey}`, noticeIds, + reservationKey, }); - return { at, kind: 'claimed', noticeIds, revision: committed.revision }; + return { at, kind: 'claimed', noticeIds, reservationKey, revision: committed.revision }; } catch (error) { if (!isRevisionConflict(error)) return { error, kind: 'failed', stage: 'record' }; } @@ -185,7 +203,7 @@ export const createNoticeInboxSignaller = ( return { error: new AgentNoticeError( 'invalid-input', - `Notice availability claim lost ${String(MAX_CLAIM_ATTEMPTS)} consecutive revision races`, + `Notice availability reservation lost ${String(MAX_CLAIM_ATTEMPTS)} consecutive revision races`, ), kind: 'failed', stage: 'record', @@ -221,17 +239,38 @@ export const createNoticeInboxSignaller = ( return exhaustive; } } - // The budget is spent durably before the wire write so no other process - // can spend it too; a transport failure here leaves the notice pending and - // readable through the inbox, and the receipt honestly records that this - // connection attempted the signal. - for (const id of claimed.noticeIds) current.signalled.add(id); + // The slot is held durably before the wire write so no other process sends + // for it too, but nothing is spent yet: a failed send releases the hold and + // the notice stays eligible for the next observation, while a successful + // send finalizes the hold into the availability receipt. Only the receipt + // means the protocol write succeeded. try { await send(); } catch (error) { + try { + await ledger.releaseAvailability({ + idempotencyKey: `agent-notices:availability:release:${claimed.reservationKey}`, + noticeIds: claimed.noticeIds, + reservationKey: claimed.reservationKey, + }); + } catch { + // The hold expires on its own after the reservation TTL; the send + // failure is the outcome worth reporting. + } return Object.freeze({ error, kind: 'failed' as const, stage: 'send' as const }); } - return Object.freeze({ kind: 'signalled', noticeIds: claimed.noticeIds, revision: claimed.revision }); + for (const id of claimed.noticeIds) current.signalled.add(id); + try { + const committed = await ledger.signalAvailability({ + at: claimed.at, + idempotencyKey: `agent-notices:availability:signal:${claimed.reservationKey}`, + noticeIds: claimed.noticeIds, + reservationKey: claimed.reservationKey, + }); + return Object.freeze({ kind: 'signalled', noticeIds: claimed.noticeIds, revision: committed.revision }); + } catch (error) { + return Object.freeze({ error, kind: 'failed' as const, stage: 'record' as const }); + } }; return Object.freeze({ diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index f7cfd01b8..57a978430 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -76,6 +76,11 @@ const availabilitySchema = z.object({ lastAt: z.string().min(1), }).strict().readonly(); +const availabilityReservationSchema = z.object({ + at: z.string().min(1), + key: z.string().min(1), +}).strict().readonly(); + const acknowledgementSchema = z.object({ acknowledgedAt: z.string().min(1), invocationId: z.string().min(1), @@ -85,6 +90,7 @@ const noticeSchema = z.object({ acknowledgement: acknowledgementSchema.optional(), attempts: z.array(attemptSchema).readonly(), availability: availabilitySchema.optional(), + availabilityReservation: availabilityReservationSchema.optional(), content: documentSchema, createdAt: z.string().min(1), dedupeKey: z.string().min(1).optional(), @@ -122,10 +128,20 @@ export const agentNoticeEventSchemas = { principal: principalSchema, unavailableIds: z.array(z.string().min(1)), }).strict(), + 'availability-released': z.object({ + noticeIds: z.array(z.string().min(1)), + reservationKey: z.string().min(1), + }).strict(), + 'availability-reserved': z.object({ + at: z.string().min(1), + noticeIds: z.array(z.string().min(1)), + reservationKey: z.string().min(1), + }).strict(), 'availability-signalled': z.object({ at: z.string().min(1), channel: z.literal('mcp-resource-updated'), noticeIds: z.array(z.string().min(1)), + reservationKey: z.string().min(1).optional(), }).strict(), exposed: z.object({ at: z.string().min(1), @@ -245,16 +261,24 @@ const transitionAcknowledgement = ( } }; +/** Strips the reservation when `reservationKey` is absent or matches the holder's key. */ +const withoutReservation = (notice: AgentNotice, reservationKey: string | undefined): AgentNotice => { + if (notice.availabilityReservation === undefined) return notice; + if (reservationKey !== undefined && notice.availabilityReservation.key !== reservationKey) return notice; + const { availabilityReservation: _released, ...rest } = notice; + return Object.freeze(rest); +}; + const transitionAvailability = ( notice: AgentNotice, - input: { readonly at: string; readonly noticeIds: ReadonlySet }, + input: { readonly at: string; readonly noticeIds: ReadonlySet; readonly reservationKey: string | undefined }, ): AgentNotice => { if (!input.noticeIds.has(notice.id)) return notice; switch (notice.state) { case 'pending': case 'attempted': return Object.freeze({ - ...notice, + ...withoutReservation(notice, input.reservationKey), availability: Object.freeze({ channel: 'mcp-resource-updated' as const, count: (notice.availability?.count ?? 0) + 1, @@ -274,6 +298,36 @@ const transitionAvailability = ( } }; +/** + * Holds a budget slot without spending it. A live notice may carry one + * reservation; a newer reserver replaces an older one outright because the + * ledger cannot tell abandoned from slow, so the signaller decides staleness + * by `at` against the reservation TTL before it ever reserves. + */ +const transitionAvailabilityReservation = ( + notice: AgentNotice, + input: { readonly at: string; readonly noticeIds: ReadonlySet; readonly reservationKey: string }, +): AgentNotice => { + if (!input.noticeIds.has(notice.id)) return notice; + switch (notice.state) { + case 'pending': + case 'attempted': + return Object.freeze({ + ...notice, + availabilityReservation: Object.freeze({ at: input.at, key: input.reservationKey }), + }); + case 'expired': + case 'unavailable': + case 'withdrawn': + case 'acknowledged': + return notice; + default: { + const exhaustive: never = notice.state; + return exhaustive; + } + } +}; + const transitionAdmission = ( notice: AgentNotice, input: { @@ -425,9 +479,28 @@ export const agentNoticeStateDefinition = ( notices: state.notices.map((notice) => transitionAvailability(notice, { at: event.payload.at, noticeIds, + reservationKey: event.payload.reservationKey, })), }; } + case 'availability-reserved': { + const noticeIds = new Set(event.payload.noticeIds); + return { + notices: state.notices.map((notice) => transitionAvailabilityReservation(notice, { + at: event.payload.at, + noticeIds, + reservationKey: event.payload.reservationKey, + })), + }; + } + case 'availability-released': { + const noticeIds = new Set(event.payload.noticeIds); + return { + notices: state.notices.map((notice) => noticeIds.has(notice.id) + ? withoutReservation(notice, event.payload.reservationKey) + : notice), + }; + } default: { const exhaustive: never = event; return exhaustive; diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 213d4e4cd..027c930bb 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -858,6 +858,71 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); await driver.close(); }); + + it('holds a budget slot with a reservation that finalizes into a receipt or releases without one', async () => { + const { driver, ledger } = await openLedger(); + const published = await publishTo(ledger); + const id = published.notice.id; + const revision = (await ledger.read()).revision; + const reserved = await ledger.reserveAvailability({ + at: '2026-09-01T19:04:00.000Z', + expectedRevision: revision, + idempotencyKey: 'reserve:a', + noticeIds: [id], + reservationKey: 'holder-a:1', + }); + // Reserving spends nothing and is guarded by the same compare-and-swap. + expect(reserved.notices[0]).toMatchObject({ availabilityReservation: { at: '2026-09-01T19:04:00.000Z', key: 'holder-a:1' } }); + expect(reserved.notices[0]).not.toHaveProperty('availability'); + await expect(ledger.reserveAvailability({ + at: '2026-09-01T19:04:01.000Z', + expectedRevision: revision, + idempotencyKey: 'reserve:b', + noticeIds: [id], + reservationKey: 'holder-b:1', + })).rejects.toMatchObject({ code: 'revision-conflict' }); + + // Releasing with another holder's key leaves the hold intact; the owner's key clears it. + const foreignRelease = await ledger.releaseAvailability({ + idempotencyKey: 'release:b', + noticeIds: [id], + reservationKey: 'holder-b:1', + }); + expect(foreignRelease.notices[0]?.availabilityReservation).toMatchObject({ key: 'holder-a:1' }); + const released = await ledger.releaseAvailability({ + idempotencyKey: 'release:a', + noticeIds: [id], + reservationKey: 'holder-a:1', + }); + expect(released.notices[0]).not.toHaveProperty('availabilityReservation'); + expect(released.notices[0]).not.toHaveProperty('availability'); + + // A successful send finalizes: the receipt lands and the hold is cleared together. + await ledger.reserveAvailability({ + at: '2026-09-01T19:05:00.000Z', + idempotencyKey: 'reserve:c', + noticeIds: [id], + reservationKey: 'holder-c:1', + }); + const signalled = await ledger.signalAvailability({ + at: '2026-09-01T19:05:00.000Z', + idempotencyKey: 'signal:c', + noticeIds: [id], + reservationKey: 'holder-c:1', + }); + expect(signalled.notices[0]?.availability).toMatchObject({ count: 1, firstAt: '2026-09-01T19:05:00.000Z' }); + expect(signalled.notices[0]).not.toHaveProperty('availabilityReservation'); + + for (const invalid of [ + () => ledger.reserveAvailability({ at: 'never', idempotencyKey: 'x', noticeIds: [id], reservationKey: 'k' }), + () => ledger.reserveAvailability({ at: '2026-09-01T19:06:00.000Z', idempotencyKey: 'y', noticeIds: [], reservationKey: 'k' }), + () => ledger.reserveAvailability({ at: '2026-09-01T19:06:00.000Z', idempotencyKey: 'z', noticeIds: [id], reservationKey: ' ' }), + () => ledger.releaseAvailability({ idempotencyKey: 'w', noticeIds: [id], reservationKey: '' }), + ]) { + await expect(invalid()).rejects.toMatchObject({ code: 'invalid-input' }); + } + await driver.close(); + }); }); describe('notice delivery route selection', () => { diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index fd468ae84..fbae523f8 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@rstest/core'; import { + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, AGENT_NOTICE_INBOX_URI, agentNoticeStateDefinition, createAgentNoticeLedger, @@ -108,7 +109,8 @@ describe('notice inbox resources/updated signaller', () => { const published = await publish(ledger, { sessionId: 's1' }); const first = await signaller.observe(send); - expect(first).toEqual({ kind: 'signalled', noticeIds: [published.notice.id], revision: 2 }); + // Two commits per signal: the reservation, then the receipt after the send. + expect(first).toEqual({ kind: 'signalled', noticeIds: [published.notice.id], revision: 3 }); expect(sends).toHaveLength(1); const afterSignal = (await ledger.read()).notices.find((notice) => notice.id === published.notice.id); @@ -364,27 +366,70 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); - it('claims the durable budget before the wire write, so a failed send never frees a second signal', async () => { + it('records availability only after the wire write succeeds and releases a failed send', async () => { const { driver, ledger } = await openLedger(); const signaller = signallerOver(ledger); await signaller.subscribe(principal('s1')); await publish(ledger, { sessionId: 's1' }); let sends = 0; + let transportUp = false; const send = async (): Promise => { sends += 1; - throw new Error('transport closed'); + // The slot is held, not spent, while the write is in flight. + const held = (await ledger.read()).notices[0]; + expect(held).not.toHaveProperty('availability'); + expect(held?.availabilityReservation).toMatchObject({ at: T1 }); + if (!transportUp) throw new Error('transport closed'); }; await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'failed', stage: 'send' }); expect(sends).toBe(1); - // The receipt records the attempt this connection made; the notice stays - // pending and readable through the inbox, and the default budget is spent. - expect((await ledger.read()).notices[0]).toMatchObject({ availability: { count: 1 }, state: 'pending' }); + // A failed protocol write is not availability: no receipt, no budget spent, + // and the reservation is released so the notice is eligible again. + const afterFailure = (await ledger.read()).notices[0]; + expect(afterFailure).toMatchObject({ state: 'pending' }); + expect(afterFailure).not.toHaveProperty('availability'); + expect(afterFailure).not.toHaveProperty('availabilityReservation'); + + transportUp = true; + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); + expect(sends).toBe(2); + const afterSuccess = (await ledger.read()).notices[0]; + expect(afterSuccess).toMatchObject({ availability: { channel: 'mcp-resource-updated', count: 1, firstAt: T1, lastAt: T1 } }); + expect(afterSuccess).not.toHaveProperty('availabilityReservation'); + // The default budget of one is now spent, durably, across restarts. await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); const restarted = signallerOver(ledger); await restarted.subscribe(principal('s1')); await expect(restarted.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); - expect(sends).toBe(1); + expect(sends).toBe(2); + await driver.close(); + }); + + it('treats a reservation as held until its TTL elapses, then as abandoned', async () => { + const { driver, ledger } = await openLedger(); + const published = await publish(ledger, { sessionId: 's1' }); + // A holder that reserved and then crashed before finalizing or releasing. + await ledger.reserveAvailability({ + at: T1, + idempotencyKey: 'reserve:crashed', + noticeIds: [published.notice.id], + reservationKey: 'crashed-holder:1', + }); + const { send, sends } = sender(); + const withinTtl = signallerOver(ledger, () => new Date(Date.parse(T1) + 1_000)); + await withinTtl.subscribe(principal('s1')); + await expect(withinTtl.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toEqual([]); + + const afterTtl = signallerOver(ledger, () => new Date(Date.parse(T1) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS)); + await afterTtl.subscribe(principal('s1')); + await expect(afterTtl.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); + expect(sends).toHaveLength(1); + const notice = (await ledger.read()).notices[0]; + expect(notice?.availability).toMatchObject({ count: 1 }); + // The stale hold was superseded and cleared by the successful signal. + expect(notice).not.toHaveProperty('availabilityReservation'); await driver.close(); }); @@ -393,9 +438,9 @@ describe('notice inbox resources/updated signaller', () => { await publish(ledger, { sessionId: 's1' }); const { send, sends } = sender(); - // Process B reads the ledger before process A claims, then stalls until + // Process B reads the ledger before process A reserves, then stalls until // A has committed: its compare-and-swap must lose and its re-read must - // find the budget already spent. + // find the slot spent (A's receipt) or still held (A's reservation). let releaseB: () => void = () => undefined; const aDone = new Promise((resolve) => { releaseB = resolve; @@ -463,6 +508,8 @@ describe('notice inbox resources/updated signaller', () => { expire: reject, openRequest: reject, read: reject, + releaseAvailability: reject, + reserveAvailability: reject, signalAvailability: reject, withdraw: reject, }); From 76e4aea7a9e2481e24ad45d2a773a2ab35326307 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:13:28 +0000 Subject: [PATCH 05/18] fix(notices): keep wire-successful receipts owed and renew the hold during pending sends Codex P2 x2 on #376: - A send that succeeded but whose signalAvailability() commit failed was deduplicated only in memory, so a restarted signaller could resend it once the abandoned hold lapsed. Owed receipts are now retried with the same idempotency key before any later observation spends, and on close(). - A protocol write pending longer than the 30s TTL let another process take the hold and send too. The holder now renews under its key while send() is pending; the reducer refuses a foreign key while a hold is live and refuses a lapsed holder's late renewal once another key has taken over. --- .changeset/notice-inbox-resource-updated.md | 2 +- packages/rsc-runtime/README.md | 9 +- .../src/notices/resource-updated.ts | 137 ++++++++++++++++-- packages/rsc-runtime/src/notices/state.ts | 30 ++-- .../rsc-runtime/tests/notices-ledger.test.ts | 41 ++++++ .../tests/notices-resource-updated.test.ts | 137 ++++++++++++++++++ 6 files changed, 330 insertions(+), 26 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index 465842b8c..0004e5d67 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts. The ledger gains `reserveAvailability()` / `releaseAvailability()` (and `AgentNotice.availabilityReservation`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`): the signaller holds a notice's budget slot by compare-and-swap before the wire write so concurrent processes cannot both send, finalizes the hold into the receipt only when the protocol write succeeds, and releases it when the write fails, so a failed send costs no budget and the receipt always means the write succeeded; `unsubscribe()` resolves only after in-flight observations settle; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. +Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts. The ledger gains `reserveAvailability()` / `releaseAvailability()` (and `AgentNotice.availabilityReservation`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`): the signaller holds a notice's budget slot by compare-and-swap before the wire write so concurrent processes cannot both send, finalizes the hold into the receipt only when the protocol write succeeds, and releases it when the write fails, so a failed send costs no budget and the receipt always means the write succeeded. The hold is renewed while the write is pending (a different key may take it over only after the TTL, and a lapsed holder cannot steal it back), and a receipt whose commit failed after a successful send is retried idempotently before later observations and on `close()`; `unsubscribe()` resolves only after in-flight observations settle; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index aaa52760d..5db29cd2e 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -272,10 +272,15 @@ protocol write failed, so the receipt only ever means the write succeeded and a failed send costs no budget. Eligibility is recipient-matched against the subscriber's observed identity, respects `nextAttemptAt`, skips notices whose slot another signaller currently holds (a hold older than -`AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS` counts as abandoned), and is +`AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS` counts as abandoned; a live +holder renews it under its key while its write is pending, and the reducer +refuses a renewal once another key has legitimately taken over), and is bounded by `retryBudget` (availability receipts per notice, durable across restarts); because the slot is held before the wire write, two server processes -over one store can never both signal the same notice. Exposure and availability +over one store can never both signal the same notice. A send that reached the +wire but whose receipt commit failed stays owed: the same idempotent receipt is +retried before any later observation spends (and on `close()`), so a restarted +process cannot resend a notice the wire already carried. Exposure and availability receipts never re-trigger a signal, so a subscribed client cannot be driven into a refetch loop. Subscribing fails closed when the store is unreadable, `unsubscribe()` resolves only after in-flight observations settle, and only the diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index b1fdbea71..4b3039e51 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -28,6 +28,12 @@ export interface AgentNoticeInboxStore { export interface CreateNoticeInboxSignallerOptions { /** Clock injection for deterministic tests. */ readonly now?: () => Date; + /** + * How often a hold is renewed while its `send()` is still pending. Defaults + * to a third of `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS` so a live + * holder never lapses; only a holder whose process is gone does. + */ + readonly reservationRenewalIntervalMs?: number; readonly store: AgentNoticeInboxStore; } @@ -59,11 +65,14 @@ export type AgentNoticeInboxSignalOutcome = export interface AgentNoticeInboxSignaller { readonly inboxUri: typeof AGENT_NOTICE_INBOX_URI; readonly subscribed: boolean; + /** Commits any receipt still owed for a send that reached the wire, then closes the store. */ close(): Promise; /** - * Runs after one completed render: reads the ledger and, when the - * subscriber has newly eligible pending notices, sends exactly one - * `resources/updated` through `send` and records the availability receipt. + * Runs after one completed render: first commits any receipt still owed from + * an earlier send, then reads the ledger and, when the subscriber has newly + * eligible pending notices, holds their budget slot, sends exactly one + * `resources/updated` through `send` (renewing the hold while the write is + * pending), and records the availability receipt once the write succeeded. * Never throws; failures are returned so the render path stays unaffected. */ observe(send: () => Promise): Promise; @@ -89,6 +98,13 @@ interface InboxSubscription { readonly signalled: Set; } +/** A send that succeeded on the wire whose availability receipt has not been committed yet. */ +interface PendingReceipt { + readonly at: string; + readonly noticeIds: readonly string[]; + readonly reservationKey: string; +} + const eligibleForSignal = ( notice: AgentNotice, principal: AgentNoticePrincipal, @@ -127,8 +143,16 @@ export const createNoticeInboxSignaller = ( options: CreateNoticeInboxSignallerOptions, ): AgentNoticeInboxSignaller => { const now = options.now ?? ((): Date => new Date()); + const renewalIntervalMs = options.reservationRenewalIntervalMs + ?? Math.floor(AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS / 3); let subscription: InboxSubscription | undefined; let signalSequence = 0; + // Sends that reached the wire but whose receipt commit failed. The send is + // a fact, so the receipt is retried with the same idempotency key on every + // later observation (and on close) until it lands; the memory dedupe alone + // would otherwise let a restarted signaller spend the same slot again once + // the abandoned hold lapsed. + const pendingReceipts = new Map(); // Observations and subscription changes serialize on one queue: two renders // completing together cannot both select the same notice and send two // signals for one revision, and an unsubscribe (or re-subscribe) that @@ -210,9 +234,74 @@ export const createNoticeInboxSignaller = ( }; }; + /** Commits the receipt for one wire-successful send; idempotent across retries. */ + const commitReceipt = ( + ledger: AgentNoticeLedger, + receipt: PendingReceipt, + ): Promise>> => ledger.signalAvailability({ + at: receipt.at, + idempotencyKey: `agent-notices:availability:signal:${receipt.reservationKey}`, + noticeIds: receipt.noticeIds, + reservationKey: receipt.reservationKey, + }); + + /** Retries every outstanding receipt; the first failure is returned so the caller reports it. */ + const drainPendingReceipts = async (ledger: AgentNoticeLedger): Promise => { + for (const receipt of [...pendingReceipts.values()]) { + try { + await commitReceipt(ledger, receipt); + pendingReceipts.delete(receipt.reservationKey); + } catch (error) { + return error; + } + } + return undefined; + }; + + /** + * Keeps a hold alive while its protocol write is pending. A write that + * outlives the TTL would otherwise let another process treat the hold as + * abandoned and send too; renewing under the same key refreshes `at`, and + * the reducer refuses a renewal once a different key has legitimately taken + * over, so a holder that could not renew for a whole TTL never steals back. + * The timer exists only for the duration of one in-flight send. + */ + const renewWhile = async ( + ledger: AgentNoticeLedger, + hold: { readonly noticeIds: readonly string[]; readonly reservationKey: string }, + pending: Promise, + ): Promise => { + let renewals = 0; + let stopped = false; + let timer: ReturnType | undefined; + const tick = (): void => { + if (stopped) return; + renewals += 1; + const renewal = renewals; + void ledger.reserveAvailability({ + at: now().toISOString(), + idempotencyKey: `agent-notices:availability:renew:${hold.reservationKey}:${String(renewal)}`, + noticeIds: hold.noticeIds, + reservationKey: hold.reservationKey, + }).catch(() => undefined).then(() => { + if (stopped) return; + timer = setTimeout(tick, renewalIntervalMs); + timer.unref?.(); + }); + }; + timer = setTimeout(tick, renewalIntervalMs); + timer.unref?.(); + try { + return await pending; + } finally { + stopped = true; + if (timer !== undefined) clearTimeout(timer); + } + }; + const observeOnce = async (send: () => Promise): Promise => { const current = subscription; - if (current === undefined) { + if (current === undefined && pendingReceipts.size === 0) { return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: undefined }); } let ledger: AgentNoticeLedger; @@ -221,7 +310,13 @@ export const createNoticeInboxSignaller = ( } catch (error) { return Object.freeze({ error, kind: 'failed' as const, stage: 'read' as const }); } - if (pendingUnsubscribes > 0) { + // Receipts owed from earlier sends come first: they are facts about the + // wire, and a ledger that cannot take them is not one to spend against. + const owed = await drainPendingReceipts(ledger); + if (owed !== undefined) { + return Object.freeze({ error: owed, kind: 'failed' as const, stage: 'record' as const }); + } + if (current === undefined || pendingUnsubscribes > 0) { return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: undefined }); } const claimed = await claim(ledger, current); @@ -245,7 +340,7 @@ export const createNoticeInboxSignaller = ( // send finalizes the hold into the availability receipt. Only the receipt // means the protocol write succeeded. try { - await send(); + await renewWhile(ledger, claimed, send()); } catch (error) { try { await ledger.releaseAvailability({ @@ -259,14 +354,18 @@ export const createNoticeInboxSignaller = ( } return Object.freeze({ error, kind: 'failed' as const, stage: 'send' as const }); } + // The wire write succeeded: this subscription never sends for these notices + // again, and the receipt is owed until it commits. for (const id of claimed.noticeIds) current.signalled.add(id); + const receipt: PendingReceipt = Object.freeze({ + at: claimed.at, + noticeIds: claimed.noticeIds, + reservationKey: claimed.reservationKey, + }); + pendingReceipts.set(receipt.reservationKey, receipt); try { - const committed = await ledger.signalAvailability({ - at: claimed.at, - idempotencyKey: `agent-notices:availability:signal:${claimed.reservationKey}`, - noticeIds: claimed.noticeIds, - reservationKey: claimed.reservationKey, - }); + const committed = await commitReceipt(ledger, receipt); + pendingReceipts.delete(receipt.reservationKey); return Object.freeze({ kind: 'signalled', noticeIds: claimed.noticeIds, revision: committed.revision }); } catch (error) { return Object.freeze({ error, kind: 'failed' as const, stage: 'record' as const }); @@ -279,8 +378,18 @@ export const createNoticeInboxSignaller = ( return subscription !== undefined; }, close(): Promise { - subscription = undefined; - return options.store.close(); + return serialized(async () => { + subscription = undefined; + if (pendingReceipts.size > 0) { + try { + await drainPendingReceipts(await options.store.noticeLedger()); + } catch { + // A receipt still owed at close is lost with the process; the hold + // it left behind lapses after the TTL. + } + } + await options.store.close(); + }); }, observe(send: () => Promise): Promise { return serialized(() => observeOnce(send)); diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index 57a978430..d6f7c9df6 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -11,10 +11,11 @@ import type { AgentStateLifetime, } from '../state/contract.js'; import { canonicalJson, defineState } from '../state/index.js'; -import type { - AgentNotice, - AgentNoticePrincipal, - AgentRecipient, +import { + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, + type AgentNotice, + type AgentNoticePrincipal, + type AgentRecipient, } from './contract.js'; const observed = (value: T) => z.discriminatedUnion('state', [ @@ -299,10 +300,12 @@ const transitionAvailability = ( }; /** - * Holds a budget slot without spending it. A live notice may carry one - * reservation; a newer reserver replaces an older one outright because the - * ledger cannot tell abandoned from slow, so the signaller decides staleness - * by `at` against the reservation TTL before it ever reserves. + * Holds a budget slot without spending it. A live notice carries at most one + * reservation: the holder renews it by reserving again under the same key, + * and a different key takes it over only once the current hold is older than + * the reservation TTL at the event's own `at` — a rule the reducer can apply + * deterministically on replay, so a slow holder's renewal can never steal a + * hold back from the process that legitimately took over after it lapsed. */ const transitionAvailabilityReservation = ( notice: AgentNotice, @@ -311,11 +314,20 @@ const transitionAvailabilityReservation = ( if (!input.noticeIds.has(notice.id)) return notice; switch (notice.state) { case 'pending': - case 'attempted': + case 'attempted': { + const held = notice.availabilityReservation; + if ( + held !== undefined + && held.key !== input.reservationKey + && Date.parse(held.at) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS > Date.parse(input.at) + ) { + return notice; + } return Object.freeze({ ...notice, availabilityReservation: Object.freeze({ at: input.at, key: input.reservationKey }), }); + } 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 027c930bb..a1c92a234 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@rstest/core'; import { + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, AGENT_NOTICE_STATES, AgentNoticeError, selectNoticeDeliveryRoutes, @@ -882,6 +883,46 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { reservationKey: 'holder-b:1', })).rejects.toMatchObject({ code: 'revision-conflict' }); + // A live hold is not overwritten by a different key even without a + // compare-and-swap; the holder's own key renews it; a lapsed hold is taken. + const contested = await ledger.reserveAvailability({ + at: '2026-09-01T19:04:02.000Z', + idempotencyKey: 'reserve:b-uncontended', + noticeIds: [id], + reservationKey: 'holder-b:1', + }); + expect(contested.notices[0]?.availabilityReservation).toEqual({ at: '2026-09-01T19:04:00.000Z', key: 'holder-a:1' }); + const renewed = await ledger.reserveAvailability({ + at: '2026-09-01T19:04:10.000Z', + idempotencyKey: 'renew:a:1', + noticeIds: [id], + reservationKey: 'holder-a:1', + }); + expect(renewed.notices[0]?.availabilityReservation).toEqual({ at: '2026-09-01T19:04:10.000Z', key: 'holder-a:1' }); + const lapsedAt = new Date(Date.parse('2026-09-01T19:04:10.000Z') + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS).toISOString(); + const takenOver = await ledger.reserveAvailability({ + at: lapsedAt, + idempotencyKey: 'reserve:b-after-lapse', + noticeIds: [id], + reservationKey: 'holder-b:1', + }); + expect(takenOver.notices[0]?.availabilityReservation).toEqual({ at: lapsedAt, key: 'holder-b:1' }); + // The lapsed holder's late renewal cannot steal the hold back. + const lateRenewal = await ledger.reserveAvailability({ + at: new Date(Date.parse(lapsedAt) + 1_000).toISOString(), + idempotencyKey: 'renew:a:2', + noticeIds: [id], + reservationKey: 'holder-a:1', + }); + expect(lateRenewal.notices[0]?.availabilityReservation).toEqual({ at: lapsedAt, key: 'holder-b:1' }); + await ledger.releaseAvailability({ idempotencyKey: 'release:b-takeover', noticeIds: [id], reservationKey: 'holder-b:1' }); + await ledger.reserveAvailability({ + at: '2026-09-01T19:04:00.000Z', + idempotencyKey: 'reserve:a-again', + noticeIds: [id], + reservationKey: 'holder-a:1', + }); + // Releasing with another holder's key leaves the hold intact; the owner's key clears it. const foreignRelease = await ledger.releaseAvailability({ idempotencyKey: 'release:b', diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index fbae523f8..a23e86adf 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -406,6 +406,143 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); + it('keeps a wire-successful send owed until its receipt commits, so a restart cannot resend it', async () => { + const { driver, ledger } = await openLedger(); + let receiptOutage = true; + const flaky: AgentNoticeLedger = Object.freeze({ + ...ledger, + signalAvailability: async (input) => { + if (receiptOutage) throw new AgentStateError('unavailable', 'receipt commit lost'); + return ledger.signalAvailability(input); + }, + }); + const signaller = signallerOver(flaky); + await signaller.subscribe(principal('s1')); + const published = await publish(ledger, { sessionId: 's1' }); + const { send, sends } = sender(); + + // The wire write succeeded; only the second-phase receipt failed. + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'failed', stage: 'record' }); + expect(sends).toHaveLength(1); + const owed = (await ledger.read()).notices[0]; + expect(owed).not.toHaveProperty('availability'); + expect(owed?.availabilityReservation).toMatchObject({ at: T1 }); + + // While the receipt is owed the signaller neither resends nor spends anew: + // the retry of the same idempotent receipt comes first, and its failure is + // the reported outcome. + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'failed', stage: 'record' }); + expect(sends).toHaveLength(1); + + // Once the ledger takes writes again the owed receipt lands before anything + // else, with the original send time, and the slot is finally spent. + receiptOutage = false; + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(1); + const settled = (await ledger.read()).notices[0]; + expect(settled?.availability).toMatchObject({ count: 1, firstAt: T1, lastAt: T1 }); + expect(settled).not.toHaveProperty('availabilityReservation'); + + // A restarted process long after the hold would have lapsed finds the + // budget spent rather than a pending notice with a stale hold. + const restarted = signallerOver(ledger, () => new Date(Date.parse(T1) + 2 * AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS)); + await restarted.subscribe(principal('s1')); + await expect(restarted.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(1); + expect(published.notice.id).toBe(settled?.id); + await driver.close(); + }); + + it('commits an owed receipt on close instead of losing it with the process', async () => { + const { driver, ledger } = await openLedger(); + let receiptOutage = true; + const flaky: AgentNoticeLedger = Object.freeze({ + ...ledger, + signalAvailability: async (input) => { + if (receiptOutage) throw new AgentStateError('unavailable', 'receipt commit lost'); + return ledger.signalAvailability(input); + }, + }); + let closed = 0; + const signaller = createNoticeInboxSignaller({ + now: () => new Date(T1), + store: { + close: async () => { + closed += 1; + }, + noticeLedger: async () => flaky, + }, + }); + await signaller.subscribe(principal('s1')); + await publish(ledger, { sessionId: 's1' }); + const { send, sends } = sender(); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'failed', stage: 'record' }); + expect(sends).toHaveLength(1); + + receiptOutage = false; + await signaller.close(); + expect(closed).toBe(1); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + await driver.close(); + }); + + it('renews the hold while a protocol write is pending so no other process treats it as abandoned', async () => { + const { driver, ledger } = await openLedger(); + let clock = Date.parse(T1); + const signaller = createNoticeInboxSignaller({ + now: () => new Date(clock), + reservationRenewalIntervalMs: 5, + store: { close: async () => undefined, noticeLedger: async () => ledger }, + }); + await signaller.subscribe(principal('s1')); + const published = await publish(ledger, { sessionId: 's1' }); + + let finishSend: () => void = () => undefined; + const sendSettled = new Promise((resolve) => { + finishSend = resolve; + }); + const sends: string[] = []; + const slowSend = async (): Promise => { + sends.push(AGENT_NOTICE_INBOX_URI); + await sendSettled; + }; + const observing = signaller.observe(slowSend); + + // The write is still in flight when the wall clock passes the TTL, yet the + // hold's `at` keeps moving with it, so a second process sees a live hold. + const heldAt = async (): Promise => + (await ledger.read()).notices[0]?.availabilityReservation?.at; + const reservedAt = await (async () => { + for (let i = 0; i < 200 && await heldAt() === undefined; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + return heldAt(); + })(); + expect(reservedAt).toBe(T1); + clock = Date.parse(T1) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS + 1_000; + for (let i = 0; i < 200 && await heldAt() === T1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(await heldAt()).toBe(new Date(clock).toISOString()); + + const other = signallerOver(ledger, () => new Date(clock)); + await other.subscribe(principal('s1')); + await expect(other.observe(async () => { + sends.push('other'); + })).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + + finishSend(); + await expect(observing).resolves.toMatchObject({ kind: 'signalled', noticeIds: [published.notice.id] }); + expect(sends).toEqual([AGENT_NOTICE_INBOX_URI]); + const settled = (await ledger.read()).notices[0]; + expect(settled?.availability).toMatchObject({ count: 1 }); + expect(settled).not.toHaveProperty('availabilityReservation'); + // Renewal stops with the send: no further hold appears afterwards. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect((await ledger.read()).notices[0]).not.toHaveProperty('availabilityReservation'); + await driver.close(); + }); + it('treats a reservation as held until its TTL elapses, then as abandoned', async () => { const { driver, ledger } = await openLedger(); const published = await publish(ledger, { sessionId: 's1' }); From 37f1568226ad25fb638b772e31127d5209ab3304 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:32:05 +0000 Subject: [PATCH 06/18] fix(notices): refuse receipts from lost holds and retry owed receipts on their own cadence Codex P2 x2 on #376: - signalAvailability({ reservationKey }) records a receipt only on notices that key still holds; a fresh commit that recorded nothing rejects with the new AgentNoticeError code 'reservation-lost'. A stale holder whose send completes after a takeover therefore cannot push a budget-one notice to count 2. Idempotent replays are recognised by revision and never misreported as lost. - Owed receipts are retried on the renewal cadence by a timer that also renews their hold, so a server that receives no further render past the TTL neither lapses nor loses a wire-successful send. A lost hold drops the owed receipt and is reported once. --- .changeset/notice-inbox-resource-updated.md | 2 +- packages/rsc-runtime/README.md | 13 +- packages/rsc-runtime/src/notices/contract.ts | 10 +- packages/rsc-runtime/src/notices/ledger.ts | 18 +++ .../src/notices/resource-updated.ts | 76 +++++++++-- packages/rsc-runtime/src/notices/state.ts | 6 + .../rsc-runtime/tests/notices-ledger.test.ts | 30 +++++ .../tests/notices-resource-updated.test.ts | 118 ++++++++++++++++++ 8 files changed, 258 insertions(+), 15 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index 0004e5d67..81c2d2fda 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts. The ledger gains `reserveAvailability()` / `releaseAvailability()` (and `AgentNotice.availabilityReservation`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`): the signaller holds a notice's budget slot by compare-and-swap before the wire write so concurrent processes cannot both send, finalizes the hold into the receipt only when the protocol write succeeds, and releases it when the write fails, so a failed send costs no budget and the receipt always means the write succeeded. The hold is renewed while the write is pending (a different key may take it over only after the TTL, and a lapsed holder cannot steal it back), and a receipt whose commit failed after a successful send is retried idempotently before later observations and on `close()`; `unsubscribe()` resolves only after in-flight observations settle; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. +Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts. The ledger gains `reserveAvailability()` / `releaseAvailability()` (and `AgentNotice.availabilityReservation`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`): the signaller holds a notice's budget slot by compare-and-swap before the wire write so concurrent processes cannot both send, finalizes the hold into the receipt only when the protocol write succeeds, and releases it when the write fails, so a failed send costs no budget and the receipt always means the write succeeded. The hold is renewed while the write is pending (a different key may take it over only after the TTL, and a lapsed holder cannot steal it back), a reserved receipt is recorded only by the key that still holds the slot (otherwise `signalAvailability()` rejects with the new `AgentNoticeError` code `reservation-lost`), and a receipt whose commit failed after a successful send is retried idempotently on the renewal cadence, before later observations, and on `close()`; `unsubscribe()` resolves only after in-flight observations settle; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 5db29cd2e..346fbde75 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -277,10 +277,15 @@ holder renews it under its key while its write is pending, and the reducer refuses a renewal once another key has legitimately taken over), and is bounded by `retryBudget` (availability receipts per notice, durable across restarts); because the slot is held before the wire write, two server processes -over one store can never both signal the same notice. A send that reached the -wire but whose receipt commit failed stays owed: the same idempotent receipt is -retried before any later observation spends (and on `close()`), so a restarted -process cannot resend a notice the wire already carried. Exposure and availability +over one store can never both signal the same notice; a receipt presented by a +key that lost its hold is refused (`reservation-lost`) rather than counted, so +a stale send and a takeover send can never push a budget-one notice to two. A +send that reached the wire but whose receipt commit failed stays owed: the same +idempotent receipt is retried on the renewal cadence (renewing its hold as it +goes), before any later observation spends, and on `close()`, so a live process +cannot lose a send the wire already carried; only a process that dies while the +ledger is refusing writes leaves an unrecorded send, and its hold then lapses +after the TTL. Exposure and availability receipts never re-trigger a signal, so a subscribed client cannot be driven into a refetch loop. Subscribing fails closed when the store is unreadable, `unsubscribe()` resolves only after in-flight observations settle, and only the diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index a63fa6061..39756c3de 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -205,7 +205,13 @@ export interface AgentNoticeAvailabilitySignalOptions { readonly expectedRevision?: number; readonly idempotencyKey: string; readonly noticeIds: readonly string[]; - /** The reservation this signal finalizes; the reservation is cleared with the receipt. */ + /** + * The reservation this signal finalizes. The receipt is recorded only on + * notices this key still holds and the hold is cleared with it; a key that + * lost the hold (another signaller took over after the TTL) records nothing + * and the call rejects with `reservation-lost`, so two holders can never + * both spend one budget slot. + */ readonly reservationKey?: string; } @@ -242,6 +248,8 @@ export type AgentNoticeErrorCode = | 'aborted' | 'invalid-input' | 'request-closed' + /** A reserved availability receipt was refused because the reservation key no longer holds the slot. */ + | 'reservation-lost' | 'unauthorized'; export class AgentNoticeError extends Error { diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index e8bab6165..82223f150 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -530,6 +530,7 @@ export const createAgentNoticeLedger = ( ? undefined : nonEmptyText(options.reservationKey, 'Notice availability reservation key')); const expectedRevision = yield* noticeEffect(() => availabilityExpectedRevision(options.expectedRevision)); + const before = reservationKey === undefined ? undefined : yield* storeEffect(() => store.read()); const committed = yield* storeEffect(() => store.dispatch( 'availability-signalled', { @@ -540,6 +541,23 @@ export const createAgentNoticeLedger = ( }, { ...(expectedRevision === undefined ? {} : { expectedRevision }), idempotencyKey }, )); + // A fresh commit (not an idempotent replay, whose revision predates the + // head just read) records nothing on a live notice this key no longer + // held; the reducer refused it, and the caller must learn that. + if (before !== undefined && committed.revision > before.revision) { + const lost = before.state.notices.filter((notice) => + noticeIds.includes(notice.id) + && (notice.state === 'pending' || notice.state === 'attempted') + && notice.availabilityReservation?.key !== reservationKey); + if (lost.length > 0) { + return yield* Effect.fail(new AgentNoticeError( + 'reservation-lost', + `Notice availability reservation ${JSON.stringify(reservationKey)} no longer holds ${ + lost.map((notice) => notice.id).join(', ') + }; no receipt was recorded for it`, + )); + } + } return snapshotFrom(committed.revision, committed.state); })); }, diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index 4b3039e51..7ad748232 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -148,11 +148,15 @@ export const createNoticeInboxSignaller = ( let subscription: InboxSubscription | undefined; let signalSequence = 0; // Sends that reached the wire but whose receipt commit failed. The send is - // a fact, so the receipt is retried with the same idempotency key on every - // later observation (and on close) until it lands; the memory dedupe alone - // would otherwise let a restarted signaller spend the same slot again once - // the abandoned hold lapsed. + // a fact, so the receipt is retried with the same idempotency key — on an + // independent timer that also renews the hold, before every later + // observation, and on close — until it lands or the hold is lost. The memory + // dedupe alone would otherwise let a restarted signaller spend the same slot + // again once the abandoned hold lapsed. const pendingReceipts = new Map(); + let receiptRetryTimer: ReturnType | undefined; + let renewalSequence = 0; + let closed = false; // Observations and subscription changes serialize on one queue: two renders // completing together cannot both select the same notice and send two // signals for one revision, and an unsubscribe (or re-subscribe) that @@ -245,19 +249,64 @@ export const createNoticeInboxSignaller = ( reservationKey: receipt.reservationKey, }); - /** Retries every outstanding receipt; the first failure is returned so the caller reports it. */ + const isReservationLost = (error: unknown): boolean => + error instanceof AgentNoticeError && error.code === 'reservation-lost'; + + /** + * Retries every owed receipt, renewing its hold first so the slot stays ours + * while the ledger recovers. A receipt whose hold was lost (the ledger could + * not be reached for a whole TTL and another signaller took over) can never + * be recorded — the takeover's send is the one the budget counts — so it is + * dropped and reported once. The first failure is returned so the caller + * reports it. + */ const drainPendingReceipts = async (ledger: AgentNoticeLedger): Promise => { for (const receipt of [...pendingReceipts.values()]) { + renewalSequence += 1; + try { + await ledger.reserveAvailability({ + at: now().toISOString(), + idempotencyKey: `agent-notices:availability:renew:${receipt.reservationKey}:owed:${String(renewalSequence)}`, + noticeIds: receipt.noticeIds, + reservationKey: receipt.reservationKey, + }); + } catch { + // The commit below is the write that matters; a failed renewal only + // shortens how long the hold survives a longer outage. + } try { await commitReceipt(ledger, receipt); pendingReceipts.delete(receipt.reservationKey); } catch (error) { + if (isReservationLost(error)) pendingReceipts.delete(receipt.reservationKey); return error; } } return undefined; }; + /** + * Keeps retrying owed receipts on the renewal cadence, independently of + * renders: a server that receives no further render past the TTL would + * otherwise let its hold lapse while a wire-successful send stayed unrecorded. + * The timer exists only while a receipt is owed. + */ + const scheduleReceiptRetry = (): void => { + if (closed || receiptRetryTimer !== undefined || pendingReceipts.size === 0) return; + receiptRetryTimer = setTimeout(() => { + receiptRetryTimer = undefined; + void serialized(async () => { + if (closed || pendingReceipts.size === 0) return; + try { + await drainPendingReceipts(await options.store.noticeLedger()); + } catch { + // Retried on the next tick. + } + }).finally(scheduleReceiptRetry); + }, renewalIntervalMs); + receiptRetryTimer.unref?.(); + }; + /** * Keeps a hold alive while its protocol write is pending. A write that * outlives the TTL would otherwise let another process treat the hold as @@ -271,16 +320,14 @@ export const createNoticeInboxSignaller = ( hold: { readonly noticeIds: readonly string[]; readonly reservationKey: string }, pending: Promise, ): Promise => { - let renewals = 0; let stopped = false; let timer: ReturnType | undefined; const tick = (): void => { if (stopped) return; - renewals += 1; - const renewal = renewals; + renewalSequence += 1; void ledger.reserveAvailability({ at: now().toISOString(), - idempotencyKey: `agent-notices:availability:renew:${hold.reservationKey}:${String(renewal)}`, + idempotencyKey: `agent-notices:availability:renew:${hold.reservationKey}:${String(renewalSequence)}`, noticeIds: hold.noticeIds, reservationKey: hold.reservationKey, }).catch(() => undefined).then(() => { @@ -314,6 +361,7 @@ export const createNoticeInboxSignaller = ( // wire, and a ledger that cannot take them is not one to spend against. const owed = await drainPendingReceipts(ledger); if (owed !== undefined) { + scheduleReceiptRetry(); return Object.freeze({ error: owed, kind: 'failed' as const, stage: 'record' as const }); } if (current === undefined || pendingUnsubscribes > 0) { @@ -368,6 +416,11 @@ export const createNoticeInboxSignaller = ( pendingReceipts.delete(receipt.reservationKey); return Object.freeze({ kind: 'signalled', noticeIds: claimed.noticeIds, revision: committed.revision }); } catch (error) { + if (isReservationLost(error)) { + pendingReceipts.delete(receipt.reservationKey); + } else { + scheduleReceiptRetry(); + } return Object.freeze({ error, kind: 'failed' as const, stage: 'record' as const }); } }; @@ -378,6 +431,11 @@ export const createNoticeInboxSignaller = ( return subscription !== undefined; }, close(): Promise { + closed = true; + if (receiptRetryTimer !== undefined) { + clearTimeout(receiptRetryTimer); + receiptRetryTimer = undefined; + } return serialized(async () => { subscription = undefined; if (pendingReceipts.size > 0) { diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index d6f7c9df6..c3f06e92f 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -278,6 +278,12 @@ const transitionAvailability = ( switch (notice.state) { case 'pending': case 'attempted': + // A reserved receipt is honoured only by the key that holds the slot: a + // holder whose hold lapsed and was taken over records nothing, so the + // takeover's send is the one the budget counts. + if (input.reservationKey !== undefined && notice.availabilityReservation?.key !== input.reservationKey) { + return notice; + } return Object.freeze({ ...withoutReservation(notice, input.reservationKey), availability: Object.freeze({ diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index a1c92a234..3229c13ab 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -945,6 +945,17 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { noticeIds: [id], reservationKey: 'holder-c:1', }); + // A key that does not hold the slot records nothing and is told so; the + // hold and the budget are untouched. + await expect(ledger.signalAvailability({ + at: '2026-09-01T19:05:00.000Z', + idempotencyKey: 'signal:stale', + noticeIds: [id], + reservationKey: 'holder-a:1', + })).rejects.toMatchObject({ code: 'reservation-lost' }); + const afterStale = (await ledger.read()).notices[0]; + expect(afterStale).not.toHaveProperty('availability'); + expect(afterStale?.availabilityReservation).toMatchObject({ key: 'holder-c:1' }); const signalled = await ledger.signalAvailability({ at: '2026-09-01T19:05:00.000Z', idempotencyKey: 'signal:c', @@ -953,6 +964,25 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { }); expect(signalled.notices[0]?.availability).toMatchObject({ count: 1, firstAt: '2026-09-01T19:05:00.000Z' }); expect(signalled.notices[0]).not.toHaveProperty('availabilityReservation'); + // Replaying the committed receipt (same key, same payload) is not a lost + // hold: the idempotent result comes back and nothing is counted twice. + const replayed = await ledger.signalAvailability({ + at: '2026-09-01T19:05:00.000Z', + idempotencyKey: 'signal:c', + noticeIds: [id], + reservationKey: 'holder-c:1', + }); + expect(replayed.revision).toBe(signalled.revision); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + // A late receipt from the holder that lost the slot is refused even once + // the hold is gone: only the send the ledger authorized is counted. + await expect(ledger.signalAvailability({ + at: '2026-09-01T19:05:01.000Z', + idempotencyKey: 'signal:stale-after', + noticeIds: [id], + reservationKey: 'holder-a:1', + })).rejects.toMatchObject({ code: 'reservation-lost' }); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); for (const invalid of [ () => ledger.reserveAvailability({ at: 'never', idempotencyKey: 'x', noticeIds: [id], reservationKey: 'k' }), diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index a23e86adf..d6c8fdda7 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -6,6 +6,7 @@ import { agentNoticeStateDefinition, createAgentNoticeLedger, createNoticeInboxSignaller, + type AgentNoticeError, type AgentNoticeLedger, type AgentNoticePrincipal, type AgentNoticePublishInput, @@ -453,6 +454,123 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); + it('retries an owed receipt on its own cadence, renewing the hold, without waiting for another render', async () => { + const { driver, ledger } = await openLedger(); + let receiptOutage = true; + let clock = Date.parse(T1); + const flaky: AgentNoticeLedger = Object.freeze({ + ...ledger, + signalAvailability: async (input) => { + if (receiptOutage) throw new AgentStateError('unavailable', 'receipt commit lost'); + return ledger.signalAvailability(input); + }, + }); + const signaller = createNoticeInboxSignaller({ + now: () => new Date(clock), + reservationRenewalIntervalMs: 5, + store: { close: async () => undefined, noticeLedger: async () => flaky }, + }); + await signaller.subscribe(principal('s1')); + await publish(ledger, { sessionId: 's1' }); + const { send, sends } = sender(); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'failed', stage: 'record' }); + expect(sends).toHaveLength(1); + + // While the ledger refuses the receipt, the retry loop keeps the hold + // renewed as the wall clock moves, so no other process treats it as + // abandoned even though no render arrives. + clock = Date.parse(T1) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS + 5_000; + const heldAt = async (): Promise => + (await ledger.read()).notices[0]?.availabilityReservation?.at; + for (let i = 0; i < 200 && await heldAt() === T1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(await heldAt()).toBe(new Date(clock).toISOString()); + const other = signallerOver(ledger, () => new Date(clock)); + await other.subscribe(principal('s1')); + await expect(other.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(1); + + // The ledger recovers: the receipt lands from the timer alone. + receiptOutage = false; + const availability = async () => (await ledger.read()).notices[0]?.availability; + for (let i = 0; i < 200 && await availability() === undefined; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(await availability()).toMatchObject({ count: 1, firstAt: T1 }); + expect((await ledger.read()).notices[0]).not.toHaveProperty('availabilityReservation'); + expect(sends).toHaveLength(1); + await signaller.close(); + await driver.close(); + }); + + it('refuses to record a receipt for a hold another process took over, so budget one stays one', async () => { + const { driver, ledger } = await openLedger(); + let clock = Date.parse(T1); + // Process A can reserve once but every renewal fails: the ledger is + // unreachable for A while its send hangs past the TTL. + let reservations = 0; + const partitioned: AgentNoticeLedger = Object.freeze({ + ...ledger, + reserveAvailability: async (input) => { + reservations += 1; + if (reservations > 1) throw new AgentStateError('unavailable', 'partitioned'); + return ledger.reserveAvailability(input); + }, + }); + const processA = createNoticeInboxSignaller({ + now: () => new Date(clock), + reservationRenewalIntervalMs: 5, + store: { close: async () => undefined, noticeLedger: async () => partitioned }, + }); + await processA.subscribe(principal('s1')); + const published = await publish(ledger, { sessionId: 's1' }); + + let finishSend: () => void = () => undefined; + const sendSettled = new Promise((resolve) => { + finishSend = resolve; + }); + const sends: string[] = []; + const observingA = processA.observe(async () => { + sends.push('A'); + await sendSettled; + }); + const heldBy = async (): Promise => + (await ledger.read()).notices[0]?.availabilityReservation?.key; + for (let i = 0; i < 200 && await heldBy() === undefined; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + const keyA = await heldBy(); + expect(keyA).toBeDefined(); + + // A whole TTL passes with A unable to renew; process B takes the hold over + // and sends. + clock = Date.parse(T1) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS + 1_000; + const processB = signallerOver(ledger, () => new Date(clock)); + await processB.subscribe(principal('s1')); + await expect(processB.observe(async () => { + sends.push('B'); + })).resolves.toMatchObject({ kind: 'signalled', noticeIds: [published.notice.id] }); + expect(sends).toEqual(['A', 'B']); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + + // A's send finally completes. Its receipt is refused — B's hold (already + // finalized) is the one the budget counted — and A reports the loss rather + // than pushing the notice to count 2. + finishSend(); + const outcomeA = await observingA; + expect(outcomeA).toMatchObject({ kind: 'failed', stage: 'record' }); + expect((outcomeA as { error: AgentNoticeError }).error).toMatchObject({ code: 'reservation-lost' }); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + // Nothing is owed any more: a later observation neither retries nor resends. + await expect(processA.observe(async () => { + sends.push('A-again'); + })).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toEqual(['A', 'B']); + await processA.close(); + await driver.close(); + }); + it('commits an owed receipt on close instead of losing it with the process', async () => { const { driver, ledger } = await openLedger(); let receiptOutage = true; From 2e0cb710ef3b1f83dc655529470e8d591dce78de Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:44:17 +0000 Subject: [PATCH 07/18] fix(notices): judge a reserved receipt against the exact state it commits over Codex P2 on #376: the ownership check read the head, then dispatched without a guard, so a takeover between the two left a refused receipt reported as recorded. signalAvailability({ reservationKey }) now dispatches with expectedRevision set to the revision it judged, re-reading on revision-conflict a bounded number of times; an idempotent replay is recognised from the store's replayed flag, never by revision arithmetic. --- packages/rsc-runtime/src/notices/ledger.ts | 87 +++++++++++++------ .../rsc-runtime/tests/notices-ledger.test.ts | 53 +++++++++++ 2 files changed, 112 insertions(+), 28 deletions(-) diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 82223f150..dda510e68 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -11,7 +11,7 @@ import { toRuntimeError, } from '../effect/boundary.js'; import type { AgentStateStore } from '../state/contract.js'; -import { canonicalJson } from '../state/index.js'; +import { AgentStateError, canonicalJson } from '../state/index.js'; import { AgentNoticeError, type AgentNotice, @@ -47,6 +47,9 @@ export interface CreateAgentNoticeLedgerOptions { type NoticeStore = AgentStateStore; +/** Revision races tolerated while committing a reserved receipt over the state it was judged against. */ +const MAX_RESERVED_RECEIPT_ATTEMPTS = 8; + const noticeEffect = (evaluate: () => A): Effect.Effect => Effect.try({ catch: (error) => error, @@ -530,35 +533,63 @@ export const createAgentNoticeLedger = ( ? undefined : nonEmptyText(options.reservationKey, 'Notice availability reservation key')); const expectedRevision = yield* noticeEffect(() => availabilityExpectedRevision(options.expectedRevision)); - const before = reservationKey === undefined ? undefined : yield* storeEffect(() => store.read()); - const committed = yield* storeEffect(() => store.dispatch( - 'availability-signalled', - { - at, - channel: 'mcp-resource-updated', - noticeIds, - ...(reservationKey === undefined ? {} : { reservationKey }), - }, - { ...(expectedRevision === undefined ? {} : { expectedRevision }), idempotencyKey }, - )); - // A fresh commit (not an idempotent replay, whose revision predates the - // head just read) records nothing on a live notice this key no longer - // held; the reducer refused it, and the caller must learn that. - if (before !== undefined && committed.revision > before.revision) { - const lost = before.state.notices.filter((notice) => - noticeIds.includes(notice.id) - && (notice.state === 'pending' || notice.state === 'attempted') - && notice.availabilityReservation?.key !== reservationKey); - if (lost.length > 0) { - return yield* Effect.fail(new AgentNoticeError( - 'reservation-lost', - `Notice availability reservation ${JSON.stringify(reservationKey)} no longer holds ${ - lost.map((notice) => notice.id).join(', ') - }; no receipt was recorded for it`, - )); + const payload = { + at, + channel: 'mcp-resource-updated' as const, + noticeIds, + ...(reservationKey === undefined ? {} : { reservationKey }), + }; + if (reservationKey === undefined) { + const committed = yield* storeEffect(() => store.dispatch( + 'availability-signalled', + payload, + { ...(expectedRevision === undefined ? {} : { expectedRevision }), idempotencyKey }, + )); + return snapshotFrom(committed.revision, committed.state); + } + // A reserved receipt is judged against the exact state it commits over: + // the dispatch is guarded by the revision just read, so the ownership + // check and the reducer see the same holds. Unrelated writers move the + // revision too, hence the bounded re-read. An idempotent replay of an + // already-committed receipt short-circuits the guard inside the store + // and is never mistaken for a lost hold. + for (let attempt = 0; attempt < MAX_RESERVED_RECEIPT_ATTEMPTS; attempt += 1) { + const before = yield* storeEffect(() => store.read()); + if (expectedRevision !== undefined && expectedRevision !== before.revision) { + return yield* storeEffect(() => Promise.reject(new AgentStateError( + 'revision-conflict', + `Notice availability expected revision ${String(expectedRevision)} but the head is ${String(before.revision)}`, + ))); } + const committed = yield* storeEffect(() => store.dispatch( + 'availability-signalled', + payload, + { expectedRevision: before.revision, idempotencyKey }, + )).pipe(Effect.catch((error) => + error instanceof AgentStateError && error.code === 'revision-conflict' + ? Effect.succeed(undefined) + : Effect.fail(error))); + if (committed === undefined) continue; + if (!committed.replayed) { + const lost = before.state.notices.filter((notice) => + noticeIds.includes(notice.id) + && (notice.state === 'pending' || notice.state === 'attempted') + && notice.availabilityReservation?.key !== reservationKey); + if (lost.length > 0) { + return yield* Effect.fail(new AgentNoticeError( + 'reservation-lost', + `Notice availability reservation ${JSON.stringify(reservationKey)} no longer holds ${ + lost.map((notice) => notice.id).join(', ') + }; no receipt was recorded for it`, + )); + } + } + return snapshotFrom(committed.revision, committed.state); } - return snapshotFrom(committed.revision, committed.state); + return yield* storeEffect(() => Promise.reject(new AgentStateError( + 'revision-conflict', + `Notice availability receipt lost ${String(MAX_RESERVED_RECEIPT_ATTEMPTS)} consecutive revision races`, + ))); })); }, diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 3229c13ab..87399818f 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -984,6 +984,59 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { })).rejects.toMatchObject({ code: 'reservation-lost' }); expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + await driver.close(); + }); + + it('judges a reserved receipt against the exact state it commits over, even when the hold changes hands mid-call', async () => { + const { driver, ledger, store } = await openLedger(); + const published = await publishTo(ledger); + const id = published.notice.id; + await ledger.reserveAvailability({ + at: '2026-09-01T19:04:00.000Z', + idempotencyKey: 'reserve:a', + noticeIds: [id], + reservationKey: 'holder-a:1', + }); + // Between holder A's ownership read and its commit, holder B takes the + // lapsed slot over. The receipt must not be reported as recorded. + const lapsedAt = new Date(Date.parse('2026-09-01T19:04:00.000Z') + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS).toISOString(); + let interposed = false; + const originalRead = store.read.bind(store); + const racing = createAgentNoticeLedger(Object.freeze({ + ...store, + read: async (options?: Parameters[0]) => { + const snapshot = await originalRead(options); + if (!interposed) { + interposed = true; + await ledger.reserveAvailability({ + at: lapsedAt, + idempotencyKey: 'reserve:b-takeover', + noticeIds: [id], + reservationKey: 'holder-b:1', + }); + } + return snapshot; + }, + }) as typeof store, { authorize: () => ({ state: 'authorized' }) }); + await expect(racing.signalAvailability({ + at: lapsedAt, + idempotencyKey: 'signal:a-late', + noticeIds: [id], + reservationKey: 'holder-a:1', + })).rejects.toMatchObject({ code: 'reservation-lost' }); + const after = (await ledger.read()).notices[0]; + expect(after).not.toHaveProperty('availability'); + expect(after?.availabilityReservation).toEqual({ at: lapsedAt, key: 'holder-b:1' }); + // B's own receipt still lands normally. + const signalled = await ledger.signalAvailability({ + at: lapsedAt, + idempotencyKey: 'signal:b', + noticeIds: [id], + reservationKey: 'holder-b:1', + }); + expect(signalled.notices[0]?.availability).toMatchObject({ count: 1 }); + expect(signalled.notices[0]).not.toHaveProperty('availabilityReservation'); + for (const invalid of [ () => ledger.reserveAvailability({ at: 'never', idempotencyKey: 'x', noticeIds: [id], reservationKey: 'k' }), () => ledger.reserveAvailability({ at: '2026-09-01T19:06:00.000Z', idempotencyKey: 'y', noticeIds: [], reservationKey: 'k' }), From 08bf84554f4427e4f640db7489dfab86e709fbb0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:31:12 +0000 Subject: [PATCH 08/18] fix(notices): record a receipt on a notice that turned terminal while its send was in flight Codex P2 on #376: an acknowledgement (or expiry/withdrawal) landing between the wire send and receipt finalization left the notice out of the pending/attempted filter, so the reducer no-op'd and the ledger reported the receipt as recorded while nothing was written. Ownership now decides alone: transitionAvailability records the receipt for any state when the key still holds the slot (state unchanged), and the ledger judges reservation-lost by the hold, not by the state. --- .changeset/notice-inbox-resource-updated.md | 2 +- packages/rsc-runtime/README.md | 6 +- packages/rsc-runtime/src/notices/ledger.ts | 5 +- packages/rsc-runtime/src/notices/state.ts | 47 +++++------ .../rsc-runtime/tests/notices-ledger.test.ts | 80 +++++++++++++++++++ .../tests/notices-resource-updated.test.ts | 32 ++++++++ 6 files changed, 142 insertions(+), 30 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index 81c2d2fda..6c893220e 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts. The ledger gains `reserveAvailability()` / `releaseAvailability()` (and `AgentNotice.availabilityReservation`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`): the signaller holds a notice's budget slot by compare-and-swap before the wire write so concurrent processes cannot both send, finalizes the hold into the receipt only when the protocol write succeeds, and releases it when the write fails, so a failed send costs no budget and the receipt always means the write succeeded. The hold is renewed while the write is pending (a different key may take it over only after the TTL, and a lapsed holder cannot steal it back), a reserved receipt is recorded only by the key that still holds the slot (otherwise `signalAvailability()` rejects with the new `AgentNoticeError` code `reservation-lost`), and a receipt whose commit failed after a successful send is retried idempotently on the renewal cadence, before later observations, and on `close()`; `unsubscribe()` resolves only after in-flight observations settle; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. +Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts. The ledger gains `reserveAvailability()` / `releaseAvailability()` (and `AgentNotice.availabilityReservation`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`): the signaller holds a notice's budget slot by compare-and-swap before the wire write so concurrent processes cannot both send, finalizes the hold into the receipt only when the protocol write succeeds, and releases it when the write fails, so a failed send costs no budget and the receipt always means the write succeeded. The hold is renewed while the write is pending (a different key may take it over only after the TTL, and a lapsed holder cannot steal it back), a reserved receipt is recorded only by the key that still holds the slot (otherwise `signalAvailability()` rejects with the new `AgentNoticeError` code `reservation-lost`; a notice acknowledged, expired, or withdrawn while its send was in flight keeps the hold and records the receipt without changing state), and a receipt whose commit failed after a successful send is retried idempotently on the renewal cadence, before later observations, and on `close()`; `unsubscribe()` resolves only after in-flight observations settle; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 346fbde75..221f522fd 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -279,7 +279,11 @@ bounded by `retryBudget` (availability receipts per notice, durable across restarts); because the slot is held before the wire write, two server processes over one store can never both signal the same notice; a receipt presented by a key that lost its hold is refused (`reservation-lost`) rather than counted, so -a stale send and a takeover send can never push a budget-one notice to two. A +a stale send and a takeover send can never push a budget-one notice to two. +Ownership, not state, decides whether a receipt lands: a notice acknowledged, +expired, or withdrawn while its send was in flight still keeps the hold, so the +receipt for the send that happened is recorded on it without moving its state, +and a key that never held the slot is refused on a terminal notice too. A send that reached the wire but whose receipt commit failed stays owed: the same idempotent receipt is retried on the renewal cadence (renewing its hold as it goes), before any later observation spends, and on `close()`, so a live process diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index dda510e68..9d7cddbaf 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -571,9 +571,12 @@ export const createAgentNoticeLedger = ( : Effect.fail(error))); if (committed === undefined) continue; if (!committed.replayed) { + // Ownership, not state, decides: a notice acknowledged, expired, or + // withdrawn after the send still keeps its hold, and the receipt for + // the send that happened lands on it; only a hold held by another + // key (or no hold at all) refuses the receipt. const lost = before.state.notices.filter((notice) => noticeIds.includes(notice.id) - && (notice.state === 'pending' || notice.state === 'attempted') && notice.availabilityReservation?.key !== reservationKey); if (lost.length > 0) { return yield* Effect.fail(new AgentNoticeError( diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index c3f06e92f..b89058c02 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -270,39 +270,32 @@ const withoutReservation = (notice: AgentNotice, reservationKey: string | undefi return Object.freeze(rest); }; +/** + * Records a wire-level availability receipt. The receipt is evidence that a + * `resources/updated` signal reached the wire, so it is recorded whatever the + * notice's state became after the hold was taken: an acknowledgement, expiry, + * or withdrawal that raced the send does not erase the send, and the state + * itself is never moved by a receipt. A reserved receipt is honoured only by + * the key that holds the slot: a holder whose hold lapsed and was taken over + * records nothing, so the takeover's send is the one the budget counts. + */ const transitionAvailability = ( notice: AgentNotice, input: { readonly at: string; readonly noticeIds: ReadonlySet; readonly reservationKey: string | undefined }, ): AgentNotice => { if (!input.noticeIds.has(notice.id)) return notice; - switch (notice.state) { - case 'pending': - case 'attempted': - // A reserved receipt is honoured only by the key that holds the slot: a - // holder whose hold lapsed and was taken over records nothing, so the - // takeover's send is the one the budget counts. - if (input.reservationKey !== undefined && notice.availabilityReservation?.key !== input.reservationKey) { - return notice; - } - return Object.freeze({ - ...withoutReservation(notice, input.reservationKey), - availability: Object.freeze({ - channel: 'mcp-resource-updated' as const, - count: (notice.availability?.count ?? 0) + 1, - firstAt: notice.availability?.firstAt ?? input.at, - lastAt: input.at, - }), - }); - case 'expired': - case 'unavailable': - case 'withdrawn': - case 'acknowledged': - return notice; - default: { - const exhaustive: never = notice.state; - return exhaustive; - } + if (input.reservationKey !== undefined && notice.availabilityReservation?.key !== input.reservationKey) { + return notice; } + return Object.freeze({ + ...withoutReservation(notice, input.reservationKey), + availability: Object.freeze({ + channel: 'mcp-resource-updated' as const, + count: (notice.availability?.count ?? 0) + 1, + firstAt: notice.availability?.firstAt ?? input.at, + lastAt: input.at, + }), + }); }; /** diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 87399818f..d3193477d 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -1037,6 +1037,86 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { expect(signalled.notices[0]?.availability).toMatchObject({ count: 1 }); expect(signalled.notices[0]).not.toHaveProperty('availabilityReservation'); + await driver.close(); + }); + + it('records the receipt of a send that raced an acknowledgement instead of discarding it as a no-op', async () => { + const { driver, ledger } = await openLedger(); + const published = await publishTo(ledger); + const id = published.notice.id; + await ledger.reserveAvailability({ + at: '2026-09-01T19:04:00.000Z', + idempotencyKey: 'reserve:holder', + noticeIds: [id], + reservationKey: 'holder:1', + }); + // The wire send succeeds, and before its receipt is finalized the + // recipient acknowledges the notice through another request. + const acknowledged = await run(ledger, { + actorId: 'recipient', + id: 'ack-racing', + kind: 'event', + startedAt: '2026-09-01T19:04:01.000Z', + }, async () => (await agent()).notices!.acknowledge(id)); + expect(acknowledged.state).toBe('acknowledged'); + expect(acknowledged.availabilityReservation).toEqual({ at: '2026-09-01T19:04:00.000Z', key: 'holder:1' }); + + // The holder still owns the slot, so the receipt for the send that + // happened lands on the acknowledged notice without moving its state. + const signalled = await ledger.signalAvailability({ + at: '2026-09-01T19:04:02.000Z', + idempotencyKey: 'signal:holder', + noticeIds: [id], + reservationKey: 'holder:1', + }); + expect(signalled.notices[0]).toMatchObject({ + acknowledgement: { invocationId: 'ack-racing' }, + availability: { channel: 'mcp-resource-updated', count: 1, firstAt: '2026-09-01T19:04:02.000Z' }, + state: 'acknowledged', + }); + expect(signalled.notices[0]).not.toHaveProperty('availabilityReservation'); + + // A key that never held the slot is still refused on a terminal notice, + // so a stale holder cannot invent evidence after the fact. + await expect(ledger.signalAvailability({ + at: '2026-09-01T19:04:03.000Z', + idempotencyKey: 'signal:stranger', + noticeIds: [id], + reservationKey: 'stranger:1', + })).rejects.toMatchObject({ code: 'reservation-lost' }); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + + // Expiry after the hold behaves the same: the receipt records on the + // expired notice and the state stays expired. + const expiring = await run(ledger, { + actorId: 'publisher', + id: 'publish-expiring', + kind: 'tool', + startedAt: '2026-09-01T19:05:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('expiring'), + expiresAt: '2026-09-01T19:05:30.000Z', + priority: 'high', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:expiring' })); + await ledger.reserveAvailability({ + at: '2026-09-01T19:05:01.000Z', + idempotencyKey: 'reserve:expiring', + noticeIds: [expiring.notice.id], + reservationKey: 'holder:2', + }); + await ledger.expire({ at: '2026-09-01T19:05:31.000Z', idempotencyKey: 'expire:racing' }); + expect((await ledger.read()).notices.find((notice) => notice.id === expiring.notice.id)?.state).toBe('expired'); + const lateReceipt = await ledger.signalAvailability({ + at: '2026-09-01T19:06:00.000Z', + idempotencyKey: 'signal:expiring', + noticeIds: [expiring.notice.id], + reservationKey: 'holder:2', + }); + const expired = lateReceipt.notices.find((notice) => notice.id === expiring.notice.id); + expect(expired).toMatchObject({ availability: { count: 1 }, state: 'expired' }); + expect(expired).not.toHaveProperty('availabilityReservation'); + for (const invalid of [ () => ledger.reserveAvailability({ at: 'never', idempotencyKey: 'x', noticeIds: [id], reservationKey: 'k' }), () => ledger.reserveAvailability({ at: '2026-09-01T19:06:00.000Z', idempotencyKey: 'y', noticeIds: [], reservationKey: 'k' }), diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index d6c8fdda7..12cf74139 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -140,6 +140,38 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); + it('records the receipt of a send that an acknowledgement raced, so the evidence is never silently dropped', async () => { + const { driver, ledger } = await openLedger(); + const signaller = signallerOver(ledger); + await signaller.subscribe(principal('s1')); + const published = await publish(ledger, { sessionId: 's1' }); + + // The wire write succeeds, and while it is in flight the recipient + // acknowledges the notice through its own request. + const send = async (): Promise => { + await runAgentRequest({ + host: available({ name: 'claude' }, 'native'), + invocation: { id: 'ack-during-send', kind: 'tool', startedAt: T1 }, + noticeLedger: ledger, + session: available({ sessionId: 's1' }, 'native'), + workspace: available({ root: '/workspace' }, 'native'), + }, async () => (await agent()).notices!.acknowledge(published.notice.id)); + }; + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled', noticeIds: [published.notice.id] }); + + // The receipt landed on the acknowledged notice: the send is recorded as + // availability evidence, the state stays acknowledged, and nothing is owed. + const after = (await ledger.read()).notices.find((notice) => notice.id === published.notice.id); + expect(after).toMatchObject({ + acknowledgement: { invocationId: 'ack-during-send' }, + availability: { channel: 'mcp-resource-updated', count: 1, firstAt: T1 }, + state: 'acknowledged', + }); + expect(after).not.toHaveProperty('availabilityReservation'); + await expect(signaller.observe(sender().send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + await driver.close(); + }); + it('never signals a connection whose principal the recipient does not match', async () => { const { driver, ledger } = await openLedger(); const signaller = signallerOver(ledger); From 890807fcbc1eb6c79b6ba855e14451e508e0b7b9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:42:50 +0000 Subject: [PATCH 09/18] chore(changeset): bump @agent-bundle/runtime minor and write the consumer-facing summary for #376 --- .changeset/notice-inbox-resource-updated.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index 6c893220e..8f383fe20 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -1,6 +1,6 @@ --- -"@agent-bundle/runtime": patch +"@agent-bundle/runtime": minor "agent-bundle": patch --- -Wire the #99 stage-4 `mcp-resource-updated` delivery route into generated stateful MCP servers. `@agent-bundle/runtime/notices` gains `createNoticeInboxSignaller` — one connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`) that, after each completed render, sends at most one `notifications/resources/updated` for the subscriber's newly eligible pending notices and records it through `signalAvailability()` as an availability receipt (never delivery), honouring `nextAttemptAt` and bounding signals per notice by `retryBudget` across restarts. The ledger gains `reserveAvailability()` / `releaseAvailability()` (and `AgentNotice.availabilityReservation`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`): the signaller holds a notice's budget slot by compare-and-swap before the wire write so concurrent processes cannot both send, finalizes the hold into the receipt only when the protocol write succeeds, and releases it when the write fails, so a failed send costs no budget and the receipt always means the write succeeded. The hold is renewed while the write is pending (a different key may take it over only after the TTL, and a lapsed holder cannot steal it back), a reserved receipt is recorded only by the key that still holds the slot (otherwise `signalAvailability()` rejects with the new `AgentNoticeError` code `reservation-lost`; a notice acknowledged, expired, or withdrawn while its send was in flight keeps the hold and records the receipt without changing state), and a receipt whose commit failed after a successful send is retried idempotently on the renewal cadence, before later observations, and on `close()`; `unsubscribe()` resolves only after in-flight observations settle; `@agent-bundle/runtime/mount` gains `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()` so a server process can hold its own handle on the durable store its worker mounts. Generated workspace-durable MCP entries now register `resources/subscribe`/`resources/unsubscribe` for the inbox URI only, advertise `resources.subscribe` exactly when that wiring is active, fail subscriptions closed when the store is unreadable, and the inbox projection exposes the `availability` receipt alongside `exposure`. Volatile lifetimes keep the store in the worker's heap and advertise no subscription capability. +Generated MCP servers with a workspace-durable state lifetime now deliver notices over the `mcp-resource-updated` route: they accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Subscriptions fail closed when the store is unreadable; volatile lifetimes advertise no subscription capability. `@agent-bundle/runtime/notices` exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, and the `AgentNoticeError` code `reservation-lost`; `@agent-bundle/runtime/mount` exports `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()`. Breaking for implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field. (#376) From e200fbd5c4c3b4103ddc424d482fcdf218147cbf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:21:17 +0000 Subject: [PATCH 10/18] fix(notices): refuse to re-create a hold whose budget the takeover already spent Codex P2 on #376: after a lapsed holder's slot was taken over and the takeover's receipt cleared the hold, the old holder's renewal found an empty slot and re-created its reservation, letting its stale receipt push a budget-one notice to count 2. The reservation reducer now treats an empty (or lapsed foreign) slot whose availability count has reached retryBudget as not free for any key but the current holder. Also re-imports RenderRouteContext in the test harness after the rebase onto main. --- packages/agent-bundle/src/test/mcp.ts | 2 +- packages/rsc-runtime/README.md | 3 +- packages/rsc-runtime/src/notices/state.ts | 10 ++- .../rsc-runtime/tests/notices-ledger.test.ts | 59 ++++++++++++++++ .../tests/notices-resource-updated.test.ts | 68 +++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 72f0f0200..868f50527 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -30,7 +30,7 @@ import { composeLayouts, loadLayoutChain, type LoadedLayout } from './layouts.ts import { MCP_IN_MEMORY_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts'; import { claimProcessHit, mountProviders } from './providers.ts'; import { registeredRouteLoader, testManifest } from './registry.ts'; -import type { HarnessOptionsArguments, RenderRouteContextInit } from './render.ts'; +import type { HarnessOptionsArguments, RenderRouteContext, RenderRouteContextInit } from './render.ts'; import type { RenderedRouteProvenance, TestableRouteDescriptor } from './types.ts'; /** Where an in-memory projection result came from and what it proves. */ diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 221f522fd..e728a7b72 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -274,7 +274,8 @@ subscriber's observed identity, respects `nextAttemptAt`, skips notices whose slot another signaller currently holds (a hold older than `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS` counts as abandoned; a live holder renews it under its key while its write is pending, and the reducer -refuses a renewal once another key has legitimately taken over), and is +refuses a renewal once another key has legitimately taken over — or once the +takeover's receipt has spent the budget and cleared the hold), and is bounded by `retryBudget` (availability receipts per notice, durable across restarts); because the slot is held before the wire write, two server processes over one store can never both signal the same notice; a receipt presented by a diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index b89058c02..66fb07387 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -304,7 +304,8 @@ const transitionAvailability = ( * and a different key takes it over only once the current hold is older than * the reservation TTL at the event's own `at` — a rule the reducer can apply * deterministically on replay, so a slow holder's renewal can never steal a - * hold back from the process that legitimately took over after it lapsed. + * hold back from the process that legitimately took over after it lapsed — + * nor re-create a hold the takeover already spent. */ const transitionAvailabilityReservation = ( notice: AgentNotice, @@ -322,6 +323,13 @@ const transitionAvailabilityReservation = ( ) { return notice; } + // A slot whose budget is already spent is not free: once a takeover's + // receipt has cleared the hold, the previous holder's renewal must not + // re-create it and let its stale receipt push the count past the budget. + // Only the key that currently holds the slot may still renew. + if (held?.key !== input.reservationKey && (notice.availability?.count ?? 0) >= (notice.retryBudget ?? 1)) { + return notice; + } return Object.freeze({ ...notice, availabilityReservation: Object.freeze({ at: input.at, key: input.reservationKey }), diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index d3193477d..9f7346372 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -1040,6 +1040,65 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { await driver.close(); }); + it('refuses to re-create a hold the takeover already spent, so a lapsed holder cannot push a budget-one notice to two', async () => { + const { driver, ledger } = await openLedger(); + const published = await publishTo(ledger); + const id = published.notice.id; + const heldAt = '2026-09-01T19:04:00.000Z'; + const lapsedAt = new Date(Date.parse(heldAt) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS).toISOString(); + await ledger.reserveAvailability({ at: heldAt, idempotencyKey: 'reserve:a', noticeIds: [id], reservationKey: 'holder-a:1' }); + // A's lease lapses for a full TTL; B takes over and its receipt lands, + // spending the single budget slot and clearing the hold. + await ledger.reserveAvailability({ at: lapsedAt, idempotencyKey: 'reserve:b', noticeIds: [id], reservationKey: 'holder-b:1' }); + const spent = await ledger.signalAvailability({ at: lapsedAt, idempotencyKey: 'signal:b', noticeIds: [id], reservationKey: 'holder-b:1' }); + expect(spent.notices[0]).toMatchObject({ availability: { count: 1 }, state: 'pending' }); + expect(spent.notices[0]).not.toHaveProperty('availabilityReservation'); + + // A's late renewal (from its owed-receipt loop or a still-pending send) + // finds an empty slot whose budget is spent: no hold is re-created. + const renewedAt = new Date(Date.parse(lapsedAt) + 1_000).toISOString(); + const renewed = await ledger.reserveAvailability({ + at: renewedAt, + idempotencyKey: 'renew:a', + noticeIds: [id], + reservationKey: 'holder-a:1', + }); + expect(renewed.notices[0]).not.toHaveProperty('availabilityReservation'); + + // Its receipt is therefore refused, and the count stays at the budget. + await expect(ledger.signalAvailability({ + at: renewedAt, + idempotencyKey: 'signal:a-late', + noticeIds: [id], + reservationKey: 'holder-a:1', + })).rejects.toMatchObject({ code: 'reservation-lost' }); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + + // With budget left, a fresh hold on the empty slot is still allowed. + const roomy = await run(ledger, { + actorId: 'publisher', + id: 'publish-roomy', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('roomy'), + priority: 'high', + recipient: { actor: { id: 'recipient' } }, + retryBudget: 2, + }, { idempotencyKey: 'publish:roomy' })); + await ledger.reserveAvailability({ at: heldAt, idempotencyKey: 'reserve:c', noticeIds: [roomy.notice.id], reservationKey: 'holder-c:1' }); + await ledger.signalAvailability({ at: heldAt, idempotencyKey: 'signal:c', noticeIds: [roomy.notice.id], reservationKey: 'holder-c:1' }); + const second = await ledger.reserveAvailability({ + at: renewedAt, + idempotencyKey: 'reserve:d', + noticeIds: [roomy.notice.id], + reservationKey: 'holder-d:1', + }); + expect(second.notices.find((notice) => notice.id === roomy.notice.id)?.availabilityReservation) + .toEqual({ at: renewedAt, key: 'holder-d:1' }); + await driver.close(); + }); + it('records the receipt of a send that raced an acknowledgement instead of discarding it as a no-op', async () => { const { driver, ledger } = await openLedger(); const published = await publishTo(ledger); diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index 12cf74139..339c91252 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -603,6 +603,74 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); + it('cannot win a spent slot back by renewing after the takeover finalized, so budget one stays one', async () => { + const { driver, ledger } = await openLedger(); + let clock = Date.parse(T1); + // Process A reserves once, then is partitioned from the ledger for a full + // TTL while its send hangs; the partition lifts after B has spent the slot. + let partitioned = false; + let renewalsAfterPartition = 0; + const flaky: AgentNoticeLedger = Object.freeze({ + ...ledger, + reserveAvailability: async (input) => { + if (partitioned) throw new AgentStateError('unavailable', 'partitioned'); + if (input.idempotencyKey.includes(':renew:')) renewalsAfterPartition += 1; + return ledger.reserveAvailability(input); + }, + }); + const processA = createNoticeInboxSignaller({ + now: () => new Date(clock), + reservationRenewalIntervalMs: 5, + store: { close: async () => undefined, noticeLedger: async () => flaky }, + }); + await processA.subscribe(principal('s1')); + const published = await publish(ledger, { sessionId: 's1' }); + + let finishSend: () => void = () => undefined; + const sendSettled = new Promise((resolve) => { + finishSend = resolve; + }); + const sends: string[] = []; + const heldBy = async (): Promise => + (await ledger.read()).notices[0]?.availabilityReservation?.key; + const observingA = processA.observe(async () => { + sends.push('A'); + partitioned = true; + await sendSettled; + }); + for (let i = 0; i < 200 && await heldBy() === undefined; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(await heldBy()).toBeDefined(); + + clock = Date.parse(T1) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS + 1_000; + const processB = signallerOver(ledger, () => new Date(clock)); + await processB.subscribe(principal('s1')); + await expect(processB.observe(async () => { + sends.push('B'); + })).resolves.toMatchObject({ kind: 'signalled', noticeIds: [published.notice.id] }); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + expect(await heldBy()).toBeUndefined(); + + // The partition lifts: A's renewals now reach the ledger and find an empty + // slot whose budget is spent. None of them re-creates a hold. + partitioned = false; + for (let i = 0; i < 200 && renewalsAfterPartition < 2; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(renewalsAfterPartition).toBeGreaterThanOrEqual(2); + expect(await heldBy()).toBeUndefined(); + + finishSend(); + const outcomeA = await observingA; + expect(outcomeA).toMatchObject({ kind: 'failed', stage: 'record' }); + expect((outcomeA as { error: AgentNoticeError }).error).toMatchObject({ code: 'reservation-lost' }); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + expect(sends).toEqual(['A', 'B']); + await processA.close(); + await driver.close(); + }); + it('commits an owed receipt on close instead of losing it with the process', async () => { const { driver, ledger } = await openLedger(); let receiptOutage = true; From f6595a69de29ab38cf239fe3404316b1580c695a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:57:22 +0000 Subject: [PATCH 11/18] fix(notices): keep tracking on a repeated subscribe and drain owed receipts before the host closes Codex P2s on #376: a client repeating resources/subscribe without unsubscribing replaced the subscription and cleared its signalled set, so a retryBudget > 1 notice was re-sent to a connection that never lapsed; and the generated server closed the host (which owns or shares the durable store) before the signaller's final drain, so a receipt owed for a wire-successful send failed against a closed ledger. subscribe() is now idempotent for the same principal, and the signaller closes before the host. --- .../agent-bundle/src/mcp-server-runtime.ts | 7 +- .../tests/mcp-server-runtime.test.ts | 67 ++++++++++++++++++- .../src/notices/resource-updated.ts | 16 ++++- .../tests/notices-resource-updated.test.ts | 20 +++++- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 06a94c70c..2636b91ad 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -727,10 +727,13 @@ export const createGeneratedRouteMcpServer = async ( const close = server.close.bind(server); server.close = async (): Promise => { await events?.close(); + // The signaller drains any receipt still owed for a send that reached the + // wire, so it must close while the ledger it commits to is still open: + // the host owns (or shares) that store and closes after it. try { - await options.host.close(); - } finally { await options.notices?.close(); + } finally { + await options.host.close(); } await close(); }; diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index a8c6b4fed..90bcdc4ee 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from '@rstest/core'; import { z } from 'zod'; -import { advertisedOutputSchema } from '../src/mcp-server-runtime.ts'; +import { + advertisedOutputSchema, + createGeneratedRouteMcpServer, + type GeneratedNoticeDeliveryBinding, + type GeneratedRouteExecutionHost, +} from '../src/mcp-server-runtime.ts'; /** * The MCP specification requires every result of a tool that declares @@ -38,3 +43,63 @@ describe('advertisedOutputSchema', () => { expect(advertisedOutputSchema(opaque)).toBe(opaque); }); }); + +describe('generated server teardown', () => { + const stubs = (noticesClose: () => Promise) => { + const order: string[] = []; + const host: GeneratedRouteExecutionHost = { + availability: () => ({ state: 'available' }) as never, + close: async () => { + order.push('host'); + }, + execute: async () => { + throw new Error('not rendered'); + }, + identity: { artifactEpoch: 'epoch', instanceId: 'test' } as never, + markUnavailable: () => undefined, + }; + const notices: GeneratedNoticeDeliveryBinding = { + inboxUri: 'agent-bundle://notices/inbox', + subscribed: false, + close: async () => { + order.push('notices'); + await noticesClose(); + }, + observe: async () => ({ kind: 'idle', reason: 'no-subscription', revision: undefined }), + subscribe: async () => undefined, + unsubscribe: async () => undefined, + }; + return { host, notices, order }; + }; + + it('closes the notice signaller before the host that owns the ledger it drains into', async () => { + // A receipt still owed for a wire-successful send is committed by the + // signaller's close; the host closes the (shared) store, so it must go + // second or the drain fails against a closed ledger and the send is lost. + const { host, notices, order } = stubs(async () => undefined); + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + host, + notices, + plugin: { name: 'teardown', version: '0.0.0' }, + routes: {}, + }); + await server.close(); + expect(order).toEqual(['notices', 'host']); + }); + + it('still closes the host when the signaller close fails', async () => { + const { host, notices, order } = stubs(async () => { + throw new Error('drain failed'); + }); + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + host, + notices, + plugin: { name: 'teardown', version: '0.0.0' }, + routes: {}, + }); + await expect(server.close()).rejects.toThrow('drain failed'); + expect(order).toEqual(['notices', 'host']); + }); +}); diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index 7ad748232..513337a51 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { AgentStateError } from '../state/index.js'; +import { AgentStateError, canonicalJson } from '../state/index.js'; import { AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, AgentNoticeError, @@ -80,7 +80,10 @@ export interface AgentNoticeInboxSignaller { * Records the connection as the inbox subscriber for `principal`. Fails * closed: the durable store must be readable before a subscription exists, * so an unavailable store yields a rejected subscribe instead of a - * subscription that could never be honoured. + * subscription that could never be honoured. Repeating the call for the + * same principal while subscribed is idempotent and keeps what has already + * been signalled; tracking resets only after `unsubscribe()` or when the + * principal changes. */ subscribe(principal: AgentNoticePrincipal): Promise; /** @@ -456,6 +459,15 @@ export const createNoticeInboxSignaller = ( return serialized(async () => { const ledger = await options.store.noticeLedger(); await ledger.read(); + // A client that repeats resources/subscribe without unsubscribing is + // still the same continuously subscribed connection: keeping its + // signalled set means a notice with retryBudget > 1 is not re-sent + // (and its budget not spent again) for a subscription that never + // lapsed. Tracking resets only after a completed unsubscribe or when + // the connection's observed identity actually changed. + if (subscription !== undefined && canonicalJson(subscription.principal) === canonicalJson(principal)) { + return; + } subscription = Object.freeze({ id: randomUUID(), principal, diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index 339c91252..c4322e160 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -288,8 +288,24 @@ describe('notice inbox resources/updated signaller', () => { await signaller.subscribe(principal('s1')); await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); - // Same connection re-subscribing is a new subscription: the durable budget - // (two) still has one signal left, and the in-memory dedupe restarts. + // A client repeating resources/subscribe without unsubscribing is the same + // continuously subscribed connection: what was already signalled stays + // signalled, so the notice's second budget slot is not spent on it. + await signaller.subscribe(principal('s1')); + expect(signaller.subscribed).toBe(true); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(1); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + + // Subscribing under a different observed identity is a new subscription + // (and, being another principal, matches its own recipients only). + await signaller.subscribe(principal('s2')); + await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + expect(sends).toHaveLength(1); + + // Only a completed unsubscribe followed by a subscribe restarts the + // in-memory dedupe; the durable budget (two) then has one signal left. + await signaller.unsubscribe(); await signaller.subscribe(principal('s1')); await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'signalled' }); await expect(signaller.observe(send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); From 314f73df39263e2c929f71233eb7f4a3adccb016 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 11:35:13 +0000 Subject: [PATCH 12/18] fix(notices): move the notice ledger to schema version 2 with an in-place migration Codex P1 on #376: version 1 journaled availability-signalled over a terminal notice as a no-op, and the version-2 reducer now records it, so replaying a version-1 journal disagreed with its materialized head and the sqlite driver rejected the store as corrupt. agentNoticeStateDefinition is now version 2 (AGENT_NOTICE_STATE_VERSION) with an identity migration: a version-1 store is rebased on its head on first open, keeping every notice. --- .changeset/notice-inbox-resource-updated.md | 2 +- packages/rsc-runtime/README.md | 6 +- packages/rsc-runtime/src/notices/index.ts | 1 + packages/rsc-runtime/src/notices/state.ts | 16 +++ .../rsc-runtime/tests/notices-ledger.test.ts | 118 +++++++++++++++++- 5 files changed, 140 insertions(+), 3 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index 8f383fe20..0966a6cf0 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Generated MCP servers with a workspace-durable state lifetime now deliver notices over the `mcp-resource-updated` route: they accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Subscriptions fail closed when the store is unreadable; volatile lifetimes advertise no subscription capability. `@agent-bundle/runtime/notices` exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, and the `AgentNoticeError` code `reservation-lost`; `@agent-bundle/runtime/mount` exports `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()`. Breaking for implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field. (#376) +Generated MCP servers with a workspace-durable state lifetime now deliver notices over the `mcp-resource-updated` route: they accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Subscriptions fail closed when the store is unreadable; volatile lifetimes advertise no subscription capability. `@agent-bundle/runtime/notices` exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, and the `AgentNoticeError` code `reservation-lost`; `@agent-bundle/runtime/mount` exports `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()`. Breaking for implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field. The notice ledger state definition moves to schema version 2 (`AGENT_NOTICE_STATE_VERSION`); existing workspace-durable notice stores are migrated in place on first open, with no data loss. (#376) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index e728a7b72..81ecdef63 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -284,7 +284,11 @@ a stale send and a takeover send can never push a budget-one notice to two. Ownership, not state, decides whether a receipt lands: a notice acknowledged, expired, or withdrawn while its send was in flight still keeps the hold, so the receipt for the send that happened is recorded on it without moving its state, -and a key that never held the slot is refused on a terminal notice too. A +and a key that never held the slot is refused on a terminal notice too. These +reducer semantics are schema version 2 of the notice ledger +(`AGENT_NOTICE_STATE_VERSION`); a workspace-durable store journaled under +version 1 is migrated from its materialized head on first open rather than +replayed by a reducer that would disagree with it. A send that reached the wire but whose receipt commit failed stays owed: the same idempotent receipt is retried on the renewal cadence (renewing its hold as it goes), before any later observation spends, and on `close()`, so a live process diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index 6e3f18082..d85bdec3b 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -69,6 +69,7 @@ export type { CreateNoticeInboxSignallerOptions, } from './resource-updated.js'; export { + AGENT_NOTICE_STATE_VERSION, agentNoticeEventSchemas, agentNoticeStateDefinition, recipientMatchesPrincipal, diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index 66fb07387..2f74b0269 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -116,6 +116,14 @@ export interface AgentNoticeLedgerState { readonly notices: readonly AgentNotice[]; } +/** + * Schema version of the notice ledger definition. Bumped whenever the reducer + * changes what an already-journaled event means, so a durable store written + * under the previous version is migrated from its materialized head instead + * of being replayed by a reducer that would disagree with it. + */ +export const AGENT_NOTICE_STATE_VERSION = 2; + export const agentNoticeEventSchemas = { acknowledged: z.object({ at: z.string().min(1), @@ -445,6 +453,13 @@ export const agentNoticeStateDefinition = ( id: '@agent-bundle/runtime/agent-notice-ledger/v1', initial: { notices: [] }, lifetime, + // Version 2 changes replay semantics, not shape: `availability-signalled` + // now records a receipt on a notice in any state (version 1 ignored + // terminal notices) and reservations carry keys, TTLs, and budget checks. + // Journals written under version 1 therefore cannot be replayed by this + // reducer; the migration rebases them on their materialized head, which + // already satisfies the version 2 schema unchanged. + migrations: { 2: (persisted) => persisted }, reduce: (state, event) => { switch (event.name) { case 'published': { @@ -529,4 +544,5 @@ export const agentNoticeStateDefinition = ( schema: z.object({ notices: z.array(noticeSchema).readonly(), }).strict().readonly(), + version: AGENT_NOTICE_STATE_VERSION, }); diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 9f7346372..4ad8de45a 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -1,7 +1,12 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it } from '@rstest/core'; import { AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, + AGENT_NOTICE_STATE_VERSION, AGENT_NOTICE_STATES, AgentNoticeError, selectNoticeDeliveryRoutes, @@ -15,7 +20,8 @@ import { available, runAgentRequest, } from '../src/index.js'; -import { createMemoryStateDriver } from '../src/state/index.js'; +import { createMemoryStateDriver, defineState } from '../src/state/index.js'; +import { createSqliteStateDriver } from '../src/state/sqlite.js'; const document = (text: string) => ({ root: { kind: 'text' as const, text }, @@ -1277,3 +1283,113 @@ describe('stage-4 review findings regressions', () => { await driver.close(); }); }); + +describe('notice ledger schema version', () => { + /** + * The version-1 reducer as PR #361 shipped it for the one event whose + * meaning version 2 changes: `availability-signalled` was a no-op for any + * notice no longer pending or attempted. Reservations did not exist yet. + */ + const legacyDefinition = () => { + const current = agentNoticeStateDefinition('workspace-durable'); + return defineState({ + events: current.events, + id: current.id, + initial: current.initial, + lifetime: current.lifetime, + reduce: (state, event) => { + if (event.name !== 'availability-signalled') return current.reduce(state, event); + const live = new Set(state.notices + .filter((notice) => notice.state === 'pending' || notice.state === 'attempted') + .map((notice) => notice.id)); + return current.reduce(state, { + ...event, + payload: { ...event.payload, noticeIds: event.payload.noticeIds.filter((id) => live.has(id)) }, + } as typeof event); + }, + schema: current.schema, + version: 1, + }); + }; + + it('migrates a version-1 journal whose replay the version-2 reducer would contradict', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-notice-ledger-v1-')); + try { + expect(AGENT_NOTICE_STATE_VERSION).toBe(2); + // A version-1 store: publish, acknowledge, then a receipt over the + // acknowledged notice that version 1 journaled as a no-op. + const legacyDriver = createSqliteStateDriver({ root }); + const legacyStore = await legacyDriver.open(legacyDefinition()); + const legacy = createAgentNoticeLedger(legacyStore, { authorize: () => ({ state: 'authorized' }) }); + const published = await run(legacy, { + actorId: 'publisher', + id: 'publish-legacy', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('legacy'), + priority: 'high', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:legacy' })); + await run(legacy, { + actorId: 'recipient', + id: 'ack-legacy', + kind: 'event', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.acknowledge(published.notice.id)); + const noop = await legacy.signalAvailability({ + at: '2026-09-01T19:02:00.000Z', + idempotencyKey: 'signal:legacy', + noticeIds: [published.notice.id], + }); + expect(noop.notices[0]).toMatchObject({ state: 'acknowledged' }); + expect(noop.notices[0]).not.toHaveProperty('availability'); + await legacyDriver.close(); + + // The same journal replayed by the version-2 reducer disagrees with the + // materialized head, so without a version bump the store is corrupt. + const unversioned = createSqliteStateDriver({ root }); + await expect(unversioned.open(defineState({ + ...agentNoticeStateDefinition('workspace-durable'), + migrations: {}, + version: 1, + }))).rejects.toMatchObject({ code: 'corrupt' }); + await unversioned.close(); + + // Under version 2 the store migrates from its head instead: the + // acknowledged notice is intact, still without a receipt, and the + // ledger keeps working with the new semantics. + const driver = createSqliteStateDriver({ root }); + const store = await driver.open(agentNoticeStateDefinition('workspace-durable')); + const ledger = createAgentNoticeLedger(store, { authorize: () => ({ state: 'authorized' }) }); + const migrated = await ledger.read(); + expect(migrated.notices).toHaveLength(1); + expect(migrated.notices[0]).toMatchObject({ id: published.notice.id, state: 'acknowledged' }); + expect(migrated.notices[0]).not.toHaveProperty('availability'); + await ledger.reserveAvailability({ + at: '2026-09-01T19:03:00.000Z', + idempotencyKey: 'reserve:v2', + noticeIds: [published.notice.id], + reservationKey: 'holder:1', + }); + // A pre-migration terminal notice takes no new hold (budget rules apply + // to pending/attempted only), so a keyed receipt is refused — and an + // unreserved receipt now records on it, as version 2 defines. + await expect(ledger.signalAvailability({ + at: '2026-09-01T19:03:01.000Z', + idempotencyKey: 'signal:v2-keyed', + noticeIds: [published.notice.id], + reservationKey: 'holder:1', + })).rejects.toMatchObject({ code: 'reservation-lost' }); + const recorded = await ledger.signalAvailability({ + at: '2026-09-01T19:03:02.000Z', + idempotencyKey: 'signal:v2-unreserved', + noticeIds: [published.notice.id], + }); + expect(recorded.notices[0]).toMatchObject({ availability: { count: 1 }, state: 'acknowledged' }); + await driver.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); From c134d5ea28423f2beb4c986e8673b1a7d83b769e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:06:24 +0000 Subject: [PATCH 13/18] fix(mcp): detach the inbox observation from render completion; imperative changeset Codex P2 on #376: settled() awaited the inbox observation in finally, so a subscriber whose resources/updated write wedged (and whose hold is renewed for as long as it takes) held every completed tool, resource, prompt, and event response hostage. The observation is now scheduled at render completion and not awaited; the signaller serializes it and never rejects. Tests poll for the notification instead of sleeping. Codex P1: changeset summary rewritten in imperative form. --- .changeset/notice-inbox-resource-updated.md | 2 +- .../agent-bundle/src/mcp-server-runtime.ts | 16 ++- .../tests/generated-route-server.test.ts | 10 +- .../tests/mcp-server-runtime.test.ts | 109 +++++++++++++----- .../tests/projection/mcp-in-memory.test.ts | 15 ++- packages/rsc-runtime/README.md | 3 +- 6 files changed, 116 insertions(+), 39 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index 0966a6cf0..442ba4e23 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Generated MCP servers with a workspace-durable state lifetime now deliver notices over the `mcp-resource-updated` route: they accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Subscriptions fail closed when the store is unreadable; volatile lifetimes advertise no subscription capability. `@agent-bundle/runtime/notices` exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, and the `AgentNoticeError` code `reservation-lost`; `@agent-bundle/runtime/mount` exports `createGeneratedNoticeRuntime` and `GeneratedRuntimeState.noticeLedger()`. Breaking for implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field. The notice ledger state definition moves to schema version 2 (`AGENT_NOTICE_STATE_VERSION`); existing workspace-durable notice stores are migrated in place on first open, with no data loss. (#376) +Deliver notices over the `mcp-resource-updated` route from generated MCP servers with a workspace-durable state lifetime: accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, detached from the render that triggered it so a slow subscriber never delays a tool result, and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Fail subscriptions closed when the store is unreadable; volatile lifetimes advertise no subscription capability. Use the new exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, `AGENT_NOTICE_STATE_VERSION`, and the `AgentNoticeError` code `reservation-lost` from `@agent-bundle/runtime/notices`, and `createGeneratedNoticeRuntime` plus `GeneratedRuntimeState.noticeLedger()` from `@agent-bundle/runtime/mount`. Update implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field (breaking). Existing workspace-durable notice stores migrate in place to schema version 2 on first open, with no data loss. (#376) diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 2636b91ad..060d83a5b 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -45,7 +45,7 @@ import type { Observed, WarmFlightHost, } from '@agent-bundle/runtime'; -import type { AgentNoticeInboxSignaller } from '@agent-bundle/runtime/notices'; +import type { AgentNoticeInboxSignaller, AgentNoticeInboxSignalOutcome } from '@agent-bundle/runtime/notices'; /** One route the generated server hosts, as the generated module records it. */ export interface GeneratedRouteRecord { @@ -238,7 +238,8 @@ export const advertisedOutputSchema = (schema: unknown): unknown => { /** * Runs after every render the server completes, successful or not: a route * that published a notice and then failed still advanced the ledger. The hook - * never throws into the request path. + * never throws into the request path and resolves as soon as the follow-up + * work is scheduled, never waiting on another connection's wire. */ export type GeneratedRenderSettled = () => Promise; @@ -587,8 +588,7 @@ const installNoticeInboxSubscriptions = ( const send = async (): Promise => { await protocol.sendResourceUpdated({ uri: notices.inboxUri }); }; - return async (): Promise => { - const outcome = await notices.observe(send); + const report = (outcome: AgentNoticeInboxSignalOutcome): void => { switch (outcome.kind) { case 'idle': case 'signalled': @@ -602,6 +602,14 @@ const installNoticeInboxSubscriptions = ( } } }; + // Detached from the render that triggered it. The signaller serializes its + // observations and never rejects, and a notification write to a slow or + // wedged connection — renewed for as long as it takes — must not hold the + // completed render's response, or unrelated event handling, hostage. + return (): Promise => { + void notices.observe(send).then(report); + return Promise.resolve(); + }; }; const nativeString = ( diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 0ad8be2d7..1e979ce6a 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -607,6 +607,14 @@ it('emits notifications/resources/updated for the durable notice inbox to the su const settle = async (): Promise => { await new Promise((resolve) => setTimeout(resolve, 50)); }; + // The inbox observation is detached from the render that triggered it. + const signalled = async (count: number): Promise => { + for (let i = 0; i < 500 && updates.length < count; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + // The availability receipt commits right after the wire write resolves. + await settle(); + }; const readInbox = async (): Promise>[]> => { const read = await session.client.readResource({ uri: inboxUri }); const content = read.contents[0]; @@ -627,7 +635,7 @@ it('emits notifications/resources/updated for the durable notice inbox to the su await settle(); expect(updates).toEqual([]); await session.client.callTool({ arguments: { host: 'generated-route-test', message: 'for this client' }, name: 'notify' }, { signal: AbortSignal.timeout(10_000) }); - await settle(); + await signalled(1); expect(updates).toEqual([inboxUri]); const inbox = await readInbox(); diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index 90bcdc4ee..eadc2ea5b 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -1,3 +1,4 @@ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { describe, expect, it } from '@rstest/core'; import { z } from 'zod'; @@ -44,39 +45,89 @@ describe('advertisedOutputSchema', () => { }); }); -describe('generated server teardown', () => { - const stubs = (noticesClose: () => Promise) => { - const order: string[] = []; - const host: GeneratedRouteExecutionHost = { - availability: () => ({ state: 'available' }) as never, - close: async () => { - order.push('host'); - }, - execute: async () => { - throw new Error('not rendered'); +const stubs = (options: { + readonly noticesClose?: () => Promise; + readonly observe?: GeneratedNoticeDeliveryBinding['observe']; +} = {}) => { + const order: string[] = []; + const host: GeneratedRouteExecutionHost = { + availability: () => ({ state: 'available' }) as never, + close: async () => { + order.push('host'); + }, + execute: async () => { + throw new Error('not rendered'); + }, + identity: { artifactEpoch: 'epoch', instanceId: 'test' } as never, + markUnavailable: () => undefined, + }; + const notices: GeneratedNoticeDeliveryBinding = { + inboxUri: 'agent-bundle://notices/inbox', + subscribed: false, + close: async () => { + order.push('notices'); + await options.noticesClose?.(); + }, + observe: options.observe ?? (async () => ({ kind: 'idle', reason: 'no-subscription', revision: undefined })), + subscribe: async () => undefined, + unsubscribe: async () => undefined, + }; + return { host, notices, order }; +}; + +describe('generated server render completion', () => { + it('answers a completed render while the inbox observation is still pending on another connection', async () => { + // The signaller renews a hold for as long as a notification write takes, + // so a wedged subscriber's wire must not hold a tool response hostage. + let observations = 0; + const { host, notices } = stubs({ + observe: () => { + observations += 1; + return new Promise(() => undefined); }, - identity: { artifactEpoch: 'epoch', instanceId: 'test' } as never, - markUnavailable: () => undefined, - }; - const notices: GeneratedNoticeDeliveryBinding = { - inboxUri: 'agent-bundle://notices/inbox', - subscribed: false, - close: async () => { - order.push('notices'); - await noticesClose(); + }); + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + host, + notices, + plugin: { name: 'pending-observation', version: '0.0.0' }, + routes: { + 'mcp/pending/tools/probe': { + config: {}, + id: 'mcp/pending/tools/probe', + kind: 'tool', + module: { + default: () => undefined, + inputSchema: z.object({}).strict(), + resultSchema: z.object({ ok: z.boolean() }).strict(), + }, + name: 'probe', + }, }, - observe: async () => ({ kind: 'idle', reason: 'no-subscription', revision: undefined }), - subscribe: async () => undefined, - unsubscribe: async () => undefined, - }; - return { host, notices, order }; - }; + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'pending-observation-test', version: '0.0.0' }); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + // The stub host cannot render, so the call settles as a tool error — + // promptly, even though the observation it triggered never resolves. + const result = await client.callTool({ arguments: {}, name: 'probe' }, { signal: AbortSignal.timeout(5_000) }); + expect(result.isError).toBe(true); + expect(observations).toBe(1); + } finally { + await client.close(); + await server.close(); + } + }); +}); + +describe('generated server teardown', () => { it('closes the notice signaller before the host that owns the ledger it drains into', async () => { // A receipt still owed for a wire-successful send is committed by the // signaller's close; the host closes the (shared) store, so it must go // second or the drain fails against a closed ledger and the send is lost. - const { host, notices, order } = stubs(async () => undefined); + const { host, notices, order } = stubs(); const server = await createGeneratedRouteMcpServer({ artifactEpoch: 'epoch', host, @@ -89,8 +140,10 @@ describe('generated server teardown', () => { }); it('still closes the host when the signaller close fails', async () => { - const { host, notices, order } = stubs(async () => { - throw new Error('drain failed'); + const { host, notices, order } = stubs({ + noticesClose: async () => { + throw new Error('drain failed'); + }, }); const server = await createGeneratedRouteMcpServer({ artifactEpoch: 'epoch', diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 21705d6e9..6de480b73 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -378,9 +378,16 @@ describe('the in-memory MCP projection level', () => { state: { definition: stateDefinition, driver: createSqliteStateDriver({ root }) }, }); const settle = async (): Promise => { - // Notifications ride the transport ahead of the request result; one turn - // of the event loop lets the linked in-memory pair deliver them. - await new Promise((resolve) => setTimeout(resolve, 20)); + // The inbox observation is detached from the render that triggered it; + // a few turns of the event loop let it reserve, send, and record. + await new Promise((resolve) => setTimeout(resolve, 50)); + }; + const signalled = async (session: 's1' | 's2', count: number): Promise => { + for (let i = 0; i < 200 && updates[session].length < count; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + // The availability receipt commits right after the wire write resolves. + await settle(); }; const readInbox = async (client: (typeof subscribed)['client']) => { const read = await client.readResource({ uri: inboxUri }); @@ -405,7 +412,7 @@ describe('the in-memory MCP projection level', () => { // s1 publishes to itself: the subscribed session gets exactly one signal. await subscribed.client.callTool({ arguments: { message: 'for s1', recipientSession: 's1' }, name: 'publish-notice' }); - await settle(); + await signalled('s1', 1); expect(updates).toEqual({ s1: [inboxUri], s2: [] }); // Availability is a receipt on the pending notice, not a state change; diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 81ecdef63..305c980a1 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -262,7 +262,8 @@ itself: one long-lived MCP connection's subscription to the reserved inbox resource (`AGENT_NOTICE_INBOX_URI`). The generated server process opens its own handle on the workspace-durable store its Flight worker mounts (`createGeneratedNoticeRuntime` from `@agent-bundle/runtime/mount`), and -after every completed render `observe(send)` reads the ledger, reserves the +after every completed render `observe(send)` — detached from that render's +response, so a slow subscriber's wire never delays a tool result — reads the ledger, reserves the budget slot of the subscriber's newly eligible pending notices as one compare-and-swap against the revision it read (`reserveAvailability()` with `expectedRevision`), sends at most one `notifications/resources/updated`, and From bb938409298453611f8b83c6a0c4cabf71234041 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:25:38 +0000 Subject: [PATCH 14/18] fix(mcp): always close the protocol transport, even when notice or host teardown fails Codex P2 on #376: a rejecting notices.close() skipped the bound protocol close, leaving the transport and its listeners open after server.close() failed. The protocol close now runs from an outer finally; the teardown error still surfaces once it has. --- packages/agent-bundle/src/mcp-server-runtime.ts | 15 ++++++++++----- .../tests/mcp-server-runtime.test.ts | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 060d83a5b..e9ff168aa 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -734,16 +734,21 @@ export const createGeneratedRouteMcpServer = async ( registerGeneratedMcpApps(server, options.apps ?? []); const close = server.close.bind(server); server.close = async (): Promise => { - await events?.close(); // The signaller drains any receipt still owed for a send that reached the // wire, so it must close while the ledger it commits to is still open: - // the host owns (or shares) that store and closes after it. + // the host owns (or shares) that store and closes after it. Whatever + // fails on the way, the protocol and its transport are always closed; + // the teardown error surfaces once they are. try { - await options.notices?.close(); + await events?.close(); + try { + await options.notices?.close(); + } finally { + await options.host.close(); + } } finally { - await options.host.close(); + await close(); } - await close(); }; return server; }; diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index eadc2ea5b..22cf4f5ae 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -139,7 +139,7 @@ describe('generated server teardown', () => { expect(order).toEqual(['notices', 'host']); }); - it('still closes the host when the signaller close fails', async () => { + it('still closes the host and the protocol transport when the signaller close fails', async () => { const { host, notices, order } = stubs({ noticesClose: async () => { throw new Error('drain failed'); @@ -152,7 +152,21 @@ describe('generated server teardown', () => { plugin: { name: 'teardown', version: '0.0.0' }, routes: {}, }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'teardown-test', version: '0.0.0' }); + let clientSawClose = false; + client.onclose = () => { + clientSawClose = true; + }; + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); await expect(server.close()).rejects.toThrow('drain failed'); expect(order).toEqual(['notices', 'host']); + // The failed drain did not leave the transport open: the linked client + // observed the server side close. + for (let i = 0; i < 100 && !clientSawClose; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(clientSawClose).toBe(true); + await client.close(); }); }); From 8dc8aabd8ed9bb946919f0f724e6cf5c32b23d62 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:50:54 +0000 Subject: [PATCH 15/18] fix(notices,mcp): let pinned receipt retries replay through the store; tear down notices and host even when the event runtime close fails Codex P2s on #376: signalAvailability({ reservationKey, expectedRevision }) prechecked the pin against the head before dispatching, so a retry of a receipt whose commit landed but whose response was lost could never reach the store's idempotency replay; the guard is now applied only by the store, which resolves the idempotency key first. And a rejecting events.close() skipped the signaller drain and host close; both now run from a finally, with the protocol close still outermost. --- .../agent-bundle/src/mcp-server-runtime.ts | 9 ++++-- .../tests/mcp-server-runtime.test.ts | 31 +++++++++++++++++++ packages/rsc-runtime/src/notices/ledger.ts | 22 ++++++------- .../rsc-runtime/tests/notices-ledger.test.ts | 31 +++++++++++++++++++ 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index e9ff168aa..72dc0af6c 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -740,11 +740,14 @@ export const createGeneratedRouteMcpServer = async ( // fails on the way, the protocol and its transport are always closed; // the teardown error surfaces once they are. try { - await events?.close(); try { - await options.notices?.close(); + await events?.close(); } finally { - await options.host.close(); + try { + await options.notices?.close(); + } finally { + await options.host.close(); + } } } finally { await close(); diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index 22cf4f5ae..e28684b91 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -139,6 +139,37 @@ describe('generated server teardown', () => { expect(order).toEqual(['notices', 'host']); }); + it('still drains the signaller and closes the host when the event runtime close fails', async () => { + const { host, notices, order } = stubs(); + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + events: { + allowedTargets: ['claude'], + artifactEpoch: 'epoch', + createCanonicalEventProps: (() => { + throw new Error('not invoked'); + }) as never, + createEventRuntimeServer: (async () => ({ + close: async () => { + order.push('events'); + throw new Error('socket teardown failed'); + }, + })) as never, + endpointId: 'teardown-test', + projectEventDocument: (() => { + throw new Error('not invoked'); + }) as never, + target: 'claude', + }, + host, + notices, + plugin: { name: 'teardown', version: '0.0.0' }, + routes: {}, + }); + await expect(server.close()).rejects.toThrow('socket teardown failed'); + expect(order).toEqual(['events', 'notices', 'host']); + }); + it('still closes the host and the protocol transport when the signaller close fails', async () => { const { host, notices, order } = stubs({ noticesClose: async () => { diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 9d7cddbaf..4a45e4ceb 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -548,25 +548,21 @@ export const createAgentNoticeLedger = ( return snapshotFrom(committed.revision, committed.state); } // A reserved receipt is judged against the exact state it commits over: - // the dispatch is guarded by the revision just read, so the ownership - // check and the reducer see the same holds. Unrelated writers move the - // revision too, hence the bounded re-read. An idempotent replay of an - // already-committed receipt short-circuits the guard inside the store - // and is never mistaken for a lost hold. + // the dispatch is guarded by the revision just read (or the caller's own + // pin), so the ownership check and the reducer see the same holds. + // Unrelated writers move the revision too, hence the bounded re-read + // when the caller did not pin one. The guard is only ever applied by the + // store, which resolves the idempotency key first: a retry of a receipt + // whose commit landed but whose response was lost replays that commit + // instead of failing a revision precheck it could never satisfy. for (let attempt = 0; attempt < MAX_RESERVED_RECEIPT_ATTEMPTS; attempt += 1) { const before = yield* storeEffect(() => store.read()); - if (expectedRevision !== undefined && expectedRevision !== before.revision) { - return yield* storeEffect(() => Promise.reject(new AgentStateError( - 'revision-conflict', - `Notice availability expected revision ${String(expectedRevision)} but the head is ${String(before.revision)}`, - ))); - } const committed = yield* storeEffect(() => store.dispatch( 'availability-signalled', payload, - { expectedRevision: before.revision, idempotencyKey }, + { expectedRevision: expectedRevision ?? before.revision, idempotencyKey }, )).pipe(Effect.catch((error) => - error instanceof AgentStateError && error.code === 'revision-conflict' + error instanceof AgentStateError && error.code === 'revision-conflict' && expectedRevision === undefined ? Effect.succeed(undefined) : Effect.fail(error))); if (committed === undefined) continue; diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 4ad8de45a..95a4e1408 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -1105,6 +1105,37 @@ describe('notice delivery routing receipts (#99 stage 4)', () => { await driver.close(); }); + it('replays a pinned reserved receipt whose commit landed but whose response was lost', async () => { + const { driver, ledger } = await openLedger(); + const published = await publishTo(ledger); + const id = published.notice.id; + const reserved = await ledger.reserveAvailability({ + at: '2026-09-01T19:04:00.000Z', + idempotencyKey: 'reserve:pinned', + noticeIds: [id], + reservationKey: 'holder:1', + }); + const receipt = { + at: '2026-09-01T19:04:01.000Z', + expectedRevision: reserved.revision, + idempotencyKey: 'signal:pinned', + noticeIds: [id], + reservationKey: 'holder:1', + }; + const committed = await ledger.signalAvailability(receipt); + expect(committed.notices[0]?.availability).toMatchObject({ count: 1 }); + // The head has moved past the pin; the same key must replay the commit + // rather than fail a revision check the store never got to judge. + const replayed = await ledger.signalAvailability(receipt); + expect(replayed.revision).toBe(committed.revision); + expect(replayed.notices[0]?.availability).toMatchObject({ count: 1 }); + // A genuinely new receipt against a stale pin is still refused. + await expect(ledger.signalAvailability({ ...receipt, idempotencyKey: 'signal:pinned-stale' })) + .rejects.toMatchObject({ code: 'revision-conflict' }); + expect((await ledger.read()).notices[0]?.availability).toMatchObject({ count: 1 }); + await driver.close(); + }); + it('records the receipt of a send that raced an acknowledgement instead of discarding it as a no-op', async () => { const { driver, ledger } = await openLedger(); const published = await publishTo(ledger); From ebac0663007b939c3ac07b0b2061ea791fcaf952 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 13:18:30 +0000 Subject: [PATCH 16/18] fix(notices): await in-flight renewals, abandon wedged sends on close, coalesce observations - renewWhile awaits a renewal still pending at the ledger before the hold is released or finalized, so a late renewal cannot re-create an orphan hold that blocks the slot for a whole TTL - the signaller's close() races pending sends against shutdown: an unsettled resources/updated write is abandoned (outcome unknown, hold left to lapse) so a subscriber that stopped reading cannot wedge server teardown - the generated server keeps one observation in flight and at most one owed instead of queueing a detached observation per completed render --- .changeset/notice-inbox-resource-updated.md | 2 +- .../agent-bundle/src/mcp-server-runtime.ts | 27 ++++- .../tests/mcp-server-runtime.test.ts | 57 +++++++++ packages/rsc-runtime/README.md | 11 +- .../src/notices/resource-updated.ts | 48 +++++++- .../tests/notices-resource-updated.test.ts | 114 ++++++++++++++++++ 6 files changed, 249 insertions(+), 10 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index 442ba4e23..1fcd0a36d 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Deliver notices over the `mcp-resource-updated` route from generated MCP servers with a workspace-durable state lifetime: accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, detached from the render that triggered it so a slow subscriber never delays a tool result, and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Fail subscriptions closed when the store is unreadable; volatile lifetimes advertise no subscription capability. Use the new exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, `AGENT_NOTICE_STATE_VERSION`, and the `AgentNoticeError` code `reservation-lost` from `@agent-bundle/runtime/notices`, and `createGeneratedNoticeRuntime` plus `GeneratedRuntimeState.noticeLedger()` from `@agent-bundle/runtime/mount`. Update implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field (breaking). Existing workspace-durable notice stores migrate in place to schema version 2 on first open, with no data loss. (#376) +Deliver notices over the `mcp-resource-updated` route from generated MCP servers with a workspace-durable state lifetime: accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, detached from the render that triggered it and coalesced behind a pending write so a slow subscriber never delays a tool result nor grows a queue, abandoned (never awaited) by server teardown when its write cannot settle, and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Fail subscriptions closed when the store is unreadable; volatile lifetimes advertise no subscription capability. Use the new exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, `AGENT_NOTICE_STATE_VERSION`, and the `AgentNoticeError` code `reservation-lost` from `@agent-bundle/runtime/notices`, and `createGeneratedNoticeRuntime` plus `GeneratedRuntimeState.noticeLedger()` from `@agent-bundle/runtime/mount`. Update implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field (breaking). Existing workspace-durable notice stores migrate in place to schema version 2 on first open, with no data loss. (#376) diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 72dc0af6c..ddc9b096e 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -606,8 +606,25 @@ const installNoticeInboxSubscriptions = ( // observations and never rejects, and a notification write to a slow or // wedged connection — renewed for as long as it takes — must not hold the // completed render's response, or unrelated event handling, hostage. + // Observations coalesce: one is in flight and at most one more is owed, + // because an observation reads the whole ledger, so every render completing + // behind a pending write is covered by the single follow-up and a client + // that stops reading cannot grow a queue of closures per render. + let observing: Promise | undefined; + let owed = false; + const observe = (): void => { + observing = notices.observe(send).then(report, (error: unknown) => { + noticeDiagnostic(`resources/updated observation failed: ${describeError(error)}`); + }).then(() => { + observing = undefined; + if (!owed) return; + owed = false; + observe(); + }); + }; return (): Promise => { - void notices.observe(send).then(report); + if (observing === undefined) observe(); + else owed = true; return Promise.resolve(); }; }; @@ -736,9 +753,11 @@ export const createGeneratedRouteMcpServer = async ( server.close = async (): Promise => { // The signaller drains any receipt still owed for a send that reached the // wire, so it must close while the ledger it commits to is still open: - // the host owns (or shares) that store and closes after it. Whatever - // fails on the way, the protocol and its transport are always closed; - // the teardown error surfaces once they are. + // the host owns (or shares) that store and closes after it. Its close + // abandons a notification write still pending rather than waiting on the + // client's wire, so a subscriber that stopped reading cannot wedge this + // teardown. Whatever fails on the way, the protocol and its transport are + // always closed; the teardown error surfaces once they are. try { try { await events?.close(); diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index e28684b91..e9f465ef9 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -119,6 +119,63 @@ describe('generated server render completion', () => { await server.close(); } }); + + it('coalesces renders completing behind a pending observation into one follow-up', async () => { + // Every observation reads the whole ledger, so the renders that complete + // while a notification write is pending are all covered by one follow-up: + // a client that stops reading cannot grow a queue of observations. + const settlers: Array<() => void> = []; + const { host, notices } = stubs({ + observe: () => new Promise((resolve) => { + settlers.push(() => { + resolve({ kind: 'idle', reason: 'nothing-eligible', revision: 1 }); + }); + }), + }); + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + host, + notices, + plugin: { name: 'coalesced-observation', version: '0.0.0' }, + routes: { + 'mcp/coalesced/tools/probe': { + config: {}, + id: 'mcp/coalesced/tools/probe', + kind: 'tool', + module: { + default: () => undefined, + inputSchema: z.object({}).strict(), + resultSchema: z.object({ ok: z.boolean() }).strict(), + }, + name: 'probe', + }, + }, + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'coalesced-observation-test', version: '0.0.0' }); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + try { + for (let i = 0; i < 5; i += 1) { + await client.callTool({ arguments: {}, name: 'probe' }, { signal: AbortSignal.timeout(5_000) }); + } + // Five completed renders, one observation in flight, one owed. + expect(settlers).toHaveLength(1); + settlers[0]!(); + await tick(); + expect(settlers).toHaveLength(2); + // The single follow-up settles with nothing further owed. + settlers[1]!(); + await tick(); + expect(settlers).toHaveLength(2); + // A render after the queue drained starts a fresh observation. + await client.callTool({ arguments: {}, name: 'probe' }, { signal: AbortSignal.timeout(5_000) }); + expect(settlers).toHaveLength(3); + } finally { + await client.close(); + await server.close(); + } + }); }); describe('generated server teardown', () => { diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 305c980a1..e91b24a73 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -295,7 +295,16 @@ idempotent receipt is retried on the renewal cadence (renewing its hold as it goes), before any later observation spends, and on `close()`, so a live process cannot lose a send the wire already carried; only a process that dies while the ledger is refusing writes leaves an unrecorded send, and its hold then lapses -after the TTL. Exposure and availability +after the TTL. A renewal still awaiting the ledger when a send settles is +awaited before the hold is released or finalized, so no orphan hold is +re-created behind a release. The wire is the one dependency the signaller never +waits on at shutdown: `close()` abandons a `resources/updated` write that has +not settled (its outcome is unknown, so it is neither recorded nor released and +its hold lapses after the TTL), commits any owed receipt, and closes the store, +so a subscriber that stopped reading cannot wedge server teardown. The generated +server coalesces observations — one in flight, at most one owed — because every +observation reads the whole ledger, so renders completing behind a pending +write never queue per-render work. Exposure and availability receipts never re-trigger a signal, so a subscribed client cannot be driven into a refetch loop. Subscribing fails closed when the store is unreadable, `unsubscribe()` resolves only after in-flight observations settle, and only the diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index 513337a51..a6b6155eb 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -65,7 +65,12 @@ export type AgentNoticeInboxSignalOutcome = export interface AgentNoticeInboxSignaller { readonly inboxUri: typeof AGENT_NOTICE_INBOX_URI; readonly subscribed: boolean; - /** Commits any receipt still owed for a send that reached the wire, then closes the store. */ + /** + * Commits any receipt still owed for a send that reached the wire, then + * closes the store. Never waits on a client's wire: a `resources/updated` + * write still pending is abandoned with its outcome unknown, and the hold + * it took lapses after the reservation TTL like any holder gone mid-send. + */ close(): Promise; /** * Runs after one completed render: first commits any receipt still owed from @@ -160,6 +165,10 @@ export const createNoticeInboxSignaller = ( let receiptRetryTimer: ReturnType | undefined; let renewalSequence = 0; let closed = false; + let resolveClosing: () => void = () => undefined; + const closing = new Promise((resolve) => { + resolveClosing = resolve; + }); // Observations and subscription changes serialize on one queue: two renders // completing together cannot both select the same notice and send two // signals for one revision, and an unsubscribe (or re-subscribe) that @@ -316,7 +325,10 @@ export const createNoticeInboxSignaller = ( * abandoned and send too; renewing under the same key refreshes `at`, and * the reducer refuses a renewal once a different key has legitimately taken * over, so a holder that could not renew for a whole TTL never steals back. - * The timer exists only for the duration of one in-flight send. + * The timer exists only for the duration of one in-flight send, and a + * renewal still awaiting the ledger when the send settles is awaited before + * the caller releases or finalizes the hold: a renewal landing afterwards + * would re-create a hold nobody owns and block the slot for a whole TTL. */ const renewWhile = async ( ledger: AgentNoticeLedger, @@ -325,15 +337,17 @@ export const createNoticeInboxSignaller = ( ): Promise => { let stopped = false; let timer: ReturnType | undefined; + let renewing: Promise | undefined; const tick = (): void => { if (stopped) return; renewalSequence += 1; - void ledger.reserveAvailability({ + renewing = ledger.reserveAvailability({ at: now().toISOString(), idempotencyKey: `agent-notices:availability:renew:${hold.reservationKey}:${String(renewalSequence)}`, noticeIds: hold.noticeIds, reservationKey: hold.reservationKey, }).catch(() => undefined).then(() => { + renewing = undefined; if (stopped) return; timer = setTimeout(tick, renewalIntervalMs); timer.unref?.(); @@ -346,9 +360,22 @@ export const createNoticeInboxSignaller = ( } finally { stopped = true; if (timer !== undefined) clearTimeout(timer); + await renewing; } }; + /** + * Races one protocol write against the signaller closing. The wire is the + * one dependency the signaller cannot bound — a subscriber that stops + * reading leaves the write pending forever — so shutdown must not wait on + * it: the send is abandoned with its outcome unknown and its hold is left to + * lapse after the TTL, exactly as for a holder that vanished mid-send. + */ + const sendUntilClosed = (send: () => Promise): Promise<'closed' | 'sent'> => Promise.race([ + send().then((): 'sent' => 'sent'), + closing.then((): 'closed' => 'closed'), + ]); + const observeOnce = async (send: () => Promise): Promise => { const current = subscription; if (current === undefined && pendingReceipts.size === 0) { @@ -390,8 +417,9 @@ export const createNoticeInboxSignaller = ( // the notice stays eligible for the next observation, while a successful // send finalizes the hold into the availability receipt. Only the receipt // means the protocol write succeeded. + let wire: 'closed' | 'sent'; try { - await renewWhile(ledger, claimed, send()); + wire = await renewWhile(ledger, claimed, sendUntilClosed(send)); } catch (error) { try { await ledger.releaseAvailability({ @@ -405,6 +433,15 @@ export const createNoticeInboxSignaller = ( } return Object.freeze({ error, kind: 'failed' as const, stage: 'send' as const }); } + if (wire === 'closed') { + // Whether the write reached the client is unknowable now, so neither a + // receipt nor a release would be honest; the hold lapses on its own. + return Object.freeze({ + error: new AgentNoticeError('aborted', 'Notice inbox signaller closed while a resources/updated write was pending'), + kind: 'failed' as const, + stage: 'send' as const, + }); + } // The wire write succeeded: this subscription never sends for these notices // again, and the receipt is owed until it commits. for (const id of claimed.noticeIds) current.signalled.add(id); @@ -435,6 +472,9 @@ export const createNoticeInboxSignaller = ( }, close(): Promise { closed = true; + // Unblocks an observation whose protocol write never settles so the + // queue — and this close behind it — cannot wait on a client's wire. + resolveClosing(); if (receiptRetryTimer !== undefined) { clearTimeout(receiptRetryTimer); receiptRetryTimer = undefined; diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index c4322e160..acdb260b3 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -777,6 +777,120 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); + it('waits for a renewal still in flight before releasing a failed send, so no orphan hold survives', async () => { + const { driver, ledger } = await openLedger(); + // Renewals reach the ledger only when the test lets them, standing in for + // a ledger that answers slowly while the wire fails fast. + let releaseRenewals: () => void = () => undefined; + const renewalsGate = new Promise((resolve) => { + releaseRenewals = resolve; + }); + let renewals = 0; + const slowRenewals: AgentNoticeLedger = Object.freeze({ + ...ledger, + reserveAvailability: async (input) => { + if (input.expectedRevision === undefined) { + renewals += 1; + await renewalsGate; + } + return ledger.reserveAvailability(input); + }, + }); + const signaller = createNoticeInboxSignaller({ + now: () => new Date(T1), + reservationRenewalIntervalMs: 5, + store: { close: async () => undefined, noticeLedger: async () => slowRenewals }, + }); + await signaller.subscribe(principal('s1')); + await publish(ledger, { sessionId: 's1' }); + + // The send fails only once a renewal has started and is awaiting the ledger. + const failingSend = async (): Promise => { + for (let i = 0; i < 200 && renewals === 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(renewals).toBe(1); + throw new Error('transport closed'); + }; + const observing = signaller.observe(failingSend); + // The failure path does not release until that renewal has settled, so + // the observation is still pending while the renewal is gated. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect((await ledger.read()).notices[0]?.availabilityReservation).toMatchObject({ at: T1 }); + releaseRenewals(); + await expect(observing).resolves.toMatchObject({ kind: 'failed', stage: 'send' }); + + // Release came after the renewal landed: the slot is free, and stays free. + const released = (await ledger.read()).notices[0]; + expect(released).toMatchObject({ state: 'pending' }); + expect(released).not.toHaveProperty('availabilityReservation'); + expect(released).not.toHaveProperty('availability'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect((await ledger.read()).notices[0]).not.toHaveProperty('availabilityReservation'); + const other = signallerOver(ledger, () => new Date(Date.parse(T1) + 1_000)); + await other.subscribe(principal('s1')); + await expect(other.observe(sender().send)).resolves.toMatchObject({ kind: 'signalled' }); + await signaller.close(); + await driver.close(); + }); + + it('closes without waiting on a protocol write that never settles, leaving its hold to lapse', async () => { + const { driver, ledger } = await openLedger(); + let storeClosed = 0; + const signaller = createNoticeInboxSignaller({ + now: () => new Date(T1), + store: { + close: async () => { + storeClosed += 1; + }, + noticeLedger: async () => ledger, + }, + }); + await signaller.subscribe(principal('s1')); + const published = await publish(ledger, { sessionId: 's1' }); + + // A subscriber that stopped reading: the write is accepted but never settles. + let sends = 0; + const wedgedSend = (): Promise => { + sends += 1; + return new Promise(() => undefined); + }; + const observing = signaller.observe(wedgedSend); + const heldAt = async (): Promise => + (await ledger.read()).notices[0]?.availabilityReservation?.at; + for (let i = 0; i < 200 && await heldAt() === undefined; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(sends).toBe(1); + + // Close resolves promptly: the queue behind the wedged write is unblocked + // by shutdown itself, never by the client. + let closed = false; + const closing = signaller.close().then(() => { + closed = true; + }); + await Promise.race([closing, new Promise((resolve) => setTimeout(resolve, 2_000))]); + expect(closed).toBe(true); + expect(storeClosed).toBe(1); + const outcome = await observing; + expect(outcome).toMatchObject({ kind: 'failed', stage: 'send' }); + expect((outcome as { error: AgentNoticeError }).error.code).toBe('aborted'); + + // The outcome of the write is unknown, so nothing is spent and nothing is + // released: the hold lapses after the TTL exactly as for a crashed holder. + const abandoned = (await ledger.read()).notices[0]; + expect(abandoned).toMatchObject({ id: published.notice.id, state: 'pending' }); + expect(abandoned).not.toHaveProperty('availability'); + expect(abandoned?.availabilityReservation).toMatchObject({ at: T1 }); + const withinTtl = signallerOver(ledger, () => new Date(Date.parse(T1) + 1_000)); + await withinTtl.subscribe(principal('s1')); + await expect(withinTtl.observe(sender().send)).resolves.toMatchObject({ kind: 'idle', reason: 'nothing-eligible' }); + const afterTtl = signallerOver(ledger, () => new Date(Date.parse(T1) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS)); + await afterTtl.subscribe(principal('s1')); + await expect(afterTtl.observe(sender().send)).resolves.toMatchObject({ kind: 'signalled' }); + await driver.close(); + }); + it('treats a reservation as held until its TTL elapses, then as abandoned', async () => { const { driver, ledger } = await openLedger(); const published = await publish(ledger, { sessionId: 's1' }); From 473f963627f1979207e8a6824f2cf6edeed59c9c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 13:49:48 +0000 Subject: [PATCH 17/18] fix(notices): never await an unanswered renewal once the signaller is closing --- packages/rsc-runtime/README.md | 10 ++-- .../src/notices/resource-updated.ts | 5 +- .../tests/notices-resource-updated.test.ts | 47 +++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index e91b24a73..2f8930894 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -297,11 +297,11 @@ cannot lose a send the wire already carried; only a process that dies while the ledger is refusing writes leaves an unrecorded send, and its hold then lapses after the TTL. A renewal still awaiting the ledger when a send settles is awaited before the hold is released or finalized, so no orphan hold is -re-created behind a release. The wire is the one dependency the signaller never -waits on at shutdown: `close()` abandons a `resources/updated` write that has -not settled (its outcome is unknown, so it is neither recorded nor released and -its hold lapses after the TTL), commits any owed receipt, and closes the store, -so a subscriber that stopped reading cannot wedge server teardown. The generated +re-created behind a release. Shutdown never waits on the wire, nor on a renewal +the ledger has not answered: `close()` abandons a `resources/updated` write that +has not settled (its outcome is unknown, so it is neither recorded nor released +and its hold lapses after the TTL), commits any owed receipt, and closes the +store, so a subscriber that stopped reading cannot wedge server teardown. The generated server coalesces observations — one in flight, at most one owed — because every observation reads the whole ledger, so renders completing behind a pending write never queue per-render work. Exposure and availability diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index a6b6155eb..9b9bd4f7b 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -329,6 +329,9 @@ export const createNoticeInboxSignaller = ( * renewal still awaiting the ledger when the send settles is awaited before * the caller releases or finalizes the hold: a renewal landing afterwards * would re-create a hold nobody owns and block the slot for a whole TTL. + * That wait ends with shutdown, though — `close()` never blocks on a ledger + * write that has not answered — and a renewal that lands after an abandoned + * send only moves the lapse of a hold that was already being left to lapse. */ const renewWhile = async ( ledger: AgentNoticeLedger, @@ -360,7 +363,7 @@ export const createNoticeInboxSignaller = ( } finally { stopped = true; if (timer !== undefined) clearTimeout(timer); - await renewing; + if (renewing !== undefined) await Promise.race([renewing, closing]); } }; diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index acdb260b3..3097f651d 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -891,6 +891,53 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); + it('closes without waiting on a renewal the ledger never answers, so shutdown is never wedged by the store', async () => { + const { driver, ledger } = await openLedger(); + let renewals = 0; + // Renewals reach a ledger that has stopped answering, while the initial + // claim (with expectedRevision) still landed. + const stuckRenewals: AgentNoticeLedger = Object.freeze({ + ...ledger, + reserveAvailability: (input) => { + if (input.expectedRevision === undefined) { + renewals += 1; + return new Promise(() => undefined); + } + return ledger.reserveAvailability(input); + }, + }); + let storeClosed = 0; + const signaller = createNoticeInboxSignaller({ + now: () => new Date(T1), + reservationRenewalIntervalMs: 5, + store: { + close: async () => { + storeClosed += 1; + }, + noticeLedger: async () => stuckRenewals, + }, + }); + await signaller.subscribe(principal('s1')); + await publish(ledger, { sessionId: 's1' }); + const observing = signaller.observe(() => new Promise(() => undefined)); + for (let i = 0; i < 200 && renewals === 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(renewals).toBe(1); + + let closed = false; + const closing = signaller.close().then(() => { + closed = true; + }); + await Promise.race([closing, new Promise((resolve) => setTimeout(resolve, 2_000))]); + expect(closed).toBe(true); + expect(storeClosed).toBe(1); + await expect(observing).resolves.toMatchObject({ kind: 'failed', stage: 'send' }); + // The hold was already being left to lapse; the unanswered renewal changes nothing. + expect((await ledger.read()).notices[0]?.availabilityReservation).toMatchObject({ at: T1 }); + await driver.close(); + }); + it('treats a reservation as held until its TTL elapses, then as abandoned', async () => { const { driver, ledger } = await openLedger(); const published = await publish(ledger, { sessionId: 's1' }); From 98d57eaa323792afc10eaa62a39fdc7cfd1ce225 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 14:22:07 +0000 Subject: [PATCH 18/18] fix(notices): bound every store wait by shutdown; give the close-time drain a deadline - observations race each ledger call (read, reserve, release, receipt commit, store open) against closing, so a ledger that never answers cannot pin the serialized queue and, behind it, server teardown; an abandoned commit keeps its receipt owed instead of inferring either way - close() drains owed receipts and closes the store within closeTimeoutMs (default 5s), the one bounded chance to land a receipt before the process goes - subscribe() awaiting a wedged store rejects with aborted at close --- .changeset/notice-inbox-resource-updated.md | 2 +- packages/rsc-runtime/README.md | 12 +- .../src/notices/resource-updated.ts | 141 ++++++++++++------ .../tests/notices-resource-updated.test.ts | 53 +++++++ 4 files changed, 157 insertions(+), 51 deletions(-) diff --git a/.changeset/notice-inbox-resource-updated.md b/.changeset/notice-inbox-resource-updated.md index 1fcd0a36d..d61f56e83 100644 --- a/.changeset/notice-inbox-resource-updated.md +++ b/.changeset/notice-inbox-resource-updated.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Deliver notices over the `mcp-resource-updated` route from generated MCP servers with a workspace-durable state lifetime: accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, detached from the render that triggered it and coalesced behind a pending write so a slow subscriber never delays a tool result nor grows a queue, abandoned (never awaited) by server teardown when its write cannot settle, and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Fail subscriptions closed when the store is unreadable; volatile lifetimes advertise no subscription capability. Use the new exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, `AGENT_NOTICE_STATE_VERSION`, and the `AgentNoticeError` code `reservation-lost` from `@agent-bundle/runtime/notices`, and `createGeneratedNoticeRuntime` plus `GeneratedRuntimeState.noticeLedger()` from `@agent-bundle/runtime/mount`. Update implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field (breaking). Existing workspace-durable notice stores migrate in place to schema version 2 on first open, with no data loss. (#376) +Deliver notices over the `mcp-resource-updated` route from generated MCP servers with a workspace-durable state lifetime: accept `resources/subscribe` / `resources/unsubscribe` for the reserved inbox resource `AGENT_NOTICE_INBOX_URI`, advertise `resources.subscribe` only when that wiring is active, and send each subscribed session at most one `notifications/resources/updated` per newly eligible pending notice — honouring `nextAttemptAt`, bounded per notice by `retryBudget` across restarts, never duplicated across concurrent server processes over one store, detached from the render that triggered it and coalesced behind a pending write so a slow subscriber never delays a tool result nor grows a queue, abandoned (never awaited) by server teardown when its write or ledger call cannot settle (`closeTimeoutMs` bounds the close-time receipt drain), and recorded as an `availability` receipt (never a delivery claim) that the inbox projection exposes beside `exposure`. Fail subscriptions closed when the store is unreadable; volatile lifetimes advertise no subscription capability. Use the new exports `createNoticeInboxSignaller`, `AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS`, `AGENT_NOTICE_STATE_VERSION`, and the `AgentNoticeError` code `reservation-lost` from `@agent-bundle/runtime/notices`, and `createGeneratedNoticeRuntime` plus `GeneratedRuntimeState.noticeLedger()` from `@agent-bundle/runtime/mount`. Update implementers of `AgentNoticeLedger`: the interface now requires `reserveAvailability()` and `releaseAvailability()`, and `AgentNotice` gains the optional `availabilityReservation` field (breaking). Existing workspace-durable notice stores migrate in place to schema version 2 on first open, with no data loss. (#376) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 2f8930894..685941cf0 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -297,11 +297,13 @@ cannot lose a send the wire already carried; only a process that dies while the ledger is refusing writes leaves an unrecorded send, and its hold then lapses after the TTL. A renewal still awaiting the ledger when a send settles is awaited before the hold is released or finalized, so no orphan hold is -re-created behind a release. Shutdown never waits on the wire, nor on a renewal -the ledger has not answered: `close()` abandons a `resources/updated` write that -has not settled (its outcome is unknown, so it is neither recorded nor released -and its hold lapses after the TTL), commits any owed receipt, and closes the -store, so a subscriber that stopped reading cannot wedge server teardown. The generated +re-created behind a release. Shutdown never waits on the wire, nor on a ledger +call that has not answered: `close()` abandons a `resources/updated` write or +ledger call still pending (its outcome is unknown, so nothing is inferred from +it — no receipt, no release — and its hold lapses after the TTL), then gives +owed receipts and the store close one chance bounded by `closeTimeoutMs` +(default 5 s), so neither a subscriber that stopped reading nor a store that +stopped answering can wedge server teardown. The generated server coalesces observations — one in flight, at most one owed — because every observation reads the whole ledger, so renders completing behind a pending write never queue per-render work. Exposure and availability diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index 9b9bd4f7b..a4e1432c7 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -13,6 +13,15 @@ import { recipientMatchesPrincipal } from './state.js'; /** Consecutive compare-and-swap losses tolerated before a reservation reports failure. */ const MAX_CLAIM_ATTEMPTS = 4; +/** How long `close()` waits on the store for the owed-receipt drain and the store close by default. */ +const DEFAULT_CLOSE_TIMEOUT_MS = 5_000; + +/** Marks a store or wire wait that shutdown abandoned rather than awaited. */ +const CLOSED: unique symbol = Symbol('agent-notices:signaller-closed'); + +/** Bounds one wait by a promise that settles when the wait must be given up. */ +type Bound = (pending: Promise) => Promise; + const isRevisionConflict = (error: unknown): boolean => error instanceof AgentStateError && error.code === 'revision-conflict'; @@ -26,6 +35,13 @@ export interface AgentNoticeInboxStore { } export interface CreateNoticeInboxSignallerOptions { + /** + * Upper bound on how long `close()` waits on the store to commit owed + * receipts and to close. A store that never answers cannot pin server + * teardown; a receipt still owed past the bound is lost with the process and + * its hold lapses after the reservation TTL. Defaults to 5 seconds. + */ + readonly closeTimeoutMs?: number; /** Clock injection for deterministic tests. */ readonly now?: () => Date; /** @@ -67,9 +83,12 @@ export interface AgentNoticeInboxSignaller { readonly subscribed: boolean; /** * Commits any receipt still owed for a send that reached the wire, then - * closes the store. Never waits on a client's wire: a `resources/updated` - * write still pending is abandoned with its outcome unknown, and the hold - * it took lapses after the reservation TTL like any holder gone mid-send. + * closes the store. Never waits on a client's wire, and never waits on the + * store past `closeTimeoutMs`: a `resources/updated` write or ledger call + * still pending is abandoned with its outcome unknown — an owed receipt gets + * one bounded chance to land and is otherwise lost with the process — and + * the hold it took lapses after the reservation TTL like any holder gone + * mid-send. */ close(): Promise; /** @@ -169,6 +188,15 @@ export const createNoticeInboxSignaller = ( const closing = new Promise((resolve) => { resolveClosing = resolve; }); + const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; + // Every wait on the store or the wire inside an observation is raced against + // shutdown: a ledger call that never answers must not pin the queue and, with + // it, the server's teardown. The abandoned call's outcome is unknown, so + // nothing is inferred from it — an owed receipt stays owed, a hold lapses. + const untilClosed = (pending: Promise): Promise => + Promise.race([pending, closing.then((): typeof CLOSED => CLOSED)]); + const abandoned = (what: string): AgentNoticeError => + new AgentNoticeError('aborted', `Notice inbox signaller closed while ${what} was pending`); // Observations and subscription changes serialize on one queue: two renders // completing together cannot both select the same notice and send two // signals for one revision, and an unsubscribe (or re-subscribe) that @@ -211,13 +239,14 @@ export const createNoticeInboxSignaller = ( | { readonly error: unknown; readonly kind: 'failed'; readonly stage: 'read' | 'record' } > => { for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) { - let snapshot: Awaited>; + let snapshot: Awaited> | typeof CLOSED; try { - snapshot = await ledger.read(); + snapshot = await untilClosed(ledger.read()); } catch (error) { return { error, kind: 'failed', stage: 'read' }; } - if (pendingUnsubscribes > 0 || subscription !== current) return { kind: 'unsubscribed' }; + // Shutdown ends the subscription; nothing is claimed or sent for it. + if (snapshot === CLOSED || pendingUnsubscribes > 0 || subscription !== current) return { kind: 'unsubscribed' }; const at = now().toISOString(); const nowMs = Date.parse(at); const noticeIds = Object.freeze(snapshot.notices @@ -228,13 +257,15 @@ export const createNoticeInboxSignaller = ( signalSequence += 1; const reservationKey = `${current.id}:${String(signalSequence)}`; try { - const committed = await ledger.reserveAvailability({ + const committed = await untilClosed(ledger.reserveAvailability({ at, expectedRevision: snapshot.revision, idempotencyKey: `agent-notices:availability:reserve:${reservationKey}`, noticeIds, reservationKey, - }); + })); + // Whether the hold landed is unknown; if it did, it lapses after the TTL. + if (committed === CLOSED) return { kind: 'unsubscribed' }; return { at, kind: 'claimed', noticeIds, reservationKey, revision: committed.revision }; } catch (error) { if (!isRevisionConflict(error)) return { error, kind: 'failed', stage: 'record' }; @@ -270,24 +301,26 @@ export const createNoticeInboxSignaller = ( * not be reached for a whole TTL and another signaller took over) can never * be recorded — the takeover's send is the one the budget counts — so it is * dropped and reported once. The first failure is returned so the caller - * reports it. + * reports it. Every wait is bounded by `bound`: a commit the ledger never + * answers is abandoned with the receipt still owed, never inferred either way. */ - const drainPendingReceipts = async (ledger: AgentNoticeLedger): Promise => { + const drainPendingReceipts = async (ledger: AgentNoticeLedger, bound: Bound): Promise => { for (const receipt of [...pendingReceipts.values()]) { renewalSequence += 1; try { - await ledger.reserveAvailability({ + await bound(ledger.reserveAvailability({ at: now().toISOString(), idempotencyKey: `agent-notices:availability:renew:${receipt.reservationKey}:owed:${String(renewalSequence)}`, noticeIds: receipt.noticeIds, reservationKey: receipt.reservationKey, - }); + })); } catch { // The commit below is the write that matters; a failed renewal only // shortens how long the hold survives a longer outage. } try { - await commitReceipt(ledger, receipt); + const committed = await bound(commitReceipt(ledger, receipt)); + if (committed === CLOSED) return abandoned('an owed availability receipt'); pendingReceipts.delete(receipt.reservationKey); } catch (error) { if (isReservationLost(error)) pendingReceipts.delete(receipt.reservationKey); @@ -310,7 +343,8 @@ export const createNoticeInboxSignaller = ( void serialized(async () => { if (closed || pendingReceipts.size === 0) return; try { - await drainPendingReceipts(await options.store.noticeLedger()); + const ledger = await untilClosed(options.store.noticeLedger()); + if (ledger !== CLOSED) await drainPendingReceipts(ledger, untilClosed); } catch { // Retried on the next tick. } @@ -367,32 +401,21 @@ export const createNoticeInboxSignaller = ( } }; - /** - * Races one protocol write against the signaller closing. The wire is the - * one dependency the signaller cannot bound — a subscriber that stops - * reading leaves the write pending forever — so shutdown must not wait on - * it: the send is abandoned with its outcome unknown and its hold is left to - * lapse after the TTL, exactly as for a holder that vanished mid-send. - */ - const sendUntilClosed = (send: () => Promise): Promise<'closed' | 'sent'> => Promise.race([ - send().then((): 'sent' => 'sent'), - closing.then((): 'closed' => 'closed'), - ]); - const observeOnce = async (send: () => Promise): Promise => { const current = subscription; if (current === undefined && pendingReceipts.size === 0) { return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: undefined }); } - let ledger: AgentNoticeLedger; + let ledger: AgentNoticeLedger | typeof CLOSED; try { - ledger = await options.store.noticeLedger(); + ledger = await untilClosed(options.store.noticeLedger()); } catch (error) { return Object.freeze({ error, kind: 'failed' as const, stage: 'read' as const }); } + if (ledger === CLOSED) return Object.freeze({ kind: 'idle', reason: 'no-subscription', revision: undefined }); // Receipts owed from earlier sends come first: they are facts about the // wire, and a ledger that cannot take them is not one to spend against. - const owed = await drainPendingReceipts(ledger); + const owed = await drainPendingReceipts(ledger, untilClosed); if (owed !== undefined) { scheduleReceiptRetry(); return Object.freeze({ error: owed, kind: 'failed' as const, stage: 'record' as const }); @@ -420,27 +443,32 @@ export const createNoticeInboxSignaller = ( // the notice stays eligible for the next observation, while a successful // send finalizes the hold into the availability receipt. Only the receipt // means the protocol write succeeded. - let wire: 'closed' | 'sent'; + // The wire is the one dependency the signaller cannot bound — a subscriber + // that stops reading leaves the write pending forever — so shutdown does + // not wait on it: the send is abandoned with its outcome unknown and its + // hold is left to lapse after the TTL, exactly as for a holder that + // vanished mid-send. + let wire: void | typeof CLOSED; try { - wire = await renewWhile(ledger, claimed, sendUntilClosed(send)); + wire = await renewWhile(ledger, claimed, untilClosed(send())); } catch (error) { try { - await ledger.releaseAvailability({ + await untilClosed(ledger.releaseAvailability({ idempotencyKey: `agent-notices:availability:release:${claimed.reservationKey}`, noticeIds: claimed.noticeIds, reservationKey: claimed.reservationKey, - }); + })); } catch { // The hold expires on its own after the reservation TTL; the send // failure is the outcome worth reporting. } return Object.freeze({ error, kind: 'failed' as const, stage: 'send' as const }); } - if (wire === 'closed') { + if (wire === CLOSED) { // Whether the write reached the client is unknowable now, so neither a // receipt nor a release would be honest; the hold lapses on its own. return Object.freeze({ - error: new AgentNoticeError('aborted', 'Notice inbox signaller closed while a resources/updated write was pending'), + error: abandoned('a resources/updated write'), kind: 'failed' as const, stage: 'send' as const, }); @@ -455,7 +483,15 @@ export const createNoticeInboxSignaller = ( }); pendingReceipts.set(receipt.reservationKey, receipt); try { - const committed = await commitReceipt(ledger, receipt); + const committed = await untilClosed(commitReceipt(ledger, receipt)); + if (committed === CLOSED) { + // Still owed: the close-time drain gets one bounded chance to land it. + return Object.freeze({ + error: abandoned('an availability receipt commit'), + kind: 'failed' as const, + stage: 'record' as const, + }); + } pendingReceipts.delete(receipt.reservationKey); return Object.freeze({ kind: 'signalled', noticeIds: claimed.noticeIds, revision: committed.revision }); } catch (error) { @@ -484,15 +520,28 @@ export const createNoticeInboxSignaller = ( } return serialized(async () => { subscription = undefined; - if (pendingReceipts.size > 0) { - try { - await drainPendingReceipts(await options.store.noticeLedger()); - } catch { - // A receipt still owed at close is lost with the process; the hold - // it left behind lapses after the TTL. + // The drain and the store close get one bounded chance: a store that + // never answers must not pin teardown, and a receipt still owed past + // the bound is lost with the process; its hold lapses after the TTL. + let deadlineTimer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(CLOSED), closeTimeoutMs); + deadlineTimer.unref?.(); + }); + const untilDeadline: Bound = (pending) => Promise.race([pending, deadline]); + try { + if (pendingReceipts.size > 0) { + try { + const ledger = await untilDeadline(options.store.noticeLedger()); + if (ledger !== CLOSED) await drainPendingReceipts(ledger, untilDeadline); + } catch { + // Reported by the observation that owed it; nothing more to do here. + } } + await untilDeadline(options.store.close()); + } finally { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); } - await options.store.close(); }); }, observe(send: () => Promise): Promise { @@ -500,8 +549,10 @@ export const createNoticeInboxSignaller = ( }, subscribe(principal: AgentNoticePrincipal): Promise { return serialized(async () => { - const ledger = await options.store.noticeLedger(); - await ledger.read(); + const ledger = await untilClosed(options.store.noticeLedger()); + if (ledger === CLOSED || await untilClosed(ledger.read()) === CLOSED) { + throw abandoned('a resources/subscribe'); + } // A client that repeats resources/subscribe without unsubscribing is // still the same continuously subscribed connection: keeping its // signalled set means a notice with retryBudget > 1 is not re-sent diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index 3097f651d..0ae91c992 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -938,6 +938,59 @@ describe('notice inbox resources/updated signaller', () => { await driver.close(); }); + it('closes within its bound when the receipt commit never settles, keeping the receipt owed rather than inferred', async () => { + const { driver, ledger } = await openLedger(); + // The wire write succeeds; the ledger then stops answering receipt commits. + let commits = 0; + const stuckCommits: AgentNoticeLedger = Object.freeze({ + ...ledger, + signalAvailability: () => { + commits += 1; + return new Promise(() => undefined); + }, + }); + let storeClosed = 0; + const signaller = createNoticeInboxSignaller({ + closeTimeoutMs: 50, + now: () => new Date(T1), + store: { + close: async () => { + storeClosed += 1; + }, + noticeLedger: async () => stuckCommits, + }, + }); + await signaller.subscribe(principal('s1')); + await publish(ledger, { sessionId: 's1' }); + const { send, sends } = sender(); + const observing = signaller.observe(send); + for (let i = 0; i < 200 && commits === 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(sends).toHaveLength(1); + + // The observation at the head of the queue is abandoned by shutdown, the + // close-time drain retries the owed receipt once within the bound, and the + // store still closes when that retry never answers either. + let closed = false; + const closing = signaller.close().then(() => { + closed = true; + }); + await Promise.race([closing, new Promise((resolve) => setTimeout(resolve, 2_000))]); + expect(closed).toBe(true); + expect(storeClosed).toBe(1); + expect(commits).toBe(2); + const outcome = await observing; + expect(outcome).toMatchObject({ kind: 'failed', stage: 'record' }); + expect((outcome as { error: AgentNoticeError }).error.code).toBe('aborted'); + // Nothing is inferred from a commit that never answered: no receipt was + // recorded, and the hold is left to lapse. + const notice = (await ledger.read()).notices[0]; + expect(notice).not.toHaveProperty('availability'); + expect(notice?.availabilityReservation).toMatchObject({ at: T1 }); + await driver.close(); + }); + it('treats a reservation as held until its TTL elapses, then as abandoned', async () => { const { driver, ledger } = await openLedger(); const published = await publish(ledger, { sessionId: 's1' });