7.0 KiB
Agent Note: 进程管理器是 bash 执行器之下的独立 seam(dsh-subprocess / dsh-subprocess-local)
Status: implemented
English | 中文
问题
dsh-bash-local 原先把两项因不同原因而变化的能力捆绑在一起:运行一条 bash 命令(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与运行并管理一个子进程(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 DSH_* 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(run.ts)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 DSH_*/CollectedOutput 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的任务注册表:后者的注册存续期刻意长于生产方 fiber。
决策
新的 subprocess/ 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方:
@deepseek-ai/dsh-subprocess(接口)——拥有ctx.subprocess的抽象SubprocessService:执行环境 cwd 与运行时存储、可执行文件查找、完全显式的普通 spawn,以及可移植执行环境决策新增的终端原语。其词汇包括按流划分的 stdio 处置方式(disposition)、进程与终端句柄、刻意不含超时/取消分类的退出事实,以及共享的凭据清除与DSH_ENV_PREFIX/DshEnvironment/CollectedOutput类型。argv绝不经过 shell 解释。@deepseek-ai/dsh-subprocess-local(实现)——LocalSubprocessService构建在原run.ts管道(现为spawn.ts)与node-pty之上:detached 进程组、有界收集与私有 spill 文件、可执行文件查找、私有运行时存储、前台/会话检查、清除之后合并显式 env 的凭据清除、进程树清理,以及终止每个受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。Bash 与 PTY 的呈现环境覆盖仍归各自消费方所有。dsh-bash-local(消费方)——inject: ['subprocess'];把每个解析后的BashExecSpec映射为一个SubprocessSpawnSpec(['bash', '-c', command]),并保留自身配置、resolve()默认值补全、基于融合 deadline 的timedOut/aborted分类、带[stderr]标记的后台读取合并及其消费游标,以及onProcessDone子类钩子。dsh-bash-sandbox除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。dsh-bash(seam)——把迁走的词汇从dsh-subprocess重导出,因此没有任何 bash 消费方需要改动导入;BashExecRequest/BashExecSpec/BashProcess与沙箱事实仍归 bash 所有。
如今,每个加载 bash 执行器的组合都同时加载 @deepseek-ai/dsh-subprocess-local:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。
后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject done,且不缓冲任何内容),因此执行器把 spawn failed: … 提示注入恰好一个 readOutput() 增量。
曾考虑的替代方案
**把进程管道留在 dsh-bash-local 里(维持现状)。**否决的理由与任务注册表拆分得以落地的理由相同:这条边界既稳定,也早已记录在代码里(run.ts 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。
**在引入 seam 的同一变更中迁移仓库其余 spawn 调用点。**否决,因为在当时的规模下属于带有真实设计风险的范围蔓延。后续的消费方迁移与可移植执行环境决策围绕已观察到的 LSP、subagent、PTY 与 Code Runtime 消费方重塑接口;SDK 与 test-support 启动器因所有权归属仍留在服务之外。
**改把 run_in_background/任务语义放进进程 seam。**否决:那条边界已经存在。ctx.tasks 拥有 id、所有权与通知,bash 工具则把 BashProcess 适配成任务钩子。进程 seam 位于 bash 执行器之下,而不是与任务注册表并列。
**把 ENV_OVERRIDES(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 DSH_* 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。
后果
换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY、Code Runtime 与 ACP(Agent Client Protocol)消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;共享的 DSH_*/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 dsh-subprocess-local 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。
代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,ctx.bash 会因等待 ctx.subprocess 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 dsh-bash 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 readFrom(0) 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。