fix(agent-loop): latch wakes landing in the cancel-convergence window
This commit is contained in:
23 files changed
+379
-77
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user