[BUG] Headless: background subagent notification arriving after end_turn is interrupted and reclassifies a completed turn as error_during_execution (2.1.215)

Status Open
Reported on v2.1.215
Maintainer reply None cached
Activity 0 comments · opened Jul 29, 2026

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

  1. From a claude -p invocation, have the model spawn several Agent subagents (backgrounded by default).
  2. 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.)
  3. 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 -p mode (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-only end_turn, which in -p kills 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 -p exits 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.

View original on GitHub ↗