feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced read/kill/wait/list, attachSurface misconfiguration fence, reported-flag notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/ task_kill, completion-notice injection, background prompt habit). Producers opt in via their own enableRunInBackground config: bash (stream kind; seam slimmed to resolve/run/start returning a BashProcess handle, bash_output/bash_kill deleted) and subagent (final-output kind; done settles after run.dispose()). Owner disposal drains tasks through the new awaited ctx.agents.onCleanup seam in the loop's disposal chain. Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
This commit is contained in:
10
packages/tasks/README.md
Normal file
10
packages/tasks/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# tasks/ — background task capability family
|
||||
|
||||
The shared background-task runtime: ONE home for task ids, owner isolation, polling, cancellation, wait, and completion notification, so bash, subagents, and every future long-running tool expose the same model-facing habit instead of cloning a private task protocol each. Rationale and the full design: [the background-task-runtime RFC](../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
|
||||
|
||||
| Package | ctx key | Role |
|
||||
|---|---|---|
|
||||
| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry service: branded `<kind>-N` ids, owner-fenced read/kill/wait/list, settlement bookkeeping, the awaited owner-cleanup path, and the `attachSurface` misconfiguration fence |
|
||||
| [`tool-tasks`](tool-tasks/README.md) (`@deepseek-ai/dsh-tool-tasks`) | — | The model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection, and the background-habit prompt section |
|
||||
|
||||
The split is the state/surface boundary: the registry holds task state (an HMR reload of any tool plugin never orphans or kills a running task), while the tool surface is stateless presentation. Producers (`dsh-tool-bash`, `dsh-tool-subagent`) register running work via `ctx.tasks.register` and keep their own execution concerns; whether a producer exposes `run_in_background` is that producer's own `enableRunInBackground` config, never rewritten by this family.
|
||||
25
packages/tasks/tasks/README.md
Normal file
25
packages/tasks/tasks/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @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
|
||||
|
||||
- `register(registration): TaskId` — a producer hands over running work: `kind` (also the id prefix), `label`, optional `owner: Agent`, `cancel(reason?)`, `done: Promise<TaskOutcome>` (settles at QUIESCENCE, never rejects), optional `readOutput()` (stream kinds; absence = final-output-only). Throws while no control surface is attached — the loud fence against a deployment exposing `run_in_background` with no way to collect or stop the work — and is ATOMIC: a failed registration mutates nothing (no stored task, no counter bump, no owner-cleanup bookkeeping), so producers can reliably cancel their just-started work and rethrow.
|
||||
- `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.
|
||||
- `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](../../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) § Alternatives.
|
||||
35
packages/tasks/tasks/package.json
Normal file
35
packages/tasks/tasks/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tasks",
|
||||
"description": "Background task registry (ctx.tasks) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
457
packages/tasks/tasks/src/index.ts
Normal file
457
packages/tasks/tasks/src/index.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* The background task registry (`ctx.tasks`): ONE home for the semantics every
|
||||
* long-running tool needs — branded task ids, owner-scoped isolation, status
|
||||
* snapshots, incremental/final output reads, cancellation, wait-for-terminal,
|
||||
* completion listeners, and the awaited owner-cleanup path. Producers
|
||||
* (`dsh-tool-bash` background commands, `dsh-tool-subagent` background
|
||||
* delegations, future long-running tools) register running work via
|
||||
* {@link TaskService.register} and keep their own execution concerns; the
|
||||
* model-facing control surface (`@deepseek-ai/dsh-tool-tasks`) drives the
|
||||
* generic read/list/kill/wait operations.
|
||||
*
|
||||
* A CONCRETE service, not an interface/implementation seam pair: there is one
|
||||
* sensible in-process implementation today, and the capability-seam convention
|
||||
* says not to split preemptively (see the background-task-runtime RFC).
|
||||
*
|
||||
* Cross-session isolation lives IN the registry: task ids are runtime-global
|
||||
* and predictable (`bash-1`, `subagent-1`), so every read/kill/wait compares
|
||||
* the task's owner session against the caller and rejects a foreign one —
|
||||
* every surface gets the fence for free instead of re-implementing it.
|
||||
*
|
||||
* Task registrations are NOT effect-scoped to the registering fiber: a task
|
||||
* belongs to its owning agent and producing backend, not to the tool plugin
|
||||
* whose call started it, so an HMR reload of a producer or of the control
|
||||
* surface never orphans or kills a running task. The registry's own disposal
|
||||
* cancels every live task and awaits settlement — no orphans survive
|
||||
* `fiber.dispose()`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tasks
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { TaskId } from './types.ts'
|
||||
import type { TaskDoneListener, TaskOutcome, TaskRead, TaskRegistration, TaskSnapshot, TaskStatus } from './types.ts'
|
||||
|
||||
export { TaskId } from './types.ts'
|
||||
export type {
|
||||
TaskDoneListener,
|
||||
TaskOutcome,
|
||||
TaskRead,
|
||||
TaskRegistration,
|
||||
TaskSnapshot,
|
||||
TaskStatus,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
tasks: TaskService
|
||||
}
|
||||
}
|
||||
|
||||
/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
|
||||
interface TrackedTask {
|
||||
id: TaskId
|
||||
kind: string
|
||||
label: string
|
||||
/** The owner's session id (`session.header.id`), or undefined for an unowned task. */
|
||||
ownerSession: string | undefined
|
||||
cancel: (reason?: string) => void
|
||||
readOutput: (() => string) | undefined
|
||||
status: TaskStatus
|
||||
detail: string | undefined
|
||||
output: string | undefined
|
||||
startedAt: number
|
||||
finishedAt: number | undefined
|
||||
reported: boolean
|
||||
/** Resolves once the terminal snapshot is recorded and listeners notified. */
|
||||
settled: Promise<void>
|
||||
/** Resolver for {@link settled} (called exactly once, by {@link TaskService.settle}). */
|
||||
markSettled: () => void
|
||||
/** Live {@link TaskService.wait} calls — a settlement with waiters marks the task reported. */
|
||||
waiters: number
|
||||
}
|
||||
|
||||
/** True for the three terminal {@link TaskStatus} values. */
|
||||
function isTerminal(status: TaskStatus): boolean {
|
||||
return status === 'completed' || status === 'killed' || status === 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* The `tasks` service: the runtime-global background task registry. See the
|
||||
* module doc for the ownership, isolation, and lifecycle contracts.
|
||||
*/
|
||||
export class TaskService extends Service {
|
||||
private store = new Map<TaskId, TrackedTask>()
|
||||
private counters = new Map<string, number>()
|
||||
private surfaces = new Set<symbol>()
|
||||
private listeners = new Set<TaskDoneListener>()
|
||||
private listenersClosed = false
|
||||
/** Owner agents that already have this registry's cleanup attached. */
|
||||
private ownerCleanups = new Set<AgentId>()
|
||||
/**
|
||||
* The service's OWN construction-time context, for work that outlives the
|
||||
* calling fiber: detached settlement continuations (logging), and the
|
||||
* owner-cleanup registration on `ctx.agents` (which must survive a producer
|
||||
* plugin's HMR reload, unlike the caller-fiber-scoped effects in
|
||||
* {@link onTaskDone}/{@link attachSurface}).
|
||||
*/
|
||||
private readonly selfCtx: Context
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'tasks')
|
||||
this.selfCtx = ctx
|
||||
ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register running background work and receive its task id (`<kind>-N`,
|
||||
* per-kind counter). The registry attaches ONE continuation to
|
||||
* `registration.done` that records the terminal snapshot, notifies
|
||||
* {@link onTaskDone} listeners, and releases waiters; an owned task also
|
||||
* gets the owner's awaited disposal cleanup attached (once per owner agent)
|
||||
* through `ctx.agents.onCleanup`. Throws when no control surface is
|
||||
* attached ({@link attachSurface}) — a task the model could never read or
|
||||
* stop must fail loud at the start, not dangle — and for an empty
|
||||
* kind/label. ATOMIC: a throw mutates no registry state, so a producer can
|
||||
* cancel its just-started work and rethrow without leaving a stored task
|
||||
* behind.
|
||||
* @param registration - the producer's task contract (see {@link TaskRegistration}).
|
||||
* @returns the registry-issued task id.
|
||||
*/
|
||||
register(registration: TaskRegistration): TaskId {
|
||||
if (this.surfaces.size === 0) {
|
||||
throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
|
||||
}
|
||||
if (registration.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
|
||||
if (registration.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
|
||||
// EVERYTHING that can throw runs before any mutation (counter, store):
|
||||
// a failed registration must leave the registry exactly as it was — no
|
||||
// stored-but-unreturned task the producer could never read or kill.
|
||||
if (registration.owner !== undefined) this.ensureOwnerCleanup(registration.owner)
|
||||
|
||||
const count = (this.counters.get(registration.kind) ?? 0) + 1
|
||||
this.counters.set(registration.kind, count)
|
||||
const id = TaskId(`${registration.kind}-${count}`)
|
||||
|
||||
let markSettled!: () => void
|
||||
const settled = new Promise<void>((resolve) => { markSettled = resolve })
|
||||
const task: TrackedTask = {
|
||||
id,
|
||||
kind: registration.kind,
|
||||
label: registration.label,
|
||||
ownerSession: registration.owner?.session.header.id,
|
||||
cancel: registration.cancel.bind(registration),
|
||||
readOutput: registration.readOutput?.bind(registration),
|
||||
status: 'running',
|
||||
detail: undefined,
|
||||
output: undefined,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: undefined,
|
||||
reported: false,
|
||||
settled,
|
||||
markSettled,
|
||||
waiters: 0,
|
||||
}
|
||||
this.store.set(id, task)
|
||||
|
||||
void registration.done.then(
|
||||
(outcome) => { this.settle(task, outcome) },
|
||||
(error: unknown) => {
|
||||
// Producer contract violation (`done` must never reject) — contained
|
||||
// as a failed outcome so waiters, cleanup, and disposal never hang.
|
||||
this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`)
|
||||
this.settle(task, { status: 'failed', detail: String(error) })
|
||||
},
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller-VISIBLE tasks (owned by the caller's session, or unowned), in
|
||||
* registration order. Never lists another session's tasks — a global
|
||||
* listing would leak their labels across the isolation fence.
|
||||
* @param caller - the reading agent; undefined (a non-agent caller) sees only unowned tasks.
|
||||
* @returns fresh snapshots; mutating them does not affect the registry.
|
||||
*/
|
||||
list(caller?: Agent): TaskSnapshot[] {
|
||||
const session = caller?.session.header.id
|
||||
return [...this.store.values()]
|
||||
.filter(task => task.ownerSession === undefined || task.ownerSession === session)
|
||||
.map(task => this.snapshot(task))
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-consuming snapshot of one task — unlike {@link read}, never touches
|
||||
* the stream cursor or the reported flag (the kill surface uses it to
|
||||
* describe an already-terminal task WITHOUT eating a pending delta).
|
||||
* Throws for an unknown id or a task owned by another session.
|
||||
* @param id - the task to look up.
|
||||
* @param caller - the reading agent, checked against the task's owner.
|
||||
* @returns a fresh snapshot.
|
||||
*/
|
||||
get(id: TaskId, caller?: Agent): TaskSnapshot {
|
||||
const task = this.expect(id)
|
||||
this.assertAccess(task, caller)
|
||||
return this.snapshot(task)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a task's output. Stream kinds (registered with `readOutput`) yield
|
||||
* the CONSUMING delta since the previous read — one cursor per task, the
|
||||
* owning model is v1's single intended reader; final-output kinds yield
|
||||
* empty text while live and the terminal output idempotently once settled.
|
||||
* A read that returns the terminal state marks the task {@link TaskSnapshot.reported}.
|
||||
* Throws for an unknown id or a task owned by another session.
|
||||
* @param id - the task to read.
|
||||
* @param caller - the reading agent, checked against the task's owner.
|
||||
* @returns the read text plus the post-read snapshot.
|
||||
*/
|
||||
read(id: TaskId, caller?: Agent): TaskRead {
|
||||
const task = this.expect(id)
|
||||
this.assertAccess(task, caller)
|
||||
const text = task.readOutput !== undefined
|
||||
? task.readOutput()
|
||||
: isTerminal(task.status) ? task.output ?? '' : ''
|
||||
if (isTerminal(task.status)) task.reported = true
|
||||
return { text, snapshot: this.snapshot(task) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Request cancellation of a task. A live task has its producer
|
||||
* `cancel(reason)` invoked FIRST — a throw propagates (fail loud) and
|
||||
* leaves the task untouched (still `running`, notice not suppressed) —
|
||||
* then moves to `stopping` and settles through the normal `done` path; an
|
||||
* already-terminal task is reported, not failed. Every SUCCESSFUL kill
|
||||
* marks the task {@link TaskSnapshot.reported}: the killer has seen (or
|
||||
* asked for) the end, so the completion notice is suppressed. Throws for
|
||||
* an unknown id or a task owned by another session.
|
||||
* @param id - the task to cancel.
|
||||
* @param caller - the killing agent, checked against the task's owner.
|
||||
* @param reason - the surface's logged reason, forwarded to the producer.
|
||||
* @returns 'requested' when cancellation was asked of a live task, 'already-terminal' otherwise.
|
||||
*/
|
||||
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal' {
|
||||
const task = this.expect(id)
|
||||
this.assertAccess(task, caller)
|
||||
if (isTerminal(task.status)) {
|
||||
task.reported = true
|
||||
return 'already-terminal'
|
||||
}
|
||||
// Producer cancel FIRST: a throw must leave the task untouched (still
|
||||
// `running`, notice not suppressed) — the killer's tool call fails loud,
|
||||
// but task_list and the eventual completion notice keep telling the
|
||||
// truth about a cancellation that never happened. Cancel is synchronous
|
||||
// and settlement lands on a later microtask, so the mutations below
|
||||
// cannot race the settle path.
|
||||
task.cancel(reason)
|
||||
task.status = 'stopping'
|
||||
task.reported = true
|
||||
return 'requested'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a task to settle, bounded by a timeout. Resolves with the
|
||||
* terminal snapshot (marked {@link TaskSnapshot.reported} — the wait
|
||||
* response reports the end, so the completion notice is suppressed), or
|
||||
* with the still-live snapshot when the timeout expires first. An abort of
|
||||
* `signal` rejects the WAIT only — the task keeps running. Throws for an
|
||||
* unknown id, a task owned by another session, or a non-positive timeout.
|
||||
* @param id - the task to wait for.
|
||||
* @param timeoutMs - max wait in milliseconds (positive, finite; the surface caps it).
|
||||
* @param caller - the waiting agent, checked against the task's owner.
|
||||
* @param signal - optional abort for the wait itself.
|
||||
* @returns the snapshot at settlement, or at timeout when the task outlives the wait.
|
||||
*/
|
||||
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> {
|
||||
const task = this.expect(id)
|
||||
this.assertAccess(task, caller)
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`)
|
||||
}
|
||||
if (!isTerminal(task.status)) {
|
||||
if (signal?.aborted) throw new Error('wait aborted')
|
||||
task.waiters += 1
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const timer = setTimeout(() => { cleanup(); resolve() }, timeoutMs)
|
||||
const onAbort = (): void => { cleanup(); reject(new Error('wait aborted')) }
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
void task.settled.then(() => { cleanup(); resolve() })
|
||||
})
|
||||
} finally {
|
||||
task.waiters -= 1
|
||||
}
|
||||
}
|
||||
if (isTerminal(task.status)) task.reported = true
|
||||
return this.snapshot(task)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a completion listener, called exactly once per task with the
|
||||
* terminal snapshot. Effect-scoped (disposed with the calling fiber);
|
||||
* per-listener containment (one throwing listener is logged, never starves
|
||||
* the rest); never fires after this service is disposed.
|
||||
* @param listener - called with each settling task's terminal snapshot.
|
||||
* @returns the disposer that unregisters the listener.
|
||||
*/
|
||||
onTaskDone(listener: TaskDoneListener): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}, 'tasks.onTaskDone()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare that a control surface capable of reading/stopping tasks is
|
||||
* loaded. {@link register} refuses to start a background task while NO
|
||||
* surface is attached — the loud fence against a deployment exposing
|
||||
* `run_in_background` without any way to collect or stop the work. The
|
||||
* model-facing `@deepseek-ai/dsh-tool-tasks` attaches on load; a deployment
|
||||
* with a custom (non-model) surface attaches its own. Effect-scoped:
|
||||
* detached with the calling fiber.
|
||||
* @param name - a diagnostic label for the surface (duplicate names count independently).
|
||||
* @returns the disposer that detaches the surface.
|
||||
*/
|
||||
attachSurface(name: string): () => void {
|
||||
// One token per attach call: duplicate names stay independent, and the
|
||||
// single-shot effect disposer removes exactly its own attachment.
|
||||
const token = Symbol(name)
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.surfaces.add(token)
|
||||
return () => this.surfaces.delete(token)
|
||||
}, 'tasks.attachSurface()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** Look up a task or fail loud. */
|
||||
private expect(id: TaskId): TrackedTask {
|
||||
const task = this.store.get(id)
|
||||
if (task === undefined) throw new Error(`unknown task ${id}`)
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* The isolation fence: a task with an owner is reachable only by callers
|
||||
* whose session id matches (`!== undefined` semantics — an unowned task is
|
||||
* open, and a no-agent caller can never match an owned one).
|
||||
*/
|
||||
private assertAccess(task: TrackedTask, caller?: Agent): void {
|
||||
if (task.ownerSession !== undefined && task.ownerSession !== caller?.session.header.id) {
|
||||
throw new Error(`task ${task.id} belongs to another session`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a fresh read-only snapshot from the mutable record. */
|
||||
private snapshot(task: TrackedTask): TaskSnapshot {
|
||||
return {
|
||||
id: task.id,
|
||||
kind: task.kind,
|
||||
label: task.label,
|
||||
...task.ownerSession !== undefined ? { ownerSession: task.ownerSession } : {},
|
||||
status: task.status,
|
||||
...task.detail !== undefined ? { detail: task.detail } : {},
|
||||
startedAt: task.startedAt,
|
||||
...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {},
|
||||
reported: task.reported,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a task's terminal outcome (called exactly once — the single `done`
|
||||
* continuation is the only caller), notify listeners with containment, then
|
||||
* release waiters. A settlement observed by a pending {@link wait} marks
|
||||
* the task reported BEFORE listeners run, so the notice surface can
|
||||
* suppress its redundant "finished".
|
||||
*/
|
||||
private settle(task: TrackedTask, outcome: TaskOutcome): void {
|
||||
task.status = outcome.status
|
||||
task.detail = outcome.detail
|
||||
task.output = outcome.output
|
||||
task.finishedAt = Date.now()
|
||||
if (task.waiters > 0) task.reported = true
|
||||
if (!this.listenersClosed) {
|
||||
const snapshot = this.snapshot(task)
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(snapshot)
|
||||
} catch (error: unknown) {
|
||||
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
task.markSettled()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the awaited owner-disposal cleanup for an owner agent, once: when
|
||||
* the agent's disposal chain drains (`ctx.agents.drainCleanups`), the
|
||||
* owner's still-live tasks are cancelled, awaited to settlement, and their
|
||||
* snapshots dropped. Registered through {@link selfCtx} so the cleanup
|
||||
* survives producer-plugin reloads. Fails loud when no agent registry is
|
||||
* mounted — an owned background task without the cleanup seam would outlive
|
||||
* its owner silently.
|
||||
*/
|
||||
private ensureOwnerCleanup(owner: Agent): void {
|
||||
if (this.ownerCleanups.has(owner.id)) return
|
||||
const agents = this.selfCtx.get('agents')
|
||||
if (agents === undefined) {
|
||||
throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
|
||||
}
|
||||
// Attach FIRST, record after: onCleanup throws for an unregistered agent,
|
||||
// and marking the owner as covered before that would make every later
|
||||
// registration for the same owner silently skip the cleanup.
|
||||
agents.onCleanup(owner.id, async () => {
|
||||
this.ownerCleanups.delete(owner.id)
|
||||
await this.disposeOwned(owner.session.header.id)
|
||||
})
|
||||
this.ownerCleanups.add(owner.id)
|
||||
}
|
||||
|
||||
/** Cancel (contained), await, and drop every task owned by one session. */
|
||||
private async disposeOwned(ownerSession: string): Promise<void> {
|
||||
const owned = [...this.store.values()].filter(task => task.ownerSession === ownerSession)
|
||||
this.cancelForTeardown(owned, 'owner disposed')
|
||||
await Promise.all(owned.map(task => task.settled))
|
||||
for (const task of owned) this.store.delete(task.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Service teardown: close the listener registry FIRST (late completions
|
||||
* from teardown kills stay silent), cancel every live task, and await
|
||||
* quiescence. No orphan child work survives the tasks fiber.
|
||||
*/
|
||||
private async disposeAll(): Promise<void> {
|
||||
this.listenersClosed = true
|
||||
this.listeners.clear()
|
||||
const all = [...this.store.values()]
|
||||
this.cancelForTeardown(all, 'tasks service disposed')
|
||||
await Promise.all(all.map(task => task.settled))
|
||||
this.store.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown-path cancellation with per-task containment: unlike the
|
||||
* model-facing {@link kill} (where a throwing producer `cancel` should fail
|
||||
* the tool call loudly), a teardown must reach quiescence past a broken
|
||||
* producer, so a throw is logged and the sweep continues.
|
||||
*/
|
||||
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
|
||||
for (const task of tasks) {
|
||||
if (isTerminal(task.status)) continue
|
||||
task.status = 'stopping'
|
||||
try {
|
||||
task.cancel(reason)
|
||||
} catch (error: unknown) {
|
||||
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TaskService
|
||||
155
packages/tasks/tasks/src/types.ts
Normal file
155
packages/tasks/tasks/src/types.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Task-registry vocabulary: the registration a producer hands to
|
||||
* {@link TaskService.register} and the snapshots/reads consumers get back.
|
||||
* Types only — the service lives in `./index.ts`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tasks/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/**
|
||||
* Identifies one background task in the runtime-global registry. Generated by
|
||||
* {@link TaskService.register} as `<kind>-N` (per-kind counter) — kind-prefixed
|
||||
* so transcripts stay self-describing, sequential because the owner fence (not
|
||||
* id secrecy) is the isolation boundary.
|
||||
*/
|
||||
export type TaskId = Branded<'TaskId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link TaskId}.
|
||||
* @param id - the raw task-id string (the registry generates `<kind>-N`).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function TaskId(id: string): TaskId {
|
||||
return id as TaskId
|
||||
}
|
||||
|
||||
/**
|
||||
* Task lifecycle. `running` → (`stopping` when cancellation was requested) →
|
||||
* exactly one terminal {@link TaskOutcome.status} (`completed`, `killed`,
|
||||
* `failed`). The vocabulary is generic and CLOSED — kind-specific meaning
|
||||
* (exit codes, stop reasons) rides in {@link TaskSnapshot.detail}, so the
|
||||
* registry never learns process or agent semantics.
|
||||
*/
|
||||
export type TaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* The terminal result a producer's {@link TaskRegistration.done} resolves
|
||||
* with, mapped from the producer's own vocabulary (a process exit, a subagent
|
||||
* stop reason) into the registry's closed status set.
|
||||
*/
|
||||
export 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 TaskRegistration.readOutput}),
|
||||
* read idempotently after the task settles. Stream kinds leave it unset —
|
||||
* their output is consumed incrementally through `readOutput`.
|
||||
*/
|
||||
output?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What a producer registers with {@link TaskService.register}: the running
|
||||
* work's identity, its owner, and the three hooks the registry drives it
|
||||
* through. The producer stays the owner of its execution concerns (process
|
||||
* streams, child agents); the registry owns ids, isolation, status, and
|
||||
* completion fan-out.
|
||||
*/
|
||||
export interface TaskRegistration {
|
||||
/** 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 disposal
|
||||
* cancels and awaits the task through the `ctx.agents.onCleanup` seam.
|
||||
* `undefined` registers an UNOWNED task: open to any caller, alive until the
|
||||
* tasks service disposes.
|
||||
*/
|
||||
owner?: Agent | undefined
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only projection of one task, safe to hand to listeners and tools —
|
||||
* a fresh object per call, never live registry state.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
ownerSession?: string
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* One {@link TaskService.read}: the output text this read yields (may be
|
||||
* empty — the surface decides how to render "nothing new") plus the snapshot
|
||||
* taken after the read.
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
|
||||
/** Completion callback registered via {@link TaskService.onTaskDone}. */
|
||||
export type TaskDoneListener = (snapshot: TaskSnapshot) => void
|
||||
465
packages/tasks/tasks/tests/tasks.spec.ts
Normal file
465
packages/tasks/tasks/tests/tasks.spec.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskOutcome, TaskRegistration, TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
const id = AgentId(rawId)
|
||||
return {
|
||||
id,
|
||||
options: {},
|
||||
session: new Session(SessionId(`${id}-session`)),
|
||||
status: 'idle',
|
||||
send() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
}
|
||||
|
||||
/** A controllable producer: settle its `done` on demand, record cancels. */
|
||||
function producer(overrides: Partial<TaskRegistration> = {}) {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
const registration: TaskRegistration = {
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
cancel(reason) { cancels.push(reason) },
|
||||
done: new Promise<TaskOutcome>((res, rej) => { settle = res; reject = rej }),
|
||||
...overrides,
|
||||
}
|
||||
return { registration, settle, reject, cancels }
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
ctx.tasks.attachSurface('test-surface')
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Let the settlement continuation (a `done.then`) run. */
|
||||
const tick = () => new Promise<void>(r => setTimeout(r, 0))
|
||||
|
||||
describe('TaskService.register', () => {
|
||||
it('refuses to register while no control surface is attached', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TaskService)
|
||||
expect(() => ctx.tasks.register(producer().registration))
|
||||
.toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
|
||||
})
|
||||
|
||||
it('rejects an empty kind and an empty label', async () => {
|
||||
const ctx = await harness()
|
||||
expect(() => ctx.tasks.register(producer({ kind: '' }).registration)).toThrow('invalid task kind')
|
||||
expect(() => ctx.tasks.register(producer({ label: '' }).registration)).toThrow('invalid task label')
|
||||
})
|
||||
|
||||
it('issues kind-prefixed ids from per-kind counters', async () => {
|
||||
const ctx = await harness()
|
||||
expect(ctx.tasks.register(producer().registration)).toBe('bash-1')
|
||||
expect(ctx.tasks.register(producer().registration)).toBe('bash-2')
|
||||
expect(ctx.tasks.register(producer({ kind: 'subagent' }).registration)).toBe('subagent-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskService reads and settlement', () => {
|
||||
it('stream kinds read a consuming delta; terminal reads mark reported', async () => {
|
||||
const ctx = await harness()
|
||||
const chunks = ['first', '', 'rest']
|
||||
const p = producer({ readOutput: () => chunks.shift() ?? '' })
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
|
||||
expect(ctx.tasks.read(id)).toMatchObject({ text: 'first', snapshot: { status: 'running', reported: false } })
|
||||
expect(ctx.tasks.read(id).text).toBe('')
|
||||
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
const read = ctx.tasks.read(id)
|
||||
expect(read.text).toBe('rest')
|
||||
expect(read.snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', reported: true })
|
||||
expect(read.snapshot.finishedAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer({ kind: 'subagent', label: 'research task' })
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
|
||||
expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } })
|
||||
|
||||
p.settle({ status: 'completed', output: 'final answer' })
|
||||
await tick()
|
||||
expect(ctx.tasks.read(id).text).toBe('final answer')
|
||||
expect(ctx.tasks.read(id).text).toBe('final answer') // idempotent, not consumed
|
||||
})
|
||||
|
||||
it('a settled task without output reads as empty text', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer({ kind: 'subagent' })
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'failed', detail: 'max-tokens' })
|
||||
await tick()
|
||||
expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'failed', detail: 'max-tokens' } })
|
||||
})
|
||||
|
||||
it('throws for unknown task ids', async () => {
|
||||
const ctx = await harness()
|
||||
expect(() => ctx.tasks.read(TaskId('bash-99'))).toThrow('unknown task bash-99')
|
||||
})
|
||||
|
||||
it('notifies onTaskDone once per task with containment across listeners', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(() => { throw new Error('listener boom') })
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom'))
|
||||
})
|
||||
|
||||
it('contains a rejecting done as a failed outcome (producer contract violation)', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
p.reject(new Error('transport exploded'))
|
||||
await tick()
|
||||
|
||||
expect(ctx.tasks.read(id).snapshot).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' })
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('producer contract violation'))
|
||||
})
|
||||
|
||||
it('unregisters onTaskDone listeners with the contributing fiber (HMR safety)', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: string[] = []
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
|
||||
}, { inject: ['tasks'] }))
|
||||
await fiber.dispose()
|
||||
// The returned disposer detaches too (the non-fiber path).
|
||||
const detach = ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
|
||||
detach()
|
||||
|
||||
const p = producer()
|
||||
ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskService.kill', () => {
|
||||
it('cancels a live task with the forwarded reason and suppresses the notice', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
|
||||
expect(ctx.tasks.kill(id, undefined, 'no longer needed')).toBe('requested')
|
||||
expect(p.cancels).toEqual(['no longer needed'])
|
||||
expect(ctx.tasks.list()[0]).toMatchObject({ status: 'stopping', reported: true })
|
||||
|
||||
p.settle({ status: 'killed' })
|
||||
await tick()
|
||||
// The listener still fires (telemetry may care), but carries reported: true
|
||||
// so the notice surface suppresses its redundant "finished".
|
||||
expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true })
|
||||
})
|
||||
|
||||
it('reports an already-terminal task instead of failing', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(ctx.tasks.kill(id)).toBe('already-terminal')
|
||||
})
|
||||
|
||||
it('propagates a throwing producer cancel and leaves the task untouched', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
let broken = true
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const id = ctx.tasks.register({
|
||||
kind: 'bash',
|
||||
label: 'flaky cancel',
|
||||
cancel() { if (broken) throw new Error('cancel boom') },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
})
|
||||
expect(() => ctx.tasks.kill(id)).toThrow('cancel boom')
|
||||
// The failed kill mutated NOTHING: still running, notice not suppressed,
|
||||
// and a later (successful) kill still works.
|
||||
expect(ctx.tasks.get(id)).toMatchObject({ status: 'running', reported: false })
|
||||
settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire
|
||||
|
||||
broken = false
|
||||
expect(ctx.tasks.kill(id)).toBe('already-terminal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskService.wait', () => {
|
||||
it('resolves with the terminal snapshot when the task settles, marked reported', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
|
||||
const wait = ctx.tasks.wait(id, 5_000)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
expect(await wait).toMatchObject({ status: 'completed', reported: true })
|
||||
// The pending wait marked the task reported BEFORE listeners ran.
|
||||
expect(seen[0]).toMatchObject({ id, reported: true })
|
||||
})
|
||||
|
||||
it('returns the live snapshot on timeout without marking reported', async () => {
|
||||
const ctx = await harness()
|
||||
const id = ctx.tasks.register(producer().registration)
|
||||
expect(await ctx.tasks.wait(id, 5)).toMatchObject({ status: 'running', reported: false })
|
||||
})
|
||||
|
||||
it('returns immediately for an already-terminal task', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer()
|
||||
const id = ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(await ctx.tasks.wait(id, 5_000)).toMatchObject({ status: 'completed', reported: true })
|
||||
})
|
||||
|
||||
it('rejects a non-positive or non-finite timeout', async () => {
|
||||
const ctx = await harness()
|
||||
const id = ctx.tasks.register(producer().registration)
|
||||
await expect(ctx.tasks.wait(id, 0)).rejects.toThrow('invalid wait timeout')
|
||||
await expect(ctx.tasks.wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout')
|
||||
})
|
||||
|
||||
it('an aborted signal rejects the wait only — the task stays alive', async () => {
|
||||
const ctx = await harness()
|
||||
const id = ctx.tasks.register(producer().registration)
|
||||
|
||||
const controller = new AbortController()
|
||||
const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
|
||||
controller.abort()
|
||||
await expect(wait).rejects.toThrow('wait aborted')
|
||||
expect(ctx.tasks.list()[0]).toMatchObject({ status: 'running' })
|
||||
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
await expect(ctx.tasks.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskService owner isolation', () => {
|
||||
it('fences read/kill/wait to the owning session and keeps unowned tasks open', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent('owner')
|
||||
ctx.agents.register(owner)
|
||||
const other = stubAgent('other')
|
||||
|
||||
const owned = ctx.tasks.register(producer({ owner }).registration)
|
||||
const open = ctx.tasks.register(producer().registration)
|
||||
|
||||
// The owner and the unowned task are reachable.
|
||||
expect(ctx.tasks.read(owned, owner).snapshot.id).toBe(owned)
|
||||
expect(ctx.tasks.read(open, other).snapshot.id).toBe(open)
|
||||
|
||||
// A different session and a no-agent caller are rejected.
|
||||
expect(() => ctx.tasks.read(owned, other)).toThrow(`task ${owned} belongs to another session`)
|
||||
expect(() => ctx.tasks.kill(owned, other)).toThrow('belongs to another session')
|
||||
await expect(ctx.tasks.wait(owned, 10, other)).rejects.toThrow('belongs to another session')
|
||||
expect(() => ctx.tasks.read(owned)).toThrow('belongs to another session')
|
||||
})
|
||||
|
||||
it('list() shows only caller-owned plus unowned tasks', async () => {
|
||||
const ctx = await harness()
|
||||
const alice = stubAgent('alice')
|
||||
const bob = stubAgent('bob')
|
||||
ctx.agents.register(alice)
|
||||
ctx.agents.register(bob)
|
||||
|
||||
const aliceTask = ctx.tasks.register(producer({ owner: alice }).registration)
|
||||
const bobTask = ctx.tasks.register(producer({ owner: bob }).registration)
|
||||
const openTask = ctx.tasks.register(producer({ kind: 'subagent' }).registration)
|
||||
|
||||
expect(ctx.tasks.list(alice).map(t => t.id)).toEqual([aliceTask, openTask])
|
||||
expect(ctx.tasks.list(bob).map(t => t.id)).toEqual([bobTask, openTask])
|
||||
expect(ctx.tasks.list().map(t => t.id)).toEqual([openTask])
|
||||
})
|
||||
|
||||
it('rejects an owned registration when no agent registry is mounted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TaskService)
|
||||
ctx.tasks.attachSurface('test-surface')
|
||||
expect(() => ctx.tasks.register(producer({ owner: stubAgent('a') }).registration))
|
||||
.toThrow('background task ownership requires the agent registry')
|
||||
// The failed registration mutated nothing: no stored task, counter untouched.
|
||||
expect(ctx.tasks.list()).toEqual([])
|
||||
expect(ctx.tasks.register(producer().registration)).toBe('bash-1')
|
||||
})
|
||||
|
||||
it('a failed owner-cleanup attach leaves the registry unchanged and does not poison the owner', async () => {
|
||||
const ctx = await harness()
|
||||
const ghost = stubAgent('ghost') // never registered in ctx.agents
|
||||
|
||||
// onCleanup rejects the unregistered agent BEFORE any registry mutation.
|
||||
expect(() => ctx.tasks.register(producer({ owner: ghost }).registration))
|
||||
.toThrow('is not registered')
|
||||
expect(ctx.tasks.list(ghost)).toEqual([])
|
||||
|
||||
// Once the agent actually exists, the same owner gets a WORKING cleanup —
|
||||
// the failed attempt must not have marked it as already covered.
|
||||
ctx.agents.register(ghost)
|
||||
const cancels: (string | undefined)[] = []
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const id = ctx.tasks.register({
|
||||
kind: 'bash',
|
||||
label: 'after retry',
|
||||
owner: ghost,
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
})
|
||||
expect(id).toBe('bash-1') // the failed attempt burned no counter
|
||||
await ctx.agents.drainCleanups(ghost.id)
|
||||
expect(cancels).toEqual(['owner disposed'])
|
||||
expect(ctx.tasks.list(ghost)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskService owner cleanup', () => {
|
||||
it('drains the owner: cancels live tasks, awaits settlement, drops snapshots', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent('owner')
|
||||
ctx.agents.register(owner)
|
||||
|
||||
// The producer settles only when cancelled — models a child that stops on request.
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
ctx.tasks.register({
|
||||
kind: 'subagent',
|
||||
label: 'long research',
|
||||
owner,
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
})
|
||||
const terminal = producer({ owner })
|
||||
ctx.tasks.register(terminal.registration)
|
||||
terminal.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
await ctx.agents.drainCleanups(owner.id)
|
||||
expect(cancels).toEqual(['owner disposed'])
|
||||
// Snapshots dropped: nothing of the owner's remains, listing is empty.
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('attaches one cleanup per owner and re-attaches after a drain', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent('owner')
|
||||
ctx.agents.register(owner)
|
||||
|
||||
const first = producer({ owner })
|
||||
const second = producer({ owner })
|
||||
ctx.tasks.register(first.registration)
|
||||
ctx.tasks.register(second.registration)
|
||||
first.settle({ status: 'completed' })
|
||||
second.settle({ status: 'completed' })
|
||||
await tick()
|
||||
await ctx.agents.drainCleanups(owner.id)
|
||||
|
||||
// A fresh task after the drain gets a fresh cleanup (the set was consumed).
|
||||
const third = producer({ owner })
|
||||
ctx.tasks.register(third.registration)
|
||||
third.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(ctx.tasks.list(owner)).toHaveLength(1)
|
||||
await ctx.agents.drainCleanups(owner.id)
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('contains a throwing producer cancel on the cleanup path', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const owner = stubAgent('owner')
|
||||
ctx.agents.register(owner)
|
||||
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
ctx.tasks.register({
|
||||
kind: 'bash',
|
||||
label: 'broken producer',
|
||||
owner,
|
||||
cancel() { throw new Error('cancel boom') },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
})
|
||||
|
||||
const drain = ctx.agents.drainCleanups(owner.id)
|
||||
settle({ status: 'failed', detail: 'gave up' })
|
||||
await drain
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cancel boom'))
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskService disposal', () => {
|
||||
it('cancels live tasks, awaits settlement, and silences listeners', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const fiber = await ctx.plugin(TaskService)
|
||||
const surface = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.tasks.attachSurface('test-surface')
|
||||
}, { inject: ['tasks'] }))
|
||||
void surface
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
ctx.tasks.register({
|
||||
kind: 'bash',
|
||||
label: 'sleep 600',
|
||||
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
})
|
||||
|
||||
await fiber.dispose()
|
||||
expect(cancels).toEqual(['tasks service disposed'])
|
||||
// The teardown kill settles AFTER the listener registry closed: silent.
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
it('detaching the last surface re-arms the register fence', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TaskService)
|
||||
const detachA1 = ctx.tasks.attachSurface('a')
|
||||
const detachA2 = ctx.tasks.attachSurface('a') // duplicate name counts independently
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.tasks.attachSurface('b')
|
||||
}, { inject: ['tasks'] }))
|
||||
|
||||
detachA1()
|
||||
detachA1() // second call of the same disposer is a no-op
|
||||
expect(() => ctx.tasks.register(producer().registration)).not.toThrow() // a ×1 + b remain
|
||||
detachA2()
|
||||
expect(() => ctx.tasks.register(producer().registration)).not.toThrow() // b remains
|
||||
await fiber.dispose() // detaches b with its fiber (HMR safety)
|
||||
expect(() => ctx.tasks.register(producer().registration)).toThrow('no control surface is attached')
|
||||
})
|
||||
})
|
||||
24
packages/tasks/tasks/tsconfig.json
Normal file
24
packages/tasks/tasks/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
24
packages/tasks/tool-tasks/README.md
Normal file
24
packages/tasks/tool-tasks/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-tool-tasks
|
||||
|
||||
The model-facing background task control surface over `ctx.tasks`: three kind-agnostic tools, the completion-notice injection, and the prompt section that teaches the background habit. Loading this plugin calls `ctx.tasks.attachSurface('tool-tasks')`, which is what arms producers' `register()`.
|
||||
|
||||
## Tools
|
||||
|
||||
- `task_output(task_id, wait?, timeout_ms?)` — non-blocking read by default (stream kinds: the consuming delta since the previous read; final kinds: the final answer once terminal); every response ends with a `[status: …]` line (generic status + producer detail, e.g. `[status: completed, exit code: 0]`). `wait: true` blocks until settlement, bounded by `waitTimeoutMs`/`maxWaitTimeoutMs` config; a timed-out wait returns `[status: running]` and leaves the task alive.
|
||||
- `task_list()` — the caller's tasks, `<id> [<kind>] <status> — <label>` per line.
|
||||
- `task_kill(task_id, reason?)` — requests cancellation and returns immediately; the logged `reason` is forwarded to the producer. An already-terminal task is described via a non-consuming snapshot (never eats a pending delta).
|
||||
|
||||
ACP render intent: all three are `generic` cards (`read`/`read`/`execute`) — a task read is not a terminal.
|
||||
|
||||
## Completion notices
|
||||
|
||||
On `onTaskDone`, injects `background task <id> (<kind>: <label>) finished [status: …]. Read its output with task_output.` into the owning agent's session (`agent.inject()` — durable context for the next request, not a wake-up). Suppressed when the snapshot is `reported` (the model already killed it, or a read/wait returned the end) — never a redundant "finished". The disposed-owner race is contained; a missing agent registry drops the notice.
|
||||
|
||||
## Config
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `waitTimeoutMs` | `30000` | wait duration when `task_output` sets `wait` without `timeout_ms` |
|
||||
| `maxWaitTimeoutMs` | `600000` | hard cap; larger model-supplied `timeout_ms` values are clamped |
|
||||
|
||||
A config whose default exceeds the cap fails loud at load.
|
||||
43
packages/tasks/tool-tasks/package.json
Normal file
43
packages/tasks/tool-tasks/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-tasks",
|
||||
"description": "Model-facing background task control tools (task_output, task_list, task_kill) over the ctx.tasks registry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
183
packages/tasks/tool-tasks/src/index.ts
Normal file
183
packages/tasks/tool-tasks/src/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* The model-facing background task control tools: `task_output`, `task_list`,
|
||||
* `task_kill`. Kind-agnostic — a background bash command and a background
|
||||
* subagent read, list, and die through the same three schemas — with every
|
||||
* task concern (ids, isolation, cursors, settlement) behind the `ctx.tasks`
|
||||
* registry (`@deepseek-ai/dsh-tasks`).
|
||||
*
|
||||
* This plugin IS the control surface: it calls `ctx.tasks.attachSurface()` on
|
||||
* load, which is what re-arms producers' `register()` (the registry refuses
|
||||
* background work while no surface could collect or stop it).
|
||||
*
|
||||
* Completion notices: when a task settles, a short notice is injected into
|
||||
* the owning agent's session (`agent.inject()` — durable context for the NEXT
|
||||
* model request, not a wake-up). A task whose terminal state the model
|
||||
* already saw (`snapshot.reported` — an explicit kill, or a read/wait that
|
||||
* returned the end) is suppressed, so the model never gets a redundant
|
||||
* "finished" for work it just collected.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-tasks
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
export const name = 'tool-tasks'
|
||||
export const inject = ['tools', 'tasks', 'systemPrompt']
|
||||
|
||||
/** Config: the `task_output` wait bounds (defaulted, capped — never hardcoded). */
|
||||
export interface Config {
|
||||
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
|
||||
waitTimeoutMs?: number
|
||||
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
|
||||
maxWaitTimeoutMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
waitTimeoutMs: z.number().min(1).default(30_000),
|
||||
maxWaitTimeoutMs: z.number().min(1).default(600_000),
|
||||
})
|
||||
|
||||
/**
|
||||
* Render a snapshot's status line — generic status plus the producer's
|
||||
* kind-specific detail: `[status: completed, exit code: 0]`,
|
||||
* `[status: failed, max-tokens]`, `[status: running]`. Exported for tests
|
||||
* and for producers that want a consistent line in their own results.
|
||||
* @param snapshot - the task state to render.
|
||||
* @returns the bracketed status line.
|
||||
*/
|
||||
export function statusLine(snapshot: TaskSnapshot): string {
|
||||
return snapshot.detail !== undefined
|
||||
? `[status: ${snapshot.status}, ${snapshot.detail}]`
|
||||
: `[status: ${snapshot.status}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an empty `task_id`. Type/presence come from the SchemaSpec
|
||||
* validation; only the non-empty constraint, which the DSL cannot express,
|
||||
* is checked here.
|
||||
*/
|
||||
function validateTaskId(value: string): TaskId {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
return TaskId(value)
|
||||
}
|
||||
|
||||
/** Pending-state presentation shared by the three control tools (generic cards by design — a task read/kill is not a terminal). */
|
||||
function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: string): GenericCallView {
|
||||
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const waitDefault = config.waitTimeoutMs ?? 30_000
|
||||
const waitCap = config.maxWaitTimeoutMs ?? 600_000
|
||||
if (waitDefault > waitCap) {
|
||||
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
|
||||
}
|
||||
|
||||
// The registry's misconfiguration fence: producers can register background
|
||||
// work only while a surface capable of collecting/stopping it is attached.
|
||||
ctx.tasks.attachSurface('tool-tasks')
|
||||
|
||||
// The cross-call HABIT the per-tool descriptions cannot carry. Order 106:
|
||||
// right after tool:bash (105), before deployment product sections.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:tasks',
|
||||
order: 106,
|
||||
text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.',
|
||||
})
|
||||
|
||||
// Background completion → inject a notice into the owning agent's session.
|
||||
// `ctx.get('agents')` (not static inject): this listener runs from a
|
||||
// detached settlement continuation on the tasks fiber — a foreign fiber —
|
||||
// where the `ctx.agents` property proxy would throw; `ctx.get` is the
|
||||
// topology-independent lookup. No registry mounted → drop the notice.
|
||||
ctx.tasks.onTaskDone((snapshot) => {
|
||||
// A reported terminal state was already surfaced by an explicit
|
||||
// read/wait/kill response — a notice would be a redundant "finished".
|
||||
if (snapshot.reported || snapshot.ownerSession === undefined) return
|
||||
const agent = ctx.get('agents')?.list().find(a => a.session.header.id === snapshot.ownerSession)
|
||||
if (!agent) return
|
||||
try {
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
|
||||
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// The ONE expected failure: the agent was disposed between settlement
|
||||
// and this injection (inject throws `agent "<id>" is disposed`). That
|
||||
// race is benign — drop the notice. Anything else must surface.
|
||||
if (error instanceof Error && error.message.includes('is disposed')) return
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'task_output',
|
||||
description: 'Read output/status from a background task (started by a tool with `run_in_background`). '
|
||||
+ 'Stream tasks (bash) return only output produced since your previous task_output call; '
|
||||
+ 'final-output tasks (subagent) return the final answer once the task finishes. '
|
||||
+ 'Every response ends with a [status: ...] line. Non-blocking by default; '
|
||||
+ 'set `wait: true` to block until the task finishes (bounded by a capped timeout) when you are genuinely blocked on its result.',
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
|
||||
wait: { type: 'boolean', description: 'Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive.' },
|
||||
timeout_ms: { type: 'number', description: 'Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum.' },
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const id = validateTaskId(args.task_id)
|
||||
if (args.wait === true) {
|
||||
const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
|
||||
await ctx.tasks.wait(id, timeout, exec.agent, exec.signal)
|
||||
}
|
||||
const read = ctx.tasks.read(id, exec.agent)
|
||||
const body = read.text.length > 0 ? read.text : '(no new output)'
|
||||
const separator = body.endsWith('\n') ? '' : '\n'
|
||||
return [{ type: 'text', text: `${body}${separator}${statusLine(read.snapshot)}` }]
|
||||
},
|
||||
presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'task_list',
|
||||
description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
|
||||
parameters: {},
|
||||
// execute is synchronous (registry reads + string shaping) but the
|
||||
// ToolDefinition contract wants a Promise — hence resolve(), not async.
|
||||
execute(_args, exec) {
|
||||
const tasks = ctx.tasks.list(exec.agent)
|
||||
const text = tasks.length === 0
|
||||
? '(no background tasks)'
|
||||
: tasks.map(t => `${t.id} [${t.kind}] ${t.status} — ${t.label}`).join('\n')
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
},
|
||||
presentCall: () => presentTaskCall('List background tasks', 'read'),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'task_kill',
|
||||
description: 'Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.',
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
|
||||
reason: { type: 'string', description: 'Optional short reason, recorded in the log and forwarded to the task.' },
|
||||
},
|
||||
execute(args, exec) {
|
||||
const id = validateTaskId(args.task_id)
|
||||
const result = ctx.tasks.kill(id, exec.agent, args.reason)
|
||||
if (result === 'already-terminal') {
|
||||
// ctx.tasks.get, NOT .read: a read would consume a stream task's
|
||||
// pending delta just to describe the terminal state.
|
||||
const snapshot = ctx.tasks.get(id, exec.agent)
|
||||
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
|
||||
}
|
||||
return Promise.resolve([{ type: 'text', text: `requested cancellation of task ${id}` }])
|
||||
},
|
||||
presentCall: args => presentTaskCall(`Kill background task ${args.task_id}`, 'execute', args.task_id),
|
||||
}))
|
||||
}
|
||||
306
packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
Normal file
306
packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskOutcome, TaskRegistration, TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
async function setup(config: ToolTasks.Config = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const agentsFiber = await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
const toolsFiber = await ctx.plugin(ToolTasks, config)
|
||||
return { ctx, agentsFiber, toolsFiber }
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake agent whose session token is `sessionId`, registered in `ctx.agents`
|
||||
* (the notice path finds the owner by scanning the registry for a matching
|
||||
* `session.header.id` — the agent id is deliberately DIFFERENT so a
|
||||
* wrong-field match fails the test).
|
||||
*/
|
||||
function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
||||
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
/** A controllable producer registration (settle `done` on demand, record cancels). */
|
||||
function producer(overrides: Partial<TaskRegistration> = {}) {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
const registration: TaskRegistration = {
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
cancel(reason) { cancels.push(reason) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
...overrides,
|
||||
}
|
||||
return { registration, settle, cancels }
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
|
||||
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(r => setTimeout(r, 0))
|
||||
|
||||
describe('tool-tasks setup', () => {
|
||||
it('attaches the control surface on load and detaches it with the fiber', async () => {
|
||||
const { ctx, toolsFiber } = await setup()
|
||||
expect(() => ctx.tasks.register(producer().registration)).not.toThrow()
|
||||
await toolsFiber.dispose()
|
||||
expect(() => ctx.tasks.register(producer().registration)).toThrow('no control surface is attached')
|
||||
})
|
||||
|
||||
it('rejects a config whose default wait exceeds the cap', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 }))
|
||||
.rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
|
||||
})
|
||||
|
||||
it('renders status lines with and without producer detail', () => {
|
||||
const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as TaskSnapshot
|
||||
expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]')
|
||||
expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]')
|
||||
})
|
||||
|
||||
it('applies the built-in wait bounds when apply() receives a bare config', async () => {
|
||||
// Bypasses the schemastery defaults on purpose: apply() must stand on its
|
||||
// own `??` fallbacks when embedded programmatically without the schema.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
ToolTasks.apply(ctx, {})
|
||||
expect(ctx.tools.get('task_output')).toBeDefined()
|
||||
expect(() => ctx.tasks.register(producer().registration)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('task_output', () => {
|
||||
it('reads a consuming delta with a trailing status line', async () => {
|
||||
const { ctx } = await setup()
|
||||
const chunks = ['line one\n', '']
|
||||
ctx.tasks.register(producer({ readOutput: () => chunks.shift() ?? '' }).registration)
|
||||
|
||||
// A body already ending in a newline gets no doubled separator.
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('line one\n[status: running]')
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
|
||||
})
|
||||
|
||||
it('returns the final output of a settled final-output task', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ kind: 'subagent', label: 'research' })
|
||||
ctx.tasks.register(p.registration)
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]')
|
||||
|
||||
p.settle({ status: 'completed', detail: 'completed', output: 'the answer' })
|
||||
await tick()
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
|
||||
})
|
||||
|
||||
it('wait: true blocks until settlement and reports the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ kind: 'subagent', label: 'research' })
|
||||
ctx.tasks.register(p.registration)
|
||||
|
||||
const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true })
|
||||
p.settle({ status: 'completed', output: 'done deal' })
|
||||
expect(text(await pending)).toBe('done deal\n[status: completed]')
|
||||
})
|
||||
|
||||
it('wait: true times out against the configured cap and leaves the task alive', async () => {
|
||||
const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 })
|
||||
ctx.tasks.register(producer().registration)
|
||||
|
||||
// A model-supplied timeout far above the cap is clamped: this returns
|
||||
// promptly (≤ the 20ms cap), not after ten minutes.
|
||||
const result = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true, timeout_ms: 600_000 })
|
||||
expect(text(result)).toBe('(no new output)\n[status: running]')
|
||||
})
|
||||
|
||||
it('rejects an empty or unknown task id as an errored result', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((await call(ctx, 'task_output', { task_id: '' })).isError).toBe(true)
|
||||
const unknown = await call(ctx, 'task_output', { task_id: 'bash-99' })
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(text(unknown)).toContain('unknown task bash-99')
|
||||
})
|
||||
})
|
||||
|
||||
describe('task_list', () => {
|
||||
it('lists caller-visible tasks and renders the empty case', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'task_list', {}))).toBe('(no background tasks)')
|
||||
|
||||
const alice = fakeAgent(ctx, 'sess-alice')
|
||||
ctx.tasks.register(producer({ owner: alice, label: 'pnpm test' }).registration)
|
||||
ctx.tasks.register(producer({ kind: 'subagent', label: 'open research' }).registration)
|
||||
const p = producer({ owner: alice, label: 'build' })
|
||||
ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
expect(text(await call(ctx, 'task_list', {}, alice))).toBe([
|
||||
'bash-1 [bash] running — pnpm test',
|
||||
'subagent-1 [subagent] running — open research',
|
||||
'bash-2 [bash] completed — build',
|
||||
].join('\n'))
|
||||
// A different caller sees only the unowned task.
|
||||
const bob = fakeAgent(ctx, 'sess-bob')
|
||||
expect(text(await call(ctx, 'task_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
|
||||
})
|
||||
})
|
||||
|
||||
describe('task_kill', () => {
|
||||
it('requests cancellation with the forwarded reason', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer()
|
||||
ctx.tasks.register(p.registration)
|
||||
|
||||
const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
|
||||
expect(text(result)).toBe('requested cancellation of task bash-1')
|
||||
expect(p.cancels).toEqual(['superseded'])
|
||||
})
|
||||
|
||||
it('reports an already-terminal task without consuming its pending delta', async () => {
|
||||
const { ctx } = await setup()
|
||||
let delta = 'unread tail'
|
||||
const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
|
||||
ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
expect(text(await call(ctx, 'task_kill', { task_id: 'bash-1' })))
|
||||
.toBe('task bash-1 had already finished [status: completed, exit code: 0]')
|
||||
// The kill described the task via a non-consuming snapshot: the delta is intact.
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
|
||||
})
|
||||
|
||||
it('rejects an empty task id as an errored result', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((await call(ctx, 'task_kill', { task_id: '' })).isError).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned UI presentation (presentCall)', () => {
|
||||
it('renders generic cards for all three control tools', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.get('task_output')?.presentCall?.({ task_id: 'bash-1' }))
|
||||
.toEqual({ card: 'generic', title: 'Read output from background task bash-1', kind: 'read', rawInput: 'bash-1' })
|
||||
expect(ctx.tools.get('task_list')?.presentCall?.({}))
|
||||
.toEqual({ card: 'generic', title: 'List background tasks', kind: 'read' })
|
||||
expect(ctx.tools.get('task_kill')?.presentCall?.({ task_id: 'subagent-2' }))
|
||||
.toEqual({ card: 'generic', title: 'Kill background task subagent-2', kind: 'execute', rawInput: 'subagent-2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('completion notices', () => {
|
||||
it('injects a notice into the owning agent when an unreported task settles', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.tasks.register(p.registration)
|
||||
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
expect(inject).toHaveBeenCalledWith(
|
||||
[{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
|
||||
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses the notice for a task the model already killed', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.register(p.registration)
|
||||
|
||||
await call(ctx, 'task_kill', { task_id: 'bash-1' }, owner)
|
||||
p.settle({ status: 'killed' })
|
||||
await tick()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses the notice when a wait returned the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const p = producer({ owner, kind: 'subagent' })
|
||||
ctx.tasks.register(p.registration)
|
||||
|
||||
const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true }, owner)
|
||||
p.settle({ status: 'completed', output: 'answer' })
|
||||
expect(text(await pending)).toContain('answer')
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the notice for unowned tasks and for a disposed owner (benign race)', async () => {
|
||||
const { ctx } = await setup()
|
||||
// Unowned: settles with nobody to notify — nothing throws.
|
||||
const unowned = producer()
|
||||
ctx.tasks.register(unowned.registration)
|
||||
unowned.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
// Disposed owner: inject throws the disposed message — contained.
|
||||
const inject = vi.fn(() => { throw new Error('agent "agent-sess-1" is disposed') })
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('propagates a non-disposed inject failure (a real bug must surface)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.register(p.registration)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
// The throw escapes the notice listener and is contained (logged) by the
|
||||
// registry's per-listener containment — visible, not swallowed.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
|
||||
})
|
||||
|
||||
it('drops the notice when no live agent matches and when the agent registry is gone', async () => {
|
||||
const { ctx, agentsFiber } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
|
||||
// Owner known at registration, unregistered before settlement → no match.
|
||||
const p1 = producer({ owner })
|
||||
ctx.tasks.register(p1.registration)
|
||||
// A second task whose settlement happens after the whole registry is gone.
|
||||
const p2 = producer({ owner })
|
||||
ctx.tasks.register(p2.registration)
|
||||
|
||||
await agentsFiber.dispose()
|
||||
p1.settle({ status: 'completed' })
|
||||
p2.settle({ status: 'failed' })
|
||||
await tick()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
33
packages/tasks/tool-tasks/tsconfig.json
Normal file
33
packages/tasks/tool-tasks/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user