Yoda
参考Agent 设计指南Session

Resume / Fork / 历史

CC 是「DAG 选链」——leafUuid + parentUuid 回溯出一条链,resume 原地续写同文件、fork 拷链换 ID;Codex 是「日志重放」——rollout 全量重放 + 反向扫描求最新存活前缀,fork 有显式截断语义

Resume / Fork / 历史

结论

两个 runtime 的 resume 是两种世界观。Claude Code 的 transcript 是消息 DAGuuid/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-sessioncodex 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-2330loadTranscriptFromFile):全量解析 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-490processResumedConversation):

  • 非 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-980 copyFileHistoryForResume,见《Checkpoint》章)。

/branch 命令是 fork 的交互式包装,会给 fork 文件写定制标题(commands/branch/branch.ts:252)。

Codex CLI

Resume:全量重放。 [一手源码] rollout/src/recorder.rs:842-927load_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-180reconstruct_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 抢占 → 删 .zstcompression.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/forkdocs/codex_mcp_interface.md:16thread_processor.rs:3124+,支持 exclude_turns 只继承配置不继承历史)。codex resume 同理三入口(cli/src/main.rs:2247-2273),resume 一个 NotLoaded 线程即从 SQLite/rollout 冷加载。

差异矩阵

维度Claude CodeCodex CLI
持久化形态消息 DAG(uuid/parentUuid),单文件多 leafappend-only 事件日志(RolloutItem 流)
listing无索引:mtime 排序 + head/tail 64KB lite readSQLite 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-sessioncodex 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-server SessionConfigured 捕获 threadId 持久化。
  • 冷会话状态:CC 离线只有文件(30 天清理);Codex 有 ThreadStatus::NotLoaded 一等态 + SQLite。harness 的会话列表对 Codex 应直接查 SQLite(Yoda 即如此),对 CC 复刻 lite read(只读 head/tail,别全量 parse 大 transcript)。
  • fork 给产品的机会:Codex 的 TruncateBeforeNthUserMessage 是现成的「从这条消息分叉」原语(app-server thread/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 压缩策略变化(如直接支持对 .zst append,materialize 路径废弃);
  • 两侧快照版本错配:CC 2026-03-31 vs Codex 2026-06-06。

参考资料

  • CC 源码:utils/listSessionsImpl.ts:153-228utils/sessionStoragePortable.ts:209-282utils/sessionStorage.ts:2069-2330utils/sessionRestore.ts:409-500main.tsx:988, 1279-1282commands/branch/branch.ts
  • CC 文档:claude-code-docs/docs/sessions.md(路径、30 天清理、fork 用法)
  • Codex 源码:rollout/src/recorder.rs:842-977rollout/src/compression.rs:47-115core/src/session/rollout_reconstruction.rscore/src/thread_manager.rs:127-146, 869-927, 1559-1640cli/src/main.rs:2247-2300app-server/src/request_processors/thread_processor.rs:3124+
  • Yoda 实现:src/main/core/conversations/resumeConversation.tsimpl/agent-command.tscodex-session-id.tscodex-archive.ts
  • 前序报告:《手工川-session-state-sync-cc-vs-codex-2026-06-11-v0.1》Axis 4(离线状态确定)

On this page