bug: redundant model turn triggered by task_notification after TaskOutput(block=true) already consumed result

Resolved 💬 5 comments Opened Mar 13, 2026 by MrVPlusOne Closed Apr 16, 2026

Summary

When the model launches a background task (Bash with run_in_background: true), then reads the completed result via TaskOutput(block=true) in the same turn, the CLI still triggers a redundant second model turn from the task_notification event. The model already has the complete result, but responds again with essentially the same information.

Reproduction steps

  1. Start a Claude Code session (SDK mode via --sdk-url)
  2. Ask the model to run a background command and wait for its result:

``
Run "sleep 3 && echo done" in the background, then read its output
``

  1. Observe:
  • Turn 1: Model calls Bash(run_in_background=true), then TaskOutput(task_id=X, block=true). TaskOutput returns the full completed result. Model responds with the output.
  • Turn 2 (redundant): A task_notification triggers a new model turn. The model re-reads or re-summarizes the same completed task, producing a duplicate response.

Expected behavior

If the model already consumed the complete result of a background task via TaskOutput(block=true) during Turn N, the task_notification for that same task should not trigger a new Turn N+1. The notification is redundant — the model already has all the information.

Root cause analysis

This is a microtask vs macrotask race condition in the CLI's background task completion handling.

When a background bash task completes, two things happen concurrently:

  1. The task's .then() callback (runs as a microtask):
  • Updates task.status → "completed"
  • Checks task.notifiedfalse (TaskOutput hasn't polled yet)
  • Sets task.notified = true
  • Pushes a task-notification item to the turn input queue
  1. TaskOutput's polling loop (runs on setTimeout, i.e. macrotask):
  • Polls every 100ms checking task.status
  • On next poll, sees status === "completed" → returns result to model
  • Sets task.notified = true (redundant — already set by step 1)

Because microtasks always execute before macrotasks in the JS event loop, the .then() callback (step 1) always wins the race. It pushes the notification to the turn input queue before TaskOutput can return the result and mark it as consumed.

The result: after the current model turn completes (having already consumed the full result via TaskOutput), the main loop checks the turn input queue, finds the stale task-notification item, and starts a redundant new turn.

Proposed fix

After each model turn completes, sweep the turn input queue and remove task-notification items for tasks that are already marked notified === true in the app state. This is safe because:

  • If notified === true, either TaskOutput already returned the result, or the notification handler already ran — either way, the model has been informed.
  • If notified === false, the notification is genuine and should trigger a new turn (e.g., model launched a background task but never called TaskOutput).

Alternatively, the .then() callback could check whether TaskOutput is currently blocking on the same task (i.e., a pending TaskOutput(block=true) call exists for this task_id), and skip pushing to the turn queue in that case, letting TaskOutput be the sole delivery mechanism.

Impact

  • User confusion: The model appears to "forget" it already processed the result and responds redundantly
  • Wasted tokens/latency: An entire model turn is spent re-processing already-known information
  • Orchestration breakage: In multi-session setups, the redundant turn can cause the model to take unintended actions. For example: Turn 1 asks the user "should I proceed?", then Turn 2 (triggered by stale notification) starts implementing without waiting for the answer, because the notification-triggered turn doesn't carry the conversational context of the pending question.

Evidence

Controlled experiment results:

| Turn | Trigger | Result |
|------|---------|--------|
| Turn 1 | User message | Model launches Bash(run_in_background=true), calls TaskOutput(block=true), receives full completed output, responds |
| Turn 2 | task_notification | Model is triggered again for the same task, re-reads the output file, responds with duplicate information |

The task_notification fires unconditionally regardless of whether TaskOutput(block=true) already returned the complete result in the same turn.

Related issues

  • #19195 — Same race condition pattern (notification after result consumed), manifests as session hang (stop_reason: null)
  • #27052 — Duplicate API requests from notification race during Esc
  • #22702, #22703, #18544 — Feature requests to suppress/batch/disable background task notifications (motivated by the same token waste)
  • #20754 — Inverse bug (notifications lost rather than duplicated), suggesting the notification mechanism has race conditions in both directions

Environment

  • Claude Code CLI v2.1.72 (binary) / v2.1.74 (npm)
  • SDK mode via --sdk-url (WebSocket NDJSON protocol)
  • Likely introduced or became more visible after the Feb 13, 2026 fix for background task notifications in streaming Agent SDK mode

View original on GitHub ↗

This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗