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 files changed
+281 -240

No files matched your search

@@ -25,16 +25,21 @@ The registry is a CONCRETE service, not an interface/implementation seam pair: t
`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 registers a task with:
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 TaskRegistration {
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. */
@@ -64,7 +69,7 @@ Cross-session isolation lives IN the runtime so every consumer gets the same rul
```ts ignore-check
class TaskService extends Service { // ctx.tasks
register(reg: TaskRegistration): TaskId // throws when no control surface is attached; ATOMIC — a throw mutates nothing
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
@@ -77,7 +82,7 @@ class TaskService extends Service { // ctx.tasks
`TaskSnapshot` is the read-only projection: id, kind, label, owner session, 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.
**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 `register()` 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.
**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
@@ -95,7 +100,7 @@ Completion notices stay durable context, not a wake-up (`agent.inject()` appends
## 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 disabled producer omits the parameter from its schema entirely, so schema and capability can never disagree. `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 `register()` without a control surface throws the load-this-package error. `register()` is atomic (a throw mutates no registry state), and a producer whose registration fails cancels and awaits its just-started work before rethrowing — background work never runs without a collectable id.
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 disabled producer omits the parameter from its schema entirely, so schema and capability can never disagree. `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, the 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.
## The awaited owner-cleanup seam
@@ -110,11 +115,11 @@ A background task must not outlive its owner: the subagent case leaks live child
`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.bash.start(...)` + `ctx.tasks.register({ kind: 'bash', label: command, owner: exec.agent, cancel, done, readOutput })`, 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.
`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; the headline consequence is that `dsh-tool-subagent` KEEPS its one-instance-per-provider shape — the multi-tool reshape existed only to keep cloned companion tools from colliding, and there are no companion tools to clone. Its background call starts the provider run, then registers `{ kind: 'subagent', label: description, owner: parent, cancel: run.cancel, done }` where `done` awaits `run.result`, awaits `run.dispose()` (quiescence), and maps the stop reason (`completed` → `completed`; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as detail) and the final text as `output`. No `readOutput` — the child session remains the detailed trace, exactly as that RFC argues.
[Background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md) rides this runtime; the headline consequence is that `dsh-tool-subagent` KEEPS its one-instance-per-provider shape — the multi-tool reshape existed only to keep cloned companion tools from colliding, and there are no companion tools to clone. Its background call is `ctx.tasks.start({ kind: 'subagent', label: description, owner: parent, run })` whose `run()` starts the provider run and returns `{ cancel: run.cancel, done }`, where `done` awaits `run.result`, awaits `run.dispose()` (quiescence), and maps the stop reason (`completed` → `completed`; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as detail) and the final text as `output`. No `readOutput` — the child session remains the detailed trace, exactly as that RFC argues.
## Alternatives considered
@@ -164,7 +169,7 @@ Everything model-visible already lands in the log: starts and reads are tool cal
## Testing
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, register atomicity — a failed registration mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' registration mapping plus their no-orphan guarantee (a failed `register()` kills/cancels and awaits the just-started work before rethrowing), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, start atomicity — a failed preflight mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' start mapping plus the structural no-orphan guarantee (a failed preflight means the producer's `run()` — the spawn — was never invoked), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
## Consequences
@@ -16,7 +16,7 @@ Each `dsh-tool-subagent` instance may expose `run_in_background?: boolean`, gate
A foreground call keeps the synchronous semantics: it waits for `run.result`, returns final text on `completed`, maps non-clean terminal stop reasons to an errored tool result, and disposes the run in `finally`.
A background call validates that a parent agent exists, checks an already-aborted tool signal before starting, starts the provider run through `ctx.subagents`, registers the run with `ctx.tasks`, and returns `started background subagent task <task_id>`. After the id is returned the tool-call signal is NOT connected to `run.cancel()` — the parent step may finish while the child continues; cancellation belongs to `task_kill` and the owner-cleanup path. A registration that throws (the no-control-surface fence) does not orphan the child: the producer cancels the run, awaits its `done` (which settles only after `run.dispose()`), and rethrows — the model never learns an id for work that is not actually tracked. `ctx.tasks.register()` supplies the runtime guarantees this feature needs and this RFC does not implement: kind-prefixed branded task ids, owner-scoped access (the parent agent is the owner; another session's agent cannot read or kill the task), the loud no-control-surface failure, completion-notice injection, and the generic prompt guidance.
A background call validates that a parent agent exists, checks an already-aborted tool signal, and hands the delegation to `ctx.tasks.start()` — the runtime preflights the control-surface fence and the owner cleanup BEFORE its `run()` starter creates the child through `ctx.subagents`, so a child that started without a collectable id is structurally impossible — then returns `started background subagent task <task_id>`. After the id is returned the tool-call signal is NOT connected to `run.cancel()` — the parent step may finish while the child continues; cancellation belongs to `task_kill` and the owner-cleanup path. `ctx.tasks.start()` supplies the runtime guarantees this feature needs and this RFC does not implement: kind-prefixed branded task ids, owner-scoped access (the parent agent is the owner; another session's agent cannot read or kill the task), the loud no-control-surface failure, completion-notice injection, and the generic prompt guidance.
The registration maps the seam vocabulary onto the runtime's:
@@ -53,10 +53,10 @@ The child session is already the trace for internal reasoning, tool calls, and i
## Testing
Unit coverage pins the stop-reason → outcome mapping (`runOutcome`, including unknown merge-extensible reasons), `settleRun`'s dispose-before-report on both result paths, the detached-signal contract (a pre-aborted signal refuses to start; a returned id is never wired to the tool signal), background settlement collected through the real `task_output`/`task_kill` tools, the no-orphan rollback when `register()` throws, per-instance schema gating (`enableRunInBackground: false` omits the parameter and the background wording), and the loud failure when the tasks runtime is absent. Snapshot coverage pins the changed `subagent`/`subagent_fork` schemas through the pinned-header fixture; recording a live background-delegation transcript requires a `DEEPSEEK_API_KEY` re-record and remains named follow-up work.
Unit coverage pins the stop-reason → outcome mapping (`runOutcome`, including unknown merge-extensible reasons), `settleRun`'s dispose-before-report on both result paths, the detached-signal contract (a pre-aborted signal refuses to start; a returned id is never wired to the tool signal), background settlement collected through the real `task_output`/`task_kill` tools, the structural no-orphan guarantee (a failed `tasks.start` preflight never invokes the provider), per-instance schema gating (`enableRunInBackground: false` omits the parameter and the background wording), and the loud failure when the tasks runtime is absent. Snapshot coverage pins the changed `subagent`/`subagent_fork` schemas through the pinned-header fixture; recording a live background-delegation transcript requires a `DEEPSEEK_API_KEY` re-record and remains named follow-up work.
## Consequences
Slow delegation no longer holds the parent step open: the model fans out background children, keeps working, and collects with the same three control tools it already uses for bash — no new habit, no schema clones, and `dsh-tool-subagent`'s per-provider shape survived unchanged. The feature's usability depends on the tasks pair being loaded; the runtime's `register()` fence turns a missing control surface into a loud, actionable error rather than a silent dead end, and the `dsh-agent-core` bundle ships the pair so every stock deployment has it.
Slow delegation no longer holds the parent step open: the model fans out background children, keeps working, and collects with the same three control tools it already uses for bash — no new habit, no schema clones, and `dsh-tool-subagent`'s per-provider shape survived unchanged. The feature's usability depends on the tasks pair being loaded; the runtime's `start()` preflight fence turns a missing control surface into a loud, actionable error (raised before any child exists) rather than a silent dead end, and the `dsh-agent-core` bundle ships the pair so every stock deployment has it.
The prompt guidance reduces abandoned tasks but cannot force a model to collect every background result. Runtime cleanup through the awaited owner-disposal path is the hard stop; a future planner or guard could enforce "no final answer with relevant running tasks" more strongly if the prompt proves insufficient. A background child outliving its starting tool call means a misbehaving child consumes tokens until collected, killed, or owner-disposed; `task_list` keeps it visible, and setting `enableRunInBackground: false` per instance keeps a deployment's delegation strictly synchronous.