Summary
When a top-level session (a normal chat/dashboard session — i.e. one that is not itself a subagent) spawns N parallel subagents and calls sessions_yield to wait, the runtime never fires the "all descendants settled → continue and send your final answer" wake for it. That forceful wake (wakeSubagentRunAfterDescendants / buildDescendantWakeMessage) only targets orchestrators that have a subagent-registry run record; a top-level session has none. So the parent only receives the passive per-child completion announces, and — because of three compounding gaps below — it commonly mis-tracks completion, replies NO_REPLY/re-yields on the final child's turn, and then parks permanently because no further event ever arrives. Recovery requires a human message ("are they done?").
This is the top-level completion of the mechanism that #97090 fixed for nested orchestrators — the gap #97090 did not close. Observed on 2026.6.10 (npm, with the #97090 guard); the gating code is unchanged on current main.
This is a contract violation rather than a missing feature: the runtime tells the model not to poll and promises to inject authoritative subagent state — but that injected state disappears at exactly the moment the parent must decide "all done", and there is no control-flow wake to fall back on.
The contract, and where it breaks
The documented design for a waiting orchestrator is: "don't poll — the runtime injects a live ## Active Subagents block into your turns, and pushes completion events." The parent is explicitly instructed (in the sessions_spawn result and system guidance) not to call sessions_list/sessions_history or poll, and to "track expected child session keys".
Every leg of the promised authoritative state fails precisely on the decisive turn:
-
The injected ## Active Subagents block vanishes at zero-active.
src/agents/subagent-active-context.ts → buildActiveSubagentSystemPromptAddition() returns undefined when list.active.length === 0. The block only lists active children (never a completion tally), so on the very turn where the parent should conclude "all done, synthesize", the authoritative block is absent — not rendered as "0 outstanding / all done".
-
Completion events carry no outstanding/total count.
The per-child completion context injected to the parent reports one child and contains no machine count of how many children remain, while also inviting NO_REPLY ("Reply ONLY: NO_REPLY when …"). The parent cannot learn "0 outstanding" from the event either.
-
No control-flow wake for the top-level parent.
src/agents/subagent-announce.ts → wakeSubagentRunAfterDescendants() is only invoked when a settling run has wakeOnDescendantSettle === true (the nested case). A top-level session has no run record to carry that flag. Worse, the announce give-up path clears wakeOnDescendantSettle, so a child whose announce gives up (a NO_REPLY completion turn) loses even the nested hook.
Net: on the last child's completion turn the parent has (a) no injected active block, (b) no count in the completion event, (c) a standing instruction not to poll, and (d) no forceful wake. It falls back to its own conversational memory of "who's still out", mis-counts under load, replies with a "still waiting" note or NO_REPLY, re-yields — and since that was the final completion event, nothing ever wakes it again.
Reproduction
- From a top-level session (not a subagent), spawn N ≥ ~8 parallel subagents (e.g. the first-party
deep-research skill: "Dispatches parallel investigators"), each expectsCompletionMessage, cleanup:"keep".
sessions_yield to wait for all.
- Do not send any further user message.
Expected: when the last child settles, the parent wakes, synthesizes all results, and delivers a final answer (0 post-yield user messages).
Actual: the parent parks after the last child's completion turn and never delivers until a human sends a message. Larger N makes it more likely (more completion events → more memory drift; failed NO_REPLY announces compound it).
Evidence (one captured incident, 8 parallel investigators)
All 8 children ended within a 2-minute span, all before 09:15:17 UTC (gateway run <id> ended with stopReason=stop):
| task |
child ended (UTC) |
announce result |
| social |
09:13:22 |
give-up (retry-limit, NO_REPLY) |
| english_media |
09:13:34 |
give-up (retry-limit, NO_REPLY) |
| products |
09:14:05 |
delivered 09:14:24 |
| identity |
09:14:06 |
delivered |
| chinese_media |
09:14:08 |
delivered |
| industry |
09:14:14 |
delivered |
| network |
09:14:38 |
delivered |
| style |
09:15:17 |
delivered 09:15:25 |
- The parent's last turn (09:15:25) said "still waiting for 2: products + english_media" — but products had ended at 09:14:05 with its announce delivered at 09:14:24, and english_media had ended at 09:13:34. Both named-as-pending children were long finished. The parent mis-counted.
- Between 09:15:25 and the user's ping at 09:20:45 (5.5 minutes) there is zero subagent/announce/resume/settle activity in the gateway log — the parent received no wake. It delivered the full report only after the human nudged it.
Impact
Any top-level fan-out-then-aggregate workflow (research, multi-source review, batch analysis) can silently stall and require manual intervention, defeating the "spawn, orchestrate, synthesize" design. It is load-sensitive and non-deterministic, so it evades light testing (a 5-child canary can pass while 8 children stall).
Proposed fix
Give the registry-less top-level requester the same forceful "all children settled → synthesize now" wake that nested orchestrators already get, reusing the existing announce delivery pipeline. PR with implementation + tests to follow (will be linked here).
A prompt-layer mitigation (re-inject an "all done, deliver now" block instead of dropping it at zero-active) is structurally incomplete — it can only decorate turns that occur, and cannot create the wake turn while the session is parked — so the runtime wake is the real fix.
Notes on prior attempts (context for maintainers)
A downstream deployment (this reporter's fleet) tried to extend the wake to the top-level parent via compiled-dist patches (last-sibling detection + buildDescendantWakeMessage overrides) and found the completion machinery too timing-flaky to patch reliably in compiled dist (unreliable result injection, retry give-up, last-sibling-detection races), then fell back to a per-agent prompt policy — which is unreliable (the same fleet, fully patched, still parked in the incident above). The races are tractable in source: an idempotency key on the wake defangs the last-sibling race, and suspension/give-up semantics can be handled at the right seam.
Summary
When a top-level session (a normal chat/dashboard session — i.e. one that is not itself a subagent) spawns N parallel subagents and calls
sessions_yieldto wait, the runtime never fires the "all descendants settled → continue and send your final answer" wake for it. That forceful wake (wakeSubagentRunAfterDescendants/buildDescendantWakeMessage) only targets orchestrators that have a subagent-registry run record; a top-level session has none. So the parent only receives the passive per-child completion announces, and — because of three compounding gaps below — it commonly mis-tracks completion, repliesNO_REPLY/re-yields on the final child's turn, and then parks permanently because no further event ever arrives. Recovery requires a human message ("are they done?").This is the top-level completion of the mechanism that #97090 fixed for nested orchestrators — the gap #97090 did not close. Observed on
2026.6.10(npm, with the #97090 guard); the gating code is unchanged on currentmain.This is a contract violation rather than a missing feature: the runtime tells the model not to poll and promises to inject authoritative subagent state — but that injected state disappears at exactly the moment the parent must decide "all done", and there is no control-flow wake to fall back on.
The contract, and where it breaks
The documented design for a waiting orchestrator is: "don't poll — the runtime injects a live
## Active Subagentsblock into your turns, and pushes completion events." The parent is explicitly instructed (in thesessions_spawnresult and system guidance) not to callsessions_list/sessions_historyor poll, and to "track expected child session keys".Every leg of the promised authoritative state fails precisely on the decisive turn:
The injected
## Active Subagentsblock vanishes at zero-active.src/agents/subagent-active-context.ts→buildActiveSubagentSystemPromptAddition()returnsundefinedwhenlist.active.length === 0. The block only lists active children (never a completion tally), so on the very turn where the parent should conclude "all done, synthesize", the authoritative block is absent — not rendered as "0 outstanding / all done".Completion events carry no outstanding/total count.
The per-child completion context injected to the parent reports one child and contains no machine count of how many children remain, while also inviting
NO_REPLY("Reply ONLY: NO_REPLY when …"). The parent cannot learn "0 outstanding" from the event either.No control-flow wake for the top-level parent.
src/agents/subagent-announce.ts→wakeSubagentRunAfterDescendants()is only invoked when a settling run haswakeOnDescendantSettle === true(the nested case). A top-level session has no run record to carry that flag. Worse, the announce give-up path clearswakeOnDescendantSettle, so a child whose announce gives up (aNO_REPLYcompletion turn) loses even the nested hook.Net: on the last child's completion turn the parent has (a) no injected active block, (b) no count in the completion event, (c) a standing instruction not to poll, and (d) no forceful wake. It falls back to its own conversational memory of "who's still out", mis-counts under load, replies with a "still waiting" note or
NO_REPLY, re-yields — and since that was the final completion event, nothing ever wakes it again.Reproduction
deep-researchskill: "Dispatches parallel investigators"), eachexpectsCompletionMessage,cleanup:"keep".sessions_yieldto wait for all.Expected: when the last child settles, the parent wakes, synthesizes all results, and delivers a final answer (0 post-yield user messages).
Actual: the parent parks after the last child's completion turn and never delivers until a human sends a message. Larger N makes it more likely (more completion events → more memory drift; failed
NO_REPLYannounces compound it).Evidence (one captured incident, 8 parallel investigators)
All 8 children ended within a 2-minute span, all before 09:15:17 UTC (gateway
run <id> ended with stopReason=stop):NO_REPLY)NO_REPLY)Impact
Any top-level fan-out-then-aggregate workflow (research, multi-source review, batch analysis) can silently stall and require manual intervention, defeating the "spawn, orchestrate, synthesize" design. It is load-sensitive and non-deterministic, so it evades light testing (a 5-child canary can pass while 8 children stall).
Proposed fix
Give the registry-less top-level requester the same forceful "all children settled → synthesize now" wake that nested orchestrators already get, reusing the existing announce delivery pipeline. PR with implementation + tests to follow (will be linked here).
A prompt-layer mitigation (re-inject an "all done, deliver now" block instead of dropping it at zero-active) is structurally incomplete — it can only decorate turns that occur, and cannot create the wake turn while the session is parked — so the runtime wake is the real fix.
Notes on prior attempts (context for maintainers)
A downstream deployment (this reporter's fleet) tried to extend the wake to the top-level parent via compiled-dist patches (last-sibling detection +
buildDescendantWakeMessageoverrides) and found the completion machinery too timing-flaky to patch reliably in compiled dist (unreliable result injection, retry give-up, last-sibling-detection races), then fell back to a per-agent prompt policy — which is unreliable (the same fleet, fully patched, still parked in the incident above). The races are tractable in source: an idempotency key on the wake defangs the last-sibling race, and suspension/give-up semantics can be handled at the right seam.