DSH / Atlas
2026-07-26implementedarchitecture

The subprocess service is its own seam under the bash executors (`dsh-subprocess` / `dsh-subprocess-local`)

进程服务是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`)

`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalatio

English

Problem

dsh-bash-local bundled two capabilities that change for different reasons: running a bash command (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and running and managing a child process (detached process groups, bounded tail-keep output with spill files, the credential scrub and DSH_* merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — run.ts, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared DSH_*/CollectedOutput vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling job registry, whose registrations deliberately outlive producer fibers.

Decision

A new subprocess/ capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it:

  • @deepseek-ai/dsh-subprocess (Service Definition) — the abstract SubprocessRuntime owning ctx.subprocess: executable lookup, fully explicit ordinary spawns, and the terminal primitive added by the portable execution-world decision. Each stdio stream independently selects 'pipe', 'inherit', or bounded collection { maxBytes, spill? }; stdin selects 'ignore', 'pipe', or { data }. SubprocessOutcome carries exit facts with deliberately no timeout/cancel classification, while collected output remains on the handle after settlement. The Service Definition also owns process and terminal handles, the shared scrub, and DSH_ENV_PREFIX/DshEnvironment/CollectedOutput; argv is never shell-interpreted.
  • @deepseek-ai/dsh-subprocess-local (Service Provider)LocalSubprocessRuntime over the former run.ts plumbing (spawn.ts) plus node-pty: detached groups, bounded collection and private spill files, executable lookup, foreground/session inspection, and disposal that terminates and joins every managed process. terminate() owns TERM→grace→KILL for the tree, waitForExit() observes tree liveness, and injected taskkill /T covers Windows. Ordinary and terminal spawns apply the Service Definition's case-insensitive KEY/PASSWORD/SECRET/TOKEN scrub before explicit env. The provider has no config; every limit arrives on the spec, while Bash and PTY presentation environment overrides stay in their Consumers.
  • dsh-bash-local (Consumer)inject: ['subprocess']; maps each resolved ShellExecSpec onto a SubprocessSpawnSpec (['bash', '-c', command]), keeps its config, resolve() defaulting, fused-deadline timedOut/aborted classification, the [stderr]-marked background read merge with its consuming cursor, and the onProcessDone subclass hook. dsh-bash-sandbox is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path.
  • dsh-shell (Service Definition) — re-exports the moved vocabulary from dsh-subprocess, so no bash Consumer changes an import; ShellExecRequest/ShellExecSpec/ShellProcess and the sandbox facts remain bash-owned.

Every composition that loads a bash executor also loads @deepseek-ai/dsh-subprocess-local (CLI, examples, the Python bundled runtime, and inline test configs).

Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral contract shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects done and buffers nothing for a process that never ran), so the executor injects the spawn failed: … note into exactly one readOutput() delta.

Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses spawnTerminal() while keeping readiness and terminal policy. dsh-subagent-subprocess and the private LSP tree helpers were deleted. MCP transport spawning and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable.

Alternatives considered

Leave the process plumbing inside dsh-bash-local (status quo). Rejected for the same reason the job registry split landed: the boundary is stable and already documented in-code (run.ts's module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this change was exactly this split.

Keep the original batch-only interface and leave stream consumers bespoke. Rejected after the observed LSP, ACP, and PTY shapes showed that private process-tree signalling and environment scrubs would otherwise remain duplicated. The Node-shaped dispositions cover those consumers without buffering piped streams.

Use one stdio: 'pipe' | 'inherit' | 'collect' mode for all streams. Rejected because real consumers mix modes per stream: LSP uses pipe/pipe/collect, ACP uses pipe/pipe/inherit, and Bash uses data/collect/collect.

Route every process launch through ctx.subprocess. Rejected because the MCP SDK owns its transport spawn and support launchers deliberately stay independent of product seams. PTY allocation did move behind spawnTerminal() because the provider, not the consumer, owns that substrate-specific primitive.

Put run_in_background/task semantics into the subprocess capability seam instead. Rejected: that boundary already exists — ctx.jobs owns ids, ownership, and notices, and the bash tool adapts a ShellProcess into task hooks. The subprocess seam sits below the bash executor, not beside the job registry.

Move ENV_OVERRIDES (TERM=dumb, PAGER=cat …) into the subprocess service. Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the ambient scrub (credential-shaped and DSH_* names) is a security/identity invariant and stays, but terminal friendliness is the bash tool's choice, expressed through the spec's explicit env where a caller's own entry still wins.

Consequences

Bought: "run and manage a process" is a swappable capability used by Bash, LSP, PTY, and ACP consumers; a containerized or remote process backend slots in without changing their domain semantics; tree signalling, escalation, bounded collection, terminal mechanics, and credential scrubbing each have one implementation; and background processes survive executor reloads, matching the job registry's lifetime model. Process and terminal plumbing is tested through dsh-subprocess-local; consumer suites pin only their owned behavior against the real service.

Cost: one more package pair and one more composition row wherever a consumer loads; a missing subprocess provider leaves the consumer pending by standard service-injection behavior. Every backend implements executable lookup, three stdio modes, tree lifecycle, and one terminal primitive. The moved-vocabulary re-exports keep dsh-shell imports working but mean two packages name the same types; the subprocess seam is the owner. The spawn-failure note became single-delivery through Bash's consuming read cursor instead of repeatable stderr-buffer content.

中文

问题

dsh-bash-local 原先把两项因不同原因而变化的能力捆绑在一起:运行一条 bash 命令(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与运行并管理一个子进程(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 DSH_* 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(run.ts)约占整个包的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 DSH_*/CollectedOutput 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的任务注册表:后者的注册存续期刻意长于生产方 fiber。

决策

新的 subprocess/ 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方:

  • @deepseek-ai/dsh-subprocess(Service Definition)——拥有 ctx.subprocess 的抽象 SubprocessRuntime:可执行文件查找、完全显式的普通 spawn,以及可移植执行环境决策新增的终端原语。每条 stdio 流独立选择 'pipe''inherit' 或有界收集 { maxBytes, spill? };stdin 选择 'ignore''pipe'{ data }SubprocessOutcome 只承载刻意不含超时/取消分类的退出事实,收集输出在结算后仍留在句柄上。该 Service Definition 还拥有进程与终端句柄、共享凭据清除,以及 DSH_ENV_PREFIX/DshEnvironment/CollectedOutputargv 绝不经过 shell 解释。
  • @deepseek-ai/dsh-subprocess-local(Service Provider)——LocalSubprocessRuntime 构建在原 run.ts 管道(现为 spawn.ts)与 node-pty 之上:detached 进程组、有界收集与私有 spill 文件、可执行文件查找、前台/会话检查,以及终止每个受管进程并等待其退出的 dispose。terminate() 拥有面向进程树的 TERM→宽限→KILL,waitForExit() 观察进程树存活性,可注入的 taskkill /T 覆盖 Windows。普通与终端 spawn 都先应用 Service Definition 对 KEY/PASSWORD/SECRET/TOKEN 不区分大小写的清除,再合并显式 env。该 Service Provider 没有配置;每项限制都随 spec 到达,Bash 与 PTY 的呈现环境覆盖仍归各自 Consumer 所有。
  • dsh-bash-local(Consumer)——inject: ['subprocess'];把每个解析后的 ShellExecSpec 映射为一个 SubprocessSpawnSpec['bash', '-c', command]),并保留自身配置、resolve() 默认值补全、基于融合 deadline 的 timedOut/aborted 分类、带 [stderr] 标记的后台读取合并及其消费游标,以及 onProcessDone 子类钩子。dsh-bash-sandbox 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。
  • dsh-shell(Service Definition)——把迁走的词汇从 dsh-subprocess 重导出,因此没有任何 bash Consumer 需要改动导入;ShellExecRequest/ShellExecSpec/ShellProcess 与沙箱事实仍归 bash 所有。

每个加载 bash 执行器的组合都同时加载 @deepseek-ai/dsh-subprocess-local:CLI(命令行界面)、各示例、Python 捆绑运行时以及各内联测试配置。

后台进程的存续期从执行器移到了服务:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(服务的 dispose)仍是先终止再等待退出的边界。一条行为约定随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,服务会 reject done,且不缓冲任何内容),因此执行器把 spawn failed: … 提示注入恰好一个 readOutput() 增量。

基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seam:LSP 使用管道化协议流加收集式 stderr 尾部;ACP(Agent Client Protocol)后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯;PTY 使用 spawnTerminal(),同时保留就绪与终端策略。dsh-subagent-subprocess 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn 和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。

曾考虑的替代方案

**把进程管道留在 dsh-bash-local 里(维持现状)。**否决的理由与任务注册表拆分得以落地的理由相同:这条边界既稳定,也早已记录在代码里(run.ts 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。本次变更对用户可见的动因正是这一拆分。

**保留最初只支持批量的接口,让流式消费方继续各自实现。**否决:已观察到的 LSP、ACP 与 PTY 形状表明,这会继续保留重复的私有进程树信号与环境清除。Node 形状的处置方式覆盖这些消费方,又不缓冲管道化流。

**用单个 stdio: 'pipe' | 'inherit' | 'collect' 模式统一全部流。**否决:真实消费方按流混用模式——LSP 使用 pipe/pipe/collect,ACP 使用 pipe/pipe/inherit,Bash 使用 data/collect/collect。

**把每一次进程启动都路由到 ctx.subprocess。**否决:MCP SDK 拥有其传输 spawn,support 启动器则刻意独立于产品 seam。PTY 分配迁到 spawnTerminal(),因为这项底层专用原语归提供方而非消费方所有。

**改把 run_in_background/任务语义放进 subprocess 能力 seam。**否决:那条边界已经存在。ctx.jobs 拥有 id、所有权与通知,bash 工具则把 ShellProcess 适配成任务钩子。subprocess seam 位于 bash 执行器之下,而不是与任务注册表并列。

**把 ENV_OVERRIDES(TERM=dumb、PAGER=cat 等)移入服务。**否决:通用进程服务不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 DSH_* 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。

后果

换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY 与 ACP 消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;进程树信号、升级终止、有界收集、终端机制与凭据清除各自只剩一份实现;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 dsh-subprocess-local 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。

代价是:多出一对包,而且凡加载消费方之处都多一行组合配置;缺少 subprocess 提供方时,消费方会按标准服务注入行为保持挂起。每个后端都要实现可执行文件查找、三种 stdio 模式、进程树生命周期和一个终端原语。迁移词汇的重导出让 dsh-shell 的导入继续可用,但也意味着两个包命名同一批类型;进程 seam 是所有者。spawn 失败提示经由 Bash 的消费式读取游标变为单次交付,不再是可重复读取的 stderr 缓冲内容。