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

View File

@@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
@@ -57,7 +57,7 @@ The handle every plugin programs against:
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale.
- `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; the exact `user | parent` object is validated, detached, and frozen before queues are cleared and the current turn's shared signal is aborted. Invalid causes throw synchronously, repeated active-turn cancellation is first-wins, and idle cancellation is a safe no-op that does not arm the next turn. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins, and idle cancellation is a safe no-op that does not arm the next turn. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`

View File

@@ -1,35 +1,6 @@
/** Public normalization helpers for explicit turn cancellation. @module @deepseek-ai/dsh-agent/cancellation */
/** Runtime reason inspection for explicit turn cancellation. @module @deepseek-ai/dsh-agent/cancellation */
import type { AgentCancelCause, AgentInterruptReason } from './types.ts'
/**
* Validate and detach a caller-supplied Agent cancellation cause.
* @param value - the candidate cancellation cause.
* @returns a fresh frozen cause suitable for the current turn signal.
* @throws {TypeError} when the value is not an exact supported cause.
*/
export function normalizeAgentCancelCause(value: unknown): AgentCancelCause {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('agent cancel cause must be an exact plain object with kind "user" or "parent"')
}
const prototype = Object.getPrototypeOf(value) as unknown
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError('agent cancel cause must be an exact plain object with kind "user" or "parent"')
}
const keys = Reflect.ownKeys(value)
if (keys.length !== 1 || keys[0] !== 'kind') {
throw new TypeError('agent cancel cause must contain exactly one field: kind')
}
const kind = (value as { readonly kind?: unknown }).kind
switch (kind) {
case 'user':
return Object.freeze({ kind: 'user' })
case 'parent':
return Object.freeze({ kind: 'parent' })
default:
throw new TypeError(`unsupported agent cancel cause kind: ${String(kind)}`)
}
}
import type { AgentInterruptReason } from './types.ts'
/**
* Read a supported agent interruption from an explicitly supplied signal.
@@ -41,19 +12,19 @@ export function normalizeAgentCancelCause(value: unknown): AgentCancelCause {
export function agentInterruptReasonOf(signal: AbortSignal): AgentInterruptReason | undefined {
if (!signal.aborted) return undefined
const reason: unknown = signal.reason
if (typeof reason === 'object' && reason !== null && !Array.isArray(reason)) {
const prototype = Object.getPrototypeOf(reason) as unknown
const keys = Reflect.ownKeys(reason)
if ((prototype === Object.prototype || prototype === null)
&& keys.length === 1 && keys[0] === 'kind'
&& (reason as { readonly kind?: unknown }).kind === 'disposed') {
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return undefined
const prototype = Object.getPrototypeOf(reason) as unknown
const keys = Reflect.ownKeys(reason)
if ((prototype !== Object.prototype && prototype !== null)
|| keys.length !== 1 || keys[0] !== 'kind') return undefined
switch ((reason as { readonly kind?: unknown }).kind) {
case 'user':
return Object.freeze({ kind: 'user' })
case 'parent':
return Object.freeze({ kind: 'parent' })
case 'disposed':
return Object.freeze({ kind: 'disposed' })
}
}
try {
return normalizeAgentCancelCause(reason)
} catch (error: unknown) {
if (error instanceof TypeError) return undefined
throw error
default:
return undefined
}
}

View File

@@ -15,7 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
export { agentInterruptReasonOf, normalizeAgentCancelCause } from './cancellation.ts'
export { agentInterruptReasonOf } from './cancellation.ts'
export { agentEvents, assembleContextFor } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'

View File

@@ -132,8 +132,8 @@ export interface Agent {
* Clear all queued and steering work, including items waiting to start, and
* abort the active turn. The first cause wins for that turn, and `whenIdle()`
* resolves after cancellation reaches quiescence. Omission means
* `{ kind: 'user' }`; invalid causes throw synchronously even while idle.
* Idle cancellation is a no-op after validation and does not arm a later cancel.
* `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm a later
* cancel. The active turn snapshots and freezes the typed cause.
* @param cause - the stable caller intent carried by the current turn signal.
*/
cancel(cause?: AgentCancelCause): void

View File

@@ -5,10 +5,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, {
agentEvents,
agentInterruptReasonOf,
normalizeAgentCancelCause,
} from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
const id = SessionId(rawId)
@@ -187,41 +186,21 @@ describe('agentEvents()', () => {
})
describe('explicit cancellation helpers', () => {
it('normalizes exact causes into detached frozen values', () => {
const user = { kind: 'user' as const }
const parent = Object.assign(Object.create(null) as object, { kind: 'parent' })
const normalizedUser = normalizeAgentCancelCause(user)
const normalizedParent = normalizeAgentCancelCause(parent)
expect(normalizedUser).toEqual({ kind: 'user' })
expect(normalizedUser).not.toBe(user)
expect(Object.isFrozen(normalizedUser)).toBe(true)
expect(normalizedParent).toEqual({ kind: 'parent' })
expect(Object.getPrototypeOf(normalizedParent)).toBe(Object.prototype)
expect(Object.isFrozen(normalizedParent)).toBe(true)
})
it.each([
undefined,
null,
'user',
[],
new Error('user'),
{ kind: 'user', detail: true },
Object.assign({ kind: 'user' }, { [Symbol('extra')]: true }),
{ kind: 'timeout' },
])('rejects unsupported cancellation cause %#', (cause) => {
expect(() => normalizeAgentCancelCause(cause)).toThrow(TypeError)
it('exposes the closed typed cancellation cause at the Agent seam', () => {
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause | undefined>()
})
it('reads only supported reasons from an explicit signal', () => {
const read = (reason: unknown) => {
const controller = new AbortController()
controller.abort(reason)
return agentInterruptReasonOf(controller.signal)
}
const live = new AbortController()
expect(agentInterruptReasonOf(live.signal)).toBeUndefined()
const user = new AbortController()
user.abort({ kind: 'user' })
expect(agentInterruptReasonOf(user.signal)).toEqual({ kind: 'user' })
expect(read({ kind: 'user' })).toEqual({ kind: 'user' })
expect(read({ kind: 'parent' })).toEqual({ kind: 'parent' })
const disposed = new AbortController()
disposed.abort(Object.assign(Object.create(null) as object, { kind: 'disposed' }))
@@ -229,29 +208,13 @@ describe('explicit cancellation helpers', () => {
expect(disposedReason).toEqual({ kind: 'disposed' })
expect(Object.isFrozen(disposedReason)).toBe(true)
const unsupported = new AbortController()
unsupported.abort(new Error('private runtime reason'))
expect(agentInterruptReasonOf(unsupported.signal)).toBeUndefined()
const primitive = new AbortController()
primitive.abort('private runtime reason')
expect(agentInterruptReasonOf(primitive.signal)).toBeUndefined()
})
it('does not swallow non-validation failures while reading a cause', () => {
let reads = 0
const reason = Object.defineProperty({}, 'kind', {
enumerable: true,
get() {
reads += 1
if (reads === 1) return 'user'
throw new Error('kind getter failed')
},
})
const controller = new AbortController()
controller.abort(reason)
expect(() => agentInterruptReasonOf(controller.signal)).toThrow('kind getter failed')
expect(read(null)).toBeUndefined()
expect(read([])).toBeUndefined()
expect(read('private runtime reason')).toBeUndefined()
expect(read(new Error('private runtime reason'))).toBeUndefined()
expect(read({ kind: 'user', detail: true })).toBeUndefined()
expect(read({ other: 'user' })).toBeUndefined()
expect(read({ kind: 'timeout' })).toBeUndefined()
})
})