[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)

Open 💬 0 comments Opened Jul 6, 2026 by zhupanov

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:

  1. Premature notification correlated with stdout activity while the process is alive. A wrapper script printed a one-line banner to stdout, then blocked in wait on its worker; a <task-notification> carrying exitCode=0 arrived while the process was verifiably still running (it completed minutes later). The notification's exitCode=0 did not correspond to any process exit. (larch#6508, also larch#5418, larch#6080, larch#6268)
  1. 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)
  1. 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)
  1. 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

  1. 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).
  2. 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).
  3. Give hooks visibility: let a hook observe/suppress a pending task-notification-driven invocation (the gap identified in #39027).
  4. 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=0 while 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.

View original on GitHub ↗