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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
Reference in New Issue
Block a user