DSHarness 系统拆解 固定基线 47f943859b · 36 已复核 / 0 撰写中 / 36 章
English
执行环境·第 21 章

Shell、PTY、Terminal 与后台作业

短命进程、持久会话、输出留存和生命周期

已复核上游 47f943859b范围: 追踪 subprocess、shell executor、terminal ownership、job registry、spill、signal 和 kill escalation。

结论:这里不是一个 Shell,而是四个相互正交的生命周期系统

DeepSeek Harness 把“运行命令”拆成四层:ctx.subprocess 管执行世界、进程树与字节流;ctx.shell 把一次性命令归一化为前台结果或后台进程;ctx.jobs 给不同生产者提供统一的后台身份、所有权和完成通知;ctx.terminals 则管理跨调用存活的 PTY 会话。工具只是这些 seam 的模型界面,不拥有底层资源。

这种拆分真正解决的是生命周期,而不是命令语法:谁能看见资源、取消何时算完成、输出丢失怎样显式表达、Agent 销毁时谁负责收尸。代价是部署者必须同时理解 composition、owner scope 和 provider 能力;仅看到一个 bash 工具名,无法判断它是一次性进程还是持久 Shell。

1. 四层拓扑:执行、命令、后台控制与交互会话

模型工具
  bash / pwsh ───────────────→ ctx.shell ──────→ ctx.subprocess.spawn
       └─ run_in_background ─→ ctx.jobs ───────┘

  terminal_* ────────────────→ ctx.terminals ─→ PTY backend
       └─ background send ───→ ctx.jobs             └→ ctx.subprocess.spawnTerminal

  persistent bash wrapper ───→ ctx.terminals(每个 Agent 复用一个 PTY)
底层合同

SubprocessRuntime 是 shipped Bash terminal backend 与普通进程的共享 substrate;它要求 provider 在同一执行世界中解析 executable、立即返回 live handle、定义树级终止合同,并在服务销毁时回收仍存活的资源。TerminalBackend 本身仍是可替换接口,其他 backend 可以自带不同 substrate。SHELL-SUBPROCESS-SEAM

ctx.shell 刻意不知道 Session 与 Job ID;后台注册、轮询和通知属于 Job 层。反过来,Job Registry 不知道命令、PTY 或 subagent 的实现,只接受生产者给出的 cancel/done/readOutput hooks。

2. 发布配置选择两种完全不同的 bash

Preset模型看到的 Bash状态后台控制
Standarddsh-tool-bash;Windows 换为 pwsh每次调用新进程run_in_background + 三个 job_* 工具
Minimaldsh-tool-bash-persistent每个 exact Agent 复用一个 PTY命令本身串行;长任务由 Shell 内部后台化
Terminal 工具包六个 terminal_*显式创建、发送、读取、signal、列出、关闭terminal_send 可接入 Job Registry
真实装配

Standard preset 在非 Windows 注册一次性 Bash、在 Windows 注册 PowerShell,并挂载 Job controls;Minimal preset 则隔离自己的 Terminal service/backend,只暴露持久 Bash 与编辑器。Standard 不暴露六个 Terminal 工具,Minimal 也不把 PTY lifecycle 直接交给模型。SHELL-STANDARD-COMPOSITIONSHELL-MINIMAL-COMPOSITION

3. Subprocess seam 拒绝隐式默认:每一项执行条件都由 caller 给出

SubprocessSpawnSpec 明确携带 argvcwd、三条 stdio disposition、cleanup grace、AbortSignal 和环境 overlay;provider 不替调用者猜 Shell、目录、缓冲上限或 deadline。argv[0] 直接作为 executable,substrate 本身不做 Shell 解释。

结果语义

done 只为 spawn-level failure reject;进程启动后的 exit code/signal 是普通 outcome。Collected stream 用调用者持有的 byte offset 读取,多个 reader 不会互相消费;raw pipe 则完全交给上层。SHELL-SPAWN-CONTRACT

这使本地执行与远端执行可以替换而不改工具合同:provider 可以改变 executable lookup、进程/PTY transport 与文件系统世界,但调用者仍决定策略和呈现。

4. 子进程环境先清洗,再显式重建当前 Harness 身份

process.env
  ├─ 删除名称匹配 KEY|PASSWORD|SECRET|TOKEN 的变量
  ├─ 删除全部 DSH_*(大小写不敏感)
  └─ 保留 PATH / HOME / locale / proxy 等
        ↓
调用者 env overlay(string 恢复;undefined 删除)
        ↓
Shell tool 每次从 ctx.shellEnv 注入当前 DSH_* snapshot
防止陈旧身份泄漏

清洗函数是普通 process 与 PTY backend 共用的单一定义;显式 overlay 在清洗后合并,因此部署仍可有意识地转发 credential-shaped 或 DSH_* 值。SHELL-ENV-SCRUB

受管变量

ctx.shellEnv 每次调用重建、排序并冻结 DSH_HOMEDSH_SHELL=1、Agent 的 DSH_SESSION_ID,以及后端可定位时的 DSH_SESSION_JSONL;贡献者必须预声明 key,重复 owner 或运行时返回未声明 key 会 fail loud。SHELL-MANAGED-ENV

5. 长输出优先保留尾部;完整 Spill 只有在仍可信时才发布

1

内存有界

Collector 逐字节计数,超限后从头删除,精确保留最后 maxBytes

2

首次溢出建 Spill

私有临时目录、随机文件名、exclusive create、0600 权限,并补写先前 chunks。

3

完整性有上限

总流超过 spill cap 后删除 spill;它不再能代表完整输出。

4

运行中可读,settle 后定稿

仍完整时可以返回当前 spill path;进程 settle 且 seal 成功后,它才代表最终完整输出。

可观测丢失

Offset reader 返回 lossy、下一 offset 与仍完整时的 spill path;进程运行期间该路径表示截至当下的完整流,settlement seal 成功后才可视为最终文件。请求的 offset 已滑出内存窗口时,返回的是整个保留尾部,而不是伪装成连续 delta。SHELL-OUTPUT-SPILL

“保留尾部”是诊断取向:错误和总结常在输出末端;spill 才承担完整取回。模型结果还会再经过工具级渲染上限,因此 substrate 可恢复并不等于该次模型上下文包含全部字节。

6. 一次性 Shell 把非零退出、超时与取消当成事实,而不是基础设施异常

情况ctx.shell.run模型工具
exit 0 / 非零resolve ShellRunResult输出;非零追加 exit marker
Executor timeoutresolve,timedOut:true显示 timeout/partial output
Tool AbortSignal 首先触发resolve,aborted:true转成稳定的 tool aborted error
无法 spawn / provider failurereject(前台)归一化 execution error
Service contract

Executor 的 resolve 拥有 defaults/caps;前台 run 只有 infrastructure failure reject。后台 start 不施加 timeout,spawn failure 也被收敛成 killed handle 和 stderr,避免 Job cleanup 因 rejected done 悬挂。SHELL-EXECUTOR-CONTRACT

7. 后台切换不是“不要 await”,而是一次原子所有权转移

bash(args)
  → 参数 / escalation / workdir / shellEnv 全部解析
  → 检查 tool-call signal 尚未 abort
  → jobs.start({ owner, kind, label, run })
       ├─ controller / owner / capacity preflight
       ├─ run() 真正 spawn
       ├─ 注册 record 与 JobId
       └─ Job runtime 接管 cancel / done / output
  → 返回 job id
Detached cancellation boundary

Bash 工具在 jobs.start 之前保留 tool-call cancellation;Job ID 发布后,不再把 exec.signal 传给进程。之后只有 job_kill 或 owner/service teardown 终止它。非零后台命令是 completed 并在 detail 报 exit code,不是 registry-level failedSHELL-BASH-TOOLSHELL-BACKGROUND-OUTCOME

8. Job Registry 是 producer-neutral 的 process-local 控制面

一个 Job 由 kind、label、可选 exact-Agent owner、输出 cap 与三个 hook 组成:producer 在 run() 中返回 cancel、永不 reject 的 done、可选 consuming readOutput;registry 负责 ID、状态、访问控制、waiter、通知与销毁。

先检查,再开工

本地 registry 在调用 producer 前验证 scoped controller 可达、kind/label/output limit、owner 正在注册表中,以及每 owner 活跃数未达到默认 10 的上限;没有队列。只有 preflight 全部成功才调用 run() 并分配 <kind>-NSHELL-JOBS-START

隔离与 first-wins

Owned Job 只允许同 Session ID caller 读取/取消;监听器又按 exact owner 的 scope chain 路由。Settlement first-wins,先提交终态、释放 waiter、通知 UI observer,最后才发完成 listener,防止 reporter 开新 turn 时其他观察者仍看见旧状态。SHELL-JOBS-SETTLEMENT

9. Job 工具区分“请求取消”和“已经停止”

工具读取/突变关键语义
job_output流式 producer 消费下一段;final-output producer settle 后取最终值wait:true 超时只返回 running,不杀 Job
job_list列出 caller 可见的 live 与 settled recordsregistry 不在 settle 时自动删除
job_kill立即调用 cancel hook返回 cancellation-requested;真正终态等待 producer settle
模型控制面

三工具共享 producer 提供的输出上限;job_output 自己管理 wait deadline,避免将正常的“仍在运行”映射成 tool timeout。Kill 后取的是非消费 snapshot,因此不会意外吃掉剩余输出。SHELL-JOB-TOOLS

10. 完成通知会选择当前 Turn 的 inbox 或新 Follow-up Turn

Job settle 且尚未 reported
  ├─ owner busy                         → owner.inject(notice)
  ├─ owner idle + wake budget 尚有余额 → owner.followup(notice)
  └─ quiet mode / budget耗尽           → owner.inject(notice)
防止自激循环

默认 wake budget 是每 exact Agent 连续 3 个由 Job completion 打开的 Turn;只有真正 user-authored message 被 claim 才重置。这样“完成通知触发新任务,新任务又完成并唤醒”不会无限购买模型请求。SHELL-JOB-NOTICES

11. Job 生命周期跟 exact Agent 对象绑定,而不只是可复用的 Session 字符串

Owner cleanup 被挂到 exact Agent scope;销毁时 registry 先对 owned Jobs 请求取消,等待每个 settlement,最后删除 records 并通知镜像。服务整体销毁也执行同一套 quiescence。

失败边界

如果 cancel hook 抛错,registry 会强制把 record 标成 failed,并明确记录工作可能 orphan;如果 cancel 返回但生产者永不 settle,teardown 无法区分“慢停”与“取消无效”,会一直等待。SHELL-JOBS-CLEANUP

Web 的 Job 列表只是 Host 推送的只读镜像:进程重启后清空,历史 transcript 中的启动卡仍然存在。它显示“曾经启动”和“当前仍有 live control”是两种不同事实。SHELL-JOBS-UI

12. Terminal service 发布的是 exact-owner 会话,不是可猜中的全局 PTY

1

预留

验证 live owner、backend 与 owner-local name,预留 name 和 pending spawn。

2

初始化

Backend 分配 PTY 并完成初始化;期间 owner/service cancellation 可中止。

3

发布

再次检查 service 与 exact owner,才把 pty-N 放入 registry。

4

回滚

失败时 close 未发布 session;清理也失败则保留/聚合错误,而不是静默泄漏。

访问与互斥

read/signal/kill/list 全部要求 exact owner;同一 PTY 同时最多一个 active send。Close 只有 backend 真正静止后才删除 record;close 失败会清除 closing fence,允许调用者重试。SHELL-TERMINAL-SERVICE

13. terminal_send 的返回表示“何时适合再次思考”,不等于命令退出

waitReason证据不能推出
stdin_read受控 prompt + Shell 前台组,或 foreground syscall probe 显示等待输入整个 Session 已退出
inferred_idle有输出后经历配置的 silence 窗口前台进程已完成
timeout本次 send 等待 deadline 到达进程已被杀;Session 已退出
session_exit顶层 PTY process outcome 已到达此前的 idle 推断等同于 exit
Readiness algorithm

Local PTY backend 结合受控 prompt、foreground process-group、Linux exact stdin-wait probe、输出静默与绝对 timeout;prompt 只有在 shell 重新拥有前台组后才被接受。取消 active send 会真正向 foreground group 发送 SIGINT,并持有 exclusive slot 直到 signal path settle。SHELL-PTY-READINESS

14. 六个 Terminal 工具暴露资源;Persistent Bash 则隐藏资源

界面模型责任Runtime 责任
terminal_open/send/read/signal/list/close保存 Session ID、理解 readiness、最终 closeOwner fence、scrollback、signal、quiescent cleanup
Persistent bash(command)只提交命令每 owner 缓存 PTY、串行调用、marker 抽取 exit、timeout/abort 后 reset
一次性 bash/pwsh每次给完整 command/workdir新进程、timeout、bounded output、可选 Job detach
显式 Terminal 工具

六工具都要求调用 Agent;后台 terminal_send 先占用 PTY 的 exclusive send,再作为 pty-send Job 暴露。Job cancel 转成 operation cancel/SIGINT,输出和结果受统一 byte cap。SHELL-TERMINAL-TOOLS

隐藏式 Persistent Bash

Wrapper 用随机 start/end marker 包裹每条命令,按 owner 串行读取 scrollback;timeout、abort、send failure 或 shell exit 会关闭不确定会话,下次从 workspace 新建,而不是冒险复用污染状态。SHELL-PERSISTENT-BASH

15. Kill 面向进程树,但静止证明因平台而异

普通本地进程在 POSIX 以 detached process group 为树根,先 TERM、等待 grace、再 KILL,并用 group liveness 观察静止;Windows 调用 taskkill /T /F 请求树级终止,但把 direct-child exit 当作可观察完成代理,没有独立的 descendant/group liveness probe。

普通进程树

POSIX 实现会在 direct child 关闭后继续探测 group liveness,避免 TERM-trapping helper 被遗漏;确认 group 消失后才停止 escalation。Windows 的保障更弱:终止动作包含 /T,而等待边界仍是 root process exit,不能据此声称独立证明整棵树已经静止。SHELL-PROCESS-TREE

PTY 清理

PTY backend 用 PID + start identity 跟踪后代,先清后代、再关 shell、再扫描后代;向当前 shell 本身发 SIGKILL 会被拒绝,调用者应关闭 Terminal session。若仍有 survivor,close 明确失败。SHELL-PTY-CLEANUP

16. 跨平台与远端替换发生在 substrate/provider,不应渗进工具合同

Bash 与 PowerShell 工具共享 ctx.shell、Job、审批和 managed env 语义;差异留在 executable、启动 flags、编码与 sandbox runner。E2B provider 则替换 ctx.subprocess 的 executable lookup、普通进程和 Terminal transport,让上层 Shell/PTY consumer 继续使用同一 seam。SHELL-E2B-SEAM

设计评价

这是比“每个工具自己调用 child_process”更强的抽象边界:安全环境、进程树、输出和 PTY transport 可以集中替换。然而跨平台 parity 仍依赖每个具体 tool provider 是否真正复用共享 helper;公共 Service Definition 只是合同,不是自动证明。

17. 固定基线上的三个静态审计发现

发现影响判定
shellEnv.list() 不列出 registry 自己拥有的三个 built-ins诊断/UI 若把 list 当全集,会漏报实际注入变量源码 TODO 与 README 均明确记录
PowerShell README 仍声明旧 inject:tools,bash,systemPrompt,bashEnv扩展作者照抄会引用不存在/过时 seam当前源码实际为 tools,shell,systemPrompt,shellEnv
PowerShell 的默认 cwd 仍直接取 raw Session header,而 Bash 会 canonicalize/优先 policy workspace root跨平台同一 Session 的路径身份可能不完全同构README 将其列为已知 parity gap
可复核漂移

这些结论来自当前生产源码与相邻官方 README 的直接对照,不是运行推断。SHELL-ENV-LIST-GAPSHELL-PWSH-DOC-DRIFTSHELL-PWSH-INJECTSHELL-PWSH-CWD-GAPSHELL-BASH-CWD

18. 保证矩阵:什么是耐久事实,什么只是当前进程的控制状态

对象跨调用跨 Agent跨进程重启输出读取
前台一次性 ShellN/A结果写入 Session 后可重放
Background JobOwned Job 否stream 为单消费;结果/通知进入历史后耐久
Explicit Terminal否,exact ownerbounded scrollback 分页
Persistent Bash是,同一 Agentmarker 定界的一次命令结果
Spill file同一进程/文件存在期间由路径与主机权限决定不是 Session durability仅完整且 seal 成功时发布
最终评价

最成熟的部分是把“已经发出取消”与“资源已经静止”、把“等待返回”与“命令退出”、把“历史中有启动卡”与“当前仍有控制 handle”严格分开。最大的认知成本也来自这里:Process、Job、Terminal、Turn 各有自己的状态机。正确的运维和 UI 必须显示这些状态,而不能把它们压扁成 running/done 两个词。

验证状态

除固定 commit 的源码、配置、README 与测试控制流审计外,本次交付运行了覆盖审批、preset、扩权、文件、进程、Job 与 Terminal 的 15 个 targeted Vitest 文件:508 项通过,1 项跳过。平台差异与本章静态 findings 仍按证据边界单独陈述。

我的学习体会

内容仅自动保存到当前浏览器,不上传、不进入仓库。你可以导出 Markdown 自行归档。