diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4db3b1c6e4..fce449c6bb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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` diff --git a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index a049df012f..5664c19dd5 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -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. diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index ec5a82f52c..14a2f75d6e 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -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. diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 3b2b9532c2..8db26d7ff2 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -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" } } diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index ac9821b849..5d926b5230 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -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((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 diff --git a/packages/tasks/tasks/tsconfig.json b/packages/tasks/tasks/tsconfig.json index 97fb432ffb..bf369ad156 100644 --- a/packages/tasks/tasks/tsconfig.json +++ b/packages/tasks/tasks/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/agent" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 30ff629e88..859c2b0f21 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -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.' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07d0bb41a7..03cb98ede7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)