Skip to content

Commit 82c199b

Browse files
fix(lineage): Codex stop correction reads the child rollout for exact depth; a recorded agent path never claims a differently-pathed spawn
Review threads on #480: (1) reparenting to a parent the registry never placed kept the inferred depth; the same SubagentStop names the child's own rollout in agent_transcript_path, so exact parent and depth are read from it first, with the parent-rollout basename as the fallback. (2) with an exact agent path from the rollout, pending spawns recorded with a different path are excluded; only matching or pathless (response missed) calls remain eligible.
1 parent 189bd4c commit 82c199b

5 files changed

Lines changed: 106 additions & 17 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ gives shell commands the agent runs `CURSOR_CONVERSATION_ID`/`CURSOR_REQUEST_ID`
314314
| Host | Answer | How |
315315
| --- | --- | --- |
316316
| Claude | Yes, with the parent inferred | `SubagentStart` names the child (`agent_id`). The parent is not in the payload; it is the agent whose `Agent`/`Task` `PreToolUse` is open when `SubagentStart` fires (root when none is open) — re-verified live at depth 1 and 2 in all three runs, including two spawns issued in one message (the host serialises them: each `SubagentStart` fires before the next `Agent` `PreToolUse` opens). The host then confirms the link on the parent's `Agent` `PostToolUse` (`tool_response.agentId` = the child, `status` `async_launched` or `completed`). `Stop`/`SubagentStop` list the children still running **in the background** in `background_tasks[]` (empty when every child ran in the foreground). |
317-
| Codex | Yes | `SubagentStart` names the child thread and its rollout (`transcript_path`); the rollout's `session_meta` head records `source.subagent.thread_spawn.{parent_thread_id, depth, agent_path}` (§10), so the parent is read, not inferred. The `collaborationspawn_agent` call is matched to the child by `agent_path` (= the call's `PostToolUse` `tool_response.task_name`, rows 16/27). With the rollout unreadable, the parent is inferred from the newest unclaimed spawn call and corrected at `SubagentStop` by the parent rollout in `transcript_path`. MCP calls carry `parent_thread_id` directly. |
317+
| Codex | Yes | `SubagentStart` names the child thread and its rollout (`transcript_path`); the rollout's `session_meta` head records `source.subagent.thread_spawn.{parent_thread_id, depth, agent_path}` (§10), so the parent is read, not inferred. The `collaborationspawn_agent` call is matched to the child by `agent_path` (= the call's `PostToolUse` `tool_response.task_name`, rows 16/27). With the rollout unreadable, the parent is inferred from the newest unclaimed spawn call and corrected at `SubagentStop` — from the child's own rollout (`agent_transcript_path`) when readable by then, else from the parent rollout in `transcript_path`. MCP calls carry `parent_thread_id` directly. |
318318
| Cursor | Yes for the spawn, weakly for the child's traffic | `subagentStart` carries `parent_conversation_id`, `subagent_id`/`tool_call_id`, `is_parallel_worker`. The child's own `conversation_id` is not in that payload, so the first event with an unseen conversation id after a `subagentStart` is bound to it (unambiguous when children start sequentially; ambiguous for parallel workers). |
319319

320320
**Can a plugin running under a subagent know its parent/root?**
@@ -481,4 +481,6 @@ places the thread with `resolution: 'transcript'`; the MCP `_meta` lineage
481481
(§3) and the hook-side lineage therefore agree by construction. What stays
482482
host-side: the payload itself still carries no `parent_thread_id`, so a hook
483483
that cannot read `CODEX_HOME/sessions` (or a rollout not yet flushed) falls
484-
back to spawn-ordering inference and is corrected at `SubagentStop`.
484+
back to spawn-ordering inference and is corrected at `SubagentStop` (from the
485+
child's own rollout when readable by then, else from the parent rollout's
486+
basename).

‎docs/entry-conventions.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,10 @@ claimed, by parent and by the `agent_path` its `PostToolUse`
444444
`tool_response.task_name` carried, so `subagent.toolCallId` is exact even for
445445
same-parent siblings. Only when the rollout is unreadable does Codex fall back
446446
to the Claude rule (`resolution: 'registry'`), and a parent inferred that way
447-
is corrected at `SubagentStop`, where `transcript_path` is the parent's
448-
rollout (`rollout-<timestamp>-<thread id>.jsonl`); descendants move with it.
447+
is corrected at `SubagentStop` — from the child's own rollout
448+
(`agent_transcript_path`, exact parent and depth) when it is readable by then,
449+
else from the parent rollout in `transcript_path`
450+
(`rollout-<timestamp>-<thread id>.jsonl`); descendants move with it.
449451
Cursor names a child only on the parent's `subagentStart` (`subagent_id` =
450452
the parent's `Task` call id); the child's own hooks carry a fresh
451453
`conversation_id` and nothing that points back, so the registry binds by

‎packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@
446446
"state": "supported",
447447
"evidence": [
448448
"2026-09-03: SubagentStart names no parent thread (#423), but names the new thread's rollout in transcript_path — as does every hook fired inside the thread; SubagentStop moves it to agent_transcript_path — and that rollout's session_meta head records source.subagent.thread_spawn.parent_thread_id, present on every thread-spawn rollout observed for cli 0.130.0 through 0.152.0 (fixtures/host-lineage/codex-0.147.0-rollouts). The registry reads it at SubagentStart, on the first hook of a thread seen mid-flight, and at SubagentStop (resolution: transcript, provenance derived); standalone hooks read it the same way. tools/call _meta.x-codex-turn-metadata carries parent_thread_id natively.",
449-
"2026-09-03: with the rollout unreadable the registry falls back to claiming the newest unclaimed collaborationspawn_agent call under the same root (resolution: registry; refused when two parents have unclaimed spawns) and corrects that parent at SubagentStop from the parent rollout named in transcript_path (rollout-<timestamp>-<thread id>.jsonl). The spawn call's PostToolUse tool_response.task_name (/root/<task>/…) equals the child rollout's agent_path, so the spawning tool call is matched by path rather than by order."
449+
"2026-09-03: with the rollout unreadable the registry falls back to claiming the newest unclaimed collaborationspawn_agent call under the same root (resolution: registry; refused when two parents have unclaimed spawns) and corrects that parent at SubagentStop: from the child's own rollout in agent_transcript_path (exact parent and depth) when readable, else from the parent rollout named in transcript_path (rollout-<timestamp>-<thread id>.jsonl). The spawn call's PostToolUse tool_response.task_name (/root/<task>/…) equals the child rollout's agent_path, so the spawning tool call is matched by path rather than by order."
450450
]
451451
},
452452
"depth": {

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

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,17 @@ export const createAgentLineageRegistry = (
319319
const spawn = SPAWN_TOOLS[host];
320320
const underRoot = state.pendingSpawns.filter((call) =>
321321
spawn(call.toolName) && (nodeFor(call.conversation)?.root ?? call.conversation) === root);
322-
// A known parent narrows the field to that parent's spawns; a known
323-
// agent path names the exact call, since the spawn's tool response
324-
// carried the same path.
322+
// A known parent narrows the field to that parent's spawns. A known agent
323+
// path names the exact call, since the spawn's tool response carried the
324+
// same path; a call recorded with a *different* path belongs to another
325+
// child and is never consumed, while a call whose response was missed
326+
// (no path recorded) stays eligible.
325327
const fromParent = target === undefined ? underRoot : underRoot.filter((call) => call.conversation === target.parent);
326-
const byPath = target?.agentPath === undefined ? [] : fromParent.filter((call) => call.agentPath === target.agentPath);
327-
const candidates = byPath.length > 0 ? byPath : fromParent;
328+
const compatible = target?.agentPath === undefined
329+
? fromParent
330+
: fromParent.filter((call) => call.agentPath === undefined || call.agentPath === target.agentPath);
331+
const exact = target?.agentPath === undefined ? [] : compatible.filter((call) => call.agentPath === target.agentPath);
332+
const candidates = exact.length > 0 ? exact : compatible;
328333
if (candidates.length === 0) return { kind: 'none' };
329334
// Several unclaimed spawns from different parents under one root: the
330335
// start payload carries nothing to pick between them, so no guess.
@@ -544,14 +549,23 @@ export const createAgentLineageRegistry = (
544549
// A stop for a thread the registry never placed (cold registry, missed
545550
// start) still names the thread's rollout in `agent_transcript_path`.
546551
if (state.nodes[stopped] === undefined) await placeUnknownCodexThread('agent/stop', native, observedAt, keys);
547-
// `transcript_path` on SubagentStop is the parent's rollout, and the
548-
// rollout basename carries its thread id: a parent inferred from spawn
549-
// ordering that disagrees is corrected before the node retires.
552+
// A node placed by spawn ordering is checked against the host's own
553+
// record before it retires: the child's rollout (`agent_transcript_path`)
554+
// states parent and depth exactly; failing that, `transcript_path` is
555+
// the parent's rollout and its basename carries the parent thread id
556+
// (the depth then follows the parent when the registry knows it).
550557
const node = state.nodes[stopped];
551-
const parent = codexThreadFromRolloutPath(nativeString(native, 'transcript_path'));
552-
if (node !== undefined && node.placement !== 'transcript' && parent !== undefined && parent !== node.id && node.parent !== parent) {
553-
const parentDepth = parent === node.root ? 0 : nodeFor(parent)?.depth;
554-
await dispatch('nodeReparented', { ...(parentDepth === undefined ? {} : { depth: parentDepth + 1 }), id: stopped, parent }, keys);
558+
if (node !== undefined && node.placement !== 'transcript') {
559+
const own = await readCodexSpawnLineage(nativeString(native, 'agent_transcript_path'), stopped, readTranscript);
560+
if (own !== undefined) {
561+
await dispatch('nodeReparented', { depth: own.depth, id: stopped, parent: own.parent, placement: 'transcript' }, keys);
562+
} else {
563+
const parent = codexThreadFromRolloutPath(nativeString(native, 'transcript_path'));
564+
if (parent !== undefined && parent !== node.id && node.parent !== parent) {
565+
const parentDepth = parent === node.root ? 0 : nodeFor(parent)?.depth;
566+
await dispatch('nodeReparented', { ...(parentDepth === undefined ? {} : { depth: parentDepth + 1 }), id: stopped, parent }, keys);
567+
}
568+
}
555569
}
556570
}
557571
if (state.nodes[stopped] === undefined) return;

‎packages/rsc-runtime/tests/lineage-codex-rollout.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,77 @@ describe('lineage registry places Codex threads from their own rollout (#423)',
312312
expect(registry.snapshot().nodes[other]?.stoppedAt).toBeUndefined();
313313
});
314314

315+
it('takes exact parent and depth from the child rollout at SubagentStop when the parent thread was never placed', async () => {
316+
// Unreadable at start (inference files the nested thread directly under the
317+
// root), readable by the time it stops.
318+
const rollouts = new Map<string, string>();
319+
const registry = createAgentLineageRegistry({ readTranscript: async (path) => rollouts.get(basename(path)) });
320+
const observe = (event: string, key: string, native: Record<string, unknown>) =>
321+
registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' });
322+
await observe('session/start', 's', codexHook('SessionStart', undefined));
323+
await observe('tool/before', 'sp', codexHook('PreToolUse', undefined, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp' }));
324+
const misfiled = await observe('agent/start', 'nested', codexHook('SubagentStart', NESTED, { transcript_path: NESTED_ROLLOUT }));
325+
expect(value(misfiled)).toMatchObject({ depth: 1, parent: ROOT, resolution: 'registry' });
326+
await observe('tool/before', 'gc-spawn', codexHook('PreToolUse', NESTED, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'gc-spawn' }));
327+
const grandchild = '01a00000-0000-7000-8000-00000000000c';
328+
await observe('agent/start', 'gc', codexHook('SubagentStart', grandchild, { transcript_path: `/x/rollout-2026-09-03T00-00-00-${grandchild}.jsonl` }));
329+
330+
rollouts.set(basename(NESTED_ROLLOUT), (await readCodexRolloutHead(rolloutFixture(NESTED_ROLLOUT)))!);
331+
// SUBAGENT, the real parent, was never seen by this registry: only the child's own rollout can say how deep it sits.
332+
const stopped = await observe('agent/stop', 'nested-stop', codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: SUBAGENT_ROLLOUT }));
333+
expect(value(stopped)).toMatchObject({ conversation: NESTED, depth: 2, parent: SUBAGENT, resolution: 'transcript' });
334+
expect(registry.snapshot().nodes[NESTED]).toMatchObject({ depth: 2, parent: SUBAGENT, placement: 'transcript' });
335+
expect(registry.snapshot().nodes[grandchild]).toMatchObject({ depth: 3, parent: NESTED });
336+
expect(registry.snapshot().nodes[SUBAGENT]).toBeUndefined();
337+
});
338+
339+
it('keeps the inferred depth when only the parent basename is available and that parent is unknown', async () => {
340+
const registry = createAgentLineageRegistry({ readTranscript: noRollouts });
341+
const observe = (event: string, key: string, native: Record<string, unknown>) =>
342+
registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' });
343+
await observe('session/start', 's', codexHook('SessionStart', undefined));
344+
await observe('tool/before', 'sp', codexHook('PreToolUse', undefined, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp' }));
345+
await observe('agent/start', 'nested', codexHook('SubagentStart', NESTED, { transcript_path: NESTED_ROLLOUT }));
346+
const stopped = await observe('agent/stop', 'nested-stop', codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: SUBAGENT_ROLLOUT }));
347+
// The parent is corrected to what the basename proves; the depth stays what inference gave, still reported as `registry`.
348+
expect(value(stopped)).toMatchObject({ conversation: NESTED, depth: 1, parent: SUBAGENT, resolution: 'registry' });
349+
});
350+
351+
it('never consumes a sibling\'s spawn call whose recorded path differs from the starting child\'s', async () => {
352+
const rollouts = new Map<string, string>();
353+
const registry = createAgentLineageRegistry({ readTranscript: async (path) => rollouts.get(path) });
354+
const observe = (event: string, key: string, native: Record<string, unknown>) =>
355+
registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' });
356+
const meta = (thread: string, agentPath: string) =>
357+
JSON.stringify({ payload: { id: thread, session_id: ROOT, source: { subagent: { thread_spawn: { agent_path: agentPath, depth: 1, parent_thread_id: ROOT } } } }, type: 'session_meta' });
358+
const a = '01a00000-0000-7000-8000-0000000000aa';
359+
const b = '01a00000-0000-7000-8000-0000000000bb';
360+
rollouts.set('/r/a.jsonl', meta(a, '/root/a'));
361+
rollouts.set('/r/b.jsonl', meta(b, '/root/b'));
362+
await observe('session/start', 's', codexHook('SessionStart', undefined));
363+
// A's spawn call was fully observed; B's spawn hooks were missed entirely.
364+
await observe('tool/before', 'sa', codexHook('PreToolUse', undefined, { tool_input: { task_name: 'a' }, tool_name: 'collaborationspawn_agent', tool_use_id: 'spawn-a' }));
365+
await observe('tool/after', 'sa-after', codexHook('PostToolUse', undefined, { tool_input: { task_name: 'a' }, tool_name: 'collaborationspawn_agent', tool_response: '{"task_name":"/root/a"}', tool_use_id: 'spawn-a' }));
366+
const startB = await observe('agent/start', 'b', codexHook('SubagentStart', b, { transcript_path: '/r/b.jsonl' }));
367+
expect(value(startB)).toMatchObject({ conversation: b, depth: 1, parent: ROOT, resolution: 'transcript' });
368+
expect(value(startB).subagent?.toolCallId).toBeUndefined();
369+
expect(registry.snapshot().pendingSpawns.map((call) => call.toolCallId)).toEqual(['spawn-a']);
370+
const startA = await observe('agent/start', 'a', codexHook('SubagentStart', a, { transcript_path: '/r/a.jsonl' }));
371+
expect(value(startA)).toMatchObject({ conversation: a, resolution: 'transcript', subagent: { toolCallId: 'spawn-a' } });
372+
expect(registry.snapshot().pendingSpawns).toEqual([]);
373+
});
374+
375+
it('still claims a spawn call whose response (and path) was missed, when it is the parent\'s only pending one', async () => {
376+
const meta = JSON.stringify({ payload: { id: NESTED, session_id: ROOT, source: { subagent: { thread_spawn: { agent_path: '/root/x', depth: 1, parent_thread_id: ROOT } } } }, type: 'session_meta' });
377+
const registry = createAgentLineageRegistry({ readTranscript: async () => meta });
378+
const observe = (event: string, key: string, native: Record<string, unknown>) =>
379+
registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' });
380+
await observe('session/start', 's', codexHook('SessionStart', undefined));
381+
await observe('tool/before', 'sp', codexHook('PreToolUse', undefined, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'pathless' }));
382+
const start = await observe('agent/start', 'n', codexHook('SubagentStart', NESTED, { transcript_path: '/r/n.jsonl' }));
383+
expect(value(start)).toMatchObject({ depth: 1, parent: ROOT, resolution: 'transcript', subagent: { toolCallId: 'pathless' } });
384+
});
385+
315386
it('leaves a transcript-placed node alone at SubagentStop and ignores a self-referential path', async () => {
316387
const registry = createAgentLineageRegistry({ readTranscript: capturedRollouts });
317388
const observe = (event: string, key: string, native: Record<string, unknown>) =>

0 commit comments

Comments
 (0)