# 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.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) ## Ids and status `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: `TaskStart` and `TaskHooks` 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 TaskStart { /** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */ kind: string /** One-line model-facing label (the command; the delegation description). */ label: string /** * 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` 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 * cancel that cannot even be requested is a producer bug). The optional * reason is `task_kill`'s logged reason, forwarded verbatim. */ cancel(reason?: string): void /** * Settles with the terminal outcome at QUIESCENCE — after the producer has * released the task's resources (process exited, child agent disposed) — * not merely when the work finished. Must never reject; a rejection is * contained as a `failed` outcome and logged as a producer contract * violation. */ done: Promise /** * OPTIONAL incremental read (stream kinds): everything produced since the * previous call, formatted by the producer (truncation/spill notices * included). Consecutive calls never re-deliver output; the registry keeps * ONE consuming cursor per task, so v1's single intended reader is the * owning model. Absence marks a final-output-only kind (the method presence * IS the capability). */ readOutput?(): string } ``` ```ts type-equiv interface TaskOutcome { /** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ detail?: string /** * Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskHooks.readOutput}), * read idempotently after the task settles. Stream kinds leave it unset — * their output is consumed incrementally through `readOutput`. */ output?: string } ``` ## What consumers see: `TaskSnapshot` and `TaskRead` Snapshots are fresh projections, never live registry state. `reported` is the notice-suppression flag: the completion-notice injector (`dsh-tool-tasks`) skips a task whose terminal state the model already saw. ```ts type-equiv interface TaskSnapshot { /** The registry-issued id (`-N`). */ id: TaskId /** The producer kind the task was registered with. */ kind: string /** The producer-supplied one-line label. */ label: string /** * The owner's session id (`session.header.id`), for surfaces that must * reach the owning agent (the completion-notice injector); absent for * unowned tasks. Session ids are runtime-shared identifiers, not secrets — * the read/kill/wait/list FENCE is what isolation rests on. */ ownerSession?: string /** Current lifecycle state. */ status: TaskStatus /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ detail?: string /** Epoch ms when the task was registered. */ startedAt: number /** Epoch ms when the task settled; absent while `running`/`stopping`. */ finishedAt?: number /** * True once the terminal state has been (or is being) reported to the owner * through an explicit surface response — a `kill` call, or a `read`/`wait` * that returned the terminal state (including a wait pending at settlement). * Completion-notice surfaces suppress their notice when set, so the model * never gets a redundant "finished" for a task it just collected or killed. */ reported: boolean } ``` ```ts type-equiv interface TaskRead { /** * Stream kinds: the consuming delta since the previous read. Final-output * kinds: empty while live, the terminal {@link TaskOutcome.output} (or * empty) once settled — idempotent, never consumed. */ text: string /** The task's state at read time. */ snapshot: TaskSnapshot } ``` ## The service `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).