# RFC: The background task runtime (`ctx.tasks`) and the generic task control tools Status: implemented ## Problem The bash capability seam supports both foreground commands and long-running background tasks. Background support was large: the abstract executor exposed `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracked tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model saw three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injected completion notices back into the owning agent's session. The local executor fenced task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. The [tool cookbook](../../../cookbook/adding-a-tool.md) already pointed at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. The pressure stopped being hypothetical with [background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md), which needs the same task ids, owner isolation, polling, stop, completion notices, and prompt guidance, and whose first draft answered by cloning the protocol under new names (`subagent_wait`, `subagent_output`, `subagent_stop`) and reshaping `dsh-tool-subagent` into a multi-tool plugin solely so the cloned companion tools would not collide across instances. Every future long-running capability (dev servers, watchers, remote jobs) would clone it again, and the model would learn a new collect/stop habit per capability. The surveyed peer products converged on the opposite shape. Claude Code exposes one `TaskOutput`/`TaskStop` pair spanning seven task kinds (background shells, subagents, remote sessions, …), with its earlier per-capability `BashOutput`/`KillShell` names kept only as aliases; Kimi Code's `BackgroundManager` runs process, agent, and pending-question kinds behind the same two tools and a ~5-method producer interface; DeepSeek-Reasonix serves bash and delegation from one session-scoped jobs manager; OpenCode's `BackgroundJob` registry is kind-agnostic by construction. The lesson is that the task registry, the control tools, and the notification path are one capability, and the producers (bash, subagents) are plugins into it. ## Decision The `tasks/` package group owns background-task semantics once, and bash and subagents are producers: - `@deepseek-ai/dsh-tasks` — the task registry service (`ctx.tasks`): branded task ids, owner-scoped authorization, status snapshots, incremental/final output reads, cancellation, wait-for-terminal, completion listeners, and the awaited owner-cleanup path. - `@deepseek-ai/dsh-tool-tasks` — the model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection into the owning session, and the system-prompt section that teaches the background-task habit. Producers register running work into `ctx.tasks` and stay owners of their execution concerns: `dsh-tool-bash`'s `run_in_background` path registers the process it started (incremental stdout, spill formatting, kill), and `dsh-tool-subagent`'s background mode ([the feature RFC](../feature/2026-07-08-background-subagent-tasks.md)) registers the child run (final output only, cancel + dispose). The bash seam carries no registry: `bash_output`/`bash_kill` no longer exist (the generic tools replaced them), and the subagent companion tools were never created. The `dsh-agent-core` bundle loads the pair, so every shipped deployment has the control surface. The registry is a CONCRETE service, not an interface/implementation seam pair: there is exactly one sensible in-process implementation today, and the capability-seam convention says not to split preemptively. The pre-release stance lets a later durable/remote job system extract an interface when a second backend actually exists. ## Task model `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 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 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. */ done: Promise /** OPTIONAL incremental read (stream kinds). Consecutive calls never re-deliver output; the producer owns truncation/spill formatting. Absence = final-output-only kind. */ readOutput?(): string } interface TaskOutcome { status: 'completed' | 'killed' | 'failed' /** Kind-specific detail rendered into the status line ('exit code: 3', 'max-tokens'). */ detail?: string /** Final output for final-only kinds; read idempotently after the task settles. */ output?: string } ``` The task status vocabulary is generic and closed: `running`, `stopping` (cancel requested, not yet settled), and the three terminal values above. Kind-specific meaning rides in `detail`, so the registry never learns process or agent semantics — the method presence (`readOutput`) is the capability, mirroring `SubagentRun.sendMessage`. The registry attaches ONE continuation to `done`: record the terminal snapshot, then notify task-done listeners with per-listener containment (the guarantee the bash seam's `notifyTaskDone` used to give its own listener set). `done` settling at quiescence — not merely at completion — makes ordinary owner cleanup and service disposal awaitable without a second completion surface. Settlement is first-wins because teardown has one explicit failure fallback: if producer `cancel` throws before the request is delivered, `done` may never settle, so the registry force-fails the record with a possible-orphan detail instead of deadlocking disposal; a late producer outcome cannot overwrite that diagnosis or notify twice. This fallback terminates bookkeeping, not necessarily the underlying work. Registrations are NOT effect-scoped to the registering fiber: a task belongs to its owning agent and its producing backend, not to the tool plugin whose call started it, so an HMR reload of `dsh-tool-bash` or `dsh-tool-tasks` never orphans or kills a running task (the same argument that used to keep bash ownership in the executor). The registry's own disposal cancels every live task and awaits contract-compliant producers to quiescence. A teardown cancel that throws force-fails the terminal record and logs that work may be orphaned; this prevents a broken producer from deadlocking the fiber without pretending the work stopped. ## Authorization and the service surface Cross-session isolation lives IN the runtime so every consumer gets the same rule for free: read/kill/wait/get take the caller (`Agent | undefined`), and a task whose owner session differs from the caller's session is rejected (`!== undefined` comparison — an unowned task is open, a no-agent caller cannot match an owned one). `list(caller)` returns only the caller-visible tasks (owned-by-caller or unowned) — a global listing would leak other sessions' labels. The snapshot carries that owner as the canonical branded `SessionId`, not a package-local token or bare string. Lifecycle ownership is checked independently at start: the supplied owner must be the exact live `Agent` instance currently registered under its id, so an old object cannot attach its session's work to a replacement agent's cleanup after id reuse. ```ts ignore-check class TaskService extends Service { // ctx.tasks 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 kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal' wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise onTaskDone(listener: (snapshot: TaskSnapshot) => void): () => void // effect-scoped, contained, never fires after dispose attachSurface(name: string): () => void // the misconfiguration fence, below } ``` `TaskSnapshot` is the read-only projection: id, kind, label, branded owner `SessionId`, 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 — unless the task already settled, in which case the wait still delivers the terminal snapshot (settlement suppressed the completion notice on this live waiter's behalf, and an aborted waiter un-counts itself synchronously so a same-tick settlement never suppresses a notice nobody will deliver). **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 `dsh-tool-tasks` registers three kind-agnostic tools (ACP render intent: `generic` cards, `kind: 'execute'` for kill and `'read'` for output/list, no `locations`): - `task_output(task_id, wait?, timeout_ms?)` — non-blocking by default: stream kinds return output produced since the previous read, final kinds return only a status line while running and the final output once terminal; every response ends with the status line (`[status: running]`, `[status: completed, exit code: 0]`, `[status: failed, max-tokens]` — generic status + producer detail). `wait: true` blocks until the task settles or the timeout expires (config: defaulted `waitTimeoutMs`, capped `maxWaitTimeoutMs`); a timed-out wait returns `[status: running]` and leaves the task alive. Polling-by-default preserves the established bash habit; `wait` is what a parent uses when it is genuinely blocked on a subagent's answer. - `task_list()` — the caller's tasks, one line each: ` [] —