refactor(tasks): drive wait() timing through dsh-timeout

ctx.tasks.wait arms a deadline() fusing the caller's abort with the
wait timeout and classifies the outcome with timeoutOf scoped to the
new TASK_WAIT_TIMEOUT code: a wait timeout resolves to the live
snapshot (the task keeps running), a caller abort rejects the wait —
same contract, no hand-rolled timer/listener plumbing, and a nested
foreign deadline can no longer misread as a wait timeout. task_output
deliberately declares NO ToolDefinition.timeoutMs: timeout-policy
turns a timed-out call into a structured TOOL_TIMEOUT failure, but a
timed-out wait is a SUCCESS that must still report [status: running]
(decision recorded in the runtime RFC alternatives).
This commit is contained in:
Yichen Jiang
2026-07-09 21:36:56 +08:00
parent beaace5eb3
commit f858c647a6
8 changed files with 43 additions and 10 deletions

View File

@@ -30,6 +30,7 @@
import { Context, Service } from 'cordis'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { TaskId } from './types.ts'
import type { TaskDoneListener, TaskOutcome, TaskRead, TaskRegistration, TaskSnapshot, TaskStatus } from './types.ts'
@@ -49,6 +50,14 @@ declare module 'cordis' {
}
}
/**
* The `dsh-timeout` code stamped on a {@link TaskService.wait} deadline's
* `TimeoutReason`. A wait timeout only ends the WAIT (the task keeps running
* and the live snapshot is returned) — scoping `timeoutOf` to this code keeps
* a foreign (outer, nested) deadline's timeout from being misread as ours.
*/
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
interface TrackedTask {
id: TaskId
@@ -273,15 +282,22 @@ export class TaskService extends Service {
if (signal?.aborted) throw new Error('wait aborted')
task.waiters += 1
try {
// The dsh-timeout deadline fits wait() exactly because both only
// NOTIFY: a wait timeout returns the live snapshot (the task keeps
// running — nothing is terminated), and timeoutOf scoped to our own
// code tells that timeout apart from a caller abort, which rejects
// the wait. `using` clears the timer on every exit path.
using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT)
await new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
const onAbort = (): void => {
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) resolve()
else reject(new Error('wait aborted'))
}
const timer = setTimeout(() => { cleanup(); resolve() }, timeoutMs)
const onAbort = (): void => { cleanup(); reject(new Error('wait aborted')) }
signal?.addEventListener('abort', onAbort, { once: true })
void task.settled.then(() => { cleanup(); resolve() })
d.signal.addEventListener('abort', onAbort, { once: true })
void task.settled.then(() => {
d.signal.removeEventListener('abort', onAbort)
resolve()
})
})
} finally {
task.waiters -= 1