Skip to content

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

Description

@obasilakis

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions