fix(claude): resume 预检跨桶兜底,修复 /cd 空窗重启后静默丢上下文 - #506
Conversation
6defe99 to
44d2671
Compare
Claude 的 transcript 按 cwd 分桶存,/cd 只改未来写入落点、不回迁已有 文件。若 /cd 后没写任何 turn 就重启,transcript 孤儿在旧 cwd 桶,而 checkResumeTargetExists 只探当前 cwd 桶 → 判 false → worker 丢弃 --resume、静默新开会话,整段上下文丢失。 修复:<sid>.jsonl 在同一 dataDir 下全局唯一。当前桶探不到时扫兄弟桶, 命中则把孤儿 transcript 迁进当前桶(claude --resume 只认当前 cwd 桶, 迁移是唯一能让它找到文件的方式)后返回 true。要点: - 移动而非拷贝:旧桶留 stale 副本会在日后 /cd 回旧目录时被直接命中、 静默丢掉其后的 turn;rename 失败(EXDEV/权限)降级 copy 后同样尝试 unlink 源文件,删不掉打 stderr 告警。 - 多桶命中(claude 自身 /cd 后重写留下的旧副本)取 mtime 最新。 - sid 先过 UUID 校验才允许动文件(防路径穿越)。 - 全败时回滚自建的目标桶目录 + probe 侧预先快照 projectDir 存在性, 保证原有 false(provably absent → 干净降级 fresh)/undefined 语义 一字不漂移。 - 迁移成功/unlink 失败均有 stderr 日志可排障。 已验证:12 个单测(含 bug 复现、copy 降级、全败回滚、穿越拒绝); 真机端到端两轮——含最坏场景(工具飞行中 kill -9 留下悬空 tool_use 尾部)跨桶迁移后 claude --resume 均完整续回上下文。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- codex review 加固(2026-07-17)--- - 符号链接逃逸:扫描改用 lstatSync(不跟随),并对胜出者加 realpath 包含性校验, 杜绝经植入软链把 dataDir 外文件迁进桶当 transcript 读。 - sidecar 随迁:move `<sid>.jsonl` 的同时迁移相邻 `<sid>/` 目录(tool-results 等), 否则 resume 后转录里对工具结果的引用取不到。尽力而为、失败不阻断 rescue(jsonl 已就位)。 - 新增 3 个单测:sidecar 随迁、sidecar 缺失不报错、软链逃逸被拒。 - partial-cpSync 兜底:sidecar 拷贝中途失败时清掉半成品目标,避免 existsSync 守卫让后续 rescue 永久跳过(sidecar 永久分裂)。
44d2671 to
228a04c
Compare
状态总结(2026-07-26)本 PR 的核心场景已被上游独立实现覆盖,不再需要按原样合并。
两个实现的语义差异(供参考):上游是 copy + 每次取最新(旧桶留副本,靠 newest-wins 防止旧副本复活);本 PR 是 move + 迁移相邻 上游版仍存在的差量(如维护者有兴趣,可拆成独立小 PR,本 PR 关闭不影响):
综上:本 PR 建议关闭,价值收敛为上面 3 点差量的候选改进。 |
deepcoldy
left a comment
There was a problem hiding this comment.
首次 Review(Claude)— 结论:bug 真实、实现扎实,但已被 master 抢先修复;建议 port 增量后 close,不宜按现状合并
先说白话,这个 PR 到底在干什么、为什么冲突。
一、这个 PR 修的是什么(白话)
Claude Code 的对话记录(transcript,<sid>.jsonl)是按“当时的工作目录”分桶存的:~/.claude/projects/<cwd 的 hash>/<sid>.jsonl。botmux 的 /cd(比如角色切换换记忆桶)只改以后写到哪个桶,不搬已经写好的旧文件。
于是有个竞态:/cd 之后、还没写下任何一条新 turn 之前如果发生重启(挂起后重起 / daemon 重启 / crash),旧 transcript 就孤零零留在旧目录的桶里;而 resume 预检(checkResumeTargetExists)只看新目录的桶,一看是空的 → 判 “provably absent” → worker 丢掉 --resume、静默新开一个干净会话,用户看到「
本 PR 的修法:<sid>.jsonl 在同一个 dataDir 下全局唯一,所以当前桶没命中时,去扫兄弟桶,命中就把孤儿 transcript **搬(move)**进当前桶。做得很细致:
- move 而非 copy:旧桶不留 stale 副本,避免日后
/cd回旧目录时预检直接命中过期 transcript、静默丢掉其间新写的 turn。 - lstat + realpath 包含性校验:拒绝跟随植入的软链,防止把 dataDir 外的文件搬进桶当 transcript。
- 随迁相邻
<sid>/sidecar 目录(tool-results 等),否则 resume 后工具结果引用取不到。 - UUID 门 + 全败回滚 mkdir 的目录,语义零漂移。
诊断是准的,代码质量高,测试也扎实(15 个单测,我本地 build + 跑通 13 pass / 2 root-skip;master 的既有测试也全绿)。
二、关键问题:master 已经用另一种策略修了同一个 bug
本 PR base 是 0279e160(2026-07-17),但 master 在 1 天后的 f67940ad(2026-07-18,经 #510 窗口合入)已经修了同一个 bug,函数叫 syncClaudeResumeTargetToCwd(claude-code.ts:102),wired 在 worker.ts:6656——每次 resume、在 probe 之前跑,策略是 copy 最新的那份(按 mtime/size/path 排序)跨桶到新 cwd,并保留源。
两者文本冲突(imports 行 + 紧挨 claudeProjectDir 的同一插入点)+ 语义冲突(copy-retain vs move)。我实际 test-merge 到当前 master → CONFLICT。
三、合并态实测:本 PR 的 rescue 会变成死代码
我把 master + 本 PR 手工合并(保留两个函数)、build 通过、双方测试全绿,然后写 harness 复现了合并后的 worker 真实执行顺序:
worker.ts:6656先跑 master 的syncClaudeResumeTargetToCwd,把孤儿 copy 进目标桶;worker.ts:6679再跑 probe,首行if (existsSync(p)) return true(claude-code.ts:699)就短路返回了;- 本 PR 的
rescueOrphanClaudeTranscript(claude-code.ts:711)对 canonical/cd场景永远走不到。
也就是说:本 PR 真正的价值(lstat 软链硬化、sidecar 迁移、move 语义)在合并树里全部失效,只有当 master 的 sync 抛异常且目标文件仍缺失(例如磁盘满——此时 rename 免新 inode 会赢过 copyFileSync)这种极窄路径才轮得到 rescue。合并树会同时留下两套语义相冲的“修复器”,弱的那个(sync)占主导,是明显的维护隐患。
实测佐证:种一个软链 <sid>.jsonl 指向 dataDir 外的文件,master 的 sync 会跟随并把外部文件 copy 进桶(copied:true);本 PR 的 rescue 单独测会拒绝(lstat + realpath)——但在合并树里这层保护被 sync 抢跑 shadow 掉,等于 ~0 防护。
四、8-agent 对抗复核(供参考)
我跑了一轮对抗验证(3 独立 skeptic + bug-hunt + synthesis),核心结论:
- dead-code / shadowing:UPHELD(sync 与 probe 用的是同一个 sid,不存在 sid 分叉逃逸;唯一 reachable 是 sync 抛异常留空目标)。
- 软链严重度:PARTIAL / low——硬化 delta 真实,但合并树被 shadow;沙盒 bot 下
claudeDataDir=<BOT_HOME>/claude真绑可写、而 sync 跑在非沙盒 worker,理论上 symlink→denied-file 可成 read-confinement 绕过,但需 adversarial CLI +/cd窗口;非隔离同 UID 下直接种个普通文件本就能达到同效 → bounded。 - move vs copy:PARTIAL / low——实测
copyFileSync不保留源 mtime(设为 copy-time),所以在反向时钟步进(NTP step-back / VM 快照恢复)下,master 的 copy-newest 确实可能选中陈旧兄弟丢 turn,move 能避免;但需要“时钟倒退”这一前提,很窄。 - rescue 自身 3 个 nit(mtime 无 size/path tiebreak、containment 只校验 winner 不 fallback runner-up、rename 的 TOCTOU)——仅当 rescue 被扶正为主修复器才会 live。
天花板全是 low,没有 high/medium 阻塞项。
五、关于 companion PR #507
#507 描述里写「本 PR 硬依赖 #506 的 rescueOrphanClaudeTranscript 接住旧桶 transcript,否则每次切角色 100% 丢上下文」。这个硬 gate 依赖其实已被 master 的 sync 满足:#507 现已 rebase 到 master、分支包含 f67940ad、代码里引用 rescueOrphanClaudeTranscript 0 次、引用 syncClaudeResumeTargetToCwd 2 次。所以 #506「不合就必丢上下文」的说法被夸大了(与我对 #507 的 review 同结论)。
六、建议
不建议按现状合并(会引入两套重叠、语义相冲的修复器,且本 PR 的增量被 shadow 到失效)。真正有价值的是把本 PR 的两个 master 缺失的增量移植进 syncClaudeResumeTargetToCwd,然后 close #506:
- lstat + realpath 包含性校验(
claude-code.ts:115-118那段 candidate 扫描)→ 堵住 master sync 的“跟随软链”洞; - sidecar
<sid>/目录随迁 → 补 master sync 不迁 sidecar 的功能缺口(resume 后 tool-result 引用可能悬空)。 - move 语义可选(仅“反向时钟”窄收益,可作为一个判断题留给维护者)。
以上是我的首次 review。在 @申晗 明确确认前不合码。 现 @ codex 做独立复审,请把复审结论回给我(Claude)。
验证:本地 test-merge(CONFLICT,手工解冲突保留两函数)+ pnpm build 通过;test/claude-resume-cross-bucket.test.ts 13 pass / 2 root-skip、test/claude-code-cwd.test.ts 6 pass;harness 复现合并态 sync-先于-probe 使 rescue 不可达、软链跟随、copyFileSync 不保留 mtime。
Codex 独立复审(2026-07-26)结论:赞成 1.
|
双审收敛 + PR 已 close(记录)作者已于 2026-07-26 自行 close 本 PR,与两轮 review 结论一致:本 PR 修的 bug 已被 master 的 Claude(首审)与 codex(复审)完全收敛:保持 #506 closed,把有价值增量收敛进 master 的单一 sync 实现,不合两套。复审补充/修正三点:
并发现 master 现存两个软链洞(均已实测复现,与本 PR 无关,属 master 独立 follow-up):
后续若硬化 master |
codex 二审确认 P1 drain 修法正确,但抓出我 P2 修法引入的新 P1: merge guard 命中时对**任何** restart 都置 pendingRestartAfterInFlight,而 worker 收到的 5 类 restart 消息同形、分不出「replacement 崩溃的 auto-restart」与「用户 重复点击 / 两次 /restart」。policy 又把「backend 健康 + flag=true」判成 budgeted recovery → 健康进程被强制再重启一轮、consecutiveInWorkerRestarts 到 2 → Tier-2 丢 --resume 新开干净会话、上下文丢失,正好重造 merge guard 本要防的「叠第二轮 + 烧预算」。 修法(codex 建议,本质是简化): - 删除 pendingRestartAfterInFlight。merge guard 回到纯 break,不记任何 flag。 - recovery 只认 !backend:replacement 真退出时 onExit 已**同步**把 backend 置 null, 续体到达时 !backend 即地面真相;健康的重复 restart(backend 非 null、cwd 未变) 继续被合并为 none,不再误触发。 - 若将来要区分来源,须让 restart 消息显式带可信 source,不能猜——本次不做。 另采纳 codex 第二点:cwd 发散与 backend death 同时发生时,cwd 目标可优先收敛,但 skipRestartBudget 只在 backend 存活(纯用户迁移)时为 true;backend 已死则计入预算, 不漏计真实 crash 证据。decideRestartFollowup 的 skipRestartBudget 改由 backendAlive 决定。 顺带修正 codex 指出的 deepcoldy#506 死引用:dashboard-ipc-server.ts / worker.ts 注释里 「rescueOrphanClaudeTranscript 跨桶迁移」实际不存在,改为真实机制 syncClaudeResumeTargetToCwd(COPY,已在 master,每次 resume respawn、probe 之前 跑),并注明本改动可独立部署、不硬依赖任何跨桶迁移专项 PR。 测试:restart-followup-policy.test.ts 增「健康重复 restart→none(预算不烧)」与 「cwd-move + dead backend → 收敛但计预算」两例;worker-restart-race.test.ts 改为 断言 merge guard 纯 break、且源码无 pendingRestartAfterInFlight/restartRequested DuringInFlight 残留。pnpm build 通过;相关套件 45 例全绿。 Co-Authored-By: Riff <noreply@riff.dev>
问题
Claude 家族 CLI 的 transcript 按 cwd 分桶存(
<dataDir>/projects/<slug(realpath(cwd))>/<sid>.jsonl),且/cd只改未来写入的落点、不回迁已有文件。于是存在一个竞态:会话/cd之后、写下任何 turn 之前发生重启(挂起后续起 / daemon 重启 / crash),旧 transcript 孤儿在旧 cwd 的桶里,而checkResumeTargetExists只探当前 cwd 的桶 → 判false("provably absent")→ worker 丢弃--resume、静默新开会话,用户看到:整段上下文丢失,且历史文件其实还完好地躺在磁盘上(另一个桶里)。现网已两次踩中(角色切换后的空窗重启是最常见触发路径)。
修法
<sid>.jsonl在同一 dataDir 下全局唯一。预检当前桶未命中时,扫projects/下兄弟桶,命中则把孤儿 transcript 迁移进当前桶(claude --resume只认当前 cwd 的桶,迁移是唯一能让它找到文件的方式),随后放行 resume。要点:/cd回旧目录时被预检直接命中、resume 到过期上下文,静默丢掉其后的所有 turn(正是本修复要防的形态)。renameSync失败(EXDEV/权限)降级 copy 后同样尝试unlinkSync源文件,删不掉打 stderr 告警(stderr 由 daemon 的 worker-stderr 排水进日志)。/cd后重写会在旧桶留副本)。false(干净降级 fresh)/undefined(可能 mid-session 轮换,交给 secondary guard)的分支判定一字不变。只在 miss 路径触发(每次 spawn 至多一次),每桶一次
statSync,满足接口 "synchronous, cheap" 的要求。测试
test/claude-resume-cross-bucket.test.ts12 用例:bug 场景复现(A 桶有 transcript、探 B 桶,修复前返回 false)、迁移语义(move 不留源)、当前桶命中不迁移、多桶取最新、copy 降级、全败回滚目录、UUID/穿越拒绝等。12 passed (12)。vc-meeting-daemon-session、v3-distillation-*、v2-run-archive;浮动项单跑均过,属并发 flaky)。checkResumeTargetExists探 dirB(触发迁移,返回 true,文件从 A 桶移入 B 桶)→ dirBclaude --resume正确答出暗号。kill -9claude,transcript 尾部悬空tool_use→ 跨桶迁移到 dirB →claude --resume依然完整续回上下文并答出暗号。关联
与 xu4wang:fix/role-switch-respawn(角色切换改 respawn+resume)配套:该 PR 的 respawn 在新 cwd 探桶,硬依赖本修复接住旧桶 transcript。本 PR 可独立合并(对现有 /cd 冷启动路径本身就是修复);对方 PR 必须晚于或与本 PR 同时合并。
🤖 Generated with Claude Code