fix(agent-loop): latch wakes landing in the cancel-convergence window

This commit is contained in:
_Kerman
2026-08-08 19:42:42 +08:00
committed by Tianyi Cui
parent 22609ea425
commit df30c62e2b
23 files changed
+379 -77

No files matched your search

+40 -11
View File
@@ -43,7 +43,7 @@ type Phase =
lastTurn: number
wakeRequested: boolean
}
| { kind: 'running'; abort: AbortController; turn: number; step: number }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
@@ -112,10 +112,12 @@ export class ReactLoopAgent implements Agent {
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
// Waking input cannot join an aborted activity, so it starts the next turn.
// The classification is captured BEFORE the insertion: a reentrant cancel
// from a synchronous splice observer must not reclassify this wake.
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
if (wakeup) this.wakeDriver()
if (wakeup) this.wakeDriver(wakingAfterAbort)
}
followup(input: UserMessage): void {
@@ -133,7 +135,7 @@ export class ReactLoopAgent implements Agent {
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
if (!options.keepInbox) {
this.inbox.clear()
if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false
if (this.phase.kind !== 'idle') this.phase.wakeRequested = false
}
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
}
@@ -154,22 +156,44 @@ export class ReactLoopAgent implements Agent {
return await task(maintenance.abort.signal)
} finally {
this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn })
if (maintenance.wakeRequested) this.wakeDriver()
if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver()
done.resolve()
}
})()
}
/** Start one driver, or remember its wake behind maintenance. */
private wakeDriver(): void {
if (this.phase.kind === 'maintenance') {
if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true
/**
* Start one driver, or latch its wake behind maintenance or an aborted
* activity. A wake sent while idle always opens its turn boundary, even
* when its message is cleared before the driver claims; only a latched
* replay is suppressed when the queue no longer holds the wake.
* @param wakeAfterAbort - the send-time classification from {@link send}:
* the wake landed after the abort fired. Captured before the inbox
* insertion so a reentrant cancel cannot reclassify it.
*/
private wakeDriver(wakeAfterAbort = false): void {
if (this.phase.kind !== 'idle') {
// The current activity cannot deliver this wake: a maintenance task
// never reads the queue, and an aborted activity converges without
// restarting — both latch for the exiting activity to replay. A live
// driver claims queued work itself, so it needs no latch. A disposal
// cancel never latches: replaying would make `whenIdle()` wait on a
// full model turn over a session being torn down.
const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined
if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) {
this.phase.wakeRequested = true
}
return
}
if (this.phase.kind !== 'idle') return
const driver = Promise.withResolvers<void>()
this.activityDone = driver.promise
this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0 })
this.setPhase({
kind: 'running',
abort: new AbortController(),
turn: this.phase.lastTurn,
step: 0,
wakeRequested: false,
})
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
}
@@ -196,7 +220,9 @@ export class ReactLoopAgent implements Agent {
} finally {
/* v8 ignore next -- kick owns a running phase until this driver boundary */
if (this.phase.kind === 'running') {
this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
const { turn, wakeRequested } = this.phase
this.setPhase({ kind: 'idle', lastTurn: turn })
if (wakeRequested && this.inbox.hasPending) this.wakeDriver()
}
}
}
@@ -302,6 +328,9 @@ export class ReactLoopAgent implements Agent {
}
if (!this.inbox.hasPending) return false
phase.abort = new AbortController()
// The driver keeps running with a fresh controller: any latch set on the
// old one is stale, and the live driver claims the queue itself.
phase.wakeRequested = false
phase.step = 0
return true
}