refactor(tasks): declare-then-execute — ctx.tasks.start() replaces register()

start({ kind, label, owner, run }) preflights everything that can fail
(the attachSurface fence, validation, the owner-cleanup attach) BEFORE
invoking the producer's run() starter, then commits atomically —
'work started but never got a collectable id' is now structurally
impossible instead of a producer try/catch rollback obligation (the
P1 review fix, rebuilt on #185's declare/execute split). Producers
lose their catch-wraps; the leak tests now pin the stronger property
that a failed preflight never spawns anything. TaskRegistration splits
into TaskStart (identity + run) and TaskHooks (cancel/done/readOutput);
docs, type-equiv manifest, catalogs, and both RFCs move with it.
This commit is contained in:
Yichen Jiang
2026-07-09 21:55:07 +08:00
parent f858c647a6
commit bd59fddacd
22 changed files with 281 additions and 240 deletions
+1 -1
View File
@@ -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 |
+21 -9
View File
@@ -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 `<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.
## 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).