Bash、Subprocess 和 PTY:各管哪段生命周期
对比辨析 Bash Tool、ShellExecutor、SubprocessRuntime 和 PTY Terminal 四个层次的职责边界:Bash Tool 是 agent 面向模型的接口层,ShellExecutor 是命令执行语义层,SubprocessRuntime 是进程原语层,PTY 是终端仿真层。每一层有明确的生命周期所有权和交互契约。
为什么你会混淆这三个概念
当你在 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-bash | PTY 分配、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。它只做一件事:管理操作系统进程树的整个生命周期。
进程原语层的核心契约
- spawn 立即返回:
spawn(spec)同步返回一个SubprocessHandle,包含 pid、piped/collected streams、donepromise 和terminate()方法。 - 树级终止:
terminate()对 POSIX 发送 SIGTERM 到 detached process group(kill(-pid, SIGTERM)),grace 后升级到 SIGKILL。Windows 用taskkill /T /F。 - 环境清洗:
scrubbedParentEnv()从父环境中移除所有匹配KEY|PASSWORD|SECRET|TOKEN的变量和所有DSH_*前缀变量,防止凭据泄露到子进程。 - 有界收集:OutputCollector 保留 in-memory tail(溢出保留尾部),可选 spill file 保存完整流。
- 不分类原因:
SubprocessOutcome只有exitCode和signal,不说”超时”还是”取消”——那是调用者(语义层)的事。
本地实现的 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 中最复杂的状态机之一。它的核心问题是:你写入了一条命令,怎么知道它执行完了?
答案是多重信号融合:
- PROMPT_COMMAND 标记:shell 配置了
PROMPT_COMMAND='printf "\\033]133;D;%s\\007" "$?"',每次 prompt 出现时发送 OSC 序列。 - 前台进程组检测:
inspectForeground()检查当前前台 pgid 是否回到 shell 自身。 - 输出静默超时:如果输出停止超过
idleSilenceMs,推断为 idle。 - 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.ts 的 startSend() 和 pollReadiness() 方法中。
PTY 层不做的事
- 不执行一次性命令:那是
tool-bash+ShellExecutor的路径。 - 不管命令含义:它只管”写入文本 → 等待就绪”。
- 不做输出的 spill file:它用 BoundedTextBuffer 做 scrollback,但没有 spill 机制。
四层对比:谁拥有什么
| 生命周期事件 | 所有者 | 其他层的角色 |
|---|---|---|
参数校验 (command 非空) | tool-bash | — |
| sandbox 升权审批 | tool-bash | sandboxPolicy 提供策略 |
| 超时默认值 & 上限 | ShellExecutor | tool-bash 传入可选值 |
bash -c argv 构造 | ShellExecutor | — |
| 环境变量清洗 | SubprocessRuntime | ShellExecutor 传入 explicit overrides |
| detached process group | SubprocessRuntime | — |
| SIGTERM → SIGKILL 升级 | SubprocessRuntime | ShellExecutor 传入 graceMs |
| 输出 tail-keep + spill | SubprocessRuntime (OutputCollector) | ShellExecutor 传入 maxBytes/maxSpillBytes |
| 超时原因分类 | ShellExecutor (deadline) | SubprocessRuntime 只报告 exitCode/signal |
| PTY 分配 (node-pty) | SubprocessRuntime.spawnTerminal | terminal-bash 请求 |
| prompt 就绪检测 | terminal-bash (LocalPtySession) | SubprocessTerminalHandle 提供 inspectForeground |
| 前台组信号 (Ctrl+C) | terminal-bash | SubprocessTerminalHandle.signalForeground 执行 |
| 后台 job 注册 & polling | tool-bash + ctx.jobs | ShellExecutor.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 的引用。
调试时如何区分层次
场景:命令超时被杀
- tool-bash 收到带
timedOut: true的结果 → 它只负责渲染[exit code: N]标记 - ShellExecutor 的
deadline()触发了 abort signal → 它把BASH_TIMEOUTreason 和aborted区分开 - SubprocessRuntime 收到 abort signal → 调用
terminate()→ SIGTERM → grace → SIGKILL
所以:
- 如果超时阈值不对 → 检查 ShellExecutor 的 config(
timeoutMs、maxTimeoutMs) - 如果进程收到 SIGTERM 后没死 → 检查 SubprocessRuntime 的 graceMs 和 treeAlive 探测
- 如果结果渲染错误(比如 timedOut 标记丢失)→ 检查 tool-bash 的 render 逻辑
场景:后台任务输出丢失
- SubprocessRuntime 的 OutputCollector 只保留 tail(
maxBytes)→ 早期输出被丢弃 - spill file 超过
maxSpillBytes后整个 spill 被删除(discardSpill()) - ShellExecutor 的
readOutput()是增量的(offset-based)→ 如果 polling 间隔太大,中间的输出可能 lossy
→ 全部在 SubprocessRuntime 层。tool-bash 和 ShellExecutor 只是转发。具体来说:OutputCollector.readFrom(offset) 返回 lossy: true 表明你错过了内容;spillPath 字段指向完整输出的磁盘文件(如果 spill 还在的话)。
场景:persistent terminal 卡在 waiting
- LocalPtySession 的
pollReadiness()没有检测到就绪条件 - 可能原因:
PROMPT_COMMAND没生效(shell 不是 bash)、inspectForeground()返回错误的 pgid、输出没停(程序持续打印) - 最终超时后
settleActive('timeout')被触发,返回到目前为止收集的输出
→ 全部在 terminal-bash 层。SubprocessRuntime 只提供底层的 inspectForeground() 和 write()。调试时检查 promptSeen、promptTextSeen、shellPgid 和 lastOutputAt 四个状态变量的值即可定位卡在哪个条件上。
场景:sandbox 拒绝后重试失败
- tool-bash 检查
result.sandbox.denied === true,渲染[sandbox: file access denied] - 模型设置
sandbox_permissions+justification重试 - tool-bash 调用
approveBashEscalation()→ 如果用户拒绝 → 抛出错误 - ShellExecutor 的
resolve()将 approved mode 写入sandboxPolicy字段 - 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() 只收集本次命令的增量输出,而不是整个会话历史。
什么时候你需要区分它们
以下是常见的修改场景,以及你应该去哪一层:
- 扩展新的 shell 后端(比如远程执行):你需要实现
SubprocessRuntime的子类,而不是修改 tool-bash 或 ShellExecutor。远程执行只是把spawn()和spawnTerminal()的实现换成 SSH/容器 API,上层完全不感知。 - 调整超时策略:那是 ShellExecutor 的 config(
timeoutMs、maxTimeoutMs),不是 SubprocessRuntime 的graceMs。graceMs 是 SIGTERM 到 SIGKILL 的等待时间,不是命令允许运行多久。混淆这两个值是最常见的配置错误。 - 添加新的工具参数(比如
stdin输入):修改 tool-bash 的 schema 和 execute 函数,不碰底层。参数在 tool 层校验后作为 request 字段透传。 - 修改输出截断行为:调整 SubprocessRuntime 的 OutputCollector 配置(
maxOutputBytes、maxSpillBytes),这些值通过 ShellExecutor 的 config 传入,最终体现在SubprocessSpawnSpec.stdio的 collect 参数中。 - 改进 PTY 就绪检测:只碰
terminal-bash/session.ts的pollReadiness()逻辑。如果你要添加新的就绪信号(比如 Zsh 的 precmd hook),只需要修改TerminalSanitizer的匹配规则和pollReadiness的判定条件。 - 添加 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 管终端交互,和一次性命令执行没关系。
当你遇到问题时,先判断它属于哪个生命周期阶段,然后直接去那一层找答案。