diff --git a/.changeset/codex-lineage-from-rollout.md b/.changeset/codex-lineage-from-rollout.md new file mode 100644 index 000000000..e11a66cd0 --- /dev/null +++ b/.changeset/codex-lineage-from-rollout.md @@ -0,0 +1,6 @@ +--- +'@agent-bundle/runtime': patch +'agent-bundle': patch +--- + +Resolve a Codex subagent's `request.lineage` parent and depth from the thread's own rollout instead of spawn ordering: the registry reads the `session_meta` head of the rollout every hook payload names in `transcript_path` (`agent_transcript_path` on `SubagentStop`), places the thread with the new `resolution: 'transcript'` (provenance `derived`), matches the `spawn_agent` call to the child by `agent_path`, and corrects an inferred parent at `SubagentStop` from the parent rollout it names. Standalone hooks gain `resolveStandaloneLineage` for the same read. The Codex capability table's `lineage.parent` and `lineage.depth` rows move to `supported`. Refs #423 (#480) diff --git a/docs/audits/2026-09-03-host-lineage-matrix.md b/docs/audits/2026-09-03-host-lineage-matrix.md index 705df428c..5735b6dd0 100644 --- a/docs/audits/2026-09-03-host-lineage-matrix.md +++ b/docs/audits/2026-09-03-host-lineage-matrix.md @@ -314,7 +314,7 @@ gives shell commands the agent runs `CURSOR_CONVERSATION_ID`/`CURSOR_REQUEST_ID` | Host | Answer | How | | --- | --- | --- | | 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). | -| Codex | Yes | `SubagentStart` names the child thread; the parent is inferred from the open `collaborationspawn_agent` call and confirmed at `SubagentStop` by the parent rollout in `transcript_path`. MCP calls carry `parent_thread_id` directly. | +| 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. | | 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). | **Can a plugin running under a subagent know its parent/root?** @@ -322,7 +322,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 (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`) | +| 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 from the thread's own rollout head, which every hook inside the thread names in `transcript_path` (§10) — even in a standalone hook process with no registry | | Cursor | Only through the registry — a child's payload carries neither root nor parent | Only through the registry (ordering-bound) | **Operator identity is out of scope.** The maintainer decided on 2026-09-03 @@ -342,12 +342,18 @@ root, and the parent-of-subagent chain — is the only identity-adjacent surface - Hook→MCP correlation: Codex from `_meta`, Claude from `claudecode/toolUseId`, Cursor from the open `MCP:` pre-tool hook. - Claude `PostToolUse` string `tool_response` (MCP tools) accepted. +- Codex hook-side parent and depth read from the thread's own rollout head + (§10; `resolution: 'transcript'`), with the spawn call matched by + `agent_path`; `SubagentStop` corrects an inferred parent from the parent + rollout it names; standalone Codex hooks resolve the same way + (`resolveStandaloneLineage`). Landed after #444 for #423. ## 7. Gaps and host-blocked items | Gap | Host | Evidence | Status | | --- | --- | --- | --- | -| No parent id on `SubagentStart`; child events carry no parent | Claude, Codex | §1, §2 | Inferred from the newest unclaimed spawn call under the same root; refused when two parents have unclaimed spawns; filed as #422 / #423 | +| No parent id on `SubagentStart`; child events carry no parent | Claude | §1, §2 | Inferred from the newest unclaimed spawn call under the same root; refused when two parents have unclaimed spawns; filed as #422 | +| No parent id on `SubagentStart`; child events carry no parent | Codex | §1, §2, §10 | Ours: read from the thread's own rollout head (`thread_spawn.parent_thread_id`, `depth`), which every hook names in `transcript_path`; spawn call matched by `agent_path`; inferred parent corrected at `SubagentStop`. Remaining host-side: the payload itself carries no `parent_thread_id`, so a hook on a machine that cannot read `CODEX_HOME/sessions` (or a rollout not yet flushed) falls back to inference (#423) | | Child conversation id absent from `subagentStart`; child events carry no parent/root | Cursor | §1, §2 | Bound by elimination in the registry (single pending start per workspace); refused while ambiguous for parallel workers; filed as #424 | | `_meta` carries no conversation/tool-call id | Cursor | §3 | Hook-correlated only; filed | | `sessionStart` never dispatched on the desktop (`workspaceOpen`/`sessionEnd` are) | Cursor | §1, §9 | Host-side (#424 gap 4); lineage never depends on it to establish a root | @@ -437,3 +443,44 @@ Consequences for lineage: `subagentStart`/`subagentStop` delivery is unobserved here; the §1 CLI capture remains the evidence for the subagent families. + +## 10. Codex rollout heads: the parent the hook payloads omit (added 2026-09-03) + +Every Codex hook payload names a rollout file. Inside a subagent thread it is +the thread's own (`transcript_path` on `SubagentStart` and on every tool hook, +fixture rows 17, 19–27, 28, 30–36; `agent_transcript_path` on `SubagentStop`, +rows 37 and 39, where `transcript_path` is the **parent's** rollout). The +rollout basename is `rollout--.jsonl`, and its +first line is a `session_meta` item. For a spawned thread that line carries the +edge the hook payload does not: + +```json +{"type":"session_meta","payload":{"id":"","session_id":"","parent_thread_id":"","forked_from_id":"", + "source":{"subagent":{"thread_spawn":{"parent_thread_id":"","depth":2,"agent_path":"/root/host_probe/nested_probe","agent_nickname":"…","agent_role":null}}}, + "thread_source":"subagent","agent_path":"/root/host_probe/nested_probe","cli_version":"0.147.0", …}} +``` + +Checked on the capturing machine's own `~/.codex/sessions` (8,567 rollouts, +cli 0.130.0 → 0.152.0): all 8,049 `thread_spawn` rollouts carry +`source.subagent.thread_spawn.parent_thread_id` and `depth`; `agent_path` +is present from 0.141.0 on (absent on 839 older rollouts, `null` inside +`thread_spawn` on ≤0.136.0); the top-level `parent_thread_id` copy appears +later than the nested one. Roots carry a string `source` (`exec`, `cli`, +`vscode`) and `thread_source: "user"`; host-internal helpers carry +`{"subagent":{"other":"guardian"}}` with no spawn lineage. First-line size: +13–43 KiB (`base_instructions` is inlined). The `agent_path` equals the +`spawn_agent` `PostToolUse` `tool_response.task_name` (`/root/host_probe`, +`/root/host_probe/nested_probe`, rows 16 and 27), which is how the spawning +call is matched to the child without relying on order. + +`fixtures/host-lineage/codex-0.147.0-rollouts/` holds one head per thread of +the capture in this shape (ids, paths, agent paths, and commit from the +capture; long strings redacted — see its README). The registry reads the head +the payload names, verifies `payload.id` equals the payload's `agent_id`, and +places the thread with `resolution: 'transcript'`; the MCP `_meta` lineage +(§3) and the hook-side lineage therefore agree by construction. What stays +host-side: the payload itself still carries no `parent_thread_id`, so a hook +that cannot read `CODEX_HOME/sessions` (or a rollout not yet flushed) falls +back to spawn-ordering inference and is corrected at `SubagentStop` (from the +child's own rollout when readable by then, else from the parent rollout's +basename). diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 46c5cdd5c..a0e52fa86 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -444,7 +444,7 @@ 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' | 'confirmed' | 'inferred'; + resolution: 'native' | 'registry' | 'confirmed' | 'transcript' | 'inferred'; } ``` @@ -473,15 +473,31 @@ 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; 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 | +| Codex | `agent_id`, else `session_id` | `session_id` | `source.subagent.thread_spawn.parent_thread_id` (and `depth`) in the head of the thread's own rollout, which every hook names in `transcript_path` (`agent_transcript_path` on `SubagentStop`); with that file unreadable, 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:`; when several conversations have that tool open, the one whose hook `tool_input` equals the call's `arguments` (identical arguments stay `id-not-resolvable`) | -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 — 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. +A Claude subagent is placed only when its spawning pre-tool hook +(`Agent`/`Task`) was observed, so projects that want `parent`/`depth` for +subagents route `tool/before` alongside `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. + +A Codex thread carries its own evidence: the rollout its payload names opens +with a `session_meta` line recording the spawning thread, the depth, and the +`agent_path`, so the registry places it from that file (`resolution: +'transcript'`, provenance `derived`) at `SubagentStart`, on the first hook of +a thread it never saw start (or held unplaced), and at `SubagentStop` — no +spawn ordering involved, and two parents with unclaimed spawns are no longer +ambiguous. The `spawn_agent` call is still claimed, by parent and by the +`agent_path` its `PostToolUse` `tool_response.task_name` carried, so +`subagent.toolCallId` is exact even for same-parent siblings. Only when the +rollout is unreadable does Codex fall back to the Claude rule (`resolution: +'registry'`), and a parent inferred that way is corrected at `SubagentStop`, +— from the child's own rollout (`agent_transcript_path`, exact parent and +depth) when it is readable by then, else from the parent rollout in +`transcript_path` (`rollout--.jsonl`); descendants move +with it. 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 @@ -559,7 +575,9 @@ target defines no subagent families — portable), `id-not-resolvable` (the payload names an agent the registry never saw start, e.g. a cold runtime), `cloud-agent-no-user-hooks` (Cursor cloud agents run no user hooks), `no-shared-runtime` (a standalone hook process holds no registry; Claude and -Codex root payloads still resolve to depth 0 from the payload alone), +Codex root payloads still resolve to depth 0 from the payload alone, and a +Codex subagent payload resolves fully from the rollout it names — +`resolveStandaloneLineage`), `unsupported-surface` (routed CLI and rendered scripts run outside any host conversation), or `not-provided` (no registry was mounted). Per-host capability rows live under `lineage` in each pinned capability table. diff --git a/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md index 3ca7f9af2..6d13b1032 100644 --- a/examples/worktree-proximity/README.md +++ b/examples/worktree-proximity/README.md @@ -75,9 +75,10 @@ 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`, `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: +`registry`, `inferred`, `confirmed` and `transcript` are the runtime lineage +registry's own resolutions (`confirmed` once the host has named every edge up +to the root; `transcript` is read from the host's own rollout file), and +`derived` is this application's fallback: - `session/start` observes `session:` as the root actor, where the root is `(await agent()).lineage.root` when the runtime resolved a lineage and the diff --git a/examples/worktree-proximity/src/state.ts b/examples/worktree-proximity/src/state.ts index 15f675011..e054acf6c 100644 --- a/examples/worktree-proximity/src/state.ts +++ b/examples/worktree-proximity/src/state.ts @@ -5,11 +5,12 @@ 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`, - * `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:`). + * `inferred`, `confirmed` and `transcript` are the runtime lineage registry's + * own resolutions (`confirmed` once the host has named every edge to the root; + * `transcript` read from the host's own rollout file), and `derived` is this + * application's fallback (`worktree:`). */ -const provenance = z.enum(['native', 'registry', 'inferred', 'confirmed', 'derived']); +const provenance = z.enum(['native', 'registry', 'inferred', 'confirmed', 'transcript', 'derived']); export type IdentityProvenance = z.output; diff --git a/fixtures/host-lineage/codex-0.147.0-rollouts/README.md b/fixtures/host-lineage/codex-0.147.0-rollouts/README.md new file mode 100644 index 000000000..a8f8215e1 --- /dev/null +++ b/fixtures/host-lineage/codex-0.147.0-rollouts/README.md @@ -0,0 +1,21 @@ +# Codex 0.147.0 rollout heads for the lineage capture + +One file per thread of the `codex-0.147.0.ndjson` capture, named exactly as the +hook payloads name them in `transcript_path` / `agent_transcript_path` +(`rollout--.jsonl`). Each holds the `session_meta` line +Codex writes first, followed by one redacted `response_item` line so readers +prove they stop at the first newline. + +Provenance: the ids, paths, timestamps, `agent_path` values (the +`spawn_agent` `PostToolUse` `tool_response.task_name` of the capture, rows 16 +and 27), and git commit are the capture's own. The `session_meta` field set and +nesting (`source.subagent.thread_spawn.{parent_thread_id, depth, agent_path, +agent_nickname, agent_role}`, `thread_source`, `forked_from_id`, +`parent_thread_id`, `subagent_history_start_ordinal`, `context_window`) are +copied from real cli 0.147.0 rollouts on the capturing machine +(`~/.codex/sessions`, 1,191 thread-spawn rollouts at that version; every one of +the 8,049 thread-spawn rollouts across 0.130.0 → 0.152.0 carries +`parent_thread_id` and `depth`). The capture's own rollout files under the +isolated `CODEX_HOME` were not retained, so `base_instructions.text`, +`agent_nickname`, the repository URL, and the `context_window.window_id` +values are redacted placeholders, not observations. diff --git a/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-26-06-01a06660-110e-7290-8d1c-8ef1b2b68fc2.jsonl b/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-26-06-01a06660-110e-7290-8d1c-8ef1b2b68fc2.jsonl new file mode 100644 index 000000000..eb2e90c80 --- /dev/null +++ b/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-26-06-01a06660-110e-7290-8d1c-8ef1b2b68fc2.jsonl @@ -0,0 +1,2 @@ +{"timestamp":"2026-09-03T08:26:06.402Z","type":"session_meta","payload":{"session_id":"01a06660-110e-7290-8d1c-8ef1b2b68fc2","id":"01a06660-110e-7290-8d1c-8ef1b2b68fc2","timestamp":"2026-09-03T08:26:06.089Z","cwd":"/tmp/host-test/codex-workspace","originator":"codex_exec","cli_version":"0.147.0","model_provider":"openai","base_instructions":{"text":""},"history_mode":"legacy","multi_agent_version":"v2","git":{"commit_hash":"840e5bd01b2601e82ed34c286c17597b29959a50","branch":"main","repository_url":""},"source":"exec","thread_source":"user","context_window":{"window_id":"01a06660-1110-7a4e-9c7b-2f4c1a6d8e01"}}} +{"timestamp":"2026-09-03T08:26:06.402Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}} diff --git a/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-26-39-01a06660-8faf-7122-80af-24ba2da81ad7.jsonl b/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-26-39-01a06660-8faf-7122-80af-24ba2da81ad7.jsonl new file mode 100644 index 000000000..a8b6333c8 --- /dev/null +++ b/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-26-39-01a06660-8faf-7122-80af-24ba2da81ad7.jsonl @@ -0,0 +1,2 @@ +{"timestamp":"2026-09-03T08:26:39.611Z","type":"session_meta","payload":{"session_id":"01a06660-110e-7290-8d1c-8ef1b2b68fc2","id":"01a06660-8faf-7122-80af-24ba2da81ad7","forked_from_id":"01a06660-110e-7290-8d1c-8ef1b2b68fc2","parent_thread_id":"01a06660-110e-7290-8d1c-8ef1b2b68fc2","timestamp":"2026-09-03T08:26:39.318Z","cwd":"/tmp/host-test/codex-workspace","originator":"codex_exec","cli_version":"0.147.0","model_provider":"openai","base_instructions":{"text":""},"history_mode":"legacy","multi_agent_version":"v2","git":{"commit_hash":"840e5bd01b2601e82ed34c286c17597b29959a50","branch":"main","repository_url":""},"source":{"subagent":{"thread_spawn":{"parent_thread_id":"01a06660-110e-7290-8d1c-8ef1b2b68fc2","depth":1,"agent_path":"/root/host_probe","agent_nickname":"","agent_role":null}}},"thread_source":"subagent","agent_nickname":"","agent_path":"/root/host_probe","subagent_history_start_ordinal":9,"context_window":{"window_id":"01a06660-8fb0-7c2d-8e11-6a0b5d3c2f02"}}} +{"timestamp":"2026-09-03T08:26:39.611Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}} diff --git a/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-27-12-01a06661-100a-7ad3-a0f5-b0e6ffdb4b11.jsonl b/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-27-12-01a06661-100a-7ad3-a0f5-b0e6ffdb4b11.jsonl new file mode 100644 index 000000000..160affd06 --- /dev/null +++ b/fixtures/host-lineage/codex-0.147.0-rollouts/rollout-2026-09-03T08-27-12-01a06661-100a-7ad3-a0f5-b0e6ffdb4b11.jsonl @@ -0,0 +1,2 @@ +{"timestamp":"2026-09-03T08:27:12.240Z","type":"session_meta","payload":{"session_id":"01a06660-110e-7290-8d1c-8ef1b2b68fc2","id":"01a06661-100a-7ad3-a0f5-b0e6ffdb4b11","forked_from_id":"01a06660-8faf-7122-80af-24ba2da81ad7","parent_thread_id":"01a06660-8faf-7122-80af-24ba2da81ad7","timestamp":"2026-09-03T08:27:12.011Z","cwd":"/tmp/host-test/codex-workspace","originator":"codex_exec","cli_version":"0.147.0","model_provider":"openai","base_instructions":{"text":""},"history_mode":"legacy","multi_agent_version":"v2","git":{"commit_hash":"840e5bd01b2601e82ed34c286c17597b29959a50","branch":"main","repository_url":""},"source":{"subagent":{"thread_spawn":{"parent_thread_id":"01a06660-8faf-7122-80af-24ba2da81ad7","depth":2,"agent_path":"/root/host_probe/nested_probe","agent_nickname":"","agent_role":null}}},"thread_source":"subagent","agent_nickname":"","agent_path":"/root/host_probe/nested_probe","subagent_history_start_ordinal":6,"context_window":{"window_id":"01a06661-100b-7e8f-9a21-4c7d2b1e3a03"}}} +{"timestamp":"2026-09-03T08:27:12.240Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}} diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index ef778f44a..d2aedea75 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -451,12 +451,17 @@ ] }, "parent": { - "state": "degraded", - "reason": "2026-09-03: SubagentStart names no parent (host gap tracked in #423; parent_thread_id exists only in tools/call _meta.x-codex-turn-metadata, and SubagentStop hints at it through the parent rollout in transcript_path). Hooks resolve the parent through the registry by claiming the newest unclaimed collaborationspawn_agent call under the same root (resolution: registry; refused when two parents have unclaimed spawns), while MCP calls resolve it natively." + "state": "supported", + "evidence": [ + "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.", + "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--.jsonl). The spawn call's PostToolUse tool_response.task_name (/root//…) equals the child rollout's agent_path, so the spawning tool call is matched by path rather than by order." + ] }, "depth": { - "state": "degraded", - "reason": "2026-09-03: no depth counter is delivered (#423); the registry derives it from the parent chain for hooks and from parent_thread_id for MCP calls (a grandchild whose parent thread the registry never saw stays id-not-resolvable rather than guessed)." + "state": "supported", + "evidence": [ + "2026-09-03: no depth counter is delivered on hook payloads or in tools/call _meta (#423); the thread's rollout session_meta head records source.subagent.thread_spawn.depth (1 for a child of the root), read by the registry and by standalone hooks as for parent (resolution: transcript). MCP calls take the depth of the registered thread, or parent depth + 1 for a direct child of the root; a grandchild whose thread no hook has placed stays id-not-resolvable rather than guessed." + ] }, "mcp-correlation": { "state": "supported", diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 883110b6d..b31354999 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -629,7 +629,7 @@ const eventRouteHookWrapperSource = ( "import { dirname, resolve } from 'node:path';", ...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []), ...(standalone - ? ["import { agent, available, createAgentRenderDispatcher, resolveNativeLineage, runAgentRequest, unavailable } from '@agent-bundle/runtime';"] + ? ["import { agent, available, createAgentRenderDispatcher, resolveStandaloneLineage, runAgentRequest, unavailable } from '@agent-bundle/runtime';"] : []), ...(retiresLineage ? [ @@ -732,8 +732,9 @@ const eventRouteHookWrapperSource = ( ...(retiresLineage ? [' await retireLineage(native, props.canonical.idempotencyKey, props.canonical.observedAt);'] : []), ' const sessionId = typeof native.session_id === "string" ? native.session_id : typeof native.conversation_id === "string" ? native.conversation_id : undefined;', ' const workspaceRoot = typeof native.cwd === "string" ? native.cwd : Array.isArray(native.workspace_roots) && typeof native.workspace_roots[0] === "string" ? native.workspace_roots[0] : undefined;', - // Standalone hooks hold no registry, so lineage is only what the payload proves (docs/audits/2026-09-03-host-lineage-matrix.md). - ' const lineage = target === "claude" || target === "codex" || target === "cursor" ? resolveNativeLineage(target, native) : unavailable("no-subagent-events");', + // Standalone hooks hold no registry, so lineage is what the payload proves — plus, on Codex, what the + // thread's own rollout named in the payload records (docs/audits/2026-09-03-host-lineage-matrix.md, #423). + ' const lineage = target === "claude" || target === "codex" || target === "cursor" ? await resolveStandaloneLineage(target, native) : unavailable("no-subagent-events");', ' const document = await runAgentRequest({', ' host: available({ name: target }, "native"),', ' invocation: { artifactEpoch, hostContractRevision: capabilityRevision, kind: "event", operationId: `event:${canonicalEvent}`, surface: canonicalEvent },', diff --git a/packages/agent-bundle/src/contracts/request-provenance.ts b/packages/agent-bundle/src/contracts/request-provenance.ts index 089530699..06f289749 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' | 'confirmed' | 'inferred'; + readonly resolution: 'native' | 'registry' | 'confirmed' | 'transcript' | 'inferred'; readonly root: string; readonly subagent?: Readonly<{ readonly id: string; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 1dfb22c3e..50d1876b7 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -1348,8 +1348,11 @@ it('advertises conversation lineage per host with dated 2026-09-03 evidence', as expect(Object.values(lineage).every((entry) => entry.state === 'unavailable')).toBe(true); } else { // Every hook-bearing host names its subagents; none names a parent on the child's own events. + // Codex names the child's rollout instead, whose head records the parent and depth (#423). expect(lineage['subagent-events']!.state).toBe('supported'); - expect(lineage['parent']!.state).toBe('degraded'); + expect(lineage['parent']!.state).toBe(host === 'codex' ? 'supported' : 'degraded'); + expect(lineage['depth']!.state).toBe(host === 'codex' ? 'supported' : 'degraded'); + if (host === 'codex') expect(lineage['parent']!.evidence?.join(' ')).toMatch(/thread_spawn\.parent_thread_id/u); } // Only Codex resolves MCP calls without a hook window; Cursor cannot correlate natively at all. expect(lineage['mcp-correlation']!.state).toBe(host === 'cursor' ? 'degraded' : host === 'portable' ? 'unavailable' : 'supported'); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index b12b24fd0..af8ce63ce 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -360,6 +360,9 @@ it('plans a thin epoch-bound event-route client and keeps standalone execution e expect(degradedSource).toContain('error.code === "runtime-unavailable"'); expect(degradedSource).toContain('Array.isArray(native.workspace_roots)'); expect(degradedSource).toContain('native.workspace_roots[0]'); + // Standalone lineage reads the Codex rollout the payload names (#423), so it is awaited. + expect(degradedSource).toContain("resolveStandaloneLineage, runAgentRequest, unavailable } from '@agent-bundle/runtime'"); + expect(degradedSource).toContain('await resolveStandaloneLineage(target, native)'); expect(degradedSource).not.toContain('import * as routeModule'); expect(degradedSource).not.toContain('renderStandaloneEventRoute'); }); diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 64aa0ce34..7bd371b4b 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -100,12 +100,14 @@ export interface AgentLineageSubagent { * 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. + * spawn `tool_use_id` (`confirmed`); read from the host-written transcript the + * payload named (`transcript`: a Codex subagent's rollout head records + * `parent_thread_id` and `depth`, which its hook payloads omit); 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' | 'confirmed' | 'inferred'; +export type AgentLineageResolution = 'native' | 'registry' | 'confirmed' | 'transcript' | 'inferred'; /** * Where this request sits in the conversation tree (#host-lineage). The shape diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index eec62f1f3..94693117a 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -92,7 +92,7 @@ export { createRscRequestContext } from './request-context.js'; export type { AgentRenderInvocation } from './agent-request.js'; // Registry-free lineage helpers: what a payload proves on its own. The // registry itself ships behind the './lineage' subpath with the state kernel. -export { lineageCarrier, lineageHostFromClient, resolveNativeLineage } from './lineage-native.js'; +export { lineageCarrier, lineageHostFromClient, resolveNativeLineage, resolveStandaloneLineage } from './lineage-native.js'; export type { LineageCarrier, LineageHost } from './lineage-native.js'; // Type-only: the state kernel itself ships behind the './state' subpath so // stateless artifacts include none of it (#98). diff --git a/packages/rsc-runtime/src/lineage-native.ts b/packages/rsc-runtime/src/lineage-native.ts index 6ff7da89e..f7e099076 100644 --- a/packages/rsc-runtime/src/lineage-native.ts +++ b/packages/rsc-runtime/src/lineage-native.ts @@ -1,4 +1,5 @@ import { available, unavailable, type AgentLineage, type Observed } from './agent-request.js'; +import { readCodexSpawnLineage, type CodexRolloutReader } from './lineage/codex-rollout.js'; export type LineageHost = 'claude' | 'codex' | 'cursor'; @@ -83,3 +84,37 @@ export const resolveNativeLineage = ( root: carrier.root, }, 'native'); }; + +/** + * `resolveNativeLineage`, plus what the host's own transcript proves: a Codex + * hook fired inside a subagent names the thread's rollout (`transcript_path`; + * `agent_transcript_path` on `SubagentStop`), whose head records the spawning + * thread and depth the payload omits. Read from that file, the lineage is + * exact (`resolution: 'transcript'`, provenance `derived`) without a registry; + * an unreadable or foreign rollout leaves the payload-only answer. + */ +export const resolveStandaloneLineage = async ( + host: LineageHost, + native: Readonly>, + read?: CodexRolloutReader, +): Promise> => { + const fromPayload = resolveNativeLineage(host, native); + if (host !== 'codex' || fromPayload.state === 'available') return fromPayload; + const carrier = lineageCarrier(host, native); + if (carrier.conversation === undefined || carrier.root === undefined) return fromPayload; + const ownRollout = nativeString(native, 'hook_event_name') === 'SubagentStop' + ? nativeString(native, 'agent_transcript_path') + : nativeString(native, 'transcript_path'); + const spawn = await readCodexSpawnLineage(ownRollout, carrier.conversation, read); + if (spawn === undefined) return fromPayload; + const type = nativeString(native, 'agent_type'); + return available({ + conversation: carrier.conversation, + depth: spawn.depth, + ...(carrier.generation === undefined ? {} : { generation: carrier.generation }), + parent: spawn.parent, + resolution: 'transcript', + root: carrier.root, + subagent: { id: carrier.conversation, ...(type === undefined ? {} : { type }) }, + }, 'derived'); +}; diff --git a/packages/rsc-runtime/src/lineage/codex-rollout.ts b/packages/rsc-runtime/src/lineage/codex-rollout.ts new file mode 100644 index 000000000..ea5625aee --- /dev/null +++ b/packages/rsc-runtime/src/lineage/codex-rollout.ts @@ -0,0 +1,132 @@ +import { open } from 'node:fs/promises'; + +/** + * The head of a Codex rollout (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout--.jsonl`) + * is a `session_meta` line the host writes when the thread is created. For a + * thread spawned by `spawn_agent` it names the spawning thread and the depth + * (`source.subagent.thread_spawn`), which the hook payloads omit (#423). Hooks + * fired inside a subagent name that rollout in `transcript_path` + * (`agent_transcript_path` on `SubagentStop`), so the lineage the payload + * lacks is read from the file the same payload points at. Observed shape, + * cli 0.130.0 → 0.152.0 (8,049 thread-spawn rollouts, 2026-09-03): + * `payload.id` (thread), `payload.session_id` (root), `payload.thread_source` + * (`user` | `subagent`), `payload.source.subagent.thread_spawn.{parent_thread_id, depth, agent_path?}`. + */ +export interface CodexRolloutMeta { + /** `source.subagent.thread_spawn.agent_path`, e.g. `/root/host_probe/nested_probe`; absent on some builds. */ + readonly agentPath?: string; + /** Spawn depth the host recorded: 1 for a child of the root. Absent on a root. */ + readonly depth?: number; + /** The spawning thread. Absent on a root. */ + readonly parent?: string; + /** The root thread (`session_id`); equals `thread` on a root. */ + readonly root?: string; + /** Which subagent mechanism produced the thread (`thread_spawn`, `review`, …); absent on a root. */ + readonly subagentKind?: string; + /** `payload.id`: the thread the rollout belongs to. */ + readonly thread: string; +} + +/** Longest `session_meta` line the reader accepts (observed 13–43 KiB; `base_instructions` is inlined). */ +export const CODEX_ROLLOUT_HEAD_BYTES = 1_048_576; + +const ROLLOUT_BASENAME = /rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\.jsonl$/iu; + +/** The thread id a rollout path encodes (`rollout--.jsonl`), or `undefined` for any other path. */ +export const codexThreadFromRolloutPath = (path: string | undefined): string | undefined => { + if (path === undefined) return undefined; + const match = ROLLOUT_BASENAME.exec(path); + return match?.[1]?.toLowerCase(); +}; + +const record = (value: unknown): Readonly> | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Readonly>) : undefined; + +const text = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value : undefined); + +/** Parses the first line of a rollout; `undefined` for anything that is not a `session_meta` naming its thread. */ +export const parseCodexRolloutMeta = (head: string): CodexRolloutMeta | undefined => { + const newline = head.indexOf('\n'); + const line = (newline === -1 ? head : head.slice(0, newline)).trim(); + if (line === '') return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return undefined; + } + const item = record(parsed); + if (item?.['type'] !== 'session_meta') return undefined; + const payload = record(item['payload']); + const thread = text(payload?.['id']); + if (payload === undefined || thread === undefined) return undefined; + const root = text(payload['session_id']); + const source = record(payload['source']); + // A string `source` (`exec`, `cli`, `vscode`) is a root. `{ subagent: { thread_spawn: {…} } }` + // is a spawned thread; `{ subagent: { other: "…" } }` or `{ subagent: "review" }` are + // host-internal threads that have no spawn lineage. + const subagent = record(source?.['subagent']); + if (subagent === undefined) { + const kind = text(source?.['subagent']); + return { ...(kind === undefined ? {} : { subagentKind: kind }), ...(root === undefined ? {} : { root }), thread }; + } + const [subagentKind] = Object.keys(subagent); + const spawn = record(subagent['thread_spawn']); + const parent = text(spawn?.['parent_thread_id']) ?? text(payload['parent_thread_id']); + const rawDepth = spawn?.['depth']; + const depth = typeof rawDepth === 'number' && Number.isInteger(rawDepth) && rawDepth > 0 ? rawDepth : undefined; + const agentPath = text(spawn?.['agent_path']) ?? text(payload['agent_path']); + return { + ...(agentPath === undefined ? {} : { agentPath }), + ...(depth === undefined ? {} : { depth }), + ...(parent === undefined ? {} : { parent }), + ...(root === undefined ? {} : { root }), + ...(subagentKind === undefined ? {} : { subagentKind }), + thread, + }; +}; + +/** Reads a file head for the parser; the default reader stops at the first newline or the byte cap. */ +export type CodexRolloutReader = (path: string) => Promise; + +export const readCodexRolloutHead: CodexRolloutReader = async (path) => { + let handle: Awaited> | undefined; + try { + handle = await open(path, 'r'); + const buffer = Buffer.allocUnsafe(CODEX_ROLLOUT_HEAD_BYTES); + let filled = 0; + while (filled < buffer.length) { + const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled); + if (bytesRead === 0) break; + const newline = buffer.subarray(filled, filled + bytesRead).indexOf(0x0a); + if (newline !== -1) { + filled += newline; + break; + } + filled += bytesRead; + } + return buffer.subarray(0, filled).toString('utf8'); + } catch { + return undefined; + } finally { + await handle?.close().catch(() => undefined); + } +}; + +/** + * The spawn lineage of `thread` as its own rollout records it, or `undefined` + * when the file is unreadable, belongs to a different thread, or describes a + * thread the host did not spawn (a root, or a review/compact helper). + */ +export const readCodexSpawnLineage = async ( + path: string | undefined, + thread: string, + read: CodexRolloutReader = readCodexRolloutHead, +): Promise<(CodexRolloutMeta & { readonly depth: number; readonly parent: string }) | undefined> => { + if (path === undefined) return undefined; + const head = await read(path); + if (head === undefined) return undefined; + const meta = parseCodexRolloutMeta(head); + if (meta === undefined || meta.thread !== thread || meta.parent === undefined || meta.depth === undefined) return undefined; + return { ...meta, depth: meta.depth, parent: meta.parent }; +}; diff --git a/packages/rsc-runtime/src/lineage/index.ts b/packages/rsc-runtime/src/lineage/index.ts index 107b4b82d..975dbff7a 100644 --- a/packages/rsc-runtime/src/lineage/index.ts +++ b/packages/rsc-runtime/src/lineage/index.ts @@ -10,9 +10,19 @@ export { lineageCarrier, lineageHostFromClient, resolveNativeLineage, + resolveStandaloneLineage, type LineageCarrier, type LineageHost, } from '../lineage-native.js'; +export { + CODEX_ROLLOUT_HEAD_BYTES, + codexThreadFromRolloutPath, + parseCodexRolloutMeta, + readCodexRolloutHead, + readCodexSpawnLineage, + type CodexRolloutMeta, + type CodexRolloutReader, +} from './codex-rollout.js'; export { AGENT_LINEAGE_STATE_ID, agentLineageStateDefinition, diff --git a/packages/rsc-runtime/src/lineage/registry.ts b/packages/rsc-runtime/src/lineage/registry.ts index 50526a00e..b5a78e4a2 100644 --- a/packages/rsc-runtime/src/lineage/registry.ts +++ b/packages/rsc-runtime/src/lineage/registry.ts @@ -10,6 +10,13 @@ import { import { lineageCarrier, type LineageHost } from '../lineage-native.js'; import { AgentStateError, type AgentStateStore } from '../state/contract.js'; import { canonicalJson } from '../state/index.js'; +import { + codexThreadFromRolloutPath, + readCodexRolloutHead, + readCodexSpawnLineage, + type CodexRolloutMeta, + type CodexRolloutReader, +} from './codex-rollout.js'; import { initialLineageState, reduceLineage, @@ -70,6 +77,11 @@ export interface AgentLineageRegistry { } export interface CreateAgentLineageRegistryOptions { + /** + * Reads the head of a Codex rollout the payload named (`transcript_path`); + * defaults to a bounded filesystem read. Tests inject captured heads. + */ + readonly readTranscript?: CodexRolloutReader; /** Durable journal; omitted registries live only in memory. */ readonly store?: AgentStateStore; } @@ -91,6 +103,35 @@ const inputDigest = (input: unknown): string | undefined => { return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex').slice(0, 16); }; +/** + * The rollout that belongs to the thread a Codex payload speaks for: its own + * `transcript_path` on every hook fired inside it, except `SubagentStop`, + * where `transcript_path` is the parent's rollout and the child's moves to + * `agent_transcript_path` (fixture rows 37 and 39, codex-0.147.0.ndjson). + */ +const codexOwnRollout = (event: LineageEventFamily, native: Readonly>): string | undefined => + event === 'agent/stop' ? nativeString(native, 'agent_transcript_path') : nativeString(native, 'transcript_path'); + +/** + * Codex answers a successful `spawn_agent` with `{"task_name": "/root//…"}` + * — the child's `agent_path`, the same string the child's rollout head + * records — as a JSON string (shell/collaboration tools) or an object. + */ +const codexSpawnAgentPath = (native: Readonly>): string | undefined => { + const response = native['tool_response']; + let parsed: unknown = response; + if (typeof response === 'string') { + try { + parsed = JSON.parse(response); + } catch { + return undefined; + } + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + const taskName = (parsed as Readonly>)['task_name']; + return typeof taskName === 'string' && taskName.startsWith('/') ? taskName : undefined; +}; + /** * The hosts' own subagent-spawning tools, by exact native spelling (observed * 2026-09-03: Claude `Agent`, Codex `collaborationspawn_agent`, Cursor @@ -245,7 +286,7 @@ const rootNode = ( export const createAgentLineageRegistry = ( options: CreateAgentLineageRegistryOptions = {}, ): AgentLineageRegistry => { - const { store } = options; + const { readTranscript = readCodexRolloutHead, store } = options; let state: LineageState = initialLineageState; let hydration: Promise | undefined; const applied = new Set(); @@ -355,10 +396,27 @@ export const createAgentLineageRegistry = ( | { readonly kind: 'claimed'; readonly call: OpenToolCall; readonly toolCallIdCertain: boolean } | { readonly kind: 'none' }; - const claimSpawn = async (host: LineageHost, root: string, keys: JournalKeys): Promise => { + /** What the child's own rollout said about it, when the host wrote one (Codex). */ + interface SpawnTarget { + readonly agentPath?: string; + readonly parent: string; + } + + const claimSpawn = async (host: LineageHost, root: string, keys: JournalKeys, target?: SpawnTarget): Promise => { const spawn = SPAWN_TOOLS[host]; - const candidates = state.pendingSpawns.filter((call) => + const underRoot = state.pendingSpawns.filter((call) => spawn(call.toolName) && (nodeFor(call.conversation)?.root ?? call.conversation) === root); + // A known parent narrows the field to that parent's spawns. A known agent + // path names the exact call, since the spawn's tool response carried the + // same path; a call recorded with a *different* path belongs to another + // child and is never consumed, while a call whose response was missed + // (no path recorded) stays eligible. + const fromParent = target === undefined ? underRoot : underRoot.filter((call) => call.conversation === target.parent); + const compatible = target?.agentPath === undefined + ? fromParent + : fromParent.filter((call) => call.agentPath === undefined || call.agentPath === target.agentPath); + const exact = target?.agentPath === undefined ? [] : compatible.filter((call) => call.agentPath === target.agentPath); + const candidates = exact.length > 0 ? exact : compatible; if (candidates.length === 0) return { kind: 'none' }; // Several unclaimed spawns from different parents under one root: the // start payload carries nothing to pick between them, so no guess. @@ -371,6 +429,69 @@ export const createAgentLineageRegistry = ( return { call, kind: 'claimed', toolCallIdCertain: !cohort && call.uncertain !== true }; }; + /** + * Places a Codex thread from the spawn lineage its own rollout head records + * (parent thread, depth, agent path): exact host evidence, so the node is + * marked `transcript` and no ordering guess is involved. The spawn call is + * still claimed — by parent and agent path — so `toolCallId` is certain + * whenever the parent's `spawn_agent` response was observed. + */ + const placeCodexFromRollout = async ( + agentId: string, + spawn: CodexRolloutMeta & { readonly depth: number; readonly parent: string }, + native: Readonly>, + observedAt: string, + keys: JournalKeys, + ): Promise => { + const root = spawn.root ?? lineageCarrier('codex', native).root; + if (root === undefined) return undefined; + const rootNodeValue = await ensureRoot('codex', root, undefined, observedAt, keys, true, undefined); + if (rootNodeValue === undefined) return undefined; + const claim = await claimSpawn('codex', rootNodeValue.root, keys, { ...(spawn.agentPath === undefined ? {} : { agentPath: spawn.agentPath }), parent: spawn.parent }); + // A start held unplaced (its rollout was unreadable at the time) is + // materialized as it started, and as already stopped when its stop came + // first; placing it drops it from the unplaced set (`nodeStarted`). + const waiting = (state.unplacedStarts ?? []).find((start) => start.id === agentId); + const generation = waiting?.generation ?? nativeString(native, 'turn_id'); + const type = waiting?.type ?? nativeString(native, 'agent_type'); + await dispatch('nodeStarted', { + depth: spawn.depth, + ...(generation === undefined ? {} : { generation }), + id: agentId, + parent: spawn.parent, + placement: 'transcript', + root: rootNodeValue.root, + startedAt: waiting?.startedAt ?? observedAt, + ...(claim.kind === 'claimed' && claim.toolCallIdCertain ? { toolCallId: claim.call.toolCallId } : {}), + ...(type === undefined ? {} : { type }), + }, keys); + if (waiting?.stoppedAt !== undefined) await dispatch('nodeStopped', { id: agentId, stoppedAt: waiting.stoppedAt }, keys); + return state.nodes[agentId]; + }; + + /** + * A Codex thread the tree has not placed, seen on any hook that names its + * rollout: placed from that rollout when it is readable and describes a + * spawned thread. Replayed starts stay recognized through `seenStarts`, + * except a start still held unplaced, which this hook may now place. + */ + const placeUnknownCodexThread = async ( + event: LineageEventFamily, + native: Readonly>, + observedAt: string, + keys: JournalKeys, + ): Promise => { + const agentId = nativeString(native, 'agent_id'); + if (agentId === undefined) return undefined; + const known = state.nodes[agentId]; + if (known !== undefined) return known; + const unplaced = (state.unplacedStarts ?? []).some((start) => start.id === agentId); + if (state.seenStarts.includes(agentId) && !unplaced) return undefined; + const spawn = await readCodexSpawnLineage(codexOwnRollout(event, native), agentId, readTranscript); + if (spawn === undefined) return undefined; + return placeCodexFromRollout(agentId, spawn, native, observedAt, keys); + }; + /** * The node for a conversation that speaks for itself, creating it when it is * new. A never-seen Cursor conversation while exactly one `subagentStart` is @@ -442,7 +563,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' - : registryResolution(node, fallback); + : node.placement === 'transcript' ? 'transcript' : registryResolution(node, fallback); return available(lineageOf(node, carrier.generation, resolution), resolution === 'native' ? 'native' : 'derived'); }; @@ -492,6 +613,16 @@ export const createAgentLineageRegistry = ( }, keys); return; } + // Codex names the new thread's rollout in `transcript_path`; its head + // states the parent and depth the payload omits, so no spawn ordering is + // needed when it is readable (#423). + if (host === 'codex') { + const spawn = await readCodexSpawnLineage(nativeString(native, 'transcript_path'), agentId, readTranscript); + if (spawn !== undefined) { + await placeCodexFromRollout(agentId, spawn, native, observedAt, 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); @@ -534,6 +665,30 @@ export const createAgentLineageRegistry = ( })() : nativeString(native, 'agent_id'); if (stopped === undefined) return; + if (host === 'codex') { + // A stop for a thread the registry never placed (cold registry, missed + // start, or a start left unplaced while its rollout was unreadable) + // still names the thread's rollout in `agent_transcript_path`. + if (state.nodes[stopped] === undefined) await placeUnknownCodexThread('agent/stop', native, observedAt, keys); + // A node placed by spawn ordering is checked against the host's own + // record before it retires: the child's rollout (`agent_transcript_path`) + // states parent and depth exactly; failing that, `transcript_path` is + // the parent's rollout and its basename carries the parent thread id + // (the depth then follows the parent when the registry knows it). + const node = state.nodes[stopped]; + if (node !== undefined && node.placement !== 'transcript') { + const own = await readCodexSpawnLineage(nativeString(native, 'agent_transcript_path'), stopped, readTranscript); + if (own !== undefined) { + await dispatch('nodeReparented', { depth: own.depth, id: stopped, parent: own.parent, placement: 'transcript' }, keys); + } else { + const parent = codexThreadFromRolloutPath(nativeString(native, 'transcript_path')); + if (parent !== undefined && parent !== node.id && node.parent !== parent) { + const parentDepth = parent === node.root ? 0 : nodeFor(parent)?.depth; + await dispatch('nodeReparented', { ...(parentDepth === undefined ? {} : { depth: parentDepth + 1 }), id: stopped, parent }, keys); + } + } + } + } // 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); @@ -605,6 +760,10 @@ export const createAgentLineageRegistry = ( : carrier.conversation === carrier.root; if (rootLike || host === 'cursor') { await ensureRoot(host, carrier.conversation, carrier.generation, observedAt, keys, rootLike, host === 'cursor' ? cursorWorkspace(native) : undefined); + } else if (host === 'codex' && event !== 'agent/start' && event !== 'agent/stop') { + // A Codex subagent first seen mid-flight (registry restarted, start + // missed) names its rollout on every hook; place it from there. + await placeUnknownCodexThread(event, native, observedAt, keys); } } const toolCallId = nativeString(native, 'tool_use_id') ?? nativeString(native, 'tool_call_id'); @@ -627,7 +786,10 @@ export const createAgentLineageRegistry = ( toolName, }, keys); } else if (event === 'tool/after' || event === 'tool/failure') { - await dispatch('toolCallClosed', { conversation: carrier.conversation, toolCallId }, keys); + // A successful Codex spawn answers with the child's agent path; kept + // on the pending spawn so the child's rollout can name its exact call. + const agentPath = host === 'codex' && event === 'tool/after' && SPAWN_TOOLS[host](toolName) ? codexSpawnAgentPath(native) : undefined; + await dispatch('toolCallClosed', { ...(agentPath === undefined ? {} : { agentPath }), 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' && SPAWNS_NEW_AGENT[host](toolName, native)) { diff --git a/packages/rsc-runtime/src/lineage/state.ts b/packages/rsc-runtime/src/lineage/state.ts index 0b6be1f53..b7961e11f 100644 --- a/packages/rsc-runtime/src/lineage/state.ts +++ b/packages/rsc-runtime/src/lineage/state.ts @@ -23,6 +23,12 @@ export const LineageNodeSchema = z.object({ id, isParallelWorker: z.boolean().optional(), parent: id.optional(), + /** + * How `parent`/`depth` were placed when not by the host's own start/stop + * payloads: `transcript` means they were read from the host-written rollout + * the payload named (Codex `transcript_path`), so they are exact, not inferred. + */ + placement: z.enum(['transcript']).optional(), root: id, startedAt: timestamp, stoppedAt: timestamp.optional(), @@ -40,6 +46,8 @@ export const LineageNodeSchema = z.object({ /** A pre-tool hook whose post-tool hook has not fired: the correlation window for MCP calls and spawns. */ export const OpenToolCallSchema = z.object({ + /** Codex `spawn_agent` answers `{"task_name": "/root//…"}`: the child's `agent_path`, matched against its rollout at start. */ + agentPath: id.optional(), conversation: id, /** The carrier's turn-shaped id when the window opened (Cursor `generation_id`, Codex `turn_id`, Claude `prompt_id`). */ generation: id.optional(), @@ -133,6 +141,18 @@ export const lineageEventSchemas = { */ childUnbound: z.object({ conversation: id, subagentId: id }).strict(), nodeStarted: LineageNodeSchema, + /** + * Later host evidence named a different parent (or an exact depth) for a + * node placed by inference; descendants shift with it. Codex: the rollout + * head of the child, or the parent rollout `SubagentStop` names in `transcript_path`. + */ + nodeReparented: z.object({ + depth: z.number().int().nonnegative().optional(), + id, + parent: id, + placement: z.enum(['transcript']).optional(), + toolCallId: id.optional(), + }).strict(), nodeStopped: z.object({ id, stoppedAt: timestamp }).strict(), /** A finished session releases its correlation windows and pending spawns. */ sessionRetired: z.object({ root: id }).strict(), @@ -151,7 +171,8 @@ export const lineageEventSchemas = { 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(), + /** `agentPath` annotates a still-pending spawn with the child path the host's tool response carried. */ + toolCallClosed: z.object({ agentPath: id.optional(), conversation: id, toolCallId: id }).strict(), toolCallOpened: OpenToolCallSchema.extend({ spawn: z.boolean().optional() }).strict(), } as const; @@ -220,6 +241,8 @@ export const reduceLineage = ( case 'nodeStarted': { const node = event.payload as LineageNode; const nodes = pruneStopped({ ...state.nodes, [node.id]: node }); + // A start placed by other evidence (a Codex rollout read later) is no longer waiting on its edge. + const unplaced = state.unplacedStarts?.filter((start) => start.id !== node.id); return { ...state, nodes, @@ -227,6 +250,7 @@ export const reduceLineage = ( ? [...state.pendingChildren.filter((pending) => pending !== node.id), node.id] : state.pendingChildren, seenStarts: node.depth === 0 ? state.seenStarts : remember(state.seenStarts, [node.id]), + ...(unplaced === undefined || unplaced.length === state.unplacedStarts?.length ? {} : { unplacedStarts: unplaced }), }; } case 'nodeStopped': { @@ -300,8 +324,47 @@ export const reduceLineage = ( }; } case 'toolCallClosed': { - const { toolCallId } = event.payload as { conversation: string; toolCallId: string }; - return { ...state, openCalls: state.openCalls.filter((open) => open.toolCallId !== toolCallId) }; + const { agentPath, toolCallId } = event.payload as { agentPath?: string; conversation: string; toolCallId: string }; + return { + ...state, + openCalls: state.openCalls.filter((open) => open.toolCallId !== toolCallId), + pendingSpawns: agentPath === undefined + ? state.pendingSpawns + : state.pendingSpawns.map((open) => (open.toolCallId === toolCallId ? { ...open, agentPath } : open)), + }; + } + case 'nodeReparented': { + const { depth, id: nodeId, parent, placement, toolCallId } = event.payload as { + depth?: number; id: string; parent: string; placement?: 'transcript'; toolCallId?: string; + }; + const node = state.nodes[nodeId]; + if (node === undefined) return state; + const nextDepth = depth ?? node.depth; + const shift = nextDepth - node.depth; + const nodes: Record = { + ...state.nodes, + [nodeId]: { + ...node, + depth: nextDepth, + parent, + ...(placement === undefined ? {} : { placement }), + ...(toolCallId === undefined ? {} : { toolCallId }), + }, + }; + if (shift !== 0) { + // Every descendant sits `shift` levels away from where it was filed. + const queue = [nodeId]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const child of Object.values(nodes)) { + if (child.parent === current && child.id !== nodeId) { + nodes[child.id] = { ...child, depth: child.depth + shift }; + queue.push(child.id); + } + } + } + } + return { ...state, nodes }; } case 'spawnClaimed': { const { siblingsUncertain, toolCallId } = event.payload as { siblingsUncertain?: boolean; toolCallId: string }; diff --git a/packages/rsc-runtime/tests/lineage-codex-rollout.test.ts b/packages/rsc-runtime/tests/lineage-codex-rollout.test.ts new file mode 100644 index 000000000..058bf7f10 --- /dev/null +++ b/packages/rsc-runtime/tests/lineage-codex-rollout.test.ts @@ -0,0 +1,483 @@ +import { readFileSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; + +import { unavailable } from '../src/agent-request.js'; +import { + codexThreadFromRolloutPath, + createAgentLineageRegistry, + parseCodexRolloutMeta, + readCodexRolloutHead, + readCodexSpawnLineage, + resolveNativeLineage, + resolveStandaloneLineage, + type AgentLineageRegistry, + type CodexRolloutReader, +} from '../src/lineage/index.js'; + +interface FixtureRecord { + readonly event?: { + readonly canonical: { readonly event: string; readonly idempotencyKey: string; readonly observedAt: string }; + readonly native: Readonly>; + }; + readonly kind: 'event' | 'mcp' | 'cli'; + readonly observed?: { readonly client?: { readonly name: string }; readonly mcpReq?: { readonly _meta?: Record }; readonly tool?: string }; +} + +const fixturesRoot = resolve(import.meta.dirname, '../../../fixtures/host-lineage'); +const capture = (): FixtureRecord[] => readFileSync(resolve(fixturesRoot, 'codex-0.147.0.ndjson'), 'utf8') + .trim().split('\n').map((line) => JSON.parse(line) as FixtureRecord); +const rolloutFixture = (path: string): string => resolve(fixturesRoot, 'codex-0.147.0-rollouts', basename(path)); + +/** Serves the captured rollout heads for the paths the hook payloads name; the capture's CODEX_HOME itself is gone. */ +const capturedRollouts: CodexRolloutReader = (path) => readCodexRolloutHead(rolloutFixture(path)); +const noRollouts: CodexRolloutReader = async () => undefined; + +const ROOT = '01a06660-110e-7290-8d1c-8ef1b2b68fc2'; +const SUBAGENT = '01a06660-8faf-7122-80af-24ba2da81ad7'; +const NESTED = '01a06661-100a-7ad3-a0f5-b0e6ffdb4b11'; +const ROOT_ROLLOUT = `/tmp/host-test/codex-home/.codex/sessions/2026/09/03/rollout-2026-09-03T08-26-06-${ROOT}.jsonl`; +const SUBAGENT_ROLLOUT = `/tmp/host-test/codex-home/.codex/sessions/2026/09/03/rollout-2026-09-03T08-26-39-${SUBAGENT}.jsonl`; +const NESTED_ROLLOUT = `/tmp/host-test/codex-home/.codex/sessions/2026/09/03/rollout-2026-09-03T08-27-12-${NESTED}.jsonl`; +const SPAWN_CALLS = { nested: 'call_i63o2ohoAVpJXjlsGEjoK4we', subagent: 'call_3koNvBFvpKYdTIqLb1xDOjSX' }; + +const replay = async (records: readonly FixtureRecord[], registry: AgentLineageRegistry) => { + const lineages: { readonly index: number; readonly kind: string; readonly lineage: Awaited>; readonly native?: Readonly> }[] = []; + for (const [position, record] of records.entries()) { + if (record.event !== undefined) { + const lineage = await registry.observe({ + event: record.event.canonical.event, + host: 'codex', + idempotencyKey: record.event.canonical.idempotencyKey, + native: record.event.native, + observedAt: record.event.canonical.observedAt, + }); + lineages.push({ index: position + 1, kind: record.event.canonical.event, lineage, native: record.event.native }); + } else if (record.kind === 'mcp' && record.observed?.tool !== undefined) { + const lineage = await registry.resolveToolCall({ host: 'codex', meta: record.observed.mcpReq?._meta, toolName: record.observed.tool }); + lineages.push({ index: position + 1, kind: `mcp:${record.observed.tool}`, lineage }); + } + } + return lineages; +}; + +const value = (observed: Awaited>) => { + expect(observed.state).toBe('available'); + return observed.state === 'available' ? observed.value : undefined!; +}; + +/** A hook payload shaped like the capture's, for scenarios the capture did not exercise. */ +const codexHook = (event: string, agent: string | undefined, extra: Record = {}): Record => ({ + cwd: '/tmp/host-test/codex-workspace', + hook_event_name: event, + model: 'gpt-5.6-sol', + permission_mode: 'bypassPermissions', + session_id: ROOT, + ...(agent === undefined ? { transcript_path: ROOT_ROLLOUT } : { agent_id: agent, agent_type: 'default' }), + ...extra, +}); + +describe('Codex rollout heads (#423): what the host writes about a spawned thread', () => { + it('parses the captured session_meta of a spawned thread into parent, depth, and agent path', async () => { + const head = await readCodexRolloutHead(rolloutFixture(NESTED_ROLLOUT)); + expect(head).toBeDefined(); + // The reader stops at the first newline: the second fixture line never reaches the parser. + expect(head).not.toContain('response_item'); + expect(parseCodexRolloutMeta(head!)).toEqual({ + agentPath: '/root/host_probe/nested_probe', + depth: 2, + parent: SUBAGENT, + root: ROOT, + subagentKind: 'thread_spawn', + thread: NESTED, + }); + expect(parseCodexRolloutMeta((await readCodexRolloutHead(rolloutFixture(SUBAGENT_ROLLOUT)))!)).toMatchObject({ depth: 1, parent: ROOT, thread: SUBAGENT }); + }); + + it('describes a root rollout without inventing a parent, and a host-internal subagent without a spawn lineage', async () => { + expect(parseCodexRolloutMeta((await readCodexRolloutHead(rolloutFixture(ROOT_ROLLOUT)))!)).toEqual({ root: ROOT, thread: ROOT }); + // Observed 0.147.0 shape for a review/guardian helper thread: `source.subagent` names no thread_spawn. + const helper = JSON.stringify({ payload: { id: 'helper', session_id: ROOT, source: { subagent: { other: 'guardian' } }, thread_source: 'subagent' }, type: 'session_meta' }); + expect(parseCodexRolloutMeta(helper)).toEqual({ root: ROOT, subagentKind: 'other', thread: 'helper' }); + const review = JSON.stringify({ payload: { id: 'review', session_id: ROOT, source: { subagent: 'review' } }, type: 'session_meta' }); + expect(parseCodexRolloutMeta(review)).toEqual({ root: ROOT, subagentKind: 'review', thread: 'review' }); + expect(await readCodexSpawnLineage('helper.jsonl', 'helper', async () => helper)).toBeUndefined(); + // Older builds (≤0.136.0) carry `agent_path: null` inside thread_spawn and no top-level copy: parent and depth still parse. + const older = JSON.stringify({ payload: { id: 'old', source: { subagent: { thread_spawn: { agent_path: null, depth: 1, parent_thread_id: 'p' } } } }, type: 'session_meta' }); + expect(parseCodexRolloutMeta(older)).toEqual({ depth: 1, parent: 'p', subagentKind: 'thread_spawn', thread: 'old' }); + }); + + it('rejects anything that is not a session_meta naming its thread', () => { + expect(parseCodexRolloutMeta('')).toBeUndefined(); + expect(parseCodexRolloutMeta('not json')).toBeUndefined(); + expect(parseCodexRolloutMeta(JSON.stringify({ payload: { type: 'message' }, type: 'response_item' }))).toBeUndefined(); + expect(parseCodexRolloutMeta(JSON.stringify({ payload: {}, type: 'session_meta' }))).toBeUndefined(); + expect(parseCodexRolloutMeta(JSON.stringify({ payload: { id: 'x', source: { subagent: { thread_spawn: { depth: 0, parent_thread_id: 'p' } } } }, type: 'session_meta' }))).toEqual({ parent: 'p', subagentKind: 'thread_spawn', thread: 'x' }); + }); + + it('refuses a rollout that belongs to a different thread than the payload speaks for', async () => { + expect(await readCodexSpawnLineage(NESTED_ROLLOUT, SUBAGENT, capturedRollouts)).toBeUndefined(); + expect(await readCodexSpawnLineage(NESTED_ROLLOUT, NESTED, capturedRollouts)).toMatchObject({ depth: 2, parent: SUBAGENT }); + expect(await readCodexSpawnLineage(undefined, NESTED, capturedRollouts)).toBeUndefined(); + expect(await readCodexRolloutHead('/nonexistent/rollout.jsonl')).toBeUndefined(); + }); + + it('reads the thread id out of a rollout basename', () => { + expect(codexThreadFromRolloutPath(SUBAGENT_ROLLOUT)).toBe(SUBAGENT); + expect(codexThreadFromRolloutPath('/tmp/agent-transcripts/abc/abc.jsonl')).toBeUndefined(); + expect(codexThreadFromRolloutPath(undefined)).toBeUndefined(); + }); +}); + +describe('lineage registry places Codex threads from their own rollout (#423)', () => { + it('replays the 2026-09-03 capture: SubagentStart resolves parent, depth, and the exact spawn call without ordering inference', async () => { + const registry = createAgentLineageRegistry({ readTranscript: capturedRollouts }); + const lineages = await replay(capture(), registry); + + const [subagentStart, nestedStart] = lineages.filter((entry) => entry.kind === 'agent/start'); + expect(subagentStart!.lineage).toMatchObject({ source: 'derived', state: 'available' }); + expect(value(subagentStart!.lineage)).toEqual({ + conversation: SUBAGENT, + depth: 1, + generation: '01a06660-901a-77f1-a660-ac3c549409c0', + parent: ROOT, + resolution: 'transcript', + root: ROOT, + subagent: { id: SUBAGENT, toolCallId: SPAWN_CALLS.subagent, type: 'default' }, + }); + expect(value(nestedStart!.lineage)).toMatchObject({ + conversation: NESTED, + depth: 2, + parent: SUBAGENT, + resolution: 'transcript', + root: ROOT, + subagent: { id: NESTED, toolCallId: SPAWN_CALLS.nested, type: 'default' }, + }); + + // Every hook inside a subagent resolves the same way; root hooks stay native. + const nestedTool = lineages.find((entry) => entry.kind === 'tool/before' && entry.native?.['agent_id'] === NESTED)!; + expect(value(nestedTool.lineage)).toMatchObject({ conversation: NESTED, depth: 2, parent: SUBAGENT, resolution: 'transcript' }); + expect(value(lineages[0]!.lineage)).toMatchObject({ conversation: ROOT, depth: 0, resolution: 'native' }); + + // MCP calls still resolve natively from _meta and agree with the hooks. + const probes = lineages.filter((entry) => entry.kind === 'mcp:probe').map((entry) => value(entry.lineage)); + expect(probes).toMatchObject([ + { conversation: ROOT, depth: 0, resolution: 'native' }, + { conversation: SUBAGENT, depth: 1, parent: ROOT, resolution: 'native' }, + { conversation: NESTED, depth: 2, parent: SUBAGENT, resolution: 'native' }, + ]); + + // Both spawn calls were claimed by agent path; both threads stopped on SubagentStop. + const snapshot = registry.snapshot(); + expect(snapshot.pendingSpawns).toEqual([]); + expect(snapshot.nodes[SUBAGENT]).toMatchObject({ placement: 'transcript', stoppedAt: expect.any(String), toolCallId: SPAWN_CALLS.subagent }); + expect(snapshot.nodes[NESTED]).toMatchObject({ placement: 'transcript', stoppedAt: expect.any(String), toolCallId: SPAWN_CALLS.nested }); + }); + + it('falls back to spawn-ordering inference when the rollout is unreadable, as the pre-#423 registry did', async () => { + const registry = createAgentLineageRegistry({ readTranscript: noRollouts }); + const lineages = await replay(capture(), registry); + const [subagentStart, nestedStart] = lineages.filter((entry) => entry.kind === 'agent/start'); + expect(value(subagentStart!.lineage)).toMatchObject({ depth: 1, parent: ROOT, resolution: 'registry', subagent: { toolCallId: SPAWN_CALLS.subagent } }); + expect(value(nestedStart!.lineage)).toMatchObject({ depth: 2, parent: SUBAGENT, resolution: 'registry', subagent: { toolCallId: SPAWN_CALLS.nested } }); + expect(registry.snapshot().nodes[SUBAGENT]?.placement).toBeUndefined(); + }); + + it('keeps the spawn agent path from the parent\'s PostToolUse on the pending spawn', async () => { + const registry = createAgentLineageRegistry({ readTranscript: noRollouts }); + const records = capture(); + const upToSpawnClose = records.findIndex((record) => record.event?.canonical.event === 'tool/after' && record.event.native['tool_name'] === 'collaborationspawn_agent') + 1; + await replay(records.slice(0, upToSpawnClose), registry); + expect(registry.snapshot().pendingSpawns).toMatchObject([{ agentPath: '/root/host_probe', toolCallId: SPAWN_CALLS.subagent }]); + }); + + it('resolves a start that spawn ordering alone had to refuse: two parents with unclaimed spawns', async () => { + const rollouts = new Map(); + const read: CodexRolloutReader = async (path) => rollouts.get(path); + const registry = createAgentLineageRegistry({ readTranscript: read }); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' }); + const spawnMeta = (thread: string, parent: string, depth: number, agentPath: string) => + JSON.stringify({ payload: { id: thread, session_id: ROOT, source: { subagent: { thread_spawn: { agent_path: agentPath, depth, parent_thread_id: parent } } }, thread_source: 'subagent' }, type: 'session_meta' }); + const rollout = (thread: string) => `/home/u/.codex/sessions/2026/09/03/rollout-2026-09-03T00-00-00-${thread}.jsonl`; + const worker = '01a00000-0000-7000-8000-000000000001'; + const workerChild = '01a00000-0000-7000-8000-000000000002'; + const rootChild = '01a00000-0000-7000-8000-000000000003'; + rollouts.set(rollout(worker), spawnMeta(worker, ROOT, 1, '/root/worker')); + rollouts.set(rollout(workerChild), spawnMeta(workerChild, worker, 2, '/root/worker/nested')); + rollouts.set(rollout(rootChild), spawnMeta(rootChild, ROOT, 1, '/root/second')); + + await observe('session/start', 's', codexHook('SessionStart', undefined)); + await observe('tool/before', 'sp1', codexHook('PreToolUse', undefined, { tool_input: { task_name: 'worker' }, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp1' })); + await observe('tool/after', 'sp1-after', codexHook('PostToolUse', undefined, { tool_input: { task_name: 'worker' }, tool_name: 'collaborationspawn_agent', tool_response: '{"task_name":"/root/worker"}', tool_use_id: 'sp1' })); + await observe('agent/start', 'w', codexHook('SubagentStart', worker, { transcript_path: rollout(worker), turn_id: 'w-turn' })); + // The root and the worker each spawn again before either child starts: nothing in the start payloads tells them apart. + await observe('tool/before', 'sp2', codexHook('PreToolUse', undefined, { tool_input: { task_name: 'second' }, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp2' })); + await observe('tool/after', 'sp2-after', codexHook('PostToolUse', undefined, { tool_input: { task_name: 'second' }, tool_name: 'collaborationspawn_agent', tool_response: '{"task_name":"/root/second"}', tool_use_id: 'sp2' })); + await observe('tool/before', 'sp3', codexHook('PreToolUse', worker, { tool_input: { task_name: 'nested' }, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp3', transcript_path: rollout(worker) })); + await observe('tool/after', 'sp3-after', codexHook('PostToolUse', worker, { tool_input: { task_name: 'nested' }, tool_name: 'collaborationspawn_agent', tool_response: { task_name: '/root/worker/nested' }, tool_use_id: 'sp3', transcript_path: rollout(worker) })); + + const nested = await observe('agent/start', 'wc', codexHook('SubagentStart', workerChild, { transcript_path: rollout(workerChild), turn_id: 'wc-turn' })); + expect(value(nested)).toMatchObject({ conversation: workerChild, depth: 2, parent: worker, resolution: 'transcript', subagent: { toolCallId: 'sp3' } }); + const second = await observe('agent/start', 'rc', codexHook('SubagentStart', rootChild, { transcript_path: rollout(rootChild), turn_id: 'rc-turn' })); + expect(value(second)).toMatchObject({ conversation: rootChild, depth: 1, parent: ROOT, resolution: 'transcript', subagent: { toolCallId: 'sp2' } }); + expect(registry.snapshot().pendingSpawns).toEqual([]); + }); + + it('places a thread first seen mid-flight from the rollout its hook names, and MCP _meta for that grandchild then resolves', async () => { + const registry = createAgentLineageRegistry({ readTranscript: capturedRollouts }); + // The registry never saw the root start, the spawn calls, or either SubagentStart. + const tool = await registry.observe({ + event: 'tool/before', + host: 'codex', + idempotencyKey: 'cold', + native: codexHook('PreToolUse', NESTED, { tool_input: { command: 'pwd' }, tool_name: 'Bash', tool_use_id: 'exec-1', transcript_path: NESTED_ROLLOUT, turn_id: 'n-turn' }), + }); + expect(tool).toMatchObject({ source: 'derived', state: 'available' }); + expect(value(tool)).toEqual({ + conversation: NESTED, + depth: 2, + generation: 'n-turn', + parent: SUBAGENT, + resolution: 'transcript', + root: ROOT, + subagent: { id: NESTED, type: 'default' }, + }); + // The root node was materialized from the payload's session_id; the parent thread itself is named, not fabricated. + expect(registry.snapshot().nodes[ROOT]).toMatchObject({ depth: 0 }); + expect(registry.snapshot().nodes[SUBAGENT]).toBeUndefined(); + // The pre-#423 refusal for a grandchild whose parent the registry never saw no longer applies once its hook placed it. + expect(await registry.resolveToolCall({ + host: 'codex', + meta: { 'x-codex-turn-metadata': { parent_thread_id: SUBAGENT, session_id: ROOT, thread_id: NESTED, turn_id: 'n-turn' } }, + toolName: 'probe', + })).toMatchObject({ value: { conversation: NESTED, depth: 2, parent: SUBAGENT, resolution: 'native' } }); + }); + + it('still answers honestly when a mid-flight thread names an unreadable rollout', async () => { + const registry = createAgentLineageRegistry({ readTranscript: noRollouts }); + const tool = await registry.observe({ + event: 'tool/before', + host: 'codex', + idempotencyKey: 'cold', + native: codexHook('PreToolUse', NESTED, { tool_input: {}, tool_name: 'Bash', tool_use_id: 'exec-1', transcript_path: NESTED_ROLLOUT }), + }); + expect(tool).toEqual(unavailable('id-not-resolvable')); + expect(registry.snapshot().nodes).toEqual({}); + }); + + it('places and retires a thread whose only observed event is SubagentStop, from agent_transcript_path', async () => { + const registry = createAgentLineageRegistry({ readTranscript: capturedRollouts }); + const stop = await registry.observe({ + event: 'agent/stop', + host: 'codex', + idempotencyKey: 'stop', + native: codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: SUBAGENT_ROLLOUT, turn_id: 'n-turn' }), + observedAt: '2026-09-03T08:27:40.000Z', + }); + expect(value(stop)).toMatchObject({ conversation: NESTED, depth: 2, parent: SUBAGENT, resolution: 'transcript' }); + expect(registry.snapshot().nodes[NESTED]).toMatchObject({ placement: 'transcript', stoppedAt: '2026-09-03T08:27:40.000Z' }); + }); + + it('places a start held unplaced (rollout unreadable, no claimable spawn) once a later hook finds the rollout readable', async () => { + // The rollout is not flushed yet when SubagentStart fires and no spawn + // hook was observed: the start is held unplaced, as for Claude. The + // thread's first tool hook then finds the rollout readable. + let readable = false; + const lateRollouts: CodexRolloutReader = (path) => readable ? capturedRollouts(path) : noRollouts(path); + const registry = createAgentLineageRegistry({ readTranscript: lateRollouts }); + const observe = (event: string, key: string, native: Record, observedAt: string) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt }); + await observe('session/start', 's', codexHook('SessionStart', undefined), '2026-09-03T08:26:00.000Z'); + const start = await observe('agent/start', 'sub', codexHook('SubagentStart', SUBAGENT, { agent_type: 'default', transcript_path: SUBAGENT_ROLLOUT }), '2026-09-03T08:26:39.000Z'); + expect(start).toEqual(unavailable('id-not-resolvable')); + expect(registry.snapshot().unplacedStarts).toEqual([expect.objectContaining({ id: SUBAGENT, root: ROOT, startedAt: '2026-09-03T08:26:39.000Z', type: 'default' })]); + + readable = true; + const tool = await observe('tool/before', 't', codexHook('PreToolUse', SUBAGENT, { tool_input: {}, tool_name: 'Bash', tool_use_id: 'b', transcript_path: SUBAGENT_ROLLOUT }), '2026-09-03T08:26:45.000Z'); + expect(value(tool)).toMatchObject({ conversation: SUBAGENT, depth: 1, parent: ROOT, resolution: 'transcript' }); + // Placed as it started, and no longer waiting on its edge. + expect(registry.snapshot().nodes[SUBAGENT]).toMatchObject({ placement: 'transcript', startedAt: '2026-09-03T08:26:39.000Z', type: 'default' }); + expect(registry.snapshot().unplacedStarts ?? []).toEqual([]); + }); + + it('materializes an unplaced start as already stopped when its stop came before the rollout was readable', async () => { + let readable = false; + const lateRollouts: CodexRolloutReader = (path) => readable ? capturedRollouts(path) : noRollouts(path); + const registry = createAgentLineageRegistry({ readTranscript: lateRollouts }); + const observe = (event: string, key: string, native: Record, observedAt: string) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt }); + await observe('session/start', 's', codexHook('SessionStart', undefined), '2026-09-03T08:26:00.000Z'); + await observe('agent/start', 'sub', codexHook('SubagentStart', SUBAGENT, { transcript_path: SUBAGENT_ROLLOUT }), '2026-09-03T08:26:39.000Z'); + // The stop's own rollout is unreadable too; the stop stays with the unplaced start. + const stop = await observe('agent/stop', 'stop', codexHook('SubagentStop', SUBAGENT, { agent_transcript_path: SUBAGENT_ROLLOUT, stop_hook_active: false, transcript_path: ROOT_ROLLOUT }), '2026-09-03T08:27:40.000Z'); + expect(stop).toEqual(unavailable('id-not-resolvable')); + expect(registry.snapshot().nodes[SUBAGENT]).toBeUndefined(); + expect(registry.snapshot().unplacedStarts).toEqual([expect.objectContaining({ id: SUBAGENT, stoppedAt: '2026-09-03T08:27:40.000Z' })]); + + // A late-delivered hook from the thread finds the rollout: the node is + // placed as it started and retired at the stop it already had. + readable = true; + const late = await observe('tool/after', 'late', codexHook('PostToolUse', SUBAGENT, { tool_input: {}, tool_name: 'Bash', tool_response: '', tool_use_id: 'b', transcript_path: SUBAGENT_ROLLOUT }), '2026-09-03T08:27:41.000Z'); + expect(value(late)).toMatchObject({ conversation: SUBAGENT, depth: 1, parent: ROOT, resolution: 'transcript' }); + expect(registry.snapshot().nodes[SUBAGENT]).toMatchObject({ placement: 'transcript', startedAt: '2026-09-03T08:26:39.000Z', stoppedAt: '2026-09-03T08:27:40.000Z' }); + expect(registry.snapshot().unplacedStarts ?? []).toEqual([]); + }); + + it('corrects an inferred parent at SubagentStop from the parent rollout the payload names, shifting descendants', async () => { + // Rollouts are unreadable at start (the pre-#423 inference path), so the + // nested thread is filed under the only parent with an unclaimed spawn. + const registry = createAgentLineageRegistry({ readTranscript: noRollouts }); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' }); + const other = '01a00000-0000-7000-8000-00000000000a'; + const grandchild = '01a00000-0000-7000-8000-00000000000b'; + await observe('session/start', 's', codexHook('SessionStart', undefined)); + await observe('tool/before', 'sp1', codexHook('PreToolUse', undefined, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp1' })); + await observe('agent/start', 'sub', codexHook('SubagentStart', SUBAGENT, { transcript_path: SUBAGENT_ROLLOUT })); + await observe('tool/before', 'sp2', codexHook('PreToolUse', undefined, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp2' })); + await observe('agent/start', 'other', codexHook('SubagentStart', other, { transcript_path: `/x/rollout-2026-09-03T00-00-00-${other}.jsonl` })); + // The registry missed SUBAGENT's spawn hook; `other` has one unclaimed, so the nested thread is misfiled under `other`. + await observe('tool/before', 'sp3', codexHook('PreToolUse', other, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp3' })); + const misfiled = await observe('agent/start', 'nested', codexHook('SubagentStart', NESTED, { transcript_path: NESTED_ROLLOUT })); + expect(value(misfiled)).toMatchObject({ depth: 2, parent: other, resolution: 'registry' }); + await observe('tool/before', 'sp4', codexHook('PreToolUse', NESTED, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp4' })); + await observe('agent/start', 'gc', codexHook('SubagentStart', grandchild, { transcript_path: `/x/rollout-2026-09-03T00-00-00-${grandchild}.jsonl` })); + expect(value(await observe('tool/before', 'gc-tool', codexHook('PreToolUse', grandchild, { tool_input: {}, tool_name: 'Bash', tool_use_id: 'b' })))).toMatchObject({ depth: 3, parent: NESTED }); + + // SubagentStop names the real parent's rollout in transcript_path (fixture rows 37 and 39): here the + // root's, so the thread moves up a level and its own child follows. + const stopped = await observe('agent/stop', 'nested-stop', codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: ROOT_ROLLOUT })); + expect(value(stopped)).toMatchObject({ conversation: NESTED, depth: 1, parent: ROOT, resolution: 'registry' }); + expect(registry.snapshot().nodes[NESTED]).toMatchObject({ depth: 1, parent: ROOT, stoppedAt: '2026-09-03T00:00:00.000Z' }); + expect(registry.snapshot().nodes[grandchild]).toMatchObject({ depth: 2, parent: NESTED }); + // The parent it was misfiled under is untouched. + expect(registry.snapshot().nodes[other]).toMatchObject({ depth: 1, parent: ROOT }); + expect(registry.snapshot().nodes[other]?.stoppedAt).toBeUndefined(); + }); + + it('takes exact parent and depth from the child rollout at SubagentStop when the parent thread was never placed', async () => { + // Unreadable at start (inference files the nested thread directly under the + // root), readable by the time it stops. + const rollouts = new Map(); + const registry = createAgentLineageRegistry({ readTranscript: async (path) => rollouts.get(basename(path)) }); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' }); + await observe('session/start', 's', codexHook('SessionStart', undefined)); + await observe('tool/before', 'sp', codexHook('PreToolUse', undefined, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp' })); + const misfiled = await observe('agent/start', 'nested', codexHook('SubagentStart', NESTED, { transcript_path: NESTED_ROLLOUT })); + expect(value(misfiled)).toMatchObject({ depth: 1, parent: ROOT, resolution: 'registry' }); + await observe('tool/before', 'gc-spawn', codexHook('PreToolUse', NESTED, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'gc-spawn' })); + const grandchild = '01a00000-0000-7000-8000-00000000000c'; + await observe('agent/start', 'gc', codexHook('SubagentStart', grandchild, { transcript_path: `/x/rollout-2026-09-03T00-00-00-${grandchild}.jsonl` })); + + rollouts.set(basename(NESTED_ROLLOUT), (await readCodexRolloutHead(rolloutFixture(NESTED_ROLLOUT)))!); + // SUBAGENT, the real parent, was never seen by this registry: only the child's own rollout can say how deep it sits. + const stopped = await observe('agent/stop', 'nested-stop', codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: SUBAGENT_ROLLOUT })); + expect(value(stopped)).toMatchObject({ conversation: NESTED, depth: 2, parent: SUBAGENT, resolution: 'transcript' }); + expect(registry.snapshot().nodes[NESTED]).toMatchObject({ depth: 2, parent: SUBAGENT, placement: 'transcript' }); + expect(registry.snapshot().nodes[grandchild]).toMatchObject({ depth: 3, parent: NESTED }); + expect(registry.snapshot().nodes[SUBAGENT]).toBeUndefined(); + }); + + it('keeps the inferred depth when only the parent basename is available and that parent is unknown', async () => { + const registry = createAgentLineageRegistry({ readTranscript: noRollouts }); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' }); + await observe('session/start', 's', codexHook('SessionStart', undefined)); + await observe('tool/before', 'sp', codexHook('PreToolUse', undefined, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'sp' })); + await observe('agent/start', 'nested', codexHook('SubagentStart', NESTED, { transcript_path: NESTED_ROLLOUT })); + const stopped = await observe('agent/stop', 'nested-stop', codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: SUBAGENT_ROLLOUT })); + // The parent is corrected to what the basename proves; the depth stays what inference gave, still reported as `registry`. + expect(value(stopped)).toMatchObject({ conversation: NESTED, depth: 1, parent: SUBAGENT, resolution: 'registry' }); + }); + + it('never consumes a sibling\'s spawn call whose recorded path differs from the starting child\'s', async () => { + const rollouts = new Map(); + const registry = createAgentLineageRegistry({ readTranscript: async (path) => rollouts.get(path) }); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' }); + const meta = (thread: string, agentPath: string) => + JSON.stringify({ payload: { id: thread, session_id: ROOT, source: { subagent: { thread_spawn: { agent_path: agentPath, depth: 1, parent_thread_id: ROOT } } } }, type: 'session_meta' }); + const a = '01a00000-0000-7000-8000-0000000000aa'; + const b = '01a00000-0000-7000-8000-0000000000bb'; + rollouts.set('/r/a.jsonl', meta(a, '/root/a')); + rollouts.set('/r/b.jsonl', meta(b, '/root/b')); + await observe('session/start', 's', codexHook('SessionStart', undefined)); + // A's spawn call was fully observed; B's spawn hooks were missed entirely. + await observe('tool/before', 'sa', codexHook('PreToolUse', undefined, { tool_input: { task_name: 'a' }, tool_name: 'collaborationspawn_agent', tool_use_id: 'spawn-a' })); + 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' })); + const startB = await observe('agent/start', 'b', codexHook('SubagentStart', b, { transcript_path: '/r/b.jsonl' })); + expect(value(startB)).toMatchObject({ conversation: b, depth: 1, parent: ROOT, resolution: 'transcript' }); + expect(value(startB).subagent?.toolCallId).toBeUndefined(); + expect(registry.snapshot().pendingSpawns.map((call) => call.toolCallId)).toEqual(['spawn-a']); + const startA = await observe('agent/start', 'a', codexHook('SubagentStart', a, { transcript_path: '/r/a.jsonl' })); + expect(value(startA)).toMatchObject({ conversation: a, resolution: 'transcript', subagent: { toolCallId: 'spawn-a' } }); + expect(registry.snapshot().pendingSpawns).toEqual([]); + }); + + it('still claims a spawn call whose response (and path) was missed, when it is the parent\'s only pending one', async () => { + 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' }); + const registry = createAgentLineageRegistry({ readTranscript: async () => meta }); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' }); + await observe('session/start', 's', codexHook('SessionStart', undefined)); + await observe('tool/before', 'sp', codexHook('PreToolUse', undefined, { tool_input: {}, tool_name: 'collaborationspawn_agent', tool_use_id: 'pathless' })); + const start = await observe('agent/start', 'n', codexHook('SubagentStart', NESTED, { transcript_path: '/r/n.jsonl' })); + expect(value(start)).toMatchObject({ depth: 1, parent: ROOT, resolution: 'transcript', subagent: { toolCallId: 'pathless' } }); + }); + + it('leaves a transcript-placed node alone at SubagentStop and ignores a self-referential path', async () => { + const registry = createAgentLineageRegistry({ readTranscript: capturedRollouts }); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'codex', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' }); + await observe('agent/start', 'nested', codexHook('SubagentStart', NESTED, { transcript_path: NESTED_ROLLOUT })); + const stopped = await observe('agent/stop', 'stop', codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: NESTED_ROLLOUT })); + expect(value(stopped)).toMatchObject({ depth: 2, parent: SUBAGENT, resolution: 'transcript' }); + }); + + it('ignores a replayed SubagentStart once the transcript-placed node has stopped', async () => { + const registry = createAgentLineageRegistry({ readTranscript: capturedRollouts }); + const start = codexHook('SubagentStart', NESTED, { transcript_path: NESTED_ROLLOUT }); + await registry.observe({ event: 'agent/start', host: 'codex', idempotencyKey: 'n', native: start }); + await registry.observe({ event: 'agent/stop', host: 'codex', idempotencyKey: 'n-stop', native: codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: SUBAGENT_ROLLOUT }) }); + const stoppedAt = registry.snapshot().nodes[NESTED]?.stoppedAt; + await registry.observe({ event: 'agent/start', host: 'codex', idempotencyKey: 'n', native: start }); + expect(registry.snapshot().nodes[NESTED]?.stoppedAt).toBe(stoppedAt); + }); +}); + +describe('standalone Codex hooks read their own rollout (#423)', () => { + it('resolves a subagent hook exactly from the rollout the payload names, with derived provenance', async () => { + const native = codexHook('PreToolUse', SUBAGENT, { tool_input: {}, tool_name: 'Bash', tool_use_id: 'exec-1', transcript_path: SUBAGENT_ROLLOUT, turn_id: '01a06660-901a-77f1-a660-ac3c549409c0' }); + expect(resolveNativeLineage('codex', native)).toEqual(unavailable('no-shared-runtime')); + expect(await resolveStandaloneLineage('codex', native, capturedRollouts)).toEqual({ + source: 'derived', + state: 'available', + value: { + conversation: SUBAGENT, + depth: 1, + generation: '01a06660-901a-77f1-a660-ac3c549409c0', + parent: ROOT, + resolution: 'transcript', + root: ROOT, + subagent: { id: SUBAGENT, type: 'default' }, + }, + }); + }); + + it('uses agent_transcript_path on SubagentStop, where transcript_path is the parent\'s rollout', async () => { + const stop = codexHook('SubagentStop', NESTED, { agent_transcript_path: NESTED_ROLLOUT, stop_hook_active: false, transcript_path: SUBAGENT_ROLLOUT }); + expect(await resolveStandaloneLineage('codex', stop, capturedRollouts)).toMatchObject({ value: { conversation: NESTED, depth: 2, parent: SUBAGENT } }); + }); + + it('keeps the payload-only answer for roots, other hosts, and unreadable rollouts', async () => { + expect(await resolveStandaloneLineage('codex', codexHook('SessionStart', undefined), capturedRollouts)).toMatchObject({ source: 'native', value: { conversation: ROOT, depth: 0, resolution: 'native' } }); + expect(await resolveStandaloneLineage('claude', { agent_id: 'child', session_id: 'root' }, capturedRollouts)).toEqual(unavailable('no-shared-runtime')); + expect(await resolveStandaloneLineage('cursor', { conversation_id: 'c' }, capturedRollouts)).toEqual(unavailable('no-shared-runtime')); + expect(await resolveStandaloneLineage('codex', codexHook('PreToolUse', SUBAGENT, { transcript_path: SUBAGENT_ROLLOUT }), noRollouts)).toEqual(unavailable('no-shared-runtime')); + // The default reader hits the filesystem; the capture's CODEX_HOME no longer exists. + expect(await resolveStandaloneLineage('codex', codexHook('PreToolUse', SUBAGENT, { transcript_path: SUBAGENT_ROLLOUT }))).toEqual(unavailable('no-shared-runtime')); + }); +}); diff --git a/packages/workbench/src/request-provenance.ts b/packages/workbench/src/request-provenance.ts index 8bc3855ce..9022bc595 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', 'confirmed', 'inferred']), + resolution: z.enum(['native', 'registry', 'confirmed', 'transcript', '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 50c240879..d5099c215 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -85,12 +85,13 @@ start/stop and pre-tool hooks, or `unavailable` with a typed reason (`id-not-res 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. +`tool_response.agentId`, the child — for every edge up to the root), `transcript` (read from +the host-written rollout the payload names — Codex records a subagent's parent thread and +depth there, which its hook payloads omit), 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 0e7debe42..efa369ffc 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -79,9 +79,10 @@ actor 认证,因此这两个轴保持诚实的不可用,而不是被伪造 `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,并把窗口 -挂错父节点的子代理挪到正确的父节点下。框架刻意不提供操作者或用户身份轴:它永远不会读取或暴露宿主 +直到根会话的每一条边都如此)、`transcript`(从载荷所指向的宿主自写 rollout 文件中读取——Codex 会在 +其中记录子代理的父线程与深度,而其钩子载荷并不携带这些字段)、或 `inferred`(Cursor 把子会话绑定到 +唯一一个待定的 `subagentStart`)。一次确认还会为盲选认领的同胞补上 `subagent.toolCallId`,安放 spawn +窗口无法安放的 start,并把窗口挂错父节点的子代理挪到正确的父节点下。框架刻意不提供操作者或用户身份轴:它永远不会读取或暴露宿主 会话背后的人是谁。 ## 共享布局