Files
deepseek-harness/docs/core-data-structures/tasks.md
T
Tianyi Cui d462348556 fix(tasks): contain async completion listeners
TaskService contained synchronous listener throws but discarded returned promises. An async TaskDoneListener could therefore reject as an unhandled process rejection even though the API promises per-listener containment.

Allow listeners to return PromiseLike<void> and attach a rejection handler to each result without awaiting it. This logs asynchronous failures independently while preserving the observation-only contract: later listeners, task waiters, and teardown are not delayed. Add a regression test and synchronize the public docs and generated API declaration.
2026-07-15 11:40:47 +08:00

8.4 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.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, 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: 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.

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
   * identity (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
}
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
}
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.

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 authorization and
   * correlation; absent for unowned tasks. A listener that must reach the
   * lifecycle owner receives the exact Agent separately through
   * {@link TaskDoneListener}. 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
}
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.taskspackages/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, given the exact lifecycle owner and effect-scoped). Listener calls contain synchronous throws and returned promise rejections independently; returned promises are not awaited and therefore do not delay task settlement. Start retains the exact live Agent instance validated under its id; owner-scope cleanup selects by that identity, so a reused agent/session id cannot make an old scope cancel replacement work. Read/kill/wait/get authorization remains session-based and rejects a foreign session. 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.