Skip to content

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

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

feat(spawn-freeze): 维护窗口内不起新 CLI(升级 claude / 刷凭证),并告知发消息的人#647
xu4wang wants to merge 3 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 暂存(每个逻辑会话一个),解冻后自动重放。用户的消息只是晚几秒/几分钟被处理,不会丢。

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 就是全队。

取舍(明写出来)

冻结期不写凭证副本,代价是首次 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 27 例:deadline 到点 / pid 已死 / EPERM 当作存活 / mtime 硬上限 / 未来 mtime 拒绝(clamp 会让窗口每次读取都重新锚定,即永不过期)/ 符号链接拒绝(会借用别的文件的 mtime)/ 13 种坏输入 fail-open / scope 生效与空 scope 等于全队 / clear 幂等 / 每会话只暂存一个 / 按 scope 分别释放 / 会话关闭丢弃 / 无人释放也会因 deadline 重放 / 通知每话题一条。

全量单测对照上游 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 导致悬空符号链接删不掉。

第三轮确认三点闭合、无新增缺陷。

已知未处理: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.

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