refactor(process): split the process manager out of the bash executor
New process/ capability family: @deepseek-ai/dsh-process owns ctx.processes — abstract ProcessManager.spawn(spec) over a fully-explicit ProcessSpawnSpec — plus the shared DSH_* managed-environment and CollectedOutput vocabulary; @deepseek-ai/dsh-process-local carries the former bash-local run.ts plumbing (detached groups, tail-keep spill-backed output, credential scrub, kill escalation, kill-and-join disposal) with no config of its own. dsh-bash-local becomes a consumer: it keeps command defaulting, the fused deadline timedOut/aborted classification, the model-friendly terminal env (now merged through the ordinary env channel), and the [stderr]-marked background read merge, and spawns through ctx.processes. Background-process lifetime moves to the manager, so an executor reload no longer kills live background work; a background spawn failure is injected once into the read path instead of being buffered as fake stderr. dsh-bash re-exports the moved vocabulary so bash consumers keep one import root; dsh-bash-sandbox only redeclares the inherited inject. Every composition loading a bash executor now loads dsh-process-local (CLI, examples, python bundled runtime, create-sdk bash feature, inline test configs).
This commit is contained in:
70
packages/process/process-local/tests/manager.spec.ts
Normal file
70
packages/process/process-local/tests/manager.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
|
||||
function spec(command: string, overrides: Partial<ProcessSpawnSpec> = {}): ProcessSpawnSpec {
|
||||
return {
|
||||
argv: ['bash', '-c', command],
|
||||
cwd: process.cwd(),
|
||||
stdoutMaxBytes: 64_000,
|
||||
stderrMaxBytes: 64_000,
|
||||
maxSpillBytes: 64 * 1024 * 1024,
|
||||
graceMs: 200,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('LocalProcessManager', () => {
|
||||
it('registers as ctx.processes and spawns managed handles', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
const result = await ctx.processes.spawn(spec('echo managed')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('managed\n')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposal kills still-running processes and awaits their exit', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
const handle = ctx.processes.spawn(spec('sleep 60'))
|
||||
await fiber.dispose()
|
||||
const outcome = await handle.done
|
||||
expect(outcome.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
const handle = ctx.processes.spawn(spec('true'))
|
||||
const outcome = await handle.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposal tolerates a handle whose spawn already failed', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' }))
|
||||
await expect(handle.done).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposal contains a spawn-failure rejection that races teardown', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
// Dispose before the rejection continuation removes the handle from the
|
||||
// live set, so teardown itself must swallow the rejected done.
|
||||
const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' }))
|
||||
await fiber.dispose()
|
||||
await expect(handle.done).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
class SecondManager extends LocalProcessManager {}
|
||||
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user