bug: redundant model turn triggered by task_notification after TaskOutput(block=true) already consumed result
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
- Start a Claude Code session (SDK mode via
--sdk-url) - 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
- Observe:
- Turn 1: Model calls
Bash(run_in_background=true), thenTaskOutput(task_id=X, block=true). TaskOutput returns the full completed result. Model responds with the output. - Turn 2 (redundant): A
task_notificationtriggers 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:
- The task's
.then()callback (runs as a microtask):
- Updates
task.status → "completed" - Checks
task.notified→false(TaskOutput hasn't polled yet) - Sets
task.notified = true - Pushes a
task-notificationitem to the turn input queue
TaskOutput's polling loop (runs onsetTimeout, 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, eitherTaskOutputalready 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 calledTaskOutput).
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
5 Comments
Found 2 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Can confirm, this issue prevents using subagents in combination with structured output for me as the task notifications come in after structured output has been written, causing a new turn with new output, which means claude thinks no structured output was provided and fails.
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
This bug is still reproducible on v2.1.119 (macOS). Simple repro:
The model summarizes all 5 results correctly via
TaskOutput(block:true), then 5 staletask-notificationitems trigger a redundant turn repeating the same information. The issue scales linearly — N background tasks = N redundant notifications.Traced through the source: the
notifiedflag inenqueueShellNotification(LocalShellTask.tsx:109-122) only prevents duplicate enqueue, but cannot remove notifications already sitting in the command queue.TaskOutputmarkingnotified=true(TaskOutputTool.tsx:272-275) always loses the race sinceenqueueShellNotificationruns synchronously right afterupdateTaskState(LocalShellTask.tsx:226-243), before the 100ms poll interval can fire.Filed #52786 independently before finding this issue — closed it as duplicate.
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.