Skip to content

feat(codex): 支持空闲会话自动升级已安装版本 - #1306

Open
anarkh wants to merge 6 commits into
masterfrom
feat/codex-session-auto-upgrade
Open

anarkh wants to merge 6 commits into
masterfrom
feat/codex-session-auto-upgrade

Conversation

@anarkh

@anarkh anarkh commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

本机 Codex 安装已经更新时,长期运行的会话仍可能停留在旧进程,继续使用旧版本的模型列表和行为。此改动让 Botmux 在确认会话安全空闲后,切换到本机已安装的较新 Codex,并静默恢复原线程;安装更新与会话进程换代由此衔接起来。

改动

  • 每 10 分钟监测会话运行版本,比较实际 Codex 进程与已安装目标版本,只升级、不降级。解析官方 standalone 安装入口,同时保留显式运行时配置;此功能不下载或安装 Codex。
  • Dashboard 新增“自动升级会话 Codex 版本”开关,对应 dashboard.autoUpgradeCodexSessions,默认开启,修改后热生效。
  • 升级时保留 Botmux worker、原线程、工作目录、环境及待处理输入。确认旧进程退出后启动新版,以空 prompt、非 fork 方式恢复原线程,不覆盖原会话模型和推理强度;维护恢复禁用 TUI 自动 recap,不重复执行启动命令或标题生成。
  • 新进程 ready 后,继续核对实际版本和原线程占用证据,通过后才释放输入队列。RPC 恢复必须返回原线程和 resumed 结果,失败不会降级为新建线程。
  • 首次 ready 和升级成功后上报实际运行版本,更新 Dashboard 会话内存状态。异步探测结果受 backend、spawn generation 和当前 worker 所有权约束,旧进程的晚到结果不能覆盖新状态;不改写会话持久化数据或全局安装版本缓存。

安全边界与影响面

  • 仅自动管理 Botmux 自有的本地 codex / codex-app 运行进程。adopt 会话、外部或共享 App Server、配置了 CLI wrapper 的会话不参与自动换代,其他 CLI 不启用此能力。
  • 有进行中的回合、待处理输入、Hook review 输入保持、TUI 交互、终端连接、后台子进程或原生 Goal 时延后。无法确认进程身份、原线程独占关系或 Goal 状态时同样延后;仅允许明确识别的无状态直属 helper 随进程重启。
  • 触及 worker 公共的停止、ready 和输入释放路径,但维护逻辑受 Codex 条件约束。PTY、tmux 及本地 RPC 路径共用原线程恢复校验;普通启动、其他 CLI 和非维护恢复沿用原行为。适配器测试包含其他 CLI 的参数回归。
  • 覆盖 Linux 与 macOS 进程探测。Linux 以运行进程的实际 executable 验证版本;macOS 若原二进制已被覆盖、无法追溯旧进程版本,则拒绝自动换代,不将磁盘上的新版本当作旧进程版本。
  • 停止或恢复失败时保留 worker 和已接受的输入,保持输入阻塞,并明确提示检查后在当前会话发送 /restart,不自动新建线程,也不自动重发已提交给旧进程的消息。

验证

最新提交 b8555164 仅将自动升级检查周期从 60 秒调整为 10 分钟。使用 Bun 1.4.2 执行 bun run build 通过;bun run test test/worker-codex-session-upgrade.test.ts test/codex-session-upgrade.test.ts49 passed。此提交的新一轮 CI 已触发,以下完整回归与 CI 结果对应明确标注的此前提交。

本轮开发机只读检查仍有 1 个会话 working,因此未部署或重启,现网仍为 fix2;10 分钟周期需部署新构建并重启 worker 后生效。

提交 01f32072 已合入 master61dadb04c,保留 quietResume 和上游 shellSubprocessEnv 两项参数。原有 4 条源码结构断言恢复通过,运行时版本观察和启动命令的守卫语义不变。

macOS 与 Linux 分别执行以下 15 个文件,各 895 passed、1 skipped;两端 bun run build 完整构建通过,包含上游新增的 typecheck:test-mocks

bun run test \
  test/worker-pipe-initial-screen-order.test.ts \
  test/restart-worker-null-reattach.test.ts \
  test/raw-input-followup-atomicity.test.ts \
  test/startup-commands.test.ts \
  test/worker-codex-session-upgrade.test.ts \
  test/worker-startup-retry-wiring.test.ts \
  test/hook-review-input-hold-wiring.test.ts \
  test/stuck-detector.test.ts \
  test/codex-session-upgrade.test.ts \
  test/codex-upgrade-target.test.ts \
  test/codex-upgrade-helpers.test.ts \
  test/cli-adapters.test.ts \
  test/auto-upgrade-codex-sessions-config.test.ts \
  test/codex-app-runner.integration.test.ts \
  test/settings-write-applier.test.ts
bun run build

上述前 4 个阻断回归文件还分别在原生 Bun 1.4.2 下独立运行,使用隔离 HOME 和仓库测试 shim,共 99 passed、0 failed

01f32072 的 CI 的构建、3 个测试分片及聚合、3 类二进制检查均通过;CodeQL 全部通过。非阻断的 bun-test 为 1111/1112 文件通过:唯一失败在未修改的 test/child-env.test.ts:251,真实 node-pty 子进程没有读到预期输出;未重跑确认是否偶发,不将该作业标作通过。

01f32072 的 Linux fix3 独立构建使用 Bun 1.4.2 和原样 frozen lock,单文件二进制构建及全部 smoke 通过。该轮开发机检查有 2 个运行中会话,因此没有切换入口或重启,现网继续运行 fix2;本次新构建尚未完成 live 接管验证。

以下静默恢复与开发机验证在此前功能构建上完成:

  • macOS、Linux 各 9 文件 638 passed、1 skipped;两端完整构建及 Linux 单文件二进制构建、smoke 通过。
  • 原生 Codex 0.153.4 配合隔离的本地 mock provider,验证 TUI 和 App Server 静默恢复:观察窗口内没有新增 HTTP 请求或 usage。该测试不使用真实账户计费。
  • 开发机完成实际 0.153.2 → 0.153.4 会话换代:原线程与 Botmux worker PID 保持,原生 CLI 进程换代,未新增模型回合或 token 用量。
  • fix2 构建重新接管已有会话后,新 worker 保留原生 CLI PID 与原线程,Dashboard 显示实际版本 0.153.4;观察窗口内 task_started 增量为 0,总 token 用量不变。这项验证覆盖接管后的版本显示,没有再次执行原生版本升级。

真实 Hook review 菜单尚未手工验证,目前由行为回归测试覆盖阻塞与解除后的升级。上述实机结果不包含真实计费压测。

开关与回滚

关闭 Dashboard 开关或设置 dashboard.autoUpgradeCodexSessions: false 可停止后续自动换代,无需重启 daemon;关闭开关不会取消已经开始的换代,也不会回退已运行的 Codex 版本。需要回滚 Botmux 时,先关闭开关并等待当前换代结束,再恢复上一版构建并按既有流程重启。此功能不主动下载安装 Codex,也不改变 Botmux 会话的持久化结构。

界面

入口:全局设置 → 通用设置 → 实验性设置,默认开启。

自动升级会话 Codex 版本开关

@anarkh
anarkh requested a review from deepcoldy as a code owner September 7, 2026 13:04
@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR,设计上的安全边界做得相当细致 —— 只升不降、waitForCodexExit 确认旧进程真退出、失败时保留 worker 与已接受的输入而不降级成新建 thread、macOS 二进制被原地替换时拒绝换代,这些都是很稳的取舍。下面主要是一处需要修改后才能合入的问题,以及几个非阻断的观察。

阻断项:4 条既有单测被打红(必需 check,CI 已红)

CI 上 test (1/3) test (2/3) test (3/3) 三个 shard 与 bun-test 均为 failure。我在本地基于最新 origin/master13413f856)rebase 后做了同环境 A/B 对照:

  • 干净 master:2 红(都是 mojo-launcher-env-quarantine,root 环境的已知问题,与本 PR 无关)
  • 本 PR:6 红 = 上述 2 条 + 下面 4 条新增

值得强调的是:这 4 条守卫的语义都还在,不是真的安全回归,纯粹是"源码文本断言的锚点/计数被改动打破"。但它们属于必需 check,所以仍需处理:

1. test/worker-pipe-initial-screen-order.test.ts:433
expect(source.match(/await spawnCli\(/g)).toHaveLength(3) —— 本 PR 在 worker.ts:2328 新增了第 4 个 await spawnCli( 调用点,实际变成 4。新调用点本身是合理的,建议改测试断言为 toHaveLength(4)

2. test/restart-worker-null-reattach.test.ts:264 + 3. test/raw-input-followup-atomicity.test.ts:686
这两条都用 indexOf('const observedBackend = backend;') 锚定 setupBackendHandlers。新增的 observeCodexRuntimeVersionOnReady()worker.ts:2263 引入了第二处逐字相同的字符串,且位置更靠前,于是 indexOf 锚到了新函数上,4 个 handler 的查找全部返回 -1。

我验证过:按正确的(第 2 处)锚点重算,四个 fence 的偏移与 master 逐字相同(3245 / 4401 / 5540 / 6092),backend !== observedBackend 也都在 —— 守卫没丢。

最小改法是把新函数里的局部变量改个名(不必动测试):

// src/worker.ts, observeCodexRuntimeVersionOnReady()
const versionObservationBackend = backend;   // 原为 observedBackend

4. test/startup-commands.test.ts:95
断言是单行 toContain('hasRunStartupCommands = !shouldRunStartupCommandsOnSpawn({ willReattachPersistent })'),而本 PR 把该赋值折成了两行。把新条件放到后半段即可保持单行形式(|| 在这里可交换):

hasRunStartupCommands = !shouldRunStartupCommandsOnSpawn({ willReattachPersistent })
  || codexAutoUpgrade?.stage === 'restoring';

以上 4 处我都在本地实测过:改完后这 4 个文件全绿,tsc --noEmit 干净(rc=0),连同你 PR 描述里列的 9 个文件一起跑 15 文件 / 892 passed / 1 skipped

另外供参考:你描述里列的定向测试命令不包含这 4 个文件,所以本地 638 passed 不会暴露它们;这 4 条都是读 src/worker.ts 源码文本做结构断言的用例,改 worker.ts 时容易被无声打破。

非阻断观察

  1. hasCodexAutonomousGoal 遇到残缺 JSONL 行会 throw。 实测畸形行会抛 JSON Parse error。它在 check() 里,所以会让该次 tick 进入 failed 状态并刷一条 log(fail-closed 方向是对的,不会误升级)。但 rollout 正在被写入时尾行撕裂应该不算罕见,按行 try/catch 跳过可能更贴合"未能证明有 Goal ⟹ 保守等待"的本意。

  2. 换代失败后会话需要人工介入。 失败时 stage='failed' 会持续钉住 cliRestartInProgress / rawInputRestartGate,只有 restart IPC 能解除。消息不丢、user_notify 也提示了手动重启,方向理解;只是从用户视角这个会话在被重启前是"不响应"的,提示语里明确指出需要 /restart 之类的具体动作可能更友好。

  3. 轮询成本。 每个 ready 的 codex 会话每 60s 会跑一次 ps -axo pid=,ppid=,comm= 全表扫描加一次 codex --version spawn。本机进程数 2659 时 ps 实测约 250ms/次;如果一台宿主上 codex 会话较多,聚合开销值得留意(可考虑把进程表在一次 tick 内复用,或仅在版本确实落后时才展开进程树)。

  4. resolveCodexUpgradeCommandhomedir() 读宿主安装位置,注释也写明了"安装属于宿主用户、而非 bot 重定向的 CODEX_HOME"——这点是对的。真机 5 组探测(默认 / configured / official / legacy-path / cliPathOverride 直通)行为都符合预期。

顺带确认一下:tui.auto_recap 是真实存在的 key(0.153.4 二进制里能命中,对照组:check_for_update_on_startup 命中、我编的假 key 零命中),--strict-resume-c tui.auto_recap=false 的接线看起来没问题。


以上是自动评审的初步意见,可能有误判或遗漏;最终以维护者审阅为准。阻断项集中在那 4 条测试上,改动量很小,其余部分我这边没有发现功能性缺陷。

anarkh and others added 5 commits September 8, 2026 14:35
自动升级会杀掉并重启会话正在使用的 Codex 进程,属有副作用的动作,
默认开启会让所有宿主的 codex 会话无声参与。同一「实验性设置」区块的
邻居开关(codexRpcInput / noVisibleOutputHint)均为 `=== true` 默认关;
仅 bypassCodexHookTrust 因「不开会让首条消息卡死」才默认开,而本特性
不开只是保持现状,不属于此类,故与实验性开关对齐。

判定 `!== false` → `=== true`(仅显式存 true 才启用),三处读取点
一起改以保持 UI 与后端一致:config.ts 的 live getter、dashboard.ts 的
resolved 快照、settings-page.tsx 的 SPA 解析。同时修正 config.ts /
global-config.ts 里「Default ON」的注释与中英帮助文案(原文写「默认
开启 / On by default」),新文案标注「实验性,默认关闭」并提示会重启
会话正在使用的 Codex 进程。

回归:auto-upgrade-codex-sessions-config.test.ts 由钉住「默认 ON」
改为钉住「默认 OFF」,保留显式 true/false 往返与非法值 fail-closed 覆盖。

Co-Authored-By: Claude Code <noreply@anthropic.com>
换代触发源是宿主上 Codex 已完成升级、需要让运行中的旧进程换代,这类
事件以天计,且换代本身仍需等待会话真正空闲,分钟级轮询没有收益。与仓库
内既有的 codex 运行时更新监视器(cli-runtime-update.ts 的 tick 为 1h)
对齐节奏,把 worker 内定时器从 10 分钟放宽到 1 小时,周期性进程表扫描
与版本探测开销再降一个数量级。

不复用 cli-runtime-update 的 24h 缓存:那条路径跑在 daemon、探测远端
registry(「上游是否发布新版」);本监视器跑在 worker、比较本地运行
进程与磁盘已装二进制(「已装好的新版该不该换代」),是两个不同的问题,
24h TTL 会让换代延迟到不可接受。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner

主干已前进 38 个提交,rebase 到最新 master 后出现一处「语义冲突」(建议补一行)

先说明:本 PR 现在对最新 origin/mastere636f93cb)是 CONFLICTING,落后 38 个提交。
我在本地把它 rebase 到最新主干做了验证(没有 force-push 本分支、没有改动你的提交)。
4 处冲突都是「主干和本 PR 各自要自己的东西」,两边并存即可解决:

  • src/adapters/cli/codex.tsbuildArgs 签名行(主干的 shellSubprocessEnv × 本 PR 的 quietResume)、以及 --remote 分支里 tui.auto_recap 的注入位置
  • src/worker.ts:两处彼此独立的守卫 / 初始化块

需要改的一处:test/worker-codex-session-upgrade.test.ts 会红 6 条

rebase 到最新主干后:

Tests  6 failed | 21 passed (27)
ReferenceError: idleDetector is not defined

原因是主干 cfbb71dc4flushPending 加了首行守卫 if (idleDetector?.isStartupPending()) return;
而本 PR 的测试骨架是用 AST 抽取真实函数、再放进 new Function('state', 'with (state) { … }') 里求值的 ——
with 作用域里任何新出现的自由标识符只要 state 袋里没有,就会直接抛 ReferenceError

这不是你的代码有问题,也不是合并冲突能看出来的那类问题:本 PR 自己 head 上的 CI 是绿的
build + test 三个 shard 全 pass),因为那个 head 还坐在旧的 base 上。只有真正 rebase 到新主干才会暴露。

修法一行(我已实测),在 harness() 的 state 里加:

idleDetector: { isStartupPending: () => false, reset: () => undefined },

flushPending 体内只用到这两个成员(被抽取的 5 个函数里只有它碰 idleDetector)。验证结果:

  • 加上 → 27 passed;删掉 → 回到 6 failed | 21 passed(说明这一行确实承重)
  • 相关 118 个测试文件 1772 passed / 1 skippedbun run build rc=0;tsc --noEmit 0 错

另外确认了两件事(不用改)

  1. 不是生产缺陷。担心的链路是:升级的 await ready 只能由 markPromptReady 内那句
    codexAutoUpgrade.ready?.() 解开,而 markPromptReady 首行就会在 isStartupPending() 时提前返回,
    背后是 90s 的 New Codex did not become ready。而这个 startup 闩没有任何超时兜底
    我用真机 codex-cli 0.153.4 做了 A/B(自建同 cwd 的线程、argv 由真 adapter buildArgs 导出,
    两臂唯一差异就是本 PR 的 -c tui.auto_recap=false):两臂都正常走到 loaded banner 并 idle,
    4.5s / 4.9s,相对 90s 有约 18 倍余量 ⟹ quietResume 不会抑制 loaded banner。
    失败路径也是有界可恢复的(reject → failed → 提示 /restart)。
  2. Codex 实例绑定(#1333)没有冲突面:codexInstanceIdentity 只哈希 {binding, runtime}
    不含 cliPathOverride,升级 respawn 的 restartCfg{...cfg} 原样带过 ⟹ tmux 身份校验不会误拒。

以上是自动评审的初步意见,最终以维护者审阅为准。辛苦你补那一行测试骨架的声明(或维护者合并时顺手带上)。

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