mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -2,7 +2,8 @@
|
||||
* `LocalBashExecutor`: the local-subprocess implementation of the
|
||||
* `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
|
||||
* own process group (see `./run.ts` for the plumbing and the agent-tool
|
||||
* survey notes), tracks background tasks, and kills everything on dispose.
|
||||
* survey notes), tracks live background processes for disposal quiescence
|
||||
* ONLY (task semantics live in `ctx.tasks`), and kills everything on dispose.
|
||||
*
|
||||
* TODO(permissions/sandbox): execution policy does NOT belong here — use
|
||||
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md
|
||||
@@ -16,8 +17,8 @@
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
|
||||
import type { RunInternals, RunningBash } from './run.ts'
|
||||
|
||||
@@ -47,15 +48,6 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
interface TrackedTask extends BashTask {
|
||||
running: RunningBash
|
||||
/** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
|
||||
stdoutOffset: number
|
||||
stderrOffset: number
|
||||
/** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
|
||||
owner: OwnerToken | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-subprocess bash executor. Defaults follow the agent-tool survey
|
||||
* consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB
|
||||
@@ -71,8 +63,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
private tasks = new Map<BashTaskId, TrackedTask>()
|
||||
private nextTaskId = 1
|
||||
/**
|
||||
* Live background processes, tracked for DISPOSAL only: an entry leaves
|
||||
* the map the moment its process settles (callers keep reading through
|
||||
* their own {@link BashProcess} handle — the buffers live on it).
|
||||
*/
|
||||
private live = new Map<BashProcess, RunningBash>()
|
||||
/** Test seam: spill knobs forwarded to runBash. */
|
||||
internals: RunInternals = {}
|
||||
|
||||
@@ -91,17 +87,14 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
ctx.effect(() => async () => {
|
||||
// Kill every live process group and WAIT for the processes to close so
|
||||
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
|
||||
// held until the SIGKILL escalation lands. The base class already
|
||||
// silenced listeners, so these kills complete without notices.
|
||||
// held until the SIGKILL escalation lands.
|
||||
const pending: Promise<void>[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === 'running') {
|
||||
task.status = 'killed'
|
||||
task.running.kill()
|
||||
pending.push(task.done)
|
||||
}
|
||||
for (const [proc, running] of this.live) {
|
||||
proc.status = 'killed'
|
||||
running.kill()
|
||||
pending.push(proc.done)
|
||||
}
|
||||
this.tasks.clear()
|
||||
this.live.clear()
|
||||
await Promise.all(pending)
|
||||
}, 'local bash teardown')
|
||||
}
|
||||
@@ -125,9 +118,6 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
// means none). env merges AFTER the scrub in run.ts.
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
// Carry the owner through verbatim (required-but-nullable on the spec):
|
||||
// the executor never interprets it — the consumer's access policy does.
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +135,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
return { ...outcome, timeoutMs: spec.timeoutMs }
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashTask {
|
||||
// No timeout for background tasks (matches Claude Code, which detaches
|
||||
// the timeout when backgrounding); callers stop tasks via kill() — or
|
||||
// via spec.signal, which the seam contract honors for background runs
|
||||
// too (runBash wires it to the group kill). spec.timeoutMs is ignored
|
||||
// here by design.
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
// No timeout for background processes (matches Claude Code, which
|
||||
// detaches the timeout when backgrounding); callers stop them via the
|
||||
// handle's kill() — or via spec.signal, which the seam contract honors
|
||||
// for background runs too (runBash wires it to the group kill).
|
||||
// spec.timeoutMs is ignored here by design.
|
||||
const running = runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
@@ -162,79 +152,54 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
env: spec.env,
|
||||
}, this.internals)
|
||||
|
||||
const id = BashTaskId(`bash-${this.nextTaskId++}`)
|
||||
const task: TrackedTask = {
|
||||
id,
|
||||
let stdoutOffset = 0
|
||||
let stderrOffset = 0
|
||||
const proc: BashProcess = {
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
owner: spec.owner,
|
||||
running,
|
||||
stdoutOffset: 0,
|
||||
stderrOffset: 0,
|
||||
done: running.done.then((outcome) => {
|
||||
// Abort-killed tasks report as killed, not completed.
|
||||
if (task.status === 'running') task.status = outcome.aborted ? 'killed' : 'completed'
|
||||
task.exitCode = outcome.exitCode
|
||||
task.signal = outcome.signal
|
||||
this.notifyTaskDone(task)
|
||||
// Abort-killed processes report as killed, not completed.
|
||||
if (proc.status === 'running') proc.status = outcome.aborted ? 'killed' : 'completed'
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.live.delete(proc)
|
||||
}, (error: unknown) => {
|
||||
// Spawn-level failure (bad workdir, …): the task never ran. String()
|
||||
// Spawn-level failure (bad workdir, …): the process never ran. The
|
||||
// error is surfaced through the read path, not a rejection. String()
|
||||
// suffices — runBash only rejects with Error instances.
|
||||
task.status = 'killed'
|
||||
task.running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
|
||||
this.notifyTaskDone(task)
|
||||
proc.status = 'killed'
|
||||
running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
|
||||
this.live.delete(proc)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = running.stdout.readFrom(stdoutOffset)
|
||||
const err = running.stderr.readFrom(stderrOffset)
|
||||
stdoutOffset = out.nextOffset
|
||||
stderrOffset = err.nextOffset
|
||||
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
|
||||
return {
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
},
|
||||
kill: (): boolean => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
running.kill()
|
||||
return true
|
||||
},
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
return task
|
||||
}
|
||||
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
||||
// Unknown id and known-but-ownerless both read as undefined — the consumer
|
||||
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
|
||||
return this.tasks.get(id)?.owner
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
|
||||
const out = task.running.stdout.readFrom(task.stdoutOffset)
|
||||
const err = task.running.stderr.readFrom(task.stderrOffset)
|
||||
task.stdoutOffset = out.nextOffset
|
||||
task.stderrOffset = err.nextOffset
|
||||
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
|
||||
return {
|
||||
task,
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
}
|
||||
|
||||
kill(id: BashTaskId): boolean {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
if (task.status !== 'running') return false
|
||||
task.status = 'killed'
|
||||
task.running.kill()
|
||||
return true
|
||||
this.live.set(proc, running)
|
||||
return proc
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user