青雲的博客
深入浅出 DeepSeek Harness 第三部:工具执行——不是调一个函数那么简单 第 18 章

Bash、Subprocess 和 PTY:各管哪段生命周期

对比辨析 Bash Tool、ShellExecutor、SubprocessRuntime 和 PTY Terminal 四个层次的职责边界:Bash Tool 是 agent 面向模型的接口层,ShellExecutor 是命令执行语义层,SubprocessRuntime 是进程原语层,PTY 是终端仿真层。每一层有明确的生命周期所有权和交互契约。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

为什么你会混淆这三个概念

当你在 harness 里执行一条命令,表面上只是”调了 bash”。但如果命令超时被杀,你去哪一层排查?如果后台任务的输出被截断,问题出在哪?如果 persistent terminal 的 prompt 检测卡住,又该看哪个模块?

混淆的根源在于:日常使用中它们总是一起出现。模型调用 bash 工具 → ShellExecutor 执行命令 → SubprocessRuntime 启动进程 → 进程退出后输出被收集返回。这条链路从未断开,所以你很自然地把它们当成一个东西。

但它们各自管理的生命周期段落完全不同:

模块管什么不管什么
接口层tool-bash参数校验、sandbox 升权审批、前台/后台分派、结果渲染如何 spawn 进程
语义层ShellExecutor (bash-local)命令默认值、超时、bash -c 包装、输出收集预算进程树信号、PTY 分配
原语层SubprocessRuntime (subprocess-local)detached spawn、per-stream stdio、SIGTERM→SIGKILL 升级、树级终止命令含义、超时分类
终端层terminal-bashPTY 分配、prompt 就绪检测、scrollback、前台组信号一次性命令执行

下面逐层展开。


概念 A:tool-bash — 模型面对的接口层

tool-bash 是 agent 唯一能看到的 shell 入口。它的职责是把模型的意图翻译成执行请求,并把执行结果翻译回模型能理解的文本

接口层的生命周期所有权

模型调用 bash(command, timeoutMs, workdir, run_in_background, ...)

  ├─ validateBashArgs(): 空命令? 非法超时? escalation 配对?

  ├─ resolveSandboxPolicy() + approveBashEscalation(): 权限审批

  ├─ resolveWorkdir(): 相对路径解析

  ├─ 分支: run_in_background?
  │   ├─ true  → ctx.jobs.start() 注册后台任务,返回 jobId
  │   └─ false → ctx.shell.run(ctx.shell.resolve(request))

  └─ 返回结构化 JSON 或渲染为文本

注意 tool-bash 从不直接接触进程。它通过 ctx.shell(ShellExecutor 服务)完成所有实际执行。一旦进入后台分支,连执行的 ownership 都移交给 ctx.jobs

接口层不做的事

  • 不决定超时上限:它只是把 timeoutMs 传给 ctx.shell.resolve(),由 ShellExecutor 的 clampTimeout 应用配置上限。
  • 不知道进程是怎么 spawn 的bash -c 这个 argv 构造发生在 bash-local,不在 tool 层。
  • 不处理输出截断:截断和 spill 是 SubprocessRuntime 的 OutputCollector 的事。
  • 不管后台进程何时结束:一旦 ctx.jobs.start() 返回 jobId,后续的 output polling 和 kill 都通过 job_output / job_kill 工具。

概念 B:ShellExecutor — 命令执行语义层

ShellExecutor 是一个抽象服务(注册为 ctx.shell),定义了”执行一条 shell 命令”的契约。它的本地实现 LocalBashExecutor 住在 packages/shell/bash-local/。它声明三个核心方法:resolve()(填充默认值)、run()(前台执行,只对基础设施故障 reject)和 start()(后台执行,立即返回 handle)。

请求-规约分离(Request → Spec)

这是语义层最核心的设计模式。tool-bash 传入的是一个请求ShellExecRequest),里面只有 command、可选的 timeoutMs、可选的 workdir。语义层的 resolve() 方法把它变成一个完全指定的规约ShellExecSpec):

// bash-local 的 resolve 逻辑摘要
resolve(request: ShellExecRequest): ShellExecSpec {
  const timeoutMs = clampTimeout(request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs)
  return {
    command: request.command,
    workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
    timeoutMs,
    stdoutMaxBytes: request.stdoutMaxBytes ?? this.config.maxOutputBytes,
    // ... env, dshEnv, sandboxPolicy pass-through
  }
}

语义层的生命周期所有权

前台命令run() 拥有从 spawn 到 exit 的整段生命周期。它构造 deadline(组合 timeout + 上游 abort signal),调用 ctx.subprocess.spawn(),等待 handle.done,然后收集输出并分类退出原因(timedOut vs aborted)。

run(spec)
  ├─ deadline(signal, timeoutMs, 'BASH_TIMEOUT')
  ├─ ctx.subprocess.spawn(spawnSpec)  // 立即返回 handle
  ├─ await handle.done               // 等待进程退出
  ├─ 收集 stdout/stderr (finalOutput)
  └─ 返回 { exitCode, signal, timedOut, aborted, stdout, stderr }

后台命令start() 只负责启动,立即返回一个 ShellProcess handle。这个 handle 提供 readOutput()(增量读)、kill()(终止)和 done(settlement promise)。关键区别:后台命令没有 timeout

语义层不做的事

  • 不分配 PTY:前台命令通过 pipe 收集输出,不需要终端仿真。
  • 不管 SIGTERM/SIGKILL 升级细节:它只调用 subprocess.spawn() 并传入 graceMs,升级逻辑在进程原语层。
  • 不做 prompt 检测:那是 PTY 终端层的事。
  • 不管 job ID 和 polling:后台 handle 给回 tool-bash,后者交给 ctx.jobs

概念 C:SubprocessRuntime — 进程原语层

SubprocessRuntime 是最低层的进程抽象,注册为 ctx.subprocess。它只做一件事:管理操作系统进程树的整个生命周期

进程原语层的核心契约

  1. spawn 立即返回spawn(spec) 同步返回一个 SubprocessHandle,包含 pid、piped/collected streams、done promise 和 terminate() 方法。
  2. 树级终止terminate() 对 POSIX 发送 SIGTERM 到 detached process group(kill(-pid, SIGTERM)),grace 后升级到 SIGKILL。Windows 用 taskkill /T /F
  3. 环境清洗scrubbedParentEnv() 从父环境中移除所有匹配 KEY|PASSWORD|SECRET|TOKEN 的变量和所有 DSH_* 前缀变量,防止凭据泄露到子进程。
  4. 有界收集:OutputCollector 保留 in-memory tail(溢出保留尾部),可选 spill file 保存完整流。
  5. 不分类原因SubprocessOutcome 只有 exitCodesignal,不说”超时”还是”取消”——那是调用者(语义层)的事。

本地实现的 spawn 细节

subprocess-local/spawn.ts 里的 spawnSubprocess() 函数是实际的 fork 点:

const child = spawn(program, args, {
  cwd: spec.cwd,
  env,
  stdio: [ /* per-stream: ignore|pipe|inherit */ ],
  detached: platform !== 'win32',  // POSIX: 新进程组
})

SIGTERM → grace → SIGKILL 升级序列

terminate() 被调用
  ├─ 检查 treeExitObserved? → 是则 noop
  ├─ 启动 observeTreeExit() 轮询
  ├─ kill('SIGTERM') → signalTree(platform, pid, 'SIGTERM', child, taskkill)
  │   └─ POSIX: process.kill(-pid, 'SIGTERM')
  │   └─ 失败时 fallback: child.kill('SIGTERM')
  └─ setTimeout(graceMs) → kill('SIGKILL')
      └─ 再次检查 treeAlive() 后才真正发信号

树存活性检测(treeAlive)用 process.kill(-pid, 0) 探测 POSIX group 是否存在,并在 Linux 上通过 /proc 检查是否只剩 zombie。

spawnTerminal — 另一条路径

SubprocessRuntime 还提供 spawnTerminal() 方法,这是 PTY 终端层的入口:

abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>

它返回的 SubprocessTerminalHandle 提供 write()inspectForeground()signalForeground()terminate() — 和普通 SubprocessHandle 完全不同的接口,因为终端进程需要交互式 I/O 而非 batch 收集。


概念 D:terminal-bash — PTY 终端仿真层

terminal-bash 是 persistent shell 的后端,注册为 ctx.terminals 的一个 backend type。它和前面三层不在同一条调用链上——它是另一条并行路径,用于需要状态持久化的交互式 shell。

PTY 层的生命周期所有权

BashTerminalBackend.spawn(spec)
  ├─ ensureSandboxModeFence(): 防止 PTY 存活期间切换 sandbox mode
  ├─ sandboxPolicy.resolve() + spawnArgv(): 可能 confine
  ├─ ctx.subprocess.spawnTerminal({argv, cwd, env, rows, cols, graceMs})
  │   └─ 返回 SubprocessTerminalHandle (node-pty 包装)
  ├─ new LocalPtySession(terminal, config)
  └─ initializeSession(): 等待 shell 首次 prompt 出现

LocalPtySession — 就绪检测的复杂性

LocalPtySession 是整个 harness 中最复杂的状态机之一。它的核心问题是:你写入了一条命令,怎么知道它执行完了?

答案是多重信号融合:

  1. PROMPT_COMMAND 标记:shell 配置了 PROMPT_COMMAND='printf "\\033]133;D;%s\\007" "$?"',每次 prompt 出现时发送 OSC 序列。
  2. 前台进程组检测inspectForeground() 检查当前前台 pgid 是否回到 shell 自身。
  3. 输出静默超时:如果输出停止超过 idleSilenceMs,推断为 idle。
  4. stdin 等待检测inputWaiting 标志表明前台进程正在等待终端输入。
startSend(request)
  ├─ inspectForeground() → 记录初始前台组
  ├─ terminal.write(text + '\r')
  ├─ 启动 pollReadiness() 定时器
  │   └─ 每 pollIntervalMs 检查:
  │       ├─ promptSeen + promptTextSeen + idle + shellPgid 匹配 → 'stdin_read'
  │       ├─ elapsed >= exactProbeAfterMs + inputWaiting → 'stdin_read'
  │       └─ idle >= idleSilenceMs → 'inferred_idle'
  └─ deadline timer → 'timeout'

这套状态机的完整实现在 packages/terminal/terminal-bash/src/session.tsstartSend()pollReadiness() 方法中。

PTY 层不做的事

  • 不执行一次性命令:那是 tool-bash + ShellExecutor 的路径。
  • 不管命令含义:它只管”写入文本 → 等待就绪”。
  • 不做输出的 spill file:它用 BoundedTextBuffer 做 scrollback,但没有 spill 机制。

四层对比:谁拥有什么

生命周期事件所有者其他层的角色
参数校验 (command 非空)tool-bash
sandbox 升权审批tool-bashsandboxPolicy 提供策略
超时默认值 & 上限ShellExecutortool-bash 传入可选值
bash -c argv 构造ShellExecutor
环境变量清洗SubprocessRuntimeShellExecutor 传入 explicit overrides
detached process groupSubprocessRuntime
SIGTERM → SIGKILL 升级SubprocessRuntimeShellExecutor 传入 graceMs
输出 tail-keep + spillSubprocessRuntime (OutputCollector)ShellExecutor 传入 maxBytes/maxSpillBytes
超时原因分类ShellExecutor (deadline)SubprocessRuntime 只报告 exitCode/signal
PTY 分配 (node-pty)SubprocessRuntime.spawnTerminalterminal-bash 请求
prompt 就绪检测terminal-bash (LocalPtySession)SubprocessTerminalHandle 提供 inspectForeground
前台组信号 (Ctrl+C)terminal-bashSubprocessTerminalHandle.signalForeground 执行
后台 job 注册 & pollingtool-bash + ctx.jobsShellExecutor.start 提供 handle

关键交互边界

边界 1:tool-bash → ShellExecutor

调用方式:ctx.shell.run(ctx.shell.resolve(request))

tool-bash 必须 先调 resolve() 再调 run()/start()。这保证了 ShellExecutor 的配置(timeout caps、default cwd)始终生效,无论 tool 传了什么。

边界 2:ShellExecutor → SubprocessRuntime

调用方式:ctx.subprocess.spawn(spawnSpec)

ShellExecutor 构造完整的 SubprocessSpawnSpec(argv、cwd、stdio dispositions、graceMs、signal、env),SubprocessRuntime 不做任何默认值填充——“this seam applies no defaults”是 subprocess 的核心设计原则。

边界 3:terminal-bash → SubprocessRuntime.spawnTerminal

调用方式:ctx.subprocess.spawnTerminal(spec)

这是和 spawn() 完全不同的路径。spawnTerminal 返回的 handle 有 write()inspectForeground()signalForeground() — 这些在普通 SubprocessHandle 上不存在。原因很简单:普通进程用 pipe 通信,终端进程用 PTY 通信。

边界 4:后台任务分离

tool-bash 调用 ctx.jobs.start({
  run: () => {
    const proc = ctx.shell.start(ctx.shell.resolve(request))
    return {
      cancel: () => void proc.kill(),
      done: proc.done.then(() => processOutcome(proc)),
      readOutput: () => renderProcessRead(proc.readOutput(), ...),
    }
  }
})

一旦 jobs.start() 返回 jobId,tool-bash 的执行就结束了。后续的进程生命周期完全由 jobs 系统管理。tool-bash 甚至不持有 proc handle 的引用。


调试时如何区分层次

场景:命令超时被杀

  1. tool-bash 收到带 timedOut: true 的结果 → 它只负责渲染 [exit code: N] 标记
  2. ShellExecutordeadline() 触发了 abort signal → 它把 BASH_TIMEOUT reason 和 aborted 区分开
  3. SubprocessRuntime 收到 abort signal → 调用 terminate() → SIGTERM → grace → SIGKILL

所以:

  • 如果超时阈值不对 → 检查 ShellExecutor 的 config(timeoutMsmaxTimeoutMs
  • 如果进程收到 SIGTERM 后没死 → 检查 SubprocessRuntime 的 graceMs 和 treeAlive 探测
  • 如果结果渲染错误(比如 timedOut 标记丢失)→ 检查 tool-bash 的 render 逻辑

场景:后台任务输出丢失

  1. SubprocessRuntime 的 OutputCollector 只保留 tail(maxBytes)→ 早期输出被丢弃
  2. spill file 超过 maxSpillBytes 后整个 spill 被删除(discardSpill()
  3. ShellExecutorreadOutput() 是增量的(offset-based)→ 如果 polling 间隔太大,中间的输出可能 lossy

→ 全部在 SubprocessRuntime 层。tool-bash 和 ShellExecutor 只是转发。具体来说:OutputCollector.readFrom(offset) 返回 lossy: true 表明你错过了内容;spillPath 字段指向完整输出的磁盘文件(如果 spill 还在的话)。

场景:persistent terminal 卡在 waiting

  1. LocalPtySessionpollReadiness() 没有检测到就绪条件
  2. 可能原因:PROMPT_COMMAND 没生效(shell 不是 bash)、inspectForeground() 返回错误的 pgid、输出没停(程序持续打印)
  3. 最终超时后 settleActive('timeout') 被触发,返回到目前为止收集的输出

→ 全部在 terminal-bash 层。SubprocessRuntime 只提供底层的 inspectForeground()write()。调试时检查 promptSeenpromptTextSeenshellPgidlastOutputAt 四个状态变量的值即可定位卡在哪个条件上。

场景:sandbox 拒绝后重试失败

  1. tool-bash 检查 result.sandbox.denied === true,渲染 [sandbox: file access denied]
  2. 模型设置 sandbox_permissions + justification 重试
  3. tool-bash 调用 approveBashEscalation() → 如果用户拒绝 → 抛出错误
  4. ShellExecutorresolve() 将 approved mode 写入 sandboxPolicy 字段
  5. SubprocessRuntime spawn 时不感知 sandbox(sandbox runner 对它来说只是 argv 的一部分)

→ 审批在 tool-bash 层,confinement 在 ShellExecutor 层,执行在 SubprocessRuntime 层。


两条执行路径的对比

路径 A:一次性命令(tool-bash 前台/后台)
  tool-bash → ShellExecutor.resolve() → ShellExecutor.run/start()
    → SubprocessRuntime.spawn() → bash -c "command"
    → pipe stdio → OutputCollector → 收集完毕返回

路径 B:持久终端(tool-terminal)
  tool-terminal → ctx.terminals → BashTerminalBackend.spawn()
    → SubprocessRuntime.spawnTerminal() → node-pty
    → LocalPtySession → write/pollReadiness/read
    → 会话持续存活直到 close()

路径 A 和路径 B 共享 SubprocessRuntime 层,但使用完全不同的方法(spawn vs spawnTerminal),返回完全不同的 handle 类型,拥有完全不同的生命周期模型:

  • 路径 A 的进程是一次性的:执行完毕即死亡,输出一次性收集。没有交互,没有状态持久化。
  • 路径 B 的进程是持久的:shell 活着直到被 close(),每次 send 是一个交互回合。CWD、环境变量、shell 函数在回合间保留。

两条路径的 stdio 模型对比

路径 A 使用 collect mode:stdout 和 stderr 各自有一个 OutputCollector,在 in-memory 中保留尾部 maxBytes,溢出部分写入 spill file。进程退出后一次性读取 readFrom(0) 获得最终文本。这是为 batch 命令设计的——你不需要实时看到输出。

路径 B 使用 PTY stream:一个 PassThrough 流承载所有终端输出(stdout/stderr 在 PTY 中合并),LocalPtySession 通过 TerminalSanitizer 剥离 ANSI 转义序列后追加到 BoundedTextBuffer。每次 startSend() 只收集本次命令的增量输出,而不是整个会话历史。


什么时候你需要区分它们

以下是常见的修改场景,以及你应该去哪一层:

  1. 扩展新的 shell 后端(比如远程执行):你需要实现 SubprocessRuntime 的子类,而不是修改 tool-bash 或 ShellExecutor。远程执行只是把 spawn()spawnTerminal() 的实现换成 SSH/容器 API,上层完全不感知。
  2. 调整超时策略:那是 ShellExecutor 的 config(timeoutMsmaxTimeoutMs),不是 SubprocessRuntime 的 graceMs。graceMs 是 SIGTERM 到 SIGKILL 的等待时间,不是命令允许运行多久。混淆这两个值是最常见的配置错误。
  3. 添加新的工具参数(比如 stdin 输入):修改 tool-bash 的 schema 和 execute 函数,不碰底层。参数在 tool 层校验后作为 request 字段透传。
  4. 修改输出截断行为:调整 SubprocessRuntime 的 OutputCollector 配置(maxOutputBytesmaxSpillBytes),这些值通过 ShellExecutor 的 config 传入,最终体现在 SubprocessSpawnSpec.stdio 的 collect 参数中。
  5. 改进 PTY 就绪检测:只碰 terminal-bash/session.tspollReadiness() 逻辑。如果你要添加新的就绪信号(比如 Zsh 的 precmd hook),只需要修改 TerminalSanitizer 的匹配规则和 pollReadiness 的判定条件。
  6. 添加 sandbox confinement:在 ShellExecutor 的子类(如 SandboxBashExecutor)中重写 resolve()runArgv() 把原始 argv 包装到 sandbox runner 里。SubprocessRuntime 看到的是已经包装好的 argv,它不知道也不关心 sandbox 的存在。

收口

┌──────────────────────────────────────────────────────┐
│  tool-bash (接口层)                                   │
│  "模型说了什么" → 校验 → 审批 → 分派                    │
└────────────────────────┬─────────────────────────────┘
                         │ ctx.shell.run/start
┌────────────────────────▼─────────────────────────────┐
│  ShellExecutor / bash-local (语义层)                   │
│  request → spec → deadline → spawn → classify exit    │
└────────────────────────┬─────────────────────────────┘
                         │ ctx.subprocess.spawn
┌────────────────────────▼─────────────────────────────┐
│  SubprocessRuntime / subprocess-local (原语层)          │
│  detached spawn → collect/pipe → TERM→grace→KILL      │
└──────────────────────────────────────────────────────┘

                    ┌──────────────────────────────────┐
                    │  terminal-bash (终端层)            │
                    │  PTY 分配 → write → poll ready    │
                    │  通过 ctx.subprocess.spawnTerminal │
                    └──────────────────────────────────┘

最后落到一条原则:每一层只做自己命名所暗示的那件事tool-bash 是工具,不是执行器。ShellExecutor 执行命令,不管进程树。SubprocessRuntime 管进程树,不理解命令含义。terminal-bash 管终端交互,和一次性命令执行没关系。

当你遇到问题时,先判断它属于哪个生命周期阶段,然后直接去那一层找答案。