Environment
- Claude Code 2.1.211 (CLI entrypoint), Linux
- Permission mode: auto
- Plain
Agent tool background agents continued via SendMessage; no agent-teams configuration
- This bug was investigated and written up by Claude (Fable 5) in Claude Code; the affected session was its own. The operator reviewed the report before filing. The repro steps were re-run live on the same version with a scripted probe agent; each section below says what reproduced and what did not.
Summary
Updated 2026-07-18: the mechanism was independently re-derived against 2.1.214; corrections from that pass are folded in below and summarized in the comments.
An orchestrator session ran five background agents: four workers and one synthesizer. It sent each finished worker's summary to the synthesizer with SendMessage, then a final DONE message. Every send returned success, but one message never arrived. The synthesizer's last turn also finished without notifying the session. So the session sat idle for 11.3 hours instead of continuing the work, until the operator prodded it by hand.
Reading the installed binary explains the loss: queued messages are taken off the queue before delivery is confirmed, so a message can be destroyed while its sender holds a success result. The missing notification is real in the incident's records but did not recur in staged re-tests, so its trigger is narrower than we can pin from outside; the code path that decides it is named below.
Live re-testing also found a third defect: a turn that Claude Code starts automatically from queued messages can re-answer the message the previous turn just answered. That duplicate processing reproduced both times we staged it.
Two aggravators run through all of this. The message queue lives only in memory, so a restart erases it. And messages delivered mid-turn are never written to the agent's transcript, which makes every failure above hard to see.
Steps to reproduce
- Spawn a background agent whose instructions are "handle each message I send, then end your turn; never use tools". Let its first turn finish.
- Send it a message. The result says the agent "resumed from transcript", and a new turn starts. Have the message ask for a long reply (about 4000 words); that widens the window for the next step.
- While that turn is streaming, send another message. It returns "Message queued for delivery ... at its next tool round". A no-tool agent has no next tool round, so the message waits for the turn boundary. This staging is reliable once the reply is long enough to outlast the sender's round-trip time.
- Let the turn end. The queued message seeds an automatic follow-on turn within tens of milliseconds (33 ms and 35 ms in our two datasets).
- Read the follow-on turn's output. In both of our staged runs it re-answered the PREVIOUS message again, with fresh different text, before answering the queued one. That is the duplicate-processing defect.
- The other two failures come from the incident and are harder to trigger on demand. The message loss is a race: a message dies when its drained batch misses the outgoing API request, which our staged sends happened not to hit. The missing completion notification happened once, overnight, after the parent had been idle for 14 minutes; in staged re-tests the follow-on turn notified fine, both with the parent mid-turn and with the parent idle for about a minute. The incident timeline below is the evidence for both.
Expected behavior
- A queued message is delivered exactly once and in order, or the sender gets an error. A success result must never stand for a destroyed message.
- A message that was already delivered and answered is not delivered or processed again.
- Every background turn sends a completion notification, no matter how the turn was started or whether the parent is idle.
- Queued messages survive a restart, or at least their loss is reported. Resuming a session replays notifications for turns that finished while it was down.
- Messages delivered mid-turn are written to the agent's transcript like any other input.
What the code does
The analysis was first done on the installed 2.1.211 binary (a 262 MB executable with minified JavaScript embedded in it) and independently re-derived on 2.1.214, which confirmed the mechanism except where noted below. Function names are the minified ones and byte offsets point into each binary's live embedded copy; the prose uses the 2.1.211 names, with the 2.1.214 equivalents listed at the end of this section.
SendMessage to a live agent calls nWe(agentId, text, taskRegistry, opts) (offset 242918077). It appends the message to a pendingMessages array on the agent's registry entry. The array is a plain first-in-first-out queue, one per agent. Only two places ever read it, and both empty it destructively through the helper Yeo (defined at 242918312; called only from 248625004 and 252875248).
The first reader is the mid-turn drain, Lxd (248624960). It runs in the attachment step before each API call. It takes everything off the queue and wraps each message as a synthetic attachment, shown to the model as "The coordinator sent a message while you were working: ...". These attachments are not written to the agent's transcript.
The second reader is the boundary reseeder, iuf (252875100). When a turn ends with a non-empty queue, the completion handler wEu (242921950) fires an event and the reseeder answers it. It empties the queue, starts a new turn seeded with the first message (via vye, 246028248), and puts the rest back. If starting the turn throws, it re-enqueues the seed. The 2.1.211 reading had errors of type $5 in this path caught and dropped silently; on 2.1.214 the throw path re-enqueues and surfaces a "Failed to deliver queued message" warning, and only the branch that finds the agent already running returns quietly, preserving the messages.
Notifications work like this. When a turn completes, wEu marks the entry completed. It does not reset the entry's notified flag. It then guards the notify step with a quiet/idle test (Yw(entry) && !pn()). Separately, the enqueue function UHt (242918440 region) refuses any entry whose notified flag is still true. Only two places set the flag back to false: the factory for fresh entries, Nk (240321798), and the explicit unpark path in Y9. The reseeder path is not one of them. On resume, the loader TEUu starts pendingMessages as an empty array; the queue is never saved to disk.
Two further facts from 2.1.214: the notification enqueue is skipped entirely when the task's registry entry is already gone, and an emitted notification is a passive queued command that the parent consumes only when it next runs a turn; no traced code path wakes a long-idle parent to consume it. The follow-on task's context is loaded from the on-disk transcript, which confirms the duplicate-processing defect below.
2.1.214 equivalents: send append h6e (246856839), drain helper rho (~246857040), per-turn drain yPd (~249639738), boundary reseeder tTf (~254953047), follow-on context build V_e (246426909), notification enqueue bRt (~246857200), with the notified flag reset only in the unpark path F8.
The defects
Queued messages can be silently destroyed
Both readers empty the queue first and deliver second. If the drained batch never makes it into an API request, the messages are gone. There is no error, no retry, and nothing on disk. The sender still holds a success result, because the SendMessage handler prints "queued for delivery at its next tool round" for any live agent, unconditionally. A message that arrives during a turn's last API call has no next tool round at all; only the reseeder can pick it up, and the reseeder's seed-then-requeue dance is another chance to lose it. In the incident, the message sent mid-turn was exposed for five minutes of tool calls and never reached the model. The message sent during the final API call survived to the boundary and seeded the next turn. Our staged re-tests did not hit the race; every staged drain landed in a request. The loss stands on the incident evidence plus the destructive-read code path.
The follow-on turn can re-answer the previous message
Both times we staged a boundary-queued message, the reseeder's follow-on turn answered the queued message AND re-answered the message the just-finished turn had already answered, producing fresh, different text for it. The agent's transcript shows only the queued message being injected, and it does show the previous turn's reply. The mechanism is confirmed on 2.1.214: the follow-on task's context is loaded from the on-disk transcript, so the previous turn's final message is present only if it was already flushed when the follow-on started (the follow-on starts a few tens of milliseconds after that message is written). The user-visible effect is duplicate work, a second contradictory answer, and doubled side effects for any agent whose messages trigger actions.
The incident's final turn never notified completion
The incident's follow-on turn ended cleanly at 07:33:19 with its final report, and the parent transcript's notification ledger has nothing for it: six explicitly started or resumed turns before the gap all logged notification enqueues, two after the incident did too, and this turn logged none. The parent had recorded pendingBackgroundAgentCount: 1 at its last turn, so the harness knew the agent was still out. Nothing woke the session for 11.3 hours, and the later session resume replayed nothing either; the session learned the outcome by reading files off disk.
This did not recur in staged re-tests on the same version: our follow-on turns notified fine, with the parent mid-turn and with the parent idle for about a minute (their notifications carry no tool-use id, unlike spawn and resume notifications, which is a useful fingerprint). What distinguished the incident remains open: candidates are the much longer parent idle time (14 minutes, possibly crossing a parking threshold), the incident session running with no daemon present, or another input to the quiet/idle test in the completion handler. The 2.1.214 delivery model sharpens the picture either way: a notification can be skipped outright (registry entry gone), and even an emitted one is only a passive queued command with no idle-wake consumer, so a long-idle parent stays unwoken regardless of which emit-side guard fired. The completion handler's guard, the never-reset notified flag, and the consumption path are the code sites to check.
Queued messages do not survive a process exit
The queue exists only in memory, and the loader starts it empty on resume. If the Claude Code process exits, everything still queued is gone for good, and again nobody is told. Resume also replays no notifications for turns that finished while the session was down. Issue #73118 describes the same exposure for pending user messages.
Mid-turn deliveries are invisible in the subagent transcript
The attachments the mid-turn drain produces reach the model but are never saved. The two messages delivered mid-turn in the incident have zero bytes in the agent's transcript, even though the agent clearly acted on both. The only proof of receipt is the model's own narration. That made the message loss above very hard to find.
Incident timeline (transcript evidence)
The orchestrator sent five messages to the synthesizer, in order: relay 1 through relay 4 (one per finished worker) and a DONE sentinel. In the table, parent [n] and agent [n] are line numbers in the parent session transcript and the agent transcript under ~/.claude/projects/<project>/. All times UTC.
| UTC |
Event |
Evidence |
| 07:03:07 |
Synthesizer agent spawned; its first turn begins |
agent [0] |
| 07:03:38 |
First turn ends; completion notification enqueued, consumed 07:05:28 |
parent queue-ops [102]/[124] |
| 07:08:04 |
Relay 1 sent; result "no active task; resumed from transcript"; a resume turn starts with the message injected as a user entry |
parent [155], agent [12] |
| 07:12:34 |
Relay 2 sent; result "queued for delivery at its next tool round" |
parent [175] |
| 07:17:53 |
Relay 3 sent; same result |
parent [193] |
| 07:18:17.285 |
The resume turn ends; its final text still expects relay 2 to arrive next; completion notification enqueued 07:18:19, consumed 07:18:44 |
agent [74], parent queue-ops [197]/[202] |
| 07:18:17.320 |
Relay 3 injected as a user entry, seeding an automatic follow-on turn; relay 2 is never seen again |
agent [75] |
| 07:19:12 |
Relay 4 sent; "queued" result |
parent [210] |
| 07:19:13 |
DONE sentinel sent; "queued" result |
parent [212] |
| 07:19:23 |
Parent turn ends; its turn_duration entry records pendingBackgroundAgentCount: 1; the parent writes nothing further until resume |
parent [216] |
| ~07:25 |
The follow-on turn narrates receiving relay 4 ("Now draining the next queued message: [worker 4]") and processes it; relay 4's bytes appear nowhere in the transcript |
agent [117]-[159] |
| ~07:30 |
The follow-on turn narrates receiving the DONE sentinel, noting it "arrived after only THREE worker messages" and that the missing worker's relay "was never delivered to me" |
agent [164] |
| 07:33:19 |
The follow-on turn ends cleanly with a deliberate 3-of-4 final report; no completion notification is ever enqueued |
agent [182]; parent queue-op ledger empty here |
| 07:33 to 18:46 |
Nothing; when the parent process exited inside this window is not recorded anywhere |
|
| 18:46:59 |
Operator returns; a transient daemon is born; a fork of the session is created |
ps lstart; /tmp/cc-daemon- mtimes |
| 18:49:18 |
Original session resumed (SessionStart:resume hook fires); no replay of the completed turn's notification |
parent [219] |
| 18:50:00 |
Operator prods the session manually |
parent [221] |
| 18:51:30 |
The session, after its own disk forensics, re-sends relay 2's content; a fresh resume turn runs; the orchestration completes 18:55:53 |
parent [253], queue-ops [304]/[311] |
Tying the timeline to the code: relay 2 sat through five minutes of the resume turn's API calls and was discarded by the unconfirmed drain. Relay 3 arrived during that turn's last API call, so only the reseeder could deliver it; it seeded the follow-on turn 35 ms after the turn ended. No daemon ran during the window (every daemon file on the machine was created at operator return), so the follow-on turn ran inside the original session process. That process was alive at 07:33:19 when the notification should have been sent. ~/.claude/debug/ held one stale months-old symlink, so there are no scheduler logs for the window.
Suggested fixes
Each item stands on its own; they are not steps.
- Message loss: consume the queue transactionally. Take messages off only when their batch is committed to an outgoing API request or a new turn's prompt, and put undelivered ones back at the head after any failure or discard (anchors:
Yeo, Lxd, iuf). A narrower fix, seeding the new turn with all drained messages instead of first-plus-requeue, closes only the boundary race; it would not have saved the message lost here. Either way, make the reseeder's failure paths loud; 2.1.214 already re-enqueues and warns on the throw path, leaving only the already-running branch quiet.
- Duplicate processing: build the reseeded turn's context only after the previous turn's final message is durably in the transcript, and skip seeding any message the transcript already shows delivered (anchors:
iuf, vye).
- Missing notification: notify for every background turn no matter how it started or how long the parent has been idle, and cover delivery as well as emit. Reset the
notified flag when the reseeder starts a turn, as the unpark path does; emit even when the task's registry entry is gone; and wake an idle parent when a notification is enqueued, or replay unconsumed notifications when the session next runs or resumes (anchors: wEu, UHt, Nk, Y9; 2.1.214: bRt, F8).
- Restart loss: save
pendingMessages in the task snapshot and load it on resume (TEUu currently starts it empty). Replay completed-while-away notifications when a session resumes.
- Invisible deliveries: write mid-turn injections into the agent's transcript as
queued_command entries, as the main conversation loop already does (anchor: Lxd).
Related issues
Workarounds
- Keep only one unanswered SendMessage per background agent. Wait for the agent's next notification before sending more.
- Treat a "queued for delivery" result as a hope, not a fact, until the agent's behavior shows the message arrived. Write protocol messages so they are safe to send twice, and expect that an already-answered message may be answered again.
- Do not rely on an automatic follow-on turn to wake the session; watch for the expected output file instead.
Environment
Agenttool background agents continued viaSendMessage; no agent-teams configurationSummary
Updated 2026-07-18: the mechanism was independently re-derived against 2.1.214; corrections from that pass are folded in below and summarized in the comments.
An orchestrator session ran five background agents: four workers and one synthesizer. It sent each finished worker's summary to the synthesizer with SendMessage, then a final DONE message. Every send returned success, but one message never arrived. The synthesizer's last turn also finished without notifying the session. So the session sat idle for 11.3 hours instead of continuing the work, until the operator prodded it by hand.
Reading the installed binary explains the loss: queued messages are taken off the queue before delivery is confirmed, so a message can be destroyed while its sender holds a success result. The missing notification is real in the incident's records but did not recur in staged re-tests, so its trigger is narrower than we can pin from outside; the code path that decides it is named below.
Live re-testing also found a third defect: a turn that Claude Code starts automatically from queued messages can re-answer the message the previous turn just answered. That duplicate processing reproduced both times we staged it.
Two aggravators run through all of this. The message queue lives only in memory, so a restart erases it. And messages delivered mid-turn are never written to the agent's transcript, which makes every failure above hard to see.
Steps to reproduce
Expected behavior
What the code does
The analysis was first done on the installed 2.1.211 binary (a 262 MB executable with minified JavaScript embedded in it) and independently re-derived on 2.1.214, which confirmed the mechanism except where noted below. Function names are the minified ones and byte offsets point into each binary's live embedded copy; the prose uses the 2.1.211 names, with the 2.1.214 equivalents listed at the end of this section.
SendMessage to a live agent calls
nWe(agentId, text, taskRegistry, opts)(offset 242918077). It appends the message to apendingMessagesarray on the agent's registry entry. The array is a plain first-in-first-out queue, one per agent. Only two places ever read it, and both empty it destructively through the helperYeo(defined at 242918312; called only from 248625004 and 252875248).The first reader is the mid-turn drain,
Lxd(248624960). It runs in the attachment step before each API call. It takes everything off the queue and wraps each message as a synthetic attachment, shown to the model as "The coordinator sent a message while you were working: ...". These attachments are not written to the agent's transcript.The second reader is the boundary reseeder,
iuf(252875100). When a turn ends with a non-empty queue, the completion handlerwEu(242921950) fires an event and the reseeder answers it. It empties the queue, starts a new turn seeded with the first message (viavye, 246028248), and puts the rest back. If starting the turn throws, it re-enqueues the seed. The 2.1.211 reading had errors of type$5in this path caught and dropped silently; on 2.1.214 the throw path re-enqueues and surfaces a "Failed to deliver queued message" warning, and only the branch that finds the agent already running returns quietly, preserving the messages.Notifications work like this. When a turn completes,
wEumarks the entry completed. It does not reset the entry'snotifiedflag. It then guards the notify step with a quiet/idle test (Yw(entry) && !pn()). Separately, the enqueue functionUHt(242918440 region) refuses any entry whosenotifiedflag is still true. Only two places set the flag back to false: the factory for fresh entries,Nk(240321798), and the explicit unpark path inY9. The reseeder path is not one of them. On resume, the loaderTEUustartspendingMessagesas an empty array; the queue is never saved to disk.Two further facts from 2.1.214: the notification enqueue is skipped entirely when the task's registry entry is already gone, and an emitted notification is a passive queued command that the parent consumes only when it next runs a turn; no traced code path wakes a long-idle parent to consume it. The follow-on task's context is loaded from the on-disk transcript, which confirms the duplicate-processing defect below.
2.1.214 equivalents: send append
h6e(246856839), drain helperrho(~246857040), per-turn drainyPd(~249639738), boundary reseedertTf(~254953047), follow-on context buildV_e(246426909), notification enqueuebRt(~246857200), with the notified flag reset only in the unpark pathF8.The defects
Queued messages can be silently destroyed
Both readers empty the queue first and deliver second. If the drained batch never makes it into an API request, the messages are gone. There is no error, no retry, and nothing on disk. The sender still holds a success result, because the SendMessage handler prints "queued for delivery at its next tool round" for any live agent, unconditionally. A message that arrives during a turn's last API call has no next tool round at all; only the reseeder can pick it up, and the reseeder's seed-then-requeue dance is another chance to lose it. In the incident, the message sent mid-turn was exposed for five minutes of tool calls and never reached the model. The message sent during the final API call survived to the boundary and seeded the next turn. Our staged re-tests did not hit the race; every staged drain landed in a request. The loss stands on the incident evidence plus the destructive-read code path.
The follow-on turn can re-answer the previous message
Both times we staged a boundary-queued message, the reseeder's follow-on turn answered the queued message AND re-answered the message the just-finished turn had already answered, producing fresh, different text for it. The agent's transcript shows only the queued message being injected, and it does show the previous turn's reply. The mechanism is confirmed on 2.1.214: the follow-on task's context is loaded from the on-disk transcript, so the previous turn's final message is present only if it was already flushed when the follow-on started (the follow-on starts a few tens of milliseconds after that message is written). The user-visible effect is duplicate work, a second contradictory answer, and doubled side effects for any agent whose messages trigger actions.
The incident's final turn never notified completion
The incident's follow-on turn ended cleanly at 07:33:19 with its final report, and the parent transcript's notification ledger has nothing for it: six explicitly started or resumed turns before the gap all logged notification enqueues, two after the incident did too, and this turn logged none. The parent had recorded
pendingBackgroundAgentCount: 1at its last turn, so the harness knew the agent was still out. Nothing woke the session for 11.3 hours, and the later session resume replayed nothing either; the session learned the outcome by reading files off disk.This did not recur in staged re-tests on the same version: our follow-on turns notified fine, with the parent mid-turn and with the parent idle for about a minute (their notifications carry no tool-use id, unlike spawn and resume notifications, which is a useful fingerprint). What distinguished the incident remains open: candidates are the much longer parent idle time (14 minutes, possibly crossing a parking threshold), the incident session running with no daemon present, or another input to the quiet/idle test in the completion handler. The 2.1.214 delivery model sharpens the picture either way: a notification can be skipped outright (registry entry gone), and even an emitted one is only a passive queued command with no idle-wake consumer, so a long-idle parent stays unwoken regardless of which emit-side guard fired. The completion handler's guard, the never-reset
notifiedflag, and the consumption path are the code sites to check.Queued messages do not survive a process exit
The queue exists only in memory, and the loader starts it empty on resume. If the Claude Code process exits, everything still queued is gone for good, and again nobody is told. Resume also replays no notifications for turns that finished while the session was down. Issue #73118 describes the same exposure for pending user messages.
Mid-turn deliveries are invisible in the subagent transcript
The attachments the mid-turn drain produces reach the model but are never saved. The two messages delivered mid-turn in the incident have zero bytes in the agent's transcript, even though the agent clearly acted on both. The only proof of receipt is the model's own narration. That made the message loss above very hard to find.
Incident timeline (transcript evidence)
The orchestrator sent five messages to the synthesizer, in order: relay 1 through relay 4 (one per finished worker) and a DONE sentinel. In the table,
parent [n]andagent [n]are line numbers in the parent session transcript and the agent transcript under~/.claude/projects/<project>/. All times UTC.Tying the timeline to the code: relay 2 sat through five minutes of the resume turn's API calls and was discarded by the unconfirmed drain. Relay 3 arrived during that turn's last API call, so only the reseeder could deliver it; it seeded the follow-on turn 35 ms after the turn ended. No daemon ran during the window (every daemon file on the machine was created at operator return), so the follow-on turn ran inside the original session process. That process was alive at 07:33:19 when the notification should have been sent.
~/.claude/debug/held one stale months-old symlink, so there are no scheduler logs for the window.Suggested fixes
Each item stands on its own; they are not steps.
Yeo,Lxd,iuf). A narrower fix, seeding the new turn with all drained messages instead of first-plus-requeue, closes only the boundary race; it would not have saved the message lost here. Either way, make the reseeder's failure paths loud; 2.1.214 already re-enqueues and warns on the throw path, leaving only the already-running branch quiet.iuf,vye).notifiedflag when the reseeder starts a turn, as the unpark path does; emit even when the task's registry entry is gone; and wake an idle parent when a notification is enqueued, or replay unconsumed notifications when the session next runs or resumes (anchors:wEu,UHt,Nk,Y9; 2.1.214:bRt,F8).pendingMessagesin the task snapshot and load it on resume (TEUucurrently starts it empty). Replay completed-while-away notifications when a session resumes.queued_commandentries, as the main conversation loop already does (anchor:Lxd).Related issues
Workarounds