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:
Yichen Jiang
2026-07-09 21:22:54 +08:00
parent e7e382f9d1
commit 184e164091
83 changed files with 3909 additions and 1627 deletions

View File

@@ -1,11 +1,10 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { BashTaskId } from '@deepseek-ai/dsh-bash'
import type { BashTaskRead } from '@deepseek-ai/dsh-bash'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
@@ -18,34 +17,20 @@ async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1]
return { ctx, bash }
}
/** Poll until a pid no longer exists. */
async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
/**
* Poll a handle's consuming readOutput until the ACCUMULATED delta contains
* `expected`; returns the accumulation (reads never re-deliver, so the caller
* gets everything produced up to the match).
*/
async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise<string> {
const deadline = Date.now() + timeoutMs
let all = ''
while (Date.now() < deadline) {
try {
process.kill(pid, 0)
} catch {
return
}
all += proc.readOutput().delta
if (all.includes(expected)) return all
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
}
async function readUntil(
bash: LocalBashExecutor,
id: BashTaskId,
expected: string,
timeoutMs = 5_000,
): Promise<BashTaskRead> {
const deadline = Date.now() + timeoutMs
let last: BashTaskRead | undefined
while (Date.now() < deadline) {
last = bash.readOutput(id)
if (last.delta.includes(expected)) return last
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(all)}`)
}
describe('LocalBashExecutor.run', () => {
@@ -88,15 +73,6 @@ describe('LocalBashExecutor.run', () => {
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
})
it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => {
const { bash } = await setup() // setup pins graceMs: 200 via config
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
await new Promise(resolve => setTimeout(resolve, 100))
bash.kill(task.id)
await task.done
expect(task.signal).toBe('SIGKILL')
})
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
@@ -136,229 +112,175 @@ describe('LocalBashExecutor.run', () => {
})
})
describe('LocalBashExecutor background tasks', () => {
it('start returns immediately with a registered running task', async () => {
describe('LocalBashExecutor.start (background process handles)', () => {
it('start returns immediately with a running handle that settles as completed', async () => {
const { bash } = await setup()
const before = Date.now()
const task = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
const proc = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
expect(Date.now() - before).toBeLessThan(150)
expect(task.status).toBe('running')
expect(bash.get(task.id)).toBe(task)
expect(bash.list()).toContain(task)
await task.done
expect(task.status).toBe('completed')
expect(task.exitCode).toBe(0)
expect(proc.command).toBe('sleep 0.2; echo done')
expect(proc.status).toBe('running')
await proc.done
expect(proc.status).toBe('completed')
expect(proc.exitCode).toBe(0)
})
it('assigns sequential ids', async () => {
it('threads stdin and extra env into a background process', async () => {
const { bash } = await setup()
const first = bash.start(bash.resolve({ command: 'true' }))
const second = bash.start(bash.resolve({ command: 'true' }))
expect(first.id).toBe('bash-1')
expect(second.id).toBe('bash-2')
await Promise.all([first.done, second.done])
})
it('threads stdin and extra env into a background task', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({
const proc = bash.start(bash.resolve({
command: 'cat; echo "[$DSH_BG_VAR]"',
stdin: 'bg-stdin\n',
env: { DSH_BG_VAR: 'bg-env' },
}))
const read = await readUntil(bash, task.id, '[bg-env]')
expect(read.delta).toContain('bg-stdin')
await task.done
expect(task.exitCode).toBe(0)
const output = await readUntil(proc, '[bg-env]')
expect(output).toContain('bg-stdin')
await proc.done
expect(proc.exitCode).toBe(0)
})
it('readOutput returns increments without re-delivery', async () => {
it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
const first = await readUntil(bash, task.id, 'first\n')
expect(first.delta).toBe('first\n')
expect(first.lossy).toBe(false)
await task.done
const second = bash.readOutput(task.id)
const proc = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
const first = await readUntil(proc, 'first\n')
expect(first).toBe('first\n')
await proc.done
// Read-after-exit returns the remaining buffered output — once.
const second = proc.readOutput()
expect(second.delta).toBe('second\n')
const third = bash.readOutput(task.id)
expect(third.delta).toBe('')
expect(second.lossy).toBe(false)
expect(proc.readOutput().delta).toBe('')
})
it('readOutput marks stderr sections', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
await task.done
const read = bash.readOutput(task.id)
expect(read.delta).toBe('out\n[stderr]\nerr\n')
const proc = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
await proc.done
expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
})
it('readOutput reports stderr-only deltas without a leading newline', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo err >&2' }))
await task.done
expect(bash.readOutput(task.id).delta).toBe('[stderr]\nerr\n')
const proc = bash.start(bash.resolve({ command: 'echo err >&2' }))
await proc.done
expect(proc.readOutput().delta).toBe('[stderr]\nerr\n')
})
it('readOutput flags lossy reads and reports spill paths', async () => {
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
await proc.done
expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
})
it('readOutput flags lossy reads and reports stdout spill paths', async () => {
const { bash } = await setup({ maxOutputBytes: 100 })
const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
await task.done
const read = bash.readOutput(task.id)
const proc = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
await proc.done
const read = proc.readOutput()
// Window slid past offset 0 → lossy, spill path points at the full stream.
expect(read.lossy).toBe(true)
expect(read.stdoutSpillPath).toBeDefined()
})
it('readOutput throws for unknown ids', async () => {
const { bash } = await setup()
expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
})
it('kill terminates the process group and reports status killed', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
expect(bash.kill(task.id)).toBe(true)
await task.done
expect(task.status).toBe('killed')
expect(task.signal).toBe('SIGTERM')
})
it('kill returns false for finished tasks and throws for unknown ids', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(bash.kill(task.id)).toBe(false)
expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
})
it('notifies onTaskDone listeners on completion', async () => {
const { bash } = await setup()
const seen: [string, string][] = []
bash.onTaskDone(task => void seen.push([task.id, task.status]))
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(seen).toEqual([[task.id, 'completed']])
})
it('notifies onTaskDone for killed tasks too', async () => {
const { bash } = await setup()
const listener = vi.fn()
bash.onTaskDone(listener)
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
bash.kill(task.id)
await task.done
expect(listener).toHaveBeenCalledWith(task)
expect(task.status).toBe('killed')
})
it('marks tasks killed when the background spawn itself fails', async () => {
const { bash } = await setup()
const listener = vi.fn()
bash.onTaskDone(listener)
const task = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
await task.done
expect(task.status).toBe('killed')
expect(listener).toHaveBeenCalledWith(task)
expect(bash.readOutput(task.id).delta).toContain('spawn failed')
})
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
await task.done
expect(bash.readOutput(task.id).delta).toBe('out\n[stderr]\nerr\n')
})
it('readOutput reports stderr spill paths', async () => {
const { bash } = await setup({ maxOutputBytes: 100 })
const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
await task.done
const read = bash.readOutput(task.id)
const proc = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
await proc.done
const read = proc.readOutput()
expect(read.lossy).toBe(true)
expect(read.stderrSpillPath).toBeDefined()
expect(read.delta).toContain('[stderr]')
})
it('disposing with already-finished tasks only kills the running ones', async () => {
it('kill() terminates the process group: true once, false after settlement', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({ command: 'sleep 60' }))
expect(proc.kill()).toBe(true)
await proc.done
expect(proc.status).toBe('killed')
expect(proc.signal).toBe('SIGTERM')
expect(proc.kill()).toBe(false)
})
it('kill() returns false for a naturally completed process', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({ command: 'true' }))
await proc.done
expect(proc.status).toBe('completed')
expect(proc.kill()).toBe(false)
})
it('kill escalation uses the configured graceMs (a TERM-trapping process dies by SIGKILL)', async () => {
const { bash } = await setup() // setup pins graceMs: 200 via config
// The child echoes AFTER arming the trap, so waiting for the marker
// guarantees SIGTERM is already ignored when the kill lands (a fixed sleep
// is load-flaky: a slow spawn would take the SIGTERM before the trap).
const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
await readUntil(proc, 'armed')
proc.kill()
await proc.done
expect(proc.status).toBe('killed')
expect(proc.signal).toBe('SIGKILL')
})
it('a spec.signal abort settles the handle as killed, not completed', async () => {
const { bash } = await setup()
const controller = new AbortController()
const proc = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
controller.abort()
await proc.done
expect(proc.status).toBe('killed')
expect(proc.signal).toBe('SIGTERM')
})
it('a background spawn failure settles as killed with the error readable on stderr', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
// done resolves (never rejects) even though the process never ran.
await expect(proc.done).resolves.toBeUndefined()
expect(proc.status).toBe('killed')
expect(proc.readOutput().delta).toContain('spawn failed:')
})
})
describe('LocalBashExecutor disposal', () => {
it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir }
const finished = bash.start(bash.resolve({ command: 'true' }))
// The child prints its own pid ($$ = the detached bash group leader) so
// the test can probe liveness through the public read surface alone.
const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' }))
const pid = Number((await readUntil(proc, '\n')).trim())
expect(Number.isInteger(pid) && pid > 0).toBe(true)
await fiber.dispose()
// Disposal itself waited: the pid must already be gone, no grace left —
// even for a TERM-trapping child held until the SIGKILL escalation landed.
expect(() => process.kill(pid, 0)).toThrow()
expect(proc.status).toBe('killed')
await proc.done
})
it('settled processes already left the live map: dispose does not touch them', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir }
const finished = bash.start(bash.resolve({ command: 'echo done' }))
await finished.done
expect(finished.status).toBe('completed')
const running = bash.start(bash.resolve({ command: 'sleep 60' }))
await fiber.dispose()
await running.done
// The teardown marks every LIVE entry killed; a settled process had
// already left the map, so its status stays completed.
expect(finished.status).toBe('completed')
expect(running.status).toBe('killed')
await running.done
expect(running.signal).toBe('SIGTERM')
expect(bash.list()).toEqual([])
})
it('disposing the executor fiber kills running tasks (no orphans)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir }
const listener = vi.fn()
bash.onTaskDone(listener)
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
const running = bash.get(task.id)!
await new Promise(resolve => setTimeout(resolve, 50))
// Grab the pid before dispose clears the registry.
const pid = (running as unknown as { running: { pid: number } }).running.pid
await fiber.dispose()
await waitGone(pid)
expect(bash.list()).toEqual([])
// Listener silenced by base-class teardown — no late notifications.
expect(listener).not.toHaveBeenCalled()
})
})
describe('review fixes: lifecycle hardening', () => {
it('start honors a pre-aborted or later-aborted AbortSignal', async () => {
const { bash } = await setup()
const controller = new AbortController()
const task = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
controller.abort()
await task.done
expect(task.status).toBe('killed')
expect(task.signal).toBe('SIGTERM')
})
it('a throwing onTaskDone listener does not reject task.done or starve later listeners', async () => {
const { bash } = await setup()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const second = vi.fn()
try {
bash.onTaskDone(() => { throw new Error('listener bug') })
bash.onTaskDone(second)
const task = bash.start(bash.resolve({ command: 'true' }))
await expect(task.done).resolves.toBeUndefined()
expect(second).toHaveBeenCalledWith(task)
expect(errorSpy).toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir }
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
await new Promise(resolve => setTimeout(resolve, 100))
const pid = (task as unknown as { running: { pid: number } }).running.pid
await fiber.dispose()
// Disposal itself waited: the pid must already be gone, no grace left.
expect(() => process.kill(pid, 0)).toThrow()
expect(task.status).toBe('killed')
})
})