fix(session): close checkpoint cancellation races
This commit is contained in:
@@ -56,7 +56,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
|
||||
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. If structural disposal follows an effective cancellation before the turn closes, the earlier cancellation retains its `aborted` reason; disposal alone closes the turn as `disposed`. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
|
||||
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
|
||||
|
||||
|
||||
@@ -88,6 +88,19 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify an interruption before a step controller can provide an abort reason. */
|
||||
function pendingInterruptionReason(handle: LoopHandle): TurnEndReason {
|
||||
if (handle.isCancelled()) return { kind: 'aborted', reason: handle.cancelReason() }
|
||||
return { kind: 'disposed' }
|
||||
}
|
||||
|
||||
/** Preserve an effective cancel reason when structural disposal follows it. */
|
||||
function stepInterruptionReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason {
|
||||
if (handle.isCancelled()) return { kind: 'aborted', reason: handle.cancelReason() }
|
||||
if (handle.isDisposed()) return { kind: 'disposed' }
|
||||
return { kind: 'aborted', reason: String(signal.reason) }
|
||||
}
|
||||
|
||||
/** Mutable agent controls supplied to the loop driver. */
|
||||
export interface LoopHandle {
|
||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||
@@ -307,7 +320,7 @@ async function runTurn(
|
||||
// Cancellation or disposal during assembly ends the turn before any step opens.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
reason = pendingInterruptionReason(handle)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -324,7 +337,7 @@ async function runTurn(
|
||||
// Never cache an interrupted composition; the next turn recomposes it.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
reason = pendingInterruptionReason(handle)
|
||||
break
|
||||
}
|
||||
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
|
||||
@@ -336,7 +349,7 @@ async function runTurn(
|
||||
// Interruption landing during the pre-step seam: do not open an empty step.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
reason = pendingInterruptionReason(handle)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -356,7 +369,7 @@ async function runTurn(
|
||||
// turn accordingly. closeStep balances the already-appended step/start.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
reason = pendingInterruptionReason(handle)
|
||||
closeStep()
|
||||
break
|
||||
}
|
||||
@@ -382,9 +395,7 @@ async function runTurn(
|
||||
closeStep()
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
reason = stepInterruptionReason(handle, abort.signal)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -407,9 +418,7 @@ async function runTurn(
|
||||
// or a recovery-listener failure.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
reason = stepInterruptionReason(handle, abort.signal)
|
||||
break
|
||||
}
|
||||
switch (recoveryDecision.action) {
|
||||
@@ -434,11 +443,8 @@ async function runTurn(
|
||||
handle.setAbort(undefined)
|
||||
const { error } = stepOutcome
|
||||
/* v8 ignore next -- narrow race: disposal while non-request step work throws. */
|
||||
if (handle.isDisposed()) {
|
||||
reason = { kind: 'disposed' }
|
||||
} else if (abort.signal.aborted) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
|
||||
if (handle.isDisposed() || handle.isCancelled() || abort.signal.aborted) {
|
||||
reason = stepInterruptionReason(handle, abort.signal)
|
||||
} else {
|
||||
failTurn(error)
|
||||
}
|
||||
@@ -464,11 +470,8 @@ async function runTurn(
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
/* v8 ignore next -- narrow race: disposal while a post-step listener throws. */
|
||||
if (handle.isDisposed()) {
|
||||
reason = { kind: 'disposed' }
|
||||
} else if (abort.signal.aborted) {
|
||||
/* v8 ignore next -- signal.reason always set by cancellation or disposal. */
|
||||
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
|
||||
if (handle.isDisposed() || handle.isCancelled() || abort.signal.aborted) {
|
||||
reason = stepInterruptionReason(handle, abort.signal)
|
||||
} else {
|
||||
failTurn(stepOutcome.error)
|
||||
}
|
||||
@@ -476,9 +479,7 @@ async function runTurn(
|
||||
}
|
||||
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
reason = stepInterruptionReason(handle, abort.signal)
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
break
|
||||
|
||||
@@ -343,6 +343,35 @@ describe('Agent.cancel()', () => {
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves cancellation when disposal follows during post-step work', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('cancel-then-dispose'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent
|
||||
ctx.on('agent/post-step', async (subject) => {
|
||||
if (subject !== agent) return
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await entered.promise
|
||||
agent.cancel('user cancelled')
|
||||
const disposed = handle.dispose()
|
||||
release.resolve(undefined)
|
||||
await disposed
|
||||
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: 'user cancelled' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
Reference in New Issue
Block a user