DeepSeek Harness 的子进程管理:起、管、杀这三件事分别归谁
先说清楚一件事:本文所有内容都来自 deepseek-harness 仓库快照 47f9438(版本 0.1.0-rc.5)的源码与文档。该仓库 README 里有一节标题就叫 Developer preview,正文写明项目处于开发者预览阶段、迭代很快,并用加粗全大写写着会有破坏兼容性的变更。所以下面提到的类型名、字段名、默认值,都可能在你读到的时候已经变了,请以仓库最新内容为准。
一个具体的麻烦:孙进程
写过 agent 执行器的人多半踩过这个坑:模型让你跑一条命令,命令超时了,你按住 pid 杀掉,然后发现活儿没干完——那条命令自己又 fork 了一个后台进程,或者你杀的是 sh -c 的壳,真正干活的那个孩子挂到了 init 下面继续跑,还攥着你以为已经释放的端口。更阴的一种:主进程确实退了,但它有个后代继承了同一根 stdout 管道,于是你等的那个 close 事件永远不来,await 就在那儿挂着。
deepseek-harness 把这类问题集中收在一个 seam 里。packages/subprocess 目录下正好只有两个包:subprocess(Service Definition,抽象类 SubprocessRuntime,注册为 ctx.subprocess)和 subprocess-local(本地 Service Provider,LocalSubprocessRuntime)。文档 docs/subsystems/subprocess.md 把消费方也列了出来:bash 执行器家族用收集模式的批量输出,LSP 用原始管道跑 JSON-RPC 分帧,PTY 后端用终端原语,ACP subagent 后端用管道传 ndjson 且让 stderr 走 inherit。
同一套底座喂四种口味的消费方,它是怎么切的?
起:这一层一个默认值都不给
SubprocessSpawnSpec 的 JSDoc 里有一句反复出现的话——this seam applies no defaults。落到字段上就是:argv、cwd、stdio(三条流各自的处置方式)、graceMs 全是必填,signal 和 env 可选。没有「默认收集 1MB 输出」,没有「默认 10 秒超时」。文档自述的理由是让调用方自己的配置决定这些,而不是由一个藏起来的子进程服务默认值决定。
这个决定的代价是每个消费方都得自己填一遍。好处是这些数字变成了你能查到的配置项,而不是埋在库里的常量。想看它们长什么样,去各消费方的包里找:packages/shell/bash-local/src/index.ts 里 graceMs 默认 3_000、maxOutputBytes 默认 64_000、maxSpillBytes 默认 64 * 1024 * 1024;packages/shell/pwsh-local/src/index.ts 的 graceMs 同样是 3_000;packages/lsp/lsp-stdio/src/index.ts 的 killGraceMs 默认 2_000;几个 subagent 包里的 disposeGraceMs 都是 3_000。注意这些是配置默认值,不是「你用起来会怎样」的保证。
起进程之前还有几道明确的拒绝,都写在 packages/subprocess/subprocess-local/src/index.ts 的 resolveExecutable 里:绝对路径要 stat 出是文件并且过 X_OK;裸名字走清洗过的 PATH 逐个候选试;带分隔符的相对路径直接抛错,理由写在抽象方法那一侧(packages/subprocess/subprocess/src/index.ts 里 resolveExecutable 的 JSDoc):解析基准未定义,所以 provider 宁可 fail loud 也不猜。Windows 侧多一段:命令没有扩展名时按 PATHEXT 展开,环境里读不到就退回到硬写的 '.COM;.EXE;.BAT;.CMD';而且 Windows 的环境变量名是大小写不敏感的,environmentValue 专门做了大小写归一的兜底查找。
环境变量的清洗在 packages/subprocess/subprocess/src/index.ts:SENSITIVE_ENV_PATTERN 是 /KEY|PASSWORD|SECRET|TOKEN/i,scrubbedParentEnv() 会把匹配上的名字、以及所有以 DSH_(常量 DSH_ENV_PREFIX)开头的名字,从父环境里剔掉再交给子进程。两条清洗都按大小写不敏感比对。想让某个凭据形状的变量传下去怎么办?spec 上的 env 是在清洗之后合并的,显式写字符串就是有意放行;显式写 undefined 则是一个 tombstone,把普通环境里已有的那条删掉。
还有 graceMs 的校验,在 packages/subprocess/subprocess-local/src/spawn.ts 开头:必须是正的有限数,且不大于 MAX_TIMER_DELAY_MS(packages/util/timeout/src/index.ts 里定义为 2_147_483_647),否则直接抛错。argv[0] 为空同样抛错。另外 argv 在这一层绝不经过 shell 解释——要 shell 语义是 bash 执行器那层的事。
管:输出保的是尾巴,不是头
stdout / stderr 三选一:'pipe' 把裸 Readable 交给调用方(LSP 那种要自己分帧的),'inherit' 直通父进程描述符,或者给一个 SubprocessCollect 对象做有界收集。stdin 三选一:'ignore'、'pipe'、{ data }(写完即关,批量形状)。
收集模式的实现是 spawn.ts 里的 OutputCollector。它保留的是流的尾部:内存超过 maxBytes 就从头上丢整块,块太大就切掉头部若干字节,让保留窗口正好卡在上限。源码注释直接写了理由(文档自述):错误和最终结果都聚在命令输出的末尾。
那被丢掉的头去哪了?如果 collect 里带了 spill,第一次溢出时会懒创建一个 spill 文件,把已经收过的块也补写进去。文件名形如 dsh-subprocess-<pid>-<序号>-<6字节随机十六进制>-<stdout或stderr>.log,用 openSync(..., 'wx', 0o600) 打开——注释写明 'wx' 在任何已存在路径(包括符号链接)上都会失败,配上随机后缀和 owner-only 权限,是为了防止共享 tmp 目录里被人预测路径或提前种符号链接。目录则是 mkdtempSync(join(tmpdir(), 'dsh-subprocess-')) 懒建的每进程私有目录,注释标为 0700。
不带 spill 的形状也有用:语言服务器的 stderr 只想留个诊断尾巴,不希望在盘上留文件。还有一条容易忽略的规则——整条流一旦超过 spill 的 maxBytes,那个已经不完整的 spill 文件会被丢弃并删除,因为它已经不能兑现「完整流」这个承诺了。
读取用的是 readFrom(fromByte),坐标是全流字节偏移,不是游标,所以两个独立读者不会抢走对方的增量。当你要的偏移已经滑出内存窗口,返回值里的 lossy 会是 true,此时给你的是整段保留尾巴,中间的缺口只能去 spill 文件里捞。结算之后 handle.collected 依然能读,批量调用方和流式调用方共用同一条访问路径。
杀:terminate() 是唯一的动词
SubprocessHandle 上只有一个终止动词 terminate(),语义是在进程树范围上执行 SIGTERM → graceMs → SIGKILL 的升级,并且幂等。POSIX 侧 spawn 时带 detached: true,于是有了自己的进程组,信号发给负的 pid(process.kill(-pid, sig)),组不在了就退回直接给子进程发;Windows 侧不 detach,改用 spawnSync('taskkill', ['/PID', String(pid), '/T', '/F']) 按根 pid 带走整棵树。
Windows 这里要单独记一笔:注释写明任何信号值在 Windows 上都是强制终止(Node 把信号映射到 TerminateProcess),文档里也写了「Windows force-terminates immediately」。也就是说你配的那个 graceMs,在 POSIX 上是 SIGTERM 之后的宽限窗口,在 Windows 上并不构成一次「先礼后兵」。同一份配置,两个平台上的含义不一样,这一点看配置项名字是看不出来的。
顺带说清楚:这些机制是进程生命周期管理,不是安全隔离。它管的是「树里的进程别活过句柄」,不代表子进程干了什么就被限制住了。
反直觉的地方:直接子进程死了 ≠ 树死了
这部分是我觉得 spawn.ts 里最值得读的几十行。
terminate() 里挂的那个 SIGKILL 定时器,在结算清理函数 cleanup() 里是故意不清掉的,源码注释写得很直白:leader 死了不等于树死了,所以升级必须能在直接子进程结算之后仍然打到幸存者身上。而真正开火前,kill() 会先调 treeAlive() 复查——一棵已经彻底没了的树不能再被后续一层信号打,因为 pid 可能已经被复用。
treeAlive() 在 Linux 上还多一道:用 process.kill(-pid, 0) 探活会被「只剩没被回收的僵尸」的进程组骗过去,所以在直接子进程结算之后,会调 linuxProcessGroupHasLiveMembers() 扫 /proc,把状态是 Z/X/x 的成员排除掉;全是僵尸就判定为不活。这个函数在 packages/subprocess/subprocess-local/src/process-inspector.ts,读不到 /proc 时返回 undefined,也就是不下结论。
waitForExit() 等的是整棵树,不是直接子进程。轮询节奏是 15 毫秒一次(sleepTick()),定时器保持 ref’d,注释给的理由是:一个被 await 的 teardown 必须把事件循环撑到树真的退出,否则父进程可能一边宣称已经清干净、一边把它答应要收的幸存者变成孤儿。
还有开头说的那个管道挂死:child.on('exit') 之后并不会立刻 settle,而是再挂一个用同一个 graceMs 兜底的定时器;close 先到就正常结算,不到就到点强制结算。这样一个继承了管道的幸存后代,最多把 outcome 拖住一个宽限期,不会无限期挂住。
判因不归它管
SubprocessOutcome 只有两个字段:exitCode 和 signal。它刻意不带超时或取消的分类,done 也只在 spawn 级失败时 reject。这个 seam 只对 abort 信号做出反应(收到就开始终止升级),至于这次退出算超时还是算用户取消,由持有 deadline 的调用方自己判。
对应的判定代码在 packages/shell/bash-local/src/index.ts:timedOut 取决于 timeoutOf(d.signal, 'BASH_TIMEOUT') 是否存在,aborted 则是「signal 已 abort 且不是本执行器的超时」。注释写明只有这个执行器自己的超时原因才算 timedOut,外层 deadline 一律算中止。这就是为什么排查时要去消费方那层看结论,而不是盯着子进程层的返回值。
兜底的两层,以及 ACP 的参考梯子
LocalSubprocessRuntime 自己留了一个 live 句柄集合。正常 dispose 时,disposeManagedProcesses() 对每个句柄先 terminate(),再 await handle.done 之后的 waitForExit()——等的还是整棵树;有失败就补一发同步强杀,然后按失败个数抛出单个错误或 AggregateError。另一层是 process.prependListener('exit', ...):在 Node 的同步退出阶段调 terminateForHostExit(),直接 SIGKILL,不起定时器不等待。这个方法定义在 LocalSubprocessHandle 上,注释写明它故意不出现在公开的 subprocess seam 里。
消费方要自己搭分级清理,仓库里有现成的参考:packages/subagent/subagent-acp/src/run.ts 的 disposeAcpChild() 顺序是——先 child.stdin?.end() 让子进程收到 EOF 自己退(等待窗口来自 disposeEofGraceMs,该包默认 6_000),没退再 terminate(),最后 waitForExit() 不带 bound 地等到真静默。注释解释了最后一步为什么不再套一层派生的 grace:terminate() 自己已经有了有界的 SIGTERM→SIGKILL 定时器,那个无界的等待是「进程属主的退出证明」。
终端是另一套
spawnTerminal() 是这个 seam 里唯一的非管道进程原语,句柄形状也不同:write、inspectForeground、signalForeground,以及一个需要 await 的 terminate()(普通 spawn 的 terminate() 是同步返回的)。packages/subprocess/subprocess-local/src/terminal.ts 里有一处硬拒绝值得记住:当 signalForeground('SIGKILL') 解析出的前台进程组正好就是终端 shell 本身时,直接抛错,消息是拒绝 SIGKILL 终端 shell、请改为终止整个终端会话。提示符检测、就绪推断、scrollback、持久会话归属这些仍然留在 PTY 消费方,普通 spawn() 重建不了控制终端语义。
如果你只想记一句:起进程的所有参数归调用方、进程树的死活归这一层、这次退出算什么原因还是归调用方。中间那段——尾部保留、spill 文件、树范围终止——是它替你兜住的,剩下两头你得自己写。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。