feat(spawn-freeze): 维护窗口内不起新 CLI(升级 claude / 刷凭证),并告知发消息的人 - #647
Conversation
刷 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>
伪过期期间冷启动的 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>
Claude 首次 review — PR #647
|
|
To use Codex here, create a Codex account and connect to github. |
问题
维护动作会让「此刻新起的 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原地刷新。这几秒里:worker.ts每次冷启动都把「最新凭证」复制进 per-bot 副本,伪过期的文件会被原样拷走。同样形状的窗口还有:重建某个 bot 的工作区、账号被限流时不想再堆新会话、迁移数据目录。
做法
新增
core/spawn-freeze.ts:维护脚本写一份<dataDir>/spawn-freeze.json声明「T 之前不要起新 CLI」,daemon 在forkWorker里读它,命中就把这次 spawn 暂存(每个逻辑会话一个),解冻后自动重放。用户的消息只是晚几秒/几分钟被处理,不会丢。--notify:冻结期内收到消息时,回一条每个话题每次冻结只回一条(否则一个活跃群会被刷屏),下一次冻结是新窗口、会重新说一次。短窗口(如刷凭证的 5 秒)建议不开——静默几秒没人察觉,发提示反而吵;超过半分钟的窗口建议开,否则用户体感就是「bot 死了」。
升级
claude的完整跑法顺序不能反:先 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就是全队。取舍(明写出来)
冻结期不写凭证副本,代价是首次 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.ts27 例:deadline 到点 / pid 已死 / EPERM 当作存活 / mtime 硬上限 / 未来 mtime 拒绝(clamp 会让窗口每次读取都重新锚定,即永不过期)/ 符号链接拒绝(会借用别的文件的 mtime)/ 13 种坏输入 fail-open / scope 生效与空 scope 等于全队 / clear 幂等 / 每会话只暂存一个 / 按 scope 分别释放 / 会话关闭丢弃 / 无人释放也会因 deadline 重放 / 通知每话题一条。全量单测对照上游
07dfad9e干净 worktree(先 build 再跑):adopt-tmux-claude-wrapper-repro.smoke、child-env、command-handleradopt-tmux-claude-wrapper-repro.smoke、command-handler+ 若干轮换本分支多出来的失败(不同轮次分别是
codex-app-runner.integration、codex-app-threads、v3-host、skill-agentbuddy-install、workflow-c0-isolation)单独跑全部通过,都是满载下的超时型 flaky;child-env是基线里已知的 flaky。两边共有的真失败是command-handler > /status(断言写死:8800,环境相关)与 adopt-tmux smoke。零回归。npx tsc --noEmit干净。codex 复审
三轮,前两轮各抓到真缺陷:
statSync跟随符号链接,mtime 也能touch -t改到未来,配pid: 1(恒存活)+ 合法 deadline,三重失效同时失守。→lstatSync拒绝符号链接 + 拒绝未来 mtime(容忍 60s 时钟漂移)而不是 clamp。requireUnexpired ?? 回退)被 codex 第二轮指出是假修复:所有候选都过期时回退分支照样种进伪过期凭证,日志还宣称种的是「未过期的那份」。→ 改成上面那条更硬的规则。同轮还修了clearSpawnFreeze仍用statSync导致悬空符号链接删不掉。第三轮确认三点闭合、无新增缺陷。
已知未处理:per-bot 凭证副本「存在但已损坏」时,冻结期只按
existsSync判断,仍会启动 CLI(既不 warn 也不拦)。这是改动前就有的行为,本 PR 不扩大范围去动它。首个消费者
本机的凭证刷新脚本(运维脚本,不在上游):
拿不到闸门时(老版本没有
freeze子命令)脚本继续刷新——拿不到闸门是「小概率竞态」,拒绝刷新是「必定过期掉线」。