@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, optionalowner: Agent) plusrun(), the starter that returns the work'sTaskHooks(cancel(reason?),done: Promise<TaskOutcome>settling at QUIESCENCE and never rejecting, optionalreadOutput()for stream kinds; absence = final-output-only). Every check that can fail — the control-surface fence (the loud guard against a deployment exposingrun_in_backgroundwith no way to collect or stop the work), validation, the owner-cleanup attach — runs BEFORErun()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;listreturns 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 areadchange); final kinds read the terminal output idempotently.kill(id, caller?, reason?)—'requested'(live task: producercancelruns first — a throw fails the kill loud and leaves the task untouched — thenstopping) or'already-terminal'. Every successful kill marks the taskreported(the killer saw the end → completion notice suppressed).wait(id, timeoutMs, caller?, signal?)— resolves with the terminal snapshot (markedreported), or the live snapshot at timeout; an aborted signal rejects the WAIT only. Timing is adsh-timeoutdeadline()scoped to theTASK_WAIT_TIMEOUTcode, 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 eachdone, 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.