docs(tasks): condense background task prose

The background-task change repeated its lifecycle design across implemented RFCs, package READMEs, JSDoc, test commentary, and model-visible schemas. That repetition obscured the contracts that maintainers must preserve and added avoidable prompt tokens.

Rewrite the implemented RFCs around the current design, keep authorization, exact-owner cleanup, wait/abort ordering, producer quiescence, and teardown-failure guarantees at their owning surfaces, and remove peer surveys, review history, control-flow narration, and emphatic restatement.

Shorten the task and subagent schema wording, synchronize the bilingual tool cookbook, and regenerate the config, service, RFC, tool, and replay snapshot derivatives. Runtime behavior is unchanged; test edits update prose-only assertions and descriptions.
This commit is contained in:
Tianyi Cui
2026-07-15 21:08:58 +08:00
parent 306b79fa2c
commit 8bb8ac8b3c
38 changed files with 548 additions and 1156 deletions

View File

@@ -1,10 +1,10 @@
# tasks/ — background task capability family
The shared background-task runtime: ONE home for task ids, owner isolation, polling, cancellation, wait, and completion notification, so bash, subagents, and every future long-running tool expose the same model-facing habit instead of cloning a private task protocol each. Rationale and the full design: [the background-task-runtime RFC](../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
The shared home for background-task ids, owner isolation, reads, cancellation, waiting, and completion notices. Bash, subagents, and future long-running tools use one model-facing protocol. See the [background-task runtime RFC](../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
| Package | ctx key | Role |
|---|---|---|
| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry service: branded `<kind>-N` ids, owner-fenced read/kill/wait/list, settlement bookkeeping, the awaited owner-cleanup path, and the `attachSurface` misconfiguration fence |
| [`tool-tasks`](tool-tasks/README.md) (`@deepseek-ai/dsh-tool-tasks`) | — | The model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection, and the background-habit prompt section |
The split is the state/surface boundary: the registry holds task state (an HMR reload of any tool plugin never orphans or kills a running task), while the tool surface is stateless presentation. Producers (`dsh-tool-bash`, `dsh-tool-subagent`) hand their work to `ctx.tasks.start` (preflight, then the producer's starter, then an atomic commit) and keep their own execution concerns; whether a producer exposes `run_in_background` is that producer's own `enableRunInBackground` config, never rewritten by this family.
The registry owns state across producer or surface reloads; the tool package owns presentation. Producers register execution hooks through `ctx.tasks.start` and own whether their config exposes `run_in_background`.

View File

@@ -1,35 +1,34 @@
# @deepseek-ai/dsh-tasks
The background task registry (`ctx.tasks`): a runtime-global, CONCRETE service (no interface/implementation split — one sensible in-process implementation exists; a durable job backend would own that extraction) that gives every long-running tool the same ids, isolation, and lifecycle.
The process-local background task registry (`ctx.tasks`). It gives long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup. The service is concrete; a durable backend can introduce an interface when its different lifecycle is specified.
## Service API
- `start(spec): TaskId` — declare-then-execute: the producer hands identity (`kind` — also the id prefix — `label`, optional `owner: Agent`) plus `run()`, the starter that returns the work's `TaskHooks` (`cancel(reason?)`, `done: Promise<TaskOutcome>` settling at QUIESCENCE and never rejecting, optional `readOutput()` for stream kinds; absence = final-output-only). Every check that can fail — the control-surface fence (the loud guard against a deployment exposing `run_in_background` with no way to collect or stop the work), validation, the owner-cleanup attach — runs BEFORE `run()` starts the actual work, and nothing can fail after it returns: work started without a collectable id is structurally impossible, not a producer rollback obligation.
- `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 — unless the task already settled, in which case the wait still resolves and delivers the terminal snapshot (settlement suppressed the completion notice on this waiter's behalf, so rejecting would leave the finish both unreported and un-noticed). 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; timeout and abort detach their settlement resolver immediately, keeping retention bounded while the task remains live.
- `onTaskDone(listener)` — exactly once per terminal task record, with the snapshot plus its exact lifecycle owner (or `undefined`); effect-scoped, contains synchronous throws and returned promise rejections without awaiting listener work, silent after service disposal.
- `attachSurface(name)` declares a control surface exists (the model tools, or a deployment's custom surface); effect-scoped.
- `start(spec): TaskId` validates the control surface, spec, and exact live owner before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks.
- `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks.
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
- `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter.
- `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited.
- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached.
Every read/kill/wait/get compares the task's owner session (`owner.session.header.id`) with the caller's and rejects a foreign one — ids are predictable (`bash-1`), so the fence, not id secrecy, is the isolation boundary.
Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal.
## Lifecycle
- Registrations are NOT effect-scoped to the registering fiber: tasks belong to their owning agent + producing backend, so producer/surface HMR reloads never touch them.
- An owned task retains the exact live `Agent` instance validated at start and attaches one awaited cleanup through `owner.ctx`: agent-scope disposal selects only that instance's tasks, cancels them, awaits contract-compliant producers to quiescence, and drops their snapshots. Reused agent/session ids cannot make an old cleanup sweep replacement work. If a teardown cancel throws, it force-fails the record and logs that the underlying work may be orphaned rather than deadlocking `AgentHandle.dispose()`.
- Service disposal closes the listener registry first, applies the same cancellation rule to every live task, awaits terminal records, then detaches its effects from still-live agent scopes so a reloaded tasks service is not retained until those agents exit.
- A producer whose `cancel` returns but never causes `done` to settle remains indistinguishable from a slow stop and can stall teardown; solving that residual requires an explicit bounded-lifetime or forced-disposal design.
Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
See the [task type catalog](../../../docs/core-data-structures/tasks.md) and [runtime RFC](../../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
## Model Experience
Indirectly, through `dsh-tool-tasks` and producer plugins, which render task ids, output, status, and completion notices.
Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices.
## Known Limitations and Deferred Work
- **Tasks are process-local** — durable or cross-restart execution is deferred.
- **Stream output has one consuming cursor** — non-consuming observation and multiple independent readers require a separate cursor/snapshot API.
- **Foreground work cannot be promoted** — producers must choose foreground or background before execution starts.
- **A silently ineffective producer cancel can stall teardown** — the runtime can force-settle an explicit cancel throw, but cannot distinguish a slow stop from a cancel that returned without stopping work.
See the [runtime RFC](../../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) § Alternatives for the deferred designs.
- **Tasks are process-local** — durable or cross-restart execution needs a separate lifecycle.
- **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API.
- **Foreground work cannot be promoted** — producers choose foreground or background before starting.
- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely.

View File

@@ -1,32 +1,11 @@
/**
* The background task registry (`ctx.tasks`): ONE home for the semantics every
* long-running tool needs — branded task ids, owner-scoped isolation, status
* snapshots, incremental/final output reads, cancellation, wait-for-terminal,
* completion listeners, and the awaited owner-cleanup path. Producers
* (`dsh-tool-bash` background commands, `dsh-tool-subagent` background
* delegations, future long-running tools) hand their work to
* {@link TaskService.start} — preflight, then the producer's starter, then an
* atomic commit — and keep their own execution concerns; the
* model-facing control surface (`@deepseek-ai/dsh-tool-tasks`) drives the
* generic read/list/kill/wait operations.
*
* A CONCRETE service, not an interface/implementation seam pair: there is one
* sensible in-process implementation today, and the capability-seam convention
* says not to split preemptively (see the background-task-runtime RFC).
*
* Cross-session isolation lives IN the registry: task ids are runtime-global
* and predictable (`bash-1`, `subagent-1`), so every read/kill/wait compares
* the task's owner session against the caller and rejects a foreign one —
* every surface gets the fence for free instead of re-implementing it.
*
* Task registrations are NOT effect-scoped to the registering fiber: a task
* belongs to its owning agent and producing backend, not to the tool plugin
* whose call started it, so an HMR reload of a producer or of the control
* surface never orphans or kills a running task. The registry's own disposal
* cancels every live task and awaits contract-compliant producers to
* quiescence. If a teardown cancel throws, the registry force-fails its record
* to avoid deadlock and logs that the underlying work may be orphaned.
* The in-process background task registry (`ctx.tasks`). It owns task ids,
* session-scoped access, lifecycle state, completion listeners, and owner
* cleanup while producers retain their execution resources.
*
* Registrations outlive producer and control-surface fibers. Agent or service
* disposal cancels live work and awaits compliant producers; a throwing
* teardown cancel force-fails only the record and reports a possible orphan.
* @module @deepseek-ai/dsh-tasks
*/
@@ -53,12 +32,7 @@ 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.
*/
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
@@ -78,9 +52,9 @@ interface TrackedTask {
reported: boolean
/** Resolves once the terminal snapshot is recorded and listeners notified. */
settled: Promise<void>
/** Resolver for {@link settled} (called by the first effective {@link TaskService.settle}). */
/** Resolver for {@link settled}, called by the first effective settlement. */
markSettled: () => void
/** Live {@link TaskService.wait} calls — a settlement with waiters marks the task reported. */
/** Live waits; settlement with a waiter marks the task reported. */
waiters: number
/** Removable resolvers for live waits; timeout/abort unregister before the task settles. */
waitResolvers: Set<() => void>
@@ -101,15 +75,9 @@ export class TaskService extends Service {
private surfaces = new Set<symbol>()
private listeners = new Set<TaskDoneListener>()
private listenersClosed = false
/** Owner agents whose scope cleanup is attached, mapped to its exact disposer. */
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
/**
* The service's OWN construction-time context, for work that outlives the
* calling fiber: detached settlement continuations (logging), and the
* service teardown. Owner cleanup itself is registered through the owning
* agent's scope so it survives producer-plugin reloads and participates in
* the agent's structural quiescence boundary.
*/
/** Service context used by detached settlement continuations and teardown. */
private readonly selfCtx: Context
constructor(ctx: Context) {
@@ -119,24 +87,14 @@ export class TaskService extends Service {
}
/**
* PREFLIGHT, start, then atomically register background work; returns its
* task id (`<kind>-N`, per-kind counter). Every check that can fail — the
* control-surface fence ({@link attachSurface}; a task the model could
* never read or stop must fail loud before it exists), kind/label
* validation, exact live owner-instance identity, and the owner's awaited
* disposal-cleanup attach (once per owner agent, through `owner.ctx`) — runs BEFORE
* `spec.run()` starts the actual work, and nothing in the runtime can fail
* after it returns: "work started but never got a collectable id" is
* structurally impossible, not a producer rollback obligation. The runtime
* attaches ONE continuation to the returned `done` that records the
* terminal snapshot, notifies {@link onTaskDone} listeners, and releases
* waiters. A throwing `run()` propagates with nothing registered (the
* producer owns any partial cleanup of its own failed start).
* @param spec - the task's identity/owner plus the `run()` starter (see {@link TaskStart}).
* @returns the registry-issued task id.
* Preflight access, validation, and owner cleanup before starting and
* atomically registering work. A throwing starter leaves nothing registered;
* after it returns, registration cannot fail. Settlement records the outcome,
* notifies listeners, and releases waiters.
* @param spec - task identity, owner, and synchronous starter.
* @returns the registry-issued `<kind>-N` id.
*/
start(spec: TaskStart): TaskId {
// -- Preflight: everything that can throw, before any work or mutation. --
if (this.surfaces.size === 0) {
throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
}
@@ -144,10 +102,7 @@ export class TaskService extends Service {
if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
// -- Start: the producer's work begins only now, preflight-clean. --
const hooks = spec.run()
// -- Commit: pure mutations; nothing below can throw. --
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = TaskId(`${spec.kind}-${count}`)
@@ -177,8 +132,7 @@ export class TaskService extends Service {
void hooks.done.then(
(outcome) => { this.settle(task, outcome) },
(error: unknown) => {
// Producer contract violation (`done` must never reject) — contained
// as a failed outcome so waiters, cleanup, and disposal never hang.
// Contain a producer contract violation so cleanup and waiters cannot hang.
this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`)
this.settle(task, { status: 'failed', detail: String(error) })
},
@@ -187,11 +141,10 @@ export class TaskService extends Service {
}
/**
* The caller-VISIBLE tasks (owned by the caller's session, or unowned), in
* registration order. Never lists another session's tasks — a global
* listing would leak their labels across the isolation fence.
* @param caller - the reading agent; undefined (a non-agent caller) sees only unowned tasks.
* @returns fresh snapshots; mutating them does not affect the registry.
* List caller-owned and unowned tasks in registration order without exposing
* another session's labels.
* @param caller - reading agent; a non-agent caller sees only unowned tasks.
* @returns fresh snapshots.
*/
list(caller?: Agent): TaskSnapshot[] {
const session = caller?.session.header.id
@@ -201,12 +154,10 @@ export class TaskService extends Service {
}
/**
* A non-consuming snapshot of one task — unlike {@link read}, never touches
* the stream cursor or the reported flag (the kill surface uses it to
* describe an already-terminal task WITHOUT eating a pending delta).
* Throws for an unknown id or a task owned by another session.
* @param id - the task to look up.
* @param caller - the reading agent, checked against the task's owner.
* Return a non-consuming snapshot without changing its read cursor or notice
* state. Throws for an unknown or foreign task.
* @param id - task to look up.
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
get(id: TaskId, caller?: Agent): TaskSnapshot {
@@ -216,15 +167,12 @@ export class TaskService extends Service {
}
/**
* Read a task's output. Stream kinds (registered with `readOutput`) yield
* the CONSUMING delta since the previous read — one cursor per task, the
* owning model is v1's single intended reader; final-output kinds yield
* empty text while live and the terminal output idempotently once settled.
* A read that returns the terminal state marks the task {@link TaskSnapshot.reported}.
* Throws for an unknown id or a task owned by another session.
* @param id - the task to read.
* @param caller - the reading agent, checked against the task's owner.
* @returns the read text plus the post-read snapshot.
* Read the next stream delta, or the idempotent final output after settlement.
* A terminal read marks the task reported. Throws for an unknown or foreign
* task.
* @param id - task to read.
* @param caller - reading agent checked against the owner.
* @returns output text and the post-read snapshot.
*/
read(id: TaskId, caller?: Agent): TaskRead {
const task = this.expect(id)
@@ -237,18 +185,13 @@ export class TaskService extends Service {
}
/**
* Request cancellation of a task. A live task has its producer
* `cancel(reason)` invoked FIRST — a throw propagates (fail loud) and
* leaves the task untouched (still `running`, notice not suppressed) —
* then moves to `stopping` and settles through the normal `done` path; an
* already-terminal task is reported, not failed. Every SUCCESSFUL kill
* marks the task {@link TaskSnapshot.reported}: the killer has seen (or
* asked for) the end, so the completion notice is suppressed. Throws for
* an unknown id or a task owned by another session.
* @param id - the task to cancel.
* @param caller - the killing agent, checked against the task's owner.
* @param reason - the surface's logged reason, forwarded to the producer.
* @returns 'requested' when cancellation was asked of a live task, 'already-terminal' otherwise.
* Request cancellation, then mark the task stopping and reported. A producer
* throw propagates without changing task state. Throws for an unknown or
* foreign task.
* @param id - task to cancel.
* @param caller - killing agent checked against the owner.
* @param reason - logged reason forwarded to the producer.
* @returns `requested` for live work, otherwise `already-terminal`.
*/
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal' {
const task = this.expect(id)
@@ -257,12 +200,7 @@ export class TaskService extends Service {
task.reported = true
return 'already-terminal'
}
// Producer cancel FIRST: a throw must leave the task untouched (still
// `running`, notice not suppressed) — the killer's tool call fails loud,
// but task_list and the eventual completion notice keep telling the
// truth about a cancellation that never happened. Cancel is synchronous
// and settlement lands on a later microtask, so the mutations below
// cannot race the settle path.
// Cancel first so a throw leaves both lifecycle and notice state unchanged.
task.cancel(reason)
task.status = 'stopping'
task.reported = true
@@ -270,23 +208,16 @@ export class TaskService extends Service {
}
/**
* Wait for a task to settle, bounded by a timeout. Resolves with the
* terminal snapshot (marked {@link TaskSnapshot.reported} — the wait
* response reports the end, so the completion notice is suppressed), or
* with the still-live snapshot when the timeout expires first. An abort of
* `signal` rejects the WAIT only (the task keeps running) — UNLESS the task
* has already settled: settlement saw this live waiter and suppressed the
* completion notice on its behalf, so the wait still resolves and delivers
* the terminal snapshot it owes (an abort must never leave a finished task
* both unreported and notice-suppressed). Each live wait uses a removable
* resolver that timeout/abort detaches, so a long-running task does not
* retain expired waits. Throws for an unknown id, a task owned by another
* session, or a non-positive timeout.
* @param id - the task to wait for.
* @param timeoutMs - max wait in milliseconds (positive, finite; the surface caps it).
* @param caller - the waiting agent, checked against the task's owner.
* @param signal - optional abort for the wait itself.
* @returns the snapshot at settlement, or at timeout when the task outlives the wait.
* Wait for settlement or timeout without cancelling the task. Caller abort
* rejects only while the task is live; after settlement it returns the
* terminal snapshot so a notice suppressed for this waiter is still delivered.
* Timed-out and aborted waits detach their resolvers. Throws for invalid,
* unknown, or foreign input.
* @param id - task to wait for.
* @param timeoutMs - positive finite wait bound in milliseconds.
* @param caller - waiting agent checked against the owner.
* @param signal - optional cancellation of the wait itself.
* @returns snapshot at settlement or timeout.
*/
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> {
const task = this.expect(id)
@@ -296,12 +227,8 @@ export class TaskService extends Service {
}
if (!isTerminal(task.status)) {
if (signal?.aborted) throw new Error('wait aborted')
// `waiters` is the settle-path heuristic "someone WILL deliver the
// terminal snapshot, suppress the notice". An abort breaks that promise,
// so the un-count must happen SYNCHRONOUSLY inside onAbort — the
// `finally` decrement alone runs a microtask later, after a same-tick
// settlement could already have read the stale count and suppressed the
// notice for a waiter that then rejects and delivers nothing.
// Abort removes the waiter synchronously so same-tick settlement cannot
// suppress a notice for a wait that will reject.
task.waiters += 1
let counted = true
const uncount = (): void => {
@@ -310,11 +237,8 @@ export class TaskService extends Service {
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.
// The scoped deadline distinguishes a successful wait timeout from
// caller cancellation and clears its timer on every exit.
using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT)
await new Promise<void>((resolve, reject) => {
const onSettled = (): void => {
@@ -327,8 +251,7 @@ export class TaskService extends Service {
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
resolve()
} else if (isTerminal(task.status)) {
// Settlement already ran and suppressed the notice for this
// waiter — deliver the terminal snapshot instead of rejecting.
// Settlement suppressed the notice for this waiter; deliver it.
resolve()
} else {
uncount()
@@ -347,14 +270,11 @@ export class TaskService extends Service {
}
/**
* Register a completion listener, called exactly once per terminal task
* record with its snapshot and exact lifecycle owner (or `undefined` for an
* unowned task). Effect-scoped (disposed with the calling fiber); per-listener
* containment (one throwing or rejecting listener is logged, never starves
* the rest); returned promises are observed but not awaited; never fires
* after this service is disposed.
* @param listener - called with each terminal snapshot and its exact owner.
* @returns the disposer that unregisters the listener.
* Register an effect-scoped completion listener. Each listener is contained;
* returned promises are observed but not awaited. No listener runs after
* service disposal.
* @param listener - receives each terminal snapshot and its exact owner.
* @returns disposer that unregisters the listener.
*/
onTaskDone(listener: TaskDoneListener): () => void {
const dispose = this.ctx.effect(() => {
@@ -365,19 +285,13 @@ export class TaskService extends Service {
}
/**
* Declare that a control surface capable of reading/stopping tasks is
* loaded. {@link start} refuses to start a background task while NO
* surface is attached — the loud fence against a deployment exposing
* `run_in_background` without any way to collect or stop the work. The
* model-facing `@deepseek-ai/dsh-tool-tasks` attaches on load; a deployment
* with a custom (non-model) surface attaches its own. Effect-scoped:
* detached with the calling fiber.
* @param name - a diagnostic label for the surface (duplicate names count independently).
* @returns the disposer that detaches the surface.
* Attach an effect-scoped surface that can read and stop tasks. {@link start}
* refuses work while none is attached.
* @param name - diagnostic label; duplicate names remain independent.
* @returns disposer that detaches this surface.
*/
attachSurface(name: string): () => void {
// One token per attach call: duplicate names stay independent, and the
// single-shot effect disposer removes exactly its own attachment.
// One token per call keeps duplicate labels independently disposable.
const token = Symbol(name)
const dispose = this.ctx.effect(() => {
this.surfaces.add(token)
@@ -421,14 +335,9 @@ export class TaskService extends Service {
}
/**
* Record the first terminal outcome, notify listeners with containment, then
* release waiters. Normally the producer's single `done` continuation calls
* this; teardown also force-fails the record when `cancel` throws and `done`
* may never settle. First-wins makes a producer outcome arriving after that
* fallback a no-op, so listeners fire once and the diagnosed terminal state
* is never overwritten. A settlement observed by a pending {@link wait}
* marks the task reported BEFORE listeners run, so the notice surface can
* suppress its redundant "finished".
* Record the first terminal outcome, notify contained listeners, and release
* waiters. First-wins preserves a teardown force-failure against late producer
* settlement. Pending waits mark the task reported before listeners run.
*/
private settle(task: TrackedTask, outcome: TaskOutcome): void {
if (isTerminal(task.status)) return
@@ -457,17 +366,10 @@ export class TaskService extends Service {
}
/**
* Attach the awaited owner-disposal cleanup for an owner agent, once. The
* effect is registered through `owner.ctx`, so it belongs to the agent scope
* rather than the producer or long-lived tasks fiber: it survives producer
* reloads, runs at the structural agent quiescence boundary, and removes its
* wrapper automatically when that scope unwinds. The tasks service retains
* the exact disposer only so service teardown can detach cross-fiber effects
* instead of leaving a dead service captured by still-live agents.
* Fails loud when no agent registry is mounted or when `owner` is not the
* exact live instance currently registered under its id — accepting a stale
* object after id reuse would attach its session's task to another agent's
* lifecycle.
* Attach one awaited cleanup through the exact owner's scope. This survives
* producer reloads and joins agent quiescence; the retained disposer lets
* service teardown detach the cross-fiber effect. Fails when the registry is
* absent or the owner is not its currently registered instance.
*/
private ensureOwnerCleanup(owner: Agent): void {
const ownerId = owner.id
@@ -479,8 +381,7 @@ export class TaskService extends Service {
throw new Error(`agent "${ownerId}" is not the registered agent instance (background task owner must be live)`)
}
if (this.ownerCleanups.has(owner)) return
// Attach FIRST, record after: an already-disposing scope rejects effects,
// and marking the owner as covered before that would poison later starts.
// Record only after attach succeeds; a disposing scope rejects new effects.
const detach = owner.ctx.effect(() => async () => {
this.ownerCleanups.delete(owner)
await this.disposeOwned(owner)
@@ -497,11 +398,8 @@ export class TaskService extends Service {
}
/**
* Service teardown: close the listener registry FIRST (late completions
* from teardown kills stay silent), cancel every live task, and await each
* terminal record. Contract-compliant producers settle at quiescence; a
* producer whose cancel throws is force-failed so disposal cannot deadlock,
* with the possible underlying orphan logged explicitly.
* Close listeners, cancel live tasks, await settlement, and detach owner
* effects. Throwing cancels are force-failed to avoid teardown deadlock.
*/
private async disposeAll(): Promise<void> {
this.listenersClosed = true
@@ -510,24 +408,16 @@ export class TaskService extends Service {
this.cancelForTeardown(all, 'tasks service disposed')
await Promise.all(all.map(task => task.settled))
this.store.clear()
// These effects belong to agent scopes, not this service's fiber. Detach
// them after the shared store is quiescent so a tasks-service reload cannot
// leave old callbacks retaining the dead service until each agent exits.
// Detach cross-fiber owner effects after the shared store is quiescent.
const ownerCleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
await Promise.all(ownerCleanups.map(cleanup => Promise.resolve(cleanup())))
}
/**
* Teardown-path cancellation with per-task containment: unlike the
* model-facing {@link kill} (where a throwing producer `cancel` fails the tool
* call and leaves the record live), teardown force-fails a record whose cancel
* throws because its `done` may depend on a request that never arrived. This
* prevents disposal deadlock but cannot prove the underlying work stopped, so
* the potential orphan is carried in the detail and warning. A cancel that
* returns but never leads to `done` remains indistinguishable from a slow stop
* and can still stall teardown; fixing that requires a separate bounded-lifetime
* or forced-disposal design.
* Cancel tasks during teardown with per-task containment. A throwing cancel
* force-fails the record and reports a possible orphan; a cancel that returns
* without settling remains indistinguishable from a slow stop and may stall.
*/
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
for (const task of tasks) {

View File

@@ -1,9 +1,6 @@
/**
* Task-runtime vocabulary: the {@link TaskStart} a producer hands to
* {@link TaskService.start} (identity + the `run()` starter), the
* {@link TaskHooks} its work is driven through, and the snapshots/reads
* consumers get back. Types only — the service lives in `./index.ts`.
*
* Types shared by task producers, the registry, and control surfaces. The
* service implementation lives in `./index.ts`.
* @module @deepseek-ai/dsh-tasks/types
*/
@@ -12,10 +9,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
/**
* Identifies one background task in the runtime-global registry. Generated by
* {@link TaskService.start} as `<kind>-N` (per-kind counter) — kind-prefixed
* so transcripts stay self-describing, sequential because the owner fence (not
* id secrecy) is the isolation boundary.
* Identifies a background task. The registry generates `<kind>-N`; predictable
* ids rely on owner authorization rather than secrecy.
*/
export type TaskId = Branded<'TaskId'>
@@ -29,40 +24,25 @@ export function TaskId(id: string): TaskId {
}
/**
* Task lifecycle. `running` → (`stopping` when cancellation was requested) →
* exactly one terminal {@link TaskOutcome.status} (`completed`, `killed`,
* `failed`). The vocabulary is generic and CLOSED — kind-specific meaning
* (exit codes, stop reasons) rides in {@link TaskSnapshot.detail}, so the
* registry never learns process or agent semantics.
* Task lifecycle: `running`, optionally `stopping`, then exactly one terminal
* status. Producer-specific facts belong in {@link TaskSnapshot.detail}.
*/
export type TaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
/**
* The terminal result a producer's {@link TaskHooks.done} resolves
* with, mapped from the producer's own vocabulary (a process exit, a subagent
* stop reason) into the registry's closed status set.
*/
/** Terminal result supplied by a producer through {@link TaskHooks.done}. */
export interface TaskOutcome {
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
status: 'completed' | 'killed' | 'failed'
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
detail?: string
/**
* Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskHooks.readOutput}),
* read idempotently after the task settles. Stream kinds leave it unset —
* their output is consumed incrementally through `readOutput`.
*/
/** Final output for tasks without `readOutput`; stream tasks leave it unset. */
output?: string
}
/**
* What a producer hands to {@link TaskService.start}: the task's identity and
* owner (preflighted BEFORE any work starts), plus {@link run} the starter
* the runtime invokes only once preflight cannot fail anymore. The producer
* stays the owner of its execution concerns (process streams, child agents);
* the runtime owns ids, isolation, status, and completion fan-out. This
* declare-then-execute split is what makes "work started but never got a
* collectable id" structurally impossible.
* Producer declaration passed to {@link TaskService.start}. The runtime
* preflights access and cleanup before invoking {@link run}; the producer owns
* execution resources while the runtime owns identity and lifecycle state.
*/
export interface TaskStart {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
@@ -70,56 +50,38 @@ export interface TaskStart {
/** One-line model-facing label (the command; the delegation description). */
label: string
/**
* The spawning agent. Its `session.header.id` becomes the task's owner
* identity (read/kill/wait/list are fenced to that session), and its `ctx` scope
* owns an async cleanup that cancels and awaits the task during disposal. It
* must be the exact live instance currently registered under its agent id;
* a stale object whose id has been reused is rejected before work starts.
* `undefined` starts an UNOWNED task: open to any caller, alive until the
* tasks service disposes.
* Owning live agent. Access is fenced by its session id, and agent disposal
* cancels and awaits the task. The instance must be the one currently
* registered under its agent id. `undefined` creates an unowned task, open to
* any caller until service disposal.
*/
owner?: Agent | undefined
/**
* Start the actual work and return its {@link TaskHooks}. Called EXACTLY
* once, synchronously, after every preflight check (control-surface fence,
* validation, owner-cleanup attach) has passed — nothing in the runtime can
* fail after it returns, so the started work is always registered. A throw
* here propagates with nothing registered; the producer owns any partial
* cleanup of its own failed start.
* Start the work after preflight and synchronously return its hooks. Called
* once; a throw leaves nothing registered, and the producer must clean up any
* partially started resources.
*/
run(): TaskHooks
}
/**
* The live-work hooks a {@link TaskStart.run} returns: how the runtime
* cancels the work, observes its settlement, and (for stream kinds) reads
* its incremental output.
*/
/** Hooks through which the runtime controls and observes producer work. */
export interface TaskHooks {
/**
* Request termination. Idempotent, synchronous, and must lead to
* {@link done} settling; a throw propagates to the killer (fail loud — a
* cancel that cannot even be requested is a producer bug). The optional
* reason is `task_kill`'s logged reason, forwarded verbatim.
* Request termination. Must be synchronous, idempotent, and eventually settle
* {@link done}; throws propagate. The optional reason is forwarded verbatim.
*/
cancel(reason?: string): void
/**
* Settles with the terminal outcome at QUIESCENCE — after the producer has
* released the task's resources (process exited, child agent disposed) —
* not merely when the work finished. Must never reject; a rejection is
* contained as a `failed` outcome and logged as a producer contract
* violation. If `cancel` throws during teardown, the runtime may force-fail
* only its registry record to avoid deadlock because this promise may never
* settle; that fallback explicitly does not claim work quiescence.
* Resolves after the producer releases its resources, not merely when work
* finishes. Must not reject; the runtime converts a rejection to `failed`.
* If teardown cancellation throws, the runtime may force-fail only the
* registry record without claiming that the work stopped.
*/
done: Promise<TaskOutcome>
/**
* OPTIONAL incremental read (stream kinds): everything produced since the
* previous call, formatted by the producer (truncation/spill notices
* included). Consecutive calls never re-deliver output; the registry keeps
* ONE consuming cursor per task, so v1's single intended reader is the
* owning model. Absence marks a final-output-only kind (the method presence
* IS the capability).
* Consume output produced since the previous call. The producer formats
* truncation and spill notices. Absence marks a final-output-only task; each
* task has one consuming cursor.
*/
readOutput?(): string
}
@@ -136,12 +98,9 @@ export interface TaskSnapshot {
/** The producer-supplied one-line label. */
label: string
/**
* The owner's session id (`session.header.id`), for authorization and
* correlation; absent for unowned tasks. A listener that must reach the
* lifecycle owner receives the exact Agent separately through
* {@link TaskDoneListener}. Session ids are runtime-shared identifiers, not
* secrets — the read/kill/wait/list FENCE is what isolation rests on. The
* shared {@link SessionId} brand is preserved across this package boundary.
* Owner session id used for authorization and correlation; absent for
* unowned tasks. Completion listeners receive the exact {@link Agent}
* separately through {@link TaskDoneListener}.
*/
ownerSession?: SessionId
/** Current lifecycle state. */
@@ -153,20 +112,13 @@ export interface TaskSnapshot {
/** Epoch ms when the task settled; absent while `running`/`stopping`. */
finishedAt?: number
/**
* True once the terminal state has been (or is being) reported to the owner
* through an explicit surface response — a `kill` call, or a `read`/`wait`
* that returned the terminal state (including a wait pending at settlement).
* Completion-notice surfaces suppress their notice when set, so the model
* never gets a redundant "finished" for a task it just collected or killed.
* True when a kill, read, or wait has reported or committed to report the
* terminal state. Completion surfaces suppress redundant notices when set.
*/
reported: boolean
}
/**
* One {@link TaskService.read}: the output text this read yields (may be
* empty — the surface decides how to render "nothing new") plus the snapshot
* taken after the read.
*/
/** Output and post-read state returned by {@link TaskService.read}. */
export interface TaskRead {
/**
* Stream kinds: the consuming delta since the previous read. Final-output
@@ -179,10 +131,8 @@ export interface TaskRead {
}
/**
* Completion callback registered via {@link TaskService.onTaskDone}.
* `owner` is the exact lifecycle instance supplied at start, not a registry
* lookup by reusable agent or session id; it is absent for unowned tasks. A
* returned promise is observed for rejection but does not delay settlement.
* Completion callback with the exact owner supplied at start, or `undefined`
* for an unowned task. Returned promises are observed but not awaited.
*/
export type TaskDoneListener = (
snapshot: TaskSnapshot,

View File

@@ -269,7 +269,7 @@ describe('TaskService.wait', () => {
const wait = ctx.tasks.wait(id, 5_000)
p.settle({ status: 'completed', detail: 'exit code: 0' })
expect(await wait).toMatchObject({ status: 'completed', reported: true })
// The pending wait marked the task reported BEFORE listeners ran.
// A waiting reader claims delivery before completion listeners inspect the snapshot.
expect(seen[0]).toMatchObject({ id, reported: true })
})
@@ -330,7 +330,7 @@ describe('TaskService.wait', () => {
await expect(ctx.tasks.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted')
})
it('an abort racing settlement in the same tick does not swallow the notice (review finding)', async () => {
it('an abort racing settlement in the same tick does not swallow the notice', async () => {
const ctx = await harness()
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
@@ -339,11 +339,8 @@ describe('TaskService.wait', () => {
const controller = new AbortController()
const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
// Same synchronous tick, settlement QUEUED first: the settle continuation
// will run before the rejected wait's `finally`, so only the SYNCHRONOUS
// un-count inside onAbort keeps it from reading a stale waiter count,
// marking the task reported, and suppressing the completion notice for a
// wait that then delivers nothing.
// Settlement is queued first, so abort must remove the waiter synchronously;
// otherwise settlement suppresses the notice for a reader that receives nothing.
p.settle({ status: 'completed', detail: 'exit code: 0' })
controller.abort()
await expect(wait).rejects.toThrow('wait aborted')
@@ -351,15 +348,12 @@ describe('TaskService.wait', () => {
expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
})
it('an abort landing AFTER settlement still delivers the terminal snapshot it owes', async () => {
it('an abort landing after settlement still delivers the terminal snapshot it owes', async () => {
const ctx = await harness()
const controller = new AbortController()
const seen: TaskSnapshot[] = []
// The listener runs synchronously inside settle — aborting HERE lands the
// abort after settlement marked this waiter reported (notice suppressed)
// but before the wait's own resolve microtask. Rejecting now would leave
// the finished task both unreported and notice-suppressed, so the wait
// must resolve and deliver instead.
// The listener aborts after settlement has assigned delivery to this waiter
// but before its resolve microtask; the waiter must still receive the result.
ctx.tasks.onTaskDone((snapshot) => {
seen.push(snapshot)
controller.abort()
@@ -426,14 +420,12 @@ describe('TaskService owner isolation', () => {
const ctx = await harness()
const ghost = stubAgent(ctx, 'ghost') // never registered in ctx.agents
// Exact-instance preflight rejects the unregistered agent BEFORE any
// registry mutation or owner-cleanup attachment.
// Exact-instance validation precedes registry mutation and cleanup attachment.
expect(() => ctx.tasks.start(producer({ owner: ghost }).spec))
.toThrow('is not the registered agent instance')
expect(ctx.tasks.list(ghost)).toEqual([])
// Once the agent actually exists, the same owner gets a WORKING cleanup —
// the failed attempt must not have marked it as already covered.
// A later valid registration must still attach cleanup for the same object.
ctx.agents.register(ghost)
const cancels: (string | undefined)[] = []
let settle!: (outcome: TaskOutcome) => void
@@ -607,13 +599,11 @@ describe('TaskService owner cleanup', () => {
await tick()
const drainedWithoutProducerDone = drained
if (!drainedWithoutProducerDone) {
// Failure-path cleanup for the pre-fix implementation: let its pending
// drain finish without weakening the assertion captured above.
// Release the producer if the assertion fails so the test can finish.
settle({ status: 'completed' })
await drain
} else {
// A late producer completion must not replace the forced failed record or
// notify listeners a second time.
// A late producer completion must not replace the failure or notify twice.
settle({ status: 'completed' })
await tick()
}
@@ -681,7 +671,7 @@ describe('TaskService disposal', () => {
await tick()
const disposedWithoutProducerDone = disposed
if (!disposedWithoutProducerDone) {
// Failure-path cleanup for the pre-fix implementation.
// Release the producer if the assertion fails so the test can finish.
settle({ status: 'completed' })
await disposal
} else {

View File

@@ -1,35 +1,35 @@
# @deepseek-ai/dsh-tool-tasks
The model-facing background task control surface over `ctx.tasks`: three kind-agnostic tools, the completion-notice injection, and the prompt section that teaches the background habit. Loading this plugin calls `ctx.tasks.attachSurface('tool-tasks')`, which is what arms producers' `ctx.tasks.start()`.
The model-facing control surface for `ctx.tasks`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the surface required by `ctx.tasks.start()`.
## Tools
- `task_output(task_id, wait?, timeout_ms?)` — non-blocking read by default (stream kinds: the consuming delta since the previous read; final kinds: the final answer once terminal); every response ends with a `[status: …]` line (generic status + producer detail, e.g. `[status: completed, exit code: 0]`). `wait: true` blocks until settlement, bounded by `waitTimeoutMs`/`maxWaitTimeoutMs` config; a timed-out wait returns `[status: running]` and leaves the task alive.
- `task_list()` — the caller's tasks, `<id> [<kind>] <status> — <label>` per line.
- `task_kill(task_id, reason?)` requests cancellation and returns immediately; the logged `reason` is forwarded to the producer. An already-terminal task is described via a non-consuming snapshot (never eats a pending delta).
- `task_output(task_id, wait?, timeout_ms?)` reads without blocking by default. Stream tasks return only the next delta; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. `wait: true` waits up to the configured cap and leaves a still-running task alive on timeout.
- `task_list()` returns caller-visible tasks as `<id> [<kind>] <status> — <label>`.
- `task_kill(task_id, reason?)` requests cancellation immediately and forwards the logged reason. Terminal tasks return a non-consuming snapshot.
ACP render intent: all three are `generic` cards (`read`/`read`/`execute`) — a task read is not a terminal.
All three use generic ACP cards: `read` for output and list, `execute` for kill.
## Completion notices
On `onTaskDone`, injects `background task <id> (<kind>: <label>) finished [status: ]. Read its output with task_output.` through the exact owner `Agent` captured at task start (`agent.inject()` durable context for the next request, not a wake-up). It never re-resolves a reusable agent/session id to a replacement. Suppressed when the snapshot is `reported` (the model already killed it, or a read/wait returned the end) — never a redundant "finished"; the disposed-owner race is contained.
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's session. Injection is durable context for the next request, not a wake-up. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice; owner-disposal races are contained.
## Config
| key | default | meaning |
|---|---|---|
| `waitTimeoutMs` | `30000` | wait duration when `task_output` sets `wait` without `timeout_ms` |
| `maxWaitTimeoutMs` | `600000` | hard cap; larger model-supplied `timeout_ms` values are clamped |
| `waitTimeoutMs` | `30000` | wait used when `wait: true` omits `timeout_ms` |
| `maxWaitTimeoutMs` | `600000` | cap for model-supplied waits |
A config whose default exceeds the cap fails loud at load.
A default above the cap fails at load.
## Model Experience
### System prompt
**What the model sees**: Every request in this plugin's registration scope contains the background-task guidance below. Agent-scoped tool filtering can hide the control schemas without removing this independently registered section.
**What the model sees**: Every request in this plugin's registration scope contains this guidance. Agent-scoped tool filtering may hide the tools without removing the independently registered prompt section.
**Token effect**: Small fixed input cost per request while the plugin is active.
**Token effect**: Small fixed input cost per request while active.
#### Background-task guidance
@@ -39,18 +39,18 @@ Track every background task id you start. You are notified in-session when a tas
### Tool schemas
**What the model sees**: The model sees the generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this control surface is visible.
**What the model sees**: The generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this surface is visible.
**Token effect**: Fixed schema cost on each request where the tools are visible.
### Task results and notices
### Results and notices
**What the model sees**: Reads return a producer-owned output delta or `(no new output)`, followed by `[status: <status>]` with optional producer detail. Listing returns `(no background tasks)` or one `<id> [<kind>] <status> — <label>` line per visible task. Kill returns `requested cancellation of task <id>` or `task <id> had already finished [status: ...]`. An unreported owned completion injects exactly `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.`
**What the model sees**: Reads return output or `(no new output)` followed by `[status: <status>]` and optional detail. An empty list returns `(no background tasks)`. Kill returns `requested cancellation of task <id>` or the existing terminal status. Unreported owned completion uses the notice above.
**Token effect**: Results and completion notices are retained in the parent session until compaction; stream reads consume their cursor and do not repeat prior output.
**Token effect**: Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output.
## Known Limitations and Deferred Work
- **Completion notices do not wake idle agents** — they become durable context for the next request; callers needing an immediate result must use `task_output`.
- **Stream reads are single-consumer** — this control surface exposes the task runtime's one consuming cursor rather than independent observers.
- **Unowned tasks have no session fence** — deployments exposing background starts outside an agent must provide their own caller policy or avoid ownerless tasks.
- **Completion notices do not wake idle agents** — callers needing an immediate result must use `task_output`.
- **Stream reads are single-consumer** — independent observers need another runtime API.
- **Unowned tasks have no session fence** — external surfaces must supply caller policy or avoid them.

View File

@@ -1,21 +1,8 @@
/**
* The model-facing background task control tools: `task_output`, `task_list`,
* `task_kill`. Kind-agnostic — a background bash command and a background
* subagent read, list, and die through the same three schemas — with every
* task concern (ids, isolation, cursors, settlement) behind the `ctx.tasks`
* registry (`@deepseek-ai/dsh-tasks`).
*
* This plugin IS the control surface: it calls `ctx.tasks.attachSurface()` on
* load, which is what arms producers' `ctx.tasks.start()` (the runtime's
* preflight refuses background work while no surface could collect or stop it).
*
* Completion notices: when a task settles, a short notice is injected into
* the owning agent's session (`agent.inject()` — durable context for the NEXT
* model request, not a wake-up). A task whose terminal state the model
* already saw (`snapshot.reported` — an explicit kill, or a read/wait that
* returned the end) is suppressed, so the model never gets a redundant
* "finished" for work it just collected.
*
* Model-facing `task_output`, `task_list`, and `task_kill` tools over
* `ctx.tasks`. Loading the plugin attaches the control surface required by
* producers. It also injects unreported completions as durable context for the
* owner's next request; notices do not wake idle agents.
* @module @deepseek-ai/dsh-tool-tasks
*/
@@ -30,7 +17,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
export const name = 'tool-tasks'
export const inject = ['tools', 'tasks', 'systemPrompt']
/** Config: the `task_output` wait bounds (defaulted, capped — never hardcoded). */
/** Configures bounded `task_output` waits. */
export interface Config {
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
waitTimeoutMs?: number
@@ -44,12 +31,9 @@ export const Config: z<Config> = z.object({
})
/**
* Render a snapshot's status line — generic status plus the producer's
* kind-specific detail: `[status: completed, exit code: 0]`,
* `[status: failed, max-tokens]`, `[status: running]`. Exported for tests
* and for producers that want a consistent line in their own results.
* @param snapshot - the task state to render.
* @returns the bracketed status line.
* Render generic status with optional producer detail.
* @param snapshot - task state to render.
* @returns a bracketed status line.
*/
export function statusLine(snapshot: TaskSnapshot): string {
return snapshot.detail !== undefined
@@ -57,11 +41,7 @@ export function statusLine(snapshot: TaskSnapshot): string {
: `[status: ${snapshot.status}]`
}
/**
* Reject an empty `task_id`. Type/presence come from the SchemaSpec
* validation; only the non-empty constraint, which the DSL cannot express,
* is checked here.
*/
/** Validate the non-empty constraint that SchemaSpec cannot express. */
function validateTaskId(value: string): TaskId {
if (value.length === 0) {
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
@@ -69,7 +49,7 @@ function validateTaskId(value: string): TaskId {
return TaskId(value)
}
/** Pending-state presentation shared by the three control tools (generic cards by design — a task read/kill is not a terminal). */
/** Pending presentation shared by the three generic task controls. */
function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: string): GenericCallView {
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
}
@@ -81,24 +61,18 @@ export function apply(ctx: Context, config: Config): void {
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
}
// The registry's misconfiguration fence: producers can register background
// work only while a surface capable of collecting/stopping it is attached.
// Producers may start work only while a control surface is attached.
ctx.tasks.attachSurface('tool-tasks')
// The cross-call HABIT the per-tool descriptions cannot carry. Order 106:
// right after tool:bash (105), before deployment product sections.
// Cross-call guidance follows the bash section and precedes product sections.
ctx.systemPrompt.section({
name: 'tool:tasks',
order: 106,
text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.',
})
// Background completion → inject a notice through the exact lifecycle owner.
// Re-resolving by a reusable agent/session id could target a replacement
// while the old owner's scope is still unwinding.
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
ctx.tasks.onTaskDone((snapshot, owner) => {
// A reported terminal state was already surfaced by an explicit
// read/wait/kill response — a notice would be a redundant "finished".
if (snapshot.reported || owner === undefined) return
try {
owner.inject(
@@ -106,9 +80,7 @@ export function apply(ctx: Context, config: Config): void {
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
)
} catch (error: unknown) {
// The ONE expected failure: the agent was disposed between settlement
// and this injection (inject throws `agent "<id>" is disposed`). That
// race is benign — drop the notice. Anything else must surface.
// Disposal may win the race after settlement; other injection failures surface.
if (error instanceof Error && error.message.includes('is disposed')) return
throw error
}
@@ -116,16 +88,11 @@ export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'task_output',
description: 'Read output/status from a background task (started by a tool with `run_in_background`). '
+ 'Stream tasks (bash) return only output produced since your previous task_output call; '
+ '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.
description: 'Read a background task. Stream tasks return only output since the previous read; '
+ 'final-output tasks return their result after settlement. Every response ends with '
+ '`[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.',
// A timed-out wait returns task state rather than a TOOL_TIMEOUT error, so
// this tool owns its deadline instead of using ToolDefinition.timeoutMs.
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.' },
@@ -149,8 +116,6 @@ export function apply(ctx: Context, config: Config): void {
name: 'task_list',
description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
parameters: {},
// execute is synchronous (registry reads + string shaping) but the
// ToolDefinition contract wants a Promise — hence resolve(), not async.
execute(_args, exec) {
const tasks = ctx.tasks.list(exec.agent)
const text = tasks.length === 0
@@ -172,8 +137,7 @@ export function apply(ctx: Context, config: Config): void {
const id = validateTaskId(args.task_id)
const result = ctx.tasks.kill(id, exec.agent, args.reason)
if (result === 'already-terminal') {
// ctx.tasks.get, NOT .read: a read would consume a stream task's
// pending delta just to describe the terminal state.
// A snapshot describes terminal state without consuming pending output.
const snapshot = ctx.tasks.get(id, exec.agent)
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
}