From 1f19123689cdb474988e66861d53c7fe747f90c7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:42:53 +0000 Subject: [PATCH 1/3] feat(lineage): confirm Claude subagent edges from the parent's Agent PostToolUse (#422) Claude Code names no parent on any hook a subagent emits, but the parent's Agent PostToolUse carries the spawn tool_use_id, the caller's identity and tool_response.agentId, the child. The registry treats that hook as the host's word on the edge: it confirms a spawn-window match (resolution: confirmed once every edge to the root is host-named), fills in sibling toolCallIds claimed blind, places a start no window could (unplaced starts keep the start's own facts meanwhile), moves a child filed under the wrong parent and re-bases its descendants, and holds a child named before its start arrives. Adds 'confirmed' to AgentLineageResolution and the wire/Workbench mirrors, records the confirmation on the Claude capability table's lineage rows, and renders a Conversation lineage section on the generated hosts page. --- .changeset/422-claude-spawn-confirmation.md | 6 + docs/entry-conventions.md | 52 ++++- .../adapters/capabilities/claude-2.1.250.json | 9 +- .../src/contracts/request-provenance.ts | 2 +- .../tests/projection/mcp-lineage.test.ts | 16 ++ packages/rsc-runtime/src/agent-request.ts | 14 +- packages/rsc-runtime/src/lineage/registry.ts | 109 ++++++++- packages/rsc-runtime/src/lineage/state.ts | 159 ++++++++++++- .../tests/lineage-registry.test.ts | 212 +++++++++++++++++- packages/workbench/src/request-provenance.ts | 2 +- website/docs/en/guide/authoring/mcp.mdx | 12 +- website/docs/zh/guide/authoring/mcp.mdx | 8 +- website/plugins/generated-reference.ts | 50 ++++- 13 files changed, 610 insertions(+), 41 deletions(-) create mode 100644 .changeset/422-claude-spawn-confirmation.md diff --git a/.changeset/422-claude-spawn-confirmation.md b/.changeset/422-claude-spawn-confirmation.md new file mode 100644 index 000000000..5290e9943 --- /dev/null +++ b/.changeset/422-claude-spawn-confirmation.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +Take Claude Code's own word for a subagent's parent: the parent's `Agent` `PostToolUse` carries the spawn `tool_use_id` and `tool_response.agentId`, the child's `agent_id`, so the lineage registry behind `request.lineage` now confirms the edge it matched from spawn-call ordering, fills in `subagent.toolCallId` for siblings it had claimed blind, places a `SubagentStart` no spawn window could (none open, or two parents with one — the start's id, type, time and stop are kept meanwhile), moves a child it had filed under the wrong parent and re-bases that child's descendants, and holds a child the host names before its start arrives. Add `'confirmed'` to `AgentLineageResolution` (`@agent-bundle/runtime`, `RequestLineageProvenance` in `agent-bundle`): a subagent's `parent`/`root`/`depth` resolve `confirmed` once every edge up to the root is host-named — right after `SubagentStart` for a background spawn, after `SubagentStop` for a foreground one — and stay `registry` otherwise. The Claude capability table's `lineage.parent`/`lineage.depth` rows record the confirmation and its timing, and the generated hosts page gains a "Conversation lineage" section rendered from every host's `lineage` rows. (#422) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 6929368a4..2ab1d89f6 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -409,10 +409,21 @@ interface AgentLineage { depth: number; // 0 at the root, +1 per subagent level generation?: string; // Cursor generation_id, Codex turn_id, Claude prompt_id subagent?: { id: string; type?: string; toolCallId?: string; isParallelWorker?: boolean }; - resolution: 'native' | 'registry' | 'inferred'; + resolution: 'native' | 'registry' | 'confirmed' | 'inferred'; } ``` +`resolution` is the trust level of `parent`/`root`/`depth`: `native` — the +host named them on this payload (a Claude/Codex root, a Codex tool call's +`_meta`); `registry` — the warm runtime's registry placed the conversation +when its subagent started, matching the start to the newest unclaimed spawn +call; `confirmed` — that registry edge, and every edge above it up to the +root, was afterwards named by the host itself (Claude's `Agent` PostToolUse +carries the spawn `tool_use_id`, the caller's identity and +`tool_response.agentId`, the child); `inferred` — ordering inference the host +forced (Cursor binds a child conversation to the single pending +`subagentStart`). + Hooks are thin clients to the warm runtime, so lineage is runtime-held state: the generated MCP process owns an **agent lineage registry** (`@agent-bundle/runtime/lineage`), journaled through the state kernel beside @@ -426,14 +437,49 @@ for every event by the id the payload carries. The observed host vocabulary | Host | `conversation` | `root` | Parent of a new subagent | MCP call correlation | | --- | --- | --- | --- | --- | -| Claude | `agent_id`, else `session_id` | `session_id` | the agent whose `Agent`/`Task` `PreToolUse` is the newest unclaimed spawn | `_meta["claudecode/toolUseId"]` = the open `PreToolUse` `tool_use_id` | +| Claude | `agent_id`, else `session_id` | `session_id` | the agent whose `Agent`/`Task` `PreToolUse` is the newest unclaimed spawn; confirmed by that agent's `Agent` `PostToolUse` (`tool_response.agentId` = the child) | `_meta["claudecode/toolUseId"]` = the open `PreToolUse` `tool_use_id` | | Codex | `agent_id`, else `session_id` | `session_id` | the thread whose `spawn_agent` call is the newest unclaimed spawn | `_meta["x-codex-turn-metadata"]` carries `thread_id`, `parent_thread_id`, `session_id`, `turn_id` natively | | Cursor | `conversation_id` | the bound root | `parent_conversation_id` on `subagentStart`; the child's fresh `conversation_id` is bound to the single pending start in the same workspace when it first speaks | the newest open `preToolUse` whose `tool_name` is `MCP:` | A Claude or Codex subagent is placed only when its spawning pre-tool hook (`Agent`/`Task`, `collaborationspawn_agent`) was observed, so projects that want `parent`/`depth` for subagents route `tool/before` alongside -`agent/start`; a start with no claimable spawn stays `id-not-resolvable`. +`agent/start`; a start with no claimable spawn — none open, or several +parents with one — stays `id-not-resolvable`, and the registry keeps what the +start said (id, type, time, and a stop that follows) as an unplaced start. + +Claude Code names no parent on any hook a subagent emits (#422; hooks +reference "Common input fields": `agent_id` and `agent_type` are the only +subagent fields), but the *parent's* `Agent` `PostToolUse` names the edge: +`tool_use_id` is the spawn call, the carrier is the parent, and +`tool_response.agentId` is the child's `agent_id` (observed on Claude Code +2.1.257 and 2.1.259, `status: "async_launched"` for a background spawn, +`status: "completed"` for a foreground one). The registry treats that hook +as the host's word on the edge, whatever it believed before: + +- it confirms an edge the spawn window matched (`resolution: 'confirmed'` + once every edge from the conversation to the root is host-named); +- it fills in which sibling came from which spawn call when several from one + parent were claimed blind, so `subagent.toolCallId` appears after the fact; +- it places an unplaced start under the parent, as it started (time, type, + and already stopped when its stop came first), and consumes the spawn call + so no later start can claim it; +- it moves a child the window filed under the wrong parent (a missed spawn + `PreToolUse` leaves another parent's open call as the only candidate) and + re-bases everything the child spawned meanwhile; +- it holds a child it names before that child's `SubagentStart` arrives + (Claude fires a background spawn's `PostToolUse` first); the start then + adds its `agent_type`. + +Timing bounds what this buys a subagent's own events: a background spawn is +confirmed right after `SubagentStart`, so the child's hooks and MCP calls +resolve `confirmed`; a foreground spawn's `PostToolUse` fires only after the +child's `SubagentStop`, so that child's events resolve `registry` for their +whole life and only the tree (`snapshot()`, the Workbench) shows the +confirmation. A spawn whose response carries no `agentId` (the sub-agents +reference says the one-shot built-in Explore and Plan agents return no agent +ID to Claude) keeps the registry's own match. Nothing here derives an actor +or a user (#391); `request.lineage` stays the only identity surface (#444). Cursor names a child only on the parent's `subagentStart` (`subagent_id` = the parent's `Task` call id); the child's own hooks carry a fresh `conversation_id` and nothing that points back, so the registry binds by diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index b6831da81..4132cbee7 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -174,11 +174,16 @@ }, "parent": { "state": "degraded", - "reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no hook payload names a subagent's parent (host gap tracked in #422: SubagentStart carries only the child agent_id, and a subagent's own hooks carry session_id + agent_id); the runtime registry infers it from the Agent/Task PreToolUse that is the newest unclaimed spawn when SubagentStart fires (resolution: registry) — verified at depth 1 and 2 in background, foreground and parallel spawn paths, exact for sequential spawns and for the 2.1.259 parallel pair (the host serialises SubagentStart before the next Agent PreToolUse), refused as id-not-resolvable when two parents have unclaimed spawns, and toolCallId is withheld when siblings from one parent were claimed blind; the parent's Agent PostToolUse tool_response.agentId confirms the inference after the fact, and the host's stream-json task_started envelope (task_id, tool_use_id) agrees, but that stream is not delivered to plugins." + "reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no hook payload a subagent itself emits names its parent (#422: SubagentStart carries only the child agent_id and agent_type, and a subagent's own hooks carry session_id + agent_id; the hooks reference's common input fields list session_id, transcript_path, cwd, permission_mode, agent_id and agent_type, no parent). The runtime registry places the child under the Agent/Task PreToolUse that is the newest unclaimed spawn when SubagentStart fires (resolution: registry) — exact for sequential spawns and for the 2.1.259 parallel pair (the host serialises SubagentStart before the next Agent PreToolUse), verified at depth 1 and 2 in background, foreground and parallel spawn paths, refused as id-not-resolvable when two parents have unclaimed spawns, toolCallId withheld when siblings from one parent were claimed blind — and then takes the host's own word for the edge: the parent's Agent PostToolUse carries the spawn tool_use_id, the parent's identity and tool_response.agentId, the child. That hook confirms the edge (resolution: confirmed once every edge up to the root is host-named), fills in the sibling→tool_use_id assignment, places a start the window could not, and moves a child the window filed under the wrong parent, re-basing its descendants. It arrives right after SubagentStart for a background spawn (status async_launched; in 2.1.259 even before it) but only after the child's SubagentStop for a foreground one (status completed), so a foreground child's own events resolve as registry for their whole life. The host's stream-json task_started envelope (task_id, tool_use_id, spawn_depth) agrees with every placement but is not delivered to plugins.", + "evidence": [ + "live model 2026-09-03, Claude Code 2.1.257 (fixtures/host-lineage/claude-2.1.257.ndjson rows 17 and 41; claude-2.1.257-foreground.ndjson rows 47 and 49): the parent's Agent PostToolUse tool_response is {isAsync: true, status: \"async_launched\", agentId} for a background spawn and {status: \"completed\", agentId, agentType, content[]} for a foreground one; agentId equals the agent_id on the child's SubagentStart, SubagentStop and every hook the child fired.", + "live model 2026-09-03, Claude Code 2.1.259 (fixtures/host-lineage/claude-2.1.259-orchestration.ndjson): all four Agent PostToolUse payloads name their child — rows 13 and 17 (background Explore and general-purpose, async_launched; row 13 fires before that child's SubagentStart at row 14, so the registry holds the node by the host's word and the start adds its agent_type), row 99 (the depth-1 agent's own PostToolUse, carrying its agent_id, names the depth-2 child, completed) and row 101 (the root's, naming the depth-1 agent, completed); the depth-2 child's SubagentStart (row 82) and every hook it fires carry only its own agent_id and the root session_id, nothing that names the depth-1 parent.", + "uploaded 2026-09-03, Claude Code docs hooks reference (hooks-2.md, \"Common input fields\": agent_id and agent_type are the only subagent fields hooks add, no parent; \"SubagentStart\"/\"SubagentStop\": agent_id, agent_type, agent_transcript_path under the parent's transcript folder, background_tasks scoped to the parent session; \"PostToolUse\": tool_name, tool_input, tool_response, tool_use_id) and sub-agents reference (sub-agents-3.md, \"Resume subagents\": when a subagent completes Claude receives its agent ID — the id the registry matches to agent_id — and the one-shot built-in Explore and Plan agents return no agent ID to Claude, so a spawn whose tool_response omits agentId keeps the registry's own match; observed 2.1.259 background Explore responses did carry agentId)." + ] }, "depth": { "state": "degraded", - "reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no depth counter is delivered to hooks (#422; the result document's subagent_stats.max_depth and the stream-json task_started.spawn_depth are not hook fields); the registry derives it from the inferred parent chain, so it is exactly as certain as parent — it matched spawn_depth 1 and 2 in the 2.1.259 orchestration run." + "reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no depth counter is delivered to hooks (#422; the result document's subagent_stats.max_depth and the stream-json task_started.spawn_depth are not hook fields); the registry derives it from the parent chain — the placed parent's depth plus one, re-based when a host confirmation moves the parent — so it is exactly as certain as parent, resolves as confirmed only when every edge up to the root is, and matched spawn_depth 1 and 2 in the 2.1.259 orchestration run." }, "mcp-correlation": { "state": "supported", diff --git a/packages/agent-bundle/src/contracts/request-provenance.ts b/packages/agent-bundle/src/contracts/request-provenance.ts index b4814e4a2..089530699 100644 --- a/packages/agent-bundle/src/contracts/request-provenance.ts +++ b/packages/agent-bundle/src/contracts/request-provenance.ts @@ -34,7 +34,7 @@ export interface RequestLineageProvenance { readonly depth: number; readonly generation?: string; readonly parent?: string; - readonly resolution: 'native' | 'registry' | 'inferred'; + readonly resolution: 'native' | 'registry' | 'confirmed' | 'inferred'; readonly root: string; readonly subagent?: Readonly<{ readonly id: string; diff --git a/packages/agent-bundle/tests/projection/mcp-lineage.test.ts b/packages/agent-bundle/tests/projection/mcp-lineage.test.ts index aea9e0e03..2ae3dcf54 100644 --- a/packages/agent-bundle/tests/projection/mcp-lineage.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-lineage.test.ts @@ -105,6 +105,22 @@ describe('generated MCP tool calls resolve request.lineage through the runtime r expect(await callContext(registry, { 'claudecode/toolUseId': 'toolu_child_call' })).toMatchObject({ value: { conversation: child, depth: 1, resolution: 'registry' }, }); + + // The root's `Agent` PostToolUse names the child it produced + // (`tool_response.agentId`, Claude Code 2.1.257): the edge the registry + // matched is now the host's own, and the same call resolves as confirmed. + await observe(registry, 'tool/after', { + hook_event_name: 'PostToolUse', + session_id: root, + tool_input: {}, + tool_name: 'Agent', + tool_response: { agentId: child, isAsync: true, status: 'async_launched' }, + tool_use_id: 'toolu_spawn', + }); + expect(await callContext(registry, { 'claudecode/toolUseId': 'toolu_child_call' })).toMatchObject({ + source: 'derived', + value: { conversation: child, depth: 1, parent: root, resolution: 'confirmed', subagent: { toolCallId: 'toolu_spawn' } }, + }); }); it('leaves the axis honestly absent when the session has no registry', async () => { diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 64881b346..64aa0ce34 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -96,12 +96,16 @@ export interface AgentLineageSubagent { /** * How the runtime arrived at `parent`/`root`/`depth`: straight from host fields - * (`native`), from the warm runtime's registry fed by subagent start/stop - * events (`registry`), or by ordering inference the host forced on it - * (`inferred`, e.g. Cursor binds a child conversation to the most recent - * pending `subagentStart`). + * (`native`); from the warm runtime's registry fed by subagent start/stop + * events, the edge matched by spawn-window ordering (`registry`); the same + * registry edge after the host itself named it — on Claude the parent's + * `Agent` PostToolUse carries `tool_response.agentId`, the child, beside the + * spawn `tool_use_id` (`confirmed`); or by ordering inference the host forced + * on it (`inferred`, e.g. Cursor binds a child conversation to the most recent + * pending `subagentStart`). `confirmed` requires every edge from the + * conversation up to the root to be host-named. */ -export type AgentLineageResolution = 'native' | 'registry' | 'inferred'; +export type AgentLineageResolution = 'native' | 'registry' | 'confirmed' | 'inferred'; /** * Where this request sits in the conversation tree (#host-lineage). The shape diff --git a/packages/rsc-runtime/src/lineage/registry.ts b/packages/rsc-runtime/src/lineage/registry.ts index bff15bcc8..a489afeca 100644 --- a/packages/rsc-runtime/src/lineage/registry.ts +++ b/packages/rsc-runtime/src/lineage/registry.ts @@ -80,6 +80,34 @@ const SPAWN_TOOLS: Readonly boolean>> cursor: (toolName) => toolName === 'Task', }); +/** The host named the child a spawn call produced, on the spawn's own post-tool hook. */ +interface SpawnConfirmation { + readonly child: string; + /** The host reports the child already finished (Claude foreground `Agent`: `status: "completed"`). */ + readonly completed: boolean; +} + +/** + * Which spawn post-tool payloads name their child. Claude's `Agent` + * PostToolUse (hooks reference, "PostToolUse") returns `tool_response.agentId` + * — the same id the child's own `SubagentStart`/`SubagentStop` and every hook + * it fires carry as `agent_id` — beside `status: "async_launched"` for a + * background agent still running or `status: "completed"` for a foreground + * one that already stopped (captured 2026-09-03, Claude Code 2.1.257). Codex + * and Cursor spawn responses name no child. + */ +const SPAWN_CONFIRMATIONS: Readonly>) => SpawnConfirmation | undefined>> = Object.freeze({ + claude: (native) => { + const response = native['tool_response']; + if (response === null || typeof response !== 'object' || Array.isArray(response)) return undefined; + const record = response as Readonly>; + const child = nativeString(record, 'agentId'); + return child === undefined ? undefined : { child, completed: record['status'] === 'completed' }; + }, + codex: () => undefined, + cursor: () => undefined, +}); + /** * Cursor events only the user-facing conversation emits; a subagent's * conversation never carries them. Observed on a conversation the registry @@ -142,7 +170,7 @@ interface JournalKeys { next(name: keyof LineageEvents, payload: unknown): string; } -const RECEIPT_TIMESTAMPS = new Set(['openedAt', 'startedAt', 'stoppedAt']); +const RECEIPT_TIMESTAMPS = new Set(['at', 'openedAt', 'startedAt', 'stoppedAt']); /** Journal keys a storeless registry remembers to suppress redeliveries. */ const APPLIED_KEY_RETENTION = 4096; @@ -256,6 +284,25 @@ export const createAgentLineageRegistry = ( const nodeFor = (conversation: string | undefined): LineageNode | undefined => conversation === undefined ? undefined : state.nodes[conversation]; + /** + * Every edge from the node to its root was named by the host, so nothing in + * its `parent`/`root`/`depth` rests on the spawn-window inference any more. + * A root has no edge and is never "confirmed"; it resolves natively. + */ + const chainConfirmed = (node: LineageNode): boolean => { + if (node.depth === 0) return false; + let current: LineageNode | undefined = node; + for (let hops = 0; current !== undefined && current.depth > 0 && hops <= Object.keys(state.nodes).length; hops += 1) { + if (current.confirmed !== true) return false; + current = nodeFor(current.parent); + } + return current !== undefined && current.depth === 0; + }; + + /** The non-native resolution for a subagent node: the host-named edge chain, or the registry's own match. */ + const registryResolution = (node: LineageNode, fallback: AgentLineageResolution): AgentLineageResolution => + fallback === 'registry' && chainConfirmed(node) ? 'confirmed' : fallback; + /** * The spawn call that produced the subagent starting now: the most recent * unclaimed one *under the same root*, so two sessions sharing one durable @@ -355,7 +402,7 @@ export const createAgentLineageRegistry = ( if (node === undefined) return unavailable('id-not-resolvable'); const resolution: AgentLineageResolution = node.depth === 0 && (host !== 'cursor' || state.pendingChildren.length === 0) ? 'native' - : fallback; + : registryResolution(node, fallback); return available(lineageOf(node, carrier.generation, resolution), resolution === 'native' ? 'native' : 'derived'); }; @@ -390,14 +437,39 @@ export const createAgentLineageRegistry = ( if (agentId === undefined || root === undefined) return; // A replayed start must not claim a second spawn or rewrite the node — even // after retention pruned the node, the start identity is remembered. - if (state.seenStarts.includes(agentId) || state.nodes[agentId] !== undefined) return; + if (state.seenStarts.includes(agentId)) return; + const materialized = state.nodes[agentId]; + if (materialized !== undefined) { + // The host confirmed the spawn before the child's start arrived (Claude + // fires a background `Agent` PostToolUse ahead of `SubagentStart`): the + // node exists by the host's word; the start adds what only it carries. + if (materialized.depth === 0) return; + await dispatch('nodeStarted', { + ...materialized, + ...(carrier.generation === undefined ? {} : { generation: carrier.generation }), + startedAt: observedAt, + ...(nativeString(native, 'agent_type') === undefined ? {} : { type: nativeString(native, 'agent_type')! }), + }, keys); + return; + } const rootNodeValue = await ensureRoot(host, root, undefined, observedAt, keys, true, undefined); if (rootNodeValue === undefined) return; const claim = await claimSpawn(host, rootNodeValue.root, keys); // No spawn to claim (the pre-tool hook was missed, or the registry - // restarted) proves nothing about the parent: a nested agent would be - // misfiled under the root, so the start stays unresolved. - if (claim.kind !== 'claimed') return; + // restarted), or several parents with one, proves nothing about the + // parent: a nested agent would be misfiled under the root, so the start + // stays unplaced until the host names its edge (Claude's `Agent` + // PostToolUse), keeping what the start itself said. + if (claim.kind !== 'claimed') { + await dispatch('startUnplaced', { + ...(carrier.generation === undefined ? {} : { generation: carrier.generation }), + id: agentId, + root: rootNodeValue.root, + startedAt: observedAt, + ...(nativeString(native, 'agent_type') === undefined ? {} : { type: nativeString(native, 'agent_type')! }), + }, keys); + return; + } const parent = nodeFor(claim.call.conversation) ?? rootNodeValue; await dispatch('nodeStarted', { depth: parent.depth + 1, @@ -421,7 +493,11 @@ export const createAgentLineageRegistry = ( return Object.values(state.nodes).find((node) => node.subagentId === subagentId)?.id; })() : nativeString(native, 'agent_id'); - if (stopped === undefined || state.nodes[stopped] === undefined) return; + if (stopped === undefined) return; + // A stop for a start still waiting on its edge is kept with that start, so + // a late confirmation materializes an already-finished node. + const unplaced = (state.unplacedStarts ?? []).some((start) => start.id === stopped); + if (state.nodes[stopped] === undefined && !unplaced) return; await dispatch('nodeStopped', { id: stopped, stoppedAt: observedAt }, keys); }; @@ -515,6 +591,23 @@ export const createAgentLineageRegistry = ( if (event === 'tool/failure' && SPAWN_TOOLS[host](toolName)) { await dispatch('spawnFailed', { toolCallId }, keys); } + // The spawn's own post-tool hook names the child (Claude + // `tool_response.agentId`): the carrier is the parent, by the host's + // word. That places a start the claim window could not, confirms an + // edge it matched, or moves one it matched wrong — the same event in + // every case, so a redelivery is idempotent. + const confirmation = event === 'tool/after' && SPAWN_TOOLS[host](toolName) + ? SPAWN_CONFIRMATIONS[host](native) + : undefined; + if (confirmation !== undefined && confirmation.child !== carrier.conversation) { + await dispatch('spawnConfirmed', { + at: observedAt, + child: confirmation.child, + ...(confirmation.completed ? { completed: true } : {}), + parent: carrier.conversation, + toolCallId, + }, keys); + } } } return resolve(host, native, host === 'cursor' ? 'inferred' : 'registry'); @@ -599,7 +692,7 @@ export const createAgentLineageRegistry = ( if (call === undefined) return unavailable('id-not-resolvable'); const node = nodeFor(call.conversation); if (node === undefined) return unavailable('id-not-resolvable'); - const resolution: AgentLineageResolution = claudeToolUseId !== undefined ? 'registry' : 'inferred'; + const resolution: AgentLineageResolution = claudeToolUseId !== undefined ? registryResolution(node, 'registry') : 'inferred'; return available(lineageOf(node, call.generation, resolution), 'derived'); }, diff --git a/packages/rsc-runtime/src/lineage/state.ts b/packages/rsc-runtime/src/lineage/state.ts index 7dee45284..7b31dab1f 100644 --- a/packages/rsc-runtime/src/lineage/state.ts +++ b/packages/rsc-runtime/src/lineage/state.ts @@ -8,6 +8,13 @@ const timestamp = z.string().min(1).max(64); /** One conversation the runtime has seen start: the root, or a subagent below it. */ export const LineageNodeSchema = z.object({ + /** + * The host later named this exact parent→child edge itself: on Claude the + * parent's `Agent` PostToolUse carries the spawn `tool_use_id`, the caller's + * `agent_id` (or none, for the root) and `tool_response.agentId`, the child. + * Absent while the edge rests on the registry's spawn-window inference. + */ + confirmed: z.boolean().optional(), /** Root is depth 0. */ depth: z.number().int().nonnegative(), /** Codex `turn_id` / Claude `prompt_id` / Cursor `generation_id` at start, when present. */ @@ -45,6 +52,22 @@ export const OpenToolCallSchema = z.object({ uncertain: z.boolean().optional(), }).strict(); +/** + * A Claude/Codex `SubagentStart` the registry could not place: no spawn call + * was claimable, or several parents had one. Its start facts are kept so a + * later host confirmation of the edge (Claude's `Agent` PostToolUse naming the + * child) materializes the node as it started, not as it was confirmed. + */ +export const UnplacedStartSchema = z.object({ + generation: id.optional(), + id, + root: id, + startedAt: timestamp, + /** The child's stop arrived before its edge was known. */ + stoppedAt: timestamp.optional(), + type: id.optional(), +}).strict(); + export const LineageStateSchema = z.object({ /** Keyed by node id. */ nodes: z.record(id, LineageNodeSchema), @@ -63,10 +86,17 @@ export const LineageStateSchema = z.object({ * closes the spawn call before `SubagentStart` fires. */ pendingSpawns: z.array(OpenToolCallSchema), + /** + * Subagent starts awaiting a host confirmation of their edge, oldest first + * and bounded. Optional so a journal head written before the field existed + * still satisfies the schema. + */ + unplacedStarts: z.array(UnplacedStartSchema).optional(), }).strict(); export type LineageNode = z.output; export type OpenToolCall = z.output; +export type UnplacedStart = z.output; export type LineageState = z.output; export const lineageEventSchemas = { @@ -84,8 +114,25 @@ export const lineageEventSchemas = { sessionRetired: z.object({ root: id }).strict(), /** A subagent start consumed the spawn call that produced it; `siblingsUncertain` marks the cohort it was picked from. */ spawnClaimed: z.object({ siblingsUncertain: z.boolean().optional(), toolCallId: id }).strict(), + /** + * The host named a spawn edge itself (Claude: the parent's `Agent` + * PostToolUse carries `tool_use_id`, the caller's identity and + * `tool_response.agentId`). The child is placed under `parent`, moved there + * if the spawn-window inference had bound it elsewhere, and marked + * `confirmed`; a pending spawn call with that `toolCallId` is consumed. + * `completed` says the child has already finished from the host's view. + */ + spawnConfirmed: z.object({ + at: timestamp, + child: id, + completed: z.boolean().optional(), + parent: id, + toolCallId: id.optional(), + }).strict(), /** A spawn call failed before any child started, so no later start may claim it. */ spawnFailed: z.object({ toolCallId: id }).strict(), + /** A subagent started but no single spawn call could be claimed for it; the edge waits for a host confirmation. */ + startUnplaced: UnplacedStartSchema, toolCallClosed: z.object({ conversation: id, toolCallId: id }).strict(), toolCallOpened: OpenToolCallSchema.extend({ spawn: z.boolean().optional() }).strict(), } as const; @@ -100,6 +147,8 @@ export const LINEAGE_OPEN_CALL_RETENTION = 512; export const LINEAGE_PENDING_SPAWN_RETENTION = 64; /** Start identities remembered for replay detection after their nodes are pruned. */ export const LINEAGE_SEEN_START_RETENTION = 4096; +/** Subagent starts awaiting a host confirmation of their edge are dropped past this count, oldest first. */ +export const LINEAGE_UNPLACED_START_RETENTION = 64; export const AGENT_LINEAGE_STATE_ID = '@agent-bundle/runtime/agent-lineage/v1'; @@ -125,6 +174,26 @@ const remember = (seen: readonly string[], ids: readonly string[]): string[] => return next.length > LINEAGE_SEEN_START_RETENTION ? next.slice(next.length - LINEAGE_SEEN_START_RETENTION) : next; }; +/** Ids of every node below `ancestor` (by parent edges) that shares its root; cycle-safe. */ +const descendantsOf = (nodes: Record, ancestor: string, root: string): Set => { + const descendants = new Set(); + const descendsFrom = (node: LineageNode, hops = 0): boolean => { + if (node.parent === ancestor) return true; + if (node.parent === undefined || hops > Object.keys(nodes).length) return false; + const parent = nodes[node.parent]; + return parent !== undefined && descendsFrom(parent, hops + 1); + }; + for (const node of Object.values(nodes)) { + if (node.id !== ancestor && node.root === root && descendsFrom(node)) descendants.add(node.id); + } + return descendants; +}; + +const trimUnplaced = (starts: readonly UnplacedStart[]): UnplacedStart[] => + starts.length > LINEAGE_UNPLACED_START_RETENTION + ? starts.slice(starts.length - LINEAGE_UNPLACED_START_RETENTION) + : [...starts]; + export const reduceLineage = ( state: LineageState, event: { readonly name: keyof LineageEvents; readonly payload: unknown }, @@ -145,7 +214,14 @@ export const reduceLineage = ( case 'nodeStopped': { const { id: nodeId, stoppedAt } = event.payload as { id: string; stoppedAt: string }; const node = state.nodes[nodeId]; - if (node === undefined) return state; + if (node === undefined) { + // A child whose edge is still unknown finished; remember that so a + // late confirmation materializes it as already stopped. + const unplaced = state.unplacedStarts ?? []; + return unplaced.some((start) => start.id === nodeId) + ? { ...state, unplacedStarts: unplaced.map((start) => start.id === nodeId ? { ...start, stoppedAt } : start) } + : state; + } return { ...state, nodes: pruneStopped({ ...state.nodes, [nodeId]: { ...node, stoppedAt } }), @@ -171,16 +247,7 @@ export const reduceLineage = ( const { [conversation]: _moved, ...rest } = state.nodes; // Everything started beneath the misbound conversation belongs to the // root it is about to become: same shape, re-rooted, depth rebased. - const descendants = new Set(); - const descendsFrom = (node: LineageNode, hops = 0): boolean => { - if (node.parent === conversation) return true; - if (node.parent === undefined || hops > Object.keys(rest).length) return false; - const parent = rest[node.parent]; - return parent !== undefined && descendsFrom(parent, hops + 1); - }; - for (const node of Object.values(rest)) { - if (node.root === bound.root && descendsFrom(node)) descendants.add(node.id); - } + const descendants = descendantsOf(rest, conversation, bound.root); const rerooted = (node: LineageNode): LineageNode => descendants.has(node.id) ? { ...node, depth: node.depth - bound.depth, root: conversation } : node; const rerootedCall = (call: OpenToolCall): OpenToolCall => @@ -234,6 +301,73 @@ export const reduceLineage = ( const { toolCallId } = event.payload as { toolCallId: string }; return { ...state, pendingSpawns: state.pendingSpawns.filter((open) => open.toolCallId !== toolCallId) }; } + case 'startUnplaced': { + const start = event.payload as UnplacedStart; + const unplaced = (state.unplacedStarts ?? []).filter((waiting) => waiting.id !== start.id); + return { + ...state, + seenStarts: remember(state.seenStarts, [start.id]), + unplacedStarts: trimUnplaced([...unplaced, start]), + }; + } + case 'spawnConfirmed': { + const { at, child, completed, parent: parentId, toolCallId } = event.payload as { + at: string; child: string; completed?: boolean; parent: string; toolCallId?: string; + }; + const parent = state.nodes[parentId]; + const existing = state.nodes[child]; + // A root never becomes a child on a host's say-so, and no parent may be + // made to descend from its own child; the confirmation is then noise. + if (parent === undefined || child === parentId || existing?.depth === 0) return state; + const descendants = existing === undefined ? new Set() : descendantsOf(state.nodes, child, existing.root); + if (descendants.has(parentId)) return state; + const waiting = (state.unplacedStarts ?? []).find((start) => start.id === child); + const stoppedAt = existing?.stoppedAt ?? waiting?.stoppedAt ?? (completed === true ? at : undefined); + const base: LineageNode = existing ?? { + depth: parent.depth + 1, + id: child, + root: parent.root, + startedAt: waiting?.startedAt ?? at, + ...(waiting?.generation === undefined ? {} : { generation: waiting.generation }), + ...(waiting?.type === undefined ? {} : { type: waiting.type }), + }; + const placed: LineageNode = { + ...base, + confirmed: true, + depth: parent.depth + 1, + parent: parentId, + root: parent.root, + ...(stoppedAt === undefined ? {} : { stoppedAt }), + ...(toolCallId === undefined ? {} : { toolCallId }), + }; + // Whatever already hangs below a moved child follows it: same shape, + // depth rebased onto the confirmed parent, root updated. + const shift = placed.depth - (existing?.depth ?? placed.depth); + const moved = (node: LineageNode): LineageNode => + descendants.has(node.id) ? { ...node, depth: node.depth + shift, root: placed.root } : node; + const movedCall = (call: OpenToolCall): OpenToolCall => + call.root !== placed.root && (call.conversation === child || descendants.has(call.conversation)) + ? { ...call, root: placed.root } + : call; + return { + ...state, + nodes: pruneStopped({ + ...Object.fromEntries(Object.entries(state.nodes).map(([key, node]) => [key, moved(node)])), + [child]: placed, + }), + openCalls: state.openCalls.map(movedCall), + pendingSpawns: state.pendingSpawns + .filter((open) => open.toolCallId !== toolCallId) + .map(movedCall), + // A child known only from the confirmation has not started from the + // registry's view: its `SubagentStart`, when it arrives (Claude can + // fire the spawn's PostToolUse first), still fills in what it says. + seenStarts: existing === undefined && waiting === undefined ? state.seenStarts : remember(state.seenStarts, [child]), + ...(waiting === undefined + ? {} + : { unplacedStarts: (state.unplacedStarts ?? []).filter((start) => start.id !== child) }), + }; + } case 'sessionRetired': { const { root } = event.payload as { root: string }; const retired = new Set( @@ -245,6 +379,9 @@ export const reduceLineage = ( openCalls: state.openCalls.filter((open) => !belongs(open)), pendingChildren: state.pendingChildren.filter((pending) => !retired.has(pending)), pendingSpawns: state.pendingSpawns.filter((open) => !belongs(open)), + ...(state.unplacedStarts === undefined + ? {} + : { unplacedStarts: state.unplacedStarts.filter((start) => start.root !== root) }), }; } default: { diff --git a/packages/rsc-runtime/tests/lineage-registry.test.ts b/packages/rsc-runtime/tests/lineage-registry.test.ts index 800353cce..da0cb24ef 100644 --- a/packages/rsc-runtime/tests/lineage-registry.test.ts +++ b/packages/rsc-runtime/tests/lineage-registry.test.ts @@ -71,9 +71,9 @@ describe('lineage registry replaying the 2026-09-03 host captures', () => { // the foreground fixture's spawns block until the child finishes. Lineage // must come out the same either way. it.each([ - ['background root spawn', 'claude-2.1.257.ndjson'], - ['foreground spawns', 'claude-2.1.257-foreground.ndjson'], - ])('Claude 2.1.257 (%s): subagent events resolve to their own agent under the root session, nested depth 2, parent inferred from the open Agent call', async (_label, name) => { + ['background root spawn', 'claude-2.1.257.ndjson', 'confirmed'], + ['foreground spawns', 'claude-2.1.257-foreground.ndjson', 'registry'], + ])('Claude 2.1.257 (%s): subagent events resolve to their own agent under the root session, nested depth 2, parent inferred from the open Agent call', async (_label, name, liveResolution) => { const registry = createAgentLineageRegistry(); const records = fixture(name); const lineages = await replay('claude', records, registry); @@ -121,10 +121,19 @@ describe('lineage registry replaying the 2026-09-03 host captures', () => { expect(new Set(spawnResults.map((result) => result.agentId))).toEqual(new Set([subagent, nested])); for (const result of spawnResults) expect(['async_launched', 'completed']).toContain(result.status); - // The MCP probe call carries claudecode/toolUseId, which names the open PreToolUse. + // The MCP probe call carries claudecode/toolUseId, which names the open + // PreToolUse. A background child's probe runs after the root's `Agent` + // PostToolUse already named it, so its edge is host-confirmed; a + // foreground child's runs while the parent's call is still open, so the + // edge still rests on the registry's match. const probes = lineages.filter((entry) => entry.kind === 'mcp:probe'); expect(probes.map((entry) => value(entry.lineage).depth)).toEqual([0, 1, 2]); - expect(value(probes[1]!.lineage)).toMatchObject({ conversation: subagent, resolution: 'registry' }); + expect(value(probes[1]!.lineage)).toMatchObject({ conversation: subagent, parent: root, resolution: liveResolution }); + // The nested child is confirmed only by its parent's PostToolUse, which in + // both captures arrives after the nested child's own hooks have all fired. + expect(value(probes[2]!.lineage)).toMatchObject({ conversation: nested, parent: subagent, resolution: 'registry' }); + const subagentStop = lineages.find((entry) => entry.kind === 'agent/stop' && entry.native?.['agent_id'] === subagent)!; + expect(value(subagentStop.lineage)).toMatchObject({ conversation: subagent, resolution: liveResolution }); // PostToolUse for MCP tools now arrives (string tool_response), so every window closes. expect(registry.snapshot().openCalls).toEqual([]); @@ -132,6 +141,11 @@ describe('lineage registry replaying the 2026-09-03 host captures', () => { const snapshot = registry.snapshot(); expect(Object.values(snapshot.nodes).filter((node) => node.stoppedAt !== undefined).map((node) => node.id).sort()) .toEqual([nested, root, subagent].sort()); + // Every edge the registry matched was later named by the host: the + // inference never disagreed with the parent's PostToolUse in either run. + expect(snapshot.nodes[subagent]).toMatchObject({ confirmed: true, parent: root, toolCallId: spawnCalls[0] }); + expect(snapshot.nodes[nested]).toMatchObject({ confirmed: true, parent: subagent, toolCallId: spawnCalls[1] }); + expect(snapshot.unplacedStarts ?? []).toEqual([]); }); // Live Claude Code 2.1.259 (claude-sonnet-5, 2026-09-03), driven by @@ -759,6 +773,8 @@ describe('lineage registry spawn evidence (review round 9)', () => { }); expect(start).toEqual(unavailable('id-not-resolvable')); expect(registry.snapshot().nodes['orphan']).toBeUndefined(); + // The start's own facts wait for the host to name the edge. + expect(registry.snapshot().unplacedStarts).toMatchObject([{ id: 'orphan', root: 'root', type: 'general-purpose' }]); }); it('does not mistake a generated MCP tool named spawn_agent for the Codex collaboration spawn', async () => { @@ -972,3 +988,189 @@ describe('lineage registry Cursor child binding precision (desktop hooks-service expect(JSON.stringify(nodes)).not.toContain('/ws/'); }); }); + +// Claude Code 2.1.257 names no parent on `SubagentStart` (#422), but the +// parent's own `Agent` PostToolUse carries `tool_use_id`, the caller's +// `agent_id` (none for the root) and `tool_response.agentId` — the child's id +// (captured 2026-09-03: `status: "async_launched"` for a background spawn, +// `status: "completed"` for a foreground one; hooks reference "PostToolUse", +// sub-agents "Resume subagents": Claude receives the child's agent ID when it +// completes). The registry treats that hook as the host's word on the edge. +describe('lineage registry Claude spawn confirmation from the Agent PostToolUse (#422)', () => { + const claude = (registry: AgentLineageRegistry) => + (event: string, key: string, native: Record, observedAt = '2026-09-03T00:00:00.000Z') => + registry.observe({ event, host: 'claude', idempotencyKey: key, native, observedAt }); + const session = (agentId?: string) => ({ session_id: 'root', ...(agentId === undefined ? {} : { agent_id: agentId, agent_type: 'general-purpose' }) }); + const spawnBefore = (toolUseId: string, agentId?: string) => + ({ ...session(agentId), hook_event_name: 'PreToolUse', tool_input: { prompt: 'x' }, tool_name: 'Agent', tool_use_id: toolUseId }); + const spawnAfter = (toolUseId: string, child: string, status: 'async_launched' | 'completed', agentId?: string) => ({ + ...session(agentId), + hook_event_name: 'PostToolUse', + tool_input: { prompt: 'x' }, + tool_name: 'Agent', + tool_response: status === 'completed' + ? { agentId: child, agentType: 'general-purpose', content: [], status } + : { agentId: child, isAsync: true, status }, + tool_use_id: toolUseId, + }); + const start = (agentId: string) => ({ ...session(agentId), hook_event_name: 'SubagentStart' }); + const stop = (agentId: string) => ({ ...session(agentId), hook_event_name: 'SubagentStop', stop_hook_active: false }); + const bash = (toolUseId: string, agentId?: string) => + ({ ...session(agentId), hook_event_name: 'PreToolUse', tool_input: { command: 'pwd' }, tool_name: 'Bash', tool_use_id: toolUseId }); + + it('places a start two parents could have produced once the spawning parent names it, and confirms the chain edge by edge', async () => { + const registry = createAgentLineageRegistry(); + const observe = claude(registry); + await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); + await observe('tool/before', 'sp1', spawnBefore('sp1')); + await observe('agent/start', 'p', start('p')); + await observe('tool/before', 'sp2', spawnBefore('sp2')); + await observe('tool/before', 'sp3', spawnBefore('sp3', 'p')); + // Two parents, two open spawns: the start payload cannot pick, so no guess. + expect(await observe('agent/start', 'n', start('n'), '2026-09-03T00:00:05.000Z')).toEqual(unavailable('id-not-resolvable')); + expect(await observe('tool/before', 'n-bash', bash('n-bash', 'n'))).toEqual(unavailable('id-not-resolvable')); + // The nested parent's PostToolUse says sp3 produced n: placed under p at + // depth 2, as it started (time and type), with the spawn call named. + await observe('tool/after', 'sp3-done', spawnAfter('sp3', 'n', 'async_launched', 'p'), '2026-09-03T00:00:06.000Z'); + expect(registry.snapshot().nodes['n']).toMatchObject({ confirmed: true, depth: 2, parent: 'p', root: 'root', startedAt: '2026-09-03T00:00:05.000Z', toolCallId: 'sp3', type: 'general-purpose' }); + expect(registry.snapshot().unplacedStarts).toEqual([]); + expect(registry.snapshot().pendingSpawns.map((call) => call.toolCallId)).toEqual(['sp2']); + // n's own edge is host-named but p's still rests on the registry's match, so the chain is not confirmed yet. + const nestedTool = await observe('tool/before', 'n-bash-2', bash('n-bash-2', 'n')); + expect(value(nestedTool)).toMatchObject({ conversation: 'n', depth: 2, parent: 'p', resolution: 'registry', root: 'root', subagent: { id: 'n', toolCallId: 'sp3' } }); + // The root's PostToolUse names p: every edge from n to the root is now the host's. + await observe('tool/after', 'sp1-done', spawnAfter('sp1', 'p', 'async_launched')); + expect(value(await observe('tool/before', 'n-bash-3', bash('n-bash-3', 'n')))).toMatchObject({ conversation: 'n', depth: 2, parent: 'p', resolution: 'confirmed' }); + expect(value(await observe('tool/before', 'p-bash', bash('p-bash', 'p')))).toMatchObject({ conversation: 'p', depth: 1, parent: 'root', resolution: 'confirmed', subagent: { toolCallId: 'sp1' } }); + // The root itself stays native: it has no edge to confirm. + expect(value(await observe('tool/before', 'root-bash', bash('root-bash')))).toMatchObject({ conversation: 'root', depth: 0, resolution: 'native' }); + // The MCP path agrees with the hook path. + expect(await registry.resolveToolCall({ host: 'claude', meta: { 'claudecode/toolUseId': 'n-bash-3' }, toolName: 'probe' })) + .toMatchObject({ value: { conversation: 'n', resolution: 'confirmed' } }); + expect(await registry.resolveToolCall({ host: 'claude', meta: { 'claudecode/toolUseId': 'root-bash' }, toolName: 'probe' })) + .toMatchObject({ value: { conversation: 'root', resolution: 'registry' } }); + }); + + it('fills in which sibling came from which spawn call once the parent names each child', async () => { + const registry = createAgentLineageRegistry(); + const observe = claude(registry); + await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); + await observe('tool/before', 'sp1', spawnBefore('sp1')); + await observe('tool/before', 'sp2', spawnBefore('sp2')); + expect(value(await observe('agent/start', 'a', start('a'))).subagent?.toolCallId).toBeUndefined(); + expect(value(await observe('agent/start', 'b', start('b'))).subagent?.toolCallId).toBeUndefined(); + // The host's order is the reverse of the registry's blind pick. + await observe('tool/after', 'sp1-done', spawnAfter('sp1', 'b', 'async_launched')); + await observe('tool/after', 'sp2-done', spawnAfter('sp2', 'a', 'async_launched')); + expect(registry.snapshot().nodes['a']).toMatchObject({ confirmed: true, parent: 'root', toolCallId: 'sp2' }); + expect(registry.snapshot().nodes['b']).toMatchObject({ confirmed: true, parent: 'root', toolCallId: 'sp1' }); + expect(value(await observe('tool/before', 'a-bash', bash('a-bash', 'a')))).toMatchObject({ resolution: 'confirmed', subagent: { id: 'a', toolCallId: 'sp2' } }); + }); + + it('recovers a start whose spawn PreToolUse was never seen, keeping its start and stop times, through a durable journal', async () => { + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentLineageStateDefinition('process')); + const registry = createAgentLineageRegistry({ store }); + const observe = claude(registry); + await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); + // Missed PreToolUse: nothing to claim, so the start waits, and so does its stop. + expect(await observe('agent/start', 'x', start('x'), '2026-09-03T00:00:01.000Z')).toEqual(unavailable('id-not-resolvable')); + await observe('agent/stop', 'x-stop', stop('x'), '2026-09-03T00:00:02.000Z'); + expect(registry.snapshot().unplacedStarts).toMatchObject([{ id: 'x', stoppedAt: '2026-09-03T00:00:02.000Z' }]); + const before = (await store.read()).revision; + await observe('tool/after', 'sp-done', spawnAfter('sp-missed', 'x', 'completed'), '2026-09-03T00:00:03.000Z'); + expect(registry.snapshot().nodes['x']).toMatchObject({ + confirmed: true, depth: 1, parent: 'root', root: 'root', startedAt: '2026-09-03T00:00:01.000Z', stoppedAt: '2026-09-03T00:00:02.000Z', toolCallId: 'sp-missed', + }); + expect(registry.snapshot().unplacedStarts).toEqual([]); + // A redelivered PostToolUse replays the same journal entries; nothing moves. + const after = (await store.read()).revision; + expect(after).toBeGreaterThan(before); + await observe('tool/after', 'sp-done', spawnAfter('sp-missed', 'x', 'completed'), '2026-09-03T00:00:09.000Z'); + expect((await store.read()).revision).toBe(after); + expect(registry.snapshot().nodes['x']?.stoppedAt).toBe('2026-09-03T00:00:02.000Z'); + // A fresh registry over the same journal sees the confirmed node. + const rehydrated = createAgentLineageRegistry({ store }); + expect(value(await rehydrated.observe({ event: 'tool/before', host: 'claude', idempotencyKey: 'late', native: bash('late', 'x') }))) + .toMatchObject({ conversation: 'x', depth: 1, parent: 'root', resolution: 'confirmed' }); + await store.close(); + await driver.close(); + }); + + it('moves a child the spawn window filed under the wrong parent, rebases what it spawned, and materializes the real child', async () => { + const registry = createAgentLineageRegistry(); + const observe = claude(registry); + await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); + await observe('tool/before', 'sp1', spawnBefore('sp1')); + await observe('agent/start', 'p', start('p')); + await observe('tool/after', 'sp1-done', spawnAfter('sp1', 'p', 'async_launched')); + // p opens a spawn; the root's own second spawn PreToolUse is lost, so the + // root's child q is the only start beside p's open call and is filed under p. + await observe('tool/before', 'sp2', spawnBefore('sp2', 'p')); + expect(value(await observe('agent/start', 'q', start('q')))).toMatchObject({ depth: 2, parent: 'p', subagent: { toolCallId: 'sp2' } }); + await observe('tool/before', 'sp4', spawnBefore('sp4', 'q')); + expect(value(await observe('agent/start', 'r', start('r')))).toMatchObject({ depth: 3, parent: 'q' }); + // The root's PostToolUse for the lost call names q: q moves under the + // root, r follows one level up, and the call is recorded on q. + await observe('tool/after', 'lost-done', spawnAfter('sp-lost', 'q', 'async_launched')); + expect(registry.snapshot().nodes['q']).toMatchObject({ confirmed: true, depth: 1, parent: 'root', root: 'root', toolCallId: 'sp-lost' }); + expect(registry.snapshot().nodes['r']).toMatchObject({ depth: 2, parent: 'q', root: 'root' }); + expect(value(await observe('tool/before', 'r-bash', bash('r-bash', 'r')))).toMatchObject({ depth: 2, parent: 'q', resolution: 'registry' }); + // p's PostToolUse names the child sp2 really produced, one whose start the + // registry never saw: it exists from the confirmation on, under p. + await observe('tool/after', 'sp2-done', spawnAfter('sp2', 'real', 'completed', 'p'), '2026-09-03T00:00:07.000Z'); + expect(registry.snapshot().nodes['real']).toMatchObject({ confirmed: true, depth: 2, parent: 'p', startedAt: '2026-09-03T00:00:07.000Z', stoppedAt: '2026-09-03T00:00:07.000Z', toolCallId: 'sp2' }); + expect(value(await observe('tool/before', 'q-bash', bash('q-bash', 'q')))).toMatchObject({ depth: 1, parent: 'root', resolution: 'confirmed' }); + }); + + it('ignores a confirmation that names the carrier itself, a root, or would make a parent descend from its own child', async () => { + const registry = createAgentLineageRegistry(); + const observe = claude(registry); + await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); + await observe('tool/before', 'sp1', spawnBefore('sp1')); + await observe('agent/start', 'p', start('p')); + await observe('tool/before', 'sp2', spawnBefore('sp2', 'p')); + await observe('agent/start', 'q', start('q')); + const before = registry.snapshot().nodes; + await observe('tool/after', 'self', spawnAfter('sp-self', 'p', 'async_launched', 'p')); + await observe('tool/after', 'root-child', spawnAfter('sp-root', 'root', 'async_launched', 'p')); + await observe('tool/after', 'cycle', spawnAfter('sp-cycle', 'p', 'async_launched', 'q')); + expect(registry.snapshot().nodes).toEqual(before); + // A spawn response without an agentId (2.1.250-era shape) confirms nothing either. + await observe('tool/after', 'sp1-done', { ...session(), hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Agent', tool_response: { status: 'async_launched' }, tool_use_id: 'sp1' }); + expect(registry.snapshot().nodes['p']?.confirmed).toBeUndefined(); + }); + + it('takes a confirmation that precedes the child\'s start, then lets the start add its type without claiming another spawn', async () => { + // Claude Code 2.1.259 (fixtures/host-lineage/claude-2.1.259-orchestration.ndjson + // rows 12–17, PR #455): for a background Explore spawn the `Agent` + // PostToolUse (`async_launched`, agentId) fires before that child's + // SubagentStart, and the root's next spawn follows at once. + const registry = createAgentLineageRegistry(); + const observe = claude(registry); + await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); + await observe('tool/before', 'sp1', spawnBefore('sp1')); + await observe('tool/after', 'sp1-done', spawnAfter('sp1', 'explore', 'async_launched'), '2026-09-03T00:00:01.000Z'); + expect(registry.snapshot().nodes['explore']).toMatchObject({ confirmed: true, depth: 1, parent: 'root', startedAt: '2026-09-03T00:00:01.000Z', toolCallId: 'sp1' }); + expect(registry.snapshot().pendingSpawns).toEqual([]); + const started = await observe('agent/start', 'explore', { ...session('explore'), agent_type: 'Explore', hook_event_name: 'SubagentStart' }, '2026-09-03T00:00:02.000Z'); + expect(value(started)).toMatchObject({ conversation: 'explore', depth: 1, parent: 'root', resolution: 'confirmed', subagent: { id: 'explore', toolCallId: 'sp1', type: 'Explore' } }); + expect(registry.snapshot().nodes['explore']).toMatchObject({ confirmed: true, startedAt: '2026-09-03T00:00:02.000Z', type: 'Explore' }); + // The redelivered start changes nothing. + await observe('agent/start', 'explore-again', { ...session('explore'), agent_type: 'Explore', hook_event_name: 'SubagentStart' }, '2026-09-03T00:00:09.000Z'); + expect(registry.snapshot().nodes['explore']?.startedAt).toBe('2026-09-03T00:00:02.000Z'); + // The root's second spawn is claimed by its own child, not by the one already placed. + await observe('tool/before', 'sp2', spawnBefore('sp2')); + expect(value(await observe('agent/start', 'gp', start('gp')))).toMatchObject({ depth: 1, parent: 'root', resolution: 'registry', subagent: { toolCallId: 'sp2' } }); + }); + + it('drops the unplaced starts of a session that ends', async () => { + const registry = createAgentLineageRegistry(); + const observe = claude(registry); + await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); + await observe('agent/start', 'x', start('x')); + expect(registry.snapshot().unplacedStarts).toHaveLength(1); + await observe('session/end', 'e', { hook_event_name: 'SessionEnd', reason: 'other', session_id: 'root' }); + expect(registry.snapshot().unplacedStarts).toEqual([]); + }); +}); diff --git a/packages/workbench/src/request-provenance.ts b/packages/workbench/src/request-provenance.ts index 0f6e7338c..8bc3855ce 100644 --- a/packages/workbench/src/request-provenance.ts +++ b/packages/workbench/src/request-provenance.ts @@ -25,7 +25,7 @@ const availableLineageSchema = z.strictObject({ depth: z.number().int().nonnegative(), generation: textSchema.optional(), parent: textSchema.optional(), - resolution: z.enum(['native', 'registry', 'inferred']), + resolution: z.enum(['native', 'registry', 'confirmed', 'inferred']), root: textSchema, subagent: z.strictObject({ id: textSchema, diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index b8c88d07b..50c240879 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -81,8 +81,16 @@ conversation, and are we a subagent?" — `{ conversation, root, parent?, depth, subagent?, resolution }`, resolved by the warm runtime's registry from the host's subagent start/stop and pre-tool hooks, or `unavailable` with a typed reason (`id-not-resolvable`, `no-subagent-events`, `no-shared-runtime`, `cloud-agent-no-user-hooks`, `unsupported-surface`, -`not-provided`). There is deliberately no operator or user identity axis: the framework never -reads or surfaces who the human behind a host session is. +`not-provided`). `resolution` is the trust level of `parent`/`root`/`depth`: `native` (named on +the payload — a Claude or Codex root, a Codex `_meta`), `registry` (the registry matched the +subagent's start to the newest unclaimed spawn call), `confirmed` (the host afterwards named +that edge itself — Claude's `Agent` `PostToolUse` carries the spawn `tool_use_id` and +`tool_response.agentId`, the child — for every edge up to the root), or `inferred` (Cursor +binds a child conversation to the single pending `subagentStart`). A confirmation also fills +in `subagent.toolCallId` for siblings claimed blind, places a start no spawn window could, and +moves a child the window filed under the wrong parent. There is deliberately no operator or +user identity axis: the framework never reads or surfaces who the human behind a host session +is. ## Shared layouts diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 6cbf7ae98..0e7debe42 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -75,7 +75,13 @@ actor 认证,因此这两个轴保持诚实的不可用,而不是被伪造 `{ conversation, root, parent?, depth, generation?, subagent?, resolution }`,由常驻运行时的注册表根据 宿主的子代理 start/stop 与 pre-tool 钩子解析得出;无法解析时则给出带类型化原因的 `unavailable` (`id-not-resolvable`、`no-subagent-events`、`no-shared-runtime`、`cloud-agent-no-user-hooks`、 -`unsupported-surface`、`not-provided`)。框架刻意不提供操作者或用户身份轴:它永远不会读取或暴露宿主 +`unsupported-surface`、`not-provided`)。`resolution` 表示 `parent`/`root`/`depth` 的可信级别: +`native`(载荷本身给出——Claude 或 Codex 的根会话、Codex 的 `_meta`)、`registry`(注册表把子代理的 +start 匹配到最新一个未被认领的 spawn 调用)、`confirmed`(宿主随后自己说出了这条边——Claude 的 +`Agent` `PostToolUse` 同时带有 spawn 的 `tool_use_id` 与作为子代理的 `tool_response.agentId`——并且 +直到根会话的每一条边都如此)、或 `inferred`(Cursor 把子会话绑定到唯一一个待定的 `subagentStart`)。 +一次确认还会为盲选认领的同胞补上 `subagent.toolCallId`,安放 spawn 窗口无法安放的 start,并把窗口 +挂错父节点的子代理挪到正确的父节点下。框架刻意不提供操作者或用户身份轴:它永远不会读取或暴露宿主 会话背后的人是谁。 ## 共享布局 diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index 22a816fd4..1d8174930 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -22,6 +22,7 @@ interface CapabilityRow { readonly state?: string; readonly reason?: string; readonly nativeEvent?: string; + readonly evidence?: readonly string[]; readonly availability?: Readonly>; } @@ -129,17 +130,22 @@ const messages = { `:::info Generated page\nThis page is a build-time copy of [\`docs/diagnostics.md\`](${repositoryUrl}/docs/diagnostics.md), the repository's diagnostics contract. Change that file, not this page.\n:::`, hostsTitle: 'Host capability matrix', hostsDescription: - 'Pinned host capability tables for the Claude Code, Codex, Cursor, and portable targets: observed versions, manifest locations, install surfaces, path tokens, MCP transports, and plugin components.', + 'Pinned host capability tables for the Claude Code, Codex, Cursor, and portable targets: observed versions, manifest locations, install surfaces, path tokens, MCP transports, conversation lineage, and plugin components.', hostsIntro: 'Every target adapter projects the normalized bundle against a pinned capability table: a JSON document recording the host version the evidence was observed against, the paths the host reads, and — for each capability — whether it is `supported`, `degraded`, or `unavailable` with a written reason. Nothing is inferred: a capability without evidence is unavailable, never a silent guess.', pinnedHosts: 'Pinned hosts', installSurface: 'Install surface', pathTokens: 'Path tokens', mcpTransports: 'MCP transports and token fields', + lineage: 'Conversation lineage', + lineageIntro: + 'The `lineage` section of each table: what the host tells the warm runtime about the conversation tree behind `request.lineage`. `subagent-events` says whether the host emits subagent start/stop hooks at all, `root` whether every payload names the root conversation, `parent` and `depth` how a subagent is placed under its parent (`supported` only when the child\'s own payload names it, `degraded` when the runtime registry places it from spawn-call ordering and any later host confirmation), and `mcp-correlation` how a generated MCP tool call is matched to the hook window that produced it. A degraded row records what the registry does and how certain it is; a supported row records the evidence. The `resolution` field on `request.lineage` reports which path answered (`native`, `registry`, `confirmed`, `inferred`).', + lineageDetails: 'Lineage row details', pluginComponents: 'Plugin components', pluginComponentsIntro: 'The `plugin` section of each table, flattened to dotted capability paths. Boolean rows record a component the adapter emits; rows with a state carry the reason the host evidence supports or withholds it. Evidence notes stay in the JSON files.', headers: { + lineageRow: 'Lineage row', host: 'Host', version: 'Observed version', manifest: 'Plugin manifest', @@ -202,17 +208,22 @@ const messages = { `:::info 生成页面\n本页是仓库诊断契约 [\`docs/diagnostics.md\`](${repositoryUrl}/docs/diagnostics.md) 在构建时的副本,内容保留英文原文。请修改该文件而不是本页。\n:::`, hostsTitle: '宿主能力矩阵', hostsDescription: - 'Claude Code、Codex、Cursor 与 portable 目标的固定宿主能力表:观测版本、清单位置、安装方式、路径令牌、MCP 传输与插件组件。', + 'Claude Code、Codex、Cursor 与 portable 目标的固定宿主能力表:观测版本、清单位置、安装方式、路径令牌、MCP 传输、会话谱系与插件组件。', hostsIntro: '每个目标适配器都会把归一化后的 bundle 投影到一份固定的能力表上:这份 JSON 文档记录了证据所对应的宿主版本、宿主读取的路径,以及每项能力是 `supported`、`degraded` 还是 `unavailable`,并附带书面原因。没有任何推断:缺少证据的能力即为不可用,绝不会默默猜测。', pinnedHosts: '固定宿主', installSurface: '安装方式', pathTokens: '路径令牌', mcpTransports: 'MCP 传输与令牌字段', + lineage: '会话谱系', + lineageIntro: + '每张表的 `lineage` 部分:宿主向常驻运行时提供了哪些关于 `request.lineage` 背后会话树的信息。`subagent-events` 表示宿主是否发出子代理 start/stop 钩子,`root` 表示每个载荷是否都给出根会话,`parent` 与 `depth` 表示子代理如何被放到其父节点之下(只有当子代理自己的载荷给出父节点时才是 `supported`;由运行时注册表按 spawn 调用顺序放置、再由宿主事后确认时为 `degraded`),`mcp-correlation` 表示生成的 MCP 工具调用如何匹配到产生它的钩子窗口。degraded 行记录注册表的做法及其确定程度;supported 行记录证据。`request.lineage` 上的 `resolution` 字段报告是哪条路径给出了答案(`native`、`registry`、`confirmed`、`inferred`)。', + lineageDetails: '谱系行详情', pluginComponents: '插件组件', pluginComponentsIntro: '每张表的 `plugin` 部分,按点分能力路径展开。布尔行表示适配器会发出的组件;带状态的行记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', headers: { + lineageRow: '谱系行', host: '宿主', version: '观测版本', manifest: '插件清单', @@ -470,6 +481,41 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string ), ); + sections.push(`## ${m.lineage}\n`); + sections.push(m.lineageIntro); + const lineageRows = unionKeys(hosts, data => asObject(data.lineage)); + sections.push( + table( + [m.headers.lineageRow, ...hosts.map(hostHeader)], + lineageRows.map(row => [ + code(row), + ...hosts.map(host => stateCell(capabilityRow(asObject(host.data.lineage)[row]), m)), + ]), + ), + ); + sections.push(`### ${m.lineageDetails}\n`); + sections.push( + table( + [m.headers.lineageRow, m.headers.host, m.headers.state, m.headers.detail], + lineageRows.flatMap(row => + hosts.flatMap(host => { + const entry = capabilityRow(asObject(host.data.lineage)[row]); + if (entry === undefined) { + return []; + } + const details: string[] = []; + if (entry.reason !== undefined) { + details.push(escapeProse(entry.reason)); + } + if (Array.isArray(entry.evidence)) { + details.push(m.evidenceNotes(entry.evidence.length)); + } + return [[code(row), code(host.host), entry.state ?? m.unavailable, details.length > 0 ? details.join('
') : m.notApplicable]]; + }), + ), + ), + ); + sections.push(`## ${m.pluginComponents}\n`); sections.push(m.pluginComponentsIntro); for (const host of hosts) { From 38b2e880a53b6367275b31d969d96dad34ff3aa1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:58:55 +0000 Subject: [PATCH 2/3] feat(lineage): park confirmations issued by unplaced Claude starts; regression tests on the 2.1.259 orchestration capture (#422) --- .changeset/422-claude-spawn-confirmation.md | 2 +- docs/audits/2026-09-03-host-lineage-matrix.md | 4 +- docs/entry-conventions.md | 7 +- .../adapters/capabilities/claude-2.1.250.json | 2 +- packages/rsc-runtime/src/lineage/registry.ts | 40 ++--- packages/rsc-runtime/src/lineage/state.ts | 50 +++++-- .../tests/lineage-registry.test.ts | 138 +++++++++++++++--- 7 files changed, 186 insertions(+), 57 deletions(-) diff --git a/.changeset/422-claude-spawn-confirmation.md b/.changeset/422-claude-spawn-confirmation.md index 5290e9943..cb2423334 100644 --- a/.changeset/422-claude-spawn-confirmation.md +++ b/.changeset/422-claude-spawn-confirmation.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -Take Claude Code's own word for a subagent's parent: the parent's `Agent` `PostToolUse` carries the spawn `tool_use_id` and `tool_response.agentId`, the child's `agent_id`, so the lineage registry behind `request.lineage` now confirms the edge it matched from spawn-call ordering, fills in `subagent.toolCallId` for siblings it had claimed blind, places a `SubagentStart` no spawn window could (none open, or two parents with one — the start's id, type, time and stop are kept meanwhile), moves a child it had filed under the wrong parent and re-bases that child's descendants, and holds a child the host names before its start arrives. Add `'confirmed'` to `AgentLineageResolution` (`@agent-bundle/runtime`, `RequestLineageProvenance` in `agent-bundle`): a subagent's `parent`/`root`/`depth` resolve `confirmed` once every edge up to the root is host-named — right after `SubagentStart` for a background spawn, after `SubagentStop` for a foreground one — and stay `registry` otherwise. The Claude capability table's `lineage.parent`/`lineage.depth` rows record the confirmation and its timing, and the generated hosts page gains a "Conversation lineage" section rendered from every host's `lineage` rows. (#422) +Take Claude Code's own word for a subagent's parent: the parent's `Agent` `PostToolUse` carries the spawn `tool_use_id` and `tool_response.agentId`, the child's `agent_id`, so the lineage registry behind `request.lineage` now confirms the edge it matched from spawn-call ordering, fills in `subagent.toolCallId` for siblings it had claimed blind, places a `SubagentStart` no spawn window could (none open, or two parents with one — the start's id, type, time, stop, and any confirmations it issued for its own children are kept meanwhile, so a missed spawn hook at one level does not lose the subtree beneath it), moves a child it had filed under the wrong parent and re-bases that child's descendants, and holds a child the host names before its start arrives. Add `'confirmed'` to `AgentLineageResolution` (`@agent-bundle/runtime`, `RequestLineageProvenance` in `agent-bundle`): a subagent's `parent`/`root`/`depth` resolve `confirmed` once every edge up to the root is host-named — right after `SubagentStart` for a background spawn, after `SubagentStop` for a foreground one — and stay `registry` otherwise. The Claude capability table's `lineage.parent`/`lineage.depth` rows record the confirmation and its timing, and the generated hosts page gains a "Conversation lineage" section rendered from every host's `lineage` rows. (#422) diff --git a/docs/audits/2026-09-03-host-lineage-matrix.md b/docs/audits/2026-09-03-host-lineage-matrix.md index bedfcbf89..705df428c 100644 --- a/docs/audits/2026-09-03-host-lineage-matrix.md +++ b/docs/audits/2026-09-03-host-lineage-matrix.md @@ -321,7 +321,7 @@ gives shell commands the agent runs `CURSOR_CONVERSATION_ID`/`CURSOR_REQUEST_ID` | Host | Root | Parent | | --- | --- | --- | -| Claude | Yes — `session_id` on every event is the root session (re-verified live: all 42 + 46 + 127 hook payloads across the three runs carry the root id — through resumed turns and a compaction too — and `CLAUDE_CODE_SESSION_ID` in every plugin process is the root id) | Only through the runtime's registry (inferred at `SubagentStart`); nothing in the child's payload | +| Claude | Yes — `session_id` on every event is the root session (re-verified live: all 42 + 46 + 127 hook payloads across the three runs carry the root id — through resumed turns and a compaction too — and `CLAUDE_CODE_SESSION_ID` in every plugin process is the root id) | Only through the runtime's registry (placed at `SubagentStart`, confirmed by the parent's `Agent` PostToolUse); nothing in the child's payload | | Codex | Yes — `session_id` is the root thread on every event, and `_meta.x-codex-turn-metadata.session_id` on MCP calls | Yes on MCP calls (`parent_thread_id`); on hooks only through the registry (or the parent rollout at `SubagentStop`) | | Cursor | Only through the registry — a child's payload carries neither root nor parent | Only through the registry (ordering-bound) | @@ -354,7 +354,7 @@ root, and the parent-of-subagent chain — is the only identity-adjacent surface | Roots first seen on a tool hook (Cursor restart or plugin load mid-conversation) | Cursor | §9 | Ours: workspace-scoped child binding plus correction (subtree re-rooted) when a bound conversation later carries a root-only event (`beforeSubmitPrompt`, `stop`, `sessionEnd`, `preCompact`) | | Cursor CLI not exercised | Cursor | table above | Needs a signed-in `cursor-agent`; not attempted on the operator's account | | ~~Claude session used a scripted model~~ | Claude | §8 | Closed 2026-09-03: two live-model sessions replace the stand-in fixture; every stand-in claim held, see §8 | -| Claude `PostToolUse(Agent).tool_response.agentId` not consumed by the registry | Claude | §1, §2 | The registry claims the newest unclaimed spawn under the root at `SubagentStart` and marks same-parent sibling cohorts `siblingsUncertain`; the parent's `Agent` PostToolUse could later firm those up. Not needed for any live run: even the orchestration run's two parallel spawns arrived serialised (`SubagentStart` before the next `Agent` `PreToolUse`), so every claim was certain; left as an improvement | +| ~~Claude `PostToolUse(Agent).tool_response.agentId` not consumed by the registry~~ | Claude | §1, §2 | Closed 2026-09-03 (#422 follow-up PR): the registry now treats the parent's `Agent` PostToolUse as the host's word on the edge — it confirms the spawn-window match (`resolution: confirmed` once every edge to the root is host-named), fills in sibling `toolCallId`s claimed blind, places a `SubagentStart` no window could (an unplaced start keeps id/type/time/stop, and any confirmations it issued for its own children, until its edge is known), moves a child filed under the wrong parent and re-bases its descendants, and holds a child named before its start (orchestration row 13 → 14). Replays of the orchestration capture with row 64 or row 81 withheld recover the sequential agent and the depth-2 child from rows 99/101 alone | | ~~Claude interactive OAuth session expired; refresh fails~~ | Claude | header table | Closed 2026-09-03: after the operator signed in again, the unmodified harness (`probe:install claude` byte-for-byte copy of `~/.claude/.credentials.json`) produced the signed-in 2.1.259 orchestration session. The two 2.1.257 runs had used a one-off re-seed of the isolated `.credentials.json` outside the harness (`docs/audits/2026-09-03-claude-live-session-proofs.md`); no credential-handling code was ever added | | `stream-json` shows depth-1 subagent traffic only | Claude | §1 (orchestration run) | The nested agent's 7 tool calls appear in hooks (with its `agent_id`) but in no stream envelope; `task_started` still announces it with `spawn_depth: 2`. Hooks remain the only complete lineage source; recorded, no framework impact | | Auto-compaction (`PreCompact trigger: "auto"`) not induced | Claude | §1 (orchestration run) | Manual `/compact` as a resumed `-p` prompt fires `PreCompact` → `SessionStart source: compact` → `PostCompact`; the automatic path would need a session near the context limit and was not attempted | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 2ab1d89f6..646fb1610 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -463,7 +463,12 @@ as the host's word on the edge, whatever it believed before: parent were claimed blind, so `subagent.toolCallId` appears after the fact; - it places an unplaced start under the parent, as it started (time, type, and already stopped when its stop came first), and consumes the spawn call - so no later start can claim it; + so no later start can claim it; a confirmation an unplaced start issued + for its own children waits with it and is applied when it is placed, so a + missed spawn hook at one level does not lose the subtree beneath it + (replaying the 2.1.259 capture without the root's spawn `PreToolUse` + recovers both the sequential agent and its depth-2 child from the two + `PostToolUse` payloads alone); - it moves a child the window filed under the wrong parent (a missed spawn `PreToolUse` leaves another parent's open call as the only candidate) and re-bases everything the child spawned meanwhile; diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index 4132cbee7..35f56166f 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -177,7 +177,7 @@ "reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no hook payload a subagent itself emits names its parent (#422: SubagentStart carries only the child agent_id and agent_type, and a subagent's own hooks carry session_id + agent_id; the hooks reference's common input fields list session_id, transcript_path, cwd, permission_mode, agent_id and agent_type, no parent). The runtime registry places the child under the Agent/Task PreToolUse that is the newest unclaimed spawn when SubagentStart fires (resolution: registry) — exact for sequential spawns and for the 2.1.259 parallel pair (the host serialises SubagentStart before the next Agent PreToolUse), verified at depth 1 and 2 in background, foreground and parallel spawn paths, refused as id-not-resolvable when two parents have unclaimed spawns, toolCallId withheld when siblings from one parent were claimed blind — and then takes the host's own word for the edge: the parent's Agent PostToolUse carries the spawn tool_use_id, the parent's identity and tool_response.agentId, the child. That hook confirms the edge (resolution: confirmed once every edge up to the root is host-named), fills in the sibling→tool_use_id assignment, places a start the window could not, and moves a child the window filed under the wrong parent, re-basing its descendants. It arrives right after SubagentStart for a background spawn (status async_launched; in 2.1.259 even before it) but only after the child's SubagentStop for a foreground one (status completed), so a foreground child's own events resolve as registry for their whole life. The host's stream-json task_started envelope (task_id, tool_use_id, spawn_depth) agrees with every placement but is not delivered to plugins.", "evidence": [ "live model 2026-09-03, Claude Code 2.1.257 (fixtures/host-lineage/claude-2.1.257.ndjson rows 17 and 41; claude-2.1.257-foreground.ndjson rows 47 and 49): the parent's Agent PostToolUse tool_response is {isAsync: true, status: \"async_launched\", agentId} for a background spawn and {status: \"completed\", agentId, agentType, content[]} for a foreground one; agentId equals the agent_id on the child's SubagentStart, SubagentStop and every hook the child fired.", - "live model 2026-09-03, Claude Code 2.1.259 (fixtures/host-lineage/claude-2.1.259-orchestration.ndjson): all four Agent PostToolUse payloads name their child — rows 13 and 17 (background Explore and general-purpose, async_launched; row 13 fires before that child's SubagentStart at row 14, so the registry holds the node by the host's word and the start adds its agent_type), row 99 (the depth-1 agent's own PostToolUse, carrying its agent_id, names the depth-2 child, completed) and row 101 (the root's, naming the depth-1 agent, completed); the depth-2 child's SubagentStart (row 82) and every hook it fires carry only its own agent_id and the root session_id, nothing that names the depth-1 parent.", + "live model 2026-09-03, Claude Code 2.1.259 (fixtures/host-lineage/claude-2.1.259-orchestration.ndjson): all four Agent PostToolUse payloads name their child — rows 13 and 17 (background Explore and general-purpose, async_launched; row 13 fires before that child's SubagentStart at row 14, so the registry holds the node by the host's word and the start adds its agent_type), row 99 (the depth-1 agent's own PostToolUse, carrying its agent_id, names the depth-2 child, completed) and row 101 (the root's, naming the depth-1 agent, completed); the depth-2 child's SubagentStart (row 82) and every hook it fires carry only its own agent_id and the root session_id, nothing that names the depth-1 parent. Replays of that capture with the root's spawn PreToolUse (row 64) or the depth-1 agent's (row 81) withheld leave the affected starts unplaced until rows 99/101 place them — the sequential agent under the root and the depth-2 child under it, with the start and stop times the hooks carried (packages/rsc-runtime/tests/lineage-registry.test.ts). The child's SubagentStop agent_transcript_path is /subagents/agent-.jsonl for depth-1 and depth-2 children alike (rows 57, 61, 98, 100), and Stop/SubagentStop background_tasks[] entries carry id, type, status, description and agent_type but no tool_use_id or parent, so neither path nor list can bind a nested child to its parent.", "uploaded 2026-09-03, Claude Code docs hooks reference (hooks-2.md, \"Common input fields\": agent_id and agent_type are the only subagent fields hooks add, no parent; \"SubagentStart\"/\"SubagentStop\": agent_id, agent_type, agent_transcript_path under the parent's transcript folder, background_tasks scoped to the parent session; \"PostToolUse\": tool_name, tool_input, tool_response, tool_use_id) and sub-agents reference (sub-agents-3.md, \"Resume subagents\": when a subagent completes Claude receives its agent ID — the id the registry matches to agent_id — and the one-shot built-in Explore and Plan agents return no agent ID to Claude, so a spawn whose tool_response omits agentId keeps the registry's own match; observed 2.1.259 background Explore responses did carry agentId)." ] }, diff --git a/packages/rsc-runtime/src/lineage/registry.ts b/packages/rsc-runtime/src/lineage/registry.ts index a489afeca..2c28493ae 100644 --- a/packages/rsc-runtime/src/lineage/registry.ts +++ b/packages/rsc-runtime/src/lineage/registry.ts @@ -591,25 +591,31 @@ export const createAgentLineageRegistry = ( if (event === 'tool/failure' && SPAWN_TOOLS[host](toolName)) { await dispatch('spawnFailed', { toolCallId }, keys); } - // The spawn's own post-tool hook names the child (Claude - // `tool_response.agentId`): the carrier is the parent, by the host's - // word. That places a start the claim window could not, confirms an - // edge it matched, or moves one it matched wrong — the same event in - // every case, so a redelivery is idempotent. - const confirmation = event === 'tool/after' && SPAWN_TOOLS[host](toolName) - ? SPAWN_CONFIRMATIONS[host](native) - : undefined; - if (confirmation !== undefined && confirmation.child !== carrier.conversation) { - await dispatch('spawnConfirmed', { - at: observedAt, - child: confirmation.child, - ...(confirmation.completed ? { completed: true } : {}), - parent: carrier.conversation, - toolCallId, - }, keys); - } } } + // The spawn's own post-tool hook names the child (Claude + // `tool_response.agentId`): the carrier is the parent, by the host's word. + // That places a start the claim window could not, confirms an edge it + // matched, or moves one it matched wrong — the same event in every case, + // so a redelivery is idempotent. A carrier that is itself an unplaced + // start keeps the confirmation until its own edge is known. + const confirmation = event === 'tool/after' && toolName !== undefined && SPAWN_TOOLS[host](toolName) + ? SPAWN_CONFIRMATIONS[host](native) + : undefined; + if ( + confirmation !== undefined + && carrier.conversation !== undefined + && confirmation.child !== carrier.conversation + && (carrierNode !== undefined || (state.unplacedStarts ?? []).some((start) => start.id === carrier.conversation)) + ) { + await dispatch('spawnConfirmed', { + at: observedAt, + child: confirmation.child, + ...(confirmation.completed ? { completed: true } : {}), + parent: carrier.conversation, + ...(toolCallId === undefined ? {} : { toolCallId }), + }, keys); + } return resolve(host, native, host === 'cursor' ? 'inferred' : 'registry'); }; diff --git a/packages/rsc-runtime/src/lineage/state.ts b/packages/rsc-runtime/src/lineage/state.ts index 7b31dab1f..8591196e2 100644 --- a/packages/rsc-runtime/src/lineage/state.ts +++ b/packages/rsc-runtime/src/lineage/state.ts @@ -58,7 +58,22 @@ export const OpenToolCallSchema = z.object({ * later host confirmation of the edge (Claude's `Agent` PostToolUse naming the * child) materializes the node as it started, not as it was confirmed. */ +/** The host named a spawn edge: `parent` produced `child` through `toolCallId` (see `spawnConfirmed`). */ +export const SpawnConfirmationSchema = z.object({ + at: timestamp, + child: id, + completed: z.boolean().optional(), + parent: id, + toolCallId: id.optional(), +}).strict(); + export const UnplacedStartSchema = z.object({ + /** + * Edges this start confirmed for its own children while it was still + * unplaced; applied the moment the start itself is placed, so a missed + * spawn hook at one level does not lose the subtree beneath it. + */ + confirmations: z.array(SpawnConfirmationSchema).optional(), generation: id.optional(), id, root: id, @@ -97,6 +112,7 @@ export const LineageStateSchema = z.object({ export type LineageNode = z.output; export type OpenToolCall = z.output; export type UnplacedStart = z.output; +export type SpawnConfirmation = z.output; export type LineageState = z.output; export const lineageEventSchemas = { @@ -122,13 +138,7 @@ export const lineageEventSchemas = { * `confirmed`; a pending spawn call with that `toolCallId` is consumed. * `completed` says the child has already finished from the host's view. */ - spawnConfirmed: z.object({ - at: timestamp, - child: id, - completed: z.boolean().optional(), - parent: id, - toolCallId: id.optional(), - }).strict(), + spawnConfirmed: SpawnConfirmationSchema, /** A spawn call failed before any child started, so no later start may claim it. */ spawnFailed: z.object({ toolCallId: id }).strict(), /** A subagent started but no single spawn call could be claimed for it; the edge waits for a host confirmation. */ @@ -311,14 +321,25 @@ export const reduceLineage = ( }; } case 'spawnConfirmed': { - const { at, child, completed, parent: parentId, toolCallId } = event.payload as { - at: string; child: string; completed?: boolean; parent: string; toolCallId?: string; - }; + const confirmation = event.payload as SpawnConfirmation; + const { at, child, completed, parent: parentId, toolCallId } = confirmation; const parent = state.nodes[parentId]; const existing = state.nodes[child]; + if (child === parentId || existing?.depth === 0) return state; + if (parent === undefined) { + // The parent is itself waiting for its edge: keep the confirmation + // with it, to apply the moment the parent is placed. + const unplaced = state.unplacedStarts ?? []; + if (!unplaced.some((start) => start.id === parentId)) return state; + return { + ...state, + unplacedStarts: unplaced.map((start) => start.id === parentId + ? { ...start, confirmations: [...(start.confirmations ?? []).filter((kept) => kept.child !== child), confirmation] } + : start), + }; + } // A root never becomes a child on a host's say-so, and no parent may be // made to descend from its own child; the confirmation is then noise. - if (parent === undefined || child === parentId || existing?.depth === 0) return state; const descendants = existing === undefined ? new Set() : descendantsOf(state.nodes, child, existing.root); if (descendants.has(parentId)) return state; const waiting = (state.unplacedStarts ?? []).find((start) => start.id === child); @@ -349,7 +370,7 @@ export const reduceLineage = ( call.root !== placed.root && (call.conversation === child || descendants.has(call.conversation)) ? { ...call, root: placed.root } : call; - return { + const placedState: LineageState = { ...state, nodes: pruneStopped({ ...Object.fromEntries(Object.entries(state.nodes).map(([key, node]) => [key, moved(node)])), @@ -367,6 +388,11 @@ export const reduceLineage = ( ? {} : { unplacedStarts: (state.unplacedStarts ?? []).filter((start) => start.id !== child) }), }; + // Edges the child confirmed for its own children while unplaced follow it into the tree. + return (waiting?.confirmations ?? []).reduce( + (next, kept) => reduceLineage(next, { name: 'spawnConfirmed', payload: kept }), + placedState, + ); } case 'sessionRetired': { const { root } = event.payload as { root: string }; diff --git a/packages/rsc-runtime/tests/lineage-registry.test.ts b/packages/rsc-runtime/tests/lineage-registry.test.ts index da0cb24ef..6445f0e2d 100644 --- a/packages/rsc-runtime/tests/lineage-registry.test.ts +++ b/packages/rsc-runtime/tests/lineage-registry.test.ts @@ -185,22 +185,52 @@ describe('lineage registry replaying the 2026-09-03 host captures', () => { { agentId: sequential, caller: undefined, status: 'completed' }, ]); const spawnCall = new Map(spawnResults.map((result) => [result.agentId, result.toolUseId])); - - // Every child hook resolves to its own agent under the root; parents and depths follow the spawn tree. + // The fixture row (1-based) of the PostToolUse that named each child: rows 13 and 17 + // for the background pair — row 13 fires before the Explore agent's own SubagentStart + // (row 14) — row 99 (the sequential agent's, after the nested child's SubagentStop at + // row 98) and row 101 (the root's, after the sequential agent's SubagentStop at row 100). + const confirmedAt = new Map(); + for (const [position, record] of records.entries()) { + const native = record.event?.native; + if (native?.['hook_event_name'] === 'PostToolUse' && native['tool_name'] === 'Agent') { + confirmedAt.set((native['tool_response'] as { agentId: string }).agentId, position + 1); + } + } + expect([explore, parallel, nested, sequential].map((id) => confirmedAt.get(id))).toEqual([13, 17, 99, 101]); + + // Every child hook resolves to its own agent under the root; parents and depths follow + // the spawn tree. The resolution says how much of that the host had vouched for by then: + // `confirmed` once the child's spawn PostToolUse and every ancestor's have fired, + // `registry` while any edge on the chain still rests on the spawn-window match — so the + // background pair's hooks are host-confirmed for their whole run, while the foreground + // sequential and nested agents (confirmed only after their SubagentStop) never are. const expectedParent: Record = { [explore]: { depth: 1, parent: root }, [nested]: { depth: 2, parent: sequential }, [parallel]: { depth: 1, parent: root }, [sequential]: { depth: 1, parent: root }, }; + const chain = (agentId: string): string[] => { + const parent = expectedParent[agentId]!.parent; + return parent === root ? [agentId] : [agentId, ...chain(parent)]; + }; + const expectedResolution = (agentId: string, row: number): string => + chain(agentId).every((id) => confirmedAt.get(id)! < row) ? 'confirmed' : 'registry'; let childEvents = 0; + const seenResolutions = new Map>(); for (const entry of lineages) { const agentId = entry.native?.['agent_id'] as string | undefined; if (agentId === undefined) continue; childEvents += 1; - expect(value(entry.lineage)).toMatchObject({ conversation: agentId, resolution: 'registry', root, ...expectedParent[agentId]! }); + const resolution = expectedResolution(agentId, entry.index); + seenResolutions.set(agentId, new Set([...(seenResolutions.get(agentId) ?? []), resolution])); + expect(value(entry.lineage), `row ${String(entry.index)}`).toMatchObject({ conversation: agentId, resolution, root, ...expectedParent[agentId]! }); } expect(childEvents).toBe(natives.filter((native) => native['agent_id'] !== undefined).length); + expect([...seenResolutions.get(explore)!]).toEqual(['confirmed']); + expect([...seenResolutions.get(parallel)!].sort()).toEqual(['confirmed', 'registry']); + expect([...seenResolutions.get(sequential)!]).toEqual(['registry']); + expect([...seenResolutions.get(nested)!]).toEqual(['registry']); // Root-side hooks, including the resumed turns, the compact pair and the failed `dump` calls, stay at depth 0. for (const entry of lineages) { if (entry.native !== undefined && entry.native['agent_id'] === undefined) { @@ -219,7 +249,9 @@ describe('lineage registry replaying the 2026-09-03 host captures', () => { // MCP `probe` calls: `claudecode/toolUseId` names the open PreToolUse, so each resolves to the agent that made it. const probes = lineages.filter((entry) => entry.kind === 'mcp:probe').map((entry) => value(entry.lineage)); - expect(probes.map((lineage) => [lineage.conversation, lineage.depth])).toEqual([[explore, 1], [parallel, 1], [sequential, 1], [nested, 2], [root, 0]]); + expect(probes.map((lineage) => [lineage.conversation, lineage.depth, lineage.resolution])).toEqual([ + [explore, 1, 'confirmed'], [parallel, 1, 'confirmed'], [sequential, 1, 'registry'], [nested, 2, 'registry'], [root, 0, 'registry'], + ]); // PostToolUseFailure closes a window like PostToolUse; nothing stays open, all five nodes stopped. expect(natives.filter((native) => native['hook_event_name'] === 'PostToolUseFailure')).toHaveLength(3); @@ -227,6 +259,14 @@ describe('lineage registry replaying the 2026-09-03 host captures', () => { expect(snapshot.openCalls).toEqual([]); expect(Object.keys(snapshot.nodes).sort()).toEqual([explore, nested, parallel, root, sequential].sort()); expect(Object.values(snapshot.nodes).every((node) => node.stoppedAt !== undefined)).toBe(true); + // Every edge the spawn window matched was later named by the host, with the same spawn call. + for (const id of [explore, parallel, sequential, nested]) { + expect(snapshot.nodes[id], id).toMatchObject({ confirmed: true, toolCallId: spawnCall.get(id), ...expectedParent[id]! }); + } + expect(snapshot.unplacedStarts ?? []).toEqual([]); + // The Explore agent was held from its PostToolUse (row 13) and completed by its SubagentStart (row 14): + // the start's time and agent_type, not the confirmation's. + expect(snapshot.nodes[explore]).toMatchObject({ startedAt: records[13]!.event!.canonical.observedAt, type: 'Explore' }); // The model's own stream agrees: `task_started` names the same agent id (`task_id`) and // spawn call (`tool_use_id`) per child, with the depth the registry derived. @@ -1141,27 +1181,79 @@ describe('lineage registry Claude spawn confirmation from the Agent PostToolUse expect(registry.snapshot().nodes['p']?.confirmed).toBeUndefined(); }); - it('takes a confirmation that precedes the child\'s start, then lets the start add its type without claiming another spawn', async () => { - // Claude Code 2.1.259 (fixtures/host-lineage/claude-2.1.259-orchestration.ndjson - // rows 12–17, PR #455): for a background Explore spawn the `Agent` - // PostToolUse (`async_launched`, agentId) fires before that child's - // SubagentStart, and the root's next spawn follows at once. + // The live Claude Code 2.1.259 orchestration capture (PR #455) replayed with + // chosen hook rows withheld, the way a plugin whose PreToolUse hook was not + // yet installed, or a runtime that came up mid-turn, would have seen it. + const orchestration = fixture('claude-2.1.259-orchestration.ndjson'); + const row = (index: number) => orchestration[index - 1]!; + const native = (index: number) => row(index).event!.native; + const agentOf = (index: number) => native(index)['agent_id'] as string; + const at = (index: number) => row(index).event!.canonical.observedAt; + const withoutRows = (...dropped: number[]) => orchestration.filter((_record, index) => !dropped.includes(index + 1)); + /** Replays a filtered copy of the capture and keys each result by its original fixture row. */ + const replayRows = async (records: readonly FixtureRecord[], registry: AgentLineageRegistry) => { + const lineages = await replay('claude', records, registry); + return new Map(lineages.map((entry) => [orchestration.indexOf(records[entry.index - 1]!) + 1, entry.lineage])); + }; + const orchestrationRoot = native(1)['session_id'] as string; + // Row 14/16: the background Explore and general-purpose starts; 65: the + // sequential foreground start; 82: its nested child. Rows 64 and 81 are the + // root's and the sequential agent's `Agent` PreToolUse; 99 and 101 the + // PostToolUse that name the nested and the sequential child. + const [explore, parallel, sequential, nested] = [14, 16, 65, 82].map(agentOf) as [string, string, string, string]; + + it('holds the Explore child from the PostToolUse that precedes its SubagentStart (2.1.259 rows 12–17), and lets the start add its type without claiming the next spawn', async () => { const registry = createAgentLineageRegistry(); - const observe = claude(registry); - await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); - await observe('tool/before', 'sp1', spawnBefore('sp1')); - await observe('tool/after', 'sp1-done', spawnAfter('sp1', 'explore', 'async_launched'), '2026-09-03T00:00:01.000Z'); - expect(registry.snapshot().nodes['explore']).toMatchObject({ confirmed: true, depth: 1, parent: 'root', startedAt: '2026-09-03T00:00:01.000Z', toolCallId: 'sp1' }); + await replay('claude', orchestration.slice(0, 13), registry); + expect(registry.snapshot().nodes[explore]).toMatchObject({ confirmed: true, depth: 1, parent: orchestrationRoot, startedAt: at(13), toolCallId: native(12)['tool_use_id'] }); + expect(registry.snapshot().nodes[explore]?.type).toBeUndefined(); expect(registry.snapshot().pendingSpawns).toEqual([]); - const started = await observe('agent/start', 'explore', { ...session('explore'), agent_type: 'Explore', hook_event_name: 'SubagentStart' }, '2026-09-03T00:00:02.000Z'); - expect(value(started)).toMatchObject({ conversation: 'explore', depth: 1, parent: 'root', resolution: 'confirmed', subagent: { id: 'explore', toolCallId: 'sp1', type: 'Explore' } }); - expect(registry.snapshot().nodes['explore']).toMatchObject({ confirmed: true, startedAt: '2026-09-03T00:00:02.000Z', type: 'Explore' }); - // The redelivered start changes nothing. - await observe('agent/start', 'explore-again', { ...session('explore'), agent_type: 'Explore', hook_event_name: 'SubagentStart' }, '2026-09-03T00:00:09.000Z'); - expect(registry.snapshot().nodes['explore']?.startedAt).toBe('2026-09-03T00:00:02.000Z'); - // The root's second spawn is claimed by its own child, not by the one already placed. - await observe('tool/before', 'sp2', spawnBefore('sp2')); - expect(value(await observe('agent/start', 'gp', start('gp')))).toMatchObject({ depth: 1, parent: 'root', resolution: 'registry', subagent: { toolCallId: 'sp2' } }); + const [started] = await replay('claude', orchestration.slice(13, 14), registry); + expect(value(started!.lineage)).toMatchObject({ conversation: explore, depth: 1, parent: orchestrationRoot, resolution: 'confirmed', subagent: { id: explore, toolCallId: native(12)['tool_use_id'], type: 'Explore' } }); + expect(registry.snapshot().nodes[explore]).toMatchObject({ startedAt: at(14), type: 'Explore' }); + // The same start redelivered under another key changes nothing. + await registry.observe({ event: 'agent/start', host: 'claude', idempotencyKey: 'redelivered-14', native: native(14), observedAt: '2026-09-04T00:00:00.000Z' }); + expect(registry.snapshot().nodes[explore]?.startedAt).toBe(at(14)); + // The root's second spawn (rows 15–17) is claimed by its own child, not by the one already placed. + const rest = await replay('claude', orchestration.slice(14, 17), registry); + expect(value(rest[1]!.lineage)).toMatchObject({ conversation: parallel, depth: 1, parent: orchestrationRoot, resolution: 'registry', subagent: { toolCallId: native(15)['tool_use_id'] } }); + expect(registry.snapshot().nodes[parallel]).toMatchObject({ confirmed: true, toolCallId: native(15)['tool_use_id'] }); + }); + + it('recovers the nested child when the sequential agent\'s spawn PreToolUse (2.1.259 row 81) was never seen', async () => { + const registry = createAgentLineageRegistry(); + const byRow = await replayRows(withoutRows(81), registry); + // The nested start has no spawn to claim: unplaced, and unresolvable for its whole run. + expect(byRow.get(82)).toEqual(unavailable('id-not-resolvable')); + for (const index of [83, 85, 89, 98]) expect(byRow.get(index), `row ${String(index)}`).toEqual(unavailable('id-not-resolvable')); + // Row 99, the sequential agent's PostToolUse naming it, places it as it started and stopped. + expect(registry.snapshot().nodes[nested]).toMatchObject({ + confirmed: true, depth: 2, parent: sequential, root: orchestrationRoot, startedAt: at(82), stoppedAt: at(98), toolCallId: native(99)['tool_use_id'], type: 'general-purpose', + }); + expect(registry.snapshot().unplacedStarts ?? []).toEqual([]); + }); + + it('recovers the sequential agent and its nested child when the root\'s spawn PreToolUse (2.1.259 row 64) was never seen', async () => { + const registry = createAgentLineageRegistry(); + const byRow = await replayRows(withoutRows(64), registry); + // Neither start can be placed: the sequential agent has no spawn to claim, and the + // nested child's spawn call opens no window because its carrier is unplaced. + expect(byRow.get(65)).toEqual(unavailable('id-not-resolvable')); + expect(byRow.get(82)).toEqual(unavailable('id-not-resolvable')); + // Before the root's PostToolUse (row 101) the nested confirmation (row 99) waits with its unplaced parent. + const partial = createAgentLineageRegistry(); + await replay('claude', withoutRows(64).slice(0, 99), partial); + expect(partial.snapshot().unplacedStarts).toMatchObject([ + { confirmations: [{ child: nested, parent: sequential, toolCallId: native(99)['tool_use_id'] }], id: sequential, stoppedAt: at(100) }, + { id: nested, stoppedAt: at(98) }, + ]); + // Row 101 places the sequential agent under the root and applies the parked confirmation beneath it. + expect(registry.snapshot().nodes[sequential]).toMatchObject({ confirmed: true, depth: 1, parent: orchestrationRoot, startedAt: at(65), stoppedAt: at(100), toolCallId: native(101)['tool_use_id'] }); + expect(registry.snapshot().nodes[nested]).toMatchObject({ confirmed: true, depth: 2, parent: sequential, root: orchestrationRoot, startedAt: at(82), stoppedAt: at(98), toolCallId: native(99)['tool_use_id'] }); + expect(registry.snapshot().unplacedStarts ?? []).toEqual([]); + // Nothing else moved: the background pair still hangs under the root. + expect(registry.snapshot().nodes[explore]).toMatchObject({ confirmed: true, depth: 1, parent: orchestrationRoot }); + expect(registry.snapshot().nodes[parallel]).toMatchObject({ confirmed: true, depth: 1, parent: orchestrationRoot }); }); it('drops the unplaced starts of a session that ends', async () => { From 6992c89be571e4df4b0287faef16b957dd286531 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 00:25:02 +0000 Subject: [PATCH 3/3] fix(lineage): an Agent resume call is not a spawn; add confirmed to the worktree-proximity provenance enum (#422) --- examples/worktree-proximity/README.md | 3 +- examples/worktree-proximity/src/state.ts | 9 +++--- packages/rsc-runtime/src/lineage/registry.ts | 24 ++++++++++++++-- .../tests/lineage-registry.test.ts | 28 +++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md index 9d3904115..3ca7f9af2 100644 --- a/examples/worktree-proximity/README.md +++ b/examples/worktree-proximity/README.md @@ -75,7 +75,8 @@ the runtime's `outside-invocation` error outside a request. Every identity claim records where it came from. `native` is read from the host envelope (or a `request.lineage` the runtime resolved natively), -`registry` and `inferred` are the runtime lineage registry's own resolutions, +`registry`, `inferred` and `confirmed` are the runtime lineage registry's own +resolutions (`confirmed` once the host has named every edge up to the root), and `derived` is this application's fallback: - `session/start` observes `session:` as the root actor, where the root diff --git a/examples/worktree-proximity/src/state.ts b/examples/worktree-proximity/src/state.ts index d3b06ac63..15f675011 100644 --- a/examples/worktree-proximity/src/state.ts +++ b/examples/worktree-proximity/src/state.ts @@ -4,11 +4,12 @@ import { z } from 'zod'; const nonEmpty = z.string().trim().min(1); /** * Where an identity claim came from: `native` is read straight from the host - * envelope (or a `request.lineage` the runtime resolved natively), `registry` - * and `inferred` are the runtime lineage registry's own resolutions, and - * `derived` is this application's fallback (`worktree:`). + * envelope (or a `request.lineage` the runtime resolved natively), `registry`, + * `inferred` and `confirmed` are the runtime lineage registry's own + * resolutions (`confirmed` once the host has named every edge to the root), + * and `derived` is this application's fallback (`worktree:`). */ -const provenance = z.enum(['native', 'registry', 'inferred', 'derived']); +const provenance = z.enum(['native', 'registry', 'inferred', 'confirmed', 'derived']); export type IdentityProvenance = z.output; diff --git a/packages/rsc-runtime/src/lineage/registry.ts b/packages/rsc-runtime/src/lineage/registry.ts index 2c28493ae..899c094db 100644 --- a/packages/rsc-runtime/src/lineage/registry.ts +++ b/packages/rsc-runtime/src/lineage/registry.ts @@ -80,6 +80,24 @@ const SPAWN_TOOLS: Readonly boolean>> cursor: (toolName) => toolName === 'Task', }); +/** + * Whether this call of a spawn tool starts a new subagent. Claude's `Agent` + * can also be invoked with `tool_input.resume` naming an existing agent + * (sub-agents reference, "Resume subagents": resuming starts a new run under + * the same ID); its PostToolUse then returns that agent's id without a new + * lineage edge, so such a call opens no spawn window and confirms nothing. + */ +const SPAWNS_NEW_AGENT: Readonly>) => boolean>> = Object.freeze({ + claude: (toolName, native) => { + if (!SPAWN_TOOLS.claude(toolName)) return false; + const input = native['tool_input']; + if (input === null || typeof input !== 'object' || Array.isArray(input)) return true; + return nativeString(input as Readonly>, 'resume') === undefined; + }, + codex: (toolName) => SPAWN_TOOLS.codex(toolName), + cursor: (toolName) => SPAWN_TOOLS.cursor(toolName), +}); + /** The host named the child a spawn call produced, on the spawn's own post-tool hook. */ interface SpawnConfirmation { readonly child: string; @@ -580,7 +598,7 @@ export const createAgentLineageRegistry = ( ...(carrier.generation === undefined ? {} : { generation: carrier.generation }), openedAt: observedAt, root: carrierNode.root, - ...(SPAWN_TOOLS[host](toolName) ? { spawn: true } : {}), + ...(SPAWNS_NEW_AGENT[host](toolName, native) ? { spawn: true } : {}), toolCallId, toolName, }, keys); @@ -588,7 +606,7 @@ export const createAgentLineageRegistry = ( await dispatch('toolCallClosed', { conversation: carrier.conversation, toolCallId }, keys); // A spawn that failed produced no child; Codex closes a successful // spawn before SubagentStart, so only failure discards the claim. - if (event === 'tool/failure' && SPAWN_TOOLS[host](toolName)) { + if (event === 'tool/failure' && SPAWNS_NEW_AGENT[host](toolName, native)) { await dispatch('spawnFailed', { toolCallId }, keys); } } @@ -599,7 +617,7 @@ export const createAgentLineageRegistry = ( // matched, or moves one it matched wrong — the same event in every case, // so a redelivery is idempotent. A carrier that is itself an unplaced // start keeps the confirmation until its own edge is known. - const confirmation = event === 'tool/after' && toolName !== undefined && SPAWN_TOOLS[host](toolName) + const confirmation = event === 'tool/after' && toolName !== undefined && SPAWNS_NEW_AGENT[host](toolName, native) ? SPAWN_CONFIRMATIONS[host](native) : undefined; if ( diff --git a/packages/rsc-runtime/tests/lineage-registry.test.ts b/packages/rsc-runtime/tests/lineage-registry.test.ts index 6445f0e2d..2ebccac1d 100644 --- a/packages/rsc-runtime/tests/lineage-registry.test.ts +++ b/packages/rsc-runtime/tests/lineage-registry.test.ts @@ -1256,6 +1256,34 @@ describe('lineage registry Claude spawn confirmation from the Agent PostToolUse expect(registry.snapshot().nodes[parallel]).toMatchObject({ confirmed: true, depth: 1, parent: orchestrationRoot }); }); + it('treats an Agent call with tool_input.resume as no spawn: it opens no window and its agentId confirms nothing', async () => { + // sub-agents reference (sub-agents-3.md, "Resume subagents"): resuming starts a new + // run under the same agent id; the PostToolUse names that id without a new edge. + const registry = createAgentLineageRegistry(); + const observe = claude(registry); + await observe('session/start', 's', { hook_event_name: 'SessionStart', session_id: 'root' }); + await observe('tool/before', 'sp1', spawnBefore('sp1')); + await observe('agent/start', 'a', start('a')); + await observe('tool/after', 'sp1-done', spawnAfter('sp1', 'a', 'async_launched')); + // `a` spawned `b` in the foreground; `b` is confirmed under `a`. + await observe('tool/before', 'sp2', spawnBefore('sp2', 'a')); + await observe('agent/start', 'b', start('b')); + await observe('tool/after', 'sp2-done', spawnAfter('sp2', 'b', 'completed', 'a')); + expect(registry.snapshot().nodes['b']).toMatchObject({ confirmed: true, depth: 2, parent: 'a', toolCallId: 'sp2' }); + // Another child `c` of the root resumes `b` by id: no window, no confirmation, no re-parenting. + await observe('tool/before', 'sp3', spawnBefore('sp3')); + await observe('agent/start', 'c', start('c')); + const resume = { ...session('c'), hook_event_name: 'PreToolUse', tool_input: { prompt: 'continue', resume: 'b' }, tool_name: 'Agent', tool_use_id: 'rs1' }; + await observe('tool/before', 'rs1', resume); + expect(registry.snapshot().openCalls.find((call) => call.toolCallId === 'rs1')).toMatchObject({ conversation: 'c' }); + expect(registry.snapshot().pendingSpawns.map((call) => call.toolCallId)).toEqual([]); + // A start `d` arriving now cannot claim the resume call. + expect(await observe('agent/start', 'd', start('d'))).toEqual(unavailable('id-not-resolvable')); + await observe('tool/after', 'rs1-done', { ...resume, hook_event_name: 'PostToolUse', tool_response: { agentId: 'b', agentType: 'general-purpose', content: [], status: 'completed' } }); + expect(registry.snapshot().nodes['b']).toMatchObject({ confirmed: true, depth: 2, parent: 'a', toolCallId: 'sp2' }); + expect(registry.snapshot().openCalls.find((call) => call.toolCallId === 'rs1')).toBeUndefined(); + }); + it('drops the unplaced starts of a session that ends', async () => { const registry = createAgentLineageRegistry(); const observe = claude(registry);