[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
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:
- The wording claims a human refusal that never happened. No prompt is shown, no approval request is emitted, and
canUseTool/--permission-prompt-toolis NEVER consulted. A harness that trusts the message tells the user they refused something they were never asked about. - 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
- Start a query() with the SDK (streaming input, a canUseTool handler, permissionMode "acceptEdits").
- From the assistant turn, dispatch a subagent with the Agent tool IN THE BACKGROUND, so it outlives the turn.
- Let the turn end normally (Stop fires).
- Wait for the subagent's completion task-notification to re-enter the session.
- 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_
3 Comments
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:
query.interrupt(), send aninterruptcontrol request, or close the input stream when it sees theresultmessage?), and any PermissionRequest/Stop hooks configured.--debug-file /tmp/claude-debug.txtcovering the notification turn, plusclaude doctoroutput 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
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.
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
AgentThe original report described a backgrounded
Agent/Taskdispatch. I hit the same failure again on 2026-08-17 with aBashcall usingrun_in_background: true— no subagent involved. Same signature: the tool call was denied 45 ms after thetool_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:
status=stoppednotification rather than leaving them orphaned and silent. (6/6 with a query split, against 2/2status=completedcontrols where the query stayed open.)resume-ing the samesession_idin 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 -pfor turn 1, then-p --resumefor turn 2), turn 2 receives asystem/task_notificationevent carrying:which is the wording from the live incident. But the first
tool_useof 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
Bashcell 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:
system/task_notificationevent the headless path emits?signal.abortedchecked before the permission handler is consulted? In the incidents,canUseToolis 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
toolDenialKindsurfaced 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 (
PreToolUsehook onAgent/Taskunlessrun_in_background: false, plusBashwithrun_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.