Skip to content

fix(subagent-announce): defer drain while parent session is busy#71706

Merged
fabianwilliams merged 1 commit into
openclaw:mainfrom
Udjin79:fix/subagent-announce-drain-after-busy-parent
Apr 25, 2026
Merged

fix(subagent-announce): defer drain while parent session is busy#71706
fabianwilliams merged 1 commit into
openclaw:mainfrom
Udjin79:fix/subagent-announce-drain-after-busy-parent

Conversation

@Udjin79

@Udjin79 Udjin79 commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

When a subagent finishes while its parent (the main session that called sessions_spawn) is still running — executing tools or awaiting model output — the announce queue currently follows the configured debounce and immediately attempts to deliver the completion event back into the parent via callGateway. The gateway treats the parent as busy, so the announce can be buffered until the next external user message arrives, or surfaces only as a delayed echo. This breaks the natural sessions_spawn → sessions_yield workflow where the parent expects the subagent result to arrive as the next turn.

Observed in practice: a parent agent spawns a child, doesn't yield (continues with other tool calls), the child finishes — the completion announce is queued while the parent is still busy, then doesn't reliably wake the parent until the human types something else.

Fix

Add an optional shouldDefer hook on the announce queue state. The delivery layer wires it to the existing resolveRequesterSessionActivity probe, so while the parent session is still active the drain loop sleeps 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.
  • In scheduleAnnounceDrain, after the debounce wait, check shouldDefer(nextItem); if true, sleep and re-loop.
  • maybeQueueSubagentAnnounce passes the requester activity probe.

No behavior change for callers that do not pass shouldDefer. Default behaviour for non-subagent queues is unchanged.

Tests

Added a unit test in subagent-announce-queue.test.ts:

  • Enqueues an announce with shouldDefer reporting parent busy.
  • Advances fake timers across the debounce; expects no send.
  • Flips shouldDefer to false; expects exactly one send with the original prompt.

Ran locally:

pnpm vitest run src/agents/subagent-announce-queue.test.ts                  # 5/5 passed
pnpm vitest run src/agents/subagent-announce-delivery.test.ts \
                src/agents/subagent-announce-dispatch.test.ts               # 26/26 passed

Risk / Compatibility

  • shouldDefer is optional; existing call sites and tests do not pass it, so behaviour is unchanged for them.
  • The deferred path uses a bounded re-check sleep (max(250ms, debounceMs)), so even if the activity probe is wrong about idle/active state, the queue does not livelock — it keeps polling at human-perceptible cadence.
  • No public type signatures change for queue consumers outside the announce path.

Why this matters

This is the root cause of the "subagent completion arrives only after the next user message" class of issues we see when parent agents intentionally don't yield (e.g. iterative coding agents continuing other tool work in parallel). With this fix, the queue waits for the parent to be ready instead of racing it.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: XS labels Apr 25, 2026
@greptile-apps

greptile-apps Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an optional shouldDefer callback to the announce queue so that delivery is postponed while the parent session is still active, fixing the "completion arrives only after the next user message" bug. The mechanism is sound and backward-compatible. Two minor points worth considering before merging: (1) existing.shouldDefer is unconditionally overwritten in the existing-queue path, so a later enqueueAnnounce call that omits the hook silently clears it; (2) the continue in the defer branch re-enters waitForQueueDebounce, effectively doubling the poll interval when debounceMs > 250 ms.

Confidence Score: 4/5

Safe to merge; no blocking defects, two P2 polish items that don't affect correctness for current callers.

Only P2 findings: the shouldDefer overwrite-with-undefined edge case and the double-debounce overhead are both unlikely to trigger in production given how queue keys are scoped, and neither produces wrong behavior for the new subagent use-case. Core logic, test coverage, and backward-compatibility are all sound.

src/agents/subagent-announce-queue.ts lines 83–87 (shouldDefer overwrite) and 135–142 (defer loop structure).

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/subagent-announce-queue.ts
Line: 80-87

Comment:
**`shouldDefer` silently cleared when queue is re-used without the hook**

When `getAnnounceQueue` finds an existing entry it unconditionally overwrites `existing.shouldDefer = shouldDefer`. Any caller that omits `shouldDefer` (i.e. passes `undefined`) will silently clear a hook that was already installed on that queue, allowing the drain to proceed without the parent-idle check. If a secondary enqueue ever hits the same queue key without passing `shouldDefer`, queued items could be delivered while the parent is still busy.

A safer guard would skip the update when the new value is `undefined`:

```typescript
if (shouldDefer !== undefined) {
  existing.shouldDefer = shouldDefer;
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/subagent-announce-queue.ts
Line: 136-142

Comment:
**Defer loop re-enters debounce wait on every poll cycle**

After the defer sleep the loop hits `continue`, which brings it back to `await waitForQueueDebounce(queue)` before the next `shouldDefer` check. With `debounceMs = 0` this is a no-op, but with a non-zero debounce the poll interval becomes `debounceMs + max(250, debounceMs)` (typically 2× the configured debounce) on every deferred iteration rather than the intended `max(250, debounceMs)`. This makes the parent-idle check slower than advertised when debounce is large.

Resetting `lastEnqueuedAt` before the `continue`, or restructuring so debounce is only waited once per enqueue event, would keep the effective polling cadence at exactly `max(250, debounceMs)`.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(subagent-announce): defer drain whil..." | Re-trigger Greptile

Comment thread src/agents/subagent-announce-queue.ts
Comment thread src/agents/subagent-announce-queue.ts
@Udjin79 Udjin79 force-pushed the fix/subagent-announce-drain-after-busy-parent branch from 0cc7185 to 4203b00 Compare April 25, 2026 18:48
@Udjin79 Udjin79 force-pushed the fix/subagent-announce-drain-after-busy-parent branch from 4203b00 to 1b0e5ff Compare April 25, 2026 18:53
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.
@Udjin79 Udjin79 force-pushed the fix/subagent-announce-drain-after-busy-parent branch from 1b0e5ff to 3c261d1 Compare April 25, 2026 19:20
@AS76

AS76 commented Apr 25, 2026

Copy link
Copy Markdown

Confirmed from personal usage: subagent spawn (NEON-SOUL synthesis, 200+ LLM calls) completes while parent session is active — completion announce is silently deferred until next user message. Workaround: manual polling every 5-10 minutes. This breaks natural sessions_spawn → sessions_yield flow where operator expects subagent result to arrive as next turn without additional user input. Willing to test fix when available.

@fabianwilliams fabianwilliams left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean addition of shouldDefer to the announce queue — keeps drain cooperative with parent session activity. Tests cover both the busy-parent deferral and the defer-hook-preservation case on queue reuse. Targeted fix without expanding queue surface area. LGTM.

@fabianwilliams fabianwilliams merged commit 1841dd9 into openclaw:main Apr 25, 2026
62 of 65 checks passed
ayesha-aziz123 pushed a commit to ayesha-aziz123/openclaw that referenced this pull request Apr 26, 2026
…nclaw#71706)

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.
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
…nclaw#71706)

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.
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
…nclaw#71706)

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.
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
…nclaw#71706)

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.
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
…nclaw#71706)

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.
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
…nclaw#71706)

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.
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
…nclaw#71706)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants