SendMessage-resumed nested subagent's completion notification routes to the top-level session, not the depth-1 parent that resumed it (first completion routes correctly)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 2 comments · opened Jul 26, 2026

Environment

  • Claude Code 2.1.220, macOS (darwin 25.5.0)
  • Interactive session; no agent-teams flag; CLAUDE_CODE_DISABLE_BACKGROUND_TASKS unset
  • Spawn depth 3 available (default since 2.1.219)

Summary

A background depth-1 subagent that spawns a background depth-2 child receives that child's first completion notification correctly. But after the depth-1 agent resumes the completed child via SendMessage, the resumed run's completion notification is delivered to the top-level session, not to the depth-1 agent that resumed it — despite the SendMessage tool result promising "You'll be notified when it finishes." The depth-1 agent is left idle forever with no wake event, and the top-level session receives a completion for a child it never spawned or resumed.

This looks like the remaining slice of #75043: on 2.1.220 the initial-dispatch notification routing that issue reported broken now works (first completion reached the depth-1 parent in our runs), while the resume path still misroutes. Filing separately because the trigger (subagent resuming its own child, not main resuming the orchestrator), the baseline (first completion routes correctly), and the mechanism below are all distinct from what #75043 describes.

Repro

  1. Main session spawns orchestrator O via the Agent tool (general-purpose, run_in_background: true, unnamed).
  2. O spawns child C (general-purpose, run_in_background: true, unnamed) and ends its turn.
  3. C completes → O is re-invoked with C's task-notification, result inline. ✅ correct routing.
  4. O calls SendMessage(to: <C's raw agent id>). Tool result: Agent "…" was stopped (completed); resumed it in the background with your message. You'll be notified when it finishes. O ends its turn to wait.
  5. C's resumed run completes (~30s) → the task-notification (same task-id as step 3) appears in the main session's transcript. ❌ O is never re-invoked; a 5-minute watch confirmed it stays stranded. Only a manual SendMessage from main to O's raw agent id recovers it.

Mechanism (static analysis of the 2.1.220 bundle)

The live SendMessage-resume path re-registers the task with a hardcoded main-session owner, discarding the owner stamp the first run carried:

  • The initial background spawn derives ownerAgentId from the spawner (falling back to the main session id only when the spawner doesn't qualify) — which is why the first completion routes correctly.
  • The resume engine (string anchor: ; resumed it in the background with your message. You'll be notified when it finishes. Output: → the function anchored by Agent ${e} is already running or being resumed) reads the old registry record for spawnDepth/startTime/result but never reads its ownerAgentId, and re-registers with ownerAgentId: <main-session id resolver>() — a process-global that always resolves to the top-level main agent id in an interactive session. The calling agent's id is in scope in the same handler (used elsewhere in it) but is never consulted for the owner stamp.
  • The re-registration also resets notified: false on the same task-id, which is why one task-id legally notifies twice (once to the parent, once — post-resume — to main).
  • For contrast, the disk-restore resume registrar (string anchor: "(resumed agent)") stamps ownerAgentId: parentAgentId ?? <main> — it at least tries the parent. The live-resume path doesn't.

Suggested fix shape: derive the owner the same way the initial spawn does, falling back to the previous record's owner before the main-session fallback — e.g. ownerAgentId: deriveOwner(callerAgentId) ?? previousRecord.ownerAgentId ?? mainSessionId().

Adjacent observation: the resume tool result includes resumedAgentId exactly when the owner is (or falls back to) the main session, which suggests partial awareness of this gap in the current code.

Impact / workarounds

  • Any nested orchestrator that resumes its own workers strands permanently; recovery requires a human noticing stray completions in the top-level session and manually resuming the orchestrator.
  • No configuration lever fixes the routing. CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 makes the resume run foreground-inline (result returned in the tool result, no misroutable notification), but it also disables all background tasks including the child dispatch that creates the scenario.
  • Practical workaround: subagents should never SendMessage-resume their own children — dispatch a fresh child instead (fresh dispatches get a correct owner stamp), or poll the resume result's Output: file.

Related issues

  • #75043 — nearest match (v2.1.201): reported completion notifications never reaching the subagent parent, plus TaskStop ownership errors after resume. On 2.1.220 we observe the first-completion routing working, so that symptom appears at least partially resolved; this issue covers the still-broken resume path. (We did not re-test its always-async or TaskStop symptoms.)
  • #77950 — grandchild SendMessage to parent falls back to main (child-initiated messaging; likely sibling of the same missing parent-identity propagation, but a different path than system task-notifications).
  • #76681 — notification enqueued-but-undelivered for a SendMessage-resumed agent's orphaned Bash task (same resume-boundary bookkeeping, different task kind and observable).
  • #69212 — teams framing of nested-result misrouting.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗