Skip to content

fix: hold completion notifications until the parent run settles - #186

Open
vincelwt wants to merge 1 commit into
tintinweb:masterfrom
vincelwt:upstream/nudge-queue-hold
Open

fix: hold completion notifications until the parent run settles#186
vincelwt wants to merge 1 commit into
tintinweb:masterfrom
vincelwt:upstream/nudge-queue-hold

Conversation

@vincelwt

Copy link
Copy Markdown

Fixes #185.

The problem

emitIndividualNudge re-checks record.resultConsumed before sending, but that check runs when the message is handed to pi.sendMessage — 200ms after completion (NUDGE_HOLD_MS). Delivery happens much later: deliverAs: "followUp" is only delivered once the agent has no more tool calls, so a notification emitted mid-run sits in pi's follow-up queue until the run ends, where it can no longer be withdrawn. The orchestrator usually joins the agent with get_subagent_result somewhere in that gap, so the notification is already stale by the time it lands — after the final answer, costing a turn to answer "already incorporated". With pi's default followUpMode: "one-at-a-time", a batch drains one turn each.

The change

src/nudge-queue.ts (new, ~100 lines) takes over the timing that was inline in index.ts:

  • Parent idle → deliver after the existing 200ms window. Unchanged behaviour, and the case where a notification is genuinely useful.
  • Parent mid-run → park in-process instead of in pi's queue, and flush at agent_settled.

agent_settled rather than agent_end or turn_end: those fire with a retry, auto-compaction, or another tool-calling turn still ahead, so a notification emitted there is parked by pi exactly as before. agent_settled is the first point where pi won't continue on its own.

The suppression logic itself is untouched. Every send closure already re-checks resultConsumed (and the group callback re-filters unconsumed records), so deferring the call defers the check — an agent joined in the meantime simply never notifies. No new suppression path to keep in sync.

Busy state is read through ctx.isIdle() at delivery time rather than tracked as a local flag, so an unbalanced lifecycle event can't strand notifications; an absent ctx falls through to immediate delivery, i.e. today's behaviour.

dispose() drops everything undelivered, matching the existing session_shutdown semantics (results are undeliverable once the session is gone, which is why shutdown already calls abortAll()).

Notes

  • scheduleNudge / cancelNudge keep their signatures and call sites; they're now thin wrappers, which keeps the diff in index.ts small.
  • Group notifications go through the same queue under their group: key. Their callback already re-filters on resultConsumed and returns early when nothing is left unconsumed, so a fully-joined group now correctly produces no notification at all.
  • Extracted rather than left inline so the timing is testable in isolation, matching how group-join.ts and status-note.ts are structured.

Tests

10 new tests in test/nudge-queue.test.ts (fake timers, in the style of group-join.test.ts): idle delivery, mid-run parking, flush, cancellation while parked (the actual regression), cancellation inside the hold window, busy state re-read at delivery rather than schedule time, same-key replacement, flush-once, one throwing send not blocking the rest, and dispose dropping both parked and in-window notifications.

npm run lint, npm run typecheck, npm test (765 passed, 5 skipped), npm run build all pass.

I left CHANGELOG.md alone per CONTRIBUTING. Nothing in the README describes the timing being corrected here, so I didn't touch it either — happy to add a line if you'd like one.

A background agent's completion notification is sent with
`deliverAs: "followUp"`, which pi delivers only once the agent has no
more tool calls. Emitting one mid-run parks it in pi's follow-up queue
until the run ends, and a parked message can no longer be withdrawn — so
an agent the orchestrator joined with `get_subagent_result` in the
meantime still notifies after the final answer. The `resultConsumed`
guard cannot prevent this: it runs when the notification is enqueued,
200ms after completion, while the join happens minutes later.

With pi's default `followUpMode: "one-at-a-time"` those stale
notifications then drain one turn each.

Notifications now wait in-process instead of in pi's queue. A due
notification is delivered immediately when the parent is idle, and
otherwise parked until `agent_settled` — the first point where pi will
not continue on its own. Since each send closure already re-checks
`resultConsumed`, deferring the call defers the check, so a joined agent
simply never notifies. Busy state is read from `ctx.isIdle()` at
delivery time rather than tracked locally, so an unbalanced lifecycle
event cannot strand notifications.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Completion notifications for already-joined agents arrive after the final answer

1 participant