38 KiB
JSONL Transcript 会话持久化与恢复机制
本文梳理 Claude Code 基于 JSONL transcript 的会话持久化、恢复、错误恢复、上下文压缩、分支、subagent、fork agent 和 remote agent 逻辑。
这不是按文件罗列的源码笔记,而是一份机制手册:先建立心智模型,再看数据结构、生命周期、异常路径和源码入口。
怎么读
| 如果你想看 | 建议先读 |
|---|---|
| 为什么 resume 能恢复到正确位置 | 总览、读取与链路重建、恢复入口 |
| 为什么 compact 后历史还在但模型看不到 | 上下文视图、Compact 与投影 |
| 为什么 subagent 不污染主会话 | 存储拓扑、Subagent 与 Fork Agent |
/branch、--fork-session、/fork 有什么区别 |
分支与 Fork 对比 |
| 崩溃、超限、取消后如何恢复 | 错误恢复矩阵 |
总览
Claude Code 的本地会话核心是 append-only JSONL。每一行是一个 Entry,但恢复时不会按文件顺序重放整个文件,而是:
- 把 transcript message 放入
uuid -> messagemap。 - 把 metadata entry 放入各自 map 或数组。
- 选择最新 leaf。
- 从 leaf 沿
parentUuid回溯,得到当前有效链。 - 应用 compact、snip、preserved segment、content replacement 等投影。
- 恢复 sessionId、worktree、mode、agent setting、任务状态等内存状态。
核心不变量:
| 不变量 | 含义 |
|---|---|
| JSONL 尽量 append-only | compact、branch、sidechain 都优先追加新 entry,不直接改旧历史。 |
uuid/parentUuid 决定世界线 |
文件顺序只说明写入顺序,真正恢复靠链路回溯。 |
| metadata 不参与主链 | title、tag、worktree、content replacement 等通过 sessionId/messageId/agentId 合并。 |
| compact 不删除历史 | 它追加 boundary,模型视图从最后一个 boundary 后开始。 |
| subagent 是 sidechain | 子 agent 的完整对话在独立 JSONL,父会话只看到 Agent tool 的结果/通知。 |
| remote agent 不是 sidechain | remote agent 本地只保存 sidecar 身份,执行状态来自 CCR。 |
系统分层
flowchart TD
A[磁盘层<br/>append-only JSONL + sidecar metadata] --> B[链路层<br/>uuid / parentUuid / leaf]
B --> C[投影层<br/>compact / snip / tool_result budget / context-collapse]
C --> D[恢复层<br/>deserialize / interrupt detection / metadata restore]
D --> E[运行层<br/>REPL / QueryEngine / AgentTask / RemoteTask]
存储拓扑
~/.claude/projects/<project-key>/
<sessionId>.jsonl
<sessionId>/
subagents/
agent-<agentId>.jsonl
agent-<agentId>.meta.json
<subdir>/
agent-<agentId>.jsonl
agent-<agentId>.meta.json
remote-agents/
remote-agent-<taskId>.meta.json
| 文件 | 生成函数 | 用途 |
|---|---|---|
<sessionId>.jsonl |
getTranscriptPath() |
主会话 transcript。 |
subagents/agent-<agentId>.jsonl |
getAgentTranscriptPath(agentId) |
本地 subagent / fork agent sidechain。 |
subagents/agent-<agentId>.meta.json |
getAgentMetadataPath(agentId) |
agentType、worktreePath、description。 |
remote-agents/remote-agent-<taskId>.meta.json |
getRemoteAgentMetadataPath(taskId) |
remote CCR session 身份,用于恢复 polling。 |
核心源码地图
| 机制 | 主要文件 |
|---|---|
| Entry 类型 | src/types/logs.ts |
| 路径、写入、读取、链路重建 | src/utils/sessionStorage.ts |
| 大文件流式读取 | src/utils/sessionStoragePortable.ts |
| CLI resume 加载和中断检测 | src/utils/conversationRecovery.ts |
| session 切换和状态恢复 | src/utils/sessionRestore.ts |
| SDK/headless query 写 transcript | src/QueryEngine.ts |
| API query loop、compact、错误恢复 | src/query.ts |
| compact 实现 | src/services/compact/* |
| context-collapse stub 与持久化接口 | src/services/contextCollapse/* |
/branch |
src/commands/branch/branch.ts |
/fork |
src/commands/fork/fork.tsx |
| AgentTool 和 subagent | packages/builtin-tools/src/tools/AgentTool/* |
| 通用 forked side query | src/utils/forkedAgent.ts |
| remote agent task | src/tasks/RemoteAgentTask/RemoteAgentTask.tsx |
数据模型
Entry 定义在 src/types/logs.ts,可以分为三大类。
| 类别 | 典型 type | 是否进入 parentUuid 链 |
key | 恢复用途 |
|---|---|---|---|---|
| transcript message | user、assistant、attachment、system |
是 | uuid |
重建对话链、模型上下文、UI scrollback。 |
| session metadata | custom-title、tag、mode、worktree-state、pr-link、agent-setting |
否 | sessionId |
恢复标题、标签、模式、worktree、PR、agent 设置。 |
| message metadata | file-history-snapshot、attribution-snapshot、summary |
否 | messageId 或 leafUuid |
恢复文件历史、归因、摘要。 |
| replacement metadata | content-replacement |
否 | sessionId + optional agentId |
恢复大 tool_result 的替换决策。 |
| context-collapse metadata | marble-origami-commit、marble-origami-snapshot |
否 | sessionId |
预留 context-collapse 恢复接口;当前实现为 stub。 |
| queue/task metadata | queue-operation、task-summary、speculation-accept |
否 | 各自字段 | 恢复队列、任务摘要、推测接受统计。 |
TranscriptMessage 字段
真正参与链路的是 TranscriptMessage:
| 字段 | 含义 |
|---|---|
uuid |
当前消息 ID。 |
parentUuid |
链路父节点,恢复时沿它回溯。 |
logicalParentUuid |
compact boundary 等断链场景保留逻辑父节点。 |
sessionId |
所属主 session。 |
cwd |
写入时工作目录。 |
timestamp |
写入时间。 |
version |
CLI 版本。 |
gitBranch |
写入时 git 分支。 |
isSidechain |
是否是 subagent sidechain。 |
agentId |
sidechain 所属 agent。 |
teamName/agentName/agentColor |
swarm / teammate 展示元数据。 |
JSONL 示例
主会话消息:
{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s1","isSidechain":false,"cwd":"D:\\vibe\\claude-code","message":{"role":"user","content":"修复测试"}}
{"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":"s1","isSidechain":false,"message":{"role":"assistant","content":[{"type":"text","text":"我来检查。"}]}}
sidechain 消息:
{"type":"user","uuid":"u2","parentUuid":null,"sessionId":"s1","isSidechain":true,"agentId":"ag1","message":{"role":"user","content":"分析 compact 路径"}}
agent 的 content-replacement:
{"type":"content-replacement","sessionId":"s1","agentId":"ag1","replacements":[{"messageUuid":"u2","toolUseId":"toolu_...","blockIndex":0,"kind":"persisted"}]}
compact boundary:
{"type":"system","subtype":"compact_boundary","uuid":"b1","parentUuid":"a9","logicalParentUuid":"a9","sessionId":"s1","compactMetadata":{"trigger":"auto","preTokens":182000,"messagesSummarized":94}}
写入生命周期
总流程
sequenceDiagram
participant User
participant QE as QueryEngine
participant SS as sessionStorage.Project
participant FS as JSONL
participant API as query()/API
User->>QE: ask(messages)
QE->>SS: recordTranscript(user messages)
SS->>SS: clean + dedup + insertMessageChain
SS->>SS: appendEntry / enqueueWrite
SS-->>FS: drain queue append JSONL
QE->>API: start query loop
API-->>QE: assistant/user/system compact_boundary
QE->>SS: recordTranscript(streamed messages)
QE->>SS: flushSessionStorage before result when needed
关键点:
| 设计 | 为什么 |
|---|---|
| 用户输入先写 transcript,再进 API | 进程在 API 前崩溃时,resume 仍能看到用户 prompt。 |
| assistant streaming 写入多为 fire-and-forget | 不阻塞 token streaming。 |
| result 前按需 flush | 避免 SDK/桌面端拿到 result 后立即杀进程导致尾部丢失。 |
progress 不参与链路 |
高频 progress tick 不应该制造分叉或膨胀 transcript。 |
主会话写入
入口:recordTranscript(messages, teamInfo?, startingParentUuidHint?, allMessages?)。
流程:
cleanMessagesForLogging()过滤 UI-only 或不应持久化的消息。getSessionMessages(sessionId)读取当前 session 已有 UUID set。- 对未写过的消息调用
insertMessageChain()。 insertMessageChain()补parentUuid/sessionId/cwd/timestamp/version/gitBranch/isSidechain。appendEntry()进入 per-file queue。
去重不是简单丢弃所有重复:如果 prefix 中某些消息已写过,写入器会推进 startingParentUuid,确保后续新消息接在正确父节点后。
写队列、materialize 和 flush
Project 内部维护 per-file queue:
| 机制 | 细节 |
|---|---|
writeQueues |
Map<filePath, entry[]>,按文件聚合写入。 |
| drain timer | 默认 100ms;CCR/remote persistence 场景约 10ms。 |
| queue 上限 | 单队列超过 1000 条会丢弃最老 queued entry 并 resolve,防止内存无限增长。 |
| chunk 上限 | 单次 JSONL append chunk 约 100MB。 |
flushSessionStorage() |
取消 timer,等待 active drain 和 tracked writes。 |
sessionFile 初始为 null。这时 title、tag、mode、worktree 等 metadata 先存在内存或 pendingEntries 中。第一次出现 user 或 assistant 时,materializeSessionFile() 才创建 session 文件,然后:
- 写入缓存 metadata。
- 回放 pending entries。
- 之后所有 entry 正常 append。
这样可以避免“只打开 CLI 没说话”也产生 metadata-only session,污染 /resume 列表。
sidechain 写入
subagent 使用 recordSidechainTranscript(messages, agentId, startingParentUuid?)。
它底层仍走 insertMessageChain(),但写入字段不同:
isSidechain: true
agentId: agentId
appendEntry() 遇到 isSidechain && agentId 的 transcript message,会把它路由到:
<project>/<sessionId>/subagents/agent-<agentId>.jsonl
如果 content-replacement 带 agentId,也会路由到该 agent 的 sidechain JSONL,而不是主 session JSONL。
一个很重要的例外:sidechain 写入不会用主 session UUID set 做去重。fork agent 会复用父会话消息 UUID 来继承上下文;如果按主 session 去重,会把继承上下文从 sidechain 中误删,导致 agent resume 时只剩子 prompt。
读取与链路重建
从 JSONL 到有效链
flowchart TD
A[loadTranscriptFile(file)] --> B[readTranscriptForLoad<br/>大文件按 chunk 读]
B --> C[parseJSONL Entry]
C --> D[messages Map uuid->TranscriptMessage]
C --> E[metadata maps/arrays]
D --> F[progress bridge / preserved relink / snip removal]
F --> G[select leaf]
G --> H[buildConversationChain]
H --> I[recoverOrphanedParallelToolResults]
I --> J[LogOption or agent transcript]
loadTranscriptFile(filePath, opts?) 产出:
| 输出 | 用途 |
|---|---|
messages |
uuid -> TranscriptMessage。 |
leafUuids |
候选 leaf。 |
| title/tag/mode/worktree/PR maps | session metadata。 |
fileHistorySnapshots / attributionSnapshots |
文件状态恢复。 |
contentReplacements |
主线程 replacement records。 |
agentContentReplacements |
agentId -> replacement records。 |
contextCollapseCommits / contextCollapseSnapshot |
context-collapse 恢复输入。 |
leaf 与 parent 链
buildConversationChain(messages, leaf):
- 从 leaf 开始。
- 读取
parentUuid。 - 找到父消息并继续回溯。
- 检测 parent cycle,避免无限循环。
- reverse 成正序 transcript。
- 补回并行 tool_use 形成的 DAG 分支。
一个简化例子:
u1 <- a1 <- u2 <- a2
^
leaf
恢复链: a2 -> u2 -> a1 -> u1
正序链: u1, a1, u2, a2
文件顺序不等于有效链。branch、rewind、streaming fallback 都可能让 JSONL 里有死分支;恢复只选择当前 leaf 所在世界线。
metadata 合并规则
| metadata | 合并方式 | 说明 |
|---|---|---|
custom-title、tag、mode、worktree-state、pr-link、agent-setting |
sessionId keyed,通常 last-wins | 恢复最新 session 状态。 |
file-history-snapshot、attribution-snapshot |
messageId keyed / array | 恢复文件历史与归因。 |
content-replacement |
append array | 多轮 replacement 决策都要保留。 |
agentContentReplacements |
agentId keyed + append array | agent resume 重建 sidechain replacement state。 |
marble-origami-commit |
ordered array | 顺序有语义,后一个 commit 可能引用前一个 summary。 |
marble-origami-snapshot |
last-wins | staged snapshot 只恢复最新状态。 |
大文件读取优化
transcript 可增长到几百 MB 甚至 GB,读取路径有几层防护。
| 优化 | 位置 | 目的 |
|---|---|---|
| chunk 读取 | readTranscriptForLoad() |
避免一次性读爆内存。 |
| fd 层跳过大 metadata | readTranscriptForLoad() |
attribution-snapshot 等大 entry 不进入 buffer。 |
| compact 前缀跳过 | readTranscriptForLoad() |
遇到非 preserved compact boundary 后,只保留 boundary 后内容。 |
| pre-boundary metadata scan | scanPreBoundaryMetadata() |
compact 前被跳过时,仍保留 title/tag/mode/worktree/PR 等展示信息。 |
| byte-level dead branch 裁剪 | walkChainBeforeParse() |
JSON.parse 前只拼 active chain 和 metadata,跳过 dead fork/rewind branch。 |
| lite read 限制 | MAX_TRANSCRIPT_READ_BYTES |
直接读 raw transcript 的调用超过约 50MB 要避开。 |
walkChainBeforeParse() 只有预计能丢掉至少一半 buffer 时才做 concat,避免优化本身变成额外成本。
preserved segment 与 snip
compact boundary 可以带 compactMetadata.preservedSegment。恢复时 applyPreservedSegmentRelinks() 会:
- 验证
tailUuid -> headUuid链是否完整。 - 把 preserved segment 的 head 接到 compact anchor 后。
- 把 anchor 的其他 children 接到 preserved tail。
- 删除最后一个 boundary 前且不属于 preserved segment 的旧消息。
- 清零 preserved assistant 的 usage,避免恢复后马上又触发 autocompact。
示意:
compact 前: old... -> anchor -> head -> ... -> tail -> next
compact 后: boundary/summary -> head -> ... -> tail -> next
snip 和 compact 不同:compact 截断前缀,snip 删除中段。JSONL 不能真的删除旧行,所以 applySnipRemovals() 在内存 map 中删除 removedUuids,再把 dangling parentUuid 重连到最近未删除祖先。
旧链路修复
| 问题 | 修复 |
|---|---|
legacy progress 曾进入 parent 链 |
progressBridge 把指向 progress 的 parent 改回 progress 的真实父节点。 |
| parent cycle | buildConversationChain() 检测 cycle,记录并返回 partial chain。 |
| 并行 tool_use 形成 DAG | recoverOrphanedParallelToolResults() 按 assistant message.id 和 tool_result parent 关系补回 sibling。 |
| streaming fallback 孤儿尾巴 | tombstone 触发 removeTranscriptMessage(uuid) 删除失败 attempt。 |
恢复入口
入口矩阵
| 入口 | 加载源 | 是否复用原 sessionId | 是否 adopt 原 JSONL | 特点 |
|---|---|---|---|---|
--continue |
当前目录最近 session | 是 | 是 | 跳过仍 live 的 bg/daemon 非 interactive session。 |
--resume <uuid> |
指定 session | 是 | 是 | 也支持 custom title / 搜索词 / picker。 |
--resume <jsonl> |
指定 JSONL 文件 | 是 | 是 | Ant 内部/print path 支持。 |
--fork-session + resume |
旧 session messages | 否 | 否 | 保持新 sessionId,把旧消息作为新 session 初始内容。 |
--resume-session-at <message.id> |
print/headless resume | 取决于 resume | 取决于 resume | 截断到指定 assistant message。 |
REPL /resume |
picker / log option | 是或 fork | 是或否 | 会跑 SessionEnd/SessionStart hooks,切换 UI state。 |
CLI resume 流程
flowchart TD
A[main.tsx --continue/--resume] --> B[loadConversationForResume]
B --> C[load log or transcript]
C --> D[deserializeMessagesWithInterruptDetection]
D --> E[processSessionStartHooks]
E --> F[processResumedConversation]
F --> G{fork session?}
G -- no --> H[switchSession + adoptResumedSessionFile]
G -- yes --> I[keep fresh sessionId + seed content replacement]
H --> J[restore mode/worktree/agent/context-collapse/cost]
I --> J
J --> K[start REPL or print]
核心函数:
| 函数 | 责任 |
|---|---|
loadConversationForResume() |
统一加载最近 session、sessionId、LogOption 或 JSONL path;补 lite log;复制 plan/file history;做 consistency check;反序列化和中断检测;返回 metadata。 |
processResumedConversation() |
CLI interactive 启动恢复;切换或 fork session;恢复 cost、worktree、mode、agent setting、context-collapse、attribution。 |
restoreSessionStateFromLog() |
恢复 AppState 侧状态:file history、attribution、context-collapse、TodoWrite todos。 |
REPL /resume
REPL 内 resume 比 CLI 启动路径多了“从当前 session 切换到另一个 session”的工作:
- 清理目标 log messages。
- 当前 session 跑 SessionEnd hooks。
- 目标 session 跑 SessionStart resume hooks。
- 保存当前 session cost,恢复目标 session cost。
switchSession(sessionId, dirname(fullPath))原子切换 sessionId + project dir。resetSessionFilePointer()并恢复 metadata cache。- 非 fork 时退出上一次 worktree,恢复目标 worktree,
adoptResumedSessionFile()。 - fork 时不接管原 transcript,不退出当前 worktree。
- 重建 content replacement state。
- 恢复 remote/local task 状态。
- 替换 messages、清 tool JSX、清输入框。
中断检测矩阵
deserializeMessagesWithInterruptDetection() 会先清理历史消息:
| 清理 | 目的 |
|---|---|
| legacy attachment 迁移 | 兼容旧 transcript。 |
非法 permissionMode 删除 |
防止跨 build 的无效枚举进入运行态。 |
| unresolved tool_use 过滤 | 避免 API 报 tool_use/tool_result 不配对。 |
| orphaned thinking-only assistant 过滤 | 避免中断 streaming 留下孤儿 thinking block。 |
| whitespace-only assistant 过滤 | 避免取消时留下空白 assistant。 |
然后看最后一个 turn-relevant message:
| 最后有效消息 | 结果 | 额外动作 |
|---|---|---|
| assistant | none |
streaming 持久化里 stop_reason 常为 null,不能靠它判断未完成。 |
| 普通 user | interrupted_prompt |
插入 NO_RESPONSE_REQUESTED sentinel 保持 API-valid。 |
| meta user / compact summary user | none |
不把内部控制消息当用户新请求。 |
| tool_result user | 通常 interrupted_turn |
例外:Brief/SendUserMessage/SendUserFile terminal tool_result 视为完成。 |
| attachment | interrupted_turn |
追加 meta user:Continue from where you left off. |
| system/progress/API error assistant | 跳过 | 不作为 turn 完成判断依据。 |
interrupted_turn 会统一转换为 interrupted_prompt,让上层只处理一种“需要续跑”的状态。
错误恢复矩阵
| 场景 | 处理策略 | transcript 影响 |
|---|---|---|
| API 前进程崩溃 | 用户 prompt 已由 QueryEngine.ask() 先写入。 |
resume 看到普通 user,触发 interrupted_prompt。 |
| streaming fallback 产生孤儿 assistant | yield tombstone,REPL 移除 UI message 并调用 removeTranscriptMessage(uuid)。 |
优先只改 JSONL 尾部 64KB;大文件目标不在尾部时跳过慢 rewrite。 |
| prompt-too-long / media-too-large | streaming 阶段先 withheld;先 context-collapse drain,再 reactive compact;失败才暴露错误。 | compact 成功则写 boundary/summary 并重试;失败才写 API error message。 |
| max_output_tokens | 先提高 max output override;仍失败则注入内部 recovery prompt 续写;耗尽才暴露错误。 | 内部 retry prompt 不一定成为普通 transcript,取决于是否 yield 到外层。 |
| auto compact 关闭但到 blocking limit | 直接 yield prompt-too-long 风格 API error。 | 保留用户手动 /compact 空间。 |
| abort during streaming/tools | 补齐缺失 tool_result,必要时 yield user interruption message。 | reason === interrupt 时跳过 interruption message,因为后续 queued user message 已提供上下文。 |
| stop hook blocking | 把 hook blocking error 加入 state 后重试。 | 有 reactive compact guard,避免 hook/error/compact 无限循环。 |
| compact boundary 指向未落盘 tail | QueryEngine 写 boundary 前强制补写 preserved tail 前的消息。 | 避免恢复时 boundary 引用不存在 UUID。 |
| subagent transcript 尾部不完整 | resumeAgentBackground() 再次过滤 unresolved tool_use、orphan thinking、空白 assistant。 |
避免恢复 agent 后 API 请求非法。 |
上下文视图
同一份消息在系统里有四种视图,不要混在一起:
| 视图 | 内容 | 谁使用 |
|---|---|---|
| Raw transcript | JSONL 中所有 entry,包括旧历史、dead branch、metadata、sidechain。 | 磁盘持久化和审计。 |
| UI scrollback | REPL 当前展示的消息,可能保留 compact 前历史和 collapsed UI group。 | 终端 UI。 |
| Active query view | getMessagesAfterCompactBoundary() 后的消息,默认再投影 snip。 |
query.ts 上下文管理。 |
| API wire view | normalizeMessagesForAPI() 后,过滤 system boundary、修复 tool pairing、插入 cache edits。 |
Anthropic/OpenAI/Gemini 等 API client。 |
每轮 query 的 active context 顺序:
getMessagesAfterCompactBoundary(messages):取最近 compact boundary 之后的 active slice,默认叠加 snip 投影。- 删除旧
toolUseResult原始 payload,只保留 API 需要的message.content。 applyToolResultBudget():过大的 tool_result 替换为 preview/stub,并写content-replacement。snipCompactIfNeeded():HISTORY_SNIP下删除中段历史。microcompactMessages():time-based microcompact,再 cached microcompact。contextCollapse.applyCollapsesIfNeeded():当前为 identity stub。autoCompactIfNeeded():主动 compact,优先 session memory compact。- predictive autocompact:API 前估算本 turn 增长,必要时提前 compact。
- API 真实超限后:context-collapse drain,再 reactive compact。
Compact 与投影
Compact 类型对比
| 类型 | 触发 | 摘要来源 | 是否调用 compact API | 是否保留尾段 | 失败策略 |
|---|---|---|---|---|---|
| manual compact | /compact |
compact summary API 或 session memory | 取决于路径 | 取决于 full/partial/SM | 显示失败或回退传统 compact。 |
| auto compact | token 阈值 | 先 session memory,后 summary API | 取决于路径 | 取决于路径 | 连续失败 circuit breaker,默认 3 次后停止自动 compact。 |
| predictive compact | API 前估算增长 | 同 auto compact | 取决于路径 | 取决于路径 | 失败则继续原请求或走后续错误恢复。 |
| reactive compact | API 真实 413/media error 后 | compactConversation() |
是 | 当前 wrapper 取决于 compact 实现 | hasAttemptedReactiveCompact 防循环。 |
| session memory compact | manual/auto 前置尝试 | session memory 文件 | 否 | 是 | 若 post-compact 仍超阈值,放弃并回退传统 compact。 |
| microcompact | time/cached 小型压缩 | 局部清理或 API cache edit | 不一定 | 不适用 | 通常不改变 JSONL 主历史。 |
| snip | HISTORY_SNIP |
删除中段 | 否 | 保留前后上下文 | 通过 snip metadata 投影,不物理删旧行。 |
Compact 结果形态
传统 compact 会生成:
compact_boundarysystem message。- compact summary user message。
- post-compact attachments,例如当前文件、计划模式、技能、MCP/tool schema delta、hook 结果。
简化 before/after:
Raw/UI:
u1, a1, u2, a2, ... u99, a99,
system:compact_boundary,
user:compact summary,
attachment:current files,
u100
Active query view:
system:compact_boundary,
user:compact summary,
attachment:current files,
u100
API wire view:
user:compact summary,
attachment/content,
u100
boundary 本身是 system message,最后会被 API normalization 过滤;它的价值主要在本地投影、恢复和统计。
Boundary metadata
createCompactBoundaryMessage() 写:
| 字段 | 含义 |
|---|---|
compactMetadata.trigger |
manual 或 auto。 |
compactMetadata.preTokens |
compact 前 token 数。 |
compactMetadata.userContext |
用户手动 compact 的额外说明。 |
compactMetadata.messagesSummarized |
被总结消息数量。 |
logicalParentUuid |
compact 前最后消息,用于逻辑追踪。 |
后续路径还会补:
| 字段 | 来源 | 作用 |
|---|---|---|
preCompactDiscoveredTools |
traditional/SM compact | 恢复 deferred tool schema 可见性。 |
preservedSegment.{headUuid,anchorUuid,tailUuid} |
partial/SM compact | 恢复时把保留尾段接到 boundary 后。 |
Tool result budget 与 content replacement
大 tool_result 不一定直接进入后续上下文。applyToolResultBudget() 会按 API-level user message 聚合预算,必要时把大块内容持久化并替换成较小 preview/stub。
关键点:
| 点 | 说明 |
|---|---|
| replacement decision 会落 JSONL | recordContentReplacement() 写 content-replacement。 |
| 主线程和 agent 分开 | 无 agentId 写主 JSONL;有 agentId 写 sidechain JSONL。 |
| resume 会重建 replacement state | 避免恢复后同一大结果又变回完整内容,导致 token 暴涨或 prompt cache 失配。 |
--fork-session 会 seed records |
fork 新 session 时复制 replacement 决策到新 session。 |
Session memory compact
sessionMemoryCompact.ts 是传统 summary compact 前的实验路径。流程:
- 等待 session memory extraction 完成。
- 读取 session memory 文件。
- 有
lastSummarizedMessageId时,从其后保留安全尾段;否则把 resumed session 视为已有 memory summary。 - 调整切点,避免断开 tool_use/tool_result 或 thinking blocks。
- 创建标准
compact_boundary+ summary user message。 - 若 post-compact token count 仍超过阈值,放弃并回退传统 compact。
因为产物仍是标准 CompactionResult,下游写 transcript 和恢复逻辑与传统 compact 共用。
Context-collapse 当前状态
本仓库保留了 context-collapse 的持久化接口,但核心实现是 stub:
| 模块 | 当前行为 |
|---|---|
contextCollapse/index.ts |
applyCollapsesIfNeeded() 返回原 messages;recoverFromOverflow() 返回 committed=0;isWithheldPromptTooLong() 恒 false。 |
contextCollapse/operations.ts |
projectView() 是 identity。 |
contextCollapse/persist.ts |
restoreFromEntries() 是 no-op。 |
已预留 JSONL entry:
| Entry | 写入接口 | 内容 |
|---|---|---|
marble-origami-commit |
recordContextCollapseCommit() |
collapseId、summary UUID/content、archived span 边界。 |
marble-origami-snapshot |
recordContextCollapseSnapshot() |
staged spans、armed、lastSpawnTokens。 |
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。
getUserContextcache。- memory files cache。
原因:subagent 和主线程同进程,共享模块级状态。agent:* compact 如果清主线程 context-collapse 或 memory cache,会破坏父会话状态。
它明确不清 resetSentSkillNames(),避免 compact 后重新注入完整 skill listing,浪费 token 和 prompt cache。
分支与 Fork 对比
| 入口 | 本质 | 是否新主 session | 是否 subagent | 持久化位置 | 父会话看到什么 | 恢复方式 |
|---|---|---|---|---|---|---|
/branch |
复制当前主 transcript 成新 JSONL | 是 | 否 | <newSessionId>.jsonl |
直接切到新分支会话 | 普通 session resume。 |
--fork-session |
resume/continue 时把旧消息作为新 session 初始消息 | 是 | 否 | 新 session 首次写入时 materialize | 启动即在新 session 中继续 | 新 session resume。 |
/fork <directive> |
slash wrapper,调用 AgentTool fork | 否 | 是 | subagents/agent-<id>.jsonl + .meta.json |
fork started + task notification | resumeAgentBackground()。 |
AgentTool({ fork: true }) |
Tool 层 fork 子 agent | 否 | 是 | subagents/agent-<id>.jsonl + .meta.json |
sync final tool_result 或 async notification | resumeAgentBackground()。 |
| 普通 AgentTool async | 后台本地 subagent | 否 | 是 | subagents/agent-<id>.jsonl + .meta.json |
async_launched + task notification |
resumeAgentBackground()。 |
| remote AgentTool | CCR remote session | 否 | 远端 | remote-agents/*.meta.json |
remote task output/notification | restoreRemoteAgentTasks() + CCR。 |
/branch
/branch 创建新 session 文件,不是在原 JSONL 里追加 branch marker。
流程:
- 生成新的 sessionId。
- 读取当前 transcript 文件。
- 过滤主会话消息,排除
isSidechain和非 transcript entry。 - 复制消息并重写
sessionId。 - 重新串
parentUuid。 - 添加
forkedFrom: { sessionId, messageUuid }。 - 复制原 session 的
content-replacemententry 并改成新 sessionId。 - 写入
<newSessionId>.jsonl。 - 构造
LogOption并让 REPL resume 到新分支。
--fork-session
--fork-session 只改变 resume 的 ownership:
| 非 fork resume | fork-session resume |
|---|---|
| 切到旧 sessionId。 | 保持启动时 fresh sessionId。 |
adoptResumedSessionFile() 接管旧 JSONL。 |
不接管旧 JSONL。 |
| 后续继续 append 到旧 transcript。 | 后续 materialize 成新 transcript。 |
| 原 session 继续增长。 | 原 session 不被写入。 |
如果旧 session 有 content-replacement,会先把 records seed 到新 session,避免大 tool_result 的替换状态丢失。
Subagent 与 Fork Agent
普通 subagent
普通 AgentTool subagent 最终走 runAgent():
sequenceDiagram
participant Parent as 父会话
participant Tool as AgentTool
participant Agent as runAgent
participant Side as sidechain JSONL
participant Task as LocalAgentTask
Parent->>Tool: assistant tool_use Agent
Tool->>Agent: start sync or async
Agent->>Side: record initialMessages
Agent->>Side: record assistant/user/progress/compact_boundary
alt sync foreground
Agent-->>Tool: final result
Tool-->>Parent: Agent tool_result
else async/background
Tool-->>Parent: async_launched tool_result
Agent-->>Task: complete
Task-->>Parent: <task-notification>
end
父会话通常只记录:
- 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。
关键实现:
| 继承内容 | 实现 |
|---|---|
| system prompt | 优先使用 toolUseContext.renderedSystemPrompt,没有才 fallback 重建。 |
| tools | 使用父 toolUseContext.options.tools,useExactTools: true。 |
| model | FORK_AGENT.model = "inherit"。 |
| thinking/non-interactive | 通过 exact tool/options 继承,避免 cache key 分叉。 |
| messages | forkContextMessages = toolUseContext.messages。 |
buildForkedMessages() 负责构造 cache-friendly 尾部:
parent history...
assistant: [text/thinking/tool_use A/tool_use B/...]
user:
tool_result for A = "Fork started — processing in background"
tool_result for B = "Fork started — processing in background"
directive = "<this fork's task>"
多个 fork child 的长前缀相同,只有最后 directive 不同。
限制:
| 限制 | 原因 |
|---|---|
需要 FORK_SUBAGENT feature。 |
功能门控。 |
| coordinator mode 禁用。 | coordinator 已有自己的编排模型。 |
| non-interactive session 禁用。 | fork subagent 偏交互式后台任务模型。 |
| fork child 禁止递归 fork。 | 防止无限 fork;通过 querySource 和 boilerplate tag 检测。 |
resume fork agent 不再传 forkContextMessages。 |
sidechain 已包含父上下文切片,重复传会造成重复 tool_use id。 |
runForkedAgent() 不是 AgentTool fork
src/utils/forkedAgent.ts 的 runForkedAgent() 是内部 cache-safe side query 工具,用于 session memory、prompt suggestion、summary 等。它复用父 system/user/system context、tools、messages,可选 skipTranscript,但默认不写 AgentTool metadata,也不是用户可继续对话的 AgentTool fork。
Agent 恢复
本地 agent 恢复入口是 resumeAgentBackground()。
流程:
flowchart TD
A[user continues agent] --> B[getAgentTranscript(agentId)]
B --> C[load sidechain JSONL + build chain]
C --> D[readAgentMetadata(agentId)]
D --> E[filter unresolved tool_use/thinking/blank assistant]
E --> F[reconstruct content replacement state]
F --> G{metadata.worktreePath exists?}
G -- yes --> H[runWithCwdOverride(worktreePath)]
G -- no --> I[parent cwd]
H --> J[register async LocalAgentTask]
I --> J
J --> K[continue query loop]
恢复时:
| 状态 | 来源 |
|---|---|
| agent transcript | agent-<agentId>.jsonl。 |
| agent type | agent-<agentId>.meta.json。 |
| fork/general agent 选择 | metadata agentType。 |
| worktree cwd | metadata worktreePath,目录不存在则回退父 cwd。 |
| content replacement | sidechain records + parent live state gap-fill。 |
| task UI | 重新注册 async task。 |
Remote Agent 恢复
remote CCR agent 不靠本地 sidechain 继续执行。
sequenceDiagram
participant Tool as AgentTool
participant R as RemoteAgentTask
participant Sidecar as remote-agents meta
participant CCR as CCR session
participant REPL as REPL resume
Tool->>CCR: teleportToRemote()
Tool->>R: registerRemoteAgentTask()
R->>Sidecar: write remote-agent-<taskId>.meta.json
REPL->>Sidecar: restoreRemoteAgentTasks()
REPL->>CCR: fetchSession(sessionId)
alt running
REPL->>R: rebuild RemoteAgentTaskState + polling
else 404/archive
REPL->>Sidecar: delete sidecar
end
差异:
| 本地 subagent | remote agent |
|---|---|
| 有完整 sidechain JSONL。 | 没有本地执行 transcript。 |
| resume 可继续 API 对话。 | resume 只恢复 polling。 |
状态来自 JSONL + .meta.json。 |
状态来自 CCR session + local sidecar。 |
| 完成后本地 sidechain 仍可审计。 | 完成/archived 后 sidecar 会删除。 |
常见误区
| 误区 | 正确理解 |
|---|---|
| JSONL 顺序就是会话顺序 | 恢复靠 leaf + parentUuid,不是简单顺序 replay。 |
| compact 删除了旧历史 | compact 追加 boundary;旧历史仍在 raw transcript。 |
| boundary 会发给模型 | boundary 是本地 system marker,API normalization 会过滤。 |
/branch 和 /fork 都是 fork |
/branch 是新主 session;/fork 是 fork subagent sidechain。 |
--fork-session 等于 /branch |
它不是复制文件命令,而是 resume 时保持 fresh session ownership。 |
| subagent 消息会进入主上下文 | 父会话只看到 Agent tool result/notification,完整内部消息在 sidechain。 |
| remote agent 有本地 sidechain | remote 只有 sidecar 身份,执行状态来自 CCR。 |
| context-collapse 已经真实压缩上下文 | 当前仓库中 context-collapse 核心实现是 stub。 |
源码入口索引
| 问题 | 从这里看 |
|---|---|
| Entry union 有哪些类型 | src/types/logs.ts 的 Entry。 |
| 主 transcript 路径 | src/utils/sessionStorage.ts 的 getTranscriptPath()。 |
| subagent transcript 路径 | getAgentTranscriptPath(agentId)。 |
| remote sidecar 路径 | getRemoteAgentsDir() / getRemoteAgentMetadataPath()。 |
| 主写入 | recordTranscript()。 |
| sidechain 写入 | recordSidechainTranscript()。 |
| write queue | Project.enqueueWrite() / drainWriteQueue() / flush()。 |
| lazy materialize | Project.materializeSessionFile()。 |
| tombstone 删除 | removeTranscriptMessage() / Project.removeMessageByUuid()。 |
| 读取 transcript | loadTranscriptFile()。 |
| 大文件读取 | readTranscriptForLoad() in sessionStoragePortable.ts。 |
| dead branch 裁剪 | walkChainBeforeParse()。 |
| parent 链重建 | buildConversationChain()。 |
| parallel tool_result 补回 | recoverOrphanedParallelToolResults()。 |
| preserved segment | applyPreservedSegmentRelinks()。 |
| snip removal | applySnipRemovals()。 |
| CLI resume 加载 | loadConversationForResume()。 |
| resume 状态切换 | processResumedConversation()。 |
| AppState 恢复 | restoreSessionStateFromLog()。 |
| 中断检测 | deserializeMessagesWithInterruptDetection()。 |
| active context | getMessagesAfterCompactBoundary()。 |
| query context pipeline | src/query.ts。 |
| compact boundary | createCompactBoundaryMessage()。 |
| auto compact | autoCompactIfNeeded() / shouldAutoCompact()。 |
| session memory compact | src/services/compact/sessionMemoryCompact.ts。 |
| reactive compact | src/services/compact/reactiveCompact.ts。 |
| post compact cleanup | runPostCompactCleanup()。 |
| context-collapse stub | src/services/contextCollapse/*。 |
/branch |
src/commands/branch/branch.ts。 |
/fork |
src/commands/fork/fork.tsx。 |
| AgentTool fork | AgentTool.tsx + forkSubagent.ts。 |
| 普通 subagent 运行 | runAgent.ts。 |
| agent resume | resumeAgent.ts。 |
| remote task restore | restoreRemoteAgentTasks()。 |