[BUG] Task-notifications for background Bash fire before process exit and re-fire on every turn-end; no opt-out; hooks cannot intercept (autonomous token-burn loops)
Summary
For Bash tool calls launched with run_in_background: true, <task-notification> re-invocations fire before process exit and repeat on every assistant turn-end while the task is still running. The behavior is undocumented, there is no way to suppress or defer these notifications, and hook events cannot observe or block the notification-driven model invocations. In autonomous sessions each spurious notification triggers a full model turn with no user present, producing unbounded token-burning loops (we have recorded 15+ and ~40-turn loops).
We maintain a public autonomous-orchestration plugin (character-ai/larch) that launches long-running steps (10 minutes to 6 hours) via run_in_background: true and relies on the documented contract — "re-invokes you when it exits." Over the last three weeks we have shipped ~20 mitigation PRs against this behavior and it still recurs daily. This report consolidates the evidence.
Environment
- Claude Code 2.1.201, macOS (darwin 23.x)
- Incidents observed continuously 2026-06-13 → 2026-07-06 across auto-updated client versions (per-incident client versions were not recorded; we are starting to log them)
Expected behavior
Exactly one <task-notification> per background task, delivered when the process exits.
Observed behaviors
Each of these is backed by a public bug report with session-transcript evidence in our repo:
- Premature notification correlated with stdout activity while the process is alive. A wrapper script printed a one-line banner to stdout, then blocked in
waiton its worker; a<task-notification>carryingexitCode=0arrived while the process was verifiably still running (it completed minutes later). The notification'sexitCode=0did not correspond to any process exit. (larch#6508, also larch#5418, larch#6080, larch#6268)
- Notifications on child-subprocess exits under
set -m. With job control enabled in the backgrounded script, a notification fired at each child exit, long before the script itself finished. (larch#5240, larch#5510, larch#5511)
- Notification storms at every assistant turn-end with zero new output. While a background task ran silently (its output file unchanged — one blank line — the whole time), 8+ notifications arrived in rapid succession, one per assistant turn-end: the agent ends its turn → another notification is delivered → new turn → repeat. (larch#6478)
- Re-delivery of the same premature notification on every subsequent turn until true completion — one session looped 15+ consecutive turns on an identical stale notification. (larch#6508, larch#6493, larch#6309, larch#6448)
The observed trigger has not been stable across the three weeks (stdout-correlated in some incidents, turn-end-correlated with no output in others), which is consistent with the behavior being undocumented and changing across client releases.
Minimal shape matching our transcripts (behavior 1 + 4):
Bash(run_in_background: true): bash -c 'echo "banner"; sleep 1200; echo DONE'
→ a notification with exitCode=0 arrives shortly after the banner while sleep still runs; after the agent ends that turn, the notification is re-delivered on each subsequent turn-end until the real exit.
Impact
Each notification is a full autonomous model invocation. In headless/autonomous sessions there is no user to interrupt, so a single premature notification amplifies into a re-delivery loop bounded only by task duration (up to hours). This is the same failure class as #39027 (closed not-planned 2026-06-16); the behavior persists in 2.1.201.
Why no workaround exists on our side
We built four successive defense layers; none can fully contain it, because the primitive is uninterceptable:
- Prompt contracts ("on a premature notification, end the turn silently") — probabilistic, and even perfect compliance still costs one model invocation per notification.
- PreToolUse guards denying progress-probe tool calls during waits — can deny tools inside the turn, but cannot prevent the turn.
- Stop / UserPromptSubmit circuit breaker — task-notifications do not flow through
UserPromptSubmit, and no hook event fires for a notification-driven invocation (consistent with #39027), so a breaker can only inject guidance into turns that already happened, never block the next one. - Zero-stdout wrapper discipline — reduced frequency, but storms were still observed with no new output (behavior 3).
The mitigation stack now totals ~2,200 lines of hook shell, ~2,300 lines of hook tests, and extensive prompt rules — and incidents still recur (four separate ones on 2026-07-05/06 alone).
Asks
- Document the actual
<task-notification>semantics: exactly when notifications fire and re-fire for background Bash tasks — including that a foreground Bash call which exceeds its timeout is silently converted to a background task and thereby enters the same notification machinery (per #11716/#61568 this conversion is also undocumented). - Fire only on process exit, or provide a per-call/setting opt-out, e.g.
notify: "on-exit" | "never"(re-raising #18544, closed not-planned). - Give hooks visibility: let a hook observe/suppress a pending task-notification-driven invocation (the gap identified in #39027).
- Make the payload truthful: include real process state (running/exited, actual exit code) so agents and hooks can at least classify notifications reliably — today premature notifications carry
exitCode=0while the process is alive.
Related issues
#39027 (autonomous API calls from task notifications — closed not-planned; behavior persists), #18544 (opt-out request — closed not-planned), #11716 (background Bash → infinite system-reminders / token exhaustion), #61568 (background Bash runs indefinitely without visibility), #20525, #14049, #21048.
Full session transcripts for every linked incident are committed in the public repo (larch-logs/) and we are happy to provide condensed repro traces.