[BUG][SDK] task-notification resumes a stopped turn with an already-aborted AbortController — every later tool_use is cancelled and reported as a user refusal

Status Open
Reported on v2.1.229
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Aug 14, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

When a subagent dispatched with the Agent tool in the BACKGROUND finishes after the parent turn has already stopped, the SDK re-injects its completion as a task-notification into the SAME session. The resumed turn then runs with the AbortController that was already torn down at the previous Stop. Since the CLI checks abortController.signal.aborted before executing any tool_use, every subsequent tool call in that turn is short-circuited and returned as:

"The user doesn't want to take this action right now. STOP what you are doing and
wait for the user to tell you how to proceed."

with toolDenialKind: "cancelled".

Two things make this harmful:

  1. The wording claims a human refusal that never happened. No prompt is shown, no approval request is emitted, and canUseTool / --permission-prompt-tool is NEVER consulted. A harness that trusts the message tells the user they refused something they were never asked about.
  2. It hits native and MCP tools alike, and lasts for the rest of the turn. The agent keeps producing text into the void until the turn ends on its own.

EVIDENCE — from the CLI's own transcript (~/.claude/projects/<project>/<session>.jsonl),
two independent occurrences in one session, timestamps verbatim:
09:04:56.759 type=system subtype=stop_hook_summary <- turn ends (Stop)
09:04:56.770 type=user origin={kind:"task-notification"} <- +11 ms: background Agent
promptSource="sdk" completion RESUMES it
09:05:02.983 type=user toolDenialKind="cancelled" <- next tool_use cancelled
09:05:29.511 type=system subtype=stop_hook_summary
09:05:29.515 type=user origin={kind:"task-notification"} <- +4 ms
09:05:34.940 type=user toolDenialKind="cancelled"

Correlation is 2/2 in this session, and the same pattern recurs across a 4-month history (12 occurrences).

Supporting facts:

  • All three cancelled calls belong to ONE query (durationMs 429779, single query() call).The task-notification re-enters the running query instead of opening a new one.
  • The permission handler is never invoked: our harness logs every canUseTool entry, and there are ZERO entries during the window while the CLI returns the refusal wording.
  • Stop hooks are not involved: preventedContinuation=false, hookErrors=[], hasOutput=false.
  • Credentials are healthy: subagents sharing the same credential run and deliver complete results DURING the poisoned window. Only the parent's own tool calls are cancelled.
  • The refusal string is present in the CLI binary and absent from the SDK JS bundles, so it is emitted by the CLI at the point where it short-circuits on the aborted signal.

What Should Happen?

Either reset the query's AbortController when a session is resumed by a task-notification, or do not abort it at Stop while a background subagent can still resume the turn. The tool should execute, or the permission handler should be consulted.

Separately, and regardless of that fix: a CANCELLATION should not be reported to the model with the wording of a USER REFUSAL. They are different events, and today a harness cannot tell them apart — the only signal is toolDenialKind, which the model never sees. A distinct message for cancelled would let agents react correctly (retry in a fresh turn, surface an infrastructure error) instead of telling the user they refused something they were never asked.

Error Messages/Logs

Steps to Reproduce

  1. Start a query() with the SDK (streaming input, a canUseTool handler, permissionMode "acceptEdits").
  2. From the assistant turn, dispatch a subagent with the Agent tool IN THE BACKGROUND, so it outlives the turn.
  3. Let the turn end normally (Stop fires).
  4. Wait for the subagent's completion task-notification to re-enter the session.
  5. Have the assistant call any tool.

Actual: tool_result is toolDenialKind:"cancelled" with the human-refusal wording, handler never called. Every further tool call in that turn behaves the same. The next user-initiated turn is clean.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

Claude Code CLI 2.1.229

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

Other

Additional Information

_No response_

View original on GitHub ↗

3 Comments

bcherny collaborator · 14 days ago

Thanks for the detailed report. I tried to reproduce this on the released 2.1.233 build (macOS) with a harness driving the CLI over the streaming JSON protocol: streaming input, a permission handler (--permission-prompt-tool stdio), acceptEdits, a Stop hook configured, and a turn that dispatches a background Agent and ends. The background agent's completion notification started a new turn ~30 ms after the previous result, and in that turn the tool call was executed normally: the permission handler was consulted, the tool ran, and the result was returned (no "The user doesn't want to take this action right now" cancellation). I couldn't get the cancelled-tool behavior you describe.

Each turn (including one started by a background-agent notification) gets its own fresh cancellation controller, so we need to find what is aborting the notification turn in your setup. Could you share:

  • A minimal harness script that reproduces it (in particular: does your harness call query.interrupt(), send an interrupt control request, or close the input stream when it sees the result message?), and any PermissionRequest/Stop hooks configured.
  • A log from running with --debug-file /tmp/claude-debug.txt covering the notification turn, plus claude doctor output and whether it still happens on 2.1.233.

Separately, we agree that a programmatic cancellation should be distinguishable from a user refusal. Today the SDK user message already carries tool_result_meta.non_execution_kind: "cancelled" so a harness can tell them apart; making the model-facing wording distinct is something we're considering.

🤖 Generated with Claude Code

github-actions[bot] · 14 days ago

We weren't able to reproduce this. Could you provide steps to trigger the issue — what you ran, what happened, and what you expected? This issue will be closed automatically if there's no activity within 7 days.

CARL-Bouvet · 13 days ago

Investigation update: 56 controlled runs, no reproduction — and one wording match

Following up on my original report. I've spent a full day trying to reproduce this in a controlled harness so you'd have a minimal repro. I could not reproduce it — 56 runs, 0 poisonings — but the negative result is itself informative, and one detail did reproduce exactly. Everything below is measured, not inferred.

1. The vector is broader than Agent

The original report described a backgrounded Agent/Task dispatch. I hit the same failure again on 2026-08-17 with a Bash call using run_in_background: true — no subagent involved. Same signature: the tool call was denied 45 ms after the tool_use, toolDenialKind: "cancelled", wearing the "The user doesn't want to take this action right now" wording, in the turn opened by the user message that carried the completion notification as an attachment. So any background work item appears to be a vector, not subagent dispatch specifically.

2. Confirmed mechanics

Two premises of the original diagnosis are now measured rather than assumed:

  • Background tasks do not survive the query that launched them. They are killed, and the SDK emits a status=stopped notification rather than leaving them orphaned and silent. (6/6 with a query split, against 2/2 status=completed controls where the query stayed open.)
  • That notification crosses a session boundary. Closing query 1 without waiting, then resume-ing the same session_id in query 2, ,delivers the notification into query 2 — reliably, ~5-6 s after it opens.

3. The wording matches — but nothing gets cancelled

Driving the real CLI headlessly (claude -p for turn 1, then -p --resume for turn 2), turn 2 receives a system / task_notification event carrying:

"may have been running when the previous Claude Code process exited"

which is the wording from the live incident. But the first tool_use of turn 2 is unaffected — it succeeds, every time. The notification arrives as a typed system event, and a typed system event does not poison anything.

That is the crux: in the live incidents the notification is fused as an attachment onto a user turn. Neither driveable path produces that shape. It appears to be specific to the CLI's own interactive turn loop.

4. Variables tested (all negative)

| # | Variable | Grid | Result |
|---|---|---|---|
| E1 | Task duration, single query | 14 runs, 5 s / 60 s, Agent + Bash | clean |
| E2 | Query/session boundary (close()resume) | 8 runs, split vs control | clean; premises confirmed |
| E3a | Gap tightness before turn 2's first tool call | 12 runs, gap 0 / 250 / 1000 ms | clean¹ |
| E3c | Real CLI headless path + graceful / SIGTERM exit | 8 runs | clean; wording match |
| E3d | SIGKILL mid-flight + 3 concurrent background jobs | 6 runs | clean² |

All runs on claude-haiku-4-5, 120 s timeout, harness and full artefacts available on request.

¹ Caveat: even at gap 0, the notification still lands 5-7 s after turn 2 opens — the "tight boundary" this experiment targeted was probably never actually achieved. Inconclusive rather than refuting.
² Caveat: SIGKILL at +15 s killed the process mid-flight, but a backgrounded Bash cell exits on its own at ~11 s, so SIGKILL never caught that cell mid-sleep. Partial coverage.

5. What would help

The remaining hypothesis is that the poisoning requires the notification to be reinjected as an attachment on a user turn, which only the interactive CLI turn loop does. Two questions I can't answer from the outside:

  1. Where does the CLI fuse a pending task notification into the next user turn, and does that path differ from the system/task_notification event the headless path emits?
  2. In the resumed turn, is signal.aborted checked before the permission handler is consulted? In the incidents, canUseTool is never entered — the denial is synthesised upstream of it, which is why it wears human-refusal wording that no human produced.

The user-visible bug is arguably that wording, independent of the race: an aborted controller reports itself as a user refusal, which sends every downstream integrator hunting for a permissions problem.
It cost me four months and three wrong root causes. A distinct toolDenialKind surfaced in the message text would have made this a one-day bug.

6. Workaround in place, for anyone hitting this

Deny background dispatch outright in the affected session (PreToolUse hook on Agent/Task unless run_in_background: false, plus Bash with run_in_background: true), and route long work to a separate process instead. Since that hook landed I haven't seen a recurrence.

Happy to run any specific instrumentation you'd like against the harness — including a build with extra logging around the notification reinjection path.