diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index ba89caf387..efd5c8a132 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -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 `-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 ` 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 `-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 ` 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 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index fce449c6bb..793203cc1b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 0c640828cf..212570c358 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -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 | diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index f229e402ee..10e1d7f6d2 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -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 `-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). diff --git a/docs/module-graph.md b/docs/module-graph.md index cec471df99..fe4dfe5099 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.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) | diff --git a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 5664c19dd5..86aafa7944 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -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 `-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 diff --git a/docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md b/docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md index 0c9c7f884e..6774c3eb1f 100644 --- a/docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md +++ b/docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md @@ -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 `. 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 `. 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. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index b309895936..b00e768a3b 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -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` diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index bb02b67b00..6bc0d334f3 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -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({ diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 7aebb310c8..71edc21eb5 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -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 { return Promise.reject(new Error('unused')) } start(spec: BashExecSpec): BashProcess { - let close!: () => void - const done = new Promise((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 () => { diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 669621cb21..b32d6e4d4c 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -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}` }] } diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 06d39c2ec6..70e5e82c0b 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -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) }) }) diff --git a/packages/tasks/README.md b/packages/tasks/README.md index a22528ba6d..e2221dce64 100644 --- a/packages/tasks/README.md +++ b/packages/tasks/README.md @@ -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 `-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. diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 14a2f75d6e..1d491cd6e0 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -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` (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` 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). diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 5d926b5230..2b89f461e1 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -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 (`-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 (`-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((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 diff --git a/packages/tasks/tasks/src/types.ts b/packages/tasks/tasks/src/types.ts index 09b1911350..843ab11671 100644 --- a/packages/tasks/tasks/src/types.ts +++ b/packages/tasks/tasks/src/types.ts @@ -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 diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 87a71cd565..58eca32f48 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -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 = {}) { +/** A controllable producer start-spec: settle its `done` on demand, record cancels. */ +function producer(overrides: Partial & 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((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(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((res) => { settle = res }), + run: () => ({ + cancel() { if (broken) throw new Error('cancel boom') }, + done: new Promise((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((res) => { settle = res }), + run: () => ({ + cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) }, + done: new Promise((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((res) => { settle = res }), + run: () => ({ + cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) }, + done: new Promise((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((res) => { settle = res }), + run: () => ({ + cancel() { throw new Error('cancel boom') }, + done: new Promise((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((res) => { settle = res }), + run: () => ({ + cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) }, + done: new Promise((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') }) }) diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 0548526474..9246375926 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -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 diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 859c2b0f21..65d9f0ce0e 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -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 diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 930dc2b019..639d17dfb0 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -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 = {}) { +/** A controllable producer start-spec (settle `done` on demand, record cancels). */ +function producer(overrides: Partial & 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((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(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' }) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index d8e6b85627..23a57f332b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -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', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b619b9c882..d0bdbb2f4e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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" },