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:
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user