Skip to content

Close nested Claude subagent mirrors instead of leaving nameless ones running - #497

Merged
Tryanks merged 1 commit into
mainfrom
tcode/0ce8f15a-691a-40f1-964d-f2be1af2695a
Sep 21, 2026
Merged

Tryanks merged 1 commit into
mainfrom
tcode/0ce8f15a-691a-40f1-964d-f2be1af2695a

Conversation

@Tryanks

@Tryanks Tryanks commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Root cause

Claude Code sessions accumulated mirror sessions titled just subagent that stayed "working" forever.

When a Claude subagent itself calls the Agent tool, the grandchild's stdout lines carry the grandchild's tool_use id as parent_tool_use_id, but the spawning tool_use block only appears inside the child's stream. TranscriptMapper::map_assistant mapped every tool_use as a plain ToolCall, so the parent session never saw a Subagent item for the grandchild id. reroute_native_subagent_event then created a placeholder mirror ("subagent") for the first grandchild item and opened its turn; update_subagent had no tool item to update on task_started/task_notification, so the terminal status never came, and after a host restart the persisted turn_started with no turn_completed was never repaired. Evidence from a real store: child mirror d0e5dad2… records an Agent tool_call toolu_0115… with run_in_background: true; mirror 90ebe1d3… opens with turn_started toolu_0115…, contains every child item twice (stdout feed + transcript tail), ends with the grandchild's final message, and has no turn_completed.

Two more findings from the same trace: is_agent_tool (contains("agent")) treated ListAgents as a spawn (a subagent: mirror with an empty description), and dedupe_child_events only dropped an ItemStarted after its ItemCompleted, so every child item from the two feeds appeared twice.

Fix

Agent layer (crates/agent)

  • TranscriptMapper emits Agent/Task calls as ItemContent::Subagent (same spawned_subagent snapshot as a top-level spawn), keyed by the bare tool_use id — the id the grandchild's lines name as parent_tool_use_id — and parented to the child. A foreground nested spawn completes on the child's tool_result; a background one (run_in_background, tool_use_result.status == async_launched, or the launch acknowledgement text — subagent transcripts omit toolUseResult, verified on disk) is left to its own task_notification.
  • Mapper::fold_child_events registers nested spawns in tool_items (with parent_item_id) so on_task_started's tail, update_subagent, note_subagent_model and gate_subagent_terminal work unchanged for grandchildren, and emit their snapshots with the correct parent. ToolItem::Subagent carries parent_item_id.
  • Child items are deduplicated per lifecycle stage (Started/Updated(content)/Completed per id): the other feed's copy of the same stage is dropped, a changed update still passes, and a stale start still cannot reopen a completed item.
  • is_agent_tool matches exactly agent | task.

Runtime (crates/runtime) — option A, nested

  • A Subagent item that arrives as child content is recorded in the child mirror (Codex's child-thread markers are Subagent-shaped and must stay plain content) and remembered in nested_subagent_spawns. The first grandchild item that names it as parent creates a mirror under the child mirror (parent_session_id = child mirror id) titled from the spawn, and later snapshots of the spawn update/close that mirror through the same apply_subagent_snapshot path top-level spawns use.
  • find_native_subagent_mirror resolves an item id to a mirror anywhere in the session's descendant chain, so grandchild items reroute to the nested mirror across residency changes and restarts.
  • interrupt_native_subagent_work (SessionClosed) and close_orchestrator_children follow the descendant chain, so nested mirrors end with the root process like direct ones. Unknown-parent placeholders are still created for a child item with no known spawn (never left open: SessionClosed interrupts them, and the repair below catches restarts).
  • Load-time repair: after a mirror's timeline load (schedule_timeline_load), repair_orphaned_mirror_turn synthesizes TurnCompleted { Interrupted } when meta.native_subagent is set, the last persisted turn has no status, and native_subagent_turns does not track the mirror as running in this host — i.e. no live parent owns it. Existing zombie mirrors settle when opened after upgrade.

No user-facing strings were added.

Tests

crates/agent/src/claude.rs (fixture crates/agent/tests/fixtures/claude/subagent_nested_trace.jsonl, constructed from the on-disk shapes above):

  • nested_background_subagent_settles_through_its_own_task_notification — nested Subagent item with the grandchild id parented to the child; grandchild items parented to the grandchild; terminal held until the grandchild's tail flush then emitted once with summary/model/effort; both tails' replays add nothing; tail Start/Stop for the grandchild.
  • nested_foreground_subagent_completes_on_the_childs_tool_result — settles on the child's tool_result; ListAgents in the same message stays a ToolCall.
  • only_spawning_tools_are_agent_tools — is_agent_tool accepts agent/task, rejects listagents/sendmessage/…; a top-level ListAgents maps to a ToolCall.

crates/runtime/src/app/tests.rs:

  • nested_subagent_items_open_a_titled_mirror_under_the_child_mirror_and_close_it — spawn alone is child-mirror content; the first grandchild item creates a titled mirror under the child mirror (not on the root); the spawn's terminal closes it (Completed); a second nested mirror still running at the root's SessionClosed closes Interrupted; parent/child logs carry no parent_item_id.
  • unknown_parent_mirror_closes_when_the_parent_session_closes — placeholder mirror closes Interrupted on SessionClosed. (This contract already held for direct placeholders; the test guards it. The nested case above is the regression.)
  • loading_a_mirror_with_an_open_turn_and_no_live_parent_ends_it — a persisted mirror with turn_started and no completion gets TurnCompleted { Interrupted } appended on load; reloading appends nothing.

The nested runtime test and the repair test fail against the pre-fix runtime (verified by swapping in HEAD's runtime files); the nested agent test fails pre-fix because no Subagent item exists for the grandchild.

Checks

cargo fmt --all --check                                                   # ok
cargo clippy --workspace --all-targets --locked -- -D warnings            # ok
cargo nextest run --workspace --locked                                    # 913 passed, 6 skipped (pre-existing ignored)

macOS only; no live Claude probe was run — the fixture is constructed from the recorded store and ~/.claude transcripts, not a fresh capture.

Limitations

  • The nested mirror is created by the first grandchild item, not by the spawn itself (Codex emits Subagent-shaped child markers that never become parents). Claude grandchildren emit their prompt as the first line, so in practice the mirror appears immediately.
  • nested_subagent_spawns is in-memory; a grandchild item for a spawn recorded before a host restart falls back to a placeholder mirror on the root, which SessionClosed/repair still close.

… running

A Claude subagent that calls the Agent tool produces a grandchild whose
stdout lines carry the grandchild's own tool_use id as parent_tool_use_id,
while the spawning tool_use only appears inside the child's stream. The
child transcript mapper turned every tool_use into a plain ToolCall, so no
Subagent item ever existed for the grandchild: the runtime created a
placeholder mirror titled "subagent", opened its turn, and nothing closed
it — task events found no tool item, and after a host restart the open turn
sat in the persisted log for good.

The transcript mapper now emits Agent/Task calls as Subagent items keyed by
their bare tool_use id and parented to the child. The Claude mapper
registers them in tool_items, so the grandchild's task_started tail,
task_notification, model observation and terminal gating work as for a
top-level spawn; a foreground nested spawn settles with the child's
tool_result, a background one (run_in_background, async_launched, or the
launch acknowledgement text that subagent transcripts carry without a
toolUseResult) with its notification. Child items from the stdout feed and
the transcript tail are deduplicated per lifecycle stage rather than only
after completion.

The runtime keeps a Subagent item recorded inside a mirror as that mirror's
content and, once a grandchild item names it as parent, opens a titled
mirror nested under the child mirror; its snapshots then update and close
that mirror. Mirror lookup, SessionClosed interruption and orchestrator
child teardown follow the descendant chain. Loading a mirror whose last
persisted turn is still open while no live parent tracks it synthesizes an
interrupted TurnCompleted, so existing zombie mirrors settle on upgrade.

is_agent_tool now matches only the spawning tools (agent, task), so
ListAgents no longer produces a "subagent: " mirror.
@Tryanks
Tryanks merged commit e11b37e into main Sep 21, 2026
7 checks passed
@Tryanks
Tryanks deleted the tcode/0ce8f15a-691a-40f1-964d-f2be1af2695a branch September 21, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant