Просмотр исходного кода

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).
Yichen Jiang 2 месяцев назад
Родитель
Сommit
f858c647a6

+ 1 - 1
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`
 

+ 4 - 0
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.

+ 1 - 1
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.
 

+ 3 - 1
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"
   }
 }

+ 23 - 7
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<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

+ 3 - 0
packages/tasks/tasks/tsconfig.json

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

+ 5 - 0
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.' },

+ 3 - 0
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)