Skip to content

feat(spawn-freeze): 维护窗口内不起新 CLI(升级 claude / 刷凭证),并告知发消息的人 - #647

Open
xu4wang wants to merge 6 commits into
deepcoldy:masterfrom
xu4wang:feat/spawn-freeze
Open

feat(spawn-freeze): 维护窗口内不起新 CLI(升级 claude / 刷凭证),并告知发消息的人#647
xu4wang wants to merge 6 commits into
deepcoldy:masterfrom
xu4wang:feat/spawn-freeze

Conversation

@xu4wang

@xu4wang xu4wang commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

问题

维护动作会让「此刻新起的 CLI」踩到半成品状态,而 botmux 现在没有任何办法让它们等一等,也没办法告诉正在群里说话的人「稍等几分钟」。

场景一:升级 claude(单 bot 也会遇到)

npm -g install @anthropic-ai/claude-code@latest 跑到一半时,用户在飞书发来一条消息 → botmux 冷启动一个 CLI → 撞上半安装的包,或者起来的是版本错配的 CLI。用户看到的是「bot 坏了」,而且是几个人同时撞。

升级本身只要一两分钟,真正缺的是这段时间里的两件事:别起新 CLI,以及告诉发消息的人「维护中,等会自动继续」

现在两件都做不到。botmux suspend 只拆掉已在跑的 CLI,拦不住下一条消息立刻起一个新的;而「正在维护」这件事,用户完全无从得知——消息发出去就没有回应。

顺带一句:botmux 目前不管 claude 自带的自动更新(仓库里没有任何 DISABLE_AUTOUPDATER 处理),所以这个窗口现在是随时可能自己发生的。有了闸门,才有把它关掉、改成受控升级的前提。

场景二:刷共享账号凭证(多 bot 机队)

共享 Claude 账号的刷新脚本必须先把 ~/.claude/.credentials.json 伪过期,再让 claude 原地刷新。这几秒里:

  • 任何冷启动的 CLI 看到「过期 token」会自己去刷 → 轮换掉共享账号的 refresh token → 脚本这次刷新手里的旧 RT 当场作废 → 失败回滚 → 全队投毒;
  • 读隔离 bot 更糟:worker.ts 每次冷启动都把「最新凭证」复制进 per-bot 副本,伪过期的文件会被原样拷走。

同样形状的窗口还有:重建某个 bot 的工作区、账号被限流时不想再堆新会话、迁移数据目录。

做法

新增 core/spawn-freeze.ts:维护脚本写一份 <dataDir>/spawn-freeze.json 声明「T 之前不要起新 CLI」,daemon 在 forkWorker 里读它,命中就把这次 spawn 暂存(每个逻辑会话一个),解冻后自动重放。

这是一个「延迟」,不是「队列」——边界必须说清(见下方「已知限制」,review 后已按此重写代码注释与 --help):每个会话在窗口内的第一条消息会被延后重放;同会话的后续消息不排队,会打 WARN、--notify 时还在该会话回一条「请恢复后重发」。

botmux freeze --reason claude-update --for 300s --pid $$ --notify
botmux freeze --status     # 现在冻着吗、为什么、还剩多久
botmux freeze --release    # 解冻(幂等,可放 trap)

--notify:冻结期内收到消息时,回一条

🔧 维护中(claude-update),暂不启动新的 CLI 会话;约 218 秒后自动继续处理这条消息。

每个会话每次冻结只回一条(按投递锚点去重,不是按群——按群会让同群第二个话题里等着的人一条提示都收不到),下一次冻结是新窗口、会重新说一次。短窗口(如刷凭证的 5 秒)建议不开——静默几秒没人察觉,发提示反而吵;超过半分钟的窗口建议开,否则用户体感就是「bot 死了」。

升级 claude 的完整跑法

botmux freeze --reason claude-update --for 300s --pid $$ --notify
botmux suspend all                  # freeze 管「新的别起」,suspend 管「老的收干净」
npm --prefix <前缀> install -g @anthropic-ai/claude-code@latest
claude --version && claude -p "ping" --output-format json   # 冒烟:能起来 + 登录态没坏
botmux freeze --release             # 验过了 → 被 defer 的 spawn ≤1s 内自动重放
# 验不过 → 装回旧版本,再 release

顺序不能反:先 freeze 再 suspend,否则 suspend 完到 freeze 生效之间的空隙会被新消息拉起一个旧版 CLI。

真正的收益在冒烟那一步:验证不过的时候,用户一条会话都还没起——可以安静回滚,用户全程只知道「慢了两分钟」,而不是一群人同时撞坑。

几个刻意的选择

是数据,不是代码。 声明只能表达「T 前别起」,不能让 daemon 执行任何东西。这样纯 shell 脚本就能用,而影响面被限制成一个超时,而不是一个能在 daemon 进程里跑任意代码的扩展点。

三重失效 + 全路径 fail-open。 deadline / 声明进程退出(--pid $$kill -9 也能自愈)/ 按文件 mtime 算的 10 分钟硬上限;文件缺失、读坏、解析失败、字段越界一律当「无冻结」。冻几秒很便宜,永远起不来是事故。

与 device-isolation 的内存租约互补,不是替代。 那个租约只覆盖 acquire 那一刻在线的 daemon;而窗口里新启动的 daemon 会读到同一个文件并自我冻结。forkWorker 两个来源任一命中即 defer。

worker 侧也判一次。 worker.ts:8645(tmux restart)和 :10182(crash 后重试)直接调 spawnCli,不经过 forkWorker,daemon 侧闸门管不到。所以读隔离 provisioning 处也读一次声明:冻结期一律不写凭证副本(claude / codex 两支同此)。

--bot 可选。 只重建某个 bot 的工作区时,只冻它,其余 bot 照常服务;不给 --bot 就是全队。

已知限制(review 后明确保留,不做掩盖)

  1. 同会话第 2 条消息不排队,需重发。冻结期 forkWorker 提前 return、从不设 ds.worker,所以后续消息会走 worker-null re-fork 分支再被去重丢掉;worker 侧那套 pre-ready 输入缓冲此时并不存在(worker 根本没起来)。做成真队列需要连 messageId / 附件 / reaction 结算 / 结构化 CLI 输入一起缓冲并持久化——那是投递队列,不是闸门,刻意不做。改为把它变可见:每次都打 WARN,--notify 时该会话收到一条「这条不会被自动处理,请重发」。(不出声不行:后续 finishTurnReactions 会把 pending ✋ 批量翻成 ✅,用户会以为处理完了。)
  2. 冻结期 daemon 重启会丢掉已暂存的 spawn:声明在盘上,闭包在内存里。durable replay 同属上面那条的复杂度。
  3. pid 是协作标识,不是安全边界:只校验存活,同机同用户可以借别人的 pid(能写这个文件的人本来也能 --force)。它的职责是让运维自己的几个脚本别互相解除保护。
  4. 重叠的维护窗口不支持(不同 owner):第二个声明会被拒绝并报错,而不是静默覆盖。
  5. /adopt 会话不在范围内:那些 CLI 是用户自己起的,重连不产生新 CLI,窗口没有要保护的东西。

结论一句话:窗口要短

取舍(明写出来)

冻结期不写凭证副本,代价是首次 spawn 恰好撞进维护窗口的全新隔离 bot 可能撞登录页,解冻后第一次 spawn 自动同步(上限就是冻结自身的时限)。

之所以不「聪明一点、拷一份更安全的」:源凭证已过期时,无法从文件判断这是脚本的伪过期步骤(拷了就投毒)还是机器闲置(拷了才自愈)。一个 bot 短暂不可用 vs 共享账号被轮换导致全队掉线,取舍不接近。

freshestClaudeCred() 顺手补了结构校验(accessToken/refreshToken 存在且非空)。刻意按过期时间拒绝:token 过期而 RT 仍有效是完全正常的状态,拒绝复制会让 bot 再也无法自愈。

附带修复(同类缺陷,不同 gate)

deferWorkerSpawnDuringDeviceIsolation 的重放回调读的是可变ds.session.sessionId,而切 repo 会在同一个 ds 上整体替换 session(command-handler.ts:1652)→ 旧 entry 的守卫也会通过,为新会话起第二个 worker,把刚起来的顶掉。

这不是本 PR 引入的,但就在本 PR 扩展的同一行上方。两个 gate 现在都用入队时捕获的不可变 id + 重放双校验,切 repo 处显式 forgetDeferredSpawn(旧 id),并把这条不变式写进了文档注释。如果希望它单独成 PR,我拆出来。

测试

test/spawn-freeze.test.ts 31 例:deadline 到点 / pid 已死 / EPERM 当作存活 / mtime 硬上限 / 未来 mtime 拒绝(clamp 会让窗口每次读取都重新锚定,即永不过期)/ 符号链接拒绝(会借用别的文件的 mtime)/ 13 种坏输入 fail-open / scope 生效与空 scope 等于全队 / clear 幂等 / 拒绝覆盖别人仍生效的声明同 owner 可续期两个匿名写入者互不相认 / 按 owner pid 解冻 / 每会话只暂存一个(parked/dropped 语义)/ 空 prompt 的预热 spawn 会被真实 turn 顶掉、反之不行 / 按 scope 分别释放 / 会话关闭丢弃 / 无人释放也会因 deadline 重放 / 通知按锚点分 parked / dropped 各一条。

CLI 也实机验证过:三种缺值参数与未知参数报用法、--status 拒绝不相容参数、覆盖别人的窗口被拒、非 owner 解冻被拒、10 个并发写入者恰好 1 个成功(改成 link() 原子创建前是 10 个全成功)。

全量单测对照上游 07dfad9e 干净 worktree(先 build 再跑):

失败文件
上游基线 adopt-tmux-claude-wrapper-repro.smokechild-envcommand-handler
本分支 adopt-tmux-claude-wrapper-repro.smokecommand-handler + 若干轮换

本分支多出来的失败(不同轮次分别是 codex-app-runner.integrationcodex-app-threadsv3-hostskill-agentbuddy-installworkflow-c0-isolation单独跑全部通过,都是满载下的超时型 flaky;child-env 是基线里已知的 flaky。两边共有的真失败是 command-handler > /status(断言写死 :8800,环境相关)与 adopt-tmux smoke。零回归。

npx tsc --noEmit 干净。

codex 复审

五轮(每轮都在前一轮修复上又抓到东西,如实记下):

  1. P0 硬上限可被绕过 → 永久自锁statSync 跟随符号链接,mtime 也能 touch -t 改到未来,配 pid: 1(恒存活)+ 合法 deadline,三重失效同时失守。→ lstatSync 拒绝符号链接 + 拒绝未来 mtime(容忍 60s 时钟漂移)而不是 clamp。
  2. P1 重放认错会话(上面「附带修复」那条)。
  3. P1 冻结期首次 provisioning 无凭证 → 第一版修法(requireUnexpired ?? 回退)被 codex 第二轮指出是假修复:所有候选都过期时回退分支照样种进伪过期凭证,日志还宣称种的是「未过期的那份」。→ 改成上面那条更硬的规则。同轮还修了 clearSpawnFreeze 仍用 statSync 导致悬空符号链接删不掉。

第三轮确认前三点闭合。第四、五轮(在 review 意见落地之后)又抓到三处,都已修:

  1. 「拒绝覆盖」不是原子的(先 read 再 rename 的 TOCTOU)→ 改 link() 原子创建 + 有界重试;同时发现「同 owner」判定不能让 undefined === undefined 成立,否则每个匿名写入者都能替换别人(实测 10 个并发全成功,修后恰好 1 个)。顺带允许同 pid 续期,否则脚本连自己的窗口都改不了。
  2. 空 prompt 的 spawn 一旦被跳过会悄悄搞坏真实路径pendingRawInput 正是先 forkWorker(ds,'',false) 再等 prompt_ready 写 PTY,dashboard 唤醒/web 终端也走空 prompt。改成排队位有优先级——空 prompt 照常入队,但携带用户 turn 的请求会顶掉它,反之不成立。

已知未处理:per-bot 凭证副本「存在但已损坏」时,冻结期只按 existsSync 判断,仍会启动 CLI(既不 warn 也不拦)。这是改动前就有的行为,本 PR 不扩大范围去动它。

首个消费者

本机的凭证刷新脚本(运维脚本,不在上游):

写声明(--pid $$,trap 里 release)
botmux suspend all          # 窗口内既没有活 CLI,也起不了新 CLI
伪过期 → 刷新 → 校验 → 播种
release                     # 被 defer 的 spawn ≤1s 内自动重放

拿不到闸门时(老版本没有 freeze 子命令)脚本继续刷新——拿不到闸门是「小概率竞态」,拒绝刷新是「必定过期掉线」。

xu4wang and others added 3 commits July 29, 2026 01:53
刷 Claude 凭证时脚本会先把 live 凭证伪过期再逼 claude 刷新。这个窗口里任何冷启动的
CLI 都会看到「过期 token」并自己去刷,轮换掉共享账号的 refresh token,让脚本这次刷新
用的旧 RT 当场作废(失败回滚 → 全队投毒)。读隔离 bot 更糟:它每次冷启动都把「最新
凭证」复制进自己那份副本,伪过期的文件会被原样拷走。

新增 core/spawn-freeze.ts:维护脚本写一份 <dataDir>/spawn-freeze.json 声明「T 之前
不要起新 CLI」,daemon 在 forkWorker 里读它,命中就把这次 spawn 暂存(每会话一个,
与 device-isolation 同不变式),解冻后自动重放 —— 用户消息只是晚几秒,不会丢。

设计要点:
- 是数据不是代码:声明只能要求「T 前别起」,不能让 daemon 执行任何东西。既能被纯
  shell 脚本使用,影响面也被限制成一个超时而不是扩展点。
- 三重失效 + 全路径 fail-open:deadline / 声明进程退出 / 按文件 mtime 算的 10 分钟
  硬上限;文件缺失、读坏、解析失败、越界一律当「无冻结」。冻几秒很便宜,永远起不来
  是事故。
- 与 device-isolation 的内存租约互补:租约只覆盖 acquire 那刻在线的 daemon,而窗口
  里新启动的 daemon 会读到同一个文件并自我冻结。forkWorker 两个来源任一命中即 defer。
- 只拦新起,不动在跑的 CLI:拆掉现有 CLI 是 botmux suspend 的职责。
- worker 侧读隔离 provisioning 冻结期不覆盖 per-bot 凭证副本(claude + codex),
  堵住 worker 进程内部那两条不经过 forkWorker 的自重启路径。
- freshestClaudeCred() 补结构校验(accessToken/refreshToken 存在)。刻意不按过期时间
  拒绝:token 过期而 RT 仍有效是正常状态,拒绝复制会让 bot 再也无法自愈。

CLI:botmux freeze --reason X [--for 120s] [--pid $$] [--notify] [--bot <appId>]
      botmux freeze --status | --release

测试:test/spawn-freeze.test.ts 25 例(deadline/pid 死/EPERM 当活着/mtime 硬上限/
13 种坏输入 fail-open/scope/幂等 clear/每会话只暂存一个/按 scope 分别释放/会话关闭
丢弃/无人释放也会因 deadline 重放/通知每话题一条)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… 无凭证

1. [P0] 10 分钟硬上限可被绕过 → 永久自锁。statSync 跟随符号链接,mtime 又能被
   `touch -t` 改到未来,两者任一都能让 effectiveUntil 落在很远的将来,配上
   pid=1(恒存活)与合法 deadline,三重失效同时失守。改为 lstatSync 拒绝符号链接,
   并【拒绝】未来 mtime 而不是把它 clamp 到 now —— clamp 会在每次读取时重新锚定
   窗口,正是要防的那种永不过期。容忍 60s 时钟漂移。

2. [P1] 重放可能认错会话。deferred map 以入队时的 sessionId 为 key,但回调在执行时
   读的是可变的 ds.session.sessionId;切 repo 会在同一个 ds 上整体替换 session
   (command-handler.ts:1652),于是旧 entry 的守卫也会通过 → 为新会话起第二个
   worker,把刚起来的顶掉。改为入队时捕获不可变 id,重放时同时校验;切 repo 处
   显式 forgetDeferredSpawn(旧 id)。

3. [P1] 冻结期首次 provisioning 会让 bot 停在登录界面。daemon 的闸门只保证它读取
   那一刻没冻结,worker 真正 provisioning 时会重新读一次——冻结若在两者之间开始,
   凭证复制被跳过;若该 bot 还没有任何副本,就等于用空凭证启动。「跳过」只能表示
   「保留已有副本」,没有副本时改为播种(优先未过期的那份,避免propagate 脚本的
   伪过期文件),claude / codex 两支同此。

测试:+2 例(未来 mtime、符号链接),共 27 例。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ation 同类修复

1. 上一轮的 `requireUnexpired ?? 回退` 是假修复:所有候选都过期时回退分支会把
   伪过期凭证照样种进去,日志还宣称种的是「未过期的那份」。改成规则更硬的一条:
   【冻结期一律不写凭证副本】。因为「过期的源」到底是脚本的伪过期步骤(拷了就投毒)
   还是机器闲置(拷了才自愈),从文件上无法判定 —— 不猜。
   代价写进注释:首次 spawn 恰好撞进维护窗口的全新隔离 bot 可能撞登录页,解冻后
   第一次 spawn 自动同步(上限就是冻结自身的时限)。一个 bot 短暂不可用 vs 共享账号
   被轮换导致全队掉线,取舍不接近。requireUnexpired 参数随之删除。

2. clearSpawnFreeze 仍用 statSync,悬空符号链接会被判「不存在」而删不掉,与 reader
   的 lstat 语义不一致 → 一并改 lstatSync。

3. device-isolation 那个更早的 deferred gate 有同一类缺陷(重放时重读可变
   ds.session.sessionId)。它不是本次引入的,但就在本 PR 扩展的同一行上方,顺手用
   同一个不可变 id 修掉,并把这条不变式写进 deferWorkerSpawnDuringDeviceIsolation
   的文档注释。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xu4wang
xu4wang requested a review from deepcoldy as a code owner July 28, 2026 19:05
xu4wang added a commit to xu4wang/botmux that referenced this pull request Jul 28, 2026
伪过期期间冷启动的 CLI 会看到过期 token 并自己去刷 → 轮换掉 RT,让本脚本这次
刷新用的旧 RT 当场作废(→ 失败回滚 → 全队投毒);读隔离 bot 还会把伪过期文件
原样拷进自己那份副本。所以真要刷之前先 botmux freeze,EXIT trap 里 release。

- 只在「真要刷」之后才冻:每 30 分钟的 no-op 轮完全不碰闸门
- --pid $$:脚本一死立刻解冻(kill -9 也自愈);daemon 侧另有 10 分钟硬上限
- 刻意 fail-open:拿不到闸门(老版本 botmux 无此子命令)仍继续刷新 —— 小概率
  竞态 vs 必定过期掉线,后者更糟
- trap 里必须写 ${FROZE:-0}:set -u 下裸 $FROZE 会让 trap 自己炸掉,连锁都不释放

依赖 deploy/all 先带上 spawn-freeze(上游 PR deepcoldy#647)。本机 live 部署另行安排。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xu4wang xu4wang changed the title feat(spawn-freeze): 运维声明式 CLI spawn 闸门 —— 维护窗口内不起新 CLI,解冻后自动重放 feat(spawn-freeze): 维护窗口内不起新 CLI(升级 claude / 刷凭证),并告知发消息的人 Jul 28, 2026
@deepcoldy

Copy link
Copy Markdown
Owner

Claude 首次 review — PR #647 feat(spawn-freeze)

HEAD 6f16c54pnpm build ✅|tsc --noEmit ✅|spawn-freeze.test.ts 27/27 ✅|全量单测对照 baseline 零回归(失败集合=已知 env/load flaky:browser-e2e×15、coco×2、multi-bot-session 的 vi.mock、herdr-web-terminal 的 browser-grid waitFor 超时,均与本 PR 无关)。

白话:这个 PR 在做什么

维护动作(升级 claude、刷共享账号凭证、重建 bot 工作区)会开一个「半成品窗口」——这几秒/几分钟里如果有人在飞书发消息,botmux 冷启动的新 CLI 会撞上半安装的包 / 伪过期的 token,轻则 bot 坏、重则把共享账号的 refresh token 轮换掉、全队掉线。以前 botmux suspend 只能收掉「已在跑」的 CLI,拦不住「下一条消息立刻起一个新的」。

本 PR 加一个声明式闸门

  1. 数据而非代码:维护脚本写一份 <dataDir>/spawn-freeze.jsonbotmux freeze --reason … --for … --pid $$ --notify),声明「T 之前别起新 CLI」。纯 shell 脚本就能用,影响面被限制成一个超时。
  2. daemon 侧拦截forkWorker 起 CLI 前读这份声明,命中就把这次 spawn 暂存(每个会话一个),解冻后自动重放。挨着已有的 device-isolation 闸门,同款 defer+replay 形状,但落盘 → 窗口中途新启动的 daemon 也会自我冻结。
  3. worker 侧再判一次:读隔离 provisioning 处(claude/codex 两支)冻结期一律不写凭证副本——因为 daemon 闸门清掉后、provisioning 真正执行前,freeze 可能才生效。
  4. 三重失效 + 全路径 fail-open:deadline / 声明进程 --pid 退出 / 按 mtime 算的 10 分钟硬上限;文件缺失/读坏/越界一律当「无冻结」。拒绝符号链接、拒绝未来 mtime(防 touch -t 把硬上限锚到未来 = 永久自锁)。
  5. --notify:冻结期收到消息回一条「🔧 维护中…约 N 秒后自动继续」,每个 chat 每次冻结只回一条。
  6. 附带修复:device-isolation 重放读的是可变ds.session.sessionId,切 repo 会整体替换 session → 旧 entry 守卫误通过、给新会话起第二个 worker。两个闸门现在都用入队时捕获的不可变 id + 重放双校验,切 repo 处显式 forgetDeferredSpawn

工程质量很高:核心模块注释详尽、27 例测试覆盖了三重失效/坏输入/符号链接/未来 mtime/scope/幂等等边界,codex 三轮复审已抓掉硬上限绕过、重放认错会话、冻结期假修复三个真缺陷。下面是我独立核出的、目前还没被处理的点。


🟠 P1(待定策)— 冻结窗口内同一会话的第二条消息会被静默丢弃,与 PR 头号承诺「消息不丢」矛盾

PR 反复承诺:「用户的消息只是晚几秒/几分钟被处理,不会丢」。这个承诺对每个会话的第 1 条成立,对第 2 条起不成立

机制(已逐行核实):

  • 冻结期 forkWorker 命中闸门后提前 return,从不设 ds.worker(真正的 ds.worker = worker 在 worker-pool.ts:2538,早于它的 gate 在 :2158 就返回了)。
  • 于是第 2 条消息路由到同会话时,看到 ds.worker === null,进入 daemon.ts:16641 的「worker 不在 → re-fork」分支,再次调 forkWorker
  • deferSpawnDuringFreezesessionId 去重(deferredSpawns.has 已 true)→ 第 2 条的 replay 闭包被丢弃。解冻后只重放第 1 条。测试 test/spawn-freeze.test.ts:173 正是把这条语义钉死['first','other']'second' 永不出现)。

为什么"pending-input machinery"这条注释在这里不成立:那套机制活在 worker 进程里(worker 对 pre-ready 输入有缓冲,见 worker.ts:8931)。非冻结时第 2 条能活,是因为第 1 条的 forkWorker 同步设了 ds.worker,第 2 条走 live-worker 分支 sendWorkerInput 交给 worker 缓冲。冻结时 worker 从没起来,没有任何进程接住第 2 条。所以这是 freeze 新引入的丢失,不是既有行为的延续。

为什么静默、更隐蔽:card-off 会话里,第 2 条在 forkWorker 之前已被 noteTurnReceived 打了 ✋ ack;解冻后第 1 条跑完 idle,finishTurnReactions(worker-pool.ts:4164)把所有 pending ✋ 翻成 ✅——于是被丢的第 2 条显示「已完成」,用户和运维都看不出丢了。

可达性:PR 自己的 runbook 是 freezesuspend all → 干活 → releasesuspend all每个会话的 ds.worker 置 null(worker-pool.ts:1561)。所以窗口内每个活跃会话的每条消息都走 re-fork 分支;一个繁忙群在 5 分钟刷凭证窗口里,同会话连发 2 条完全现实。仅 pendingRepo(正在选 repo)的会话例外——那种 msg2 进 pendingFollowUps 缓冲(daemon.ts:16353)、能保住;pinned(oncall/defaultWorkingDir)/doc/scheduled 会话丢。

公允地说:这与既有的 device-isolation 闸门是同款去重语义(deferWorkerSpawnDuringDeviceIsolation 也丢第 2 条)。区别在 device-isolation 窗口 ≤30s 且一次性(设备注册),ops-freeze 窗口达 10min 且常态(凭证刷新按 PR 描述是例行)——暴露面大得多,且 PR 新立了一条它并不完全兑现的 invariant

建议三选一(请 @codex 与 @申晗 定夺是 blocker 还是「记录为已知限制」):

  • (A) 最小:把承诺改准——「冻结期每个会话的首条消息会被暂存重放;后续消息在此窗口内可能需要重发」,并在 --notify 文案里体现。零代码改动。
  • (B) 折中:去重时若已有 entry,用最新一次的 replay 闭包覆盖旧的deferredSpawns.set 不加 has 判断)。这样重放的是最后一条(含 daemon.ts re-fork 分支已把 queuedPrompt 前置的合并内容),比丢弃强;但仍非「全都不丢」。
  • (C) 彻底:冻结期把后续消息塞进 ds.pendingFollowUps(就像 pendingRepo 那样),首条 replay 时一并 fold。改动最大。

不管选哪个,建议补一条测试:同会话第 2 条在解冻后其内容确实到达(或明确断言「设计上丢弃且承诺已相应措辞」),把这条 invariant 钉在测试里而不是只在 PR 描述里。


🟡 P3(次要 UX)— --notify 实际是「每 chat 一条」,不是 PR 说的「每话题一条」

worker-pool.ts:2140 chatKey = ds.session.chatId ?? ds.session.sessionId。thread-scope 会话的 chatId群 oc_,同群多个话题共享它。所以一个群里有 5 个活跃话题时,只有第一个撞闸门的话题的用户收到「🔧 维护中」,其余 4 个话题静默延迟、用户不知情。

  • spawn-freeze.ts:81 注释写的是「once per chat」(与代码一致),但 PR 正文说「每个话题每次冻结只回一条」。两处措辞不一致,实际行为是「每群一条」。
  • 若想真正「每话题一条」,key 应用 sessionAnchorId(ds)(thread 用 rootMessageId、chat 用 chatId),与通知投递用的 anchor 对齐。若刻意「每群一条」防刷屏,则请改 PR 正文措辞。低优先级,非数据问题。

⚪ Nit — --reason 会吞掉紧跟的 flag

cli.ts flagValue('--reason') 直接返回 argv[i+1],所以 botmux freeze --reason --notify 会把 reason 设成 "--notify"(然后 --notify 不再生效)。运维手滑面,影响很小;可在 reason 落库前拒绝 -- 开头值。


已核对无问题

  • 跨进程 dataDir 契约:CLI 写、daemon+worker 读都走 resolveBotmuxDataDir();worker fork env 带 SESSION_DATA_DIR(worker-pool.ts:2358),三方落点一致 ✅
  • 符号链接拒绝(lstat)、未来 mtime 拒绝而非 clamp、mtime 硬上限、pid EPERM=alive、全路径 fail-open ✅
  • worker 侧 provisioning 冻结期不写凭证副本(claude/codex 双支)、freshestClaudeCred 结构校验(accessToken/refreshToken 非空、按过期拒绝)✅
  • device-isolation 附带修复:不可变 id 捕获 + 重放双校验 + 切 repo forgetDeferredSpawn(旧 id)
  • 通知投递用 sessionAnchorId(ds) + fallbackTurnId(ds, gateTurnId),与流式卡片同锚,正确跟进触发轮所在话题、不泄漏群顶层 ✅

结论:实现扎实,闸门/fail-open/凭证保护都对。唯一实质问题是 P1:「消息不丢」对同会话第 2 条不成立、且被 ✅ ack 掩盖。请 @codex 复审确认这条机制判断,并请 @申晗 拍板 A/B/C 哪种收口——未获申晗确认前不合码

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner

Codex 复审结论(HEAD 6f16c549

结论:Claude 首次 review 的 P1 机制判断成立,而且我继续核到两个会影响闸门核心安全/可靠性承诺的边界。当前建议维持 不合码、等申晗定策;若仍保留「消息不丢」承诺,A/B 都不足,至少需要 C 方向并补齐 daemon 重启后的 durable recovery。

🟠 P1(确认)同一会话冻结期第 2 条起静默丢失

调用链确认如下:

  1. forkWorker()worker-pool.ts:2120 命中 ops freeze,~2148 直接 return;真正的 ds.worker = worker~2538,因此整个冻结窗口 ds.worker 仍为空。
  2. 同会话下一条消息进入 daemon.ts:16641 的 worker-null re-fork 分支,重新构建当前消息的 replay 闭包。
  3. deferSpawnDuringFreeze()spawn-freeze.ts:294sessionId 去重,只保留第一次闭包;后续闭包没有进入任何 worker/pending queue。
  4. test/spawn-freeze.test.ts:173-193 明确断言最终只有 firstother,没有 second。这里的注释「later turns are already retained」与真实路径不符:worker 尚未创建,worker 的 pre-ready pending-input machinery 不存在。
  5. card-off 路径先在 noteTurnReceived() 登记每条 ✋,首条解冻完成后 finishTurnReactions() 会批量把该 session 的 pending reaction 翻成 ✅,所以被丢的后续消息还会呈现为“已完成”。

这个问题在公共 forkWorker 层,覆盖所有 CLI;workerless 的 PTY/Tmux/Herdr 等会话、普通 IM/doc/scheduler 等复用 fork 的入口都要按各自重复触发方式核对,不是 Claude 单一适配器问题。

🟠 P1(新增)冻结中 daemon 重启,连首条 deferred turn 也没有 durable replay

spawn-freeze.json 只持久化“闸门”,真正要重放的 { promptInput, turnId, replay closure } 只在 spawn-freeze.ts:251 的进程内 Map。daemon 在冻结期间重启/崩溃后:

  • 声明文件仍在,新 daemon 仍会自我冻结;
  • 旧进程的 deferred map 已消失;
  • lastCliInput / message queue 只是历史元数据,目前 restore 路径不会把它重建成待投递 turn(restoreActiveSessions 的自动 reattach 也只调用 forkWorker(ds, '', true);被 suspend all 标记的 suspendedColdResume 会保持 lazy)。

我用模块状态丢失模拟验证:重启前 deferredSpawnCount=1, freezeOnDisk=true,重置进程状态后变为 0, true。因此「daemon 在窗口中途新启动也会冻结」成立,但「重启前已接收的消息会在解冻后继续」不成立。现有 messageQueue.readUnread() 也没有恢复调用点。

这条建议补集成测试:defer turn → daemon state loss/restart → release → 原 turn 恰好投递一次

🟠 P1/P2(新增)单文件声明没有 owner/lease,并发维护会互相解除保护

writeSpawnFreeze() 无条件 rename 覆盖唯一的 spawn-freeze.jsonclearSpawnFreeze() 无条件删除当前文件。实测:

  1. A 写 --bot cli_a
  2. B 写 --bot cli_b,A 的 scope 立即消失;
  3. A 的 trap 执行 --release,又把 B 的声明删掉。

也就是说两个维护脚本重叠时,后写者先让前写者失去保护,先结束者再让后写者提前解冻。--bot 暴露了天然可并行的维护场景,所以不能只靠“运维不要并发”隐含规约兜底核心安全属性。

建议声明改为带随机 lease/token 的多条记录(生效范围取 union,release 只删自己的 lease);最低限度也要 active 时拒绝覆盖,并做 compare-and-delete owner 校验。

次要项确认/补充

  • Claude 的 --notify 判断成立:chatKey = ds.session.chatId ?? ... 是每群一条,不是每话题;真正按话题应与投递锚点一致使用 sessionAnchorId(ds)
  • --reason --notify 会把 reason 存成 --notify,实测成立。
  • 另有一个同类但 blast radius 更大的 CLI 输入:--bot 缺值会被静默忽略,最终变成冻结全部 bot(实测 freeze --reason scope-typo --bot --notify 的 status 显示“范围 全部 bot”)。建议所有 value flag 统一拒绝缺值/下一个 token 以 -- 开头,未知参数也 fail-fast。

A / B / C 判断

  • A 仅改措辞:只能把当前行为改成已知限制,无法继续承诺“消息不丢”;而且 daemon 重启时连“每会话首条保留”都不成立。
  • B 最新闭包覆盖:只是从“丢后面的”变成“丢前面的”,不满足可靠投递;还会让 reaction/turnId/structured Codex input 的归属更难解释。
  • C 缓冲/fold:方向正确,但不要只拼字符串。需要保留每条 turn 的 messageId/turnId、sender、attachments/mentions、Codex clean input、reaction settlement,并考虑持久化/幂等恢复,否则 daemon 重启仍丢。

实际验证

  • pnpm build
  • pnpm exec vitest run --project unit test/spawn-freeze.test.ts ✅ 27/27
  • pnpm test:11115 passed / 10 skipped;仅 group-join-shared-routing beforeAll 满载 10s timeout,单独重跑 5/5 ✅,与本 PR 路径无关
  • git diff --check origin/master...HEAD
  • temp SESSION_DATA_DIR 下实际跑 CLI 验证:并发声明覆盖/跨 owner release、缺值 --bot 扩为 fleet-wide、--reason --notify 被接受,均可复现

未改代码、未合码、未重启 live daemon。

@deepcoldy

Copy link
Copy Markdown
Owner

双审收敛 — Claude + Codex 二次复审汇总

Codex 复审确认我首审的 P1 机制判断完全成立,且建议按 blocker 处理(不止改文案)。Codex 另独立核出两处更深的遗漏,我已逐一在代码里复核全部成立。汇总如下,供 @申晗 拍板。

🔴 P1(双审一致,blocker)— 冻结期同会话第 2 条消息静默丢弃

(详见上一条评论)冻结期 forkWorker 命中闸门提前 return、从不设 ds.worker → 第 2 条走 worker-null re-fork → deferSpawnDuringFreeze 按 sessionId 去重只留首个闭包,test/spawn-freeze.test.ts:173 已把「无 second」钉死成语义。card-off 会话里第 2 条的 ✋ 还会被 finishTurnReactions 批量翻 ✅,把丢失伪装成「已完成」。Codex 独立复核一致。

🔴 P1-new(Codex 抓,我已复核成立)— daemon 在冻结期重启,连首条也丢

  • deferredSpawns纯进程内 Map(spawn-freeze.ts:251),{promptInput, turnId, replay} 只活在内存。
  • 冻结中重启:磁盘上的 freeze 文件仍 active,但 deferred Map 从 1 归零。restoreActiveSessions 只对「有存活后端 pane 的会话」或 lazy-pty 做 re-attach(staggeredRecoveryFork(…forkWorker(ds,'',true)),session-manager.ts:1526)——空 prompt、纯重连。一个从没起过 CLI 的 deferred 冷 spawn 没有 pane,restore 不会把它的首条消息内容重建成待投递 turn(无任何 queuedPrompt/lastCliInput 承载它)。
  • 结果:重启后该会话首条消息内容彻底丢失,只能等下一条新消息才恢复。这比 P1 更硬——P1 至少首条能重放,这里首条也没了。

🔴 P1-new(Codex 抓,我已复核成立)— 并发 freeze 无 owner/lease,互相踩踏

  • writeSpawnFreeze(spawn-freeze.ts)对唯一文件无条件覆盖clearSpawnFreeze 无条件删除。二者都不校验 owner。
  • 场景:运维 A freeze --bot X,运维 B 随后 freeze --bot Y → B 的写直接覆盖 A 的声明,A 对 X 的保护当场消失;接着 A 的 trap --release无条件删掉 B 的文件,B 对 Y 的保护也没了。
  • 尤其两个 --bot 维护并行时,核心保护会在双方都以为「我锁着」时提前失效——正是这个闸门要防的投毒窗口重新敞开。

🟡 次要(双审一致)

  • --notify 实为「每 chat 一条」非「每话题一条」:chatKey=ds.session.chatId ?? sessionId(worker-pool.ts:2140),thread-scope 会话共享群 oc_,一群多话题只首个收到通知。
  • 缺值 --bot 静默退化成「冻结全部 bot」(Codex 抓,我复核成立):cli.ts 的 forEach 要求 argv[i+1] && !startsWith('--'),所以 botmux freeze --reason x --bot(尾随无值)→ larkAppIds 为空 → fleet-wide。运维想只冻一个 bot 却手滑漏值,会误冻全队,且无任何提示。建议缺值/未知 flag 直接报错。
  • --reason 会吞掉紧跟的 flag--reason --notify → reason="--notify")。

收口方向(请 @申晗 拍板)

  • A(改准承诺+措辞):把「消息不丢」降级为「每会话首条在窗口内被暂存重放;后续消息可能需重发」。但 Codex 指出——重启场景下连首条都说不准,所以 A 的措辞还得再弱化。零代码、最诚实,但保护力最低。
  • B(去重用最新闭包覆盖旧的)deferredSpawns.set 不加 has 判断,重放最后一条(含 re-fork 分支已合并的内容)。比丢弃强,仍非「全不丢」,且不解决重启丢失与并发踩踏
  • C(彻底,Codex 与我共识的正确方向):冻结期后续消息塞进 ds.pendingFollowUps(像 pendingRepo 那样),首条 replay 时 fold;并需持久化逐 turn 的 turnId/sender/attachments/Codex clean-input/reaction,做到重启幂等恢复;并发问题另加 owner/lease(类似 device-isolation 的 leaseId)。改动最大,但才真正兑现「消息不丢」。

无论选哪个,建议补测试把最终 invariant 钉死(而非只写在 PR 描述里):重启后首条是否到达、并发 freeze 是否互不干扰、同会话第 2 条的最终归属。

双审均未改代码、未合码、未重启 live daemon。等 @申晗 决定 A/B/C(及是否作为 blocker 卡合并)。

验证:Claude 侧 build✅ tsc✅ spawn-freeze 27/27✅ 全量零回归;Codex 侧 build✅ spawn-freeze 27/27✅ 全量 11115 passed(仅一例满载 beforeAll timeout,单跑 5/5✅)diff-check✅。

xu4wang and others added 3 commits July 29, 2026 09:46
两位 reviewer 的 P1 判断成立,我核过调用链:冻结期 forkWorker 提前 return,从不设
ds.worker,所以同会话第 2 条消息会走 daemon.ts 的 worker-null re-fork 分支,再被
sessionId 去重丢掉。我原来那句「later turns are already retained by the session's
pending-input machinery」在冻结路径下是错的 —— 那套缓冲活在 worker 进程里,而 worker
从没起来。这是我写错的注释,不是既有行为的延续。

按定策走 A(不做 turn 缓冲/持久化,那会把闸门变成投递队列),但把限制说清、把静默变可见:

- 模块头改写成精确的「保证什么、不保证什么」:每会话第一条延后重放;同会话后续消息
  不排队、需重发;冻结期 daemon 重启会丢掉已暂存的 spawn(声明在盘上,闭包不在)。
- deferSpawnDuringFreeze 返回 { freeze, parked }。parked=false(本会话已有一条在排队)
  时 forkWorker 打 WARN,--notify 开着就在该会话回一条「这条不会被自动处理,请重发」。
  必须出声:后续 finishTurnReactions 会把 pending ✋ 批量翻成 ✅,不报就等于骗用户。
- 通知去重键从 chatId 改成 sessionAnchorId(投递锚点):按群去重会让同群第二个话题里
  等着的人一条提示都收不到。parked / dropped 两类各说一次。

顺带修 review 指出的输入与并发问题(纯校验,非新机制):

- CLI 所有 value flag 缺值一律 fail-fast,未知参数也拒绝。此前 `--reason --notify` 会把
  reason 存成 "--notify";更糟的是 `--bot` 缺值被静默忽略 → 退化成【冻结全队】。
- writeSpawnFreeze 拒绝覆盖仍生效的声明(--force 显式接管),clearSpawnFreeze 支持
  ownerPid 只删自己那份。此前两个维护脚本重叠会互相解除保护:后写者顶掉前者 scope,
  先结束者又把后者的删掉。刻意不做多记录 lease / scope union —— 重叠窗口不支持,报错就好。
  过期声明不算冲突(否则一份被遗弃的文件会挡住下一次维护)。

测试 29 例(+并发拒绝覆盖、+按 owner pid 解冻、+parked/dropped 语义与通知分类)。
CLI 六种情况实机验证过:三种缺值/未知参数报用法、正常冻结、覆盖被拒、非 owner 解冻被拒。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. 并发保护原来是 TOCTOU(先 read 再 rename),两个脚本同时通过检查仍会互相覆盖。
   改成 link() 原子创建 + 有界重试:link 在目标存在时失败,所以「检查 + 发布」是一步。
   实测 10 个并发写入者:修前 10 个全成功,修后恰好 1 个成功。
   ⚠️ 同 owner 判定必须两边都写了 pid —— undefined === undefined 会让每个匿名写入者都
   能替换别人(正是上面 10 个全赢的原因)。

2. 同一个脚本续期/修改自己的窗口原来会被自己挡住(只能 --force)。现在同 pid 直接放行,
   不同 owner 或任一方匿名一律冲突。过期声明照旧不算冲突。

3. 空 prompt 的 spawn(restore 重连 / 预热)会占掉该会话唯一的排队位,导致紧随其后的
   【第一条真实用户消息】被丢 —— 文档声称的「每会话第一条会重放」因此不恒真。现在冻结期
   直接跳过这类 spawn:会话保持 worker-less,下条消息冷启动,与 suspend 后的行为一致。

4. --status / --release 不再静默接受 --reason/--for/--bot/--notify/--force。

另外把「/adopt 会话不在闸门范围内」写进模块头:那些 CLI 是用户自己起的,重连不产生新 CLI,
维护窗口没有要保护的东西。ownerPid 遇到「合法但无 pid」的声明仍会删除 —— 没写 pid 就没有
所有权主张,这是明写的取舍,不是遗漏。

测试 30 例(+同 owner 续期 / 匿名写入者互不相认 / 不同 owner 冲突)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
上一轮为了不让预热 spawn 占掉排队位,直接在冻结期跳过空 prompt 的 spawn。codex 指出这会
悄悄搞坏真实路径:command-handler 的 pendingRawInput 正是先 forkWorker(ds,'',false)、
等 prompt_ready 再把命令写进 PTY;dashboard 唤醒/web 终端也走空 prompt,跳过后它们会报
成功却没有 worker,终端等 10 秒超时。

改成排队位有优先级:空 prompt 照常入队(它们确实需要 CLI),但携带用户 turn 的请求会
【顶掉】它(superseded,日志记一笔);反之不成立,后来的空 prompt 不会顶掉真实 turn。
这样既不丢唤醒/raw 命令,也保住「窗口内第一条真实消息会被重放」。

另外两处:
- writeFileSync 移进 try:半途写失败(磁盘满/EIO)也不会留下 tmp。
- --status 不再静默接受 --pid(此前被忽略)。

pid 的定位在模块头写清:它只是协作标识、不是安全边界(只校验存活;能写这个文件的人本来
也能 --force)。它的作用是让运维自己的多个脚本别互相解除保护,仅此而已。

测试 31 例(+顶替语义:预热入队 → 真实 turn 顶掉 → 后续空 prompt 顶不掉,解冻只重放真实那条)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xu4wang

xu4wang commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

定策:走 A(不做 turn 缓冲),但把限制说清、把静默丢失变可见

感谢两份 review,P1 的机制判断成立,我逐行核过:冻结期 forkWorker 提前 return、从不设 ds.worker,所以同会话第 2 条走 daemon.ts 的 worker-null re-fork 分支,再被 sessionId 去重丢掉。我原来那句注释「later turns are already retained by the session's pending-input machinery」在冻结路径下就是错的——那套缓冲活在 worker 进程里,而 worker 从没起来。这是我写错的注释,不是既有行为的延续。感谢指出,尤其是 ✋→✅ 那条:不出声就等于骗用户。

已推 d23f5d16

采纳(A + 把静默变可见)

  • 不再承诺「消息不丢」。模块头、--help、PR 正文都改写成精确的「保证什么、不保证什么」,并新增「已知限制」小节:同会话第 2 条不排队需重发;冻结期 daemon 重启会丢暂存的 spawn;pid 是协作标识不是安全边界;重叠窗口不支持;/adopt 不在范围内。
  • deferSpawnDuringFreeze 返回 { freeze, parked }parked: false每次都打 WARN--notify 开着就在该会话回一条「这条不会被自动处理,请在约 N 秒后重发」。
  • 通知去重键从 chatId 改成 sessionAnchorId(= 投递锚点)。按群去重会让同群第二个话题里等着的人一条提示都收不到;parked / dropped 两类各说一次。

顺带修掉 review 指出的输入与并发问题(纯校验/纠错,不是新机制)

  • CLI 所有 value flag 缺值 fail-fast,未知参数、--status/--release 带不相容参数一并拒绝。此前 --reason --notify 会把 reason 存成 "--notify";更糟的是 --bot 缺值被静默忽略 → 退化成冻结全队
  • 写入改成原子 CASlink() 创建(目标存在即失败)+ 有界重试,取代「先 read 再 rename」的 TOCTOU。实测 10 个并发写入者:改前 10 个全成功,改后恰好 1 个
    • 这里还踩到一个自己挖的坑:「同 owner」判定不能让 undefined === undefined 成立,否则每个匿名写入者都被当成同一个 owner,可以互相替换(正是 10 个全赢的原因)。现在要求两边都写了 pid。
    • 同时允许同 pid 续期/改窗口,否则脚本连自己的声明都改不了(只能 --force)。过期声明不算冲突。
  • --release --pid $$ 只删自己那份not_owner 时报错不删)。
  • 刻意不做 multi-record lease / scope union:不同 owner 的重叠窗口就报错,不猜。

第二轮反馈里我判断为「刻意保留」的两点

  • ownerPid 遇到「合法但无 pid」的声明仍会删除:没写 pid 就没有所有权主张,这是明写的取舍。
  • pid 不是安全身份:同机同用户可以借别人的 pid——但能写这个文件的人本来也能 --force,所以随机 token 只是把同一层信任换个形式。已在模块头写明它只是「让运维自己的几个脚本别互相解除保护」。

你们没提、但我在落地过程中自己撞到的一处

为了不让预热 spawn 占掉排队位,我一版改成「冻结期跳过空 prompt 的 spawn」——这会悄悄搞坏真实路径command-handlerpendingRawInput 正是先 forkWorker(ds, '', false)、等 prompt_ready 再把命令写进 PTY;dashboard 唤醒 / web 终端也走空 prompt,跳过后会报成功却没有 worker。

改成排队位有优先级:空 prompt 照常入队(它们确实需要 CLI),但携带用户 turn 的请求会顶掉它(superseded,日志记一笔);反之不成立。这样既不丢唤醒/raw 命令,也让「窗口内第一条真实消息会被重放」重新成立。

测试

  • test/spawn-freeze.test.ts 31 例(新增:拒绝覆盖别人的声明 / 同 owner 续期 / 两个匿名写入者互不相认 / 按 owner pid 解冻 / parked–dropped 语义 / 空 prompt 被真实 turn 顶掉且反之不行)
  • 全量单测对照上游 07dfad9e 干净 worktree(先 build 再跑)零回归:本分支多出的失败每轮换人(codex-app-*v3-hostskill-agentbuddy-installworkflow-c0-isolation),单独跑全部通过,是满载下的超时型 flaky;两边共有的真失败是 command-handler > /status(断言写死 :8800)与 adopt-tmux smoke
  • tsc --noEmit 干净;CLI 六类情况实机验证(见正文)

仍然按 review 建议没做

B(最新闭包覆盖 —— 只是从丢后面变成丢前面)、C(turn 缓冲 + 持久化 + 幂等恢复)、冻结期 daemon 重启的 durable replay。这三个都会把闸门变成投递队列;如果 maintainer 认为「消息不丢」必须成立,那我同意 C 是唯一正确方向,但它应该是独立 PR,而不是塞进这个闸门里。

另外「附带修复」那条(device-isolation 重放读可变 ds.session.sessionId)随时可以拆成单独 PR,说一声我就拆。

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.

2 participants