fix(core): close turn cancellation contract gaps

This commit is contained in:
Tianyi Cui
2026-07-21 12:14:53 +08:00
parent 81cdebc531
commit c6e1d35a99
23 changed files with 185 additions and 168 deletions
+3 -3
View File
@@ -7,7 +7,7 @@
*/
import type { Context } from 'cordis'
import { agentEvents, normalizeAgentCancelCause } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
@@ -325,7 +325,7 @@ export class ReactLoopAgent implements Agent {
}
cancel(cause?: AgentCancelCause): void {
const normalized = normalizeAgentCancelCause(cause ?? { kind: 'user' })
const reason = cause ?? { kind: 'user' }
const cancellation = this.turnCancellation
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
if (preRun) {
@@ -334,7 +334,7 @@ export class ReactLoopAgent implements Agent {
// Clear work already present before abort observers run. A replacement
// synchronously enqueued by an observer belongs to the next turn.
this.#inbox.clear()
cancellation?.request(normalized)
cancellation?.request(reason)
}
/**
+2 -2
View File
@@ -20,12 +20,12 @@ export class TurnCancellation {
/**
* Abort the turn once.
* @param reason - a validated caller cause or lifecycle disposal marker.
* @param reason - a typed caller cause or lifecycle disposal marker.
* @returns whether this request established the signal reason.
*/
request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean {
if (this.signal.aborted) return false
this.#controller.abort(reason)
this.#controller.abort(Object.freeze({ kind: reason.kind }))
return true
}
}
+8 -4
View File
@@ -124,7 +124,7 @@ export interface LoopHandle {
setStatus(status: 'idle' | 'running'): void
/** Install a fresh active-turn owner before the running notification. */
installTurnCancellation(): TurnCancellation
/** Clear only the exact owner whose turn and durability flush settled. */
/** Clear only the exact owner whose turn reached its terminal event boundary. */
clearTurnCancellation(cancellation: TurnCancellation): void
/** Resolves when the agent is disposed — unblocks the idle wait. */
disposed: Promise<void>
@@ -209,7 +209,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
const turn = lastTurnNumber(session) + 1
let terminalStopped = false
try {
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation.signal)
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation)
} catch (error: unknown) {
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
const err = toError(error)
@@ -232,10 +232,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
async function runTurn(
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
signal: AbortSignal,
cancellation: TurnCancellation,
): Promise<boolean> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
const { signal } = cancellation
const drainSteering = (): boolean => {
const messages = handle.inbox.drainSteering()
for (const message of messages) {
@@ -279,8 +280,11 @@ async function runTurn(
}
}
// Pre-commit validation failure escapes rather than masquerading as a committed boundary.
// Retire cancellation authority before publishing the terminal event. The
// following durability flush is quiescent turn work, but no longer part of
// the cancellable turn lifetime.
const closeTurn = (): void => {
handle.clearTurnCancellation(cancellation)
session.append('turn/end', { turn, reason })
}