# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/tool-catalog.md # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-core/tests/agent-core.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/index.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/core/agent/tests/agent.spec.ts # packages/subagent/subagent/README.md # packages/subagent/subagent/src/index.ts # packages/subagent/tool-subagent/README.md # packages/subagent/tool-subagent/src/index.ts # pnpm-lock.yaml # scripts/doc-budgets.manifest.json
144 lines
8.3 KiB
Markdown
144 lines
8.3 KiB
Markdown
# 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)
|
|
|
|
## 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.
|
|
|
|
## The producer contract: `TaskStart` and `TaskHooks`
|
|
|
|
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 TaskStart {
|
|
/** 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 `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.
|
|
*/
|
|
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
|
|
* 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. 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.
|
|
*/
|
|
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
|
|
}
|
|
```
|
|
|
|
```ts type-equiv
|
|
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 TaskHooks.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. `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.
|
|
|
|
```ts type-equiv
|
|
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. The shared
|
|
* {@link SessionId} brand is preserved across this package boundary.
|
|
*/
|
|
ownerSession?: SessionId
|
|
/** 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
|
|
}
|
|
```
|
|
|
|
```ts type-equiv
|
|
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`](../../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, effect-scoped, contained). Start validates that an owned task names the exact live Agent instance currently registered under its id, so an old reference cannot bind work to a replacement agent's cleanup after id reuse. Every read/kill/wait/get separately compares the task's owner session with the caller's and rejects a foreign one. Owned tasks register one async cleanup through the exact owner's `agent.ctx`; scope disposal cancels them and normally awaits producer quiescence. 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).
|