Resume / Fork / 历史
CC 是「DAG 选链」——leafUuid + parentUuid 回溯出一条链,resume 原地续写同文件、fork 拷链换 ID;Codex 是「日志重放」——rollout 全量重放 + 反向扫描求最新存活前缀,fork 有显式截断语义
Resume / Fork / 历史
结论
两个 runtime 的 resume 是两种世界观。Claude Code 的 transcript 是消息 DAG(uuid/parentUuid 链),resume = 全量读文件 → 找最新 leaf → 沿 parentUuid 回溯出一条链(带环检测和并行 tool_result 孤儿恢复);非 fork 时复用原 sessionId、续写同一个 JSONL 文件,--fork-session 则换新 ID、把链拷进新文件。Codex 的 rollout 是事件日志,resume = 逐行重放全部 RolloutItem 得到 InitialHistory::Resumed,内存历史再经一次反向扫描求出「最新存活前缀」(处理 compaction 替换历史、rollback 跳 turn、中断边界);fork 是显式 API/子命令,有「截断到第 N 条用户消息之前」和「视为此刻被打断」两种快照语义,且永远产生新 threadId + 新 rollout 文件。listing 性能策略也相反:CC 无索引、靠 mtime 排序 + head/tail 64KB lite read;Codex 靠 SQLite state_5.sqlite 索引 + .jsonl.zst 归档(append 前自动解压回明文)。
研究问题
--resume/--continue/--fork-session与codex resume/codex fork的精确语义?会话 ID 与磁盘文件如何变化?- 从一个可能含 compaction、中断、rollback 的持久化历史里,如何重建「当前有效上下文」?
- harness 托管 resume 时,如何把自己的会话 ID 映射到 runtime 的会话 ID?
各 Agent 设计与实现
Claude Code
Listing(picker):两段式扫描,无索引。 [一手源码] utils/listSessionsImpl.ts:153-228:先做廉价 stat pass 拿 mtime 排序(注释自述 "Lets us sort/filter before doing expensive head/tail reads"),再按排序结果分批做 lite read 直到凑满 limit。lite read 即 head+tail 各 64KB(sessionStoragePortable.ts:209-282 readHeadAndTail),首条 prompt、cwd、isSidechain 从 head 用裸字符串搜索提取(首行超 64KB 被截断也能工作),customTitle/tag/lastPrompt 从 tail 提取。
Resume 重建:leaf 选取 + 父链回溯。 [一手源码] sessionStorage.ts:2293-2330(loadTranscriptFromFile):全量解析 JSONL → messages Map + leafUuids 集合(无子节点的消息)→ 按 timestamp 取最新 leaf → buildConversationChain:
while (currentMsg) {
if (seen.has(currentMsg.uuid)) { /* 环检测 → 截断返回部分链 */ }
seen.add(currentMsg.uuid); transcript.push(currentMsg)
currentMsg = currentMsg.parentUuid ? messages.get(currentMsg.parentUuid) : undefined
}(sessionStorage.ts:2069-2094)。链建好后跑 recoverOrphanedParallelToolResults 后处理(:2096-2190):流式输出把 N 个并行 tool_use 写成 N 条同 message.id 的 assistant 消息,单父回溯只保得住一条分支,该 pass 把被孤儿化的兄弟 assistant 和 tool_result 按组拼回——写侧拓扑是 DAG,读侧负责修复成线性链。另有 checkResumeConsistency(:2224-2243)对比 turn_duration 检查点里的 messageCount 与实际链位置,仅打遥测不阻断。
Resume 身份语义:非 fork = 原地续写。 [一手源码] utils/sessionRestore.ts:435-490(processResumedConversation):
- 非 fork:
switchSession(原 sessionId)+adoptResumedSessionFile()——sessionFile 指回原 JSONL,后续消息直接 append(这就是《命名同步》章 re-append 机制成立的前提);并恢复 worktree、cost 状态。 --fork-session:保留启动时的新 sessionId,消息由 REPL 挂载时recordTranscript拷进新文件;fork 不继承原会话的 worktree(防止 fork 退出对话框删掉原会话还在用的 worktree,:467-471注释);content-replacement 记录要手动 re-seed 进新文件,否则被替换的大块内容在二次 resume 时变 FROZEN、永久 cache miss(:455-464注释)。--session-id只允许与--resume/--continue联用当--fork-session同时给出(main.tsx:1279-1282)——即「指定 ID 恢复」必须是 fork。- 文件检查点跨 fork 用硬链接迁移(
fileHistory.ts:922-980copyFileHistoryForResume,见《Checkpoint》章)。
/branch 命令是 fork 的交互式包装,会给 fork 文件写定制标题(commands/branch/branch.ts:252)。
Codex CLI
Resume:全量重放。 [一手源码] rollout/src/recorder.rs:842-927:load_rollout_items 逐行解析(坏行计数跳过、legacy ghost_snapshot 行剥离 :864-867),第一条 SessionMeta 决定 threadId;get_rollout_history 包装成:
Ok(InitialHistory::Resumed(ResumedHistory {
conversation_id, history: items,
rollout_path: Some(compression::plain_rollout_path(path)),
}))内存历史重建:反向扫描求最新存活前缀。 [一手源码] core/src/session/rollout_reconstruction.rs:95-180(reconstruct_history_from_rollout)从尾向头扫,按 turn 分段处理三种「历史改写」事件:① Compacted.replacement_history 是完整历史基底——找到最新存活的一个后更老的 rollout 不再影响重建(:122-128);② ThreadRolledBack{num_turns} 转译为「跳过接下来 finalize 的 N 个用户 turn 段」(:130-133);③ TurnAborted/TurnComplete/TurnContext 给段落定界并恢复上一 turn 的设置与基线 context。rollout 永远 append-only,重建逻辑负责把标记折算成有效前缀——与 CC「DAG 上选链」同构但机制相反(CC 改写父指针,Codex 追加标记)。
归档透明化。 .jsonl.zst 压缩归档对读取透明(compression.rs:47-58 open_rollout_line_reader 自动识别两种表示、文件消失短暂重试);append 前自动 materialize 回明文:解压到临时文件 → hard_link 抢占 → 删 .zst(compression.rs:75-115)。
Fork:显式 API,两种快照语义。 [一手源码] core/src/thread_manager.rs:127-146:
pub enum ForkSnapshot {
TruncateBeforeNthUserMessage(usize), // 截到第 n 条用户消息之前
Interrupted, // 视为「此刻被打断」
}Interrupted 模式下若持久化快照停在 turn 中段,会追加与真实 Ctrl+C 相同的 TurnAborted{Interrupted} 边界再 fork(thread_manager.rs:1592-1640 append_interrupted_boundary)——fork 出来的历史和「真的被打断过」逐字节同构。fork 总是产生新 threadId + 新 rollout 文件,并复制源线程名字(thread_processor.rs:3255-3257)。入口:codex fork [id|--last|picker](cli/src/main.rs:2275-2300)、app-server thread/fork(docs/codex_mcp_interface.md:16,thread_processor.rs:3124+,支持 exclude_turns 只继承配置不继承历史)。codex resume 同理三入口(cli/src/main.rs:2247-2273),resume 一个 NotLoaded 线程即从 SQLite/rollout 冷加载。
差异矩阵
| 维度 | Claude Code | Codex CLI |
|---|---|---|
| 持久化形态 | 消息 DAG(uuid/parentUuid),单文件多 leaf | append-only 事件日志(RolloutItem 流) |
| listing | 无索引:mtime 排序 + head/tail 64KB lite read | SQLite state_5.sqlite 索引(backfill 兜底) |
| resume 读取量 | 全量读 + DAG 选链(picker 阶段是 lite read) | 全量重放 + 反向扫描求存活前缀 |
| resume 身份 | 非 fork 复用原 sessionId,续写同一文件 | threadId 不变,rollout 继续 append(.zst 先 materialize) |
| fork 语义 | --fork-session:新 ID + 拷链全量;无截断选项(截断靠 /rewind) | ForkSnapshot:可截断到第 N 用户消息 / 注入中断边界;新 ID + 新文件 |
| 历史改写表示 | 改父指针/换 leaf(compact 写 compact_boundary 断链) | 永不改写:append Compacted/ThreadRolledBack 标记,读侧折算 |
| 损坏容忍 | 环检测截断、孤儿 tool_result 恢复、consistency 仅遥测 | 坏行跳过计数、legacy 行剥离、空文件报错 |
| 指定 ID 恢复 | --resume <id>;--session-id 需配 --fork-session | codex resume <id> / app-server thread/resume |
最小复现
# CC:fork 前后文件对比——fork 产生第二个 JSONL,原文件不再增长
claude --resume <id> --fork-session -p "继续" # 然后:
ls -lt ~/.claude/projects/<slug>/*.jsonl | head -3
# Codex:resume 后旧 rollout 文件继续 append(同一文件);fork 则出现新文件
codex resume --last
ls -lt ~/.codex/sessions/$(date +%Y/%m/%d)/ | head -3
# 验证 .zst 归档透明 materialize:对已压缩会话 resume,观察 .zst 消失、.jsonl 重现Harness 接入建议(Yoda 实践)
- Resume 即重新 spawn CLI 带 resume 参数:Yoda 的
resumeConversation只查自己 DB 后startSession(conversation, ..., true)(resumeConversation.ts:34-35),命令构造统一走buildAgentArgs——有resumeFlag的 runtime push--resume+ sessionId(impl/agent-command.ts:117-131),CC/Codex/cursor/copilot 同模板。不要自己重放历史进 prompt,让 runtime 自己做重建(CC 的孤儿恢复、Codex 的反向扫描都不是 harness 该重新实现的)。 - ID 映射是 Codex 接入的头号坑:CC 可用
--session-id让 conversationId == sessionId(fork 场景),但 Codex threadId 由 runtime 生成,Yoda 被迫用 title/createdAt 在 SQLite 里模糊反查(codex-session-id.ts)。建议:spawn 后立刻从 rollout 目录(按 mtime + cwd)或 app-serverSessionConfigured捕获 threadId 持久化。 - 冷会话状态:CC 离线只有文件(30 天清理);Codex 有
ThreadStatus::NotLoaded一等态 + SQLite。harness 的会话列表对 Codex 应直接查 SQLite(Yoda 即如此),对 CC 复刻 lite read(只读 head/tail,别全量 parse 大 transcript)。 - fork 给产品的机会:Codex 的
TruncateBeforeNthUserMessage是现成的「从这条消息分叉」原语(app-serverthread/fork);CC 没有截断 fork,等价功能要组合--fork-session+/rewind的 conversation restore,或 harness 自己裁剪 JSONL 再 fork——后者要小心 DAG 完整性(tool_use/tool_result 配对、parentUuid 悬挂)。 - 归档/解档对账:Yoda shell 出
codex archive <threadId>(codex-archive.ts:30-40)而非自己动文件——.zst/明文双表示 + SQLite archived 标记的一致性应由 runtime 维护。
失效条件
- CC 改变 leaf 选取策略(当前=最新 timestamp)或引入显式「当前分支」指针——选链结论过期;
- CC
--session-id/--fork-session约束变化(main.tsx:1279 校验); - Codex
ForkSnapshot语义扩展或thread/fork参数变化(exclude_turns当前是布尔); - Codex rollout 压缩策略变化(如直接支持对
.zstappend,materialize 路径废弃); - 两侧快照版本错配:CC 2026-03-31 vs Codex 2026-06-06。
参考资料
- CC 源码:
utils/listSessionsImpl.ts:153-228、utils/sessionStoragePortable.ts:209-282、utils/sessionStorage.ts:2069-2330、utils/sessionRestore.ts:409-500、main.tsx:988, 1279-1282、commands/branch/branch.ts - CC 文档:
claude-code-docs/docs/sessions.md(路径、30 天清理、fork 用法) - Codex 源码:
rollout/src/recorder.rs:842-977、rollout/src/compression.rs:47-115、core/src/session/rollout_reconstruction.rs、core/src/thread_manager.rs:127-146, 869-927, 1559-1640、cli/src/main.rs:2247-2300、app-server/src/request_processors/thread_processor.rs:3124+ - Yoda 实现:
src/main/core/conversations/resumeConversation.ts、impl/agent-command.ts、codex-session-id.ts、codex-archive.ts - 前序报告:《手工川-session-state-sync-cc-vs-codex-2026-06-11-v0.1》Axis 4(离线状态确定)