SendMessage racing an agent's task stop strands the reply with no notification
Environment
- Claude Code 2.1.238, Linux, interactive terminal session (tmux)
- Background subagents via the Agent tool; messages to them via SendMessage
- One session, one long-lived recipient agent; the session, agent, and tool-use identifiers are withheld from this report and available to maintainers on request
- All timestamps UTC, 2026-08-20/21, taken verbatim from the session JSONL and the subagent transcripts under ~/.claude/projects/<project>/
- The code analysis below is from the embedded minified JavaScript in the released 2.1.238 Linux binary. Function names are that build's minified identifiers, with byte offsets into the executable; they change every release.
- Re-checked on 2.1.239 (released while this was being written): every structure quoted below is present and unchanged apart from renamed identifiers. Offsets in this report are the 2.1.238 ones.
Summary
What a user sees: a message sent to a background agent arrives and gets answered, but the answer never comes back. The agent writes its reply into its own transcript and stops; no task-notification is ever emitted for it; and the sender's SendMessage result looks exactly like the success case. Anything waiting on that notification waits forever. In the incident below, an orchestrating session lost 2 of 5 replies this way and ended up frozen mid-workflow (the freeze itself is the companion issue, #88742).
The cause: every agent task has a notified flag on its record in the in-memory task registry. Enqueueing a notification for the task sets the flag. While the flag is set, any further notification attempt for that task is skipped silently; a debug-level log line is the only trace. The flag is cleared whenever the task is registered again, which happens on every resume.
When a message arrives while the agent's task is in the middle of stopping, Claude Code starts a follow-on run so the agent can answer it. Starting that run re-registers the task, which clears the flag. The problem is that the original run has not yet enqueued its own stop notification at that point, and the two steps race:
- If the original run enqueues its stop notification first, the follow-on's registration clears the flag afterwards, and when the follow-on finishes, a second notification goes out carrying the answer. The parent gets the answer.
- If the follow-on registers first, the original run's stop notification consumes the freshly cleared flag (and it carries the OLD, pre-message reply). When the follow-on finishes, its notification is skipped. The answer sits in the subagent transcript and the parent is never told.
The sender cannot see which way the race went: SendMessage returns the same "Resuming agent" result string in both cases.
This pins down the trigger that #78338 (defect 3, missing completion notifications) reported but could not reproduce, and corrects one detail of its analysis (see "Relation to #78338" at the end of the code section).
What was observed: five sends, one session
An orchestrating session sent a short "DONE" message to a long-lived worker agent at the end of each of five work rounds. Each send was triggered by a file the worker writes one API round before its task stops, so every send landed close to the stop. Two log entries make the race outcome visible, and they are the table's third and fourth columns: the follow-on run's first transcript entry (a user message with a fresh promptId, beginning "The coordinator sent a message while you were working:"), and the stop notification's queue-operation/enqueue entry in the session JSONL.
| Exchange | DONE send | Follow-on run begins | Stop notification enqueued | Which came first | Outcome |
|----------|-----------|----------------------|----------------------------|------------------|---------|
| 1 | 22:58:27.670 | 22:58:31.373 | 22:58:34.165 | follow-on | Reply STRANDED (only notification carried the old reply) |
| 2 | 23:31:19.299 | n/a (message joined the running turn) | 23:34:26 | n/a | Delivered inside the one stop notification |
| 3 | 00:04:08.064 | n/a (agent was idle; normal resume) | 00:04:41 | n/a | Delivered |
| 4 | 00:39:39.294 | 00:39:41.896 | 00:39:41.597 | stop notification | Delivered via a SECOND notification at 00:40:06.262 |
| 5 | 01:33:16.583 | 01:33:19.601 | 01:33:21.103 | follow-on | Reply STRANDED (no notification ever again) |
Exchanges 4 and 5: the race caught in both orders
These two exchanges are the same race resolving both ways, and together they isolate the flag as the mechanism.
Exchange 4 (delivered): the stop notification was enqueued at 00:39:41.597 under the previous resume's tool-use-id, and the follow-on began after it, at 00:39:41.896. The follow-on's registration cleared the flag, so its completion enqueued a second notification at 00:40:06.262 with the same task-id and a different tool-use-id, byte-identical to the DONE SendMessage's own tool_use id in the sender's transcript. This is direct evidence that re-registration stamps the resuming context's id onto the task record and re-enables notification.
Exchange 5 (stranded): the follow-on began at 01:33:19.601, BEFORE the stop notification was enqueued at 01:33:21.103. That stop notification carried the old, pre-DONE reply under the previous resume's tool-use-id (the enqueue uses the id its caller passes, not the record's). The follow-on finished its answer at 01:33:35.680. No notification for this agent was ever enqueued again, and the DONE send's own tool_use id appears in no notification.
A log detail that could mislead: the queue-operation/remove entry at 00:39:42.045 (same payload as the 00:39:41.597 enqueue) is not part of the notification lifecycle. It is the parent, which was in the middle of a turn, absorbing the pending notification into that turn as a queued_command attachment and then removing it from the queue. Whether the parent had already consumed the stale notification plays no role in the outcome: consuming a notification only changes the queue, and the flag lives on the task record.
Why it happens (2.1.238 code)
- The flag check is a single test-and-set.
Xet(309665372):function Xet(e,t){let r=!1,n;return t.update(e,(o)=>{if(n=o,o.notified)return o;return r=!0,{...o,notified:!0}}),{claimed:r,task:n}}. It returnsclaimed:falseboth when the flag is already set and when the task is missing from the registry entirely (the registry updates4Sat 312241984 does nothing for a missing entry). - The notification builder
Ofr(314427816; its own log tag is[enqueueAgentNotification]) callsXetfirst and returns silently when the claim fails, verbatim: `if(!p){E([enqueueAgentNotification] skipped taskId=${e} status=${r} taskPresent=${m} reason=${m?"already-notified":"task-not-in-registry"},{level:m?"debug":"warn"});return}. The already-notified case logs at debug level, so under default logging the loss leaves no trace. On success it enqueues withmode:"task-notification",priority:"next"`, and the tool-use-id its caller passed in. - Re-registration clears the flag. Every resume runs
kXS(313503314) ->frt(314433231) -> the registry registerc4S(312243242). The register copies a fixed list of fields from the old record (retain, startTime, diskLoaded, pendingMessages, keepaliveReasons, ownerAgentId, parentAgentId, spawnDepth, isObserver, forkedSkillName, webFetchSavedFiles, spawnedSubagent).notifiedis not on that list, so it resets to unset, and the record's tool-use-id becomes the resuming context's. On 2.1.239 the re-registration field list still omitsnotified. - How the follow-on starts: every path that starts one runs through the flag-clearing registration above. A message that arrives too late to join the current turn is appended to the task's
pendingMessages(GQn314427673 /Fmr314427248), and anagentStrandedMessagesevent fires: on arrival if the task is no longer running (emit at 314427768), or when the task finishes with messages still pending (cVf314432091, emit at 314432668). Those are the only two emit sites in the binary. The single listener (ZQA322752375 ->VQA322751686) immediately starts the follow-on viaJvt(313502964) ->kXS->frt; all sixJvtcall sites route throughfrt. - The race itself. After its final API round, the original runner still has at least two awaits to pass (
wYr, a classifier gate at 312380371 that returns immediately outside auto mode, then a worktree-result read) before it callsOfr. The follow-on setup runs as soon as the event fires. Both orders occur in practice: - Stop notification first (exchange 4):
Ofrsets the flag; the follow-on's registration then clears it; the follow-on's completion sets it again and emits the answer. Two notifications total. - Follow-on first (exchanges 1 and 5): the registration clears an already-clear flag and restamps the record; the original run's
Ofrthen sets the flag while emitting the OLD reply under the OLD tool-use-id. The follow-on completes with the flag set. Whether its completion reachesOfrand takes the debug-logged skip, or is held earlier by the keepalive path (ycl/EYr314426719, which delays notifications while the agent has live background children), the visible result is the same: no notification for the answer. One notification total, with the wrong content. - Why the sender cannot tell. The SendMessage result string comes from
mom(313554154), which produces the "Resumed agent" form with the reply text only for blocking resumes. Non-blocking sends always return the "Resuming agent" form, whether the target was idle or mid-stop (the blocking-mode predicate at 313508871 was false here;Gk306214390 is backgroundTasksDisabled,Kq307933942 applies only to the built-in web-fetch agent).
Relation to #78338 (which analyzed 2.1.211/2.1.214): its claim (a), a notified flag that is set on enqueue and "never reset" for follow-ons, is partly right. The flag exists, and consuming a notification never clears it. But every re-registration does clear it; the loss is a race, not a missing reset. Its claim (b), that completion skips notification when the registry entry is gone, is correct, and it is the same check rather than a second one: Xet returns claimed:false with reason task-not-in-registry (logged at warn level).
Expected vs actual
- Expected: when a follow-on run completes, a notification carrying its answer is enqueued; or at minimum, the SendMessage result tells the sender it hit a stopping task, so the sender knows to read the transcript instead of waiting.
- Actual: only one notification can exist per flag cycle. When the follow-on registers before the original run's stop notification goes out, the stop notification uses up the cycle on the old reply, and the answer is never signaled. In this incident the parent then waited indefinitely for a reply that was already on disk; the wait ended as a 2h24m session stall (the wake side of that stall is the companion issue, #88742).
Reproduction
Timing-dependent, not deterministic. Send to an agent in the window between its final API round completing and its stop notification being enqueued; the observed window was 1.5 to 5 seconds wide in this session, and 3 of the 5 sends that landed near a stop produced the losing order. Making the recipient's final round slow widens the window. To confirm a repro: with debug logging enabled, the losing case logs [enqueueAgentNotification] skipped taskId=<id> status=completed taskPresent=true reason=already-notified when the follow-on completes. The incident here ran default logging, where nothing records the skip.
Suggested fixes
- Tie the flag to the run instead of the record. A boolean cannot distinguish "this run was notified" from "some earlier run was notified". A per-registration generation (or comparing the record's tool-use-id with the one the enqueuing caller passes) would let a follow-on's completion notify even after the stale stop notification went out.
- Or remove the race: enqueue the stop notification BEFORE firing the stranded-messages event that starts the follow-on. The follow-on's registration would then always come after, and its completion would always notify.
- Make the SendMessage result distinguish "resumed an idle agent" from "message hit a stopping task" (both currently return "Resuming agent X"), so callers know when to watch the transcript.
References
- #78338 (silent loss of queued messages, duplicated re-delivery at task boundaries, missing completion notifications; closed unanswered): this report pins defect 3's trigger and corrects the "never reset" description.
- #39632 (stream-json idle-wake race; closed not planned): the wake-side neighbor of this bug; its interactive-path counterpart is the companion issue (#88742).
---
The investigation and writeup for this issue were done by Claude Fable 5.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗