Files
deepseek-harness/docs/core-data-structures/tasks.md
Tianyi Cui 1a69a3debe fix(tasks): bind cleanup and notices to exact owners
Task records previously retained only ownerSession. If an old agent scope unwound after another agent reused the same agent and session ids, cleanup selected both records and could cancel replacement work. The completion surface also re-resolved the session at settlement, which could inject an old task notice into the replacement agent.

Retain the exact Agent instance for lifecycle work, select owner cleanup by object identity, and pass that exact owner to completion listeners. Keep read, list, kill, and wait authorization session-based as the runtime RFC intends. Add regressions for cleanup and notice routing under id reuse, then update the public docs and generated API catalogs.
2026-07-15 11:40:37 +08:00

8.3 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, effect-scoped, contained). 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.