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:
83 files changed
+3909
-1627
No files matched your search
+29
-107
@@ -1,31 +1,36 @@
|
||||
/**
|
||||
* The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
|
||||
* bash backend does — run commands, manage background tasks — without saying
|
||||
* HOW. Implementations subclass {@link BashExecutor} and register themselves
|
||||
* as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
|
||||
* is the first. Future implementations swap in sandboxes, containers, or
|
||||
* remote exec servers without touching the tool schemas that consume them
|
||||
* (`@deepseek-ai/dsh-tool-bash`).
|
||||
* bash backend does — run foreground commands, start background processes —
|
||||
* without saying HOW. Implementations subclass {@link BashExecutor} and
|
||||
* register themselves as the `bash` service; `@deepseek-ai/dsh-bash-local`
|
||||
* (local subprocesses) is the first. Future implementations swap in
|
||||
* sandboxes, containers, or remote exec servers without touching the tool
|
||||
* schemas that consume them (`@deepseek-ai/dsh-tool-bash`).
|
||||
*
|
||||
* The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
|
||||
* surveyed agents: pi hides execution behind a `BashOperations` interface
|
||||
* (local shell / SSH / VM backends), Codex behind an exec-server protocol.
|
||||
*
|
||||
* The seam is deliberately TASK-FREE: `start()` hands back a
|
||||
* {@link BashProcess} handle (incremental reads, kill, a quiescence promise)
|
||||
* and nothing else. Task ids, owner isolation, polling tools, and completion
|
||||
* notices are the generic `ctx.tasks` runtime's job (`@deepseek-ai/dsh-tasks`)
|
||||
* — the tool layer adapts the handle into a task registration. This keeps a
|
||||
* remote/sandbox executor free of any session or registry dependency.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
|
||||
|
||||
export { BashTaskId, OwnerToken } from './types.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
BashExecSpec,
|
||||
BashProcess,
|
||||
BashProcessRead,
|
||||
BashProcessStatus,
|
||||
BashRunResult,
|
||||
BashTask,
|
||||
BashTaskListener,
|
||||
BashTaskRead,
|
||||
BashTaskStatus,
|
||||
CollectedOutput,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -47,27 +52,19 @@ declare module 'cordis' {
|
||||
* abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
|
||||
* a failed command is the tool layer's job, not an exception.
|
||||
* - {@link start} returns immediately; no timeout applies to background
|
||||
* tasks (callers stop them via {@link kill} or the spec's AbortSignal).
|
||||
* Completion must fire the {@link onTaskDone} listeners exactly once per
|
||||
* task, and must NOT fire after the service is disposed.
|
||||
* - {@link readOutput} is incremental: consecutive reads never re-deliver
|
||||
* output. Implementations bound their buffers; reads that lost data flag
|
||||
* `lossy` and point at full-stream spill files when available.
|
||||
* - Disposal kills every running task and awaits their exit (no orphan
|
||||
* processes survive `fiber.dispose()`).
|
||||
* processes (callers stop them via {@link BashProcess.kill} or the spec's
|
||||
* AbortSignal). The handle's `done` settles at process close and never
|
||||
* rejects (a spawn failure settles as `killed` with the error readable on
|
||||
* stderr).
|
||||
* - {@link BashProcess.readOutput} is incremental: consecutive reads never
|
||||
* re-deliver output. Implementations bound their buffers; reads that lost
|
||||
* data flag `lossy` and point at full-stream spill files when available.
|
||||
* - Disposal kills every running background process and awaits their exit
|
||||
* (no orphan processes survive `fiber.dispose()`).
|
||||
*/
|
||||
export abstract class BashExecutor extends Service {
|
||||
private listeners = new Set<BashTaskListener>()
|
||||
private listenersClosed = false
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'bash')
|
||||
ctx.effect(() => () => {
|
||||
// Close the listener registry before subclass teardown so late task
|
||||
// completions (e.g. from kills issued during dispose) stay silent.
|
||||
this.listenersClosed = true
|
||||
this.listeners.clear()
|
||||
}, 'bash listener teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,86 +89,11 @@ export abstract class BashExecutor extends Service {
|
||||
abstract run(spec: BashExecSpec): Promise<BashRunResult>
|
||||
|
||||
/**
|
||||
* Start a background task and return its handle immediately.
|
||||
* Start a background process and return its handle immediately.
|
||||
* @param spec - a resolved spec from {@link resolve}, never a raw request.
|
||||
* @returns the live task handle; completion fires {@link onTaskDone}.
|
||||
* @returns the live process handle (reads, kill, quiescence promise).
|
||||
*/
|
||||
abstract start(spec: BashExecSpec): BashTask
|
||||
|
||||
/**
|
||||
* Look up a background task by id.
|
||||
* @param id - the task id to look up.
|
||||
* @returns the tracked task, or undefined for an id this executor never issued.
|
||||
*/
|
||||
abstract get(id: BashTaskId): BashTask | undefined
|
||||
|
||||
/**
|
||||
* The opaque OWNER token recorded for a background task at {@link start}
|
||||
* (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
|
||||
* OR a known-but-ownerless task. The executor stores and returns the token
|
||||
* verbatim — it never interprets it; the access POLICY (who may read/kill a
|
||||
* task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
|
||||
* `ownerOf(id)` to the caller's token. Collapsing unknown-id and
|
||||
* known-but-unowned into the same `undefined` is fine: the consumer's access
|
||||
* gate treats `undefined` as "open", and a genuinely unknown id then fails
|
||||
* loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
|
||||
* Storing ownership in the executor (disposed with ITS fiber) — not in the
|
||||
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
|
||||
* @param id - the background task id to look up ownership for.
|
||||
* @returns the token recorded at start, verbatim; undefined for an unknown
|
||||
* id or a known-but-ownerless task.
|
||||
*/
|
||||
abstract ownerOf(id: BashTaskId): OwnerToken | undefined
|
||||
|
||||
/**
|
||||
* All tracked background tasks (insertion order).
|
||||
* @returns every task this executor started, running or finished.
|
||||
*/
|
||||
abstract list(): BashTask[]
|
||||
|
||||
/**
|
||||
* Read output produced since the previous read. Throws for unknown ids.
|
||||
* @param id - the task to read from.
|
||||
* @returns the incremental read; consecutive reads never re-deliver output.
|
||||
*/
|
||||
abstract readOutput(id: BashTaskId): BashTaskRead
|
||||
|
||||
/**
|
||||
* Kill a running background task. Returns false when it had already
|
||||
* finished (no-op). Throws for unknown ids.
|
||||
* @param id - the task to kill.
|
||||
* @returns true when this call killed it, false when it had already finished.
|
||||
*/
|
||||
abstract kill(id: BashTaskId): boolean
|
||||
|
||||
/**
|
||||
* Register a background-task completion listener (disposed with the
|
||||
* calling fiber). Listeners never fire after this service is disposed.
|
||||
* @param listener - called exactly once per task completion.
|
||||
* @returns the disposer that unregisters the listener.
|
||||
*/
|
||||
onTaskDone(listener: BashTaskListener): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}, 'bash.onTaskDone()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** For implementations: notify listeners that `task` completed. Listener
|
||||
* exceptions are contained (logged) — one bad listener must not reject
|
||||
* `BashTask.done` or starve the listeners after it. */
|
||||
protected notifyTaskDone(task: BashTask): void {
|
||||
if (this.listenersClosed) return
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(task)
|
||||
} catch (error: unknown) {
|
||||
// Listener bugs are reported, never propagated into task.done.
|
||||
console.error('bash onTaskDone listener threw:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
abstract start(spec: BashExecSpec): BashProcess
|
||||
}
|
||||
|
||||
export default BashExecutor
|
||||
Reference in New Issue
Block a user