# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/tool-catalog.md # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-core/tests/agent-core.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/index.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/core/agent/tests/agent.spec.ts # packages/subagent/subagent/README.md # packages/subagent/subagent/src/index.ts # packages/subagent/tool-subagent/README.md # packages/subagent/tool-subagent/src/index.ts # pnpm-lock.yaml # scripts/doc-budgets.manifest.json
184 lines
29 KiB
Markdown
184 lines
29 KiB
Markdown
# 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 `<kind>-N` with a per-kind counter (`bash-1`, `subagent-1`) — the kind prefix keeps ids self-describing in transcripts and preserves the pre-runtime `bash-N` shape. Ids are runtime-global and predictable, so every access is authorized (below).
|
|
|
|
A producer 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<TaskOutcome>
|
|
/** 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<TaskSnapshot>
|
|
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: `<id> [<kind>] <status> — <label>`; `(no background tasks)` when empty. Most peers make listing a human-only surface (`/tasks` panels) and only Gemini CLI ships a model-facing list; DSH keeps it model-facing because the harness is an SDK with no guaranteed user UI — a deployment may have no `/tasks` equivalent — and a caller-scoped list is one cheap registry read.
|
|
- `task_kill(task_id, reason?)` — requests cancellation and returns immediately (`requested cancellation of task <id>`); the optional `reason` lands in the logged tool args and is forwarded to the producer's `cancel` hook (the subagent producer aborts its task-owned signal with that reason). Killing an already-terminal task reports its terminal status rather than failing; a producer `cancel` that throws fails the call loud and leaves the task untouched (still `running`, notice not suppressed).
|
|
|
|
`task_output`'s read cursor is task-scoped and CONSUMING for stream kinds: the registry keeps one cursor per task, and a read returns everything produced since the previous read, exactly like the old `bash_output`. v1's intended reader is the owning model — the owner fence already makes it the only model-facing one — so a non-consuming observation surface (a UI tailing a task, multiple concurrent readers) is deliberately out of scope; when one is needed, it extends the registry with a cursor/snapshot read API rather than changing `task_output`, because two consumers sharing the consuming cursor would silently eat each other's output.
|
|
|
|
One system-prompt section (order 106, next to `tool:bash`) teaches the cross-call habit the per-tool descriptions cannot: track every returned task id; you are notified in-session when a task finishes, so do not busy-poll or sleep on one — keep working on independent steps and do not duplicate a running task's work; do not produce a final answer while a relevant task still runs — call `task_output` (with `wait` when blocked) to collect it first; `task_kill` tasks that stopped mattering. The do-not-poll and do-not-duplicate sentences are near-verbatim convergent across Claude Code, Kimi Code, and OpenCode — they are the two failure modes every peer engineered against.
|
|
|
|
Completion notices stay durable context, not a wake-up (`agent.inject()` appends a logged `context/message` the next model request sees; it does not run the model): on `onTaskDone`, `dsh-tool-tasks` injects `background task <id> (<kind>: <label>) finished [status: …]. Read its output with task_output.` into the owning agent's session, with the same disposed-race containment `dsh-tool-bash` used to carry. Notices are deduplicated the way Claude Code and Kimi Code both learned to: a task the model explicitly killed, or whose terminal state a read/wait already returned (including a wait pending at the moment of settlement), is marked `reported` and its notice suppressed — never a redundant "finished" for work the model just collected or ended. The model-visible ⟺ logged invariant holds with no new session event type.
|
|
|
|
## 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 bundle forwards the configs of the child plugins it owns: `dsh-agent-core` exposes `toolBash` for its built-in producer and `toolTasks` for the generic control surface, while independently composed producers such as subagent instances receive config directly. This forwarding is config reachability, not producer registration: future background-capable tools do not become `agent-core` fields unless that bundle also chooses to own them. A disabled producer omits the parameter from its schema entirely — and, because the arg validator deliberately allows undeclared keys, its `execute` ALSO refuses a forced `run_in_background: true` loud (the omission is advertising; the execution-time check is the enforcement). `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, exact live owner instance, and 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.
|
|
|
|
## Agent-scope owner cleanup
|
|
|
|
A contract-compliant background task must not outlive its owner: the subagent case leaks live child agents/sessions otherwise, and `agent/disposed` is an observe-only notification rather than a quiescence seam. Every live agent already owns an awaited structural registration scope ([agent-scope contract](2026-07-08-agent-scope-contexts.md)), so the task runtime uses that single lifecycle mechanism:
|
|
|
|
- After validating the exact registered owner instance, the first task for that owner registers an async effect through `owner.ctx`. The effect belongs to the agent scope, survives producer reloads, cancels that owner's live tasks, awaits each terminal record, and drops the snapshots.
|
|
- `AgentHandle.dispose()` stops and drains the driver, detaches the agent and session, then awaits scope disposal. The task cleanup therefore participates in the same memoized quiescence boundary as every other agent-owned registration; no task-specific link exists in `AgentRegistry` or the loop.
|
|
- The tasks service retains each exact owner-effect disposer so service reload can detach callbacks from still-live scopes after global task teardown, rather than leaving a dead service retained until every agent exits.
|
|
|
|
For contract-compliant producers, `AgentHandle.dispose()` resolves only after the owner's background children are gone. A producer whose teardown cancel throws is the explicit degradation: its record settles `failed` with a possible-orphan detail and cleanup continues. An ownerless task is the sanctioned way for healthy work to outlive an agent, and a future durable-job RFC is the way to outlive the runtime.
|
|
|
|
## Bash migration
|
|
|
|
`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.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 while `dsh-tool-subagent` keeps its one-instance-per-provider shape. The task starter creates an independent `AbortController`, immediately begins async `ctx.subagents.start()` with that signal, and synchronously returns hooks: `cancel(reason)` aborts the controller, while `done` awaits startup rollback or the ready run's result and disposal. This covers cancellation before and after readiness through the subagent seam's one signal channel. Terminal mapping remains `completed` with final text, `aborted` as `killed`, and other reasons as `failed`; there is no `readOutput` because the child session remains the detailed trace.
|
|
|
|
## Alternatives considered
|
|
|
|
### Why not per-capability companion tools (`bash_output`/`bash_kill` + `subagent_wait`/`subagent_output`/`subagent_stop`)?
|
|
|
|
That is the trajectory this RFC interrupted. Each capability re-implements ids, ownership, polling, stop, notices, and guidance; the model learns N collect/stop habits and the prompt carries N near-identical tool descriptions; `dsh-tool-subagent` needed a structural reshape purely to de-duplicate its clones. Claude Code walked this exact path — per-capability `BashOutput`/`KillShell` first, then a generalized `TaskOutput`/`TaskStop` spanning shells, agents, and remote sessions with the old names kept as deprecated aliases — and the pre-release stance let this repo land directly on the unified shape with no alias burden.
|
|
|
|
### Why not an abstract `TaskRuntime` seam with swappable backends?
|
|
|
|
There is one in-process implementation and no concrete second backend; the capability-seam rule is to split when the consumer and backend can actually evolve independently, not before. A durable/persistent job system is the plausible second backend, and it changes the lifecycle contract (survival across owner disposal) enough that its RFC should own the interface extraction.
|
|
|
|
### Why not keep authorization in the consumers, as bash did?
|
|
|
|
The bash split put policy in `dsh-tool-bash` so the executor seam stayed session-free — right for a seam that may be implemented remotely. The registry is harness-local infrastructure whose entire purpose includes the isolation fence; leaving the fence to each consumer means every future surface (model tools, a UI bridge, hook bridges) re-implements it or forgets it. Centralizing it is most of the reason the runtime exists.
|
|
|
|
### Why not a `parallel`-mode `agent/cleanup` event instead of the keyed registry?
|
|
|
|
An event fires for every agent at every disposal and every listener must filter; `Promise.all` rejection semantics need extra containment; and there is no disposer to make registrations effects. A keyed registry is targeted, contained, and disposable — and the loop already drains an ordered chain, so one more awaited link was the smaller change.
|
|
|
|
### Why not blocking-by-default `task_output` (Claude Code's `block: true`)?
|
|
|
|
The established bash habit is poll-between-work, and the guidance tells the model to keep doing independent work while tasks run; defaulting to block would silently serialize the parent on its slowest child. The explicit `wait: true` keeps blocking a deliberate act, and the wait/read/kill trio still lands within the three-tool surface.
|
|
|
|
### Why not a separate `task_wait` tool?
|
|
|
|
Waiting is never useful without reading the result afterwards; a separate tool doubles the calls and the schema surface for zero information. Folding it into `task_output` matches the only real usage pattern.
|
|
|
|
### Why not `ToolDefinition.timeoutMs` (the timeout-policy plugin) for `task_output`'s wait?
|
|
|
|
The [timeout library](2026-07-06-timeout-deadline-library.md) gives `wait()` its timing internals — `ctx.tasks.wait` arms a `deadline()` and classifies wait-timeout vs caller-abort with `timeoutOf` scoped to `TASK_WAIT_TIMEOUT` — but the tool-call-level policy is deliberately NOT adopted: timeout-policy replaces a timed-out call with a structured `TOOL_TIMEOUT` failure, whereas a timed-out `task_output(wait: true)` is a SUCCESS that must still report `[status: running]` (the model needs the task's state either way, and the task keeps running). The wait therefore bounds its own deadline through the tool's `waitTimeoutMs`/`maxWaitTimeoutMs` config. For the same reason, no timeout policy manages a background task's LIFETIME: once the id is returned the work is off the tool-call deadline entirely — cancellation belongs to `task_kill` and owner cleanup.
|
|
|
|
### Why not a push-sink producer contract (`appendOutput`/`settle`), as Kimi Code's manager uses?
|
|
|
|
A sink centralizes output buffering, truncation, and spill in the runtime, which is elegant when the runtime owns output storage. In this codebase those concerns already live — bounded, tested, spill-file-aware — inside `dsh-bash-local`, and keeping process concerns in the executor is the point of the bash seam. The pull contract (`readOutput()` returning a formatted delta) reuses that machinery as-is; a sink would relocate it for no v1 gain. If a durable backend later makes the runtime own output storage, the producer contract is the one seam to revisit.
|
|
|
|
### Why not random task ids (Claude Code's per-kind `36^8` suffixes)?
|
|
|
|
Peers with shared registries use unguessable ids as defense-in-depth against cross-session access and predictable-path attacks. Here the owner fence is the boundary — exactly as `dsh-tool-bash` documented for the old predictable `bash-N` — and the registry hands no filesystem paths derived from the id, so sequential per-kind counters keep transcripts readable and tests deterministic. Nothing prevents switching the generator later; the id is branded and opaque to consumers.
|
|
|
|
### Why not foreground→background promotion?
|
|
|
|
Claude Code, Kimi Code, and OpenCode all let a running foreground call be promoted to a background task (user action or an over-budget auto-detach). It is deliberately out of v1: promotion needs a UI/user channel the SDK does not prescribe, and it changes the foreground tools' result contracts. The registration-based design leaves the door open — a foreground execution is promotable by registering its already-running work mid-flight — and a follow-up RFC can add it without touching the model-facing control tools.
|
|
|
|
### Why not new session events for task lifecycle?
|
|
|
|
Everything model-visible already lands in the log: starts and reads are tool calls/results, notices are injected `context/message` events. A `task/*` session event would duplicate facts the log carries, and live registry state (`task_list`) is intentionally runtime state, exactly as bash's task table was.
|
|
|
|
## Testing
|
|
|
|
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers and stale owner objects after id reuse, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, start atomicity, model-facing and teardown cancel failures, ordinary disposal quiescence, and per-kind counters), the branded `SessionId` boundary, owner-effect placement and service-detach behavior, both producers' start mapping including async subagent readiness cancellation, and the structural no-uncollectable-work guarantee. Snapshot coverage pins the task tool schemas and prompt section.
|
|
|
|
## Consequences
|
|
|
|
One background-task contract exists instead of a per-capability clone family: a background subagent and a background bash command coexist in one session under one id namespace, one listing, one notice format, and one guidance section, and the [tool cookbook](../../../cookbook/adding-a-tool.md) points long-running tools at `ctx.tasks`. The cost was a wide landing change — the bash seam lost its registry surface and every test tier moved with it, model-facing tool names churned (`bash_output`/`bash_kill` deleted), and the ACP snapshot pinned header was refreshed for the new schemas — sanctioned by the pre-release stance.
|
|
|
|
Owner-scoped cleanup changed bash semantics: a task that previously outlived its agent now dies with it. Deployments that relied on fire-and-forget background commands start them unowned (a non-agent caller) or accept the new lifecycle; the uniformity was judged worth the change, and the durable-job direction remains open for real survival requirements.
|
|
|
|
Teardown trusts the producer contract that a successful `cancel` leads to `done` settling at quiescence. An explicit cancel throw is detectable and force-fails the record with a possible-orphan warning so disposal cannot deadlock; a cancel that returns but silently fails is indistinguishable from a slow stop and can still stall teardown. Covering that residual requires a bounded lifetime or a separate forced-disposal contract, both outside this runtime shape.
|
|
|
|
`wait` is the first blocking tool call whose duration is model-controlled; the config cap bounds it, but a model that serializes on `wait` loses the parallelism the feature exists for — prompt guidance mitigates, and a future continuation-policy guard can enforce. Recording live background-flow snapshot scenarios (a polled and killed background command, a completion-notice turn) and a with-key e2e background lifecycle require a `DEEPSEEK_API_KEY` re-record and remain named follow-up work. The runtime deliberately defers durable/cross-restart tasks, non-consuming observation cursors, and foreground→background promotion (see Alternatives).
|