Checkpoint / Rewind
CC 做「文件 + 对话」双维检查点(copy-based 备份 + /rewind 五选项菜单);Codex 已删除文件级 ghost 快照(feature "undo" → Removed),只剩对话级 rollback(append-only ThreadRolledBack 标记)
Checkpoint / Rewind
结论
两家在「agent 改坏代码的后悔药」上走向了相反的终局。Claude Code 把检查点做成双维:每条用户 prompt 自动建文件快照(编辑工具写前 copy 备份到 ~/.claude/file-history/<sessionId>/,快照清单作为 file-history-snapshot 条目进 transcript),/rewind(或双击 Esc)打开的菜单允许独立或组合恢复代码与对话,外加两种定向 summarize。Codex 曾有等价物(git ghost commit 文件快照),但在当前版本(b89ce9a @ 2026-06-06)已整体删除——feature flag undo 标记 Stage::Removed,配置仅兼容加载(注释明言 "snapshots are no longer produced"),rollout 读取器主动剥离 legacy ghost_snapshot 行;幸存的只有对话级 rollback:thread_rollback 校验非 turn 中 → append 一条 ThreadRolledBack{num_turns} 标记(历史不改写)→ 反向扫描重建时跳过 N 个用户 turn,TUI 的 Esc-Esc backtrack 即此。对 harness 的含义:CC 的检查点可外部读取与跨会话迁移(硬链接),但触发恢复是 TUI 交互专属;Codex 给不了文件安全网,产品必须自备(git)。
研究问题
- 检查点的对象是什么——文件、对话,还是两者?存储与生命周期?
- restore 的精确语义:恢复哪些文件、对话链怎么改、能否只恢复一边?
- runtime 不提供文件安全网时,harness 拿什么补?
各 Agent 设计与实现
Claude Code
捕获:写前备份 + 每 prompt 快照。 两层机制 [一手源码]:
- 写前备份(
fileHistoryTrackEdit,utils/fileHistory.ts:86+):FileEditTool:435、FileWriteTool:259、NotebookEditTool:312在落盘之前调用,把文件当前内容 copy 到备份目录;连 BashTool 的 sed 模拟快速路径也接入(BashTool.tsx:392-394)——但一般 bash 命令改的文件不追踪([一手文档]checkpointing.md:71-78:rm/mv/cp不可回滚)。 - 快照(
fileHistoryMakeSnapshot):每条可选用户消息触发一次(utils/handlePromptSubmit.ts:525-538),对全部 tracked files 按 mtime/hash 判断是否需要新版本备份,产出FileHistorySnapshot { messageId, trackedFileBackups, timestamp }(fileHistory.ts:39-43)——检查点以消息 UUID 为锚,这是 rewind 菜单能按 prompt 列出还原点的原因。上限MAX_SNAPSHOTS = 100(:54)。
存储:会话私有备份目录 + transcript 内清单。 备份文件在 {configDir}/file-history/{sessionId}/{sha256(path)[:16]}@v{version}(fileHistory.ts:725-741);文件不存在用 backupFileName: null 标记(删除也是一种可恢复状态)。快照清单写成 transcript 的 file-history-snapshot 条目(types/logs.ts:188-193),isSnapshotUpdate: true 表示对同 messageId 的最后写 wins(resume 时由 buildFileHistorySnapshotChain 合并,sessionStorage.ts:2247-2272)。
恢复:/rewind 五选项菜单。 /rewind(commands/rewind/rewind.ts,仅 openMessageSelector())和双击 Esc([一手文档] checkpointing.md:25)打开 MessageSelector,选中某条用户消息后 [一手源码] components/MessageSelector.tsx:31:
type RestoreOption = 'both' | 'conversation' | 'code'
| 'summarize' | 'summarize_up_to' | 'nevermind';code/both 走 onRestoreCode → fileHistoryRewind:按 messageId 找快照、对每个 tracked file 还原备份版本(null 即删除文件),纯文件系统副作用、不动 FileHistoryState(fileHistory.ts:347-397);conversation/both 走 onRestoreMessage 截断对话;两路错误独立捕获呈现(MessageSelector.tsx:216-243)。code 不可用时(无快照)默认聚焦降为 conversation。summarize 两项是定向 compaction(不动文件),见官方文档对 restore vs summarize 的对照(checkpointing.md:46-52)。
跨会话生命周期。 resume 时从 transcript 条目重建快照状态(fileHistoryRestoreStateFromLog,fileHistory.ts:888-917);fork(新 sessionId)时把上个会话目录里的备份硬链接到新会话目录(copyFileHistoryForResume,:922-980)——零拷贝共享底层数据。开关:交互模式默认开(fileCheckpointingEnabled !== false),SDK 需显式 CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING(:63-79)。
Codex CLI
文件级检查点:已删除。 三处一手证据:
- feature flag:
Feature::GhostCommit, key"undo",stage: Stage::Removed(features/src/lib.rs:218-220, 735-740,注释 "Removed compatibility flag retained as a no-op so old configs can still parseundo"); - 配置:
GhostSnapshotConfig注释[一手源码]core/src/config/mod.rs:168-170:"Compatibility-only config retained so legacyghost_snapshotsettings continue to load even though snapshots are no longer produced"; - 读取侧:resume 重放时主动剥离 legacy 行(
rollout/src/recorder.rs:864-867, 957-977strip_legacy_ghost_snapshot_rollout_line,连Compacted.replacement_history里嵌的也清)。
[推断] 删除动机未见 ADR;从遗留配置项(ignore_large_untracked_files 10MB、ignore_large_untracked_dirs 200,config/mod.rs:165-166)可推断旧实现是 git 仓库级快照、在大 untracked 工作区上成本/正确性问题突出。
幸存者:对话级 rollback。 [一手源码] core/src/session/handlers.rs:494-592 thread_rollback:
let rollback_msg = EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns });
let replay_items = stored_history.items.into_iter()
.chain(std::iter::once(RolloutItem::EventMsg(rollback_msg.clone()))).collect();
sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice()).await;
...
sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())]).await;要点:① turn 进行中拒绝(:507-518);② 历史不改写——只 append 一条 ThreadRolledBack 标记,立即在内存按「重放 + 标记」重建并落盘标记;③ 重建时反向扫描把标记折算为「跳过 N 个用户 turn 段」(rollout_reconstruction.rs:130-133),嵌套 rollback 由 thread_rollout_truncation.rs:46-94 处理。TUI 的 Esc-Esc backtrack(tui/src/app_backtrack.rs:1-25 头注释)是它的交互前端:Esc 预备 → 再 Esc 开 transcript overlay 选用户消息 → Enter 发 rollback 请求,pending_rollback guard 防并发,等 core 确认后才裁剪本地 transcript——UI 不自作主张,以持久层确认为准。文件一概不动。
反例 / 边界
- CC 快照有限额:
MAX_SNAPSHOTS = 100后旧快照被驱逐,snapshotSequence单调计数器继续增长供活动信号用(fileHistory.ts:45-54)——长会话早期 prompt 的还原点会悄悄消失,fileHistoryCanRestore按 messageId 查不到即菜单降级为仅 conversation(:399-411)。 - CC 备份是 copy-on-first-touch + 按版本递增:同一 turn 内重复 trackEdit 不会覆盖 v1(防止「第二次 trackEdit 用已编辑内容污染 v1」的投机写,
fileHistory.ts:99-103注释);快照阶段才按 mtime/内容判断要不要出新版本。 - Codex rollback 拒绝 turn 进行中(
handlers.rs:507-518),且要求线程有持久化历史(ephemeral 线程不可回退);UI 侧pending_rollbackguard 串行化并发回退请求(app_backtrack.rs:67-71)。 - Codex 被回退内容不删除:append-only 意味着「回退后又 rollback 的 rollback」(嵌套)都可被
thread_rollout_truncation.rs正确折算——离线分析 rollout 时切勿把文件里出现的消息都当作有效历史。
差异矩阵
| 维度 | Claude Code | Codex CLI |
|---|---|---|
| 检查点对象 | 文件 + 对话(独立或组合恢复) | 仅对话(文件级 ghost snapshot 已删除) |
| 捕获时机 | 每用户 prompt 一快照;编辑工具写前备份 | 每 turn 天然成段(rollout 日志即检查点流) |
| 存储 | copy 备份 ~/.claude/file-history/<sid>/ + transcript 清单条目 | 无文件存储;rollback 是 rollout 里一条标记 |
| 恢复语义 | 还原备份内容/删除标记,旁路 git | 反向扫描跳 N 用户 turn,append-only 不改史 |
| 入口 | /rewind、双击 Esc(TUI 菜单,无 CLI/SDK 触发恢复) | Esc-Esc backtrack(TUI)、Op::ThreadRollback(协议层,外部可调) |
| 覆盖盲区 | bash 任意命令/外部编辑不追踪(sed 快速路径例外) | 全部文件改动无保护 |
| 跨会话 | resume 重建状态、fork 硬链接迁移备份 | 标记随 rollout 持久,resume/fork 自然继承 |
| 与 git 关系 | 互补:「本地 undo」vs「永久历史」(官方定位) | 文件回滚完全交给 git |
最小复现
# CC:观察检查点产物
claude -p 也行但需开 SDK 开关;交互模式发一条会改文件的 prompt 后:
ls ~/.claude/file-history/<session-id>/ # {hash}@v1 备份文件
grep '"type":"file-history-snapshot"' ~/.claude/projects/<slug>/<sid>.jsonl | tail -1
# Codex:rollback 在磁盘上是显式标记
# TUI 内 Esc-Esc 选择回退一条后:
grep -o '"thread_rolled_back"[^}]*}' ~/.codex/sessions/$(date +%Y/%m/%d)/rollout-*.jsonl | tail -1
# 注意:被回退的 turn 内容仍完整保留在文件里(append-only)Harness 接入建议(Yoda 实践)
Yoda 当前未接入任何 runtime 检查点机制(conversations 模块 grep rewind/checkpoint 为负)——以下是基于本章源码事实的接入路线:
- CC 侧可读不可(程序化)触发:快照清单在 transcript 条目里、备份在 file-history 目录里,harness 完全可以读出「每个 prompt 改了哪些文件 + diff stats」做 UI 展示(
fileHistoryGetDiffStats的逻辑可复刻:对比备份与现文件);但恢复动作走openMessageSelector,是 TUI 交互——headless/SDK 模式下未发现 rewind 控制请求(UNCONFIRMED,03-31 快照内 grep 无果)。产品要程序化恢复只能自己按清单把备份 copy 回去——格式简单({hash}@v{n}+ transcript 里的路径映射),但属于逆向其私有格式,列入失效监控。 - Codex 侧必须自备文件安全网。runtime 明确放弃了这块;Yoda 这类 workspace 产品的合理补位是 git-based:每 turn 结束(tail rollout 见
TurnComplete)做一次git stash create/ 临时 commit / worktree 快照,恢复时配合Op::ThreadRollback(协议层可外部调用,这点比 CC 强——对话回退可程序化)同步回退文件。这正是被删除的 ghost commit 的产品级复活,但放在 harness 层可以用产品自己的大文件/ignore 策略,避开 runtime 当年的通用性死穴。 - 统一抽象建议:把「检查点」建模为
{anchor: 消息/turn ID, conversation: 可回退?, files: 可回退?}三元组。CC 两者皆真、Codex 仅 conversation 真——UI 按能力降级,而不是假装两边等价。 - 别把 CC 检查点当备份:备份目录按 sessionId 隔离、随 transcript 30 天清理周期失去意义、上限 100 快照(
MAX_SNAPSHOTS)、bash 改动盲区——官方自己定位是 "local undo"(checkpointing.md:90)。
失效条件
- CC 备份路径/命名(
file-history/<sid>/{hash}@v{n})或file-history-snapshot条目结构变更——外部读取/复刻恢复即断; - CC 若新增 SDK rewind 控制请求(解除「恢复仅 TUI」限制),本章 UNCONFIRMED 项需更新;
- Codex 若重新引入文件快照(关注
Feature枚举新增项与ghost_snapshot配置去留)——「harness 自备安全网」建议降级为兜底; - Codex
ThreadRolledBack折算逻辑(rollout_reconstruction.rs/thread_rollout_truncation.rs)语义变化,影响外部对 rollout 的离线解读。
参考资料
- CC 源码:
utils/fileHistory.ts(全文 1115 行)、utils/handlePromptSubmit.ts:525-538、components/MessageSelector.tsx:31, 177-243、commands/rewind/、types/logs.ts:188-193、utils/sessionStorage.ts:1476-1487, 2247-2272 - CC 文档:
claude-code-docs/docs/checkpointing.md、agent-sdk__file-checkpointing.md - Codex 源码:
features/src/lib.rs:218-220, 735-740、core/src/config/mod.rs:165-186、rollout/src/recorder.rs:957-977、core/src/session/handlers.rs:494-592、core/src/thread_rollout_truncation.rs、tui/src/app_backtrack.rs:1-25、protocol/src/protocol.rs:1169, 3221 - 姊妹章:《Resume / Fork / 历史》(rollback 标记在 resume 重建中的折算)