A missed task-notification wake stalls an interactive session indefinitely
Environment
- Claude Code 2.1.238 (instance 1) and 2.1.237 (instance 2), Linux
- Interactive terminal sessions under tmux, NOT stream-json/headless: both session JSONLs carry the interactive-only entry types (mode, permission-mode, ai-title, last-prompt, atis-latch), and an operator was attending them
- Background work via
run_in_backgroundBash tasks and Agent-tool subagents - All timestamps UTC, from the session JSONLs under ~/.claude/projects/<project>/
- The code analysis below is from the embedded minified JavaScript in the released 2.1.238 Linux binary, re-verified separately on 2.1.237. 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, and the headless recheck is still unrestricted. Offsets in this report are the 2.1.238 ones.
Summary
What a user sees: a session that ended its turn to wait for background work never wakes up when that work completes. The session looks idle and healthy, nothing is running, and nothing ever happens until someone types into the terminal.
The mechanics of a miss: the background task finishes while the session is idle, and its completion notification is enqueued (the queue-operation/enqueue line is written to the session JSONL, so the process was alive and the enqueue succeeded). Normally the session wakes within milliseconds and processes it. Occasionally it never wakes. In instance 1 below, eight idle wakes in a row worked (3 to 13 ms each, one of them after 39 idle minutes) and the ninth never fired: the notification sat, deliverable the whole time, for 2 hours 24 minutes until terminal input started a turn, and it then processed normally within 15 ms. Only the wake was lost.
The binary explains why one miss is permanent. On the interactive path, the queue is drained by a single React effect. The effect re-runs when one of its dependencies changes, and it starts the drain only if three conditions all pass: no query in progress, no local JSX UI open, queue non-empty. If one of those conditions happens to be true at the moment the enqueue re-runs the effect, that pass does nothing, and nothing ever checks again. The only periodic recheck in the interactive build is restricted to notifications addressed to subagents; the notifications at issue here (background Bash completions and the session's own agent completions) are all addressed to the main session, so they get no retry. The headless build arms the same 60-second recheck without that restriction. Which condition was true at the failing moment cannot be recovered from the logs (debug logging was off); the no-retry structure is verified from the binary.
#39632 reported this wake race for stream-json mode and argued the interactive mode was immune because a queue change re-renders the UI. On 2.1.237/238 the interactive mode fails the same way, and the immunity argument does not hold: a queue change causes a re-render, but the drain only happens when the effect's conditions pass, and nothing retries a pass that was blocked.
Instance 1 (2.1.238)
Baseline from the same session: 8 consecutive notifications enqueued while the session was idle, every one dequeued within milliseconds (enqueue -> dequeue): 00:16:43.172 -> +13ms; 00:32:31.377 (x2) -> +8ms; 00:39:31.744 -> +8ms; 00:40:06.262 -> +3ms; 00:44:05.874 -> +4ms; 01:23:47.940 (x2, after 39 idle minutes) -> +13ms; 01:25:39.343 -> +9ms; 01:33:10.031 -> +10ms.
The failure:
- 01:35:11.583 the session's turn ends. Its final text said it was waiting on a subagent reply, with a file-age watchdog running as a background Bash task.
- 01:37:46.612 the watchdog exits 0 after correctly detecting the condition it watched for; its completion notification is enqueued (
queue-operationentry in the JSONL). - Nothing follows. No dequeue, no turn, no JSONL write of any kind, for 2 hours 23 minutes 36 seconds.
- 04:01:22.626 the queue drains (dequeue entry). No user message precedes the dequeue in the transcript, so the trigger was not recorded; operator terminal input is the plausible cause. The notification then delivers at 04:01:22.641 and is processed correctly and immediately.
Why this matters: the session was coordinating subagents and had started the watchdog precisely to recover from a lost subagent reply (that loss is the companion issue, #88741). The watchdog worked. Its completion notification depends on the same wake mechanism. The wake failed, and no recovery path remained. Any workflow that ends its turn to wait for background work inherits this failure mode: an indefinite, silent stall with nothing running.
Instance 2 (2.1.237, a different session the same day)
- 20:26:55.499 turn ends; session idle.
- 20:31:19.524 a background Bash task's completion notification is enqueued; 20:31:25.278 an agent completion notification is enqueued. No wake.
- 20:37:07.153 BOTH dequeue at the identical millisecond, with no enqueue after them and no user message before them; both then process normally.
The identical timestamps with no re-enqueue match the drain function's behavior of dequeueing all main-addressed items in one batch (code below): one effect pass finally ran, 5.7 minutes late, and took both items at once. What re-ran the effect is not recorded. That session's own timer-driven prompt injections do write enqueue lines when they fire, and none appears here, so a timer prompt is ruled out.
Why it happens (2.1.238 code)
- The enqueue fires its wake signal before writing the log line. In the queue store (
d6a, 309242193), both enqueue methods push the item, then update the published snapshot and emit the change event, then write the JSONL line:function c(){o=Object.freeze([...n]),i.emit()}(the full enqueue method is under the fold below). So an enqueue line in the JSONL proves the change event fired and no listener threw. For these incidents that rules out an exception during the emit, a lost debounce, and the completion-ordering gap #39632 found in stream-json mode: the wake signal provably fired.
<details><summary>The full enqueue method Z, showing push, then c() emit, then the JSONL write</summary>
function Z(we){if(!G(we))return!1;return n.push({...lhf(we),priority:we.priority??"next",timestamp:we.timestamp??new Date().toISOString()}),c(),r("enqueue",typeof we.value==="string"?we.value:void 0),!0}
</details>
- The subscription that should react:
fye(322237160) isfunction fye(){let e=Fv();return EFg.useSyncExternalStore(e.subscribe,e.getCommandQueueSnapshot)}. (The identifiersubscribeToCommandQueuefrom #39632 no longer exists in the binary; this is its descendant.) - The drain and its three conditions.
s6g(322749168) dequeues one bash/local item, or every matching main-addressed item in one batch:let o=e.dequeueAllMatching((i)=>tk(i)&&!i6g(i)&&i.passive!==!0&&i.mode===n)(tkmeans "addressed to the main session"; the batch explains instance 2's identical timestamps).s6ghas exactly two references in the binary: its definition and one call, inside the effect ofl6g(322749661):IAs.useEffect(()=>{if(n||r.isActive)return;if(t)return;if(i.length===0)return;s6g({queue:o,executeInput:e})},[o,i,n,e,t,r]). Herenis a snapshot of the query guard (is a query running),r.isActivereads the same guard live,tis "a local JSX UI is open", andiis the queue snapshot. A queue change re-renders and runs this effect once; if any condition blocks that pass, nothing happens until some dependency changes again. Note thatr.isActiveis read live and is not a dependency: it can go back to false later without the effect re-running. - Confirmed: no retry for main-addressed notifications. The only periodic recheck on the interactive path is in
u6g(322750366):let a=n.some((d)=>d.agentId&&!tk(d)&&d.mode==="task-notification")?t.setTimeout(()=>{r.recheckCommandQueue()},qQA):void 0, withqQA=q3c=60000(322751559). The!tk(d)term restricts the 60-second recheck to subagent-addressed notifications (on 2.1.239 the same gate reads!uk(d)). Both producers of the notifications at issue address them to the main session: the agent-completion builder passes the owner's id, and the background Bash builder (c_l, 314437699) passesagentId:a??xi(). A second recheck insideu6g's dispatch cleanup only runs after a subagent-addressed notification was dispatched, so it does not help either. For completeness: every interactiverecheckCommandQueuecaller in the binary is insideu6g, and the only other dequeue helper (tNg, 322135573) belongs to the headless path. - The headless build retries; the interactive build does not. The headless equivalent (
uw, carved at 327246830) arms the same recheck with no restriction:if(clearTimeout(CF),tt.some((fr)=>fr.agentId&&fr.mode==="task-notification"))CF=setTimeout((fr)=>fr.recheckCommandQueue(),60000,v),CF.unref?.(). Headless bounds a missed pass at 60 seconds; interactive turns the same safety net off for exactly the notifications that wake an idle session. Same codebase, two behaviors: the strongest sign the restriction is a bug rather than a design choice. - Which condition blocked the failing pass is unproven. The logs cannot show it (debug logging was off). Candidates whose structure is verified: (a) the query guard class (
QBc, 322098849) can be left stuck in a non-idle state, becausecancelReservation()only works while status is exactly "dispatching",end()only while status is "running" with a matching generation, and nothing ever times the state out; (b)tviaUf=wa?.isLocalJSXCommand===!0&&wa?.jsx!=null(326447695), a local JSX command left open; (c) a torn-down subscription or the one-time store swap (fhf, 309251959), though the eight earlier wakes in the same session argue against the swap. Whichever it was, the confirmed no-retry structure turns one blocked pass into an unbounded stall. - What can wake it afterwards: only a dependency change. A turn starting or ending flips the query-guard snapshot; Escape/cancel calls the guard's
forceEnd()(handler at 322113563); another enqueue changes the queue snapshot; a JSX command opening or closing flipst. Plain keypresses into the prompt, terminal resizes, and focus changes appear in none of the dependency sources (inferred from the dependency lists; not an exhaustive search of event wiring). This matches both recoveries: terminal input at 04:01:22, and an unrecorded dependency change at 20:37:07. - 2.1.237 behaves identically: the effect (
vOg) has the same three conditions and the same dependency list, the recheck gate (wOg) reads!lT(d)for!tk(d), the interval constant isbNc=60000, and the store internals match (o=Object.freeze([...n]),i.emit()at 304491611). Both instances are explained by the same code.
On #39632's specific claims, for the interactive path of 2.1.238: (a) "the REPL is immune because any queue change re-renders": wrong, a re-render is not a drain; the drain is a guarded effect with no retry. (b) "async gaps between task completion and enqueue cause the loss": not on this path (see the enqueue-store bullet above: the emit is synchronous, before the JSONL write, and the JSONLs prove it fired). (c) "only priority now wakes": wrong here; priority is never consulted on this path: the priority map is {now:0,next:1,later:2} (309252922), the peek helper (f2c, 322135468) returns "next" and "later" items alike, neither l6g nor s6g reads priority, and all seven task-notification producers in the binary pass priority:"next".
Expected vs actual
- Expected: an enqueued task notification wakes an idle session (it demonstrably does most of the time); or, failing that, a periodic recheck bounds the delay, as the headless build's 60-second recheck already does.
- Actual: one guarded effect pass is the only chance. Main-addressed notifications are excluded from the only periodic recheck. A blocked pass strands the session until unrelated input arrives: 2h23m36s in instance 1, 5.7 minutes in instance 2, with the queued item deliverable the entire time.
Reproduction
Intermittent; no deterministic recipe, because the trigger is a transient condition at the moment of enqueue. Both instances come from long-running interactive sessions with frequent background-task completions; instance 1's observed rate was 1 missed wake in 9 idle enqueues. A targeted reproduction would hold one condition true across an idle enqueue (for example, leave the query guard stuck non-idle after a cancelled dispatch, per the QBc detail above) and observe that no drain ever follows. One log line in l6g's effect recording which condition blocked, plus debug logging, would settle the open question on the next real occurrence.
Suggested fixes
- Remove the
!tk(d)restriction inu6g, matching the headless build. A 60-second recheck while ANY task notification is queued turns an unbounded stall into at most a one-minute delay. This is a one-term change to a gate that already exists. - Separately, audit the query guard's cleanup (
QBc): its release methods only work in narrow status windows and nothing times the state out, so a stuck non-idle guard silently disables the drain while the session looks idle. - Longer term: when the drain effect returns early while the queue is non-empty, schedule a re-check (next tick or a short timer), so correctness does not depend on an unrelated dependency change happening to occur.
References
- #39632 (stream-json wake race; closed not planned): same failure class; its interactive-immunity argument does not hold on 2.1.237/238, and on this path the mechanism is the guarded effect with no retry described above.
- #78338 defect 3 (missing completion notifications, no wake for idle parents; closed unanswered): the sender-side counterpart, refiled with its trigger pinned in the companion issue (#88741).
---
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 ↗