fix(session): close checkpoint cancellation races
This commit is contained in:
@@ -16,7 +16,7 @@ This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`
|
||||
|
||||
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend makes each requested `session/flush` durable; this policy chooses the request, tool-dispatch, and completed-step checkpoints. Loading a backend without this policy is valid and retains checkpoints requested by the loop, including final `turn/end`, but crash recovery may lose the rest of an in-flight turn. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
|
||||
|
||||
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/post-step` persists the complete response/result batch before continuation work.
|
||||
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/post-step` persists the complete response/result batch before continuation work.
|
||||
|
||||
The loop records its assistant message and ordered tool results before dispatching `agent/post-step`, so the policy always captures that core batch. An event appended by another `agent/post-step` listener is captured at this checkpoint only when that listener is registered before the policy; Cordis registration order is the explicit composition rule for such extensions.
|
||||
|
||||
|
||||
@@ -37,6 +37,15 @@ function afterCheckpoint(
|
||||
})()
|
||||
}
|
||||
|
||||
/** Materialize the canonical result for a call cancelled before tool dispatch. */
|
||||
function abortedToolResult(): ToolExecutionResult {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install semantic checkpoint listeners. Loop-built model calls checkpoint the
|
||||
* logged request before adapter dispatch; top-level tool calls checkpoint their
|
||||
@@ -58,6 +67,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
|
||||
if (exec.agent === undefined || exec.parent !== undefined) return next()
|
||||
await ctx.sessions.flush(exec.agent.session)
|
||||
if (exec.signal?.aborted === true) return abortedToolResult()
|
||||
return next()
|
||||
})
|
||||
|
||||
|
||||
@@ -134,6 +134,40 @@ describe('session-checkpoint-policy tool and step boundaries', () => {
|
||||
expect(order).toEqual(['flush:start', 'flush:end', 'tool'])
|
||||
})
|
||||
|
||||
it('does not dispatch when cancellation lands during the tool checkpoint', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('tool-checkpoint-cancel'))
|
||||
const agent = { session } as Agent
|
||||
const controller = new AbortController()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', async () => {
|
||||
order.push('flush:start')
|
||||
await gate.promise
|
||||
order.push('flush:end')
|
||||
})
|
||||
ctx.tools.register({
|
||||
name: 'write', description: 'side effect', parameters: {},
|
||||
execute: async () => { order.push('tool'); return [] },
|
||||
})
|
||||
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('write-cancelled'), name: 'write', arguments: {}, agent,
|
||||
signal: controller.signal,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['flush:start'])
|
||||
controller.abort('cancelled during checkpoint')
|
||||
gate.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
expect(order).toEqual(['flush:start', 'flush:end'])
|
||||
})
|
||||
|
||||
it('turns a rejected checkpoint into an error result without running the tool body', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('tool-failure'))
|
||||
|
||||
Reference in New Issue
Block a user