Agent loop: a parallel subagent result that lands after the final turn is never consumed — and the 2.1.218 nudge does not cover it (only fires on empty turns)
Environment
- claude-agent-sdk (Python) 0.2.128, bundled Claude Code CLI 2.1.220
- macOS (arm64), model
claude-sonnet-5 - Pattern: one user request → model dispatches 3 parallel
Agent(subagent) calls in one turn → each runs one tool and returns
Summary
When N subagents run in parallel, the last result to arrive can land after the loop has already decided the turn is over. It is appended to the transcript as a user message, but no further model turn is ever scheduled, so it never enters any API request. The user receives the model's earlier interim sentence — "still waiting for X" — as the final answer, while X's result is sitting on disk, complete.
No error. No warning. Every subagent reports completed, every result is persisted, exit is clean.
This is not fixed by 2.1.218
We upgraded specifically because of:
2.1.218 — Fixed an engine teardown race that could start and abandon a phantom turn
After upgrading to CLI 2.1.220, a controlled 5-run reproduction did not reproduce (windows 2.7–3.4s, plus one at 29ms). We were about to close this as fixed.
Then it reproduced in normal use on 2.1.220.
The two observations disagree, and the pattern in how they disagree points at the gate condition.
In our 5 controlled runs the model's turn happened to be silent (no text emitted) — and in each case we observed the CLI inject:
[Your previous response had no visible output.
Please continue and produce a user-visible response.]
after which the model went on to produce the full summary. That injection is what made the runs pass.
In both real-world failures the model had emitted text (an interim "still waiting for X" sentence) — and the injection never happened.
⇒ The self-heal appears to be gated on "did this turn produce any visible text", not on "is there an unconsumed task-notification". If the model said anything at all, it does not fire.
That is consistent with the 2.1.218 note being aimed at thinking-only responses — a different failure mode from ours. Our case is out of its scope: the turn is not empty, it just isn't final.
Evidence — two real occurrences, same shape
Occurrence B (on 2.1.220) — agent_calls=3, num_turns=7, final answer 109 chars:
32 subagent_progress #1 done/completed
33 tool/result Agent dt=39.6 ← result #1
34 subagent_progress #2 done/completed
35 tool/result Agent dt=37.4 ← result #2
36 subagent_progress #3 done/completed
37 tool/result Agent dt=37.0 ← result #3 — all three now present
38 text/delta "<result #2 summary> … still waiting for <#3>."
39 complete
Occurrence A (on 2.1.205) — same shape, final answer 24 chars, output_tokens: 26. Transcript timestamps:
36.669 assistant: "<#2> received, still waiting for <#3>." ← becomes final answer
36.811 user: task-notification (#3 completed) ← appended 132ms later
(no further assistant turn — ever)
Why the model is not at fault
At the moment it generated that sentence it was correct — #3 genuinely had not arrived (36.669 < 36.811). Nothing in the session said anything wrong. One turn simply never happened.
Also note "all three results were in the model's context" is false: being in the transcript ≠ being in a request. The third is a dangling user message that was never sent.
Trigger profile
Among N parallel subagents, when the last two complete within roughly one model-turn duration of each other. In occurrence B the three dt values were 39.6 / 37.4 / 37.0 — all above 37s, spread only 2.6s. Probe/measurement-style tasks naturally cluster their completion times, so this window is not exotic.
Both of our occurrences came from ordinary use, days apart, by a real user.
Impact
Silent data loss at the user-visible layer. The work ran and was billed (3 subagents, ~70s wall clock), the results exist on disk, and the user gets none of them — just a sentence saying the system is still waiting for something that already arrived.
Suggested fix
Make the terminal condition "no pending tasks AND no unconsumed task-notification messages", and schedule one more turn when a notification is appended after the previous turn ended.
If changing the loop is not straightforward, a narrower option: extend the existing nudge's condition to also fire when there are unconsumed task-notifications, regardless of whether the turn produced text. The injection mechanism already exists; only the gate is too narrow.
Failing both, please surface unconsumed notifications in ResultMessage so hosts can detect the state instead of emitting a clean completion.
Reproduction note
Blind reproduction is unreliable — and misleading. A naive repro tends to produce silent turns, which the nudge rescues, yielding a false "fixed" result (this happened to us, 0/5).
To actually hit it, the main prompt must force the model to narrate interim status, so the turn contains text and the nudge stays out of the way. Suggested shape: 3 probes sleeping ~35 / 37 / 38.5s, with a prompt that requires reporting each line's result as it arrives.
Not claimed
- We have not traced the loop's code path. The "unconsumed notification" characterisation and the gate condition above are inferred from observed behaviour (transcripts + which runs did and did not receive the injection), not from reading the implementation.
- Whether a notification arriving during generation vs after it ends is deterministic or probabilistic — the reproduction above is intended to pin that down.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗