Skip to content

fix(cli-runtime-update): 用 canonical realpath 作持久化身份,修复 FNM 旋转路径下 Codex 更新每小时重复提醒 - #891

Merged
deepcoldy merged 1 commit into
masterfrom
fix/20260816-cli-runtime-fnm-rotating-path
Aug 19, 2026
Merged

fix(cli-runtime-update): 用 canonical realpath 作持久化身份,修复 FNM 旋转路径下 Codex 更新每小时重复提醒#891
deepcoldy merged 1 commit into
masterfrom
fix/20260816-cli-runtime-fnm-rotating-path

Conversation

@47seek

@47seek 47seek commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

问题

botmux 在 FNM 环境下 Codex 更新提醒每小时重复发送:版本差检测正确,但同一个 latest 每小时都通知一次。

根因

cli-runtime-update 持久化 key = runtimeId + raw binPath。FNM 每个新 login/interactive shell 生成一个新的 /run/user/.../fnm_multishells/<pid>_.../bin/codex 临时软链,resolveCommand('codex') 每小时拿到不同 raw path → 新 key;旧 key 不在 configuredKeys 里被 prune 删除;新 entry 丢失 lastCheckedAt / lastNotifiedVersion → 24h TTL 和「同版本只提醒一次」水位双双失效 → 每小时 probe + notify。这些路径最终都指向同一个 npm 安装。

改动(src/core/cli-runtime-update.ts)

  1. 持久化 key 改为 runtimeId + canonical realpath;raw binPath 降级为纯展示字段。
  2. entry 新增持久字段 installationPath(写入时冻结的 realpath)——旋转软链下次读取已失效无法再解析,故身份必须落盘;validEntry 对旧行从 binPath 兜底推导,无回归。
  3. canonicalInstallationPath()(realpath 失败回退 resolve),targetKey/entryKey/dedupe/filter 全部收敛到同一 identity,旋转软链自然折叠到同一行。
  4. 审计前 reindex + migration 双路径:软链仍活着 → entryKey 命中稳定 key,整行(含 watermark + lastCheckedAt)搬运;软链已死成孤儿 → 按 runtimeId + sourceFingerprint 唯一匹配迁移并保留 watermark。升级不再多提醒一次;真换安装(realpath 变、旧文件还在)判为新身份、重新 probe、不串旧水位;不同 provider/package 不串 watermark。

影响面

仅改 host 侧 Codex 更新监控(core/cli-runtime-update),read-only 探测语义不变,不触碰其它 CLI/后端/IM 路径。

测试

  • 用真实 symlink 模拟 FNM 旋转,新增回归用例覆盖验收标准 1–4:1h 内旋转不 probe/不 notify;超 24h 重探但 latest 不变不重复 notify;换安装重新 probe 不串水位;旧行迁移不重复提醒;软链仍活着走 reindex 保留 watermark。
  • cli-runtime-update 42 passed;tsc 全量 emit 通过。

@47seek
47seek requested a review from deepcoldy as a code owner August 16, 2026 06:44
@47seek
47seek force-pushed the fix/20260816-cli-runtime-fnm-rotating-path branch from d0f9ee1 to 09d34df Compare August 16, 2026 06:46
@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见:canonical realpath 作为持久化身份的主方向正确,现有 42 个模块测试和全量 pnpm build 也都通过;但迁移唯一性还有一个需要合入前补齐的边界,最终以维护者审阅为准。

阻断项:migrate 只校验 orphan 唯一,没有校验 live target 唯一。

src/core/cli-runtime-update.ts:794-806 会在同一 runtimeId + sourceFingerprint 下只要恰有一个 stale orphan,就把它迁给按顺序遇到的第一个无 entry target。仓库当前明确允许同一个 runtime id / update source 对应不同安装路径:selectCodexRuntimeUpdateTargets() 对两个不同 realpath 会同时返回,runtimeInstallationKey() 的注释也说明同一 distribution id 的不同路径必须拥有独立 cache。

实测构造“1 个已删除的旧安装 orphan + 2 个仍在线、同 runtime/source 的真实安装”后,只有第二个安装执行了 probe/notify;第一个安装随机继承旧 lastCheckedAt / lastNotifiedVersion,在剩余 TTL 内跳过检查。这既会把旧状态挂到不确定的安装上,也违反“不同安装独立 TTL/水位”的约束。

建议 migration 同时要求该 group 的 configured live targets 也恰好只有一个;只要 target 或 orphan 任一侧不唯一,就不要迁移,prune 模糊 orphan 后让各安装独立 probe,并补一个上述双安装回归用例。

另有两个小项建议顺手修正:

  • 两处 group 分隔符写入了真实 NUL byte(当前 file src/core/cli-runtime-update.ts 返回 datarg 默认把文件当 binary);请改成源码转义 \0 / \u0000 或结构化 key,避免破坏常用文本工具。
  • 当前 commit subject 冒号后为英文,与仓库 AGENTS.mdtype(scope): 中文描述 规范不符,建议 amend 为中文描述。

…ex 更新每小时重复提醒

FNM hands each new login/interactive shell a fresh
/run/user/.../fnm_multishells/<pid>_.../bin/codex launcher symlink, all
resolving to one npm install. The update monitor persisted entries under
`runtimeId + raw binPath`, so every hourly tick minted a new key, pruned the
old one, and lost its 24h TTL and per-version notification watermark —
re-probing and re-notifying the owner every hour for an unchanged latest.

Persist a stable identity instead:
- Add `installationPath` (canonical realpath captured at write time) to the
  entry and key the store by `runtimeId + installationPath`. The raw `binPath`
  is retained as a display-only field. installationPath is frozen because a
  rotated launcher symlink is already dead by the next read and cannot be
  re-resolved.
- `canonicalInstallationPath()` (realpath, resolve() fallback) backs targetKey,
  entryKey, dedupe and the dashboard filter, so rotating symlinks collapse onto
  one row.
- Reconcile before any TTL decision: reindex entries whose stored path still
  resolves onto their canonical key (carries watermark for the alive-symlink
  upgrade case), and migrate a *stale* orphan (installationPath no longer on
  disk) onto a live target only when both sides are unambiguous — exactly one
  orphan AND exactly one live install lacking an entry share the group. When a
  runtimeId+sourceFingerprint hosts two distinct live installs, migrating by
  iteration order would graft a stale watermark onto an arbitrary install and
  suppress its next probe, so migrate none and let each install re-probe under
  its own identity. A genuinely different install (realpath changed, old file
  still present) is a new identity: re-probed, never inheriting the old
  watermark.
- validEntry derives installationPath for pre-fix stores from the persisted
  binPath.
- Group key uses a `\u0000` source escape (not a literal NUL byte) so the file
  stays text-tool friendly.

Regression tests use real symlinks to exercise actual realpath rotation and
cover acceptance criteria 1-4, the alive-symlink reindex and dead-path
migration upgrade boundaries, plus the two-live-install ambiguous group where
no migration may occur.

Co-Authored-By: Claude <noreply@anthropic.com>
@47seek
47seek force-pushed the fix/20260816-cli-runtime-fnm-rotating-path branch from 09d34df to 651232f Compare August 16, 2026 11:50
@deepcoldy
deepcoldy merged commit b2d0379 into master Aug 19, 2026
6 checks passed
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