When a subagent finishes while its parent main session is still running
(executing tools or awaiting model output), the announce queue would
follow the configured debounce and immediately attempt to deliver the
completion event back into the parent session via callGateway. The
gateway treats the parent as busy and the announce can either get
buffered until the next external user message or surface only as a
delayed echo, breaking the natural sessions_spawn -> sessions_yield
workflow where the parent expects the result to arrive as the next
turn.
This change adds an optional shouldDefer hook on the announce queue
state. The delivery layer wires it to the existing requester session
activity probe (resolveRequesterSessionActivity), so while the parent
session is still active the drain loop sleeps for max(250ms,
debounceMs) and re-checks instead of pushing the announce. As soon as
the parent goes idle, the queue drains normally.
- Plumbs shouldDefer through getAnnounceQueue / enqueueAnnounce.
- Skips drain step in scheduleAnnounceDrain when shouldDefer says the
target is still busy, with a bounded re-check sleep.
- Updates maybeQueueSubagentAnnounce to pass the activity probe.
- Adds a unit test that holds drain while parent is busy and resumes
when it goes idle.
No behavior change for callers that do not pass shouldDefer.
When the gateway rejects connections (e.g. scope-upgrade 'pairing required'),
the announce queue drain loop would retry every ~1s indefinitely because
the only delay was the fixed debounceMs (default 1000ms).
This adds a consecutiveFailures counter with exponential backoff:
2s, 4s, 8s, 16s, 32s, 60s (capped). The counter resets on successful drain.
The backoff is applied by shifting lastEnqueuedAt forward so that
waitForQueueDebounce naturally delays the next attempt.
Fixes#24777
Co-authored-by: Knut <knut@Knut-sin-Mac-mini.local>