Files
deepseek-harness/packages/tasks/tasks/README.md
Yichen Jiang 43d60286e3 fix(tasks): an aborted wait must not leave a settled task notice-suppressed (review finding)
Two windows where settle() could read a live waiter count, mark the
task reported (suppressing the completion notice), and then watch that
waiter reject with 'wait aborted' delivering nothing — leaving the
owning session with no terminal notification at all:

- abort and settlement in the same tick, settle continuation ordered
  first: the waiter now un-counts itself SYNCHRONOUSLY inside onAbort
  (the finally decrement alone lands a microtask too late), so the
  settle path sees no live waiter and the notice fires;
- abort landing after settlement but before the wait's resolve
  microtask: the wait now resolves and DELIVERS the terminal snapshot
  it owes instead of rejecting (settlement suppressed the notice on
  this waiter's behalf).

Both windows pinned by deterministic tests that fail on the previous
implementation; wait()'s abort contract updated in JSDoc/README/RFC.
2026-07-09 23:08:26 +08:00

3.7 KiB

@deepseek-ai/dsh-tasks

The background task registry (ctx.tasks): a runtime-global, CONCRETE service (no interface/implementation split — one sensible in-process implementation exists; a durable job backend would own that extraction) that gives every long-running tool the same ids, isolation, and lifecycle.

Service API

  • start(spec): TaskId — declare-then-execute: the producer hands identity (kind — also the id prefix — label, optional owner: Agent) plus run(), the starter that returns the work's TaskHooks (cancel(reason?), done: Promise<TaskOutcome> settling at QUIESCENCE and never rejecting, optional readOutput() for stream kinds; absence = final-output-only). Every check that can fail — the control-surface fence (the loud guard against a deployment exposing run_in_background with no way to collect or stop the work), validation, the owner-cleanup attach — runs BEFORE run() starts the actual work, and nothing can fail after it returns: work started without a collectable id is structurally impossible, not a producer rollback obligation.
  • get(id, caller?) / list(caller?) — non-consuming snapshots; list returns only caller-owned plus unowned tasks (a global listing would leak foreign labels).
  • read(id, caller?): TaskRead — stream kinds consume the per-task cursor (v1's single intended reader is the owning model — a non-consuming multi-reader surface would be a cursor/snapshot API extension, not a read change); final kinds read the terminal output idempotently.
  • kill(id, caller?, reason?)'requested' (live task: producer cancel runs first — a throw fails the kill loud and leaves the task untouched — then stopping) or 'already-terminal'. Every successful kill marks the task reported (the killer saw the end → completion notice suppressed).
  • wait(id, timeoutMs, caller?, signal?) — resolves with the terminal snapshot (marked reported), or the live snapshot at timeout; an aborted signal rejects the WAIT only — unless the task already settled, in which case the wait still resolves and delivers the terminal snapshot (settlement suppressed the completion notice on this waiter's behalf, so rejecting would leave the finish both unreported and un-noticed). Timing is a dsh-timeout deadline() scoped to the TASK_WAIT_TIMEOUT code, so a nested foreign deadline never misreads as a wait timeout.
  • onTaskDone(listener) — exactly once per task with the terminal snapshot; effect-scoped, per-listener containment, silent after service disposal.
  • attachSurface(name) — declares a control surface exists (the model tools, or a deployment's custom surface); effect-scoped.

Every read/kill/wait/get compares the task's owner session (owner.session.header.id) with the caller's and rejects a foreign one — ids are predictable (bash-1), so the fence, not id secrecy, is the isolation boundary.

Lifecycle

  • Registrations are NOT effect-scoped to the registering fiber: tasks belong to their owning agent + producing backend, so producer/surface HMR reloads never touch them.
  • An owned task attaches (once per owner) an awaited cleanup via ctx.agents.onCleanup: on the owner's disposal the registry cancels its live tasks, awaits each done, and drops the snapshots — AgentHandle.dispose() resolves only after quiescence.
  • Service disposal closes the listener registry first (late teardown kills stay silent), cancels every live task with containment, and awaits settlement.

Non-goals (v1)

Durable/cross-restart tasks, non-consuming observation cursors, and foreground→background promotion are deliberate deferrals — see the runtime RFC § Alternatives.