fix(claude): route native subagents by actor identity#99
Merged
Conversation
Replace missing-row lifecycle inference with actor-first routing based on Claude agent IDs and session-scoped spawn ownership. Correlate Agent tool calls to SubagentStart through prompt IDs, persist validated agent-owner mappings for resumed tasks, and keep unknown actors fail-closed. Serialize running-task mutations and clean ownership, polling, and stop-claim state at session end. Claim each full SubagentStop invocation before transcript updates or polling so duplicate hook registrations cannot race message delivery against teardown. Preserve legitimate repeated stops by fingerprinting the complete payload. Harden nested cleanup and async launch handling, update the pinned Claude mock version, and expand unit and real-tool coverage for native discovery, nesting, resume, concurrency, duplicate stops, queued delivery, and relay behavior.
Two correctness bugs in the native-subagent actor routing surfaced by a PR review, both confirmed empirically: Allocation race: allocate_subagent_instance read max_n, computed the next suffix, and INSERTed across separate statements with a single max_n+2 retry. Each SubagentStart is its own process/connection, so parallel same-type siblings of one parent all read the same max_n and collide on UNIQUE(name); only the first two survive. Repro: 8 concurrent siblings -> 6 dropped, each a rowless "unknown_subagent_actor" (invisible, no delivery). Fixed by running the idempotency check + suffix scan + INSERT inside one BEGIN IMMEDIATE transaction. Also allocate the row before tracking it in the parent's running_tasks, so there is no tracked-but-rowless window. SubagentStop claim tombstone: the claim key hashed the full raw payload and persisted until SessionEnd. Captured live payloads show prompt_id is stable across re-fires and last_assistant_message repeats, so two genuinely distinct stops can be byte-identical (observed: a second stop returned in 1.33ms via the already-claimed skip, dropping poll + delivery + teardown). Since Claude blocks the subagent until its SubagentStop hook returns, same-payload stops only overlap when they are the same logical stop dispatched to duplicate hook registrations. Make the claim a concurrency guard released on completion (StopClaimGuard) so that case still dedups while a later distinct re-fire re-claims. Adds a concurrent-allocation repro test, a claim release/reclaim test, and an integration test asserting subagent_stop releases its own claim.
…dren
Maintainability pass over the actor-routing hooks:
- extract handle_subagent_start / build_subagent_start_output
- encapsulate the SubagentStop dedup in SubagentStopClaim::acquire
- add RunningTasks::{track_subagent,remove_subagent,tracks_subagent} and a
shared write_running_tasks helper
- rename with_transaction -> with_immediate_transaction,
remove_tracked_subagent_wherever_found -> remove_tracked_subagent_by_agent_id
- factor prefix_like_pattern in db::kv and cover kv_delete_prefix
- return (exit_code, stdout) consistently; drop the always-false end_task
interrupted arg
Also make ensure_subagent_row return whether the row now exists and only
track the child on success, so an allocation failure can no longer leave the
parent tracking a rowless, unaddressable subagent.
Native subagent rows carry session_id=NULL and inherit the root session as their parent_session_id, so stop_instance's session-keyed cascade never links a nested parent to its own children. Stopping a nested parent A therefore tore down A while its child B stayed alive, reparented to a name that can later be reused by an unrelated actor. Add a parent_name-keyed cascade alongside the session one, running unconditionally. The existing MAX_STOP_DEPTH guard covers cycles and the already-stopped early-return makes the overlapping root case a no-op. Reachable only when a subagent is granted the Task/Agent tool (non-default), but makes teardown correct by construction. Regression test covers A->B cascade while the live root is left untouched.
Support combined, label, and off modes for terminal and tab titles. In combined mode, capture and sanitize complete child titles and update hcom's status title live through the existing safe OSC path. Wire the setting through TOML, environment variables, CLI validation/help, README documentation, and tests.
# Conflicts: # scripts/install-mock-tools.ps1 # scripts/install-mock-tools.sh
aannoo
marked this pull request as ready for review
July 22, 2026 11:37
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.
Summary
agent_idinstead of inferring identity from instance-row lifecycleSubagentStartusing session-scoped prompt ownership, and persist validated agent ownership for resumed tasksSubagentStopinvocations before polling or teardown so duplicate hook registrations cannot race delivery against deletionWhy
Claude emits child hooks with an actor ID, but parent Agent hooks do not always carry that ID. Resumed children can also return with the same actor ID and a new prompt ID. Treating a missing instance row as proof that a child is dead does not establish ownership and can cause unknown-actor routing, cross-delivery, or teardown races.
This supersedes the missing-row fallback proposed in #82 with actor-first, session-scoped routing that remains fail-closed for genuinely unknown actors.
Impact
SubagentStartfires, before they invoke hcom themselvesValidation
cargo fmt --all -- --checkcargo clippy --all-targets --locked -- -D warningsjust ci, including ignored real Claude, Codex, and relay suites