fix(agent): don't resolve whenIdle() early on pre-step cancel + requeue (Codex review)

Codex's converge pass found a quiescence-contract violation: a whenIdle() waiter
registered for prompt A, then cancel() clears A, then prompt B is queued BEFORE
the loop resumes from the idle wait. The window-1 cancel branch called
settleIdle() UNCONDITIONALLY, resolving the waiter while B was still
queued-and-unrun — whenIdle() resolved with zero events, then B ran afterward.

Fix: in window 1, only settleIdle() + re-park when NO new work is queued. If a
send() raced in after the cancel, the marker was for the cancelled work only —
clear it and fall through to run the new prompt's turn, letting THAT turn's
running→idle settle the waiter (so whenIdle() waits for B to actually run).

Adds a regression test reproducing the exact interleaving (send A → whenIdle →
cancel → send B): whenIdle() now resolves only after B's turn ran (B's user
message + a turn/end in the log), and A was dropped.
This commit is contained in:
Tianyi Cui
2026-06-20 05:10:16 +08:00
parent c4bc6e0e38
commit 9ee22bc6f6
2 files changed
+41 -8

No files matched your search

+16 -8
View File
@@ -169,16 +169,24 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
if (handle.isDisposed()) break
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
// idle wait but before we flip to `running`. Drop the about-to-run turn: the
// queued/steering work is already cleared by `cancel()`, and we settle any
// `whenIdle()` waiter DIRECTLY (no status transition fires here, so the
// running→idle settle never runs) WITHOUT emitting `agent/status` (an ACP
// listener must not see a spurious idle that resolves a freshly-queued prompt
// as cancelled). Clear the marker and re-park.
// idle wait but before we flip to `running`. The cancelled queued/steering
// work is already cleared by `cancel()`. Clear the marker, then:
// - if NOTHING new is queued, drop the about-to-run turn and re-park,
// settling any `whenIdle()` waiter DIRECTLY (no running→idle transition
// fires here to settle it) and WITHOUT emitting `agent/status` (an ACP
// listener must not see a spurious idle that resolves a freshly-queued
// prompt as cancelled);
// - if a NEW prompt was queued AFTER the cancel (a send() that raced in
// before the loop resumed), the marker was for the cancelled work only —
// fall through and run the new prompt's turn. Do NOT settle waiters here:
// a whenIdle() waiter must wait for that new turn's running→idle, not
// resolve before it runs (the quiescence contract).
if (handle.isCancelled()) {
handle.clearCancel()
handle.settleIdle()
continue
if (!agent.inbox.hasQueued) {
handle.settleIdle()
continue
}
}
handle.setStatus('running')