Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks

# Conflicts:
#	docs/config-catalog.md
#	docs/core-data-structures/bash.md
#	packages/bash/bash-local/src/index.ts
#	packages/bash/bash/src/types.ts
#	packages/bash/bash/tests/service.spec.ts
#	packages/bash/tool-bash/README.md
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
Yichen Jiang
2026-07-15 13:38:17 +08:00
16 changed files with 181 additions and 182 deletions

View File

@@ -2,6 +2,8 @@
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
## Config
```yaml

View File

@@ -23,9 +23,6 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts'
export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts'
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
/** Default working directory for commands (default: process.cwd()). */
@@ -171,7 +168,6 @@ export class LocalBashExecutor extends BashExecutor {
let stdoutOffset = 0
let stderrOffset = 0
const proc: BashProcess = {
command: spec.command,
status: 'running',
exitCode: null,
signal: null,
@@ -184,7 +180,7 @@ export class LocalBashExecutor extends BashExecutor {
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, running)
this.onProcessDone(proc, running.stderr.readFrom(0).text)
this.live.delete(proc)
}, (error: unknown) => {
// Spawn-level failure (bad workdir, …): the process never ran. The
@@ -192,7 +188,7 @@ export class LocalBashExecutor extends BashExecutor {
// suffices — runBash only rejects with Error instances.
proc.status = 'killed'
running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
this.onProcessDone(proc, running)
this.onProcessDone(proc, running.stderr.readFrom(0).text)
this.live.delete(proc)
}),
readOutput: (): BashProcessRead => {
@@ -230,9 +226,9 @@ export class LocalBashExecutor extends BashExecutor {
* {@link BashProcess.done} resolves. The base implementation is intentionally
* empty.
* @param _proc - the settled process handle.
* @param _running - the process collectors, including full in-memory stderr.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
*/
protected onProcessDone(_proc: BashProcess, _running: RunningBash): void {}
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
}
export default LocalBashExecutor

View File

@@ -184,27 +184,6 @@ export class OutputCollector {
writeSync(this.spillFd, chunk)
}
// TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at
// the bottom of this file) and `totalBytes` is read only by a test. The live
// background-poll path goes through `readFrom()`, so inline snapshot() into
// finalize() and drop or privatize the totalBytes getter.
/**
* Read the collected tail without finalizing (the final-result snapshot).
* @returns the retained tail text, the truncation flag, and the spill path when one was created.
*/
snapshot(): CollectedOutput {
return {
text: Buffer.concat(this.chunks).toString('utf8'),
truncated: this.dropped,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
}
/** Total bytes ever pushed (including bytes dropped from memory). */
get totalBytes(): number {
return this.total
}
/**
* Incremental read in whole-stream byte coordinates: returns everything
* pushed since `fromByte`. When `fromByte` has already slid out of the
@@ -243,7 +222,11 @@ export class OutputCollector {
}
this.spillFd = undefined
}
return this.snapshot()
return {
text: Buffer.concat(this.chunks).toString('utf8'),
truncated: this.dropped,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
}
}

View File

@@ -134,7 +134,6 @@ describe('LocalBashExecutor.start (background process handles)', () => {
const before = Date.now()
const proc = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
expect(Date.now() - before).toBeLessThan(150)
expect(proc.command).toBe('sleep 0.2; echo done')
expect(proc.status).toBe('running')
await proc.done
expect(proc.status).toBe('completed')

View File

@@ -2,8 +2,8 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local'
import type { RunningBash } from '@deepseek-ai/dsh-bash-local'
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '../src/run.ts'
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
vi.mock('node:fs', async (importOriginal) => {
@@ -49,7 +49,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (running.stdout.snapshot().text.includes(expected)) return
if (running.stdout.readFrom(0).text.includes(expected)) return
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
@@ -295,19 +295,11 @@ describe('OutputCollector', () => {
expect(third.spillPath).toBeDefined()
})
it('tracks totalBytes across drops', () => {
const collector = new OutputCollector(4, 'test', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.totalBytes).toBe(8)
expect(collector.finalize().text).toBe('bbbb')
})
it('contains close failures and drops the spill path', () => {
const collector = new OutputCollector(4, 'closefail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.snapshot().spillPath).toBeDefined()
expect(collector.readFrom(0).spillPath).toBeDefined()
failNextClose.value = true
let out: ReturnType<typeof collector.finalize>