From 326a6915dfdaa61215b70fa799f3881b8b76de0e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 06:54:24 +0000 Subject: [PATCH 1/2] feat(notices): address a notice to a lineage conversation or root (#458) recipient.conversation matches request.lineage.conversation exactly and recipient.root matches every request whose lineage root is that id, in conjunction with the existing actor/host/session/workspace axes. The notice principal carries the request's lineage on every generated surface; the ledger journals only { conversation, root } of the admitting lineage as an additive optional field (no definition version bump). examples/worktree-proximity addresses proximity notices to the other actor's conversation instead of its worktree; the derived worktree: actor keeps the workspace axis. --- .changeset/458-lineage-notice-recipients.md | 6 + docs/entry-conventions.md | 5 +- examples/worktree-proximity/README.md | 37 ++- .../worktree-proximity/src/event-support.ts | 41 ++- .../src/events/session/start.tsx | 4 +- .../src/events/tool/before.tsx | 13 +- .../tests/route-unit/routes.test.ts | 110 ++++++- .../agent-bundle/src/mcp-server-runtime.ts | 5 +- packages/agent-bundle/src/test/mcp.ts | 1 + .../tests/route-unit/lineage-notices.test.ts | 232 +++++++++++++++ .../tests/worktree-proximity-journeys.test.ts | 76 +++-- packages/rsc-runtime/README.md | 19 +- packages/rsc-runtime/src/agent-request.ts | 2 +- packages/rsc-runtime/src/notices/contract.ts | 33 ++- packages/rsc-runtime/src/notices/index.ts | 3 + packages/rsc-runtime/src/notices/ledger.ts | 9 +- packages/rsc-runtime/src/notices/state.ts | 59 +++- .../rsc-runtime/tests/notices-ledger.test.ts | 273 ++++++++++++++++++ .../tests/notices-redaction.test.ts | 1 + .../tests/notices-resource-updated.test.ts | 1 + website/plugins/generated-reference.ts | 28 ++ 21 files changed, 887 insertions(+), 71 deletions(-) create mode 100644 .changeset/458-lineage-notice-recipients.md create mode 100644 packages/agent-bundle/tests/route-unit/lineage-notices.test.ts diff --git a/.changeset/458-lineage-notice-recipients.md b/.changeset/458-lineage-notice-recipients.md new file mode 100644 index 000000000..c3af3ae4d --- /dev/null +++ b/.changeset/458-lineage-notice-recipients.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +Address a notice to one agent conversation or to a whole conversation tree: `AgentRecipient` gains `conversation` (matches `request.lineage.conversation` exactly) and `root` (matches every request whose `request.lineage.root` is that id), matched in conjunction with the existing `actor` / `host` / `session` / `workspace` axes at admission, inbox reads, `resources/updated` eligibility, and acknowledgement. `AgentNoticePrincipal` carries the request's `lineage`, which every generated surface (event routes, MCP tools, routed CLI, rendered scripts) now mounts; unresolved lineage never matches a lineage-addressed recipient. The ledger journals only `{ conversation, root }` of the admitting lineage as an additive optional field — no state-definition version bump, journals written before the axes replay unchanged. `notices.publish()` rejects blank `conversation` / `root` with `invalid-input`. `examples/worktree-proximity` addresses its proximity notices to the other actor's conversation instead of its worktree. (#458) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e5afdf1ed..63de2ce82 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -158,7 +158,10 @@ directory. The npm package's routed CLI bin and rendered scripts use (`/bin/.mjs`) derives the artifact root from the parent of its own `bin/` directory instead, like the MCP worker. Notice authorization is deliberately permissive in generated mounting v1 (`authorized`); recipient/principal matching remains -enforced by the ledger, while application authorization policy is deferred. +enforced by the ledger — every generated scope mounts the request's `lineage` +on the notice principal, so `recipient.conversation` / `recipient.root` are +matched against `request.lineage` on every surface — while application +authorization policy is deferred. Each cross-request notice route is selected from the target host's pinned `noticeDelivery` table, exposed as `TargetAdapter.noticeDelivery` / diff --git a/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md index 6d13b1032..4011b7aeb 100644 --- a/examples/worktree-proximity/README.md +++ b/examples/worktree-proximity/README.md @@ -17,8 +17,10 @@ This example is intentionally not part of the newcomer path. 3. `tool/before` records current path and dependency intent. 4. The pure proximity domain compares active intents from different worktrees. 5. A conflict renders an `Agent.Context` warning with an `outcome: continue` - result and publishes a recipient-scoped notice. -6. The other actor's next event admits the pending notice, changes its + result and publishes a notice addressed to the other actor's lineage + conversation (`recipient.conversation`). +6. That actor's next event — and only that actor's, even when a sibling works + in the same worktree — admits the pending notice, changes its evidence-backed state to `attempted`, and renders its content as context. 7. `tool/after` records an empty current intent, and `stop` marks the actor stopped. @@ -84,10 +86,12 @@ to the root; `transcript` is read from the host's own rollout file), and is `(await agent()).lineage.root` when the runtime resolved a lineage and the native `session_id` otherwise. - `agent/start` records the child and its parent from `request.lineage` - (`subagent.id`, `parent`, `resolution`) when the runtime placed the start + (`conversation`, `parent`, `resolution`) when the runtime placed the start below the root — which needs the spawning `Agent`/`Task` `tool/before` to have passed through the same shared runtime — and from the native `agent_id` - + `session_id` pair otherwise. + + `session_id` pair otherwise. Either way the child's actor id is its + lineage conversation (Claude and Codex spell it `agent_id`), which is what a + directed notice targets. - Claude and Codex put the subagent's `agent_id` on every one of its hook payloads; Cursor gives the child a fresh `conversation_id` that only the runtime registry can bind to its `subagentStart`. A tool or stop event @@ -121,13 +125,19 @@ mounted handle, they return an unavailable result and the route renders that reason as `Agent.Context`; there is no fallback write path. Notice admission runs once per event invocation in the render scope. -Generated event principals in v1 mount host, session, and workspace identity, -but not actor identity. Proximity notices therefore target the recipient -worktree through `recipient.workspace.root`, while their content and dedupe -keys continue to name the target actor. Lineage-addressed delivery -(`recipient.conversation` / `recipient.root` matched against -`request.lineage`) is tracked in -[agent-bundle#458](https://github.com/scriptedalchemy/agent-bundle/issues/458). +Generated event principals mount host, session, workspace, and lineage +identity, but not actor identity (#391/#444). A proximity notice is therefore +addressed to the other actor's lineage conversation — +`recipient: { conversation }`, matched against the admitting request's +`request.lineage.conversation` — so only that agent thread admits it, even +when a sibling shares its worktree and every subagent shares the root +`session_id`. An event whose lineage the runtime could not resolve (no shared +runtime, an unplaced `agent_id`) is never the addressed agent; the notice +stays pending for the next event that is. Only the application's derived +`worktree:` fallback actor, which names no conversation, is still +addressed through `recipient.workspace.root`. `recipient.root` (every +conversation under one root) is available but unused here: proximity is a +message to one peer, not to the tree. `(await agent()).notices.read()` exposes only deliveries attempted for the current invocation; publisher-scoped visibility is [#460](https://github.com/scriptedalchemy/agent-bundle/issues/460). The @@ -142,7 +152,10 @@ and exercises the documented journeys against one shared durable runtime owner. The root integration-pool suite `packages/agent-bundle/tests/worktree-proximity-journeys.test.ts` builds the real artifact, invokes generated hooks as separate processes against linked -Git worktrees, and proves warning, workspace-directed delivery, replay +Git worktrees, and proves warning, conversation-directed delivery (the +spawning `Agent` `PreToolUse` opens the registry's spawn window and the +child's hook payloads carry its `agent_id`, as Claude's do; an event the +runtime cannot place under that child is not delivered to), replay idempotency, and exact-revision restart durability through the generated MCP server. Journey 8 has two honesty layers: the generated wrapper fails closed on an identity-less `SubagentStart` for host contracts that require diff --git a/examples/worktree-proximity/src/event-support.ts b/examples/worktree-proximity/src/event-support.ts index 6f203995e..559692878 100644 --- a/examples/worktree-proximity/src/event-support.ts +++ b/examples/worktree-proximity/src/event-support.ts @@ -3,12 +3,18 @@ import { type AgentDocumentNode, type AgentLineage, type AgentNoticeDelivery, + type AgentRecipient, type Observed, } from '@agent-bundle/runtime'; import type { AvailableWorktree } from './api.js'; import type { TopologyAccess } from './coordination.js'; -import type { IdentityProvenance, TopologyState } from './state.js'; +import type { Actor, IdentityProvenance, TopologyState } from './state.js'; + +/** The root actor observed at `session/start` is `session:`. */ +export const ROOT_ACTOR_PREFIX = 'session:'; +/** The application's own fallback identity for a worktree no envelope names an agent for. */ +export const DERIVED_ACTOR_PREFIX = 'worktree:'; export interface EventIdentity { readonly idempotencyKey: string; @@ -37,19 +43,42 @@ export const requestLineage = async (): Promise> => (awai * The subagent a request speaks for, when the runtime's `request.lineage` * places it below the root. The runtime resolves the same shape on every * host, so the route never has to know that Claude and Codex spell the child - * `agent_id` while Cursor gives it a fresh `conversation_id`. A root request - * (depth 0) is deliberately not a child; the root actor is observed at - * `session/start`. + * `agent_id` while Cursor gives it a fresh `conversation_id`: the actor id is + * the lineage `conversation`, which is also the id a directed notice targets + * through `recipient.conversation`. A root request (depth 0) is deliberately + * not a child; the root actor is observed at `session/start`. */ export const childFromLineage = (lineage: Observed): CarriedChild | undefined => { if (lineage.state !== 'available' || lineage.value.depth === 0) return undefined; return { - id: lineage.value.subagent?.id ?? lineage.value.conversation, + id: lineage.value.conversation, parentSessionId: lineage.value.parent ?? lineage.value.root, source: lineage.value.resolution, }; }; +/** + * Where a proximity notice for `actor` is addressed. An actor whose id is a + * lineage conversation — the root observed at `session/start`, or a child + * named by `request.lineage` or by the host's own `agent_id` (Claude and Codex + * put it on every one of the subagent's hook payloads, and the runtime + * resolves it as that agent's `conversation`) — is addressed through + * `recipient.conversation`, so only that agent thread admits the notice even + * when a sibling shares its worktree. The application's derived + * `worktree:` fallback names no conversation, so the notice stays + * addressed to the worktree through `recipient.workspace.root`. + */ +export const noticeRecipientFor = (actor: Actor | undefined, worktreeRoot: string): AgentRecipient => { + if (actor === undefined || actor.provenance.id === 'derived') { + return { workspace: { root: worktreeRoot } }; + } + return { + conversation: actor.kind === 'root' && actor.id.startsWith(ROOT_ACTOR_PREFIX) + ? actor.id.slice(ROOT_ACTOR_PREFIX.length) + : actor.id, + }; +}; + /** * The child actor one envelope carries: the runtime lineage first, then the * host's own `agent_id` (Claude and Codex put the subagent's id on every one @@ -163,7 +192,7 @@ export const actorForWorktree = async ( } const actor: ResolvedActor = { - id: `worktree:${worktree.root}`, + id: `${DERIVED_ACTOR_PREFIX}${worktree.root}`, source: 'derived', }; await topology.dispatch('actorObserved', { diff --git a/examples/worktree-proximity/src/events/session/start.tsx b/examples/worktree-proximity/src/events/session/start.tsx index b0de43550..31e000323 100644 --- a/examples/worktree-proximity/src/events/session/start.tsx +++ b/examples/worktree-proximity/src/events/session/start.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { worktree } from '../../api.js'; import { withNotices, withTopology } from '../../coordination.js'; -import { deliveryContexts, nativeString, requestLineage } from '../../event-support.js'; +import { ROOT_ACTOR_PREFIX, deliveryContexts, nativeString, requestLineage } from '../../event-support.js'; export const config = { runtime: 'shared', @@ -40,7 +40,7 @@ export default async function SessionStart({ ); } - const actorId = `session:${root.id}`; + const actorId = `${ROOT_ACTOR_PREFIX}${root.id}`; const topologyResult = await withTopology(async (topology) => { await topology.dispatch('actorObserved', { id: actorId, diff --git a/examples/worktree-proximity/src/events/tool/before.tsx b/examples/worktree-proximity/src/events/tool/before.tsx index 3990e6a65..0d0828ca0 100644 --- a/examples/worktree-proximity/src/events/tool/before.tsx +++ b/examples/worktree-proximity/src/events/tool/before.tsx @@ -9,6 +9,7 @@ import { actorForWorktree, deliveryContexts, extractIntent, + noticeRecipientFor, } from '../../event-support.js'; export const config = { @@ -47,6 +48,7 @@ export default async function BeforeTool({ }); return { actor, + actors: committed.state.actors, conflicts: findProximity(committed.state, currentWorktree.root, { actorId: actor.id, dependencies: intent.dependencies, @@ -66,6 +68,13 @@ export default async function BeforeTool({ const noticeResult = await withNotices(async (notices) => { const deliveries = await notices.read(); for (const [index, conflict] of resolution.conflicts.entries()) { + // The other actor's conversation is the recipient: only that agent + // thread admits the notice, even when a sibling shares its worktree. + // A derived actor has no conversation, so its worktree is addressed. + const recipient = noticeRecipientFor( + resolution.actors.find((actor) => actor.id === conflict.actorId), + conflict.worktreeRoot, + ); await notices.publish({ content: { root: { @@ -77,9 +86,7 @@ export default async function BeforeTool({ }, dedupeKey: `proximity:${resolution.actor.id}:${conflict.actorId}:${conflict.summary}`, priority: 'high', - recipient: { - workspace: { root: conflict.worktreeRoot }, - }, + recipient, }, { idempotencyKey: `${canonical.idempotencyKey}:notice:${String(index)}`, }); diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index 70c5ca956..b38252eef 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -149,6 +149,9 @@ const bindActors = async (): Promise => { ); }; +// Claude and Codex carry the subagent's `agent_id` on every one of its hook +// payloads and the shared runtime resolves it as that agent's lineage +// conversation, so a child's tool events arrive with `request.lineage` set. const recordIntent = ( actorId: 'agent-a' | 'agent-b', root: string, @@ -159,6 +162,7 @@ const recordIntent = ( 'event:tool/before', 'tool/before', { + agent_id: actorId, cwd: root, hook_event_name: 'PreToolUse', session_id: 'root-session', @@ -168,6 +172,30 @@ const recordIntent = ( id, root, actorId, + childLineage(actorId), +); + +const completeIntent = ( + root: string, + path: string, + id: string, + actorId?: string, + lineage?: Observed, +) => renderEvent( + 'event:tool/after', + 'tool/after', + { + ...(actorId === undefined ? {} : { agent_id: actorId }), + cwd: root, + hook_event_name: 'PostToolUse', + session_id: 'root-session', + tool_input: { file_path: path }, + tool_name: 'Edit', + }, + id, + root, + actorId, + lineage, ); beforeEach(async () => { @@ -219,9 +247,11 @@ describe('worktree proximity journeys', () => { expect(rendered.document.value).toEqual({ outcome: 'continue' }); const notices = await mounted.notices(); + // The notice names the other agent's lineage conversation, not its + // worktree: only that agent thread admits it (#458). expect(notices.notices).toEqual([ expect.objectContaining({ - recipient: { workspace: { root: worktrees.a } }, + recipient: { conversation: 'agent-a' }, state: 'pending', }), ]); @@ -232,20 +262,7 @@ describe('worktree proximity journeys', () => { await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); - const delivered = await renderEvent( - 'event:tool/after', - 'tool/after', - { - cwd: worktrees.a, - hook_event_name: 'PostToolUse', - session_id: 'root-session', - tool_input: { file_path: 'src/shared.ts' }, - tool_name: 'Edit', - }, - 'intent:a:after', - worktrees.a, - 'agent-a', - ); + const delivered = await completeIntent(worktrees.a, 'src/shared.ts', 'intent:a:after', 'agent-a', childLineage('agent-a')); expectDocument(delivered) .toHaveStatus('success') @@ -259,6 +276,69 @@ describe('worktree proximity journeys', () => { }); }); + it('delivers a conversation-addressed notice to one agent even when a sibling shares its worktree (#458)', async () => { + await bindActors(); + await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); + + // agent-c works in agent-a's worktree: same host, session, and workspace + // as agent-a, so a workspace-addressed notice could not tell them apart. + const sibling = await completeIntent(worktrees.a, 'src/other.ts', 'intent:c:after', 'agent-c', childLineage('agent-c')); + expectDocument(sibling).toHaveStatus('success').toHaveNodeKinds(['result']); + // An event in that worktree whose lineage the runtime could not resolve + // is not the addressed agent either, even though the worktree binding + // attributes its intent to agent-a. + const unresolved = await completeIntent(worktrees.a, 'src/other.ts', 'intent:unresolved:after'); + expectDocument(unresolved).toHaveStatus('success').toHaveNodeKinds(['result']); + + expect((await mounted.notices()).notices).toEqual([expect.objectContaining({ attempts: [], state: 'pending' })]); + expect((await mounted.read()).state.actors).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'agent-a', worktreeRoot: worktrees.a }), + expect.objectContaining({ id: 'agent-c', worktreeRoot: worktrees.a }), + ])); + + const delivered = await completeIntent(worktrees.a, 'src/shared.ts', 'intent:a:after', 'agent-a', childLineage('agent-a')); + expectDocument(delivered) + .toHaveStatus('success') + .toContainContext('Directed proximity notice') + .toContainContext('src/shared.ts'); + expect((await mounted.notices()).notices[0]).toMatchObject({ + attempts: [expect.objectContaining({ invocationId: 'invocation:intent:a:after' })], + state: 'attempted', + }); + }); + + it('addresses a derived actor through its worktree because it has no conversation', async () => { + await bindActors(); + // No agent_id and no lineage in a worktree no actor is bound to: the + // application falls back to the derived `worktree:` actor. + await renderEvent( + 'event:tool/before', + 'tool/before', + { + cwd: '/repo/.worktrees/c', + hook_event_name: 'PreToolUse', + session_id: 'root-session', + tool_input: { file_path: 'src/shared.ts' }, + tool_name: 'Edit', + }, + 'intent:derived', + '/repo/.worktrees/c', + ); + const rendered = await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + expectDocument(rendered).toHaveStatus('success').toContainContext('Proximity warning'); + + expect((await mounted.notices()).notices).toEqual([ + expect.objectContaining({ + recipient: { workspace: { root: '/repo/.worktrees/c' } }, + state: 'pending', + }), + ]); + + const delivered = await completeIntent('/repo/.worktrees/c', 'src/shared.ts', 'intent:derived:after'); + expectDocument(delivered).toContainContext('Directed proximity notice'); + }); + it('deduplicates a repeated native intent envelope (journey 7)', async () => { await bindActors(); const replayed = eventInput( diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index a12ae5358..d33c1b9bf 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -610,6 +610,7 @@ export interface GeneratedEventRuntimeBinding { export interface GeneratedNoticePrincipal { readonly actor: Observed; readonly host: Observed; + readonly lineage: Observed; readonly session: Observed; readonly workspace: Observed; } @@ -707,12 +708,14 @@ const installNoticeInboxSubscriptions = ( ) => { assertInboxUri(request.params.uri); // Subscriptions are not tool calls: no pre-tool hook precedes them, so - // there is no correlation window to resolve lineage through. + // there is no correlation window to resolve lineage through — a + // subscriber therefore never matches a `conversation`/`root` recipient. const identity = requestIdentity(context, protocol.getClientVersion()?.name, unavailable('not-provided')); try { await notices.subscribe({ actor: identity.actor ?? unavailable(), host: identity.host ?? unavailable(), + lineage: identity.lineage, session: identity.session ?? unavailable(), workspace: identity.workspace, }); diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index d8ec29b63..2b7f58092 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -335,6 +335,7 @@ const withContextIdentity = ( subscribe: (principal: GeneratedNoticePrincipal) => signaller.subscribe({ actor: context.actor ?? principal.actor, host: context.host ?? principal.host, + lineage: context.lineage ?? principal.lineage, session: context.session ?? principal.session, workspace: context.workspace ?? principal.workspace, }), diff --git a/packages/agent-bundle/tests/route-unit/lineage-notices.test.ts b/packages/agent-bundle/tests/route-unit/lineage-notices.test.ts new file mode 100644 index 000000000..5d1632f2e --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/lineage-notices.test.ts @@ -0,0 +1,232 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { Agent, agent, type AgentLineage, type Observed } from '@agent-bundle/runtime'; +import { createAgentLineageRegistry, type AgentLineageRegistry, type LineageHost } from '@agent-bundle/runtime/lineage'; +import { + agentNoticeStateDefinition, + createAgentNoticeLedger, + type AgentNoticeLedger, + type AgentRecipient, +} from '@agent-bundle/runtime/notices'; +import { createMemoryStateDriver } from '@agent-bundle/runtime/state'; +import { describe, expect, it } from '@rstest/core'; +import { createElement } from 'react'; + +import { expectDocument } from '../../src/test/matchers.ts'; +import { renderRoute } from '../../src/test/render.ts'; + +interface FixtureRecord { + readonly event?: { + readonly canonical: { readonly event: string; readonly idempotencyKey: string; readonly observedAt: string }; + readonly native: Readonly>; + }; + readonly kind: 'event' | 'mcp' | 'cli'; +} + +const fixture = (name: string): FixtureRecord[] => readFileSync( + resolve(import.meta.dirname, '../../../../fixtures/host-lineage', name), + 'utf8', +).trim().split('\n').map((line) => JSON.parse(line) as FixtureRecord); + +/** + * Replays a redacted live capture through the runtime's lineage registry the + * way the warm runtime does, and keeps the lineage each hook payload resolved + * to. Notices are then addressed with exactly those lineages: the root's own + * (`depth` 0) and each `agent/start`'s child conversation. + */ +const replay = async (host: LineageHost, records: readonly FixtureRecord[]): Promise<{ + readonly children: readonly Observed[]; + readonly registry: AgentLineageRegistry; + readonly root: Observed; +}> => { + const registry = createAgentLineageRegistry(); + let root: Observed | undefined; + // Distinct depth-1 conversations, as the first hook payload each one + // resolved with: Claude and Codex name the child on its `agent/start`, + // Cursor binds the fresh `conversation_id` only on the child's first hook. + const children = new Map>(); + for (const record of records) { + if (record.event === undefined) continue; + const lineage = await registry.observe({ + event: record.event.canonical.event, + host, + idempotencyKey: record.event.canonical.idempotencyKey, + native: record.event.native, + observedAt: record.event.canonical.observedAt, + }); + if (lineage.state !== 'available') continue; + if (lineage.value.depth === 0) root ??= lineage; + if (lineage.value.depth === 1 && !children.has(lineage.value.conversation)) { + children.set(lineage.value.conversation, lineage); + } + } + if (root === undefined) throw new Error(`${host} capture resolved no root lineage`); + return { children: [...children.values()], registry, root }; +}; + +const conversationOf = (lineage: Observed): string => { + if (lineage.state !== 'available') throw new Error('fixture lineage is unavailable'); + return lineage.value.conversation; +}; + +const openLedger = async (): Promise<{ readonly close: () => Promise; readonly ledger: AgentNoticeLedger }> => { + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentNoticeStateDefinition('process')); + return { + close: () => driver.close(), + ledger: createAgentNoticeLedger(store, { authorize: () => ({ state: 'authorized' }) }), + }; +}; + +/** An event route that publishes one notice to `recipient`. */ +const Publish = (recipient: AgentRecipient, text: string, retryBudget = 1) => async (): Promise => { + const { notices } = await agent(); + const published = await notices!.publish({ + content: { root: { kind: 'text', text }, status: 'success', version: 1 }, + priority: 'high', + recipient, + retryBudget, + }, { idempotencyKey: `publish:${text}` }); + return createElement(Agent.Result, { value: { noticeId: published.notice.id, state: published.notice.state } }); +}; + +/** An event route that only reads what admission delivered to this event. */ +const Receive = async (): Promise => { + const { notices } = await agent(); + const deliveries = await notices!.read(); + return createElement( + Agent.Result, + { value: { delivered: deliveries.map((delivery) => delivery.notice.id) } }, + ...deliveries.map((delivery) => createElement(Agent.Context, { + children: `notice: ${ + delivery.notice.content.root.kind === 'text' ? delivery.notice.content.root.text : delivery.notice.content.root.kind + }`, + key: delivery.notice.id, + })), + ); +}; + +let sequence = 0; +const eventInput = (host: string) => { + sequence += 1; + return { + canonical: { + event: 'tool/after', + idempotencyKey: `lineage-notices:${String(sequence)}`, + observedAt: `2026-09-03T12:00:${String(sequence).padStart(2, '0')}.000Z`, + provenance: { host, hostContractRevision: 'route-unit', nativeEvent: 'PostToolUse', source: 'native' }, + sequence, + }, + native: { hook_event_name: 'PostToolUse' }, + }; +}; + +const renderEvent = ( + module: () => Promise, + host: string, + ledger: AgentNoticeLedger, + lineage: Observed, +) => renderRoute({ default: module as never }, { + context: { + host: { source: 'native', state: 'available', value: { name: host } }, + lineage, + noticeLedger: ledger, + // Claude and Codex put the root `session_id` on every subagent hook, so + // the session axis is deliberately identical for every principal here. + session: { source: 'native', state: 'available', value: { sessionId: 'shared-root-session' } }, + workspace: { source: 'native', state: 'available', value: { root: '/workspace' } }, + }, + input: eventInput(host), + kind: 'event-route', + routeId: 'event:tool/after', +}); + +const delivered = (rendered: Awaited>): readonly string[] => + (rendered.document.value as { delivered: readonly string[] }).delivered; + +/** + * Lineage-addressed notices (#458) over the 2026-09-03 live host captures: + * the same principals the generated runtime mounts, with `request.lineage` + * resolved by the registry from the recorded hook payloads. + */ +describe.each([ + ['claude', 'claude-2.1.259-orchestration.ndjson', 3], + ['codex', 'codex-0.147.0.ndjson', 1], + ['cursor', 'cursor-3.18.25.ndjson', 2], +] as const)('%s capture (%s)', (host, file, expectedChildren) => { + it('lets the parent address one child conversation and no sibling, while the root subtree reaches every conversation under it', async () => { + const { children, root } = await replay(host, fixture(file)); + expect(children.length).toBeGreaterThanOrEqual(expectedChildren); + const [target, sibling] = children; + const { close, ledger } = await openLedger(); + try { + // Parent → child: `recipient.conversation` names exactly one thread. + const published = await renderEvent( + Publish({ conversation: conversationOf(target!) }, `for ${conversationOf(target!)}`), + host, + ledger, + root, + ); + expectDocument(published).toHaveStatus('success'); + const noticeId = (published.document.value as { noticeId: string }).noticeId; + + if (sibling !== undefined) { + // A sibling shares the root session and workspace; only its conversation differs. + expect(delivered(await renderEvent(Receive, host, ledger, sibling))).toEqual([]); + } + // The root itself is not the child either. + expect(delivered(await renderEvent(Receive, host, ledger, root))).toEqual([]); + const admitted = await renderEvent(Receive, host, ledger, target!); + expect(delivered(admitted)).toEqual([noticeId]); + expectDocument(admitted).toContainContext(`notice: for ${conversationOf(target!)}`); + expect((await ledger.read()).notices.find((notice) => notice.id === noticeId)).toMatchObject({ + attempts: [expect.objectContaining({ channel: 'next-event' })], + recipient: { conversation: conversationOf(target!) }, + state: 'attempted', + }); + + // Child → siblings: `recipient.root` is the whole tree under the root. + // One attempt per member: each admitting event spends one budget slot. + const members = [...children, root]; + const broadcast = await renderEvent( + Publish({ root: conversationOf(root) }, 'to the whole tree', members.length), + host, + ledger, + target!, + ); + const broadcastId = (broadcast.document.value as { noticeId: string }).noticeId; + // A conversation from another tree never matches, and spends nothing. + const elsewhere: Observed = { + source: 'native', + state: 'available', + value: { conversation: 'other-conversation', depth: 0, resolution: 'native', root: 'other-root' }, + }; + expect(delivered(await renderEvent(Receive, host, ledger, elsewhere))).toEqual([]); + for (const member of members) { + expect(delivered(await renderEvent(Receive, host, ledger, member))).toEqual([broadcastId]); + } + expect((await ledger.read()).notices.find((notice) => notice.id === broadcastId)).toMatchObject({ + attempts: members.map(() => expect.objectContaining({ channel: 'next-event' })), + recipient: { root: conversationOf(root) }, + state: 'attempted', + }); + } finally { + await close(); + } + }); + + it('never admits a lineage-addressed notice on a request whose lineage the runtime could not resolve', async () => { + const { children, root } = await replay(host, fixture(file)); + const target = children[0]!; + const { close, ledger } = await openLedger(); + try { + await renderEvent(Publish({ conversation: conversationOf(target) }, 'unresolved'), host, ledger, root); + const unresolved: Observed = { reason: 'no-shared-runtime', state: 'unavailable' }; + expect(delivered(await renderEvent(Receive, host, ledger, unresolved))).toEqual([]); + expect((await ledger.read()).notices[0]).toMatchObject({ attempts: [], state: 'pending' }); + } finally { + await close(); + } + }); +}); diff --git a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts index 1759ee3e8..ea73a5821 100644 --- a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts +++ b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts @@ -20,8 +20,8 @@ interface ActorStatus { readonly kind: 'child' | 'root'; readonly parentSessionId?: string; readonly provenance: { - readonly id: 'derived' | 'native'; - readonly parentSessionId?: 'derived' | 'native'; + readonly id: 'derived' | 'native' | 'registry'; + readonly parentSessionId?: 'derived' | 'native' | 'registry'; readonly worktreeRoot?: 'derived' | 'native'; }; readonly status: 'active' | 'stopped'; @@ -271,24 +271,37 @@ it('proves worktree proximity journeys across real processes and linked worktree source: 'startup', transcript_path: transcriptPath, }, hookEnvironment); - await runHook(fixture.hooks.agentStart, fixture.worktreeA, { - agent_id: 'agent-a', - agent_type: 'implementation', - cwd: fixture.worktreeA, - hook_event_name: 'SubagentStart', - session_id: sessionId, - transcript_path: transcriptPath, - }, hookEnvironment); - await runHook(fixture.hooks.agentStart, fixture.worktreeB, { - agent_id: 'agent-b', - agent_type: 'implementation', - cwd: fixture.worktreeB, - hook_event_name: 'SubagentStart', - session_id: sessionId, - transcript_path: transcriptPath, - }, hookEnvironment); + // Claude spawns a subagent from the root's `Agent` tool call: its PreToolUse + // opens the spawn window the shared runtime's lineage registry places the + // following `SubagentStart` under, and every one of the child's later hook + // payloads carries its `agent_id` + // (fixtures/host-lineage/claude-2.1.259-orchestration.ndjson). The registry + // resolves that id as the child's lineage conversation — the axis the + // proximity notice is addressed to. + const spawn = async (agentId: string, worktree: string): Promise => { + await runHook(fixture.hooks.beforeTool, fixture.repoRoot, { + cwd: fixture.repoRoot, + hook_event_name: 'PreToolUse', + session_id: sessionId, + tool_input: { prompt: `work in ${worktree}`, subagent_type: 'implementation' }, + tool_name: 'Agent', + tool_use_id: `spawn-${agentId}`, + transcript_path: transcriptPath, + }, hookEnvironment); + await runHook(fixture.hooks.agentStart, worktree, { + agent_id: agentId, + agent_type: 'implementation', + cwd: worktree, + hook_event_name: 'SubagentStart', + session_id: sessionId, + transcript_path: transcriptPath, + }, hookEnvironment); + }; + await spawn('agent-a', fixture.worktreeA); + await spawn('agent-b', fixture.worktreeB); const intentA = { + agent_id: 'agent-a', cwd: fixture.worktreeA, hook_event_name: 'PreToolUse', session_id: sessionId, @@ -298,6 +311,7 @@ it('proves worktree proximity journeys across real processes and linked worktree transcript_path: transcriptPath, } as const; const intentB = { + agent_id: 'agent-b', cwd: fixture.worktreeB, hook_event_name: 'PreToolUse', session_id: sessionId, @@ -324,7 +338,22 @@ it('proves worktree proximity journeys across real processes and linked worktree ); expect(hookText(secondIntent)).toContain(warning); + // Only agent-a's own conversation admits the notice: an event in worktree A + // that the runtime cannot place under agent-a (no `agent_id`) is not it. + const notAgentA = await runHook(fixture.hooks.afterTool, fixture.worktreeA, { + cwd: fixture.worktreeA, + hook_event_name: 'PostToolUse', + session_id: sessionId, + tool_input: { file_path: 'src/other.ts' }, + tool_name: 'Edit', + tool_response: { ok: true }, + tool_use_id: 'not-agent-a-after', + transcript_path: transcriptPath, + }, hookEnvironment); + expect(hookText(notAgentA)).not.toContain('Directed proximity notice'); + const delivered = await runHook(fixture.hooks.afterTool, fixture.worktreeA, { + agent_id: 'agent-a', cwd: fixture.worktreeA, hook_event_name: 'PostToolUse', session_id: sessionId, @@ -370,13 +399,16 @@ it('proves worktree proximity journeys across real processes and linked worktree status: 'active', worktreeRoot: fixture.repoRoot, }, + // Placed under the root by the runtime's lineage registry (the spawning + // `Agent` call opened the window), so the child's identity and its + // parent carry the registry's provenance rather than the raw envelope's. { id: 'agent-a', kind: 'child', parentSessionId: sessionId, provenance: { - id: 'native', - parentSessionId: 'native', + id: 'registry', + parentSessionId: 'registry', worktreeRoot: 'native', }, status: 'active', @@ -387,8 +419,8 @@ it('proves worktree proximity journeys across real processes and linked worktree kind: 'child', parentSessionId: sessionId, provenance: { - id: 'native', - parentSessionId: 'native', + id: 'registry', + parentSessionId: 'registry', worktreeRoot: 'native', }, status: 'active', diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 4c99b7809..f4ae82402 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -244,10 +244,21 @@ workspace-durable SQLite driver and passes the resulting ledger as subpath and ship no state or notice implementation. Inside an authorized request, `(await agent()).notices` is a request-bound -handle with `publish()`, `read()`, `inbox()`, and `acknowledge()`. Recipients -use only observed host/session/actor/workspace axes. Publish authorization -runs before persistence, and delivery authorization runs again when a -matching event is admitted. `read()` exposes notices selected for that event +handle with `publish()`, `read()`, `inbox()`, and `acknowledge()`. A +recipient is the conjunction of the observed axes it names — `actor`, `host`, +`session`, `workspace`, plus the two lineage axes read from the admitting +request's `lineage`: `conversation` (exactly one agent thread, +`request.lineage.conversation`) and `root` (the root conversation and every +subagent under it, `request.lineage.root`). Every named axis must match, and +an axis the request cannot observe — including lineage the runtime could not +resolve — matches nothing, so a `conversation`-addressed notice is never +admitted on a sibling's event even though Claude and Codex give every +subagent the root `session_id`. Admissions journal the principal's lineage as +just `{ conversation, root }`; both recipient fields and that scope are +additive optional schema fields (no definition version bump), and an +admission journaled before them matches exactly what it matched then. Publish +authorization runs before persistence, and delivery authorization runs again +when a matching event is admitted. `read()` exposes notices selected for that event while the ledger records a receipt containing the invocation id and state `attempted`. `acknowledge()` is recipient-matched and authorization-gated and produces the terminal `acknowledged` state — the strongest evidenced outcome. diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 47e8227bb..33fb4e3d1 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -588,7 +588,7 @@ export const runAgentRequest = async ( ? undefined : await init.noticeLedger.openRequest({ invocation, - principal: Object.freeze({ actor, host, session, workspace }), + principal: Object.freeze({ actor, host, lineage, session, workspace }), signal, }); const values: FrozenValues = Object.freeze({ diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index 60aad6112..a6b17062a 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -3,6 +3,7 @@ import type { AgentActorIdentity, AgentHostIdentity, AgentInvocation, + AgentLineage, AgentSessionIdentity, AgentWorkspaceIdentity, Observed, @@ -33,10 +34,21 @@ export type AgentNoticeState = (typeof AGENT_NOTICE_STATES)[number]; export type AgentNoticePriority = 'low' | 'normal' | 'high'; -/** A recipient is the conjunction of the observed identity axes it specifies. */ +/** + * A recipient is the conjunction of the observed identity axes it specifies: + * every axis present must match the admitting request, and an axis the + * request cannot observe never matches. `conversation` and `root` are read + * from the request's `lineage` (#host-lineage), so they address one agent + * thread — or every thread under one root — where `session` cannot: on + * Claude and Codex every subagent's hooks carry the root `session_id`. + */ export interface AgentRecipient { readonly actor?: AgentActorIdentity; + /** Matches when `request.lineage.conversation` is exactly this id: one agent thread. */ + readonly conversation?: string; readonly host?: AgentHostIdentity; + /** Matches when `request.lineage.root` is this id: the root conversation and every subagent under it. */ + readonly root?: string; readonly session?: AgentSessionIdentity; readonly workspace?: AgentWorkspaceIdentity; } @@ -44,6 +56,25 @@ export interface AgentRecipient { export interface AgentNoticePrincipal { readonly actor: Observed; readonly host: Observed; + /** The request's place in the conversation tree; unavailable lineage matches no `conversation`/`root` recipient. */ + readonly lineage: Observed; + readonly session: Observed; + readonly workspace: Observed; +} + +/** The lineage facts recipient matching reads: the request's own conversation and its root. */ +export type AgentNoticeLineageScope = Pick; + +/** + * The principal as the ledger journals it on an admission: the identity axes + * plus only the lineage scope, so a journaled admission never depends on the + * rest of the lineage shape. `lineage` is absent on admissions journaled + * before the axis existed and is treated as unavailable. + */ +export interface AgentNoticeRecordedPrincipal { + readonly actor: Observed; + readonly host: Observed; + readonly lineage?: Observed; readonly session: Observed; readonly workspace: Observed; } diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index edfecf97d..21b52e58f 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -33,11 +33,13 @@ export type { AgentNoticeLedger, AgentNoticeLedgerInspection, AgentNoticeLedgerSnapshot, + AgentNoticeLineageScope, AgentNoticePrincipal, AgentNoticePriority, AgentNoticePublishInput, AgentNoticePublishOptions, AgentNoticePublishResult, + AgentNoticeRecordedPrincipal, AgentNoticeRequest, AgentNoticeRetainOptions, AgentNoticeRetentionPolicy, @@ -114,6 +116,7 @@ export { agentNoticeStateDefinition, noticeSettledAt, recipientMatchesPrincipal, + recordedNoticePrincipal, } from './state.js'; export type { AgentNoticeLedgerState, diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 4225bd74b..27a3d89e5 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -66,6 +66,7 @@ import { agentNoticeEventSchemas, type AgentNoticeLedgerState, recipientMatchesPrincipal, + recordedNoticePrincipal, } from './state.js'; export interface CreateAgentNoticeLedgerOptions { @@ -239,9 +240,15 @@ const recipient = (input: AgentRecipient): AgentRecipient => { ...(input.actor === undefined ? {} : { actor: Object.freeze({ id: nonEmptyText(input.actor.id, 'Notice recipient actor id') }) }), + ...(input.conversation === undefined + ? {} + : { conversation: nonEmptyText(input.conversation, 'Notice recipient conversation') }), ...(input.host === undefined ? {} : { host: Object.freeze({ name: nonEmptyText(input.host.name, 'Notice recipient host name') }) }), + ...(input.root === undefined + ? {} + : { root: nonEmptyText(input.root, 'Notice recipient root') }), ...(input.session === undefined ? {} : { session: Object.freeze({ sessionId: nonEmptyText(input.session.sessionId, 'Notice recipient session id') }) }), @@ -634,7 +641,7 @@ export const createAgentNoticeLedger = ( .filter(({ decision }) => decision.state === 'authorized') .map(({ id }) => id), invocationId: request.invocation.id, - principal: request.principal, + principal: recordedNoticePrincipal(request.principal), unavailableIds: decisions .filter(({ decision }) => decision.state === 'unavailable') .map(({ id }) => id), diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index c4a97c497..bf3e2cf64 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -15,6 +15,7 @@ import { AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, type AgentNotice, type AgentNoticePrincipal, + type AgentNoticeRecordedPrincipal, type AgentNoticeRetentionSummary, type AgentNoticeWithheldEntry, type AgentNoticeWithholding, @@ -47,7 +48,11 @@ const observed = (value: T) => z.discriminatedUnion('state' const recipientSchema = z.object({ actor: z.object({ id: z.string().min(1) }).strict().optional(), + // Lineage axes (additive, optional): notices journaled before them parse + // unchanged, and absent means the axis is not part of the conjunction. + conversation: z.string().min(1).optional(), host: z.object({ name: z.string().min(1) }).strict().optional(), + root: z.string().min(1).optional(), session: z.object({ sessionId: z.string().min(1) }).strict().optional(), workspace: z.object({ root: z.string().min(1) }).strict().optional(), }).strict().refine( @@ -55,9 +60,17 @@ const recipientSchema = z.object({ 'A notice recipient requires at least one identity axis', ); +const lineageScopeSchema = z.object({ + conversation: z.string().min(1), + root: z.string().min(1), +}).strict(); + const principalSchema = z.object({ actor: observed(z.object({ id: z.string().min(1) }).strict()), host: observed(z.object({ name: z.string().min(1) }).strict()), + // Optional so admissions journaled before the lineage axes replay unchanged; + // the reducer reads an absent scope as unavailable lineage. + lineage: observed(lineageScopeSchema).optional(), session: observed(z.object({ sessionId: z.string().min(1) }).strict()), workspace: observed(z.object({ root: z.string().min(1) }).strict()), }).strict(); @@ -229,9 +242,38 @@ export const agentNoticeEventSchemas = { const sameRecipient = (left: AgentRecipient, right: AgentRecipient): boolean => canonicalJson(left) === canonicalJson(right); +/** + * The principal as an admission journals it: the lineage axis is narrowed to + * the conversation and root the matcher reads, so the journaled payload never + * grows with the lineage shape and a replay matches exactly what the live + * admission matched. + */ +export const recordedNoticePrincipal = (principal: AgentNoticePrincipal): AgentNoticeRecordedPrincipal => Object.freeze({ + actor: principal.actor, + host: principal.host, + lineage: principal.lineage.state === 'available' + ? Object.freeze({ + source: principal.lineage.source, + state: 'available' as const, + value: Object.freeze({ + conversation: principal.lineage.value.conversation, + root: principal.lineage.value.root, + }), + }) + : principal.lineage, + session: principal.session, + workspace: principal.workspace, +}); + +/** + * Every axis the recipient names must match an available axis of the + * principal; an unavailable axis — including lineage the runtime could not + * resolve — matches nothing. `conversation` is exact; `root` matches the + * root conversation itself and every conversation whose lineage root it is. + */ export const recipientMatchesPrincipal = ( recipient: AgentRecipient, - principal: AgentNoticePrincipal, + principal: AgentNoticeRecordedPrincipal, ): boolean => { if (recipient.actor !== undefined) { if (principal.actor.state !== 'available' || principal.actor.value.id !== recipient.actor.id) return false; @@ -245,6 +287,12 @@ export const recipientMatchesPrincipal = ( if (recipient.workspace !== undefined) { if (principal.workspace.state !== 'available' || principal.workspace.value.root !== recipient.workspace.root) return false; } + if (recipient.conversation !== undefined) { + if (principal.lineage?.state !== 'available' || principal.lineage.value.conversation !== recipient.conversation) return false; + } + if (recipient.root !== undefined) { + if (principal.lineage?.state !== 'available' || principal.lineage.value.root !== recipient.root) return false; + } return true; }; @@ -474,7 +522,7 @@ const transitionAdmission = ( readonly at: string; readonly authorizedIds: ReadonlySet; readonly invocationId: string; - readonly principal: AgentNoticePrincipal; + readonly principal: AgentNoticeRecordedPrincipal; readonly unavailableIds: ReadonlySet; readonly withheld: ReadonlyMap; }, @@ -580,6 +628,13 @@ export const agentNoticeStateDefinition = ( // 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. + // + // The lineage recipient axes (`recipient.conversation` / `recipient.root`, + // `principal.lineage` on admissions) are additive optional fields, not a + // version: a journal without them replays to the same state, because an + // admission that recorded no lineage matches exactly the recipients it + // matched when it was written, and no persisted notice names an axis it + // did not have. migrations: { 2: (persisted) => persisted }, reduce: (state, event) => { switch (event.name) { diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 95a4e1408..6c765308a 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -12,13 +12,20 @@ import { selectNoticeDeliveryRoutes, agentNoticeStateDefinition, createAgentNoticeLedger, + recipientMatchesPrincipal, + recordedNoticePrincipal, type AgentNoticeAuthorizationRequest, + type AgentNoticePrincipal, type AgentNoticeState, + type AgentRecipient, } from '../src/notices/index.js'; import { agent, available, runAgentRequest, + unavailable, + type AgentLineage, + type Observed, } from '../src/index.js'; import { createMemoryStateDriver, defineState } from '../src/state/index.js'; import { createSqliteStateDriver } from '../src/state/sqlite.js'; @@ -52,6 +59,7 @@ const run = async ( readonly actorId: string; readonly id: string; readonly kind: 'event' | 'tool'; + readonly lineage?: Observed; readonly startedAt: string; }, operation: () => Promise, @@ -63,11 +71,20 @@ const run = async ( kind: input.kind, startedAt: input.startedAt, }, + ...(input.lineage === undefined ? {} : { lineage: input.lineage }), noticeLedger: ledger, session, workspace, }, operation); +/** A Claude/Codex-shaped lineage: every subagent shares the root `session_id`, only `conversation` tells them apart. */ +const lineageOf = (conversation: string, root = 'root-session'): Observed => available( + conversation === root + ? { conversation, depth: 0, resolution: 'native', root } + : { conversation, depth: 1, parent: root, resolution: 'registry', root, subagent: { id: conversation } }, + conversation === root ? 'native' : 'derived', +); + describe('notice state taxonomy', () => { it('declares only framework-evidenced v1 states', () => { expect(AGENT_NOTICE_STATES).toEqual([ @@ -1424,3 +1441,259 @@ describe('notice ledger schema version', () => { } }); }); + +describe('lineage-addressed recipients (#458)', () => { + const principalOf = (overrides: Partial): AgentNoticePrincipal => ({ + actor: unavailable(), + host, + lineage: unavailable('not-provided'), + session, + workspace, + ...overrides, + }); + const matches = (recipient: AgentRecipient, principal: AgentNoticePrincipal): boolean => + recipientMatchesPrincipal(recipient, principal); + + it('matches conversation exactly, root as the whole subtree, and both in conjunction with the other axes', () => { + const child = principalOf({ lineage: lineageOf('agent-a') }); + const sibling = principalOf({ lineage: lineageOf('agent-b') }); + const rootPrincipal = principalOf({ lineage: lineageOf('root-session') }); + const otherTree = principalOf({ lineage: lineageOf('agent-z', 'other-root') }); + + expect(matches({ conversation: 'agent-a' }, child)).toBe(true); + expect(matches({ conversation: 'agent-a' }, sibling)).toBe(false); + expect(matches({ conversation: 'agent-a' }, rootPrincipal)).toBe(false); + + expect(matches({ root: 'root-session' }, child)).toBe(true); + expect(matches({ root: 'root-session' }, sibling)).toBe(true); + expect(matches({ root: 'root-session' }, rootPrincipal)).toBe(true); + expect(matches({ root: 'root-session' }, otherTree)).toBe(false); + + // Every present axis must hold: the session both children share does not + // widen a conversation-addressed notice, and a workspace mismatch still + // refuses a conversation match. + expect(matches({ conversation: 'agent-a', session: { sessionId: 'session-1' } }, child)).toBe(true); + expect(matches({ conversation: 'agent-a', session: { sessionId: 'session-1' } }, sibling)).toBe(false); + expect(matches({ conversation: 'agent-a', workspace: { root: '/elsewhere' } }, child)).toBe(false); + expect(matches({ conversation: 'agent-a', root: 'other-root' }, child)).toBe(false); + expect(matches({ root: 'root-session', workspace: { root: '/workspace' } }, sibling)).toBe(true); + }); + + it('never matches a lineage axis the request could not resolve, and the other axes keep working without lineage', () => { + const unresolved = principalOf({ lineage: unavailable('no-shared-runtime') }); + expect(matches({ conversation: 'agent-a' }, unresolved)).toBe(false); + expect(matches({ root: 'root-session' }, unresolved)).toBe(false); + expect(matches({ session: { sessionId: 'session-1' } }, unresolved)).toBe(true); + // An admission journaled before the axis existed carries no lineage at all. + const legacy = { actor: unavailable(), host, session, workspace } as const; + expect(recipientMatchesPrincipal({ conversation: 'agent-a' }, legacy)).toBe(false); + expect(recipientMatchesPrincipal({ workspace: { root: '/workspace' } }, legacy)).toBe(true); + }); + + it('journals only the conversation and root of the admitting lineage', () => { + const recorded = recordedNoticePrincipal(principalOf({ lineage: lineageOf('agent-a') })); + expect(recorded.lineage).toEqual({ + source: 'derived', + state: 'available', + value: { conversation: 'agent-a', root: 'root-session' }, + }); + expect(recordedNoticePrincipal(principalOf({ lineage: unavailable('id-not-resolvable') })).lineage) + .toEqual({ reason: 'id-not-resolvable', state: 'unavailable' }); + }); + + it('admits a conversation-addressed notice only on that conversation, not on a sibling sharing its session and workspace', async () => { + const { driver, ledger } = await openLedger(); + const published = await run(ledger, { + actorId: 'publisher', + id: 'publish-conversation', + kind: 'tool', + lineage: lineageOf('agent-b'), + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('for agent-a only'), + priority: 'high', + recipient: { conversation: 'agent-a' }, + }, { idempotencyKey: 'publish:conversation' })); + expect(published.notice.recipient).toEqual({ conversation: 'agent-a' }); + + // The sibling's event: same host, session, workspace — different conversation. + const sibling = await run(ledger, { + actorId: 'agent-b', + id: 'event-sibling', + kind: 'event', + lineage: lineageOf('agent-b'), + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.read()); + expect(sibling).toEqual([]); + // The root's own event does not receive it either, nor one without lineage. + const rootEvent = await run(ledger, { + actorId: 'root', + id: 'event-root', + kind: 'event', + lineage: lineageOf('root-session'), + startedAt: '2026-09-01T19:01:30.000Z', + }, async () => (await agent()).notices!.read()); + expect(rootEvent).toEqual([]); + const unresolved = await run(ledger, { + actorId: 'agent-a', + id: 'event-unresolved', + kind: 'event', + startedAt: '2026-09-01T19:01:45.000Z', + }, async () => (await agent()).notices!.read()); + expect(unresolved).toEqual([]); + expect((await ledger.read()).notices[0]?.state).toBe('pending'); + + const delivered = await run(ledger, { + actorId: 'agent-a', + id: 'event-agent-a', + kind: 'event', + lineage: lineageOf('agent-a'), + startedAt: '2026-09-01T19:02:00.000Z', + }, async () => (await agent()).notices!.read()); + expect(delivered.map(({ notice }) => notice.id)).toEqual([published.notice.id]); + const snapshot = await ledger.read(); + expect(snapshot.notices[0]).toMatchObject({ + attempts: [{ invocationId: 'event-agent-a' }], + state: 'attempted', + }); + await driver.close(); + }); + + it('admits a root-addressed notice on every conversation under that root and on none outside it', async () => { + const { driver, ledger } = await openLedger(); + const published = await run(ledger, { + actorId: 'agent-a', + id: 'publish-root', + kind: 'tool', + lineage: lineageOf('agent-a'), + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('everyone under root-session'), + priority: 'normal', + recipient: { root: 'root-session' }, + retryBudget: 3, + }, { idempotencyKey: 'publish:root' })); + + const elsewhere = await run(ledger, { + actorId: 'agent-z', + id: 'event-other-tree', + kind: 'event', + lineage: lineageOf('agent-z', 'other-root'), + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.read()); + expect(elsewhere).toEqual([]); + + const seen: string[] = []; + for (const [conversation, id] of [['agent-b', 'event-b'], ['root-session', 'event-root'], ['agent-a', 'event-a']] as const) { + const deliveries = await run(ledger, { + actorId: conversation, + id, + kind: 'event', + lineage: lineageOf(conversation), + startedAt: `2026-09-01T19:02:0${String(seen.length)}.000Z`, + }, async () => (await agent()).notices!.read()); + if (deliveries.length > 0) seen.push(conversation); + } + // The subtree includes the root conversation and the publisher itself: + // `root` is a pure match, not "everyone but me". + expect(seen).toEqual(['agent-b', 'root-session', 'agent-a']); + expect((await ledger.read()).notices[0]).toMatchObject({ + attempts: [ + { invocationId: 'event-b' }, + { invocationId: 'event-root' }, + { invocationId: 'event-a' }, + ], + id: published.notice.id, + state: 'attempted', + }); + await driver.close(); + }); + + it('scopes the inbox and acknowledgement to the addressed conversation', async () => { + const { driver, ledger } = await openLedger(); + const published = await run(ledger, { + actorId: 'publisher', + id: 'publish-inbox-conversation', + kind: 'tool', + lineage: lineageOf('agent-b'), + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('inbox for agent-a'), + priority: 'normal', + recipient: { conversation: 'agent-a', workspace: { root: '/workspace' } }, + }, { idempotencyKey: 'publish:inbox-conversation' })); + + expect(await run(ledger, { + actorId: 'agent-b', + id: 'inbox-sibling', + kind: 'tool', + lineage: lineageOf('agent-b'), + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.inbox())).toEqual([]); + await expect(run(ledger, { + actorId: 'agent-b', + id: 'ack-sibling', + kind: 'tool', + lineage: lineageOf('agent-b'), + startedAt: '2026-09-01T19:01:30.000Z', + }, async () => (await agent()).notices!.acknowledge(published.notice.id))).rejects.toMatchObject({ + code: 'unauthorized', + }); + + const inbox = await run(ledger, { + actorId: 'agent-a', + id: 'inbox-agent-a', + kind: 'tool', + lineage: lineageOf('agent-a'), + startedAt: '2026-09-01T19:02:00.000Z', + }, async () => (await agent()).notices!.inbox()); + expect(inbox.map((notice) => notice.id)).toEqual([published.notice.id]); + const acknowledged = await run(ledger, { + actorId: 'agent-a', + id: 'ack-agent-a', + kind: 'tool', + lineage: lineageOf('agent-a'), + startedAt: '2026-09-01T19:03:00.000Z', + }, async () => (await agent()).notices!.acknowledge(published.notice.id)); + expect(acknowledged.state).toBe('acknowledged'); + await driver.close(); + }); + + it('rejects blank lineage axes at publish and persists them as additive optional fields', async () => { + const { driver, ledger, store } = await openLedger(); + await expect(run(ledger, { + actorId: 'publisher', + id: 'publish-blank-conversation', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('blank'), + priority: 'normal', + recipient: { conversation: ' ' }, + }, { idempotencyKey: 'publish:blank-conversation' }))).rejects.toMatchObject({ code: 'invalid-input' }); + await expect(run(ledger, { + actorId: 'publisher', + id: 'publish-blank-root', + kind: 'tool', + startedAt: '2026-09-01T19:00:01.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('blank'), + priority: 'normal', + recipient: { root: '' }, + }, { idempotencyKey: 'publish:blank-root' }))).rejects.toMatchObject({ code: 'invalid-input' }); + expect((await ledger.read()).notices).toEqual([]); + + // A pre-#458 admission — no `lineage` on its principal — still journals + // and replays under the same definition version. + const legacy = await store.dispatch('admitted', { + at: '2026-09-01T19:05:00.000Z', + authorizedIds: [], + invocationId: 'legacy-admission', + principal: { actor: { state: 'unavailable', reason: 'not-provided' }, host, session, workspace }, + unavailableIds: [], + }, { idempotencyKey: 'legacy:admitted' }); + expect(legacy.state.notices).toEqual([]); + expect(AGENT_NOTICE_STATE_VERSION).toBe(2); + await driver.close(); + }); +}); diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts index 862496992..3862a3060 100644 --- a/packages/rsc-runtime/tests/notices-redaction.test.ts +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -590,6 +590,7 @@ describe('ledger disclosure through the inbox and next-event routes', () => { const principal: AgentNoticePrincipal = { actor: actor('recipient'), host: unavailable(), + lineage: unavailable(), session: unavailable(), workspace: unavailable(), }; diff --git a/packages/rsc-runtime/tests/notices-resource-updated.test.ts b/packages/rsc-runtime/tests/notices-resource-updated.test.ts index 0ae91c992..3c9356017 100644 --- a/packages/rsc-runtime/tests/notices-resource-updated.test.ts +++ b/packages/rsc-runtime/tests/notices-resource-updated.test.ts @@ -27,6 +27,7 @@ const T2 = '2026-09-02T10:02:00.000Z'; const principal = (sessionId: string): AgentNoticePrincipal => Object.freeze({ actor: unavailable(), host: available({ name: 'claude' }, 'native'), + lineage: unavailable('not-provided'), session: available({ sessionId }, 'native'), workspace: available({ root: '/workspace' }, 'native'), }); diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index aaf81af52..a24b93562 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -197,6 +197,18 @@ const messages = { 'Which notice delivery channels each pinned host supports, with the recorded reason for every unavailable channel.', noticesIntro: 'A notice is an entry in the journal-backed notice ledger co-mounted with project state (the reserved store id `@agent-bundle/runtime/agent-notice-ledger/v1`). It targets a recipient and moves only through evidenced states — `pending`, `attempted`, `acknowledged`, `expired`, `unavailable`, `withdrawn`; settled terminal notices are pruned and the journal compacted under the project\'s `notices.retention` policy, so history is bounded, not permanent. Delivery is attempted through the channels below, and a generated MCP server wires each cross-request route only where its host advertises it: the recipient-scoped inbox resource `agent-bundle://notices/inbox` is registered for stateful projects on hosts advertising `mcp-inbox` (every built-in host), and `resources/subscribe` plus one `notifications/resources/updated` per newly eligible pending notice is offered only where the host additionally advertises `mcp-resource-updated` and the state lifetime is workspace-durable — recorded on the ledger as an availability receipt, never a delivery claim. No host delivery is claimed without a supported channel.', + recipientAxes: 'Recipient axes', + recipientAxesIntro: + 'A recipient is the conjunction of the observed identity axes it names: every axis present must match the admitting request, and an axis the request cannot observe never matches. `conversation` and `root` are read from the request\'s `lineage`, so they address one agent thread — or every thread under one root — where `session` cannot: on Claude and Codex every subagent\'s hooks carry the root `session_id`, and a `conversation`-addressed notice is never admitted on a sibling\'s event. There is no push channel on any pinned host, so addressing a conversation still means publish plus admission on that conversation\'s next event.', + recipientAxisRows: [ + ['`actor`', '`request.actor.id`', 'The HTTP-authenticated MCP client only; never mounted in event scopes.'], + ['`host`', '`request.host.name`', 'Every request on that host.'], + ['`session`', '`request.session.sessionId`', 'Every request in that host session — on Claude and Codex, every subagent under the root.'], + ['`workspace`', '`request.workspace.root`', 'Every request whose observed workspace root (hook `cwd`) is that directory.'], + ['`conversation`', '`request.lineage.conversation`', 'Exactly one agent thread: the root, or one subagent (Claude/Codex `agent_id`, Cursor child `conversation_id`).'], + ['`root`', '`request.lineage.root`', 'The root conversation and every subagent whose lineage root it is — the publisher included when it is under that root.'], + ], + recipientAxisHeaders: ['Axis', 'Matched against', 'Reaches'], noticeChannels: 'Delivery channels', unavailableChannels: 'Why a channel is unavailable', sensitivityCeilings: 'Sensitivity ceilings', @@ -278,6 +290,18 @@ const messages = { '每个固定宿主支持哪些通知投递通道,以及每个不可用通道的记录原因。', noticesIntro: '通知是与项目状态共同挂载、以日志为底的通知账本中的一条记录(保留的存储 id 为 `@agent-bundle/runtime/agent-notice-ledger/v1`)。它面向一个接收者,并且只会经历有证据的状态——`pending`、`attempted`、`acknowledged`、`expired`、`unavailable`、`withdrawn`;已结束的终态通知会按项目的 `notices.retention` 策略被清理、日志被压实,因此历史是有界的,而非永久保留。投递通过下列通道尝试,生成的 MCP 服务器只在宿主宣告了某条跨请求路由时才接线:按接收者限定的收件箱资源 `agent-bundle://notices/inbox` 会为宣告 `mcp-inbox` 的宿主(所有内置宿主)上的有状态项目注册;只有当宿主还宣告了 `mcp-resource-updated` 且 state 生命周期为工作区持久时,才提供 `resources/subscribe` 以及每条新近可用的待处理通知一次 `notifications/resources/updated`——它以可用性回执记录在账本上,绝不是投递声明。没有受支持的通道时,绝不声称已投递到宿主。', + recipientAxes: '接收者轴', + recipientAxesIntro: + '接收者是它所命名的各个已观测身份轴的合取:出现的每个轴都必须与准入请求匹配,请求无法观测到的轴永不匹配。`conversation` 与 `root` 读取自请求的 `lineage`,因此能定向到单个代理线程——或某个根之下的全部线程——这是 `session` 做不到的:在 Claude 和 Codex 上,每个子代理的钩子都携带根 `session_id`,而以 `conversation` 定向的通知绝不会在兄弟代理的事件上被准入。任何固定宿主都没有推送通道,因此定向到某个会话仍然意味着发布,然后在该会话的下一个事件上准入。', + recipientAxisRows: [ + ['`actor`', '`request.actor.id`', '仅指经 HTTP 认证的 MCP 客户端;事件作用域中永不挂载。'], + ['`host`', '`request.host.name`', '该宿主上的每个请求。'], + ['`session`', '`request.session.sessionId`', '该宿主会话中的每个请求——在 Claude 和 Codex 上即根之下的每个子代理。'], + ['`workspace`', '`request.workspace.root`', '观测到的工作区根目录(钩子 `cwd`)为该目录的每个请求。'], + ['`conversation`', '`request.lineage.conversation`', '恰好一个代理线程:根,或某一个子代理(Claude/Codex 的 `agent_id`,Cursor 子会话的 `conversation_id`)。'], + ['`root`', '`request.lineage.root`', '根会话以及谱系根为它的每个子代理——发布者若位于该根之下,也包括在内。'], + ], + recipientAxisHeaders: ['轴', '匹配对象', '到达范围'], noticeChannels: '投递通道', unavailableChannels: '通道不可用的原因', sensitivityCeilings: '敏感度上限', @@ -659,6 +683,10 @@ function renderNotices(hosts: readonly HostCapabilityTable[], m: Messages): stri sections.push(m.generatedFromCapabilities(hosts.map(host => host.fileName))); sections.push(m.noticesIntro); + sections.push(`## ${m.recipientAxes}\n`); + sections.push(m.recipientAxesIntro); + sections.push(table(m.recipientAxisHeaders, m.recipientAxisRows)); + sections.push(`## ${m.noticeChannels}\n`); const channels = unionKeys(hosts, data => asObject(data.noticeDelivery)); sections.push( From 06f7754de6584ab97473d97fb7bc989f761c8cc3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:07:21 +0000 Subject: [PATCH 2/2] fix(notices): keep AgentNoticePrincipal.lineage optional so four-axis principals keep working Absent lineage reads as unavailable: nothing journaled, no conversation/root match, every other axis unchanged. Test covers a four-axis principal handed straight to openRequest. --- .changeset/458-lineage-notice-recipients.md | 2 +- .../agent-bundle/src/mcp-server-runtime.ts | 3 +- packages/agent-bundle/src/test/mcp.ts | 17 ++++++---- packages/rsc-runtime/README.md | 3 +- packages/rsc-runtime/src/notices/contract.ts | 9 ++++-- packages/rsc-runtime/src/notices/state.ts | 26 +++++++++------ .../rsc-runtime/tests/notices-ledger.test.ts | 32 ++++++++++++++++++- 7 files changed, 69 insertions(+), 23 deletions(-) diff --git a/.changeset/458-lineage-notice-recipients.md b/.changeset/458-lineage-notice-recipients.md index c3af3ae4d..39445833b 100644 --- a/.changeset/458-lineage-notice-recipients.md +++ b/.changeset/458-lineage-notice-recipients.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Address a notice to one agent conversation or to a whole conversation tree: `AgentRecipient` gains `conversation` (matches `request.lineage.conversation` exactly) and `root` (matches every request whose `request.lineage.root` is that id), matched in conjunction with the existing `actor` / `host` / `session` / `workspace` axes at admission, inbox reads, `resources/updated` eligibility, and acknowledgement. `AgentNoticePrincipal` carries the request's `lineage`, which every generated surface (event routes, MCP tools, routed CLI, rendered scripts) now mounts; unresolved lineage never matches a lineage-addressed recipient. The ledger journals only `{ conversation, root }` of the admitting lineage as an additive optional field — no state-definition version bump, journals written before the axes replay unchanged. `notices.publish()` rejects blank `conversation` / `root` with `invalid-input`. `examples/worktree-proximity` addresses its proximity notices to the other actor's conversation instead of its worktree. (#458) +Address a notice to one agent conversation or to a whole conversation tree: `AgentRecipient` gains `conversation` (matches `request.lineage.conversation` exactly) and `root` (matches every request whose `request.lineage.root` is that id), matched in conjunction with the existing `actor` / `host` / `session` / `workspace` axes at admission, inbox reads, `resources/updated` eligibility, and acknowledgement. `AgentNoticePrincipal` gains an optional `lineage`, which every generated surface (event routes, MCP tools, routed CLI, rendered scripts) now mounts; a principal built without it, or with unresolved lineage, never matches a lineage-addressed recipient and otherwise behaves exactly as before. The ledger journals only `{ conversation, root }` of the admitting lineage as an additive optional field — no state-definition version bump, journals written before the axes replay unchanged. `notices.publish()` rejects blank `conversation` / `root` with `invalid-input`. `examples/worktree-proximity` addresses its proximity notices to the other actor's conversation instead of its worktree. (#458) diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index d33c1b9bf..009c00dbe 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -610,7 +610,8 @@ export interface GeneratedEventRuntimeBinding { export interface GeneratedNoticePrincipal { readonly actor: Observed; readonly host: Observed; - readonly lineage: Observed; + /** Optional like the runtime's: absent is unavailable lineage. */ + readonly lineage?: Observed; readonly session: Observed; readonly workspace: Observed; } diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 2b7f58092..aaad23847 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -332,13 +332,16 @@ const withContextIdentity = ( }, close: () => signaller.close(), observe: (send: () => Promise) => signaller.observe(send), - subscribe: (principal: GeneratedNoticePrincipal) => signaller.subscribe({ - actor: context.actor ?? principal.actor, - host: context.host ?? principal.host, - lineage: context.lineage ?? principal.lineage, - session: context.session ?? principal.session, - workspace: context.workspace ?? principal.workspace, - }), + subscribe: (principal: GeneratedNoticePrincipal) => { + const lineage = context.lineage ?? principal.lineage; + return signaller.subscribe({ + actor: context.actor ?? principal.actor, + host: context.host ?? principal.host, + ...(lineage === undefined ? {} : { lineage }), + session: context.session ?? principal.session, + workspace: context.workspace ?? principal.workspace, + }); + }, unsubscribe: () => signaller.unsubscribe(), }); diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index f4ae82402..a33581ec8 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -251,7 +251,8 @@ request's `lineage`: `conversation` (exactly one agent thread, `request.lineage.conversation`) and `root` (the root conversation and every subagent under it, `request.lineage.root`). Every named axis must match, and an axis the request cannot observe — including lineage the runtime could not -resolve — matches nothing, so a `conversation`-addressed notice is never +resolve, or a principal built without the optional `lineage` at all — +matches nothing, so a `conversation`-addressed notice is never admitted on a sibling's event even though Claude and Codex give every subagent the root `session_id`. Admissions journal the principal's lineage as just `{ conversation, root }`; both recipient fields and that scope are diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index a6b17062a..bf7f45585 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -56,8 +56,13 @@ export interface AgentRecipient { export interface AgentNoticePrincipal { readonly actor: Observed; readonly host: Observed; - /** The request's place in the conversation tree; unavailable lineage matches no `conversation`/`root` recipient. */ - readonly lineage: Observed; + /** + * The request's place in the conversation tree. Optional so a principal + * built before the axis existed (a four-axis `openRequest()` or + * `subscribe()` caller) keeps working: absent is unavailable, and + * unavailable lineage matches no `conversation`/`root` recipient. + */ + readonly lineage?: Observed; readonly session: Observed; readonly workspace: Observed; } diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index bf3e2cf64..445f2b187 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -251,16 +251,22 @@ const sameRecipient = (left: AgentRecipient, right: AgentRecipient): boolean => export const recordedNoticePrincipal = (principal: AgentNoticePrincipal): AgentNoticeRecordedPrincipal => Object.freeze({ actor: principal.actor, host: principal.host, - lineage: principal.lineage.state === 'available' - ? Object.freeze({ - source: principal.lineage.source, - state: 'available' as const, - value: Object.freeze({ - conversation: principal.lineage.value.conversation, - root: principal.lineage.value.root, - }), - }) - : principal.lineage, + // A principal built without the axis journals none, exactly like a + // pre-#458 admission: absent reads as unavailable. + ...(principal.lineage === undefined + ? {} + : { + lineage: principal.lineage.state === 'available' + ? Object.freeze({ + source: principal.lineage.source, + state: 'available' as const, + value: Object.freeze({ + conversation: principal.lineage.value.conversation, + root: principal.lineage.value.root, + }), + }) + : principal.lineage, + }), session: principal.session, workspace: principal.workspace, }); diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 6c765308a..36e595985 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -1490,7 +1490,7 @@ describe('lineage-addressed recipients (#458)', () => { expect(recipientMatchesPrincipal({ workspace: { root: '/workspace' } }, legacy)).toBe(true); }); - it('journals only the conversation and root of the admitting lineage', () => { + it('journals only the conversation and root of the admitting lineage, and none for a four-axis principal', () => { const recorded = recordedNoticePrincipal(principalOf({ lineage: lineageOf('agent-a') })); expect(recorded.lineage).toEqual({ source: 'derived', @@ -1499,6 +1499,36 @@ describe('lineage-addressed recipients (#458)', () => { }); expect(recordedNoticePrincipal(principalOf({ lineage: unavailable('id-not-resolvable') })).lineage) .toEqual({ reason: 'id-not-resolvable', state: 'unavailable' }); + // A principal built before the axis existed keeps working: nothing journaled, nothing matched. + const fourAxis: AgentNoticePrincipal = { actor: unavailable(), host, session, workspace }; + expect(recordedNoticePrincipal(fourAxis)).not.toHaveProperty('lineage'); + expect(matches({ conversation: 'agent-a' }, fourAxis)).toBe(false); + expect(matches({ session: { sessionId: 'session-1' } }, fourAxis)).toBe(true); + }); + + it('admits through a four-axis principal handed straight to openRequest, as before the axis existed', async () => { + const { driver, ledger } = await openLedger(); + await run(ledger, { + actorId: 'publisher', + id: 'publish-legacy-principal', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('session-addressed'), + priority: 'normal', + recipient: { session: { sessionId: 'session-1' } }, + }, { idempotencyKey: 'publish:legacy-principal' })); + const lease = await ledger.openRequest({ + invocation: { id: 'legacy-event', kind: 'event', startedAt: '2026-09-01T19:01:00.000Z' }, + principal: { actor: unavailable(), host, session, workspace }, + signal: new AbortController().signal, + }); + try { + expect((await lease.handle.read()).map(({ notice }) => notice.recipient)).toEqual([{ session: { sessionId: 'session-1' } }]); + } finally { + lease.close(); + } + await driver.close(); }); it('admits a conversation-addressed notice only on that conversation, not on a sibling sharing its session and workspace', async () => {