docs(tasks): condense background task prose

The background-task change repeated its lifecycle design across implemented RFCs, package READMEs, JSDoc, test commentary, and model-visible schemas. That repetition obscured the contracts that maintainers must preserve and added avoidable prompt tokens.

Rewrite the implemented RFCs around the current design, keep authorization, exact-owner cleanup, wait/abort ordering, producer quiescence, and teardown-failure guarantees at their owning surfaces, and remove peer surveys, review history, control-flow narration, and emphatic restatement.

Shorten the task and subagent schema wording, synchronize the bilingual tool cookbook, and regenerate the config, service, RFC, tool, and replay snapshot derivatives. Runtime behavior is unchanged; test edits update prose-only assertions and descriptions.
This commit is contained in:
Tianyi Cui
2026-07-15 21:08:58 +08:00
parent 306b79fa2c
commit 8bb8ac8b3c
38 changed files with 548 additions and 1156 deletions

View File

@@ -1,16 +1,14 @@
# 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)
Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`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 `<kind>-N` with a per-kind counter (`bash-1`, `subagent-1`) — kind-prefixed so transcripts stay self-describing, sequential because the owner fence (not id secrecy) is the isolation boundary. `TaskStatus` is generic and CLOSED: `'running' | 'stopping' | 'completed' | 'killed' | 'failed'` — kind-specific meaning (exit codes, stop reasons) rides in `TaskSnapshot.detail`, so the registry never learns process or agent semantics.
`TaskId` is a [branded id](core.md#branded-ids) generated as `<kind>-N`. Access control relies on owner authorization, not id secrecy. `TaskStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `TaskSnapshot.detail`.
## The producer contract: `TaskStart` and `TaskHooks`
## Producer contract
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`.
`TaskStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state.
```ts type-equiv
interface TaskStart {
@@ -19,53 +17,41 @@ interface TaskStart {
/** One-line model-facing label (the command; the delegation description). */
label: string
/**
* The spawning agent. Its `session.header.id` becomes the task's owner
* identity (read/kill/wait/list are fenced to that session), and its `ctx` scope
* owns an async cleanup that cancels and awaits the task during disposal. It
* must be the exact live instance currently registered under its agent id;
* a stale object whose id has been reused is rejected before work starts.
* `undefined` starts an UNOWNED task: open to any caller, alive until the
* tasks service disposes.
* Owning live agent. Access is fenced by its session id, and agent disposal
* cancels and awaits the task. The instance must be the one currently
* registered under its agent id. `undefined` creates an unowned task, open to
* any caller until service disposal.
*/
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.
* Start the work after preflight and synchronously return its hooks. Called
* once; a throw leaves nothing registered, and the producer must clean up any
* partially started resources.
*/
run(): TaskHooks
}
```
`TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks.
```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.
* Request termination. Must be synchronous, idempotent, and eventually settle
* {@link done}; throws propagate. The optional reason is 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. If `cancel` throws during teardown, the runtime may force-fail
* only its registry record to avoid deadlock because this promise may never
* settle; that fallback explicitly does not claim work quiescence.
* Resolves after the producer releases its resources, not merely when work
* finishes. Must not reject; the runtime converts a rejection to `failed`.
* If teardown cancellation throws, the runtime may force-fail only the
* registry record without claiming that the work stopped.
*/
done: Promise<TaskOutcome>
/**
* 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).
* Consume output produced since the previous call. The producer formats
* truncation and spill notices. Absence marks a final-output-only task; each
* task has one consuming cursor.
*/
readOutput?(): string
}
@@ -77,18 +63,14 @@ interface TaskOutcome {
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`.
*/
/** Final output for tasks without `readOutput`; stream tasks leave it unset. */
output?: string
}
```
## What consumers see: `TaskSnapshot` and `TaskRead`
## Consumer views
Snapshots are fresh projections, never live registry state. `ownerSession` retains the shared branded `SessionId` type across the package boundary. `reported` is the notice-suppression flag: the completion-notice injector (`dsh-tool-tasks`) skips a task whose terminal state the model already saw.
Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another surface has delivered or committed to deliver the terminal state.
```ts type-equiv
interface TaskSnapshot {
@@ -99,12 +81,9 @@ interface TaskSnapshot {
/** The producer-supplied one-line label. */
label: string
/**
* The owner's session id (`session.header.id`), for authorization and
* correlation; absent for unowned tasks. A listener that must reach the
* lifecycle owner receives the exact Agent separately through
* {@link TaskDoneListener}. Session ids are runtime-shared identifiers, not
* secrets — the read/kill/wait/list FENCE is what isolation rests on. The
* shared {@link SessionId} brand is preserved across this package boundary.
* Owner session id used for authorization and correlation; absent for
* unowned tasks. Completion listeners receive the exact {@link Agent}
* separately through {@link TaskDoneListener}.
*/
ownerSession?: SessionId
/** Current lifecycle state. */
@@ -116,11 +95,8 @@ interface TaskSnapshot {
/** 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.
* True when a kill, read, or wait has reported or committed to report the
* terminal state. Completion surfaces suppress redundant notices when set.
*/
reported: boolean
}
@@ -139,6 +115,6 @@ interface TaskRead {
}
```
## The service
## Service behavior
`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 terminal record, given the exact lifecycle owner and effect-scoped). Listener calls contain synchronous throws and returned promise rejections independently; returned promises are not awaited and therefore do not delay task settlement. Start retains the exact live Agent instance validated under its id; owner-scope cleanup selects by that identity, so a reused agent/session id cannot make an old scope cancel replacement work. Read/kill/wait/get authorization remains session-based and rejects a foreign session. A teardown cancel that throws force-fails only the registry record and reports that the underlying work may be orphaned, preventing disposal deadlock without claiming quiescence. The model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md).
[`TaskService`](../../packages/tasks/tasks/src/index.ts) provides atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the package contract and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface.