dsh 命令执行三层:Subprocess / Shell / Terminal
dsh把"执行命令"做成三层叠加的抽象:ctx.subprocess是不带语义的底层坐标,只给原始进程事实;ctx.shell在上面加出 bash 执行语义(超时、中止分类、沙箱、有界批输出);ctx.terminals再往上加出持久交互式 PTY(就绪推断、滚动缓冲、独占发送、跨重载存活)。下层不依赖上层,上层在下层之上叠语义,选错层就会要么累要么漏。 判断口诀:跑一次性命令用 shell,起协议后端用 subprocess,要你来我回交互的会话用 terminal。还有一个容易漏的纪律:进程的退出事实、超时事实、中止事实是三个正交的独立报告,一个进程可以既超时又 exit 0,只看退出码会把截断的运行读成成功。
为什么要分三层
让 agent 跑命令,最朴素的想法就是调操作系统的 exec:一句话跑命令,拿回输出。但这种"一句话"在真实场景里会立刻分成三种截然不同的需求。
只想跑一条 bash 命令,拿回输出,超时杀掉,这叫批处理。要起一个语言服务器,和它通过 JSON-RPC 管道长期通信,这叫协议后端。要起一个交互式 shell,先跑几条命令,看输出,再根据输出发下一条输入,会话还得跨工具重载活着,这叫持久交互。
三种需求对进程的期待几乎相反。批处理要的是"跑完给结果,加超时加截断";协议后端要的是"原始管道别加工,我自己解析协议",你给它加个超时反而是杀死长连接;持久交互要的是伪终端、就绪推断、滚动缓冲,前两者压根没有这些概念。塞进一个 API,结果就是一个啥都能做但啥都做不好的万能接口,每个消费者都在为自己的场景关掉一堆不想要的默认行为。
dsh 的做法是分三层,每层一个接缝:
| 层 | 接缝 | 加了什么语义 |
|---|---|---|
| 底层坐标 | ctx.subprocess |
不加语义,只给原始进程事实 |
| bash 执行器 | ctx.shell |
超时、中止分类、沙箱、批输出 |
| 持久 PTY | ctx.terminals |
就绪推断、滚动缓冲、独占发送、跨重载存活 |
换执行世界(本地、沙箱、远程)时,三层跟着同一个世界走,因为它们共享同一个 subprocess 地基。
底层坐标:ctx.subprocess
ctx.subprocess 是执行的地基,它的关键品格是不加任何语义,也不设任何默认值。
完全显式的 spawn 规格
spawn 的请求里,每个流的处置、每个上限、每个目录都要显式写。接缝一个默认值都不补,这是刻意的:默认值会在不同后端上长出不同行为,显式写清楚,行为就只取决于写下的东西。
argv 在这一层绝不经过 shell 解释,第零项就是程序本身。这条规矩把注入面收掉了:想拼一段 shell 文本进来执行,这里没有入口,要 shell 语义就去上一层。
每个流的处置按消费者需求逐流选。stdin 有三种:接到 /dev/null(忽略);暴露成可写的管道(协议后端持续写请求用);或者给一段字节,写完就关(批处理形态)。stdout 和 stderr 有三种:原始管道(LSP 的 JSON-RPC、ACP 的 ndjson 用这个);透传父进程的描述符(诊断用);或者有界内存收集。
收集模式有个值得记住的细节:内存上限溢出后保留的是尾部,可选的 spill 把完整流落到文件。省略 spill 是诊断尾部的形态,比如语言服务器的 stderr,缓冲看不看随你,但不留文件;带上 spill 是可恢复的形态,完整流在上限内随时能读回来。溢出发生在 spill 之外,不完整的 spill 文件会被丢弃,不会拿一份残缺的"完整流"骗人。
DSH_* 环境命名空间
这个接缝还拥有 DSH_* 开头的环境变量命名空间和共享的凭证擦洗。擦洗后的父环境是基底,调用方的显式 env 合并上去:环境里原有的 DSH_* 变量在合并前被丢弃,一个当前事实只能作为显式条目进来;显式的 undefined 是墓碑,用来移除一个普通的环境值。
为什么要管这么细?因为环境变量是子进程事实的携带通道,放任不管就会出现两类事故:陈旧的 DSH_* 从父环境漏进子进程,agent 依据一个过期事实行动;调用方无意间把凭证形状的变量带出去。显式条目是刻意的决定,可以存活;环境继承是盲流,一律先擦。这条纪律在 shell 层还有一层收口:工具层收集的 dshEnv 在普通 env 之后合并,普通条目永远顶不掉受管条目。
句柄:读器与树级终止
spawn 立即返回一个活的句柄。收集模式的读器按整流字节偏移读取,而且不消费:多个独立读器各读各的,不会互相抢增量;结算后从零读一遍,就是批处理结果。读器返回文本、下一个偏移、一个 lossy 标记(请求的偏移已经滑出尾部窗口,这次读到的是整个保留尾部,缺口只能从 spill 找)和 spill 路径。
终止是全平台树级的。terminate() 是唯一的终止动词,升级顺序 SIGTERM、宽限期、SIGKILL,Windows 直接强制终止。POSIX 给分离的进程组发信号,组没了就退回直接子进程;Windows 用 taskkill /T 扫树。辅助进程没法在句柄不知情的情况下存活。它是幂等的,树已经没了就什么都不做,不怕进程号被复用后的误伤。宽限期除了用于终止升级,还用于退出后仍在排水的收集管道。
waitForExit() 等的是整棵树退出,不是直接子进程, teardown 之前消费者能观察到还在跑的 helper。整棵树观察是"杀干净"承诺的另一半:只看直接子进程,孤儿 helper 就成了泄漏。
结果只带退出事实
done 给的是 close 事件的词汇:退出码(被信号杀死时为 null)和信号名(正常退出时为 null)。没有超时分类,没有取消分类,没有输出。
为什么这么吝啬?因为"为什么被杀"的归因属于调用方。底层在 abort 信号到来时执行升级杀掉进程树,但从不下判断这次 abort 是超时触发的还是调用方主动取消的;bash 执行器读自己拥有的截止信号,做 timedOut 和 aborted 的拆分。机制和策略分开,在这里的具体含义是:杀进程是机制,解释为什么杀是策略,策略归拥有上下文的那一层。
找可执行文件也是同一个脾气:裸名字用擦洗过的 PATH 解析,带分隔符的相对路径直接拒绝,宁可大声失败也不猜。解析到的路径返回规范形式,调用方不用再猜同一个程序在不同写法下是不是同一个东西。
这一层的消费者值得点一下名:bash 执行器家族拿它的批量收集,LSP 拿它的原始协议管道,PTY 后端拿它的终端原语,ACP 子 agent 后端拿管道 ndjson 加继承的 stderr。注意名单里没有模型面向的工具。subprocess 是给别的接缝用的地基,模型永远隔着一两层语义摸不到它,这本身就是分层的证明。
销毁语义也在这里定死:服务销毁会终止所有还在跑的被管进程并等它们退出。这条规则向上撑起了两层的行为,后台进程扛过执行器重载、扛不过组合拆除,原因都在这:执行器重载不触发 subprocess 服务销毁,组合拆除触发。你在一层定的生命周期规则,上面所有层的安全感都从它来。
bash 执行器:ctx.shell
ctx.shell 回答"跑一条 bash 命令,拿回输出"。它坐在 subprocess 之上,加了四样东西:请求和规格的拆分、超时和中止分类、沙箱集成、批输出形态。
请求与规格的拆分
模型和插件面向的是请求,里面的工作目录、超时、输出上限都是可选的;resolve() 把它们按配置填好封顶,产出字段全必填的规格,再交给执行。中间隔这一步,是"包边界处显式优于隐式"的落点:请求可以宽松,规格必须完备,执行器拿到的东西永远没有洞。
有三个字段是可信进程内插件专用,模型面向的 bash 工具不暴露。stdin 用来写 hook 命令的 JSON 载荷;env 用来给 hooks 桥设项目目录之类的变量;输出上限让前台消费者按自己的解析预算请求完整 stdout。模型要 stdin 得用 shell 语法(heredoc、管道),不能直接传参数。这条分界线防的是提示注入顺竿爬:模型不能借工具参数往子进程环境里塞东西。
正交的结果报告
前台结果是这一层最精巧的设计:退出事实(退出码、信号)、执行器超时标记、调用方中止标记、两个流的输出,各说各的,互不覆盖。
为什么要独立报告?因为一个进程可能既超时又 exit 0。它用 trap 接住了信号,被杀时退出码还是 0。只看退出码,一次被截断的运行就被读成干净成功,agent 拿着半份输出继续干活,错误在下游才爆。超时和中止互斥(一个融合的截止时间给首次 abort 定性,是谁触发的就报谁),但它们和退出事实正交,任何组合都可能出现,调用方永远不需要猜。
每个流是一个有界收集:截断时文本是尾部,完整流溢出到私有文件。这和 fs 读取的必填上限是同一种思路,宁可记下"丢了头部、完整在溢出文件",也不静默截断。
沙箱集成
沙箱化的执行器暴露它配置的模式兜底;工具层请沙箱策略服务把每个会话的模式覆盖和不可变 cwd 解析进请求。一次沙箱化运行报告模式、保守的拒绝分类、强制完整度,以及一个"runner 在命令跑之前就失败了"的标记。前台执行在拿不到围栏时直接抛 SANDBOX_UNAVAILABLE,已结算的后台进程则通过它的事实通道暴露同一件事。
沙箱的各项事实独立于退出状态报告,调用方能分清三种结局:命令本身失败、策略拒绝了命令、沙箱基础设施坏了。模型看到拒绝事实后,可以从拒绝标记里学到当前模式,发起一次严格更宽的带理由重试,审批通过才放行。围栏和放行各归各的子系统,在这一层只是被报告出来。
渲染与解析的往返
有个不起眼但意味深长的设计:结果渲染成模型可见文本时会追加退出状态标记([exit code: N] 或 [killed by signal: X]),而这一层还导出一个解析函数,能把这些标记原样反解析回机器事实。
为什么要做往返?因为渲染后的文本是落进会话日志的持久形态,"模型可见即可重建"的规矩要求机器事实能从持久形态里恢复出来。渲染不丢弃结构化信息,解析把它拿回来,两个工具的前台呈现都靠这个拆分把输出正文和退出状态分开渲染。持久化的是文本,但文本是可逆的,这比"另存一份结构化副本"少一条会漂移的路。
从头走一遍
把一次模型发起的 bash 调用从头串起来。模型发出工具调用,命令是一段 shell 文本。工具层先请沙箱策略服务解析这次调用的模式和工作区根,填进请求;resolve() 把可选字段按配置补齐封顶,产出全必填的规格。
执行器按模式决定走围栏还是裸跑。围栏路径上,拿不到围栏直接抛 SANDBOX_UNAVAILABLE,前台调用就此失败。拿到围栏 argv 后,执行器通过 subprocess 接缝 spawn:每个流的处置显式声明,stdout 走有界收集加 spill,环境先擦后合,dshEnv 最后合并。
进程跑完,done 给出退出码或信号。执行器读自己的截止信号做归因:超时触发的杀标 timedOut,调用方取消的杀标 aborted。三个事实正交地放进结果,输出以尾部加溢出文件的形式给出,沙箱事实独立附带。最后渲染成带退出标记的文本落日志,未来任何一次重放,靠解析函数把机器事实从这份文本里原样取回。
这条链路上每一层只加自己那层的话:subprocess 不知道超时为何物,shell 不知道任务归属为何物,日志知道一切因为它拿到的是可逆的最终形态。
后台进程
start() 返回一个没有 id 也没有 owner 的进程句柄。这半句定义是理解它的关键:shell 层不管"这是哪个任务",它只给一个进程;任务身份和归属归 jobs 层,由工具把句柄适配过去。分层意味着换一套任务管理不影响进程执行,反之亦然。
done 在进程关闭时 resolve,永不 reject。spawn 失败也结算成一个被杀状态,错误放在 stderr 上。这条承诺消费端最受益:等待一个后台任务永远是一个普通的 await,不需要 try-catch 包一层"连启动都没成功"的异常分支,失败也是一种正常结算。状态恰好结算一次,沙箱事实在结算前盖好章。
输出是增量消费式的:连续读不会重复给输出,丢数据的读标记 lossy 并指向溢出文件,stderr 放在标记过的段落里。杀进程组是幂等的,已经结束就返回 false。
生命周期边界值得单独记:组合拆除(subprocess 服务销毁)会杀掉并等完所有还在跑的后台进程;只重载执行器,后台进程照跑。边界放在比 shell 更底层的地方,热重载一个 bash 执行器插件不会屠掉用户的长任务。不理解这条边界的人,会误判"重载之后我的后台任务还在不在"。
"没有 id 没有 owner"这个定义还决定了一个分工事实:任务这件事的全部概念,起名、归属、查询、排队,都在 jobs 层。工具把进程句柄适配成任务,agent 语境里的"我的后台任务"由此诞生。如果你要给后台执行加功能,先问它属于哪一层:跟进程执行有关的(输出怎么截、怎么杀)改 shell 或 subprocess,跟任务有关的(谁能看、怎么排队)改 jobs。把任务概念塞进 shell 层,是这层最常见的越界。
持久 PTY:ctx.terminals
顶层回答"起一个交互式会话,我来回发输入"。它在前两层之上加了四样:就绪推断、滚动缓冲、独占发送、跨重载存活。
身份与授权
会话 id 是服务铸造的 branded id。可选的名字是 owner 本地的展示元数据;授权比的是确切的归属 agent,不是名字也不是猜的 id。这是个安全细节:你不能靠猜一个会话名字去操作别人的 PTY。服务给确切的 owner 作用域挂一个等待式清理,外来的操作一律拒绝;查活跃状态也是全区间无空隙的,从 spawn 到关闭之间不会有"发布了但还不算数"的窗口。
等待原因与会话状态是两回事
一次 send 把控制权还给调用方时,附带一个等待原因,四个值:stdin 读到(顶层 shell 在等输入)、推断空闲(输出停了,像是不忙了)、超时、会话退出。
关键设计是它和会话状态独立。沉默或超时都可能返回,而顶层 shell 还活着;只有会话退出表示那个 shell 真的退了,不是某个前台子进程退了。为什么要分开?交互的本质是"发一段输入,等输出告一段落",如果输出暂停就被解读成会话结束,多步交互在第二步就断了。等待原因描述的是"这次为什么还给你",会话状态描述的是"会话本身还在不在",两个问题,两个答案。
独占发送与两条读取路径
一个活动会话同一时刻只接受一个 send。这个独占语义是为了终端状态的一致:两个输入源交织着往一个 PTY 里灌,终端状态机的每一步就都不可推理了。send 操作在就绪、超时、取消或顶层退出时结算;输出消费读的是上次调用以来的增量;取消请求发 SIGINT,结算之后取消就返回 false,不再生效。
读取有两条独立的路径:一条消费"这次 send 之后新产生的输出",一条分页读有界的会话滚动缓冲。增量给正在交互的消费者,滚动缓冲给"我刚接手,想看看之前发生了什么"的消费者,两条路径不会互相抢数据。
拿一个具体交互走一遍。agent 起了一个 Python REPL 会话,服务发布它,初始一段有界的开场输出先到。agent 发第一条 send:输入一行 import pandas。send 操作在跑,输出到一段后停了,就绪推断判断 REPL 空闲了,控制权带着"推断空闲"的原因还给 agent,增量输出是这次 send 期间的新文本。agent 看输出决定下一条输入,再发一次 send。整个过程里会话状态一直是 running;直到某天 agent 发了 exit(),等待原因才第一次变成会话退出,状态同步翻成 exited。
如果第二步的 send 陷在一个死循环里,agent 可以取消,一个 SIGINT 打到验证过的前台进程组,REPL 回到提示符,会话还是那个会话。信号发的是前台进程组而不是整个会话,这是交互语义的要点:打断的是正在跑的前台命令,不是承载它的 shell。
后端负责一种类型 PTY 会话的启动和就绪检测;服务只在 setup 成功后发布会话。启动到一半清不干净的资源,后端用专门的清理错误拒绝,让销毁过程保留清理失败,而不是用一个新错误覆盖调用方的取消原因。会话的关闭要等到清理完成后才移除记录,同一个关闭在途时重复调用返回 false。
什么持久,什么不持久
PTY 状态和原始字节留在进程本地,会话却能跨后端或工具插件重载存活。持久化的是模型输入和有界返回输出,走既有的工具调用、工具结果、任务结果路径,不另发一套 PTY 会话事件。
这个取舍值得咀嚼。会话的"活着"是进程内的事实,重载杀不死;会话的"可重建"只覆盖模型看到的部分。如果你希望 PTY 的全部滚动历史都能重放,这里没有,持久层刻意只存对模型有意义的投影。少记一份重复的事件流,换来的是日志里没有只有回放价值的终端噪音。
何时用哪一层
给一个判断表:
| 需求 | 用哪层 | 理由 |
|---|---|---|
| 跑一条 bash 命令,拿输出,要超时 | shell 的 run | 超时、中止分类、批输出、沙箱集成都在这层 |
| 起一个后台长跑命令,轮询输出 | shell 的 start | 后台无执行器超时,增量读,配 jobs 管身份 |
| 起一个语言服务器,JSON-RPC 长连接 | subprocess 的 spawn | 要原始管道自己解析协议 |
| 起一个交互式 REPL,看输出再发输入 | terminals | 要就绪推断、独占发送、滚动缓冲、跨重载 |
| 起一个子 agent 后端,管道 ndjson | subprocess | pipe stdin 加继承 stderr |
最常见的错误是用 shell 跑协议后端。bash 执行器把输出当批文本收集,加超时、加沙箱、加截断,这些对一条命令是对的,对一个要长期对话的协议后端全是负担:超时杀长连接,截断毁协议帧。反过来,用普通 spawn 模拟交互终端也不行,控制终端语义重建不出来,那是专门的原语的活;就算用了那个原语,就绪推断、prompt 检测、持久会话归属这些仍是终端层消费者的责任,subprocess 只给字节和信号。
权衡
分层的成本是具体的。想"跑个命令"的新人得先搞清自己要哪层;直接用 subprocess 的代码显得啰嗦,每个处置、每个上限都要显式写;终端的独占发送限制了并发,不能并行往同一个 PTY 灌输入;后台进程和 PTY 会话的生命周期边界都在比自身更底层的地方,不理解就会误判重载的后果。
环境擦洗也有代价。显式条目才能存活意味着迁移老脚本时要逐条声明依赖的环境变量,图省事的人会嫌烦。这份烦是买"子进程环境里没有意外"的价钱。
有界收集和 lossy 读也有各自的边角。尾部加溢出文件的形态保证了"永不静默截断",但消费者的读取偏移一旦滑出尾部窗口,缺口只能去溢出文件里找,而溢出文件有自己的上限,再大的流连溢出都是不完整的。PTY 那边,不同平台基底能观察到的前台进程信息深浅不一,供应商会如实写明各自的观测限制,写跨平台的终端消费者时要按"最好情况设计、最坏情况降级"来。
回报在明处。每层独立优化、独立替换,不会出现给协议后端加的超时把长连接杀了这种互相干扰;不设默认值,避免了隐藏行为在不同后端上的不一致;正交的结果报告让截断的运行永远不会被误读成成功。理解成本一次付清,换来的是排错时每一层都可以单独怀疑、单独替换。
结论
命令执行在 dsh 里是三层叠加:subprocess 是不带语义的底层坐标,显式到每个流、每个上限,退出事实只报码和信号,归因归上层;shell 在上面加出 bash 语义,请求拆规格、可信字段和模型字段分界、退出超时中止三者正交报告,渲染文本可反解析回机器事实;terminal 再往上加出持久交互,等待原因和会话状态分开,独占发送保终端一致性,跨重载存活靠把生命周期边界放在更底层。
三层各自的问题意识不同:subprocess 防"隐藏行为",shell 防"截断误读成成功",terminal 防"交互状态不可推理"。选层看需求本质:批处理用 shell,协议用 subprocess,交互用 terminal。判断时先问自己的消费者是谁:模型要结果、接缝要管道、交互要状态,答案各自指向一层。这套分层让执行命令在面对三种截然不同的需求时,各有干净合适的工具,而不是一个拧巴的万能接口。
延伸阅读
- Subprocess 官方文档:spawn 规格、句柄读器、树级终止、
DSH_*命名空间 - Bash Executor 官方文档:请求拆规格、正交结果、沙箱集成、后台进程
- Persistent PTY Sessions 官方文档:等待原因、独占发送、跨重载存活
- Persistent PTY 设计笔记:持久 PTY 的设计理由
- Timeout deadline 设计笔记:超时与中止的首因分类
上一篇:dsh 的 Filesystem 接缝:读写编辑与观察策略 下一篇:LSP 接缝:dsh 怎么让 agent 真正"懂"代码
GitHub 原文:21-shell-subprocess-terminal.md
评论
EMPTY