[BUG] Headless: background subagent notification arriving after end_turn is interrupted and reclassifies a completed turn as error_during_execution (2.1.215)
Summary
In headless (claude -p) mode, when a background subagent completes after the parent turn has already ended with stop_reason=end_turn, the harness injects a <task-notification> user message. That opens a follow-on turn which is immediately interrupted ([Request interrupted by user]), leaving a non-terminal user message as the session's last record. The terminal-state check then fails and the CLI emits result: error_during_execution, is_error: true, stop_reason: null.
Net effect: a turn that completed successfully — full assistant text, stop_reason=end_turn — is reported to the caller as a hard error, and the completed answer is discarded by any harness that trusts is_error.
Compounding it, the only entry in errors[] is the CLI's own internal [ede_diagnostic] marker, which the CLI filters out of its user-facing output — so a caller reading errors[0] gets a diagnostic header instead of a cause, and there is no cause text anywhere.
Environment
- Claude Code: 2.1.215
- Invocation:
claude --print --output-format stream-json --verbose(headless, no stdin left open) - Model: claude-sonnet-4-6
- Host: Linux container
- Frequency: 4 occurrences in ~30 runs of one scheduled task (~13%); intermittent, timing-dependent
Reproduction
- From a
claude -pinvocation, have the model spawn severalAgentsubagents (backgrounded by default). - Have it end its turn while at least one subagent is still in flight — a text-only reply, which the CLI records as
stop_reason=end_turn. (In our case the model explicitly said it was "waiting for" the two outstanding arms.) - Let one of those subagents finish a few seconds later.
The completion notification arrives after the turn has closed; the session ends in error.
Actual behaviour
Session transcript, final four records (times relative to the last one):
-1s assistant stop_reason=end_turn "<complete final answer> … waiting for <two outstanding arms>."
-1s user <task-notification> <task-id>…</task-id> <tool-use-id>toolu_…</tool-use-id>
<output-file>~/.tmp/claude-…/tasks/….output</output-file>
0s user text: [Request interrupted by user]
Result event:
{ "type": "result", "subtype": "error_during_execution", "is_error": true,
"stop_reason": null,
"errors": ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"] }
Note there is no user in headless mode to issue that interrupt.
The relevant guard in the shipped bundle — the last message is the injected/interrupted user message, whose content is not all tool_result, and stop_reason is null, so it returns false:
function wao(e, t = null) {
if (!e) return false;
if (e.type === "assistant") { let r = Kq(e.message.content);
return r?.type === "text" || r?.type === "thinking" || r?.type === "redacted_thinking"; }
if (e.type === "user") { let r = e.message.content;
if (Array.isArray(r) && r.length > 0 && r.every(n => "type" in n && n.type === "tool_result")) return true; }
return t === "end_turn";
}
// if (!wao(bn, tt)) { yield { type:"result", subtype:"error_during_execution", is_error:!0, … } }
And the errors array is built marker-first:
errors: (() => { let Lt = lvt(), sn = ls ? Lt.lastIndexOf(ls) + 1 : 0;
return [`[ede_diagnostic] result_type=${pi} last_content_type=${Er} stop_reason=${tt}`,
...Lt.slice(sn).map((Lr) => Lr.error)] })()
while the CLI's own consumer discards exactly those entries:
let t = e.errors.filter((r) => !r.startsWith("[ede_diagnostic]"));
if (t.length === 0) return null;
Expected behaviour
A turn that reached stop_reason=end_turn with a complete assistant message should not be reported as error_during_execution because a background notification arrived afterwards and its follow-on turn could not run. Either:
- don't open a new turn for a background notification once the parent turn has terminated in
-pmode (or drain notifications before finalizing), or - finalize on the last valid terminal message rather than the trailing interrupted one.
Separately: when the run genuinely is an error, errors[] should carry a cause. Today errors[0] is always the [ede_diagnostic] marker the CLI itself filters, so SDK/harness callers surfacing errors[0] show users a meaningless string.
Contrast with successful runs
Five control transcripts from the same task: runs that spawned more subagents (21 and 24 Agent calls) completed fine — because the model waited for every arm's tool_result before ending its turn. Zero <task-notification> records, zero interrupts, all ending assistant / end_turn. The single failing transcript is the only one with a notification, and it lands after end_turn. It is not about background subagents per se — only about a notification arriving once the turn has closed.
Related
- #81468 / #81476 —
Monitor's return message advises a text-onlyend_turn, which in-pkills the watched background shell. Same family (headless +end_turn+ outstanding background work); there the task is killed, here a completed session is reclassified as an error. - #74761 —
claude -pexits 0 "success" while the agent loop is mid-task (opposite symptom: under-reporting). - #78151 — interrupting a turn silently kills in-flight background subagents.
- #78046 — auto-update daemon reaps in-flight headless background tasks.