Close nested Claude subagent mirrors instead of leaving nameless ones running - #497
Merged
Merged
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
Claude Code sessions accumulated mirror sessions titled just
subagentthat stayed "working" forever.When a Claude subagent itself calls the
Agenttool, the grandchild's stdout lines carry the grandchild's tool_use id asparent_tool_use_id, but the spawningtool_useblock only appears inside the child's stream.TranscriptMapper::map_assistantmapped every tool_use as a plainToolCall, so the parent session never saw aSubagentitem for the grandchild id.reroute_native_subagent_eventthen created a placeholder mirror ("subagent") for the first grandchild item and opened its turn;update_subagenthad no tool item to update ontask_started/task_notification, so the terminal status never came, and after a host restart the persistedturn_startedwith noturn_completedwas never repaired. Evidence from a real store: child mirrord0e5dad2…records anAgenttool_calltoolu_0115…withrun_in_background: true; mirror90ebe1d3…opens withturn_startedtoolu_0115…, contains every child item twice (stdout feed + transcript tail), ends with the grandchild's final message, and has noturn_completed.Two more findings from the same trace:
is_agent_tool(contains("agent")) treatedListAgentsas a spawn (asubagent:mirror with an empty description), anddedupe_child_eventsonly dropped anItemStartedafter itsItemCompleted, so every child item from the two feeds appeared twice.Fix
Agent layer (
crates/agent)TranscriptMapperemitsAgent/Taskcalls asItemContent::Subagent(samespawned_subagentsnapshot as a top-level spawn), keyed by the bare tool_use id — the id the grandchild's lines name asparent_tool_use_id— and parented to the child. A foreground nested spawn completes on the child'stool_result; a background one (run_in_background,tool_use_result.status == async_launched, or the launch acknowledgement text — subagent transcripts omittoolUseResult, verified on disk) is left to its owntask_notification.Mapper::fold_child_eventsregisters nested spawns intool_items(withparent_item_id) soon_task_started's tail,update_subagent,note_subagent_modelandgate_subagent_terminalwork unchanged for grandchildren, and emit their snapshots with the correct parent.ToolItem::Subagentcarriesparent_item_id.Started/Updated(content)/Completedper 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_toolmatches exactlyagent|task.Runtime (
crates/runtime) — option A, nestedSubagentitem 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 innested_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 sameapply_subagent_snapshotpath top-level spawns use.find_native_subagent_mirrorresolves 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) andclose_orchestrator_childrenfollow 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).schedule_timeline_load),repair_orphaned_mirror_turnsynthesizesTurnCompleted { Interrupted }whenmeta.native_subagentis set, the last persisted turn has no status, andnative_subagent_turnsdoes 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(fixturecrates/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'stool_result;ListAgentsin the same message stays aToolCall.only_spawning_tools_are_agent_tools—is_agent_toolacceptsagent/task, rejectslistagents/sendmessage/…; a top-levelListAgentsmaps to aToolCall.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'sSessionClosedclosesInterrupted; parent/child logs carry noparent_item_id.unknown_parent_mirror_closes_when_the_parent_session_closes— placeholder mirror closesInterruptedonSessionClosed. (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 withturn_startedand no completion getsTurnCompleted { 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
macOS only; no live Claude probe was run — the fixture is constructed from the recorded store and
~/.claudetranscripts, not a fresh capture.Limitations
nested_subagent_spawnsis 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.