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. |
Codex 复审结论(HEAD
|
双审收敛 — Claude + Codex 二次复审汇总Codex 复审确认我首审的 P1 机制判断完全成立,且建议按 blocker 处理(不止改文案)。Codex 另独立核出两处更深的遗漏,我已逐一在代码里复核全部成立。汇总如下,供 @申晗 拍板。 🔴 P1(双审一致,blocker)— 冻结期同会话第 2 条消息静默丢弃(详见上一条评论)冻结期 🔴 P1-new(Codex 抓,我已复核成立)— daemon 在冻结期重启,连首条也丢
🔴 P1-new(Codex 抓,我已复核成立)— 并发 freeze 无 owner/lease,互相踩踏
🟡 次要(双审一致)
收口方向(请 @申晗 拍板)
无论选哪个,建议补测试把最终 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✅。 |
两位 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>
定策:走 A(不做 turn 缓冲),但把限制说清、把静默丢失变可见感谢两份 review,P1 的机制判断成立,我逐行核过:冻结期 已推 采纳(A + 把静默变可见)
顺带修掉 review 指出的输入与并发问题(纯校验/纠错,不是新机制)
第二轮反馈里我判断为「刻意保留」的两点
你们没提、但我在落地过程中自己撞到的一处为了不让预热 spawn 占掉排队位,我一版改成「冻结期跳过空 prompt 的 spawn」——这会悄悄搞坏真实路径: 改成排队位有优先级:空 prompt 照常入队(它们确实需要 CLI),但携带用户 turn 的请求会顶掉它( 测试
仍然按 review 建议没做的B(最新闭包覆盖 —— 只是从丢后面变成丢前面)、C(turn 缓冲 + 持久化 + 幂等恢复)、冻结期 daemon 重启的 durable replay。这三个都会把闸门变成投递队列;如果 maintainer 认为「消息不丢」必须成立,那我同意 C 是唯一正确方向,但它应该是独立 PR,而不是塞进这个闸门里。 另外「附带修复」那条(device-isolation 重放读可变 |
问题
维护动作会让「此刻新起的 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 暂存(每个逻辑会话一个),解冻后自动重放。这是一个「延迟」,不是「队列」——边界必须说清(见下方「已知限制」,review 后已按此重写代码注释与
--help):每个会话在窗口内的第一条消息会被延后重放;同会话的后续消息不排队,会打 WARN、--notify时还在该会话回一条「请恢复后重发」。--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就是全队。已知限制(review 后明确保留,不做掩盖)
forkWorker提前 return、从不设ds.worker,所以后续消息会走 worker-null re-fork 分支再被去重丢掉;worker 侧那套 pre-ready 输入缓冲此时并不存在(worker 根本没起来)。做成真队列需要连 messageId / 附件 / reaction 结算 / 结构化 CLI 输入一起缓冲并持久化——那是投递队列,不是闸门,刻意不做。改为把它变可见:每次都打 WARN,--notify时该会话收到一条「这条不会被自动处理,请重发」。(不出声不行:后续finishTurnReactions会把 pending ✋ 批量翻成 ✅,用户会以为处理完了。)pid是协作标识,不是安全边界:只校验存活,同机同用户可以借别人的 pid(能写这个文件的人本来也能--force)。它的职责是让运维自己的几个脚本别互相解除保护。/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.ts31 例: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.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导致悬空符号链接删不掉。第三轮确认前三点闭合。第四、五轮(在 review 意见落地之后)又抓到三处,都已修:
link()原子创建 + 有界重试;同时发现「同 owner」判定不能让undefined === undefined成立,否则每个匿名写入者都能替换别人(实测 10 个并发全成功,修后恰好 1 个)。顺带允许同 pid 续期,否则脚本连自己的窗口都改不了。pendingRawInput正是先forkWorker(ds,'',false)再等prompt_ready写 PTY,dashboard 唤醒/web 终端也走空 prompt。改成排队位有优先级——空 prompt 照常入队,但携带用户 turn 的请求会顶掉它,反之不成立。已知未处理:per-bot 凭证副本「存在但已损坏」时,冻结期只按
existsSync判断,仍会启动 CLI(既不 warn 也不拦)。这是改动前就有的行为,本 PR 不扩大范围去动它。首个消费者
本机的凭证刷新脚本(运维脚本,不在上游):
拿不到闸门时(老版本没有
freeze子命令)脚本继续刷新——拿不到闸门是「小概率竞态」,拒绝刷新是「必定过期掉线」。