Skip to main content

JSONL Transcript 会话持久化与恢复机制

本文梳理 Claude Code 基于 JSONL transcript 的会话持久化、恢复、错误恢复、上下文压缩、分支、subagent、fork agent 和 remote agent 逻辑。 这不是按文件罗列的源码笔记,而是一份机制手册:先建立心智模型,再看数据结构、生命周期、异常路径和源码入口。

怎么读

总览

Claude Code 的本地会话核心是 append-only JSONL。每一行是一个 Entry,但恢复时不会按文件顺序重放整个文件,而是:
  1. 把 transcript message 放入 uuid -> message map。
  2. 把 metadata entry 放入各自 map 或数组。
  3. 选择最新 leaf。
  4. 从 leaf 沿 parentUuid 回溯,得到当前有效链。
  5. 应用 compact、snip、preserved segment、content replacement 等投影。
  6. 恢复 sessionId、worktree、mode、agent setting、任务状态等内存状态。
核心不变量:

系统分层

存储拓扑

核心源码地图

数据模型

Entry 定义在 src/types/logs.ts,可以分为三大类。

TranscriptMessage 字段

真正参与链路的是 TranscriptMessage

JSONL 示例

主会话消息:
sidechain 消息:
agent 的 content-replacement
compact boundary:

写入生命周期

总流程

关键点:

主会话写入

入口:recordTranscript(messages, teamInfo?, startingParentUuidHint?, allMessages?) 流程:
  1. cleanMessagesForLogging() 过滤 UI-only 或不应持久化的消息。
  2. getSessionMessages(sessionId) 读取当前 session 已有 UUID set。
  3. 对未写过的消息调用 insertMessageChain()
  4. insertMessageChain()parentUuid/sessionId/cwd/timestamp/version/gitBranch/isSidechain
  5. appendEntry() 进入 per-file queue。
去重不是简单丢弃所有重复:如果 prefix 中某些消息已写过,写入器会推进 startingParentUuid,确保后续新消息接在正确父节点后。

写队列、materialize 和 flush

Project 内部维护 per-file queue: sessionFile 初始为 null。这时 title、tag、mode、worktree 等 metadata 先存在内存或 pendingEntries 中。第一次出现 userassistant 时,materializeSessionFile() 才创建 session 文件,然后:
  1. 写入缓存 metadata。
  2. 回放 pending entries。
  3. 之后所有 entry 正常 append。
这样可以避免“只打开 CLI 没说话”也产生 metadata-only session,污染 /resume 列表。

sidechain 写入

subagent 使用 recordSidechainTranscript(messages, agentId, startingParentUuid?) 它底层仍走 insertMessageChain(),但写入字段不同:
appendEntry() 遇到 isSidechain && agentId 的 transcript message,会把它路由到:
如果 content-replacementagentId,也会路由到该 agent 的 sidechain JSONL,而不是主 session JSONL。 一个很重要的例外:sidechain 写入不会用主 session UUID set 做去重。fork agent 会复用父会话消息 UUID 来继承上下文;如果按主 session 去重,会把继承上下文从 sidechain 中误删,导致 agent resume 时只剩子 prompt。

读取与链路重建

从 JSONL 到有效链

loadTranscriptFile(filePath, opts?) 产出:

leaf 与 parent 链

buildConversationChain(messages, leaf)
  1. 从 leaf 开始。
  2. 读取 parentUuid
  3. 找到父消息并继续回溯。
  4. 检测 parent cycle,避免无限循环。
  5. reverse 成正序 transcript。
  6. 补回并行 tool_use 形成的 DAG 分支。
一个简化例子:
文件顺序不等于有效链。branch、rewind、streaming fallback 都可能让 JSONL 里有死分支;恢复只选择当前 leaf 所在世界线。

metadata 合并规则

大文件读取优化

transcript 可增长到几百 MB 甚至 GB,读取路径有几层防护。 walkChainBeforeParse() 只有预计能丢掉至少一半 buffer 时才做 concat,避免优化本身变成额外成本。

preserved segment 与 snip

compact boundary 可以带 compactMetadata.preservedSegment。恢复时 applyPreservedSegmentRelinks() 会:
  1. 验证 tailUuid -> headUuid 链是否完整。
  2. 把 preserved segment 的 head 接到 compact anchor 后。
  3. 把 anchor 的其他 children 接到 preserved tail。
  4. 删除最后一个 boundary 前且不属于 preserved segment 的旧消息。
  5. 清零 preserved assistant 的 usage,避免恢复后马上又触发 autocompact。
示意:
snip 和 compact 不同:compact 截断前缀,snip 删除中段。JSONL 不能真的删除旧行,所以 applySnipRemovals() 在内存 map 中删除 removedUuids,再把 dangling parentUuid 重连到最近未删除祖先。

旧链路修复

恢复入口

入口矩阵

CLI resume 流程

核心函数:

REPL /resume

REPL 内 resume 比 CLI 启动路径多了“从当前 session 切换到另一个 session”的工作:
  1. 清理目标 log messages。
  2. 当前 session 跑 SessionEnd hooks。
  3. 目标 session 跑 SessionStart resume hooks。
  4. 保存当前 session cost,恢复目标 session cost。
  5. switchSession(sessionId, dirname(fullPath)) 原子切换 sessionId + project dir。
  6. resetSessionFilePointer() 并恢复 metadata cache。
  7. 非 fork 时退出上一次 worktree,恢复目标 worktree,adoptResumedSessionFile()
  8. fork 时不接管原 transcript,不退出当前 worktree。
  9. 重建 content replacement state。
  10. 恢复 remote/local task 状态。
  11. 替换 messages、清 tool JSX、清输入框。

中断检测矩阵

deserializeMessagesWithInterruptDetection() 会先清理历史消息: 然后看最后一个 turn-relevant message: interrupted_turn 会统一转换为 interrupted_prompt,让上层只处理一种“需要续跑”的状态。

错误恢复矩阵

上下文视图

同一份消息在系统里有四种视图,不要混在一起: 每轮 query 的 active context 顺序:
  1. getMessagesAfterCompactBoundary(messages):取最近 compact boundary 之后的 active slice,默认叠加 snip 投影。
  2. 删除旧 toolUseResult 原始 payload,只保留 API 需要的 message.content
  3. applyToolResultBudget():过大的 tool_result 替换为 preview/stub,并写 content-replacement
  4. snipCompactIfNeeded()HISTORY_SNIP 下删除中段历史。
  5. microcompactMessages():time-based microcompact,再 cached microcompact。
  6. contextCollapse.applyCollapsesIfNeeded():当前为 identity stub。
  7. autoCompactIfNeeded():主动 compact,优先 session memory compact。
  8. predictive autocompact:API 前估算本 turn 增长,必要时提前 compact。
  9. API 真实超限后:context-collapse drain,再 reactive compact。

Compact 与投影

Compact 类型对比

Compact 结果形态

传统 compact 会生成:
  1. compact_boundary system message。
  2. compact summary user message。
  3. post-compact attachments,例如当前文件、计划模式、技能、MCP/tool schema delta、hook 结果。
简化 before/after:
boundary 本身是 system message,最后会被 API normalization 过滤;它的价值主要在本地投影、恢复和统计。

Boundary metadata

createCompactBoundaryMessage() 写: 后续路径还会补:

Tool result budget 与 content replacement

大 tool_result 不一定直接进入后续上下文。applyToolResultBudget() 会按 API-level user message 聚合预算,必要时把大块内容持久化并替换成较小 preview/stub。 关键点:

Session memory compact

sessionMemoryCompact.ts 是传统 summary compact 前的实验路径。流程:
  1. 等待 session memory extraction 完成。
  2. 读取 session memory 文件。
  3. lastSummarizedMessageId 时,从其后保留安全尾段;否则把 resumed session 视为已有 memory summary。
  4. 调整切点,避免断开 tool_use/tool_result 或 thinking blocks。
  5. 创建标准 compact_boundary + summary user message。
  6. 若 post-compact token count 仍超过阈值,放弃并回退传统 compact。
因为产物仍是标准 CompactionResult,下游写 transcript 和恢复逻辑与传统 compact 共用。

Context-collapse 当前状态

本仓库保留了 context-collapse 的持久化接口,但核心实现是 stub: 已预留 JSONL entry: loader 会收集这些 entry;遇到 compact boundary 时会清空旧 commits/snapshot,避免它们引用已被 compact 丢弃的 UUID。 所以当前真实生效的上下文缩减主要是 compact、session memory compact、tool_result budget、microcompact 和 snip;context-collapse 只是接口已接好。

Compact 后清理

runPostCompactCleanup(querySource) 总是清:
  • microcompact state。
  • system prompt sections。
  • classifier approvals。
  • speculative bash checks。
  • beta tracing。
  • session messages memo cache。
  • compact cleanup callbacks。
  • COMMIT_ATTRIBUTION 下异步 sweep file-content cache。
只在主线程 compact 清:
  • context-collapse store。
  • getUserContext cache。
  • memory files cache。
原因:subagent 和主线程同进程,共享模块级状态。agent:* compact 如果清主线程 context-collapse 或 memory cache,会破坏父会话状态。 它明确不清 resetSentSkillNames(),避免 compact 后重新注入完整 skill listing,浪费 token 和 prompt cache。

分支与 Fork 对比

/branch

/branch 创建新 session 文件,不是在原 JSONL 里追加 branch marker。 流程:
  1. 生成新的 sessionId。
  2. 读取当前 transcript 文件。
  3. 过滤主会话消息,排除 isSidechain 和非 transcript entry。
  4. 复制消息并重写 sessionId
  5. 重新串 parentUuid
  6. 添加 forkedFrom: { sessionId, messageUuid }
  7. 复制原 session 的 content-replacement entry 并改成新 sessionId。
  8. 写入 <newSessionId>.jsonl
  9. 构造 LogOption 并让 REPL resume 到新分支。

--fork-session

--fork-session 只改变 resume 的 ownership: 如果旧 session 有 content-replacement,会先把 records seed 到新 session,避免大 tool_result 的替换状态丢失。

Subagent 与 Fork Agent

普通 subagent

普通 AgentTool subagent 最终走 runAgent() 父会话通常只记录:
  • Agent tool_use。
  • Agent tool_result。
  • async launch result。
  • task notification。
  • 必要 progress。
完整子 agent 内部工具调用和消息在 sidechain JSONL 中,不会混进主会话 active context。

Fork agent

fork agent 是 AgentTool 的一种特殊 subagent。它继承父上下文、system prompt、tools、model 和 thinking config,目标是让多个子 agent 共享尽可能长的 byte-identical prompt cache prefix。 关键实现: buildForkedMessages() 负责构造 cache-friendly 尾部:
多个 fork child 的长前缀相同,只有最后 directive 不同。 限制:

runForkedAgent() 不是 AgentTool fork

src/utils/forkedAgent.tsrunForkedAgent() 是内部 cache-safe side query 工具,用于 session memory、prompt suggestion、summary 等。它复用父 system/user/system context、tools、messages,可选 skipTranscript,但默认不写 AgentTool metadata,也不是用户可继续对话的 AgentTool fork。

Agent 恢复

本地 agent 恢复入口是 resumeAgentBackground() 流程: 恢复时:

Remote Agent 恢复

remote CCR agent 不靠本地 sidechain 继续执行。 差异:

常见误区

源码入口索引