Skip to content

fix(claude): route native subagents by actor identity#99

Merged
aannoo merged 7 commits into
mainfrom
fix/claude-subagent-actor-routing
Jul 22, 2026
Merged

fix(claude): route native subagents by actor identity#99
aannoo merged 7 commits into
mainfrom
fix/claude-subagent-actor-routing

Conversation

@aannoo

@aannoo aannoo commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • route Claude native subagent hooks by raw agent_id instead of inferring identity from instance-row lifecycle
  • correlate Agent tool calls with SubagentStart using session-scoped prompt ownership, and persist validated agent ownership for resumed tasks
  • serialize running-task mutations and make nested cleanup follow the actual owner hierarchy
  • claim complete SubagentStop invocations before polling or teardown so duplicate hook registrations cannot race delivery against deletion
  • preserve asynchronous launch semantics, update the Claude mock pin, and expand native-subagent and real-tool coverage

Why

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

  • native Claude children become visible and addressable as soon as SubagentStart fires, before they invoke hcom themselves
  • nested and concurrent children resolve to their actual parent
  • resumed children retain their hcom identity even when Claude assigns a new prompt ID
  • duplicate stop hooks produce one teardown path without hiding legitimate later stop invocations
  • session end removes prompt-owner, agent-owner, poll, and stop-claim state

Validation

  • cargo fmt --all -- --check
  • cargo clippy --all-targets --locked -- -D warnings
  • Claude hook tests: 70 passed
  • full test suite: 2077 passed, 15 ignored
  • just ci, including ignored real Claude, Codex, and relay suites
  • live Claude test with two native subagents exchanging hcom messages
  • live resumed-task test confirmed the same actor ID and hcom child identity were restored without unresolved-owner errors
  • TUI inspection confirmed the child hierarchy and a single stopped child card

aannoo added 7 commits July 21, 2026 23:29
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
aannoo marked this pull request as ready for review July 22, 2026 11:37
@aannoo
aannoo merged commit 26287c2 into main Jul 22, 2026
29 of 31 checks passed
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