Skip to content

Top-level parent that spawns parallel subagents + sessions_yield never auto-synthesizes — parks until a user nudge #99395

Description

@smthfoxy

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:

  1. The injected ## Active Subagents block vanishes at zero-active.
    src/agents/subagent-active-context.tsbuildActiveSubagentSystemPromptAddition() 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".

  2. 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.

  3. No control-flow wake for the top-level parent.
    src/agents/subagent-announce.tswakeSubagentRunAfterDescendants() 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

  1. 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".
  2. sessions_yield to wait for all.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions