refactor(subprocess): rename the process seam to subprocess and address review
Review feedback (tianyicui): 'process' is a poor service name. The family is now packages/subprocess/ — @deepseek-ai/dsh-subprocess (ctx.subprocess, abstract SubprocessService, Subprocess* vocabulary) and @deepseek-ai/dsh-subprocess-local (LocalSubprocessService) — renamed throughout code, compositions, docs (en+zh, pairs re-recorded), catalogs, and gates. 'subprocess' is the precise term for managed OS children (the Python-stdlib sense), avoids colliding with Node's global process object, and reads as one system beside dsh-subagent-subprocess. ds-review-bot findings addressed: - kill() on a settled handle is now a no-op (no signal to a possibly-reused pgid, no referenced grace timer delaying exit); pinned by a spy test. - The moved DshEnvironmentKey/DshEnvironment/CollectedOutput types get drift-checked type-equiv blocks on the new subprocess.md page, restoring their manifest registration. - subprocess.md is registered in the core.md sub-page index (en+zh).
This commit is contained in:
@@ -11,7 +11,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`process/`](process/README.md) | Child-process manager capability family: spawn seam + local process-group implementation | Product — stable surface |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-group implementation | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
|
||||
@@ -4,8 +4,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`process/`](../process/README.md) seam) | `ctx.bash` |
|
||||
| `bash-local/` | Local `BashExecutor` implementation over the [`process/`](../process/README.md) manager (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` |
|
||||
| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
|
||||
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-bash-local
|
||||
|
||||
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-process`](../../process/process/README.md) manager: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.processes`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the process manager's.
|
||||
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
|
||||
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
|
||||
|
||||
@@ -23,10 +23,10 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the manager explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-process-local`](../../process/process-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the manager's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the manager's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the manager, so it survives executor reloads and dies (killed and joined) with the manager's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -40,7 +40,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
|
||||
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
|
||||
- **POSIX-only** — the `bash` binary is hardcoded, and the underlying manager's group semantics are POSIX; Windows is unsupported.
|
||||
- **A background spawn-failure note is single-delivery** — the manager buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
|
||||
- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported.
|
||||
- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
|
||||
|
||||
Scrub-heuristic and spill-retention caveats live with [`dsh-process-local`](../../process/process-local/README.md), which owns those mechanics.
|
||||
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-process": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -39,8 +39,8 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-process": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Local implementation of the bash executor seam over the process-manager
|
||||
* Local implementation of the bash executor seam over the subprocess
|
||||
* seam. Each command runs as `bash -c` in a managed process group spawned
|
||||
* through `ctx.processes`; this executor owns command defaulting, deadlines
|
||||
* through `ctx.subprocess`; this executor owns command defaulting, deadlines
|
||||
* and cause classification, the model-friendly terminal environment, and the
|
||||
* model-facing stdout/stderr merge for background reads. Execution policy
|
||||
* belongs in `tools/pre-execute` or a sandboxing executor.
|
||||
@@ -12,7 +12,7 @@ import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
* interactive terminal features that would garble tool output (the same set
|
||||
* Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
|
||||
* merged into the ordinary env channel, so a trusted caller's own entry still
|
||||
* wins; the process manager applies its credential scrub independently.
|
||||
* wins; the subprocess service applies its credential scrub independently.
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
@@ -61,14 +61,14 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Local bash executor over `ctx.processes`. Bounded output, spill files, and
|
||||
* process-group SIGTERM→SIGKILL escalation are the process manager's
|
||||
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
|
||||
* process-group SIGTERM→SIGKILL escalation are the subprocess service's
|
||||
* mechanics; this executor supplies their configured budgets per spawn, so a
|
||||
* still-running background process stays managed (killed and joined at
|
||||
* composition teardown) even across an executor reload.
|
||||
*/
|
||||
export class LocalBashExecutor extends BashExecutor {
|
||||
static inject = ['processes']
|
||||
static inject = ['subprocess']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
@@ -116,7 +116,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
stdoutMaxBytes,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
|
||||
// no config default. The process manager owns the scrub and merge order.
|
||||
// no config default. The subprocess service owns the scrub and merge order.
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
@@ -129,7 +129,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified process spawn. */
|
||||
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): ProcessSpawnSpec {
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
return {
|
||||
argv: ['bash', '-c', spec.command],
|
||||
cwd: spec.workdir,
|
||||
@@ -147,7 +147,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done
|
||||
const outcome = await this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
|
||||
const aborted = d.signal.aborted && !timedOut
|
||||
@@ -156,9 +156,9 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
|
||||
// A spawn failure produces no process output, so the manager has nothing
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
// to buffer; the note is delivered exactly once through the read path.
|
||||
let spawnFailureNote: string | undefined
|
||||
const consumeSpawnFailure = (): string => {
|
||||
|
||||
@@ -4,15 +4,15 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
|
||||
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
// A short kill grace via the REAL config path, so escalation tests stay fast.
|
||||
await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
@@ -295,11 +295,11 @@ describe('LocalBashExecutor.start (background process handles)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('process lifecycle ownership (the manager, not the executor)', () => {
|
||||
it('a background process survives executor-fiber disposal and dies with the process manager', async () => {
|
||||
describe('process lifecycle ownership (the subprocess service, not the executor)', () => {
|
||||
it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => {
|
||||
const ctx = new Context()
|
||||
const managerFiber = await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
|
||||
@@ -316,17 +316,17 @@ describe('process lifecycle ownership (the manager, not the executor)', () => {
|
||||
expect(proc.status).toBe('running')
|
||||
expect(() => process.kill(pid, 0)).not.toThrow()
|
||||
|
||||
// Manager disposal kills the group and AWAITS its exit (no orphans).
|
||||
// Service disposal kills the group and AWAITS its exit (no orphans).
|
||||
await managerFiber.dispose()
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
})
|
||||
|
||||
it('manager disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
|
||||
it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
|
||||
const ctx = new Context()
|
||||
const managerFiber = await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../process/process"
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
|
||||
@@ -34,7 +34,7 @@ export type Config = LocalConfig
|
||||
* mode; `result.sandbox` reports the mode and enforcement actually used.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static override inject = ['processes', 'sandbox', 'sandboxPolicy']
|
||||
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
|
||||
@@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
|
||||
* exact `['bash', '-c', command]` argv this executor would spawn, get back
|
||||
* the confined argv, and re-assemble it into the `exec …` command string
|
||||
* the inherited spawn path runs (the outer `bash -c` the process manager spawns
|
||||
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
/**
|
||||
* Keyless integration of the real provider and executor through public run/start paths. With
|
||||
@@ -43,7 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
|
||||
@@ -48,7 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@
|
||||
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
@@ -59,8 +59,8 @@ async function setup(
|
||||
...mode !== undefined ? { mode } : {},
|
||||
...workspaceRoot !== undefined ? { workspaceRoot } : {},
|
||||
})
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
return { ctx, bash, calls }
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
/**
|
||||
* Keyless macOS integration of the real provider and executor through public run/start paths.
|
||||
@@ -42,7 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-process": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-process": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ declare module 'cordis' {
|
||||
* - {@link BashProcess.readOutput} is incremental: consecutive reads never
|
||||
* repeat output. Lossy reads report truncation and available spill files.
|
||||
* - A still-running background process is stopped and awaited when its
|
||||
* owning composition tears down. With the process-manager seam that
|
||||
* boundary is `ctx.processes` disposal, so a background process survives
|
||||
* owning composition tears down. With the subprocess seam that
|
||||
* boundary is `ctx.subprocess` disposal, so a background process survives
|
||||
* an executor-only reload.
|
||||
*/
|
||||
export abstract class BashExecutor extends Service {
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* Execution types for the bash executor seam. Background task semantics belong
|
||||
* to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The
|
||||
* managed-environment and captured-output vocabulary is owned by the
|
||||
* process-manager seam and re-exported here so bash consumers keep one import
|
||||
* subprocess seam and re-exported here so bash consumers keep one import
|
||||
* root.
|
||||
* @module dsh-bash/types
|
||||
*/
|
||||
|
||||
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-process'
|
||||
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process'
|
||||
export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-process'
|
||||
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
|
||||
export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/**
|
||||
* Sandbox facts for one run, present iff a sandboxing executor handled it.
|
||||
@@ -154,7 +154,7 @@ export interface BashProcessRead {
|
||||
/**
|
||||
* A background process handle returned by {@link BashExecutor.start}. It is the
|
||||
* only access path; buffered output remains readable after exit. Composition
|
||||
* teardown (the process manager's disposal) kills running processes and
|
||||
* teardown (the subprocess service's disposal) kills running processes and
|
||||
* awaits {@link done}; an executor-only reload leaves them running.
|
||||
*/
|
||||
export interface BashProcess {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../process/process"
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
@@ -17,7 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
@@ -33,8 +33,8 @@ async function setup() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
@@ -48,8 +48,8 @@ async function setupWithTasks() {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
@@ -278,8 +278,8 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
|
||||
@@ -387,7 +387,7 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
@@ -405,7 +405,7 @@ describe('bash tool', () => {
|
||||
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
@@ -417,7 +417,7 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
ToolBash.apply(ctx, {})
|
||||
const schema = ctx.tools.schemas()[0]!
|
||||
@@ -533,7 +533,7 @@ describe('background execution through the task runtime', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ToolBash, { enableRunInBackground: false })
|
||||
|
||||
|
||||
@@ -426,16 +426,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'processes',
|
||||
summary: 'Abstract process manager.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract spawn(spec: ProcessSpawnSpec): ProcessHandle',
|
||||
jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pty',
|
||||
summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.',
|
||||
@@ -754,6 +744,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'subprocess',
|
||||
summary: 'Abstract subprocess service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle',
|
||||
jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'systemPrompt',
|
||||
summary: 'Registry service for the prompt inputs assembled before each model step.',
|
||||
@@ -1458,10 +1458,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'CodeRunResult',
|
||||
declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CollectedOutput',
|
||||
declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandDefinition',
|
||||
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
|
||||
@@ -1558,14 +1554,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'DomainTableSpec',
|
||||
declaration: 'export interface DomainTableSpec<K extends string = string, V = unknown> {\n readonly valueSchema: ZodType<V>;\n readonly __key?: K;\n}',
|
||||
},
|
||||
{
|
||||
name: 'DshEnvironment',
|
||||
declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;',
|
||||
},
|
||||
{
|
||||
name: 'DshEnvironmentKey',
|
||||
declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;',
|
||||
},
|
||||
{
|
||||
name: 'EditGoalRequest',
|
||||
declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}',
|
||||
@@ -1770,26 +1758,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PresetSpec',
|
||||
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessHandle',
|
||||
declaration: 'export interface ProcessHandle {\n readonly pid: number;\n readonly stdout: ProcessOutputReader;\n readonly stderr: ProcessOutputReader;\n readonly done: Promise<ProcessOutcome>;\n kill(): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessOutcome',
|
||||
declaration: 'export interface ProcessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessOutputRead',
|
||||
declaration: 'export interface ProcessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessOutputReader',
|
||||
declaration: 'export interface ProcessOutputReader {\n readFrom(fromByte: number): ProcessOutputRead;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessSpawnSpec',
|
||||
declaration: 'export interface ProcessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptAssembly',
|
||||
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
|
||||
|
||||
@@ -94,8 +94,8 @@ async function makeConsumer(): Promise<string> {
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: mock-llm',
|
||||
' name: \'./mock-llm.mjs\'',
|
||||
'- id: processes',
|
||||
' name: \'@deepseek-ai/dsh-process-local\'',
|
||||
'- id: subprocess',
|
||||
' name: \'@deepseek-ai/dsh-subprocess-local\'',
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: acp-agent',
|
||||
|
||||
@@ -35,8 +35,8 @@ const CORDIS_YML = `
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
- id: acp-agent
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { basename, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -53,7 +53,7 @@ beforeEach(async () => {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot })
|
||||
await ctx.plugin(agentSpine, {
|
||||
|
||||
@@ -80,8 +80,8 @@ async function makeConsumer(): Promise<string> {
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: mock-llm',
|
||||
" name: './mock-llm.ts'",
|
||||
'- id: processes',
|
||||
" name: '@deepseek-ai/dsh-process-local'",
|
||||
'- id: subprocess',
|
||||
" name: '@deepseek-ai/dsh-subprocess-local'",
|
||||
'- id: bash',
|
||||
" name: '@deepseek-ai/dsh-bash-local'",
|
||||
'- id: cli-agent',
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
|
||||
@@ -18,7 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -62,7 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
@@ -53,7 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -355,7 +355,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -377,7 +377,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
|
||||
await fiber.dispose()
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
@@ -42,7 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath, ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -361,7 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so
|
||||
// the bridge must run on the raw minimal config (the per-hook timeout is
|
||||
@@ -660,7 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the session cwd).
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -690,7 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -42,7 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -166,7 +166,7 @@ describe('hooks-codex bridge', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
|
||||
await fiber.dispose()
|
||||
@@ -189,7 +189,7 @@ describe('hooks-codex bridge', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -32,7 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -312,7 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
ctx.logger.warn = warn as never
|
||||
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
|
||||
@@ -622,7 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# process/ — child-process manager capability family
|
||||
|
||||
The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [process-manager seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md).
|
||||
|
||||
| Package | ctx key | Role |
|
||||
|---|---|---|
|
||||
| [`process`](process/README.md) (`@deepseek-ai/dsh-process`) | `ctx.processes` | The seam: abstract `ProcessManager.spawn(spec)`, the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary |
|
||||
| [`process-local`](process-local/README.md) (`@deepseek-ai/dsh-process-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal |
|
||||
|
||||
The manager owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.
|
||||
@@ -33,7 +33,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
|
||||
mode: 'exclusive',
|
||||
required: true,
|
||||
baseResources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'processes', package: '@deepseek-ai/dsh-process-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
|
||||
],
|
||||
options: [
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-inprocess-invariant'
|
||||
export const name = 'subagent-insubprocess-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
@@ -28,7 +28,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
|
||||
})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
await ctx.plugin(SubagentService)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-subprocess-invariant'
|
||||
export const name = 'subagent-subsubprocess-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
|
||||
10
packages/subprocess/README.md
Normal file
10
packages/subprocess/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# subprocess/ — subprocess capability family
|
||||
|
||||
The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
|
||||
|
||||
| Package | ctx key | Role |
|
||||
|---|---|---|
|
||||
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary |
|
||||
| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal |
|
||||
|
||||
The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.
|
||||
@@ -1,14 +1,14 @@
|
||||
# @deepseek-ai/dsh-process-local
|
||||
# @deepseek-ai/dsh-subprocess-local
|
||||
|
||||
Local-subprocess implementation of the [`@deepseek-ai/dsh-process`](../process/README.md) manager seam: `LocalProcessManager` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today).
|
||||
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today).
|
||||
|
||||
## Behavior (and where it came from)
|
||||
|
||||
- **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
|
||||
- **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Offset-based reads** — `ProcessHandle` readers return deltas in whole-stream byte coordinates; the manager never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist.
|
||||
- **Kill-and-join disposal** — the manager retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement.
|
||||
- **Offset-based reads** — `SubprocessHandle` readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist.
|
||||
- **Kill-and-join disposal** — the service retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-process-local",
|
||||
"description": "Local-subprocess implementation of the DeepSeek Harness process-manager seam",
|
||||
"name": "@deepseek-ai/dsh-subprocess-local",
|
||||
"description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -28,12 +28,12 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-process": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-process": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
/**
|
||||
* Local-subprocess implementation of the process-manager seam. Each spawn is
|
||||
* Local-subprocess implementation of the subprocess seam. Each spawn is
|
||||
* a detached process group with bounded, spill-backed output; disposal kills
|
||||
* and joins live groups. It has no config: every limit arrives on the spec,
|
||||
* so the deployment-varying choices stay with the calling seam's config (the
|
||||
* bash executor's, today).
|
||||
* @module @deepseek-ai/dsh-process-local
|
||||
* @module @deepseek-ai/dsh-subprocess-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { ProcessManager } from '@deepseek-ai/dsh-process'
|
||||
import type { ProcessHandle, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { spawnProcess } from './spawn.ts'
|
||||
import type { SpawnInternals } from './spawn.ts'
|
||||
|
||||
/**
|
||||
* Local process manager: detached process groups, tail-keep truncation with
|
||||
* Local subprocess service: detached process groups, tail-keep truncation with
|
||||
* bounded spill files, credential-scrubbed environment, and group
|
||||
* SIGTERM→grace→SIGKILL escalation.
|
||||
*/
|
||||
export class LocalProcessManager extends ProcessManager {
|
||||
export class LocalSubprocessService extends SubprocessService {
|
||||
/** Live handles retained only so disposal can kill and join them. */
|
||||
private live = new Set<ProcessHandle>()
|
||||
private live = new Set<SubprocessHandle>()
|
||||
/** Test seam: spill knobs forwarded to spawnProcess. */
|
||||
internals: SpawnInternals = {}
|
||||
|
||||
@@ -36,10 +36,10 @@ export class LocalProcessManager extends ProcessManager {
|
||||
}
|
||||
this.live.clear()
|
||||
await Promise.all(pending)
|
||||
}, 'local process-manager teardown')
|
||||
}, 'local subprocess teardown')
|
||||
}
|
||||
|
||||
spawn(spec: ProcessSpawnSpec): ProcessHandle {
|
||||
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
||||
const handle = spawnProcess(spec, this.internals)
|
||||
this.live.add(handle)
|
||||
handle.done.then(
|
||||
@@ -50,4 +50,4 @@ export class LocalProcessManager extends ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalProcessManager
|
||||
export default LocalSubprocessService
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-process-local`.
|
||||
* @module @deepseek-ai/dsh-process-local/invariant
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-local`.
|
||||
* @module @deepseek-ai/dsh-subprocess-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-process-local'
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'process-local-invariant'
|
||||
export const name = 'subprocess-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Process plumbing for the local process manager: detached process-group
|
||||
* Process plumbing for the local subprocess service: detached process-group
|
||||
* spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation.
|
||||
* This layer reacts to an abort signal; callers own deadlines and classify
|
||||
* causes.
|
||||
* @module dsh-process-local/spawn
|
||||
* @module dsh-subprocess-local/spawn
|
||||
*/
|
||||
|
||||
import { type ChildProcessByStdio, spawn } from 'node:child_process'
|
||||
@@ -12,8 +12,8 @@ import { randomBytes } from 'node:crypto'
|
||||
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process'
|
||||
import type { CollectedOutput, DshEnvironment, ProcessHandle, ProcessOutcome, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { CollectedOutput, DshEnvironment, SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/**
|
||||
* Credential-shaped env vars are NOT forwarded to children (the harness's
|
||||
@@ -68,7 +68,7 @@ let defaultSpillDir: string | undefined
|
||||
* other local users read command output or pre-create symlinks.
|
||||
*/
|
||||
function privateSpillDir(): string {
|
||||
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-proc-'))
|
||||
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
|
||||
return defaultSpillDir
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export class OutputCollector {
|
||||
// prediction and symlink planting in shared tmp dirs.
|
||||
this.spillFile = join(
|
||||
this.spillDir,
|
||||
`dsh-proc-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
|
||||
`dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
|
||||
)
|
||||
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
|
||||
for (const prior of this.chunks) writeSync(this.spillFd, prior)
|
||||
@@ -236,12 +236,12 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void {
|
||||
|
||||
/**
|
||||
* Spawn one isolated detached process group and collect its output.
|
||||
* Runtime exits resolve as {@link ProcessOutcome}; only spawn failures reject.
|
||||
* Runtime exits resolve as {@link SubprocessOutcome}; only spawn failures reject.
|
||||
* @param spec - fully resolved argv, cwd, limits, and cancellation.
|
||||
* @param internals - test-only spill-directory override.
|
||||
* @returns live process handle and outcome promise.
|
||||
*/
|
||||
export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = {}): ProcessHandle {
|
||||
export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
|
||||
const spillDir = internals.spillDir ?? privateSpillDir()
|
||||
|
||||
if (spec.signal?.aborted) {
|
||||
@@ -264,12 +264,17 @@ export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals =
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
|
||||
|
||||
let graceTimer: NodeJS.Timeout | undefined
|
||||
let settled = false
|
||||
|
||||
// Failed spawns use pid -1 so kill remains a no-op.
|
||||
const pid = child.pid ?? -1
|
||||
|
||||
const kill = (): void => {
|
||||
if (graceTimer !== undefined) return // escalation already in flight
|
||||
// After settlement the group is gone and the pid may be reused; callers
|
||||
// commonly kill() in a finally, so this must not re-signal or start a
|
||||
// timer that outlives the handle.
|
||||
if (settled) return
|
||||
killGroup(pid, 'SIGTERM')
|
||||
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
|
||||
}
|
||||
@@ -284,8 +289,7 @@ export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals =
|
||||
child.stdin.end(spec.stdin)
|
||||
}
|
||||
|
||||
const done = new Promise<ProcessOutcome>((resolve, reject) => {
|
||||
let settled = false
|
||||
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
|
||||
let pipeDrainTimer: NodeJS.Timeout | undefined
|
||||
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
||||
if (settled) return
|
||||
@@ -1,9 +1,9 @@
|
||||
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'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
function spec(command: string, overrides: Partial<ProcessSpawnSpec> = {}): ProcessSpawnSpec {
|
||||
function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
|
||||
return {
|
||||
argv: ['bash', '-c', command],
|
||||
cwd: process.cwd(),
|
||||
@@ -15,11 +15,11 @@ function spec(command: string, overrides: Partial<ProcessSpawnSpec> = {}): Proce
|
||||
}
|
||||
}
|
||||
|
||||
describe('LocalProcessManager', () => {
|
||||
it('registers as ctx.processes and spawns managed handles', async () => {
|
||||
describe('LocalSubprocessService', () => {
|
||||
it('registers as ctx.subprocess 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
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const result = await ctx.subprocess.spawn(spec('echo managed')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('managed\n')
|
||||
await fiber.dispose()
|
||||
@@ -27,8 +27,8 @@ describe('LocalProcessManager', () => {
|
||||
|
||||
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'))
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const handle = ctx.subprocess.spawn(spec('sleep 60'))
|
||||
await fiber.dispose()
|
||||
const outcome = await handle.done
|
||||
expect(outcome.signal).toBe('SIGTERM')
|
||||
@@ -36,8 +36,8 @@ describe('LocalProcessManager', () => {
|
||||
|
||||
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 fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const handle = ctx.subprocess.spawn(spec('true'))
|
||||
const outcome = await handle.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
await fiber.dispose()
|
||||
@@ -45,26 +45,26 @@ describe('LocalProcessManager', () => {
|
||||
|
||||
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' }))
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-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)
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
// 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' }))
|
||||
const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-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/)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
class SecondManager extends LocalSubprocessService {}
|
||||
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DshEnvironment } from '@deepseek-ai/dsh-process'
|
||||
import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess'
|
||||
import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts'
|
||||
import type { ProcessHandle } from '@deepseek-ai/dsh-process'
|
||||
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
|
||||
failNextClose: { value: false },
|
||||
@@ -31,7 +31,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-'))
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
|
||||
|
||||
function spec(command: string, overrides: Partial<Parameters<typeof spawnProcess>[0]> = {}) {
|
||||
return {
|
||||
@@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
||||
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
|
||||
async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (running.stdout.readFrom(0).text.includes(expected)) return
|
||||
@@ -407,6 +407,21 @@ describe('killGroup', () => {
|
||||
await running.done
|
||||
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('handle.kill() after settlement signals nothing and starts no grace timer', async () => {
|
||||
// Cleanup code commonly kills handles in a finally; after settlement the
|
||||
// group is gone and the pid may be reused, so a late kill must be inert
|
||||
// (no signal to a possibly-recycled pgid, no referenced timer delaying exit).
|
||||
const running = spawnProcess(spec('true'))
|
||||
await running.done
|
||||
const spy = vi.spyOn(process, 'kill')
|
||||
try {
|
||||
running.kill()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('argv validation', () => {
|
||||
@@ -490,7 +505,7 @@ describe('environment and spill-file hardening', () => {
|
||||
{ spillDir },
|
||||
).done
|
||||
const path = result.stdout.spillPath!
|
||||
expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
||||
expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
||||
const mode = statSync(path).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
@@ -500,7 +515,7 @@ describe('environment and spill-file hardening', () => {
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
).done
|
||||
const dir = dirname(result.stdout.spillPath!)
|
||||
expect(dir).toMatch(/dsh-proc-/)
|
||||
expect(dir).toMatch(/dsh-subprocess-/)
|
||||
const mode = statSync(dir).mode & 0o777
|
||||
expect(mode).toBe(0o700)
|
||||
})
|
||||
@@ -15,7 +15,7 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../process"
|
||||
"path": "../subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
@@ -1,16 +1,16 @@
|
||||
# @deepseek-ai/dsh-process
|
||||
# @deepseek-ai/dsh-subprocess
|
||||
|
||||
The child-process manager seam (`ctx.processes`). The abstract `ProcessManager` exposes one method — `spawn(spec): ProcessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with its non-consuming offset-based output readers, `ProcessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-process-local`](../process-local/README.md).
|
||||
The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md).
|
||||
|
||||
## Contract
|
||||
|
||||
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures.
|
||||
- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden process-manager default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself.
|
||||
- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself.
|
||||
- Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists.
|
||||
- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the manager reacts to the abort but never classifies why (callers own deadlines and cause classification).
|
||||
- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification).
|
||||
- Disposal kills all still-running managed processes and awaits their exit.
|
||||
|
||||
See the [process data-structure catalog](../../../docs/core-data-structures/process.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md).
|
||||
See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-process",
|
||||
"description": "Child-process manager seam (ctx.processes) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service",
|
||||
"name": "@deepseek-ai/dsh-subprocess",
|
||||
"description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -1,37 +1,37 @@
|
||||
/**
|
||||
* The child-process manager seam (`ctx.processes`): spawn fully-specified
|
||||
* The subprocess seam (`ctx.subprocess`): spawn fully-specified
|
||||
* commands into managed process groups with bounded, spill-backed output and
|
||||
* escalated kills. Command defaulting, shell semantics, deadlines, and
|
||||
* presentation belong to consumers — the bash executor seam is the owning
|
||||
* template. The local implementation lives in
|
||||
* `@deepseek-ai/dsh-process-local`.
|
||||
* @module @deepseek-ai/dsh-process
|
||||
* `@deepseek-ai/dsh-subprocess-local`.
|
||||
* @module @deepseek-ai/dsh-subprocess
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { ProcessHandle, ProcessSpawnSpec } from './types.ts'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
|
||||
|
||||
export { DSH_ENV_PREFIX } from './types.ts'
|
||||
export type {
|
||||
CollectedOutput,
|
||||
DshEnvironment,
|
||||
DshEnvironmentKey,
|
||||
ProcessHandle,
|
||||
ProcessOutcome,
|
||||
ProcessOutputRead,
|
||||
ProcessOutputReader,
|
||||
ProcessSpawnSpec,
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
SubprocessOutputRead,
|
||||
SubprocessOutputReader,
|
||||
SubprocessSpawnSpec,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
processes: ProcessManager
|
||||
subprocess: SubprocessService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract process manager. Subclass, implement {@link spawn}, and load the
|
||||
* subclass as a plugin — it registers as `ctx.processes` (one implementation
|
||||
* Abstract subprocess service. Subclass, implement {@link spawn}, and load the
|
||||
* subclass as a plugin — it registers as `ctx.subprocess` (one implementation
|
||||
* per context; loading a second throws, which is cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
@@ -41,13 +41,13 @@ declare module 'cordis' {
|
||||
* - Output readers are offset-based and non-consuming, so independent readers
|
||||
* never consume one another's output; lossy reads report truncation and the
|
||||
* spill file holding the complete stream when one exists.
|
||||
* - {@link ProcessHandle.kill} and the spec's abort signal escalate
|
||||
* - {@link SubprocessHandle.kill} and the spec's abort signal escalate
|
||||
* SIGTERM→grace→SIGKILL across the whole process group.
|
||||
* - Disposal kills all still-running managed processes and awaits their exit.
|
||||
*/
|
||||
export abstract class ProcessManager extends Service {
|
||||
export abstract class SubprocessService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'processes')
|
||||
super(ctx, 'subprocess')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +56,7 @@ export abstract class ProcessManager extends Service {
|
||||
* @param spec - argv, directory, limits, grace, cancellation, and environment.
|
||||
* @returns the live process handle (readers, kill, outcome promise).
|
||||
*/
|
||||
abstract spawn(spec: ProcessSpawnSpec): ProcessHandle
|
||||
abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
|
||||
}
|
||||
|
||||
export default ProcessManager
|
||||
export default SubprocessService
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Package-owned invariant companion for the process-manager seam. @module @deepseek-ai/dsh-process/invariant */
|
||||
/** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-process'
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'process-invariant'
|
||||
export const name = 'subprocess-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
@@ -14,7 +14,7 @@ export const inject = ['invariants']
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register the process-manager invariant companion.
|
||||
* Register the subprocess invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Vocabulary for the process-manager seam: fully-specified spawn requests,
|
||||
* Vocabulary for the subprocess seam: fully-specified spawn requests,
|
||||
* bounded output with spill recovery, and live process handles. Command
|
||||
* defaulting, shell semantics, and presentation belong to consumers such as
|
||||
* the bash executor seam.
|
||||
* @module dsh-process/types
|
||||
* @module dsh-subprocess/types
|
||||
*/
|
||||
|
||||
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
|
||||
@@ -28,10 +28,10 @@ export interface CollectedOutput {
|
||||
/**
|
||||
* A fully-specified spawn request. This seam applies no defaults: every limit
|
||||
* and directory is explicit, so the caller's own config — not a hidden
|
||||
* process-manager default — decides them (the `dsh-bash` request/spec split
|
||||
* subprocess-service default — decides them (the `dsh-bash` request/spec split
|
||||
* is the owning template).
|
||||
*/
|
||||
export interface ProcessSpawnSpec {
|
||||
export interface SubprocessSpawnSpec {
|
||||
/** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */
|
||||
argv: readonly string[]
|
||||
/** Working directory for the child. */
|
||||
@@ -70,10 +70,10 @@ export interface ProcessSpawnSpec {
|
||||
|
||||
/**
|
||||
* Raw outcome of one closed process. Deliberately carries NO timeout or
|
||||
* cancellation classification: the manager kills on abort but does not decide
|
||||
* cancellation classification: the service kills on abort but does not decide
|
||||
* why — the caller reads the signal it owns to classify causes.
|
||||
*/
|
||||
export interface ProcessOutcome {
|
||||
export interface SubprocessOutcome {
|
||||
/** Exit code; null when the process died from a signal. */
|
||||
exitCode: number | null
|
||||
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
|
||||
@@ -82,8 +82,8 @@ export interface ProcessOutcome {
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
|
||||
/** One incremental {@link ProcessOutputReader.readFrom} read. */
|
||||
export interface ProcessOutputRead {
|
||||
/** One incremental {@link SubprocessOutputReader.readFrom} read. */
|
||||
export interface SubprocessOutputRead {
|
||||
/** Stream text from the requested offset (the whole retained tail when lossy). */
|
||||
text: string
|
||||
/** Whole-stream offset to resume from on the next read. */
|
||||
@@ -99,7 +99,7 @@ export interface ProcessOutputRead {
|
||||
* whole-stream byte coordinates owned by the caller, so independent readers
|
||||
* cannot consume one another's output.
|
||||
*/
|
||||
export interface ProcessOutputReader {
|
||||
export interface SubprocessOutputReader {
|
||||
/**
|
||||
* Read everything captured since `fromByte`. When that offset has slid out
|
||||
* of the in-memory tail window the read is `lossy` — it returns the whole
|
||||
@@ -107,22 +107,22 @@ export interface ProcessOutputReader {
|
||||
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
|
||||
* @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists.
|
||||
*/
|
||||
readFrom(fromByte: number): ProcessOutputRead
|
||||
readFrom(fromByte: number): SubprocessOutputRead
|
||||
}
|
||||
|
||||
/**
|
||||
* A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
|
||||
* escalation; buffered output remains readable after exit.
|
||||
*/
|
||||
export interface ProcessHandle {
|
||||
export interface SubprocessHandle {
|
||||
/** Process id (group leader); -1 when the spawn itself failed. */
|
||||
readonly pid: number
|
||||
/** Live stdout reader (also readable after exit). */
|
||||
readonly stdout: ProcessOutputReader
|
||||
readonly stdout: SubprocessOutputReader
|
||||
/** Live stderr reader (also readable after exit). */
|
||||
readonly stderr: ProcessOutputReader
|
||||
readonly stderr: SubprocessOutputReader
|
||||
/** Resolves when the process closes; rejects only for spawn-level failures. */
|
||||
readonly done: Promise<ProcessOutcome>
|
||||
readonly done: Promise<SubprocessOutcome>
|
||||
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
|
||||
kill(): void
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { ProcessManager } from '@deepseek-ai/dsh-process'
|
||||
import type { ProcessHandle, ProcessOutputRead, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/**
|
||||
* Minimal concrete manager: a hand-built handle. The seam is spawn-only —
|
||||
* Minimal concrete service: a hand-built handle. The seam is spawn-only —
|
||||
* defaulting, shell semantics, and deadlines belong to callers — so this stub
|
||||
* is all an implementation owes the abstract class.
|
||||
*/
|
||||
class StubProcessManager extends ProcessManager {
|
||||
spawn(spec: ProcessSpawnSpec): ProcessHandle {
|
||||
const read: ProcessOutputRead = { text: '', nextOffset: 0, lossy: false }
|
||||
class StubSubprocessService extends SubprocessService {
|
||||
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
||||
const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false }
|
||||
let killed = false
|
||||
return {
|
||||
pid: spec.argv.length,
|
||||
@@ -27,11 +27,11 @@ class StubProcessManager extends ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProcessManager seam', () => {
|
||||
it('a concrete subclass registers as ctx.processes and serves the abstract API', async () => {
|
||||
describe('SubprocessService seam', () => {
|
||||
it('a concrete subclass registers as ctx.subprocess and serves the abstract API', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubProcessManager)
|
||||
const handle = ctx.processes.spawn({
|
||||
await ctx.plugin(StubSubprocessService)
|
||||
const handle = ctx.subprocess.spawn({
|
||||
argv: ['true'],
|
||||
cwd: '/stub',
|
||||
stdoutMaxBytes: 1,
|
||||
@@ -48,8 +48,8 @@ describe('ProcessManager seam', () => {
|
||||
|
||||
it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubProcessManager)
|
||||
class SecondManager extends StubProcessManager {}
|
||||
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/)
|
||||
await ctx.plugin(StubSubprocessService)
|
||||
class SecondManager extends StubSubprocessService {}
|
||||
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user