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

@@ -224,7 +224,7 @@ attachSurface(name: string): () => void
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/tasks/tasks/src/index.ts:84`](../../packages/tasks/tasks/src/index.ts)
Source: [`packages/tasks/tasks/src/index.ts:93`](../../packages/tasks/tasks/src/index.ts)
## `ctx.tools` — `ToolRegistry`

View File

@@ -142,6 +142,10 @@ The established bash habit is poll-between-work, and the guidance tells the mode
Waiting is never useful without reading the result afterwards; a separate tool doubles the calls and the schema surface for zero information. Folding it into `task_output` matches the only real usage pattern.
### Why not `ToolDefinition.timeoutMs` (the timeout-policy plugin) for `task_output`'s wait?
The [timeout library](2026-07-06-timeout-deadline-library.md) gives `wait()` its timing internals — `ctx.tasks.wait` arms a `deadline()` and classifies wait-timeout vs caller-abort with `timeoutOf` scoped to `TASK_WAIT_TIMEOUT` — but the tool-call-level policy is deliberately NOT adopted: timeout-policy replaces a timed-out call with a structured `TOOL_TIMEOUT` failure, whereas a timed-out `task_output(wait: true)` is a SUCCESS that must still report `[status: running]` (the model needs the task's state either way, and the task keeps running). The wait therefore bounds its own deadline through the tool's `waitTimeoutMs`/`maxWaitTimeoutMs` config. For the same reason, no timeout policy manages a background task's LIFETIME: once the id is returned the work is off the tool-call deadline entirely — cancellation belongs to `task_kill` and owner cleanup.
### Why not a push-sink producer contract (`appendOutput`/`settle`), as Kimi Code's manager uses?
A sink centralizes output buffering, truncation, and spill in the runtime, which is elegant when the runtime owns output storage. In this codebase those concerns already live — bounded, tested, spill-file-aware — inside `dsh-bash-local`, and keeping process concerns in the executor is the point of the bash seam. The pull contract (`readOutput()` returning a formatted delta) reuses that machinery as-is; a sink would relocate it for no v1 gain. If a durable backend later makes the runtime own output storage, the producer contract is the one seam to revisit.

View File

@@ -8,7 +8,7 @@ The background task registry (`ctx.tasks`): a runtime-global, CONCRETE service (
- `get(id, caller?)` / `list(caller?)` — non-consuming snapshots; `list` returns only caller-owned plus unowned tasks (a global listing would leak foreign labels).
- `read(id, caller?): TaskRead` — stream kinds consume the per-task cursor (v1's single intended reader is the owning model — a non-consuming multi-reader surface would be a cursor/snapshot API extension, not a `read` change); final kinds read the terminal output idempotently.
- `kill(id, caller?, reason?)``'requested'` (live task: producer `cancel` runs first — a throw fails the kill loud and leaves the task untouched — then `stopping`) or `'already-terminal'`. Every successful kill marks the task `reported` (the killer saw the end → completion notice suppressed).
- `wait(id, timeoutMs, caller?, signal?)` — resolves with the terminal snapshot (marked `reported`), or the live snapshot at timeout; an aborted signal rejects the WAIT only.
- `wait(id, timeoutMs, caller?, signal?)` — resolves with the terminal snapshot (marked `reported`), or the live snapshot at timeout; an aborted signal rejects the WAIT only. Timing is a [`dsh-timeout`](../../util/timeout/README.md) `deadline()` scoped to the `TASK_WAIT_TIMEOUT` code, so a nested foreign deadline never misreads as a wait timeout.
- `onTaskDone(listener)` — exactly once per task with the terminal snapshot; effect-scoped, per-listener containment, silent after service disposal.
- `attachSurface(name)` — declares a control surface exists (the model tools, or a deployment's custom surface); effect-scoped.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tasks",
"description": "Background task registry (ctx.tasks) for the DeepSeek Harness shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work",
"description": "Background task registry (ctx.tasks) for the DeepSeek Harness \u2014 shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -24,12 +24,14 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

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

View File

@@ -19,6 +19,9 @@
},
{
"path": "../../core/agent"
},
{
"path": "../../util/timeout"
}
]
}

View File

@@ -125,6 +125,11 @@ export function apply(ctx: Context, config: Config): void {
+ 'final-output tasks (subagent) return the final answer once the task finishes. '
+ 'Every response ends with a [status: ...] line. Non-blocking by default; '
+ 'set `wait: true` to block until the task finishes (bounded by a capped timeout) when you are genuinely blocked on its result.',
// Deliberately NO ToolDefinition.timeoutMs: the timeout-policy plugin
// replaces a timed-out call with a structured TOOL_TIMEOUT failure, but a
// timed-out wait here is a SUCCESS that reports [status: running] — the
// task's state must reach the model either way, so the wait bounds its
// own deadline (waitTimeoutMs/maxWaitTimeoutMs) via ctx.tasks.wait.
parameters: {
task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
wait: { type: 'boolean', description: 'Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive.' },

3
pnpm-lock.yaml generated
View File

@@ -893,6 +893,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)