Skip to content

Commit 331cd59

Browse files
feat(lineage): park confirmations issued by unplaced Claude starts; regression tests on the 2.1.259 orchestration capture (#422)
1 parent 2743bc0 commit 331cd59

7 files changed

Lines changed: 186 additions & 57 deletions

File tree

‎.changeset/422-claude-spawn-confirmation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"agent-bundle": patch
44
---
55

6-
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)
6+
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)

‎docs/audits/2026-09-03-host-lineage-matrix.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ gives shell commands the agent runs `CURSOR_CONVERSATION_ID`/`CURSOR_REQUEST_ID`
321321

322322
| Host | Root | Parent |
323323
| --- | --- | --- |
324-
| 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 |
324+
| 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 |
325325
| 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`) |
326326
| Cursor | Only through the registry — a child's payload carries neither root nor parent | Only through the registry (ordering-bound) |
327327

@@ -354,7 +354,7 @@ root, and the parent-of-subagent chain — is the only identity-adjacent surface
354354
| 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`) |
355355
| Cursor CLI not exercised | Cursor | table above | Needs a signed-in `cursor-agent`; not attempted on the operator's account |
356356
| ~~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 |
357-
| 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 |
357+
| ~~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 |
358358
| ~~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 |
359359
| `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 |
360360
| 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 |

‎docs/entry-conventions.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,12 @@ as the host's word on the edge, whatever it believed before:
463463
parent were claimed blind, so `subagent.toolCallId` appears after the fact;
464464
- it places an unplaced start under the parent, as it started (time, type,
465465
and already stopped when its stop came first), and consumes the spawn call
466-
so no later start can claim it;
466+
so no later start can claim it; a confirmation an unplaced start issued
467+
for its own children waits with it and is applied when it is placed, so a
468+
missed spawn hook at one level does not lose the subtree beneath it
469+
(replaying the 2.1.259 capture without the root's spawn `PreToolUse`
470+
recovers both the sequential agent and its depth-2 child from the two
471+
`PostToolUse` payloads alone);
467472
- it moves a child the window filed under the wrong parent (a missed spawn
468473
`PreToolUse` leaves another parent's open call as the only candidate) and
469474
re-bases everything the child spawned meanwhile;

‎packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@
177177
"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.",
178178
"evidence": [
179179
"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.",
180-
"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.",
180+
"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 <session>/subagents/agent-<agent_id>.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.",
181181
"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)."
182182
]
183183
},

‎packages/rsc-runtime/src/lineage/registry.ts‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -591,25 +591,31 @@ export const createAgentLineageRegistry = (
591591
if (event === 'tool/failure' && SPAWN_TOOLS[host](toolName)) {
592592
await dispatch('spawnFailed', { toolCallId }, keys);
593593
}
594-
// The spawn's own post-tool hook names the child (Claude
595-
// `tool_response.agentId`): the carrier is the parent, by the host's
596-
// word. That places a start the claim window could not, confirms an
597-
// edge it matched, or moves one it matched wrong — the same event in
598-
// every case, so a redelivery is idempotent.
599-
const confirmation = event === 'tool/after' && SPAWN_TOOLS[host](toolName)
600-
? SPAWN_CONFIRMATIONS[host](native)
601-
: undefined;
602-
if (confirmation !== undefined && confirmation.child !== carrier.conversation) {
603-
await dispatch('spawnConfirmed', {
604-
at: observedAt,
605-
child: confirmation.child,
606-
...(confirmation.completed ? { completed: true } : {}),
607-
parent: carrier.conversation,
608-
toolCallId,
609-
}, keys);
610-
}
611594
}
612595
}
596+
// The spawn's own post-tool hook names the child (Claude
597+
// `tool_response.agentId`): the carrier is the parent, by the host's word.
598+
// That places a start the claim window could not, confirms an edge it
599+
// matched, or moves one it matched wrong — the same event in every case,
600+
// so a redelivery is idempotent. A carrier that is itself an unplaced
601+
// start keeps the confirmation until its own edge is known.
602+
const confirmation = event === 'tool/after' && toolName !== undefined && SPAWN_TOOLS[host](toolName)
603+
? SPAWN_CONFIRMATIONS[host](native)
604+
: undefined;
605+
if (
606+
confirmation !== undefined
607+
&& carrier.conversation !== undefined
608+
&& confirmation.child !== carrier.conversation
609+
&& (carrierNode !== undefined || (state.unplacedStarts ?? []).some((start) => start.id === carrier.conversation))
610+
) {
611+
await dispatch('spawnConfirmed', {
612+
at: observedAt,
613+
child: confirmation.child,
614+
...(confirmation.completed ? { completed: true } : {}),
615+
parent: carrier.conversation,
616+
...(toolCallId === undefined ? {} : { toolCallId }),
617+
}, keys);
618+
}
613619
return resolve(host, native, host === 'cursor' ? 'inferred' : 'registry');
614620
};
615621

‎packages/rsc-runtime/src/lineage/state.ts‎

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,22 @@ export const OpenToolCallSchema = z.object({
5858
* later host confirmation of the edge (Claude's `Agent` PostToolUse naming the
5959
* child) materializes the node as it started, not as it was confirmed.
6060
*/
61+
/** The host named a spawn edge: `parent` produced `child` through `toolCallId` (see `spawnConfirmed`). */
62+
export const SpawnConfirmationSchema = z.object({
63+
at: timestamp,
64+
child: id,
65+
completed: z.boolean().optional(),
66+
parent: id,
67+
toolCallId: id.optional(),
68+
}).strict();
69+
6170
export const UnplacedStartSchema = z.object({
71+
/**
72+
* Edges this start confirmed for its own children while it was still
73+
* unplaced; applied the moment the start itself is placed, so a missed
74+
* spawn hook at one level does not lose the subtree beneath it.
75+
*/
76+
confirmations: z.array(SpawnConfirmationSchema).optional(),
6277
generation: id.optional(),
6378
id,
6479
root: id,
@@ -97,6 +112,7 @@ export const LineageStateSchema = z.object({
97112
export type LineageNode = z.output<typeof LineageNodeSchema>;
98113
export type OpenToolCall = z.output<typeof OpenToolCallSchema>;
99114
export type UnplacedStart = z.output<typeof UnplacedStartSchema>;
115+
export type SpawnConfirmation = z.output<typeof SpawnConfirmationSchema>;
100116
export type LineageState = z.output<typeof LineageStateSchema>;
101117

102118
export const lineageEventSchemas = {
@@ -122,13 +138,7 @@ export const lineageEventSchemas = {
122138
* `confirmed`; a pending spawn call with that `toolCallId` is consumed.
123139
* `completed` says the child has already finished from the host's view.
124140
*/
125-
spawnConfirmed: z.object({
126-
at: timestamp,
127-
child: id,
128-
completed: z.boolean().optional(),
129-
parent: id,
130-
toolCallId: id.optional(),
131-
}).strict(),
141+
spawnConfirmed: SpawnConfirmationSchema,
132142
/** A spawn call failed before any child started, so no later start may claim it. */
133143
spawnFailed: z.object({ toolCallId: id }).strict(),
134144
/** 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 = (
311321
};
312322
}
313323
case 'spawnConfirmed': {
314-
const { at, child, completed, parent: parentId, toolCallId } = event.payload as {
315-
at: string; child: string; completed?: boolean; parent: string; toolCallId?: string;
316-
};
324+
const confirmation = event.payload as SpawnConfirmation;
325+
const { at, child, completed, parent: parentId, toolCallId } = confirmation;
317326
const parent = state.nodes[parentId];
318327
const existing = state.nodes[child];
328+
if (child === parentId || existing?.depth === 0) return state;
329+
if (parent === undefined) {
330+
// The parent is itself waiting for its edge: keep the confirmation
331+
// with it, to apply the moment the parent is placed.
332+
const unplaced = state.unplacedStarts ?? [];
333+
if (!unplaced.some((start) => start.id === parentId)) return state;
334+
return {
335+
...state,
336+
unplacedStarts: unplaced.map((start) => start.id === parentId
337+
? { ...start, confirmations: [...(start.confirmations ?? []).filter((kept) => kept.child !== child), confirmation] }
338+
: start),
339+
};
340+
}
319341
// A root never becomes a child on a host's say-so, and no parent may be
320342
// made to descend from its own child; the confirmation is then noise.
321-
if (parent === undefined || child === parentId || existing?.depth === 0) return state;
322343
const descendants = existing === undefined ? new Set<string>() : descendantsOf(state.nodes, child, existing.root);
323344
if (descendants.has(parentId)) return state;
324345
const waiting = (state.unplacedStarts ?? []).find((start) => start.id === child);
@@ -349,7 +370,7 @@ export const reduceLineage = (
349370
call.root !== placed.root && (call.conversation === child || descendants.has(call.conversation))
350371
? { ...call, root: placed.root }
351372
: call;
352-
return {
373+
const placedState: LineageState = {
353374
...state,
354375
nodes: pruneStopped({
355376
...Object.fromEntries(Object.entries(state.nodes).map(([key, node]) => [key, moved(node)])),
@@ -367,6 +388,11 @@ export const reduceLineage = (
367388
? {}
368389
: { unplacedStarts: (state.unplacedStarts ?? []).filter((start) => start.id !== child) }),
369390
};
391+
// Edges the child confirmed for its own children while unplaced follow it into the tree.
392+
return (waiting?.confirmations ?? []).reduce(
393+
(next, kept) => reduceLineage(next, { name: 'spawnConfirmed', payload: kept }),
394+
placedState,
395+
);
370396
}
371397
case 'sessionRetired': {
372398
const { root } = event.payload as { root: string };

0 commit comments

Comments
 (0)