Skip to content

fix(pm2): 恢复 restart --include-pm2 彻底重启并阻断发起方环境泄漏进 fleet - #877

Open
LucasIcarus wants to merge 5 commits into
masterfrom
fix/restart_include_pm2
Open

fix(pm2): 恢复 restart --include-pm2 彻底重启并阻断发起方环境泄漏进 fleet#877
LucasIcarus wants to merge 5 commits into
masterfrom
fix/restart_include_pm2

Conversation

@LucasIcarus

@LucasIcarus LucasIcarus commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

修订(第二轮评审的两个阻断)

  1. fleet 锁重入所有权改绑真实调用链:此前用模块级计数判断「已持锁」,dashboard 这类并发 HTTP 进程里,另一条独立请求链会把「本进程有人持锁」误判为「我持锁」而跳过文件锁,跨进程 race 只是缩成了同进程并发窗口。现改用 AsyncLocalStorage:所有权只沿真正持锁的 async 调用链传播,嵌套调用(restart 持锁中停插件)短路,独立并发链和其它进程一样在文件锁上排队(file-lock 对存活的同 pid 持有者按「占用」处理,不当作 stale)。新增行为测试(非源码文本 pin):同链嵌套短路、同进程两条独立链串行、外部存活持有者导致等待并超时。
  2. 核心只读命令不再隐式创建 Godbotmux status / botmux logs 此前裸跑 pm2,在 include-pm2 的 kill→start 窗口会生出 replacement God——虽被 start 断言安全拒绝,但代价是整个 fleet 停在离线态。现在两者都在 fleet 锁内先扫描 God:无 God 时直接报告「fleet 已停止」(并给出日志文件位置),绝不触发 pm2 隐式建 God;锁被占用(停启进行中)时提示稍后重试。pm2 logs 的长流式 tail 在检查后于锁外启动(pm2 只在客户端启动瞬间 daemonize,残余毫秒级窗口由 start 事务的零 God 断言 fail-closed 兜底)。
  3. session-only 键清单改为显式维护,修正两处误删:差集推导的前提(注入清单 ⊆ session-only)不成立——BOTMUX_INJECTED_ENV_KEYS 本质是 pane 传输白名单,混有仅以 daemon ambient env 为合法通道的配置键。已修正:CLAUDE_CODE_RESUME_TOKEN_THRESHOLD(worker 读 ambient、per-bot env 明确拒绝该键,被 scrub 即静默废功能)与 HERMES_HOME / HERMES_BOTMUX_SOURCE_HOME / HERMES_BOTMUX_PROFILES_ROOT(安装位置类 ambient 配置,botmux 从不设置,契约同 GROK_HOME)不再被清除;反向补上传输清单之外的 session 路由键 BOTMUX_SESSION_SCOPEBOTMUX_SEND_RELAY。清单每键附保留理由(判据:由 daemon/worker 每会话计算注入、不存在任何 ambient/env-file 合法通道)。新增两类语义回归:合法 ambient 配置穿过全套 pm2+boot scrub 存活;真 session-only 值被删除。

修订(第一轮评审的三个阻断)

  1. 插件 stop 失败不再被吞掉、pm2 kill 前有全量 registry 校验:include-pm2 路径逐条检查 stop reports,任何 failed 立即中止(God 与剩余进程零改动);随后用一次独立的 pm2 jlist 证明整个 registry 静止——任何仍有存活 pid 或非终态 status 的行(包括已卸载插件残留的 botmux-plugin-* 行)都会 fail-closed 阻断 kill,而不是随 God 一起被杀。
  2. 同一 PM2_HOME 的内部操作共享 fleet 锁:新增 src/cli/pm2-fleet-lock.ts,核心 start/stop/restart/start-bot 与插件 service-manager 的 start/stop/delete/status 全部经同一把文件锁串行(固定顺序:fleet 锁在外、plugin service 锁在内;重入所有权见第二轮修订——绑定 async 调用链,独立并发链一律排队文件锁)。stop→kill 之间并发插件 start 的插队路径被关闭;status 探测的 jlist 也被纳入,避免它在 kill→start 窗口顺带生出 replacement God。窗口内仍可能有 botmux 之外的 pm2 客户端生出 God,因此 start 事务在 pm2 start 前显式断言零 God——出现 replacement God 时拒绝启动而非接受它。
  3. 插件 pm2Env 对齐核心五类 scrub 并在 merge 后冻结:补齐 scrubSessionCliHomeEnv / scrubClaudeSessionMarkerEnv / scrubWorkflowWorkerEnv;全部 scrub(含 graceful sentinel 剥离)移动到 manifest env merge 之后执行,manifest env 无法复活被清除的键,TERM/PM2_HOME 同样冻结。turn-marker 键清单扩展覆盖 MCP gateway socket/required、daemon IPC 端口、read-isolation 标记、IS_SANDBOX 等 session-scoped capability(清单形态在第二轮修订中定稿为逐键注明理由的显式集合)。另按跨平台意见把 TERMINFO_DIRS 移出清单(NixOS/自定义 ncurses 下属机器级配置),保留 TERMINFO(终端应用私有目录指针)。

非阻断项的处理:manual 插件 service 重启后保持关闭是有意的产品语义(God 终止后它们本就无法幸存,先优雅停止再由 reconcile 恢复 auto 部分),文档与帮助文本一致,维持现状。

改了什么

两个问题,同一根因:PM2 在 pm2 start 时会捕获发起进程的完整环境变量,写入每个受管进程的定义并持久化到 dump.pm2,之后的重启一直沿用这份快照。因此「谁执行了 restart」决定了整个 fleet 的运行环境。

1. 在 pm2 边界清理发起方环境(新增两类 scrub)

  • INVOKER_TERMINAL_ENV_KEYS + scrubInvokerTerminalEnv:清除描述发起方终端与 agent 运行环境的变量——颜色类(NO_COLOR / FORCE_COLOR / CLICOLOR*)、终端标识类(TERM / COLORTERM / TERMINFO* / TERM_PROGRAM* 等)、CI 与 agent 标记(CI / CODEX_CI)、pager 设置(PAGER / GIT_PAGER / GH_PAGER),共 17 个键。在 agent 的非交互 shell 中执行 botmux restart 时,这些变量会随环境快照进入全部 fleet 进程,并经 daemon → worker → 会话 PTY 逐层继承,最终所有 bot 会话的 TUI 失去颜色(NO_COLOR=1),TERMINFO 还可能指向某个终端应用的私有目录。daemon 是无头服务,这些变量在它的环境里没有正确语义,删除总是安全的。
  • SESSION_TURN_MARKER_ENV_KEYS + scrubSessionTurnMarkerEnv:清除 session-only 身份与 capability——session/chat/turn id、会话 scope、owner 双通道、MCP gateway socket、daemon IPC 端口、read-isolation 标记、sandbox send relay 等。清单为显式维护、逐键注明保留理由的集合,判据是「由 daemon/worker 每会话计算注入、不存在任何 ambient/env-file 合法通道」;仅以 ambient 为合法通道的配置键(BOTS_CONFIG、SESSION_DATA_DIR、BOTMUX_LARK_LIST_BOTS_API_*、CLAUDE_CODE_RESUME_TOKEN_THRESHOLD、HERMES 三键等)明确不在清单内。从 bot 会话内部发起 restart 时,该会话的路由身份不再随环境快照进入 daemon;daemon 保持 session-agnostic(与 CLAUDE.md 的 owner 身份边界一致)。

生效位置(与既有 scrubSessionCliHomeEnv / scrubClaudeSessionMarkerEnv / scrubWorkflowWorkerEnv 三类 scrub 相同的入口,处理的是同一条泄漏路径):

  • cli.ts pm2Env():所有核心 pm2 调用的统一入口,覆盖写入受管进程的环境快照与新 God 进程继承的环境
  • core/plugins/pm2.ts pm2Env():插件 PM2 与 God 共用 PM2_HOME,同样会写入环境快照、也可能创建 God
  • index-daemon.ts 启动期:兜底从过期 dump.pm2 resurrect 的场景,并让已被污染的存量 fleet 在下次 daemon 启动时自行恢复干净,不必等待一次来自干净 shell 的 restart;原有的内联 turn-marker 清理列表合并进共享 scrub
  • 两处 pm2 入口与 daemon 启动期在清理后都把 TERM 重新设为 xterm-256color(与仓库内所有 PTY 后端一致的常量):写入的值从「取决于发起 shell」变为确定性常量,既保住 botmux logs 直连 TTY 时的颜色探测,也保住 zmx 会话(自行 forkpty、不强制 TERM)内 CLI 的颜色能力判定

2. 恢复 restart --include-pm2 的「彻底重启」能力

先前的机群停启协议改造把 --include-pm2 收敛成了「零 live God 准入检查」:只要存在活着的 God 一律拒绝,检查之后该选项没有任何行为——原始能力(连同 God 一起以干净环境重启)实质被移除。本 PR 在保持该协议全部安全边界的前提下恢复能力:

  • 时序:按代际安全协议退役核心 fleet → 优雅停止全部插件 service,逐条检查 stop 结果,任何失败立即中止 → 验证核心投影为空 → 用独立 pm2 jlist 证明整个 registry 静止(任何存活 pid / 非终态行都阻断,包括已卸载插件残留行)→ 此时 God 不再管理任何进程 → 经 PM2_HOME 控制 socket 执行 pm2 kill → 轮询验证其消失 → pm2 start 前再断言零 God(出现 replacement God 即拒绝)→ 全新启动(新 God 从当前 CLI 清理后的环境出生)
  • 权威模型:从不按 PID 发信号。kill 寻址到「拥有此 PM2_HOME 的 God」——正是要退役的对象;记录的 pid 与进程启动时间身份(readSupervisorProcessStartIdentity,Linux /proc starttime、macOS ps lstart、Windows CIM)仅用于验证消失,不作为信号依据
  • fail-closed 保留:God 扫描出非法/重复 PID、多个 God 并存时零改动拒绝;pm2 kill 后 15 秒内无法证明消失则报错,并说明当前状态与恢复路径;与 --bootstrap-shutdown-protocol 互斥不变
  • 不存在 God 时行为不变(等价普通 restart,pm2 start 自动创建新 God)
  • 实现为独立模块 src/cli/pm2-god-retirement.ts(runtime 注入、纯逻辑可测);原 pm2-god-admission.ts 及其测试删除

为什么

  • restart --include-pm2 在存在 live God 时必然报错,能力名存实亡;而它要解决的需求真实存在:一次从 agent 非交互 shell 发起的 restart,就足以让 fleet 环境长期携带 NO_COLOR=1、CODEX_CI=1、PAGER=cat 等变量;从 bot 会话内部发起的 restart 还会把该会话的 BOTMUX_SESSION_ID 写进所有 daemon 的环境
  • 污染会自我延续:dashboard 与自动升级触发的 detached restart 以 daemon 自身环境为基础,脏变量随每次重启传递下去;只有「重建 God + 干净环境重启」或本 PR 的边界清理能切断
  • 「daemon 不应继承发起 shell 的终端与 agent 标记」与 systemd / launchd 的 service 环境模型一致;选择黑名单而非白名单,是为了保留文档化的环境透传通道(API key、代理等经 shell export 流入会话)

影响面

  • 公共层pm2Env()(核心 + 插件两处)、index-daemon.ts 启动清理、cmdRestart--include-pm2 分支。普通 restart / start / stop--with-plugin--bootstrap-shutdown-protocol 路径行为不变(仅写入的环境快照变干净)
  • 跨 CLI:不触碰 adapters/cli/ 与 worker 逻辑;会话 CLI 的变化只是不再被动继承 NO_COLOR / CI 类变量。确实想让某个 bot 无色的用户可用 per-bot env 显式设置(该通道在清理之后注入,不受影响)
  • 跨平台:God 扫描与启动时间身份读取沿用现有三平台实现;pm2 kill 为 PM2 自带的跨平台行为
  • 跨会话类型:restart 期间 fleet 的代际安全退役协议未动;话题 / 群 / adopt / v3 会话不受影响
  • 插件 service:仅 --include-pm2 时全部优雅停止(auto 的重启后由既有 reconcile 自动恢复,手动启动的不恢复——God 终止后它们本就无法幸存,先优雅停止是严格更好的处理);普通 restart 不变
  • 并发行为变化:插件 service 的 start/stop/delete/status 现与核心 fleet mutation 共享同一把文件锁——core restart 进行中时,插件操作与 status 探测会等待(上限 30 秒)而不是与之交错;这是有意的串行化。botmux status / botmux logs 在停启进行中(锁被占用超 3 秒)会提示稍后重试,在 fleet 已停止时报告停止状态而不再隐式拉起 PM2 God。botmux 之外的 pm2 客户端不受锁约束,若恰在 God 退役与重启之间创建了新 God,start 事务会拒绝并提示重跑,而不是接受一只来源环境未知的 God

测试验证

  • 已 rebase 到最新 master;pnpm build(含 domain / dist audit)与 pnpm exec tsc --noEmit 通过
  • test/pm2-god-retirement.test.ts 13 用例:退役流程 7 例(无 God 不动作、多 God / 非法扫描零改动拒绝、kill 后验证消失、PID 复用按启动时间身份判定、超时 fail-closed、kill 失败原样抛出)+ registry 静止校验 4 例(空/终态放行;stop 失败仍在线、卸载残留行、非终态无 pid、终态却有活 pid 均拒绝)+ replacement God 拒绝 2 例
  • test/plugin-pm2-env.test.ts 新增:插件入口五类 scrub 与核心一致、manifest env 无法复活被清除键(含 sentinel / TERM / PM2_HOME 冻结)
  • test/child-env.test.ts:两类 scrub 行为用例、三处调用点 source pin、TERM 重设 pin、turn-marker 清单语义回归(合法 ambient 配置穿过全套 scrub 存活 / 真 session-only 值被删除 / 传输清单外的 SCOPE、SEND_RELAY 已覆盖)、TERMINFO_DIRS 豁免 pin
  • test/shutdown-supervisor-contract.test.ts 契约:cmdRestart 内「fleet 退役 → 插件严格停止 → 核心投影为空 → 全量 registry 静止校验 → God 退役 → start 事务(pm2 start 前零 God 断言)」顺序 pin;pm2 kill 全文件唯一调用点 pin;God 退役模块零 PID 信号 pin;核心四个 PM2 mutation 入口与插件 service-manager 共享 fleet 锁、status 探测持锁的 source pin
  • 新增 test/pm2-fleet-lock.test.ts 行为测试:同链嵌套(async/sync)短路、同进程两条独立并发链在文件锁上串行、外部存活持有者导致等待并超时
  • 契约新增:botmux status / botmux logs 在 fleet 锁内做 God 扫描、扫描先于任何 pm2 调用的顺序 pin
  • 聚焦套件(以上 + plugin-service-restart-lifecycle / plugin-service-lifecycle-guard / daemon-lifecycle-env)89/89 通过;全量 pnpm test 通过(除一处与本改动无关的预存失败,切回改动前基线对照确认)

@LucasIcarus
LucasIcarus requested a review from deepcoldy as a code owner August 14, 2026 12:47
@LucasIcarus
LucasIcarus force-pushed the fix/restart_include_pm2 branch from c72c453 to 1f0ae5e Compare August 14, 2026 12:54
@LucasIcarus LucasIcarus changed the title fix(pm2): 恢复 restart --include-pm2 彻底重启并在 pm2 边界消毒发起者环境 fix(pm2): 恢复 restart --include-pm2 彻底重启并阻断发起方环境泄漏进 fleet Aug 14, 2026
@LucasIcarus
LucasIcarus force-pushed the fix/restart_include_pm2 branch from 1f0ae5e to a3f6180 Compare August 15, 2026 16:54
PM2 在 pm2 start 时会捕获发起进程的完整环境变量,写入每个受管进程的
定义并持久化到 dump.pm2。从 agent 非交互 shell 或 bot 会话内部发起
restart,会把 NO_COLOR/CODEX_CI/PAGER 等发起方变量与 turn 级会话身份
带进全部 fleet 进程,导致所有会话 TUI 失去颜色。新增
INVOKER_TERMINAL_ENV_KEYS 与 SESSION_TURN_MARKER_ENV_KEYS 两类 scrub,
挂在核心与插件两处 pm2Env() 入口及 daemon 启动期(已污染的存量 fleet
下次启动自行恢复干净),TERM 在 pm2 入口重设为 xterm-256color 常量。

restart --include-pm2 此前被收敛为零 live God 准入检查:存在活 God
一律拒绝,检查之后无任何行为。现恢复能力:核心 fleet 按代际安全协议
退役并验证、插件 service 优雅停止后,经 PM2_HOME socket pm2 kill 退役
已不管理任何进程的 God(从不按 PID 发信号,pid 与启动时间身份仅用于
验证消失),再以清理后的干净环境全新启动。多 God、非法扫描、验证超时
保持 fail-closed。

Claude-Session: https://claude.ai/code/session_01RNNzgSmYPGRvtKXArzvPNS
pm2 边界的 invoker-terminal scrub 把 TERM 从 daemon/worker 环境删掉后,
只有两处 pm2 入口重设了常量;daemon 启动期的兜底 scrub 在其后再次删除
TERM 且未重设,worker 因此完全没有 TERM。其它后端由 node-pty 的 name
强制会话 TERM 不受影响,zmx 自行 forkpty 且不设 TERM,fresh 会话的 PTY
环境原样继承一次性 create client——TERM 缺失使会话内 CLI 的
supports-color 判定终端无颜色能力,TUI 整体无色。

两处补钉,互为兜底:index-daemon.ts 启动 scrub 后把 TERM 重设为
xterm-256color(与两处 pm2Env() 一致);zmx-backend 新增
zmxFreshSessionEnv(),fresh 会话的 create client 以钉住 TERM 的环境启动,
控制命令(get/set/list/kill)与用户侧真实终端 attach 不变。已存在会话的
环境在创建时已冻结,需新建会话后生效。

Claude-Session: https://claude.ai/code/session_017GEftXjPHsCe4y9BJF3nG9
readBytecloudKeychainJwt 的 platform 参数默认 process.platform,而这批用
例把 keychain 写在 ~/.config(XDG/Linux 的 config 根)下却没注入平台:在
macOS 上候选路径走 ~/Library/Application Support,读不到写入的文件——3 例
直接失败,另外 7 例因 .config 侧文件不可见而空转通过,未真正覆盖其声明的
场景。对齐同文件后半部分既有的显式注入惯用法,给所有依赖 .config 候选的
调用补上 'linux'。纯测试改动,不触碰实现。

Claude-Session: https://claude.ai/code/session_017GEftXjPHsCe4y9BJF3nG9
三处收紧:一,插件 stop 的失败 report 不再被忽略,include-pm2 路径逐条
检查,任何失败立即中止;pm2 kill 前再用独立 jlist 证明整个 registry
静止,已卸载插件残留的运行行同样 fail-closed 阻断。二,新增共享
fleet 锁(cli/pm2-fleet-lock.ts),核心 start/stop/restart/start-bot
与插件 service-manager 的 start/stop/delete/status 全部经同一把文件锁
串行(fleet 锁在外、service 锁在内,同进程嵌套用重入计数);pm2 start
前显式断言零 God,拒绝 kill→start 窗口内他方客户端生出的
replacement God。三,插件 pm2Env 补齐与核心一致的五类 scrub,且全部
在 manifest env merge 之后执行,manifest 无法复活被清除键;
turn-marker 清单改为按 BOTMUX_INJECTED_ENV_KEYS 白名单差集推导,
MCP gateway/daemon IPC 端口/read-isolation/IS_SANDBOX 等 session
capability 全覆盖,新增注入键默认纳入;TERMINFO_DIRS 属机器级配置
移出 invoker 指纹清单。

Claude-Session: https://claude.ai/code/session_01RNNzgSmYPGRvtKXArzvPNS
@LucasIcarus
LucasIcarus force-pushed the fix/restart_include_pm2 branch from a3f6180 to 5cf54f8 Compare August 18, 2026 11:20
一,fleet 锁重入所有权从模块级计数改为 AsyncLocalStorage:所有权只沿
真正持锁的 async 调用链传播,dashboard 等并发进程里独立请求链不再把
「本进程有人持锁」误判为「我持锁」,与其它进程一样在文件锁上排队;
新增行为测试覆盖同链嵌套、同进程独立链串行、外部存活持有者等待超时。

二,botmux status/logs 在 fleet 锁内先扫描 God:无 God 时报告 fleet
已停止(logs 给出日志文件位置),不再隐式创建 PM2 God;锁被占用时
提示稍后重试。logs 的流式 tail 在检查后于锁外启动,残余毫秒窗口由
start 事务的零 God 断言兜底。

三,session-only 键清单弃用差集推导,改为逐键注明理由的显式集合:
修正 CLAUDE_CODE_RESUME_TOKEN_THRESHOLD 与 HERMES 三键被误删的回归
(均为 ambient-only 配置通道),反向补上传输清单之外的
BOTMUX_SESSION_SCOPE、BOTMUX_SEND_RELAY;新增「合法 ambient 配置穿过
全套 scrub 存活」与「真 session-only 值被删除」两类语义回归测试。

Claude-Session: https://claude.ai/code/session_01RNNzgSmYPGRvtKXArzvPNS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant