Yoda
参考Agent 设计指南Session

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 快照。 两层机制 [一手源码]

  1. 写前备份fileHistoryTrackEditutils/fileHistory.ts:86+):FileEditTool:435FileWriteTool:259NotebookEditTool:312 在落盘之前调用,把文件当前内容 copy 到备份目录;连 BashTool 的 sed 模拟快速路径也接入(BashTool.tsx:392-394)——但一般 bash 命令改的文件不追踪[一手文档] checkpointing.md:71-78rm/mv/cp 不可回滚)。
  2. 快照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 五选项菜单。 /rewindcommands/rewind/rewind.ts,仅 openMessageSelector())和双击 Esc([一手文档] checkpointing.md:25)打开 MessageSelector,选中某条用户消息后 [一手源码] components/MessageSelector.tsx:31

type RestoreOption = 'both' | 'conversation' | 'code'
  | 'summarize' | 'summarize_up_to' | 'nevermind';

code/bothonRestoreCodefileHistoryRewind:按 messageId 找快照、对每个 tracked file 还原备份版本(null 即删除文件),纯文件系统副作用、不动 FileHistoryStatefileHistory.ts:347-397);conversation/bothonRestoreMessage 截断对话;两路错误独立捕获呈现(MessageSelector.tsx:216-243)。code 不可用时(无快照)默认聚焦降为 conversation。summarize 两项是定向 compaction(不动文件),见官方文档对 restore vs summarize 的对照(checkpointing.md:46-52)。

跨会话生命周期。 resume 时从 transcript 条目重建快照状态(fileHistoryRestoreStateFromLogfileHistory.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::Removedfeatures/src/lib.rs:218-220, 735-740,注释 "Removed compatibility flag retained as a no-op so old configs can still parse undo");
  • 配置:GhostSnapshotConfig 注释 [一手源码] core/src/config/mod.rs:168-170:"Compatibility-only config retained so legacy ghost_snapshot settings continue to load even though snapshots are no longer produced";
  • 读取侧:resume 重放时主动剥离 legacy 行(rollout/src/recorder.rs:864-867, 957-977 strip_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_rollback guard 串行化并发回退请求(app_backtrack.rs:67-71)。
  • Codex 被回退内容不删除:append-only 意味着「回退后又 rollback 的 rollback」(嵌套)都可被 thread_rollout_truncation.rs 正确折算——离线分析 rollout 时切勿把文件里出现的消息都当作有效历史。

差异矩阵

维度Claude CodeCodex 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-538components/MessageSelector.tsx:31, 177-243commands/rewind/types/logs.ts:188-193utils/sessionStorage.ts:1476-1487, 2247-2272
  • CC 文档:claude-code-docs/docs/checkpointing.mdagent-sdk__file-checkpointing.md
  • Codex 源码:features/src/lib.rs:218-220, 735-740core/src/config/mod.rs:165-186rollout/src/recorder.rs:957-977core/src/session/handlers.rs:494-592core/src/thread_rollout_truncation.rstui/src/app_backtrack.rs:1-25protocol/src/protocol.rs:1169, 3221
  • 姊妹章:《Resume / Fork / 历史》(rollback 标记在 resume 重建中的折算)

On this page