One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced read/kill/wait/list, attachSurface misconfiguration fence, reported-flag notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/ task_kill, completion-notice injection, background prompt habit). Producers opt in via their own enableRunInBackground config: bash (stream kind; seam slimmed to resolve/run/start returning a BashProcess handle, bash_output/bash_kill deleted) and subagent (final-output kind; done settles after run.dispose()). Owner disposal drains tasks through the new awaited ctx.agents.onCleanup seam in the loop's disposal chain. Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
6.8 KiB
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, ctx.tasks), not an interface/implementation seam pair — see the runtime RFC for the decision and the tasks group README for the package split.
Source: packages/tasks/tasks/src/types.ts
Ids and status
TaskId is branded (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
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.
interface TaskRegistration {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
kind: string
/** One-line model-facing label (the command; the delegation description). */
label: string
/**
* 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
* tasks service disposes.
*/
owner?: Agent | undefined
/**
* 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.
*/
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.
*/
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).
*/
readOutput?(): string
}
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.
interface TaskOutcome {
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
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 TaskRegistration.readOutput}),
* read idempotently after the task settles. Stream kinds leave it unset —
* their output is consumed incrementally through `readOutput`.
*/
output?: string
}
What consumers see: TaskSnapshot and TaskRead
Snapshots are fresh projections, never live registry state. reported is the notice-suppression flag: the completion-notice injector (dsh-tool-tasks) skips a task whose terminal state the model already saw.
interface TaskSnapshot {
/** The registry-issued id (`<kind>-N`). */
id: TaskId
/** The producer kind the task was registered with. */
kind: string
/** The producer-supplied one-line label. */
label: string
/**
* The owner's session id (`session.header.id`), for surfaces that must
* reach the owning agent (the completion-notice injector); absent for
* unowned tasks. Session ids are runtime-shared identifiers, not secrets —
* the read/kill/wait/list FENCE is what isolation rests on.
*/
ownerSession?: string
/** Current lifecycle state. */
status: TaskStatus
/** Kind-specific status detail, present once the producer supplied one (usually terminal). */
detail?: string
/** Epoch ms when the task was registered. */
startedAt: number
/** 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.
*/
reported: boolean
}
interface TaskRead {
/**
* Stream kinds: the consuming delta since the previous read. Final-output
* kinds: empty while live, the terminal {@link TaskOutcome.output} (or
* empty) once settled — idempotent, never consumed.
*/
text: string
/** The task's state at read time. */
snapshot: TaskSnapshot
}
The service
TaskService (ctx.tasks — 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.