[BUG] Workflow agent failures reach no machine-readable surface: partial-death runs report status:completed, result carries no census, and agent() returns a bare null with no cause

Status Open
Reported on v2.1.246
Maintainer reply None cached
Activity 0 comments · opened Aug 26, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

Everything the Workflow runtime knows about a dead agent lives in human-readable sidebands — the <failures> block, the logs array, and the <usage> counters. None of it reaches any machine-readable surface, so neither the caller reading the tool result nor the workflow script itself can detect that agents died.

Three surfaces are affected. They are one defect — the failure information exists and is withheld — so I'm filing them together rather than as three reports:

1. The headline <status> reads completed whenever at least one agent survived. A 5-agent fan-out with agents_done: 1 / agents_error: 4 reports completed, and <summary> reads Dynamic workflow "…" completed. The only contrary signal is agents_error inside <usage> — the block a reader skims last.

2. The <result> object carries nothing about the deaths. It is whatever the script returned, and the script does not know either (see 3). A stage that lost half its fan-out returns an aggregate that is well-formed and internally consistent, with no field that can express "input item X was never processed". Nothing beside it in the result envelope contradicts that reading.

3. agent() resolves a dead agent to a bare null with no cause. The injected dialect (args, agent, parallel, pipeline, workflow, phase, log, budget) gives a script no channel to the failure reason the runtime is simultaneously printing into <failures>. So a script cannot:

  • report the deaths it suffered (it cannot see them as anything but a missing slot), nor
  • classify a deterministic, human-action-required failure apart from a transient one.

That last point is the expensive one, and it is not theoretical. Login expired · Please run /login is the failure string in every occurrence below, and it behaves as both classes depending on conditions only the runtime can see:

  • In one run, an agent died on it, an in-script one-shot retry re-dispatched, and the retry died on the identical string — ~23 minutes wall clock and ~257k subagent tokens spent to reach a deterministic error. Retrying was pure waste.
  • In another run the same day, one agent of three died on the identical string while its sibling in the same fan-out completed normally, the parent session stayed authenticated throughout, and a resumeFromRunId minutes later re-ran the dead agent successfully with no re-authentication. Not retrying cost a ~38-minute round trip.

Identical message, opposite correct responses. A script cannot get this right with an allowlist of failure strings even if it had them — it needs the runtime's own terminal-vs-transient judgment, because only the runtime knows whether the credential is actually dead.

Credit where it's due — the all-dead case has improved. On 2026-08-25 a run whose every agent died reported status: failed with an explicit, well-written error naming the recovery path, plus agents_done: 0 / agents_error: 2. That is exactly the discrimination this report asks for, and it held. What remains is the partial-death case (1), which still reports completed, and surfaces (2) and (3), which are unchanged in both cases.

What Should Happen?

  1. The headline should reflect the fan-out's outcome, not just the script's. A run with agents_error > 0 needs a signal at the <status>/<summary> altitude — a distinct status, or a one-line degradation notice — rather than a counter buried in <usage>.
  2. A machine-readable failure census in the result envelope, beside result rather than only in prose. Something like { planned, completed, failed, failures: [{ label, reason }] }. A caller that reads result alone should not be able to miss that a third of the run never happened.
  3. agent() should hand the script its own failure. Either resolve to a discriminable shape ({ ok: false, reason, terminal: boolean }) behind an opt-in option, or expose a companion accessor — anything that lets a script (a) report which of its inputs have no agent output backing them, and (b) decide whether re-dispatching is worth spending. The terminal bit in particular cannot be derived downstream: the two occurrences above show the same message meaning opposite things.

Backward compatibility is straightforward for (3): keeping null as the default and putting the detail behind an option leaves every existing script byte-identical.

Error Messages/Logs

Run A — partial death, hand-authored 5-agent fan-out (4 parallel lenses + 1 synthesizer), 2026-08-25:

<status>completed</status>
<summary>Dynamic workflow "…" completed</summary>
<result>{"synthesis":null,"lensCount":1}</result>
<usage><agent_count>5</agent_count><agents_done>1</agents_done><agents_error>4</agents_error>…</usage>

logs:
  [lens-1] failed: Login expired · Please run /login
  [lens-2] failed: Login expired · Please run /login
  [lens-3] failed: Login expired · Please run /login
  [synthesize] failed: Login expired · Please run /login

status, summary and a non-throwing result all read as success. The script happened to return a lensCount of its own, which is the only reason the degradation was legible at all; a script returning {synthesis} alone would have handed back null under status: completed, indistinguishable from "the synthesizer had nothing to say".

Second-order cost specific to this run: three of the dead agents had already written scratch files into the working tree before dying. Because the run reported completed, nothing prompted a reconcile, and the residue was found only incidentally. Our own recovery doctrine for a dead side-effecting agent is reached by a reader who believes an agent died — and status: completed is precisely the signal telling them nobody did.

Run B — partial death, 3-agent stage (1 classifier + 2 analyzers), 2026-08-25:

agent_count: 3, agents_done: 2, agents_error: 1, duration_ms: 2307689
failures: [analyze:item-2] failed: Login expired · Please run /login

The surviving analyzer completed normally and the parent session stayed authenticated (it kept issuing tool calls before and after; a resumeFromRunId minutes later re-ran the dead agent successfully with no re-auth). The stage returned a fully well-formed aggregate covering one of the two input items, with an empty "needs human" list. Every field internally consistent; nothing in result able to express that item 2 was never analyzed.

Run C — all dead, 2026-08-25 (recorded as the case that now behaves correctly):

status: failed  (with an explicit error naming the recovery path)
agents_done: 0 | agents_error: 2 | subagent_tokens: 257,589 | duration_ms: 1,368,885
  [stage]       failed: Login expired · Please run /login
  [stage:retry] failed: Login expired · Please run /login

The headline here is right. The waste is the retry, which the script had no basis to skip.

Run D — the original observation, 2026-07-28, on an earlier version (recorded for history; the headline half of this one appears fixed):

status: completed
result: {"results":[]}
agents_done: 0, agents_error: 2
  [agent-1] failed: Login expired · Please run /login
  [agent-2] failed: Login expired · Please run /login

Steps to Reproduce

The failure cause is incidental — we have observed the same three surfaces with Login expired · Please run /login, API Error: Connection closed mid-response, and usage-limit walls. What reproduces is the reporting, on any agent death:

  1. Save a workflow script that fans out and returns an aggregate whose shape cannot express a missing member:

```js
export const meta = { name: 'partial-death-demo', description: 'demo' }

phase('Fan out')
const items = ['a', 'b', 'c']
const results = await parallel(items.map((id) => () =>
agent(Return {"id": "${id}"} and nothing else., {
label: work:${id},
model: 'haiku',
schema: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } },
})))

/** A dead agent arrives here as a bare null with no cause — this filter is

  • the only thing a script can do, and it silently drops the lost item. */

const survivors = results.filter(Boolean)
return { processed: survivors.map((r) => r.id) }
```

  1. Cause one of the three agents to die (in our environment, credential expiry mid-run; internally, forcing a single agent failure should be equivalent).
  1. Observe:
  • <status> is completed and <summary> says the workflow completed.
  • <result> is {"processed":["a","c"]} — well-formed, internally consistent, and silently missing b.
  • agents_error: 1 appears only in <usage>; the reason appears only in <failures>/logs.
  • Inside the script, the dead slot is null with no reason attached, so the script cannot report b as lost even if it wanted to, and cannot decide whether re-dispatching b is worth spending.
  1. For the retry half specifically: make the same agent die on a credential failure, and have the script re-dispatch dead slots once. The retry re-runs against the same unauthenticated session and dies identically. Then repeat with a transient death — the same retry recovers completely. The script cannot distinguish the two, and both are reported to it as the same bare null.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

n/a — the all-dead half of surface (1) appears to have been fixed between 2026-07-28 and 2026-08-25 (see Run C vs Run D). The partial-death half and surfaces (2) and (3) have never worked.

Claude Code Version

2.1.246 (Claude Code)

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Non-interactive/CI environment

Additional Information

Why this is worth more than a cosmetic status fix, from four occurrences between 2026-07-28 and 2026-08-25:

  • A partial death is strictly harder to detect than a total one. An all-dead run at least returns an empty aggregate, which is self-evidently suspicious. A partial one returns a plausible aggregate. In Run B, a caller proceeding to the next stage would have shipped a change addressing one of two reported findings, with a ledger naming only the one it addressed — and nothing in result contradicting that reading.
  • Downstream automation reads result, not prose. Every consumer that gates on a workflow's return — merge gates, ledgers, coverage attestations — reads the JSON. A degradation visible only in <failures> is invisible to all of them.
  • agents_done / agents_error are already computed and already correct. They just don't reach result or the script. Surfacing them costs the runtime nothing it doesn't already know.

Related upstream issues I found while searching, none of which cover this (recording them so the overlap is visible): #76336 (journal result lines carry no agent label), #80608 (respawned agent's result journaled under a different key), #72949 (closed — a transient error aborting the whole run).

View original on GitHub ↗