refactor(tasks): declare-then-execute — ctx.tasks.start() replaces register()
start({ kind, label, owner, run }) preflights everything that can fail
(the attachSurface fence, validation, the owner-cleanup attach) BEFORE
invoking the producer's run() starter, then commits atomically —
'work started but never got a collectable id' is now structurally
impossible instead of a producer try/catch rollback obligation (the
P1 review fix, rebuilt on #185's declare/execute split). Producers
lose their catch-wraps; the leak tests now pin the stronger property
that a failed preflight never spawns anything. TaskRegistration splits
into TaskStart (identity + run) and TaskHooks (cancel/done/readOutput);
docs, type-equiv manifest, catalogs, and both RFCs move with it.
This commit is contained in:
@@ -41,7 +41,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
|
||||
|
||||
## Long-running work
|
||||
|
||||
Register the running work with the shared task runtime instead of inventing a task protocol: gate a `run_in_background` parameter behind your plugin's own defaulted `enableRunInBackground`-style config, start the work, and hand it to `ctx.tasks.register({ kind, label, owner: exec.agent, cancel, done, readOutput? })` (`@deepseek-ai/dsh-tasks`). The runtime issues the `<kind>-N` id, fences access to the owning session, cancels-and-awaits your task when the owner disposes, and the generic `task_output`/`task_list`/`task_kill` tools plus the completion notice come from `@deepseek-ai/dsh-tool-tasks` — your tool returns `started background task <id>` and is done. Your producer keeps its execution concerns: `done` must settle at quiescence (resources released), and a stream-kind `readOutput` owns its own truncation/spill formatting (bound buffers, spill full output to disk so nothing is silently lost — see tool-bash's `renderProcessRead`). Do NOT wire `exec.signal` to the background work after the id is returned; check `exec.signal?.aborted` once before starting, then leave cancellation to `task_kill` and owner cleanup. **A failed `register()` must not orphan the work**: `register()` is atomic (a throw — the no-control-surface fence, a bad owner — mutates no registry state), so wrap it in try/catch, cancel the just-started work, AWAIT its quiescence, and rethrow — the model never learns an id, so nothing else could ever collect or kill what you started (tool-bash's `proc.kill(); await proc.done` and tool-subagent's `run.cancel(); await done` are the templates).
|
||||
Hand long-running work to the shared task runtime instead of inventing a task protocol: gate a `run_in_background` parameter behind your plugin's own defaulted `enableRunInBackground`-style config, then call `ctx.tasks.start({ kind, label, owner: exec.agent, run: () => ({ cancel, done, readOutput? }) })` (`@deepseek-ai/dsh-tasks`) — the runtime preflights everything that can fail (the control-surface fence, validation, owner-cleanup attach) BEFORE invoking your `run()` starter, so work that started without a collectable id is structurally impossible (no try/catch rollback in your tool). The runtime issues the `<kind>-N` id, fences access to the owning session, cancels-and-awaits your task when the owner disposes, and the generic `task_output`/`task_list`/`task_kill` tools plus the completion notice come from `@deepseek-ai/dsh-tool-tasks` — your tool returns `started background task <id>` and is done. Your producer keeps its execution concerns: `done` must settle at quiescence (resources released), and a stream-kind `readOutput` owns its own truncation/spill formatting (bound buffers, spill full output to disk so nothing is silently lost — see tool-bash's `renderProcessRead`). Do NOT wire `exec.signal` to the background work after the id is returned; check `exec.signal?.aborted` once before calling `start`, then leave cancellation to `task_kill` and owner cleanup.
|
||||
|
||||
## Permissions / sandboxing
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/sys
|
||||
The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts.
|
||||
|
||||
```ts cordis-catalog
|
||||
register(registration: TaskRegistration): TaskId
|
||||
start(spec: TaskStart): TaskId
|
||||
list(caller?: Agent): TaskSnapshot[]
|
||||
get(id: TaskId, caller?: Agent): TaskSnapshot
|
||||
read(id: TaskId, caller?: Agent): TaskRead
|
||||
@@ -224,7 +224,7 @@ attachSurface(name: string): () => void
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/tasks/tasks/src/index.ts:93`](../../packages/tasks/tasks/src/index.ts)
|
||||
Source: [`packages/tasks/tasks/src/index.ts:95`](../../packages/tasks/tasks/src/index.ts)
|
||||
|
||||
## `ctx.tools` — `ToolRegistry`
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
|
||||
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
|
||||
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, the background `BashProcess` handle |
|
||||
| [tasks.md](tasks.md) | the background task runtime: `TaskId`, `TaskRegistration`, `TaskOutcome`, `TaskSnapshot`/`TaskRead`, owner isolation, the control-tool surface |
|
||||
| [tasks.md](tasks.md) | the background task runtime: `TaskId`, `TaskStart`/`TaskHooks`, `TaskOutcome`, `TaskSnapshot`/`TaskRead`, owner isolation, the control-tool surface |
|
||||
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
|
||||
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
|
||||
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Background Task Runtime
|
||||
|
||||
The shared background-task vocabulary — what a producer (`dsh-tool-bash`, `dsh-tool-subagent`, any future long-running tool) hands to `ctx.tasks.register()` and what consumers (the `task_output`/`task_list`/`task_kill` tools, completion-notice injection) get back. The runtime is ONE concrete service ([dsh-tasks](../../packages/tasks/tasks), `ctx.tasks`), not an interface/implementation seam pair — see [the runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) for the decision and [the tasks group README](../../packages/tasks/README.md) for the package split.
|
||||
The shared background-task vocabulary — what a producer (`dsh-tool-bash`, `dsh-tool-subagent`, any future long-running tool) hands to `ctx.tasks.start()` and what consumers (the `task_output`/`task_list`/`task_kill` tools, completion-notice injection) get back. The runtime is ONE concrete service ([dsh-tasks](../../packages/tasks/tasks), `ctx.tasks`), not an interface/implementation seam pair — see [the runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) for the decision and [the tasks group README](../../packages/tasks/README.md) for the package split.
|
||||
|
||||
Source: [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts)
|
||||
|
||||
@@ -8,12 +8,12 @@ Source: [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/typ
|
||||
|
||||
`TaskId` is [branded](core.md#branded-ids) (`Branded<'TaskId'>` + a same-named factory), generated by the registry as `<kind>-N` with a per-kind counter (`bash-1`, `subagent-1`) — kind-prefixed so transcripts stay self-describing, sequential because the owner fence (not id secrecy) is the isolation boundary. `TaskStatus` is generic and CLOSED: `'running' | 'stopping' | 'completed' | 'killed' | 'failed'` — kind-specific meaning (exit codes, stop reasons) rides in `TaskSnapshot.detail`, so the registry never learns process or agent semantics.
|
||||
|
||||
## The producer contract: `TaskRegistration`
|
||||
## The producer contract: `TaskStart` and `TaskHooks`
|
||||
|
||||
A producer starts its work, then hands the running work over. The producer stays the owner of its execution concerns (process streams, child agents); the registry owns ids, isolation, status, and completion fan-out. The optional `readOutput` marks a STREAM kind — the method presence is the capability, mirroring `SubagentRun.sendMessage`.
|
||||
Declare-then-execute: the producer hands its task's identity plus a `run()` starter to `ctx.tasks.start()`, which preflights everything that can fail (the control-surface fence, validation, the owner-cleanup attach) BEFORE invoking `run()`, and commits atomically after — work that started without a collectable id is structurally impossible. The producer stays the owner of its execution concerns (process streams, child agents); the runtime owns ids, isolation, status, and completion fan-out. The optional `readOutput` hook marks a STREAM kind — the method presence is the capability, mirroring `SubagentRun.sendMessage`.
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskRegistration {
|
||||
interface TaskStart {
|
||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
|
||||
kind: string
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
@@ -22,10 +22,24 @@ interface TaskRegistration {
|
||||
* The spawning agent. Its `session.header.id` becomes the task's owner
|
||||
* token (read/kill/wait/list are fenced to that session), and its disposal
|
||||
* cancels and awaits the task through the `ctx.agents.onCleanup` seam.
|
||||
* `undefined` registers an UNOWNED task: open to any caller, alive until the
|
||||
* `undefined` starts an UNOWNED task: open to any caller, alive until the
|
||||
* tasks service disposes.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
run(): TaskHooks
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskHooks {
|
||||
/**
|
||||
* Request termination. Idempotent, synchronous, and must lead to
|
||||
* {@link done} settling; a throw propagates to the killer (fail loud — a
|
||||
@@ -53,8 +67,6 @@ interface TaskRegistration {
|
||||
}
|
||||
```
|
||||
|
||||
`register()` is ATOMIC: a throw (the no-control-surface fence, an owner-cleanup attach failure) mutates no registry state, so the producer cancels and awaits its just-started work and rethrows — background work never runs without a collectable id.
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskOutcome {
|
||||
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
|
||||
@@ -62,7 +74,7 @@ interface TaskOutcome {
|
||||
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
|
||||
detail?: string
|
||||
/**
|
||||
* Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskRegistration.readOutput}),
|
||||
* 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`.
|
||||
*/
|
||||
@@ -123,4 +135,4 @@ interface TaskRead {
|
||||
|
||||
## The service
|
||||
|
||||
`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `register` (atomic, fenced by `attachSurface`), non-consuming `get`/`list` (caller-scoped — owned-by-caller plus unowned only), `read` (consuming for stream kinds), `kill` (producer `cancel` first; a throw leaves the task untouched), `wait` (bounded, abort cancels the wait only), and `onTaskDone` (a `TaskDoneListener` per settlement, effect-scoped, contained). Every read/kill/wait/get compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and awaited when their owning agent disposes (the `ctx.agents.onCleanup` seam); the model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md).
|
||||
`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `start` (preflight → producer `run()` → atomic commit, fenced by `attachSurface`), non-consuming `get`/`list` (caller-scoped — owned-by-caller plus unowned only), `read` (consuming for stream kinds), `kill` (producer `cancel` first; a throw leaves the task untouched), `wait` (bounded, abort cancels the wait only), and `onTaskDone` (a `TaskDoneListener` per settlement, effect-scoped, contained). Every read/kill/wait/get compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and awaited when their owning agent disposes (the `ctx.agents.onCleanup` seam); the model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md).
|
||||
@@ -144,6 +144,7 @@ flowchart TD
|
||||
pkg_user_interaction --> pkg_llm
|
||||
pkg_tasks --> pkg_agent
|
||||
pkg_tasks --> pkg_brand
|
||||
pkg_tasks --> pkg_timeout
|
||||
pkg_agent_loop --> pkg_agent
|
||||
pkg_agent_loop --> pkg_llm
|
||||
pkg_agent_loop --> pkg_session
|
||||
@@ -282,7 +283,7 @@ flowchart TD
|
||||
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
|
||||
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
|
||||
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
|
||||
@@ -25,16 +25,21 @@ The registry is a CONCRETE service, not an interface/implementation seam pair: t
|
||||
|
||||
`dsh-tasks` owns the vocabulary ([data-structure catalog](../../../core-data-structures/tasks.md)). `TaskId` is branded, generated by the registry as `<kind>-N` with a per-kind counter (`bash-1`, `subagent-1`) — the kind prefix keeps ids self-describing in transcripts and preserves the pre-runtime `bash-N` shape. Ids are runtime-global and predictable, so every access is authorized (below).
|
||||
|
||||
A producer registers a task with:
|
||||
A producer hands its work to `ctx.tasks.start()` in a declare-then-execute shape (the pattern the timeout-policy plugin set: the capability declares, the shared layer executes): identity first, then a `run()` starter the runtime invokes only once nothing can fail anymore.
|
||||
|
||||
```ts ignore-check
|
||||
interface TaskRegistration {
|
||||
interface TaskStart {
|
||||
/** Producer kind — also the id prefix ('bash', 'subagent', …). */
|
||||
kind: string
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
label: string
|
||||
/** The spawning agent; undefined = unowned (open access, dies with the service). */
|
||||
owner?: Agent
|
||||
/** Start the actual work; called exactly once, after preflight passed. */
|
||||
run(): TaskHooks
|
||||
}
|
||||
|
||||
interface TaskHooks {
|
||||
/** Request termination; idempotent; must lead to `done` settling. The optional reason is `task_kill`'s logged reason, forwarded. */
|
||||
cancel(reason?: string): void
|
||||
/** Settles at QUIESCENCE — after the producer has released the task's resources. Never rejects. */
|
||||
@@ -64,7 +69,7 @@ Cross-session isolation lives IN the runtime so every consumer gets the same rul
|
||||
|
||||
```ts ignore-check
|
||||
class TaskService extends Service { // ctx.tasks
|
||||
register(reg: TaskRegistration): TaskId // throws when no control surface is attached; ATOMIC — a throw mutates nothing
|
||||
start(spec: TaskStart): TaskId // preflight (throws) → spec.run() starts the work → atomic commit (cannot fail)
|
||||
get(id: TaskId, caller?: Agent): TaskSnapshot // non-consuming; throws: unknown id, foreign owner
|
||||
list(caller?: Agent): TaskSnapshot[] // caller-visible only
|
||||
read(id: TaskId, caller?: Agent): TaskRead // delta (stream kinds, consuming) or final output (final kinds, idempotent) + snapshot
|
||||
@@ -77,7 +82,7 @@ class TaskService extends Service { // ctx.tasks
|
||||
|
||||
`TaskSnapshot` is the read-only projection: id, kind, label, owner session, status, detail, started/finished timestamps, and the `reported` notice-suppression flag (below). `wait` resolves with the terminal snapshot, or with the still-`running` snapshot on timeout; aborting the wait cancels only the wait.
|
||||
|
||||
**Misconfiguration fails loud**: a deployment that loads a background-capable producer without any control surface would let the model start tasks it can never read or stop — the half-loaded failure mode the subagent RFC's first draft reshaped a whole plugin to avoid. The fence is `attachSurface()`: `dsh-tool-tasks` attaches (effect-scoped) on load, and `register()` throws `background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)` when none is attached — the earliest self-contained moment, since concurrent plugin start makes a load-time check racy. The registry stays ignorant of tool names; a deployment with a custom (non-model) surface attaches its own.
|
||||
**Misconfiguration fails loud**: a deployment that loads a background-capable producer without any control surface would let the model start tasks it can never read or stop — the half-loaded failure mode the subagent RFC's first draft reshaped a whole plugin to avoid. The fence is `attachSurface()`: `dsh-tool-tasks` attaches (effect-scoped) on load, and `start()` throws `background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)` when none is attached — the earliest self-contained moment, since concurrent plugin start makes a load-time check racy. The registry stays ignorant of tool names; a deployment with a custom (non-model) surface attaches its own.
|
||||
|
||||
## The model-facing control tools
|
||||
|
||||
@@ -95,7 +100,7 @@ Completion notices stay durable context, not a wake-up (`agent.inject()` appends
|
||||
|
||||
## Producer opt-in and schema exposure
|
||||
|
||||
Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A disabled producer omits the parameter from its schema entirely, so schema and capability can never disagree. `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `register()` without a control surface throws the load-this-package error. `register()` is atomic (a throw mutates no registry state), and a producer whose registration fails cancels and awaits its just-started work before rethrowing — background work never runs without a collectable id.
|
||||
Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A disabled producer omits the parameter from its schema entirely, so schema and capability can never disagree. `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `start()` without a control surface throws the load-this-package error. `start()` preflights every failable check (the fence, validation, the owner-cleanup attach) BEFORE invoking the producer's `run()` and commits atomically after — background work started without a collectable id is structurally impossible, not a producer rollback obligation.
|
||||
|
||||
## The awaited owner-cleanup seam
|
||||
|
||||
@@ -110,11 +115,11 @@ A background task must not outlive its owner: the subagent case leaks live child
|
||||
|
||||
`dsh-bash` keeps the execution contract and carries no registry. The seam is `resolve`, `run`, and `start`, where `start(spec)` returns a process handle — `BashProcess`: `{ command, status, exitCode, signal, done, readOutput(), kill() }` — instead of a registry entry: `get`/`ownerOf`/`list`/`onTaskDone`, the listener machinery, `BashTaskId`, `OwnerToken`, and the spec's `owner` field are gone (a consumer census found `get`/`list` reached only by test harnesses and `onTaskDone` single-consumer — `dsh-tool-bash`; the hook bridges consume `resolve`+`run` only). The local executor keeps an internal table of LIVE processes solely for its own disposal quiescence (entries leave on settlement). The foreground trusted-plugin path (`resolve` + `run` with `stdin`/`env`, used by the hook bridges) is untouched and never routes through the runtime; `BashExecSpec.timeoutMs` stays required-but-ignored by `start()` (shared-spec status quo, documented in the seam JSDoc); the credential-scrub duplication between the bash and ACP spawn sites is explicitly NOT this runtime's work — the registry never touches process spawning.
|
||||
|
||||
`dsh-tool-bash` keeps the `bash` tool; the `run_in_background` path is `ctx.bash.start(...)` + `ctx.tasks.register({ kind: 'bash', label: command, owner: exec.agent, cancel, done, readOutput })`, where `done` maps the process exit to a `TaskOutcome` (`processOutcome`: `completed`/`killed` + exit-code/signal detail) and `readOutput` wraps the handle's incremental read with the spill/lossy formatting (`renderProcessRead`). The completion-notice listener left `dsh-tool-bash` entirely.
|
||||
`dsh-tool-bash` keeps the `bash` tool; the `run_in_background` path is `ctx.tasks.start({ kind: 'bash', label: command, owner: exec.agent, run })` whose `run()` spawns through `ctx.bash.start(...)` and returns the hooks, where `done` maps the process exit to a `TaskOutcome` (`processOutcome`: `completed`/`killed` + exit-code/signal detail) and `readOutput` wraps the handle's incremental read with the spill/lossy formatting (`renderProcessRead`). The completion-notice listener left `dsh-tool-bash` entirely.
|
||||
|
||||
## Subagent integration
|
||||
|
||||
[Background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md) rides this runtime; the headline consequence is that `dsh-tool-subagent` KEEPS its one-instance-per-provider shape — the multi-tool reshape existed only to keep cloned companion tools from colliding, and there are no companion tools to clone. Its background call starts the provider run, then registers `{ kind: 'subagent', label: description, owner: parent, cancel: run.cancel, done }` where `done` awaits `run.result`, awaits `run.dispose()` (quiescence), and maps the stop reason (`completed` → `completed`; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as detail) and the final text as `output`. No `readOutput` — the child session remains the detailed trace, exactly as that RFC argues.
|
||||
[Background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md) rides this runtime; the headline consequence is that `dsh-tool-subagent` KEEPS its one-instance-per-provider shape — the multi-tool reshape existed only to keep cloned companion tools from colliding, and there are no companion tools to clone. Its background call is `ctx.tasks.start({ kind: 'subagent', label: description, owner: parent, run })` whose `run()` starts the provider run and returns `{ cancel: run.cancel, done }`, where `done` awaits `run.result`, awaits `run.dispose()` (quiescence), and maps the stop reason (`completed` → `completed`; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as detail) and the final text as `output`. No `readOutput` — the child session remains the detailed trace, exactly as that RFC argues.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -164,7 +169,7 @@ Everything model-visible already lands in the log: starts and reads are tool cal
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, register atomicity — a failed registration mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' registration mapping plus their no-orphan guarantee (a failed `register()` kills/cancels and awaits the just-started work before rethrowing), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
|
||||
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, start atomicity — a failed preflight mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' start mapping plus the structural no-orphan guarantee (a failed preflight means the producer's `run()` — the spawn — was never invoked), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Each `dsh-tool-subagent` instance may expose `run_in_background?: boolean`, gate
|
||||
|
||||
A foreground call keeps the synchronous semantics: it waits for `run.result`, returns final text on `completed`, maps non-clean terminal stop reasons to an errored tool result, and disposes the run in `finally`.
|
||||
|
||||
A background call validates that a parent agent exists, checks an already-aborted tool signal before starting, starts the provider run through `ctx.subagents`, registers the run with `ctx.tasks`, and returns `started background subagent task <task_id>`. After the id is returned the tool-call signal is NOT connected to `run.cancel()` — the parent step may finish while the child continues; cancellation belongs to `task_kill` and the owner-cleanup path. A registration that throws (the no-control-surface fence) does not orphan the child: the producer cancels the run, awaits its `done` (which settles only after `run.dispose()`), and rethrows — the model never learns an id for work that is not actually tracked. `ctx.tasks.register()` supplies the runtime guarantees this feature needs and this RFC does not implement: kind-prefixed branded task ids, owner-scoped access (the parent agent is the owner; another session's agent cannot read or kill the task), the loud no-control-surface failure, completion-notice injection, and the generic prompt guidance.
|
||||
A background call validates that a parent agent exists, checks an already-aborted tool signal, and hands the delegation to `ctx.tasks.start()` — the runtime preflights the control-surface fence and the owner cleanup BEFORE its `run()` starter creates the child through `ctx.subagents`, so a child that started without a collectable id is structurally impossible — then returns `started background subagent task <task_id>`. After the id is returned the tool-call signal is NOT connected to `run.cancel()` — the parent step may finish while the child continues; cancellation belongs to `task_kill` and the owner-cleanup path. `ctx.tasks.start()` supplies the runtime guarantees this feature needs and this RFC does not implement: kind-prefixed branded task ids, owner-scoped access (the parent agent is the owner; another session's agent cannot read or kill the task), the loud no-control-surface failure, completion-notice injection, and the generic prompt guidance.
|
||||
|
||||
The registration maps the seam vocabulary onto the runtime's:
|
||||
|
||||
@@ -53,10 +53,10 @@ The child session is already the trace for internal reasoning, tool calls, and i
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins the stop-reason → outcome mapping (`runOutcome`, including unknown merge-extensible reasons), `settleRun`'s dispose-before-report on both result paths, the detached-signal contract (a pre-aborted signal refuses to start; a returned id is never wired to the tool signal), background settlement collected through the real `task_output`/`task_kill` tools, the no-orphan rollback when `register()` throws, per-instance schema gating (`enableRunInBackground: false` omits the parameter and the background wording), and the loud failure when the tasks runtime is absent. Snapshot coverage pins the changed `subagent`/`subagent_fork` schemas through the pinned-header fixture; recording a live background-delegation transcript requires a `DEEPSEEK_API_KEY` re-record and remains named follow-up work.
|
||||
Unit coverage pins the stop-reason → outcome mapping (`runOutcome`, including unknown merge-extensible reasons), `settleRun`'s dispose-before-report on both result paths, the detached-signal contract (a pre-aborted signal refuses to start; a returned id is never wired to the tool signal), background settlement collected through the real `task_output`/`task_kill` tools, the structural no-orphan guarantee (a failed `tasks.start` preflight never invokes the provider), per-instance schema gating (`enableRunInBackground: false` omits the parameter and the background wording), and the loud failure when the tasks runtime is absent. Snapshot coverage pins the changed `subagent`/`subagent_fork` schemas through the pinned-header fixture; recording a live background-delegation transcript requires a `DEEPSEEK_API_KEY` re-record and remains named follow-up work.
|
||||
|
||||
## Consequences
|
||||
|
||||
Slow delegation no longer holds the parent step open: the model fans out background children, keeps working, and collects with the same three control tools it already uses for bash — no new habit, no schema clones, and `dsh-tool-subagent`'s per-provider shape survived unchanged. The feature's usability depends on the tasks pair being loaded; the runtime's `register()` fence turns a missing control surface into a loud, actionable error rather than a silent dead end, and the `dsh-agent-core` bundle ships the pair so every stock deployment has it.
|
||||
Slow delegation no longer holds the parent step open: the model fans out background children, keeps working, and collects with the same three control tools it already uses for bash — no new habit, no schema clones, and `dsh-tool-subagent`'s per-provider shape survived unchanged. The feature's usability depends on the tasks pair being loaded; the runtime's `start()` preflight fence turns a missing control surface into a loud, actionable error (raised before any child exists) rather than a silent dead end, and the `dsh-agent-core` bundle ships the pair so every stock deployment has it.
|
||||
|
||||
The prompt guidance reduces abandoned tasks but cannot force a model to collect every background result. Runtime cleanup through the awaited owner-disposal path is the hard stop; a future planner or guard could enforce "no final answer with relevant running tasks" more strongly if the prompt proves insufficient. A background child outliving its starting tool call means a misbehaving child consumes tokens until collected, killed, or owner-disposed; `task_list` keeps it visible, and setting `enableRunInBackground: false` per instance keeps a deployment's delegation strictly synchronous.
|
||||
@@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
|
||||
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.register()`. |
|
||||
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
|
||||
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
|
||||
| `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
|
||||
|
||||
@@ -332,7 +332,7 @@ Read output/status from a background task (started by a tool with `run_in_backgr
|
||||
|
||||
Source: [`packages/tasks/tool-tasks/src/index.ts`](../packages/tasks/tool-tasks/src/index.ts)
|
||||
|
||||
The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.register()`.
|
||||
The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-todo`
|
||||
|
||||
|
||||
@@ -361,25 +361,22 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// (cancellation belongs to task_kill / owner cleanup), so the check
|
||||
// happens here, once, instead of passing the signal to start().
|
||||
if (exec.signal?.aborted) throw new Error('command aborted')
|
||||
const proc = ctx.bash.start(ctx.bash.resolve(request))
|
||||
let id: string
|
||||
try {
|
||||
id = tasks.register({
|
||||
kind: 'bash',
|
||||
label: args.command,
|
||||
...exec.agent ? { owner: exec.agent } : {},
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderProcessRead(proc.readOutput()),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A failed registration must not leak the just-started process: the
|
||||
// model never received an id, so nothing could ever task_kill it.
|
||||
// Kill, await quiescence, then fail the call with the real cause.
|
||||
proc.kill()
|
||||
await proc.done
|
||||
throw error
|
||||
}
|
||||
// tasks.start preflights (surface fence, owner cleanup) BEFORE run()
|
||||
// spawns anything, and cannot fail after — the process can never
|
||||
// start without a collectable id.
|
||||
const id = tasks.start({
|
||||
kind: 'bash',
|
||||
label: args.command,
|
||||
...exec.agent ? { owner: exec.agent } : {},
|
||||
run: () => {
|
||||
const proc = ctx.bash.start(ctx.bash.resolve(request))
|
||||
return {
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderProcessRead(proc.readOutput()),
|
||||
}
|
||||
},
|
||||
})
|
||||
return [{ type: 'text', text: `started background task ${id}` }]
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
|
||||
@@ -354,36 +354,30 @@ describe('background execution through the task runtime', () => {
|
||||
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
|
||||
})
|
||||
|
||||
it('a failed registration kills the just-started process (no orphan without an id)', async () => {
|
||||
it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
|
||||
class LeakProbeExecutor extends BashExecutor {
|
||||
kills = 0
|
||||
starts = 0
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
|
||||
}
|
||||
|
||||
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
let close!: () => void
|
||||
const done = new Promise<void>((res) => { close = res })
|
||||
const proc: BashProcess = {
|
||||
this.starts += 1
|
||||
return {
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => {
|
||||
this.kills += 1
|
||||
proc.status = 'killed'
|
||||
close()
|
||||
return true
|
||||
},
|
||||
kill: () => false,
|
||||
}
|
||||
return proc
|
||||
}
|
||||
}
|
||||
// TaskService WITHOUT any control surface: register() throws AFTER the
|
||||
// process already started — the producer must kill and await it.
|
||||
// TaskService WITHOUT any control surface: tasks.start preflights that
|
||||
// fence BEFORE invoking the producer's run(), so the executor is never
|
||||
// asked to spawn — there is no orphan to roll back.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -395,8 +389,8 @@ describe('background execution through the task runtime', () => {
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// The call resolved only after the kill landed (the catch awaits done).
|
||||
expect((ctx.bash as LeakProbeExecutor).kills).toBe(1)
|
||||
// Declare-then-execute: the failed preflight means no process ever ran.
|
||||
expect((ctx.bash as LeakProbeExecutor).starts).toBe(0)
|
||||
})
|
||||
|
||||
it('enableRunInBackground: false removes the parameter and flips the description', async () => {
|
||||
|
||||
@@ -264,32 +264,27 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// (the child outlives this step; cancellation belongs to task_kill
|
||||
// and owner-disposal cleanup), so the request carries NO signal.
|
||||
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
|
||||
const run = ctx.subagents.start(config.provider, {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
// tasks.start preflights (surface fence, owner cleanup) BEFORE run()
|
||||
// spawns the child, and cannot fail after — a child can never start
|
||||
// without a collectable id.
|
||||
const id = tasks.start({
|
||||
kind: 'subagent',
|
||||
label: args.description,
|
||||
owner: parent,
|
||||
run: () => {
|
||||
const run = ctx.subagents.start(config.provider, {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
})
|
||||
return {
|
||||
cancel: (reason?: string) => { run.cancel(reason ?? 'background subagent task killed') },
|
||||
done: settleRun(run),
|
||||
// No readOutput: a subagent task is final-output-only — the
|
||||
// child session remains the detailed trace.
|
||||
}
|
||||
},
|
||||
})
|
||||
const done = settleRun(run)
|
||||
let id: string
|
||||
try {
|
||||
id = tasks.register({
|
||||
kind: 'subagent',
|
||||
label: args.description,
|
||||
owner: parent,
|
||||
cancel: (reason) => { run.cancel(reason ?? 'background subagent task killed') },
|
||||
done,
|
||||
// No readOutput: a subagent task is final-output-only — the child
|
||||
// session remains the detailed trace.
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A failed registration must not leak the just-started child: the
|
||||
// model never received an id, so nothing could ever task_kill it.
|
||||
// Cancel, await `done` (which settles only after run.dispose() —
|
||||
// child quiescence), then fail the call with the real cause.
|
||||
run.cancel('background task registration failed')
|
||||
await done
|
||||
throw error
|
||||
}
|
||||
return [{ type: 'text', text: `started background subagent task ${id}` }]
|
||||
}
|
||||
|
||||
|
||||
@@ -602,28 +602,29 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('background registration failure (no orphaned child)', () => {
|
||||
it('cancels and disposes the just-started run when register() throws', async () => {
|
||||
// TaskService is loaded but NO control surface is attached, so
|
||||
// ctx.tasks.register throws AFTER the provider run already started.
|
||||
describe('background preflight failure (no orphaned child, by construction)', () => {
|
||||
it('never starts the child when tasks.start preflight throws', async () => {
|
||||
// TaskService is loaded but NO control surface is attached: tasks.start
|
||||
// preflights that fence BEFORE invoking the producer's run(), so the
|
||||
// provider is never asked to spawn — there is no orphan to roll back.
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
const parent = { id: AgentId('agent-sess-p'), inject: () => {}, session: { header: { version: 0, id: 'sess-p', createdAt: 0 } } } as unknown as Agent
|
||||
ctx.agents.register(parent)
|
||||
|
||||
const events: string[] = []
|
||||
let starts = 0
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'probe',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let settle!: (value: { output: never[]; stopReason: 'aborted' }) => void
|
||||
starts += 1
|
||||
return {
|
||||
id: AgentId('probe-child'),
|
||||
result: new Promise((res) => { settle = res }),
|
||||
cancel(reason?: string) { events.push(`cancel:${reason}`); settle({ output: [], stopReason: 'aborted' }) },
|
||||
dispose() { events.push('dispose'); return Promise.resolve() },
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -637,8 +638,7 @@ describe('background registration failure (no orphaned child)', () => {
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// The child was cancelled AND disposed before the call settled — the
|
||||
// model never got an id, so nothing else could ever collect or kill it.
|
||||
expect(events).toEqual(['cancel:background task registration failed', 'dispose'])
|
||||
// Declare-then-execute: the failed preflight means no child ever existed.
|
||||
expect(starts).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -7,4 +7,4 @@ The shared background-task runtime: ONE home for task ids, owner isolation, poll
|
||||
| [`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`) register running work via `ctx.tasks.register` 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 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.
|
||||
@@ -4,7 +4,7 @@ The background task registry (`ctx.tasks`): a runtime-global, CONCRETE service (
|
||||
|
||||
## Service API
|
||||
|
||||
- `register(registration): TaskId` — a producer hands over running work: `kind` (also the id prefix), `label`, optional `owner: Agent`, `cancel(reason?)`, `done: Promise<TaskOutcome>` (settles at QUIESCENCE, never rejects), optional `readOutput()` (stream kinds; absence = final-output-only). Throws while no control surface is attached — the loud fence against a deployment exposing `run_in_background` with no way to collect or stop the work — and is ATOMIC: a failed registration mutates nothing (no stored task, no counter bump, no owner-cleanup bookkeeping), so producers can reliably cancel their just-started work and rethrow.
|
||||
- `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).
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* 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) register running work via
|
||||
* {@link TaskService.register} and keep their own execution concerns; the
|
||||
* 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.
|
||||
*
|
||||
@@ -32,15 +33,16 @@ 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'
|
||||
import type { TaskDoneListener, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from './types.ts'
|
||||
|
||||
export { TaskId } from './types.ts'
|
||||
export type {
|
||||
TaskDoneListener,
|
||||
TaskHooks,
|
||||
TaskOutcome,
|
||||
TaskRead,
|
||||
TaskRegistration,
|
||||
TaskSnapshot,
|
||||
TaskStart,
|
||||
TaskStatus,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -114,44 +116,48 @@ export class TaskService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register running background work and receive its task id (`<kind>-N`,
|
||||
* per-kind counter). The registry attaches ONE continuation to
|
||||
* `registration.done` that records the terminal snapshot, notifies
|
||||
* {@link onTaskDone} listeners, and releases waiters; an owned task also
|
||||
* gets the owner's awaited disposal cleanup attached (once per owner agent)
|
||||
* through `ctx.agents.onCleanup`. Throws when no control surface is
|
||||
* attached ({@link attachSurface}) — a task the model could never read or
|
||||
* stop must fail loud at the start, not dangle — and for an empty
|
||||
* kind/label. ATOMIC: a throw mutates no registry state, so a producer can
|
||||
* cancel its just-started work and rethrow without leaving a stored task
|
||||
* behind.
|
||||
* @param registration - the producer's task contract (see {@link TaskRegistration}).
|
||||
* 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, and the owner's awaited disposal-cleanup attach (once per
|
||||
* owner agent, through `ctx.agents.onCleanup`) — 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.
|
||||
*/
|
||||
register(registration: TaskRegistration): TaskId {
|
||||
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)')
|
||||
}
|
||||
if (registration.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
|
||||
if (registration.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
|
||||
// EVERYTHING that can throw runs before any mutation (counter, store):
|
||||
// a failed registration must leave the registry exactly as it was — no
|
||||
// stored-but-unreturned task the producer could never read or kill.
|
||||
if (registration.owner !== undefined) this.ensureOwnerCleanup(registration.owner)
|
||||
if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
|
||||
if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
|
||||
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
|
||||
|
||||
const count = (this.counters.get(registration.kind) ?? 0) + 1
|
||||
this.counters.set(registration.kind, count)
|
||||
const id = TaskId(`${registration.kind}-${count}`)
|
||||
// -- 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}`)
|
||||
|
||||
let markSettled!: () => void
|
||||
const settled = new Promise<void>((resolve) => { markSettled = resolve })
|
||||
const task: TrackedTask = {
|
||||
id,
|
||||
kind: registration.kind,
|
||||
label: registration.label,
|
||||
ownerSession: registration.owner?.session.header.id,
|
||||
cancel: registration.cancel.bind(registration),
|
||||
readOutput: registration.readOutput?.bind(registration),
|
||||
kind: spec.kind,
|
||||
label: spec.label,
|
||||
ownerSession: spec.owner?.session.header.id,
|
||||
cancel: hooks.cancel.bind(hooks),
|
||||
readOutput: hooks.readOutput?.bind(hooks),
|
||||
status: 'running',
|
||||
detail: undefined,
|
||||
output: undefined,
|
||||
@@ -164,7 +170,7 @@ export class TaskService extends Service {
|
||||
}
|
||||
this.store.set(id, task)
|
||||
|
||||
void registration.done.then(
|
||||
void hooks.done.then(
|
||||
(outcome) => { this.settle(task, outcome) },
|
||||
(error: unknown) => {
|
||||
// Producer contract violation (`done` must never reject) — contained
|
||||
@@ -325,7 +331,7 @@ export class TaskService extends Service {
|
||||
|
||||
/**
|
||||
* Declare that a control surface capable of reading/stopping tasks is
|
||||
* loaded. {@link register} refuses to start a background task while NO
|
||||
* 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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Task-registry vocabulary: the registration a producer hands to
|
||||
* {@link TaskService.register} and the snapshots/reads consumers get back.
|
||||
* Types only — the service lives in `./index.ts`.
|
||||
* 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`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tasks/types
|
||||
*/
|
||||
@@ -36,7 +37,7 @@ export function TaskId(id: string): TaskId {
|
||||
export type TaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* The terminal result a producer's {@link TaskRegistration.done} resolves
|
||||
* 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.
|
||||
*/
|
||||
@@ -46,7 +47,7 @@ export interface TaskOutcome {
|
||||
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
|
||||
detail?: string
|
||||
/**
|
||||
* Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskRegistration.readOutput}),
|
||||
* 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`.
|
||||
*/
|
||||
@@ -54,13 +55,15 @@ export interface TaskOutcome {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a producer registers with {@link TaskService.register}: the running
|
||||
* work's identity, its owner, and the three hooks the registry drives it
|
||||
* through. The producer stays the owner of its execution concerns (process
|
||||
* streams, child agents); the registry owns ids, isolation, status, and
|
||||
* completion fan-out.
|
||||
* 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.
|
||||
*/
|
||||
export interface TaskRegistration {
|
||||
export interface TaskStart {
|
||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
|
||||
kind: string
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
@@ -69,10 +72,27 @@ export interface TaskRegistration {
|
||||
* The spawning agent. Its `session.header.id` becomes the task's owner
|
||||
* token (read/kill/wait/list are fenced to that session), and its disposal
|
||||
* cancels and awaits the task through the `ctx.agents.onCleanup` seam.
|
||||
* `undefined` registers an UNOWNED task: open to any caller, alive until the
|
||||
* `undefined` starts an UNOWNED task: open to any caller, alive until the
|
||||
* tasks service disposes.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export interface TaskHooks {
|
||||
/**
|
||||
* Request termination. Idempotent, synchronous, and must lead to
|
||||
* {@link done} settling; a throw propagates to the killer (fail loud — a
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskOutcome, TaskRegistration, TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
const id = AgentId(rawId)
|
||||
@@ -21,19 +21,19 @@ function stubAgent(rawId: string): Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/** A controllable producer: settle its `done` on demand, record cancels. */
|
||||
function producer(overrides: Partial<TaskRegistration> = {}) {
|
||||
/** A controllable producer start-spec: settle its `done` on demand, record cancels. */
|
||||
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
const registration: TaskRegistration = {
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
|
||||
const hooks: TaskHooks = {
|
||||
cancel(reason) { cancels.push(reason) },
|
||||
done: new Promise<TaskOutcome>((res, rej) => { settle = res; reject = rej }),
|
||||
...overrides,
|
||||
...hookOverrides,
|
||||
}
|
||||
return { registration, settle, reject, cancels }
|
||||
const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
|
||||
return { spec, settle, reject, cancels }
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
@@ -47,25 +47,25 @@ async function harness() {
|
||||
/** Let the settlement continuation (a `done.then`) run. */
|
||||
const tick = () => new Promise<void>(r => setTimeout(r, 0))
|
||||
|
||||
describe('TaskService.register', () => {
|
||||
describe('TaskService.start', () => {
|
||||
it('refuses to register while no control surface is attached', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TaskService)
|
||||
expect(() => ctx.tasks.register(producer().registration))
|
||||
expect(() => ctx.tasks.start(producer().spec))
|
||||
.toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
|
||||
})
|
||||
|
||||
it('rejects an empty kind and an empty label', async () => {
|
||||
const ctx = await harness()
|
||||
expect(() => ctx.tasks.register(producer({ kind: '' }).registration)).toThrow('invalid task kind')
|
||||
expect(() => ctx.tasks.register(producer({ label: '' }).registration)).toThrow('invalid task label')
|
||||
expect(() => ctx.tasks.start(producer({ kind: '' }).spec)).toThrow('invalid task kind')
|
||||
expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label')
|
||||
})
|
||||
|
||||
it('issues kind-prefixed ids from per-kind counters', async () => {
|
||||
const ctx = await harness()
|
||||
expect(ctx.tasks.register(producer().registration)).toBe('bash-1')
|
||||
expect(ctx.tasks.register(producer().registration)).toBe('bash-2')
|
||||
expect(ctx.tasks.register(producer({ kind: 'subagent' }).registration)).toBe('subagent-1')
|
||||
expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
|
||||
expect(ctx.tasks.start(producer().spec)).toBe('bash-2')
|
||||
expect(ctx.tasks.start(producer({ kind: 'subagent' }).spec)).toBe('subagent-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('TaskService reads and settlement', () => {
|
||||
const ctx = await harness()
|
||||
const chunks = ['first', '', 'rest']
|
||||
const p = producer({ readOutput: () => chunks.shift() ?? '' })
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
|
||||
expect(ctx.tasks.read(id)).toMatchObject({ text: 'first', snapshot: { status: 'running', reported: false } })
|
||||
expect(ctx.tasks.read(id).text).toBe('')
|
||||
@@ -90,7 +90,7 @@ describe('TaskService reads and settlement', () => {
|
||||
it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer({ kind: 'subagent', label: 'research task' })
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
|
||||
expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } })
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('TaskService reads and settlement', () => {
|
||||
it('a settled task without output reads as empty text', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer({ kind: 'subagent' })
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'failed', detail: 'max-tokens' })
|
||||
await tick()
|
||||
expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'failed', detail: 'max-tokens' } })
|
||||
@@ -122,7 +122,7 @@ describe('TaskService reads and settlement', () => {
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('TaskService reads and settlement', () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
p.reject(new Error('transport exploded'))
|
||||
await tick()
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('TaskService reads and settlement', () => {
|
||||
detach()
|
||||
|
||||
const p = producer()
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(seen).toEqual([])
|
||||
@@ -168,7 +168,7 @@ describe('TaskService.kill', () => {
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
|
||||
expect(ctx.tasks.kill(id, undefined, 'no longer needed')).toBe('requested')
|
||||
expect(p.cancels).toEqual(['no longer needed'])
|
||||
@@ -184,7 +184,7 @@ describe('TaskService.kill', () => {
|
||||
it('reports an already-terminal task instead of failing', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(ctx.tasks.kill(id)).toBe('already-terminal')
|
||||
@@ -196,11 +196,13 @@ describe('TaskService.kill', () => {
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
let broken = true
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const id = ctx.tasks.register({
|
||||
const id = ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'flaky cancel',
|
||||
cancel() { if (broken) throw new Error('cancel boom') },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
run: () => ({
|
||||
cancel() { if (broken) throw new Error('cancel boom') },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
expect(() => ctx.tasks.kill(id)).toThrow('cancel boom')
|
||||
// The failed kill mutated NOTHING: still running, notice not suppressed,
|
||||
@@ -221,7 +223,7 @@ describe('TaskService.wait', () => {
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
|
||||
const wait = ctx.tasks.wait(id, 5_000)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
@@ -232,14 +234,14 @@ describe('TaskService.wait', () => {
|
||||
|
||||
it('returns the live snapshot on timeout without marking reported', async () => {
|
||||
const ctx = await harness()
|
||||
const id = ctx.tasks.register(producer().registration)
|
||||
const id = ctx.tasks.start(producer().spec)
|
||||
expect(await ctx.tasks.wait(id, 5)).toMatchObject({ status: 'running', reported: false })
|
||||
})
|
||||
|
||||
it('returns immediately for an already-terminal task', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(await ctx.tasks.wait(id, 5_000)).toMatchObject({ status: 'completed', reported: true })
|
||||
@@ -247,14 +249,14 @@ describe('TaskService.wait', () => {
|
||||
|
||||
it('rejects a non-positive or non-finite timeout', async () => {
|
||||
const ctx = await harness()
|
||||
const id = ctx.tasks.register(producer().registration)
|
||||
const id = ctx.tasks.start(producer().spec)
|
||||
await expect(ctx.tasks.wait(id, 0)).rejects.toThrow('invalid wait timeout')
|
||||
await expect(ctx.tasks.wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout')
|
||||
})
|
||||
|
||||
it('an aborted signal rejects the wait only — the task stays alive', async () => {
|
||||
const ctx = await harness()
|
||||
const id = ctx.tasks.register(producer().registration)
|
||||
const id = ctx.tasks.start(producer().spec)
|
||||
|
||||
const controller = new AbortController()
|
||||
const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
|
||||
@@ -275,8 +277,8 @@ describe('TaskService owner isolation', () => {
|
||||
ctx.agents.register(owner)
|
||||
const other = stubAgent('other')
|
||||
|
||||
const owned = ctx.tasks.register(producer({ owner }).registration)
|
||||
const open = ctx.tasks.register(producer().registration)
|
||||
const owned = ctx.tasks.start(producer({ owner }).spec)
|
||||
const open = ctx.tasks.start(producer().spec)
|
||||
|
||||
// The owner and the unowned task are reachable.
|
||||
expect(ctx.tasks.read(owned, owner).snapshot.id).toBe(owned)
|
||||
@@ -296,9 +298,9 @@ describe('TaskService owner isolation', () => {
|
||||
ctx.agents.register(alice)
|
||||
ctx.agents.register(bob)
|
||||
|
||||
const aliceTask = ctx.tasks.register(producer({ owner: alice }).registration)
|
||||
const bobTask = ctx.tasks.register(producer({ owner: bob }).registration)
|
||||
const openTask = ctx.tasks.register(producer({ kind: 'subagent' }).registration)
|
||||
const aliceTask = ctx.tasks.start(producer({ owner: alice }).spec)
|
||||
const bobTask = ctx.tasks.start(producer({ owner: bob }).spec)
|
||||
const openTask = ctx.tasks.start(producer({ kind: 'subagent' }).spec)
|
||||
|
||||
expect(ctx.tasks.list(alice).map(t => t.id)).toEqual([aliceTask, openTask])
|
||||
expect(ctx.tasks.list(bob).map(t => t.id)).toEqual([bobTask, openTask])
|
||||
@@ -309,11 +311,11 @@ describe('TaskService owner isolation', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TaskService)
|
||||
ctx.tasks.attachSurface('test-surface')
|
||||
expect(() => ctx.tasks.register(producer({ owner: stubAgent('a') }).registration))
|
||||
expect(() => ctx.tasks.start(producer({ owner: stubAgent('a') }).spec))
|
||||
.toThrow('background task ownership requires the agent registry')
|
||||
// The failed registration mutated nothing: no stored task, counter untouched.
|
||||
expect(ctx.tasks.list()).toEqual([])
|
||||
expect(ctx.tasks.register(producer().registration)).toBe('bash-1')
|
||||
expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
|
||||
})
|
||||
|
||||
it('a failed owner-cleanup attach leaves the registry unchanged and does not poison the owner', async () => {
|
||||
@@ -321,7 +323,7 @@ describe('TaskService owner isolation', () => {
|
||||
const ghost = stubAgent('ghost') // never registered in ctx.agents
|
||||
|
||||
// onCleanup rejects the unregistered agent BEFORE any registry mutation.
|
||||
expect(() => ctx.tasks.register(producer({ owner: ghost }).registration))
|
||||
expect(() => ctx.tasks.start(producer({ owner: ghost }).spec))
|
||||
.toThrow('is not registered')
|
||||
expect(ctx.tasks.list(ghost)).toEqual([])
|
||||
|
||||
@@ -330,12 +332,14 @@ describe('TaskService owner isolation', () => {
|
||||
ctx.agents.register(ghost)
|
||||
const cancels: (string | undefined)[] = []
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const id = ctx.tasks.register({
|
||||
const id = ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'after retry',
|
||||
owner: ghost,
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
run: () => ({
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
expect(id).toBe('bash-1') // the failed attempt burned no counter
|
||||
await ctx.agents.drainCleanups(ghost.id)
|
||||
@@ -353,15 +357,17 @@ describe('TaskService owner cleanup', () => {
|
||||
// The producer settles only when cancelled — models a child that stops on request.
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
ctx.tasks.register({
|
||||
ctx.tasks.start({
|
||||
kind: 'subagent',
|
||||
label: 'long research',
|
||||
owner,
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
run: () => ({
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
const terminal = producer({ owner })
|
||||
ctx.tasks.register(terminal.registration)
|
||||
ctx.tasks.start(terminal.spec)
|
||||
terminal.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
@@ -378,8 +384,8 @@ describe('TaskService owner cleanup', () => {
|
||||
|
||||
const first = producer({ owner })
|
||||
const second = producer({ owner })
|
||||
ctx.tasks.register(first.registration)
|
||||
ctx.tasks.register(second.registration)
|
||||
ctx.tasks.start(first.spec)
|
||||
ctx.tasks.start(second.spec)
|
||||
first.settle({ status: 'completed' })
|
||||
second.settle({ status: 'completed' })
|
||||
await tick()
|
||||
@@ -387,7 +393,7 @@ describe('TaskService owner cleanup', () => {
|
||||
|
||||
// A fresh task after the drain gets a fresh cleanup (the set was consumed).
|
||||
const third = producer({ owner })
|
||||
ctx.tasks.register(third.registration)
|
||||
ctx.tasks.start(third.spec)
|
||||
third.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(ctx.tasks.list(owner)).toHaveLength(1)
|
||||
@@ -402,12 +408,14 @@ describe('TaskService owner cleanup', () => {
|
||||
ctx.agents.register(owner)
|
||||
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
ctx.tasks.register({
|
||||
ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'broken producer',
|
||||
owner,
|
||||
cancel() { throw new Error('cancel boom') },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
run: () => ({
|
||||
cancel() { throw new Error('cancel boom') },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
|
||||
const drain = ctx.agents.drainCleanups(owner.id)
|
||||
@@ -432,11 +440,13 @@ describe('TaskService disposal', () => {
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
ctx.tasks.register({
|
||||
ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'sleep 600',
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
run: () => ({
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
|
||||
await fiber.dispose()
|
||||
@@ -456,10 +466,10 @@ describe('TaskService disposal', () => {
|
||||
|
||||
detachA1()
|
||||
detachA1() // second call of the same disposer is a no-op
|
||||
expect(() => ctx.tasks.register(producer().registration)).not.toThrow() // a ×1 + b remain
|
||||
expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // a ×1 + b remain
|
||||
detachA2()
|
||||
expect(() => ctx.tasks.register(producer().registration)).not.toThrow() // b remains
|
||||
expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains
|
||||
await fiber.dispose() // detaches b with its fiber (HMR safety)
|
||||
expect(() => ctx.tasks.register(producer().registration)).toThrow('no control surface is attached')
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
# @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' `register()`.
|
||||
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()`.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* registry (`@deepseek-ai/dsh-tasks`).
|
||||
*
|
||||
* This plugin IS the control surface: it calls `ctx.tasks.attachSurface()` on
|
||||
* load, which is what re-arms producers' `register()` (the registry refuses
|
||||
* background work while no surface could collect or stop it).
|
||||
* 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
|
||||
|
||||
@@ -6,7 +6,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskOutcome, TaskRegistration, TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
@@ -32,18 +32,18 @@ function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[])
|
||||
return agent
|
||||
}
|
||||
|
||||
/** A controllable producer registration (settle `done` on demand, record cancels). */
|
||||
function producer(overrides: Partial<TaskRegistration> = {}) {
|
||||
/** A controllable producer start-spec (settle `done` on demand, record cancels). */
|
||||
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
const registration: TaskRegistration = {
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
|
||||
const hooks: TaskHooks = {
|
||||
cancel(reason) { cancels.push(reason) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
...overrides,
|
||||
...hookOverrides,
|
||||
}
|
||||
return { registration, settle, cancels }
|
||||
const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
|
||||
return { spec, settle, cancels }
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
@@ -60,9 +60,9 @@ const tick = () => new Promise<void>(r => setTimeout(r, 0))
|
||||
describe('tool-tasks setup', () => {
|
||||
it('attaches the control surface on load and detaches it with the fiber', async () => {
|
||||
const { ctx, toolsFiber } = await setup()
|
||||
expect(() => ctx.tasks.register(producer().registration)).not.toThrow()
|
||||
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
|
||||
await toolsFiber.dispose()
|
||||
expect(() => ctx.tasks.register(producer().registration)).toThrow('no control surface is attached')
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
|
||||
})
|
||||
|
||||
it('rejects a config whose default wait exceeds the cap', async () => {
|
||||
@@ -89,7 +89,7 @@ describe('tool-tasks setup', () => {
|
||||
await ctx.plugin(TaskService)
|
||||
ToolTasks.apply(ctx, {})
|
||||
expect(ctx.tools.get('task_output')).toBeDefined()
|
||||
expect(() => ctx.tasks.register(producer().registration)).not.toThrow()
|
||||
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('task_output', () => {
|
||||
it('reads a consuming delta with a trailing status line', async () => {
|
||||
const { ctx } = await setup()
|
||||
const chunks = ['line one\n', '']
|
||||
ctx.tasks.register(producer({ readOutput: () => chunks.shift() ?? '' }).registration)
|
||||
ctx.tasks.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec)
|
||||
|
||||
// A body already ending in a newline gets no doubled separator.
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('line one\n[status: running]')
|
||||
@@ -107,7 +107,7 @@ describe('task_output', () => {
|
||||
it('returns the final output of a settled final-output task', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ kind: 'subagent', label: 'research' })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]')
|
||||
|
||||
p.settle({ status: 'completed', detail: 'completed', output: 'the answer' })
|
||||
@@ -118,7 +118,7 @@ describe('task_output', () => {
|
||||
it('wait: true blocks until settlement and reports the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ kind: 'subagent', label: 'research' })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true })
|
||||
p.settle({ status: 'completed', output: 'done deal' })
|
||||
@@ -127,7 +127,7 @@ describe('task_output', () => {
|
||||
|
||||
it('wait: true times out against the configured cap and leaves the task alive', async () => {
|
||||
const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 })
|
||||
ctx.tasks.register(producer().registration)
|
||||
ctx.tasks.start(producer().spec)
|
||||
|
||||
// A model-supplied timeout far above the cap is clamped: this returns
|
||||
// promptly (≤ the 20ms cap), not after ten minutes.
|
||||
@@ -150,10 +150,10 @@ describe('task_list', () => {
|
||||
expect(text(await call(ctx, 'task_list', {}))).toBe('(no background tasks)')
|
||||
|
||||
const alice = fakeAgent(ctx, 'sess-alice')
|
||||
ctx.tasks.register(producer({ owner: alice, label: 'pnpm test' }).registration)
|
||||
ctx.tasks.register(producer({ kind: 'subagent', label: 'open research' }).registration)
|
||||
ctx.tasks.start(producer({ owner: alice, label: 'pnpm test' }).spec)
|
||||
ctx.tasks.start(producer({ kind: 'subagent', label: 'open research' }).spec)
|
||||
const p = producer({ owner: alice, label: 'build' })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('task_kill', () => {
|
||||
it('requests cancellation with the forwarded reason', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer()
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
|
||||
expect(text(result)).toBe('requested cancellation of task bash-1')
|
||||
@@ -183,7 +183,7 @@ describe('task_kill', () => {
|
||||
const { ctx } = await setup()
|
||||
let delta = 'unread tail'
|
||||
const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('completion notices', () => {
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
@@ -233,7 +233,7 @@ describe('completion notices', () => {
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
await call(ctx, 'task_kill', { task_id: 'bash-1' }, owner)
|
||||
p.settle({ status: 'killed' })
|
||||
@@ -246,7 +246,7 @@ describe('completion notices', () => {
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const p = producer({ owner, kind: 'subagent' })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true }, owner)
|
||||
p.settle({ status: 'completed', output: 'answer' })
|
||||
@@ -258,7 +258,7 @@ describe('completion notices', () => {
|
||||
const { ctx } = await setup()
|
||||
// Unowned: settles with nobody to notify — nothing throws.
|
||||
const unowned = producer()
|
||||
ctx.tasks.register(unowned.registration)
|
||||
ctx.tasks.start(unowned.spec)
|
||||
unowned.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
@@ -266,7 +266,7 @@ describe('completion notices', () => {
|
||||
const inject = vi.fn(() => { throw new Error('agent "agent-sess-1" is disposed') })
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
@@ -277,7 +277,7 @@ describe('completion notices', () => {
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.register(p.registration)
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
// The throw escapes the notice listener and is contained (logged) by the
|
||||
@@ -292,10 +292,10 @@ describe('completion notices', () => {
|
||||
|
||||
// Owner known at registration, unregistered before settlement → no match.
|
||||
const p1 = producer({ owner })
|
||||
ctx.tasks.register(p1.registration)
|
||||
ctx.tasks.start(p1.spec)
|
||||
// A second task whose settlement happens after the whole registry is gone.
|
||||
const p2 = producer({ owner })
|
||||
ctx.tasks.register(p2.registration)
|
||||
ctx.tasks.start(p2.spec)
|
||||
|
||||
await agentsFiber.dispose()
|
||||
p1.settle({ status: 'completed' })
|
||||
|
||||
@@ -173,7 +173,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolTasks)
|
||||
},
|
||||
note:
|
||||
'The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.register()`.',
|
||||
'The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.start()`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-todo',
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcessRead", "source": "packages/bash/bash/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRegistration", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskStart", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskHooks", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskSnapshot", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRead", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
|
||||
Reference in New Issue
Block a user