会话持久化与检查点:从零到一
这是一份快照
本文的数字、常量、行数取自 2026-08-31 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档写给谁
你知道「agent 就是个循环:问模型 → 调工具 → 把结果塞回去再问」,但没做过 「这个循环跑了 40 分钟,进程被 kill 了,怎么原地接着跑」这件事。
你想搞懂:为什么不能直接重跑一遍、状态到底有哪些、存成什么格式、 恢复的时候难在哪、
/undo那种「撤销 agent 改的代码」是怎么实现的, 以及面试问到「你怎么设计 agent 的状态持久化」时该怎么答。和两份原始研究文档的关系
文档 是什么 谁看 02-工程(agent)/.../01-Study-状态持久化与Checkpoint.md(327 行)概念研究:九轮追问,LangGraph / Temporal / Crab 三方案对比,全是论文与博客结论 已经懂的人 04-源码实战/.../01-Study-Source-State-Persistence-Checkpoint.md(412 行)源码研究:读 Claude Code 的 sessionStorage.ts(5105 行),提炼工程原则已经懂的人 本文 教学版:从「一行 append 写进文件」讲起,每个机制先讲「不做会怎样」再讲「怎么做」,最后才给「谁做得好」 完全没经验的人 那两份密度极高、术语不解释、直接摆结论。它们是资产,但第一次读会卡住。 本文补的是它们之间那一层:先把概念讲通,再把它们的结论放回该在的位置。
本文的事实来源(三类,严格区分)
- 🔬 源码实读:2026-08-31 实读 sid-code 的
packages/core/src/session/(4860 行, 16 个文件)与packages/core/src/checkpoint/(1611 行,3 个文件)。凡带文件:行号的都属这类。- 📊 本机实测:
~/.sid-code/sessions/下 51 个真实会话文件、2302 条记录,~/.sid-code/checkpoints/下 109 个索引。凡带具体数字的都属这类,附录 B 给复跑命令。- 📄 二手引用:LangGraph / Temporal / Crab / Claude Code 的行为,沿用上述两份研究文档的口径, 本文不重新验证,凡引用均标「研究口径」。Claude Code 那部分是别人读的源码, 不是我读的——引用时按二手对待。
一条关于数字的免责声明
文中所有具体数字(「51 个会话」「17 个有 session_end」「p50 = 76 KB」)都是 2026-08-31 在一台机器上的快照,不是恒定事实。引用它们是为了让你看见 「真实数据长什么样」,不要当结论沿用。附录 B 给了每个数字的复跑命令。
怎么读这份文档
按顺序读。 这是一条链,不是清单——第 5 章要用第 4 章的磁盘布局,第 10 章要用第 5、9 章的两层锚点。
| 章 | 讲什么 | 读完你能回答 |
|---|---|---|
| §0 | 名词地图 | 别人说 checkpoint / snapshot / rewind / sidechain 时你知道指什么 |
| §2 | 最小心智模型:一行 append | 能手搓一个 30 行的可恢复 agent |
| §3 | 两大流派:快照 vs 事件流 | 知道 LangGraph 和 Temporal 在赌什么 |
| §4 | ★ 一条会话在磁盘上长什么样 | 能看懂真实 JSONL 的每个字段 |
| §5 | ★ parentUuid 链表:为什么不用数组 | 本文最硬的机制之一 |
| §6 | 写入侧:缓冲、关键记录、延迟创建 | 知道「丢 100ms」这个 trade-off 怎么算 |
| §8 | 恢复到底恢复什么 | 说得出 10 种状态,而不只是「消息列表」 |
| §9 | ★ Checkpoint:文件层时间机器 | 讲清 full+diff 链和它的淘汰陷阱 |
| §10 | Rewind:把对话层和文件层锚在一起 | 讲清 Esc Esc 背后是什么 |
| §12 | 子代理与多进程并发 | 一个会话不止一条历史 |
| §13 | 十个真实陷阱 | 每个都是血案 |
| §15 | 动手:五阶段路线图 | 自己搭一遍 |
| 附录 | 术语表 / 可复跑命令 / 文档导航 | 查漏 |
如果只有 20 分钟:读 §1、§5、§7。这三章是这个领域的骨架,其余都是它们的展开。
如果只有 3 小时且明天面试:§1(开场白)→ §5(机制)→ §7(区分度)→ §9(Checkpoint)→ §14(题库)。
§0 名词地图:先把词认全
这一节是查询表,不用背。后面每章第一次用到某个词都会重新解释一遍, 这里放一份集中的,是为了你读那两份研究文档时能随时翻回来查。
按「一次会话从生到死」的顺序排列,不按字母序——因为这些词之间有位置关系, 按字母序排会把本来相邻的概念拆开。
0.1 被存的东西
| 词 | 中文 | 是什么 | 最容易搞错的地方 |
|---|---|---|---|
| Session | 会话 | 用户从启动 agent 到退出的一整段交互。有唯一 id | 不等于「一次对话」,一个 session 里可能有几十轮 |
| Turn | 轮次 | 一次「用户说话 → agent 回答完」的完整来回。中间可能调了 20 个工具 | 不等于一次 API 调用。一个 turn 里 agent 可能调模型 10 次 |
| Transcript | 对话记录 | 落盘的那份消息序列本身 | 它是存储形态,不是内存里的 messages 数组 |
| State | 状态 | agent 恢复时需要的全部东西 | 远不止消息列表,见 §8 的 10 项清单 |
| Side effect | 副作用 | agent 对外部世界做的、不可撤回的事:改了文件、发了请求、扣了款 | 这是整个领域的真正难点,见 §11 |
0.2 存储形态(这一组最容易混)
| 词 | 中文 | 是什么 | 判据 |
|---|---|---|---|
| Append-only | 只追加 | 只往文件末尾加,永不修改已写内容 | 崩溃时最多丢最后一次没写完的追加,已有数据不会坏 |
| JSONL | 每行一个 JSON | 一个文本文件,一行是一个完整 JSON 对象 | 和 JSON 数组的区别:JSONL 能增量追加,JSON 数组要重写整个文件(末尾有 ]) |
| Event sourcing | 事件溯源 | 存「发生了什么」的有序流水,不存「现在是什么」 | 恢复 = 从头重放这些事件 |
| Snapshot | 快照 | 某一时刻的全量状态拷贝 | 恢复 = 直接加载,不用重放 |
| Checkpoint | 检查点 | 一个「可以回到这里」的保存点 | ⚠️ 这个词有两个意思,见下方专门说明 |
| WAL | 预写日志 | 数据库的经典手法:先把「打算做什么」写进日志,再做 | 崩溃后重放日志重建状态。事件溯源本质上就是它 |
⚠️「Checkpoint」这个词在两个圈子里指的不是一回事,这是本领域第一号混淆源
语境 Checkpoint 指什么 例子 框架圈(LangGraph、Temporal) 对话/图状态的保存点。恢复它 = 回到某一步的对话状态 LangGraph 的 PostgresSaver存的是图节点状态Coding agent 圈(Claude Code、sid-code) 文件内容的保存点。恢复它 = 把代码文件回滚到改动前 sid-code 的 /undo用的就是这个面试时被问 "checkpoint 怎么设计",先问清是哪一层,或者主动分开答: 「对话层我用 X,文件层我用 Y」。这一句就能拉开差距,因为大部分人只知道一层。 sid-code 里这两层是两个独立模块(
session/store.ts管对话、checkpoint/manager.ts管文件), 再由第三个模块(session/rewind-manager.ts)把它们锚在一起 —— 见 §10。
0.3 恢复相关
| 词 | 中文 | 是什么 |
|---|---|---|
| Resume | 恢复 | 加载一个旧会话,接着往下跑(sid-code 的 -r / --resume) |
| Continue | 续接 | 恢复最近一个会话的快捷方式(sid-code 的 -c) |
| Fork | 分叉 | 从旧会话拷一份历史开成新会话,原会话不动 |
| Rewind | 回退 | 往回退若干轮,丢弃后面的(Claude Code 的 Esc Esc) |
| Replay | 重放 | 把事件流从头跑一遍以重建状态 |
| Time travel | 时间旅行 | 能跳到历史上任意一点(不只是最后一点) |
| Interruption detection | 中断检测 | 判断上次是正常退出还是被 kill 的 |
0.5 sid-code 特有的词(读源码时会遇到)
| 词 | 是什么 | 在哪 |
|---|---|---|
| Sidechain | 子代理的独立对话记录,单独一个文件 | session/sidechain.ts |
| Compact boundary | 上下文压缩发生的位置标记 | session/store.ts,⚠️ sid-code 刻意不用它截断恢复,见 §7.4 |
| File intent | 「我正在改这个文件」的声明,用于多进程冲突检测。不是锁 | session/file-intent.ts |
| Rewind point | 回退点:同时记住「对话到第几条」和「文件快照 id」 | session/rewind-manager.ts |
| Reanchor | 重锚定:删掉 diff 链的基点前,先把下一个 diff 重建成完整内容 | checkpoint/manager.ts |
| Stock / flow | 末次快照值 / 累计值。两者混用会算出错数 | session/state.ts |
0.6 这些词之间的关系图
先记住这张图,后面每一章都在填它的某一格:
一个 SESSION(会话,有唯一 id)
│
┌────────────────────────┼────────────────────────┐
│ │ │
对话层持久化 文件层持久化 进程层协调
(存"说了什么") (存"改了什么") (存"谁在跑")
│ │ │
sessions/*.jsonl checkpoints/<id>/ active-sessions/*.json
append-only 事件流 index.json 快照链 PID 注册 + 探活
│ │ │
parentUuid 链表 full + diff 链 file-intents/*.json
(§5) (§9) (§12)
│ │
└────────┬───────────────┘
│
RewindPoint(§10)
同时记住"对话第几条"+"文件哪个快照"
│
┌────────┴────────┐
│ │
/rewind Esc Esc
(只回对话) (对话+代码都回)三层各自独立存储,靠 session id 关联。 这个分层不是随便切的: 对话层是追加语义(历史不可改),文件层是覆盖语义(文件只有最新一版是真的), 进程层是易失语义(进程死了注册就该失效)。三种语义不同,硬塞进一个文件会互相打架 —— 见 §12.3。
§1 为什么不能「崩了重跑」
1.1 先看一个具体场景
你让 agent 做一件真实的事:
用户:把 auth 模块的回调改成 async,跑测试,有问题就修,最后提个 PRagent 开始干活。40 分钟后它已经:
- 读了 23 个文件(花了 18 万 token)
- 改了 6 个文件(磁盘上已经改了)
- 跑了 4 次测试,修了 2 个失败
- 建了一个分支,提了 3 个 commit
- 正在写 PR 描述
这时候你的终端崩了。或者笔记本盖上休眠了。或者 OOM 被系统 kill 了。
现在问题来了:重跑一遍会发生什么?
1.2 三个不可接受的代价
代价一:钱(最容易理解,但其实最不严重)
18 万 token 白烧。按输入 $3/M、输出 $15/M 的量级,一次几美元——单次不痛, 但如果你团队 50 个人每天都撞几次,一个月就是实打实的开销。
更隐蔽的是成本不是线性的:agent 会话里第 N 轮的输入约等于 N 倍第 1 轮 (每轮都要把之前的历史全带上)。所以重跑一个 30 轮的任务, 不是「再花一遍同样的钱」,而是要把那条越来越贵的曲线重新爬一遍。
📄 研究口径:「turns per task 是成本最大杠杆,2× 轮数 ≈ 3–4× 成本」。
代价二:副作用不可逆(这条才是真问题)
agent 不是纯函数。 上面那 5 步里,第 2、5 步已经在真实世界留下了痕迹:
- 6 个文件已经改了。重跑时 agent 会看到已经改过的代码, 而它以为自己在看原始代码 —— 于是它可能把 async 再改一遍, 或者看到「已经是 async 了」而困惑。
- 3 个 commit 已经提了。重跑可能提出重复 commit。
- 如果它已经调过一个「创建工单」「发通知」「扣款」的接口,那件事已经发生了。
这不是「浪费」的问题,是「出错」的问题。省钱可以谈,出错不能谈。
代价三:LLM 不确定,重跑会走上另一条路
这条最反直觉,也最能在面试里体现深度。
同样的输入,LLM 不保证同样的输出。 所以「重跑」根本不是「重放」—— 它是另一次独立的探索。
📄 研究口径给了一个特别好记的例子(Temporal 官方博客):一个订假期的 agent, 第一次决定去 Whistler、机票已经买了;崩溃后重跑,这次它决定去日本。 结果:日本的行程订上了,Whistler 那张票没人退。
没有持久化 ≠ 「白跑一趟」,而是 = 系统进入了一个谁都没设计过的状态。
把这三条并起来看,会得到一个重要的认知:
持久化的目标不是「保存进度」,是「保证副作用恰好发生一次」(exactly-once)。
这句话是本文的中轴。记住它 —— §11 会展开,§14 的开场白就是它。
1.3 那「存个 JSON 不就行了」?
这是最常见的简化思维,也是面试官最喜欢追的一刀。它不完全错,但漏了三件事。
漏了第一件:状态是复合的,不是一个整数。
「保存进度」听起来像存个 currentStep: 7。但 agent 需要恢复的是:
对话历史 + 每条消息的 token 用量 + 当前用的哪个模型 + 工具调用到哪一步
+ 改过哪些文件(以及改之前长什么样)+ Todo 列表 + 权限模式
+ 工作目录 + git 分支 + 子代理的独立历史 + 上下文压缩状态 + ...§8 会给完整清单(10 项)。说不出这份清单,就答不好这道题—— 因为「持久化难在哪」的答案很大一部分就是「状态比你想的多得多」。
漏了第二件:恢复时你怎么知道哪些副作用已经做了?
存状态是写的问题,很简单。难的是读:恢复以后, agent 怎么知道「那个工单我已经建过了」?如果没有机制回答这个问题, 恢复就等于重复执行。这就是幂等键存在的理由(§11.4)。
漏了第三件:崩溃可能发生在写的过程中。
你 JSON.stringify 一个 5MB 的状态对象往文件里写,写到 3MB 的时候进程被 kill 了。 现在磁盘上是一个语法都不合法的半个 JSON —— 不但这次的状态没了, 上一次的好状态也被你覆盖没了。
这一条直接推出了整个领域的默认答案:
append-only。 只追加、不覆盖。崩在中间,最多是最后一行不完整, 前面每一行都还是好的。恢复时跳过坏行即可。
1.4 一个反直觉的事实:文件系统帮你扛掉了一半
初学者容易以为「崩溃了什么都没了」。其实不是 —— 磁盘是持久的。
agent 改的那 6 个文件,崩溃后仍然在磁盘上,改动还在。git commit 也还在。 你不需要恢复它们。
那需要恢复什么?「agent 知道自己改过这些文件」这件事—— 也就是元数据,而不是内容本身。
这个区分很重要,它把问题规模砍掉一大块:
| 状态 | 崩溃后还在吗 | 需要持久化吗 |
|---|---|---|
| 改过的文件内容 | ✅ 在磁盘上 | ❌ 不用(但改之前的内容要存,否则没法回滚 → §9) |
| git 分支与 commit | ✅ 在 .git 里 | ❌ 不用 |
| 内存里的对话历史 | ❌ 全丢 | ✅ 必须 |
| 「我改过哪些文件」的记录 | ❌ 全丢 | ✅ 必须 |
| 起的子进程 | ❌ 全死 | ⚠️ 看场景(sid-code 不恢复) |
| 环境变量 / 当前目录 | ❌ 全丢 | ✅ 要(cwd 变了 agent 会找不到文件) |
🔬 这正是 sid-code 的取舍:它不试图恢复操作系统级状态(进程、内存映射、打开的 fd)。 只恢复「对话 + 我改过什么的元数据 + 少量环境上下文」。
📄 而学术前沿(Crab,arXiv 2604.28138)走的是另一条路:连容器里的进程和文件系统一起做 checkpoint/restore。研究口径给的数据是:只恢复对话历史(chat-only)在 Terminal-Bench 上成功率只有 8–13%。
⚠️ 但别把这个数字用错地方——它说的是「在一个 agent 会跑长命令、起服务的 sandbox 场景里,只存对话不够」。sid-code 这类跑在你自己机器上的 coding agent, 文件系统本来就没被销毁(就在你硬盘上),所以「chat + 文件元数据」够用。 同一个数字,换个场景就不成立了 —— 面试引用它时必须带上场景限定, 否则会被反问「那 Claude Code 怎么活着的」。
1.6 本章自检
能回答这四个问题再往下:
- 为什么「重跑一遍」不等于「重放一遍」?这个区别是由什么性质造成的?
- 崩溃后,agent 改过的文件内容还在磁盘上。那为什么还需要存「改动前的内容」?
- 为什么 append-only 能防住「写一半崩了」,而
JSON.stringify整体覆盖不能? - 「chat-only 恢复成功率只有 8–13%」这个数字,在什么场景下成立、什么场景下不适用?
§2 最小心智模型:从一行 append 开始
这一章的目标:让你能在 30 行代码里做出一个可恢复的 agent。 后面十几章讲的所有复杂度,都是从这 30 行长出来的——先把这个内核刻进脑子, 后面每加一层你就知道它在解决哪个具体问题。
2.1 不做持久化的 agent 长什么样
先看最朴素的 agent 循环(伪代码,任何语言都一样):
const messages = []; // ← 全部状态就在这个数组里
while (true) {
const userInput = await readUserInput();
messages.push({ role: "user", content: userInput });
while (true) { // 内层:agent 自己和工具来回
const reply = await callLLM(messages);
messages.push({ role: "assistant", content: reply.content });
if (!reply.toolCalls) break; // 模型不再要工具 → 本轮结束
const results = await runTools(reply.toolCalls);
messages.push({ role: "user", content: results }); // 工具结果塞回去
}
}盯住第一行。 整个 agent 的全部状态就是那个 messages 数组,它只存在于内存里。 进程一死,messages 蒸发。这就是 §1 说的全部问题的物理来源。
⚠️ 顺带记住一个反直觉的细节,它后面会反复出现:工具结果是以
user角色塞回去的。 不是什么第三种角色。大多数模型 API 只认user和assistant两种角色 (🔬 sid-code 的类型定义就是export type Role = "user" | "assistant",packages/core/src/llm/types.ts:9),工具结果是包在 user 消息的 content 块里的。 这个细节在 §4.5 会引出一个真实的死代码。
2.2 加上持久化:其实只多了三行
const LOG = "session.jsonl";
// ① 写:每次 push 都同时 append 一行
function record(msg) {
messages.push(msg);
fs.appendFileSync(LOG, JSON.stringify(msg) + "\n"); // ← 全部的"持久化"
}
// ② 读:启动时把文件重放回内存
function restore() {
if (!fs.existsSync(LOG)) return [];
return fs.readFileSync(LOG, "utf-8")
.split("\n")
.filter(Boolean)
.flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });
// ↑ ③ 坏行跳过,别让一行毁掉整个恢复
}
const messages = restore(); // 启动即恢复就这样。 这已经是一个能从崩溃中恢复的 agent 了。三个关键点:
| 点 | 代码 | 为什么必须这样 |
|---|---|---|
| 只追加 | appendFileSync | 崩在半行,前面所有行仍完好。若用 writeFileSync 整体覆盖,崩在中间会同时毁掉新旧两份 |
| 一行一个 JSON | + "\n" | 能增量追加。JSON 数组做不到——末尾有个 ],每次都得重写整个文件 |
| 坏行跳过 | try/catch 里 return [] | 最后一行一定可能是半截的。不跳过 = 崩溃后永远读不出来 |
第三点值得多说一句,因为它是初学者最容易漏的:append-only 的崩溃安全性, 一半来自写法(只追加),另一半来自读法(容忍坏行)。只做前一半, 你的文件确实没坏,但你的解析器会在最后一行抛异常,然后什么都恢复不出来 —— 数据完好但功能失效,比数据坏了更让人困惑。
2.3 为什么是「事件流」而不是「存最终状态」
有人会说:干嘛存每一条?直接把整个 messages 数组覆盖写进一个 state.json 不更简单?
对比一下:
| 存事件流(append 每条) | 存最终状态(覆盖写数组) | |
|---|---|---|
| 写入成本 | 每条几百字节 | 每次都要重写全部历史,第 50 轮时每次写几 MB |
| 崩在写入中 | 最多丢最后一条 | 新旧俱毁 |
| 想看「第 10 轮时是什么样」 | ✅ 读前 N 条即可 | ❌ 只有最新的,历史没了 |
| 想分叉出一个新会话 | ✅ 拷前 N 条 | ⚠️ 只能从最新点分 |
| 读取成本 | 要重放 | ✅ 直接加载 |
| 文件大小 | 只增不减 | 恒等于当前状态大小 |
事件流赢在写入侧和崩溃安全,输在读取侧。 而 agent 的读写比例极不对称: 一个会话里写几百次,读一次(启动时恢复)。所以这个 trade-off 是明显划算的。
这就是 §7 那句核心原则的来源:
写入时简单,读取时智能。 写路径就是一次 append,几乎不可能出 bug; 所有复杂度推到读路径 —— 而读路径的 bug 不会损坏数据,最多是这次没恢复好, 修了代码重试一次就行。
这个不对称性是刻意设计出来的:把 bug 赶到不会造成永久损失的那一侧。
2.4 这个 30 行版本缺什么
它能跑,但离生产还差很远。下面这张表是本文剩下部分的路线图—— 每一行缺陷都对应后面一章:
| 缺什么 | 会出什么事 | 哪章解决 |
|---|---|---|
| 每条都同步写盘 | 一轮几十条 syscall,卡 UI | §6.1 缓冲 |
| 只存了消息 | 恢复后不知道用的哪个模型、改过哪些文件、Todo 是什么 | §8 十项状态 |
| 没有 id 与顺序保证 | 两个进程同时写同一个文件 → 历史交叉错乱 | §5 parentUuid 链 |
| 不知道上次是正常退出还是被 kill | 恢复后不知道该不该「接着干」 | §11 中断检测 |
| 文件无限增长 | 长会话读取时内存尖峰 | §6.4 + §7.5 |
| 没有文件层回滚 | agent 把代码改坏了,没法 undo | §9 Checkpoint |
| 半截的工具调用会喂给 API | API 直接报 400 | §7.2 脏数据清洗 |
| 子代理的历史混在主会话里 | 恢复出一份语义错乱的历史 | §12 sidechain |
读完全文再回来看这张表,你会发现所有机制都是为了填这八个坑,没有一个是为了优雅。
2.5 本章自检
- 为什么 JSONL 能增量追加,而「一个 JSON 数组」不能?(提示:想想文件末尾那个字符)
- 「只追加」保证了写不坏,为什么还必须在读的时候容忍坏行?只做一半会怎样?
- agent 的读写比例是什么样的?这个比例如何决定了「事件流 vs 存最终状态」的选择?
§3 两大流派:快照派 vs 事件流派
上一章你已经手搓了一个事件流版本。但业界主流框架有两种不同的做法, 面试极高频(因为它可以问「你为什么选 A 不选 B」)。这一章把它们讲清楚。
📄 本章全部是研究口径(引自那两份原始文档),我没有跑过 LangGraph 或 Temporal。 我能验证的只有 sid-code 那部分(🔬)。这个区分对你也有用: 面试时把「我读过的」和「我做过的」分开说,比全都说成做过更可信—— 面试官一追细节就知道了。
3.1 流派一:快照(LangGraph 模式)
心智模型:把 agent 建成一张图(node = 一个步骤)。每次从一个 node 走到下一个 node 时, 把当前全量状态拍一张照存到数据库。
# 研究口径示例
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver(conn)
app = workflow.compile(checkpointer=checkpointer)
# thread_id 隔离不同会话
config = {"configurable": {"thread_id": f"{user_id}_{session_id}"}}
result = app.invoke({"messages": [...]}, config)
# 恢复:加载最近的快照,接着跑
state = app.get_state(config)恢复方式:加载最近那张照片,继续往下走。不需要重放。
它的好处:
- 恢复快(一次读取,无需重放)
- 天然支持 time travel(每张照片都是一个可回到的点)
- 开发者几乎无感(框架在每次
invoke时自动读写)
它的代价:
- 快照体积随状态增长。第 50 轮的状态里包含前 49 轮全部历史 → 每张照片都很大,而且越来越大
- 恢复粒度是 node 级。一个 node 内部执行到一半崩了,只能整个 node 重来
- 幂等要自己保证。框架帮你存状态,不帮你记「这个副作用做过没」
三个真实陷阱(研究口径,都很值得记,因为它们的形态是「不报错但结果错」):
| 陷阱 | 后果 | 为什么隐蔽 |
|---|---|---|
thread_id 写成固定值 | 所有用户共享同一份对话历史,A 的问题 B 看到答案 | 单人测试时完全正常 |
生产环境用了 MemorySaver | 进程一重启,所有会话历史消失 | 名字里有 "Saver",看起来在存东西 |
在异步框架里调同步 invoke() | 阻塞事件循环,所有其他请求一起卡住 | 低并发时看不出来 |
第二条尤其经典 —— 研究口径把它叫「凌晨三点事故」:白天一切正常, 凌晨进程例行重启,第二天所有人的历史都没了。默认值是内存版,这是个刻意的开发便利, 但生产照抄就出事。
3.2 流派二:事件流 + 确定性重放(Temporal 模式)
心智模型:把代码分成两半——
- Workflow:决策逻辑,必须确定性(不许用
random()、now()、不许直接 I/O) - Activity:所有副作用(调 API、写库、调 LLM),可以不确定
框架把每个 Activity 的返回值记进一个 append-only 的 Event History。
# 研究口径示例
@workflow.defn
class AgentWorkflow:
@workflow.run
async def run(self, task: dict) -> dict:
# 所有副作用都必须包成 Activity
search_result = await workflow.execute_activity(
search_topic, args=[task["query"]],
start_to_close_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(maximum_attempts=3),
)
llm_result = await workflow.execute_activity( # ← LLM 调用也是 Activity
call_llm, args=[search_result],
start_to_close_timeout=timedelta(minutes=5),
)
return llm_result恢复方式——这里是全章最关键的机制,值得慢慢看:
崩溃后重启
→ 从头重新执行 Workflow 代码
→ 执行到第一个 Activity 调用
→ 查 Event History:这个 Activity 有记录吗?
→ 有 → 直接返回记录里的值,【不真的执行】
→ 没有 → 真正执行它,并把返回值追加进 Event History于是:代码从头跑了一遍,但副作用没有重复发生。
这就是「Durable Execution」(持久化执行)这个词的含义 —— 不是一个持久化框架,是一种执行模型:让你的函数的生命周期长于进程的生命周期。
为什么 Workflow 必须确定性? 因为重放依赖「同样的代码走同样的路」。 如果 Workflow 里有 if random() > 0.5,重放时可能走进另一个分支, 去查一个 Event History 里根本不存在的 Activity 记录 —— 整个重放机制的地基就塌了。 所以框架强制把不确定的东西赶进 Activity。
而 LLM 调用天然是不确定的,所以它必须是 Activity —— 它的输出被记进 History, 重放时直接复用「当初那个决策」。这精确地解决了 §1.2 的 Whistler 问题: 重放时不会重新问模型去哪,而是复用「当初决定去 Whistler」这个已记录的事实。
它的代价:
- 确定性约束是真的约束,写代码要改习惯(不能随手
datetime.now()) - 要运维一个 Temporal 集群
- Event History 会膨胀,长任务需要
Continue-As-New(带着状态重开一局,把 History 截断)
3.3 底层:这两派其实是数据库两个老概念的翻版
这是本章最能体现深度的一段,面试时抛出来很有效:
| 数据库领域 | Agent 领域 | 共同的本质 |
|---|---|---|
| 全量备份 | LangGraph 快照 | 存「现在是什么」。恢复快,但体积大、粒度粗 |
| WAL(预写日志)+ replay | Temporal Event History | 存「发生了什么」。恢复要重放,但增量小、粒度细 |
WAL 的思想是:先记录意图,再执行操作;崩溃后重放日志重建状态。 Temporal 把这个手法从存储层搬到了业务逻辑层——你的业务代码成了「被重放的对象」。
一句能体现理解深度的表述:
「Temporal 的 Durable Execution 本质上是把数据库的 WAL 思想上提到了业务逻辑层: Workflow 是确定性状态机,Activity 是有副作用的命令,Event History 是 WAL, 崩溃恢复就是 WAL replay。 它之所以强大,是因为它把持久化从 『开发者要显式操心的事』变成了『运行时的隐式保证』。」
3.4 流派三:sid-code / Claude Code 走的第三条路
上面两个都是框架。但一个终端里跑的 coding agent,需求形态完全不同:
| 约束 | 框架的假设 | 本地 CLI 的现实 |
|---|---|---|
| 部署 | 有服务端、有数据库 | 用户机器上一个二进制,不能要求装 Postgres |
| 并发 | 多用户 | 通常一个人,但可能开几个终端窗口(§12.3) |
| 依赖 | 装什么都行 | 多一个 native 依赖就多一堆平台编译问题 |
| 可调试性 | 看数据库 | 用户得能 cat 一下看发生了什么 |
| 结构 | 图 / 工作流 | 就是一条对话流,没有「节点」概念 |
所以 sid-code 和 Claude Code 都选了同一条路:手搓 append-only JSONL 事件流, 不用任何框架。
🔬 sid-code 的选择(packages/core/src/session/store.ts 文件头注释就写明了):
~/.sid-code/sessions/<项目名>/<会话id>.jsonl一行一条记录,追加写。零依赖、人类可读、崩溃安全。
它比 Temporal 少了什么? 少了「Activity 返回值缓存」—— 也就是没有自动的副作用幂等。sid-code 恢复后不会自动跳过「已经调过的工具」; 它靠的是另一套东西(文件层快照 + 让模型自己看历史判断),见 §11。
这是个真实的取舍,不是遗漏。 面试时这么说:
「框架级方案(Temporal)给你 exactly-once 的自动保证,代价是确定性约束和运维集群。 本地 coding agent 用不上、也扛不起这个代价——它没有服务端,且它的副作用主要是 文件修改,而文件修改本身是幂等的(把内容写成 X,写两次结果一样)。 所以它把力气花在了别的地方:文件改动前快照(能回滚)+ 完整对话流(让模型自己看到 『我已经改过这个文件了』)。」
3.5 三派对照总表
| 维度 | LangGraph 快照 | Temporal 事件流 | sid-code / CC 手搓 JSONL |
|---|---|---|---|
| 存什么 | 图状态全量快照 | Activity 返回值日志 | 消息事件流 |
| 恢复方式 | 加载最近快照 | 重放代码 + 复用记录 | 重放消息链 |
| 存储后端 | Postgres / Redis / SQLite | Temporal 集群 | 一个文本文件 |
| 副作用幂等 | ⚠️ 自己保证 | ✅ 框架保证 | ⚠️ 自己保证(靠文件快照 + 模型自看) |
| 恢复粒度 | node 级 | step 级(最细) | 消息级 |
| 确定性约束 | 无 | ✅ 有,且是硬约束 | 无 |
| Time travel | ✅ 原生 | ✅ 原生 | ⚠️ 能做(沿链走),但产品没暴露 |
| 依赖 | 数据库 | 集群 | 零 |
| 开发者心智负担 | 低 | 中 | 中(全都自己写) |
| 适合 | LLM 工作流、快速原型 | 长任务、企业级、有副作用 | 本地 CLI、coding agent |
3.6 一个容易被问到的追问:「那 sid-code 为什么不用 SQLite?」
SQLite 也是零服务端的,为什么还要手搓 JSONL?三个理由:
- native 依赖。SQLite 要编译原生模块,对「一个跨 4 平台的单文件二进制」是实打实的负担。
- append 天然崩溃安全。SQLite 也安全(有自己的 WAL),但你得正确用事务; 而
appendFileSync一行 JSON,不会用错。 - 人类可读。用户和开发者能直接
cat/jq看会话文件。 排查问题时这个价值极高——本文附录 B 那些命令全都建立在这一点上。
什么条件下这个选择会反转(面试加分点,因为它显示你知道边界):
| 条件 | 该换成什么 | 为什么 |
|---|---|---|
| 需要按字段查询(「找出所有用了 X 工具的会话」) | SQLite / Postgres | JSONL 只能全扫 |
| 真正的多进程并发写同一份历史 | 数据库 | 文件追加没有跨进程事务 |
| 会话大到几百 MB | 分片 / 数据库 | 见 §13.5 的内存尖峰 |
| 要跨机器共享会话 | 服务端存储 | 本地文件没法共享 |
3.7 本章自检
- Temporal 为什么强制 Workflow 代码确定性?如果 Workflow 里有
random()会怎样? - LangGraph 的
MemorySaver为什么是个陷阱?它的失败形态有什么特点让它难被发现? - 快照派和事件流派分别对应数据库里的哪两个老概念?
- sid-code 不用框架,因此少掉了 Temporal 的哪个保证?它拿什么替代?
- 什么条件下「JSONL 手搓」这个选择会反转成「该用数据库」?
§4 ★ 一条会话在磁盘上到底长什么样
前三章都在讲概念。这一章打开真实文件,逐字段看。 读完你应该能拿到任何一份 sid-code 会话文件,说清每一行是什么。
🔬📊 本章所有内容来自 2026-08-31 实读源码 + 实测本机 51 个会话文件、2302 条记录。
4.1 目录布局:三层存储,三个目录
~/.sid-code/
├── sessions/ ← ① 对话层(append-only 事件流)
│ ├── Users-me-Code-person-sid-code/ ← 按项目分目录
│ │ ├── 20260821-104135-bd669847.jsonl ← 一个会话一个文件
│ │ ├── 20260827-191829-82f53b34.jsonl
│ │ └── summaries/ ← 会话摘要(单独 JSON)
│ ├── private-tmp-lspdemo/
│ └── _legacy/ ← 极旧会话(无 cwd 信息)的兜底目录
│
├── checkpoints/ ← ② 文件层(快照 + diff 链)
│ └── 20260805-215007-d5099a2f/ ← 按会话 id 分目录
│ └── index.json ← 所有快照内联在这一个文件里
│
├── active-sessions/ ← ③ 进程层(谁在跑,PID 注册)
│ └── <sessionId>.json
│
├── file-intents/ ← ③ 进程层(谁在改哪个文件)
│ └── <sessionId>.json
│
└── history.jsonl ← 全局输入历史(跨会话、跨项目)📊 本机实测规模:
| 目录 | 数量 | 磁盘占用 |
|---|---|---|
sessions/ | 51 个 .jsonl,分布在 34 个项目目录 | 7.7 MB |
checkpoints/ | 109 个 index.json | 13 MB |
注意这个反直觉的比例:文件快照(13 MB)比对话历史(7.7 MB)更占空间。 直觉上「对话那么多字,肯定对话大」,但实际上一份源文件的完整内容动辄几十 KB, 而一条消息通常只有几百字节。这个比例决定了 §9 为什么必须做 diff 和淘汰。
4.2 为什么会话要按项目分目录(一个真实的 bug 修复)
🔬 store.ts:246-258 的注释写明了理由,这段设计值得学:
布局:~/.sid-code/sessions/<projectKey>/<sessionId>.jsonl
projectKey = sanitizeProjectKey(resolveProjectRoot(cwd)) ← git 顶层目录优先
读写职责分工:
- 写入 → 落到「当前项目」目录
- loadLatest()(-c)→ 只扫「当前项目」目录 ← 关键
- load(id) / --resume <id> → 跨所有项目子目录解析为什么 -c 只扫当前项目? 因为 -c 的语义是「继续我刚才干的事」。 如果它扫全局,你在 A 项目敲 -c,可能恢复出你在 B 项目的会话 —— 然后 agent 带着 B 项目的上下文在 A 项目里干活。
🔬 源码注释的原话:「
loadLatest()(-c)只扫「当前项目」目录 → 跨项目串会话在存储层不可能」。这是个漂亮的设计手法,值得单独记住:把「不该发生的事」变成物理上不可能, 而不是加个 if 判断去挡。 加 if 判断的话,将来有人重构时把 if 删了就复发了; 而「文件根本不在那个目录里」是没法被重构掉的。
而 --resume <id> 反过来跨项目扫 —— 因为用户手动指定 id 时,意图是明确的, 不该因为他换了目录就找不到。
两种入口,两种扫描范围,对应两种不同的用户意图。 这个区分不是随手写的。
4.3 一行 JSONL 长什么样:session_start
📊 真实数据(本机会话文件第一行,未删改):
{
"type": "session_start",
"version": "3.0",
"schemaCompat": "claude-code-like/v3",
"sessionId": "20260827-191829-82f53b34",
"model": "glm-5.2",
"provider": "openai",
"cwd": "~/Downloads/lovable-project-20260827185801",
"timestamp": "2026-08-27T11:18:30.363Z",
"uuid": "2e993e4a-f5da-431e-95c2-2bc78241a200",
"parentUuid": null
}逐字段看,每个都有存在理由:
| 字段 | 作用 | 不存会怎样 |
|---|---|---|
type | 判别这行是什么记录 | 解析时无法分派 |
version | 格式版本号(当前 3.0) | 格式演进后无法判断该按哪套规则解析 → §13.7 |
schemaCompat | 声明「CC 风格但非逐字节兼容」 | 外部工具要靠猜字段名 |
sessionId | 会话唯一 id(时间戳 + 随机后缀) | 无法关联其他两层存储 |
model / provider | 恢复时用哪个模型继续 | 恢复后可能换了个模型,行为突变 |
cwd | 启动时的工作目录 | 恢复后 agent 找不到文件(相对路径全错) |
timestamp | 时间 | 无法排序/清理 |
uuid / parentUuid | 链字段,见 §5 | 无法抗并发交叉写入 |
version 这个字段值得单独说。 🔬 源码里有三个版本:
1.0 = 旧版全量 JSON(一整个文件一个 JSON 对象)
2.0 = JSONL 事件溯源(无链)
3.0 = JSONL + uuid/parentUuid 链 ← 当前存版本号的价值在于:你将来一定会改格式。 不存的话,遇到一个旧文件你只能靠「猜有没有某个字段」来判断格式, 猜错就是静默解析错误。🔬 sid-code 的做法是: session_start 没有 version 字段 → 隐含判定为 2.0(LEGACY_JSONL_VERSION)。 这叫用「字段缺失」本身作为版本信号,是处理「早期版本没预留版本号」的标准手法。
4.4 assistant_message:模型回复 + 内嵌用量
📊 真实数据(content 正文太长,此处删掉只看元数据):
{
"type": "assistant_message",
"message": { "role": "assistant", "content": [ ... ] },
"timestamp": "2026-08-21T02:42:37.019Z",
"usage": {
"inputTokens": 89764,
"outputTokens": 527,
"cacheReadInputTokens": 30464
},
"model": "glm-5.3",
"stopReason": "tool_use",
"uuid": "e5cb22e1-d762-498a-8430-64ddce7da35d",
"parentUuid": "d747ac90-9e70-4332-a504-f15a1c0be814"
}关键点:usage 是内嵌在每条 assistant 消息上的,记的是这一次 API 调用的用量, 不是累计值。
为什么要按条存而不只存一个总数? 因为你会想问这些问题:
- 「这个会话哪一轮最贵?」→ 需要逐条
- 「缓存是从第几轮开始命中的?」→ 需要逐条(上面这条
cacheReadInputTokens: 30464就是命中了) - 「换模型之后成本变了吗?」→ 需要逐条 + 每条的
model
只存总数的话,这些问题事后永远无法回答 —— 数据已经聚合掉了, 聚合是不可逆的。这是可观测性的通用原则:能按条存就别提前聚合。
stopReason: "tool_use" 也很有信息量:它说明这条回复的结尾是「我要调工具」, 而不是「我说完了」(end_turn)。§11 的中断检测就要用这类信号。
4.5 user_message:一个反直觉的事实
📊 先看实测数据 —— 全部 51 个会话、977 条 user_message 里, 第一个 content 块的类型分布:
| 首块类型 | 条数 | 占比 |
|---|---|---|
tool_result | 881 | 92% |
text | 76 | 8% |
92% 的「用户消息」其实不是用户说的话,是工具执行结果。
这回到了 §2.1 那个细节:模型 API 只认两种角色,工具结果必须包在 user 消息里回传。 所以在 transcript 里,user_message 这个名字名不副实 —— 它真正的语义是「非模型侧发出的消息」。
这个事实有两个实际后果,都值得记:
- 数「用户说了几句话」不能数
user_message条数,会虚高 12 倍。 得先看 content 首块是不是text。(📊 实测:977 条里只有 76 条是真人说的) - 恢复时最后一条是
user_message不代表「用户说了话等回复」 —— 更可能是「工具跑完了,正等模型继续」。这两种状态的恢复动作完全不同,见 §11.2。
4.6 一个真实的死代码:tool_result 记录类型
🔬 源码 store.ts:534-538 有这么一段分派:
const type =
message.role === "user"
? "user_message"
: message.role === "assistant"
? "assistant_message"
: "tool_result"; // ← 这个分支而 🔬 llm/types.ts:9 的定义是:
export type Role = "user" | "assistant";只有两个取值。 所以第三个分支 "tool_result" 在类型层面就不可达。
📊 实测印证:2302 条真实记录里,记录级 type 的分布是
session_start: 51 user_message: 977 assistant_message: 955
metadata: 302 session_end: 17 tool_result: 0 ← 一条都没有
context_compact: 0tool_result 作为记录类型,产生过 0 条。
这不是 bug(工具结果确实被存下来了,就在那 881 条 user_message 里), 而是一段无害但会误导人的死分支:任何人读这段代码, 都会以为 JSONL 里存在 type: "tool_result" 的行,然后写一个永远匹配不到的解析器。
这类东西在面试里是个好素材,因为它体现的是一个通用判据:
「代码里有」不等于「运行时会发生」。 要确认一个分支活着, 不能只看它写在那儿,得去数据里数它产生过几条。 同类的还有
context_compact(0 条 —— 见下一节)。§13.1 会把这条展开成一个完整的排查方法。
4.7 context_compact:0 条,但原因和上面完全不同
📊 实测同样是 0 条。但它不是死代码,成因完全不同 —— 这个区分很重要。
🔬 查调用链:
context/manager.ts:505 → 上层 App 转调
app.ts:2854 → this.sessionStore?.appendCompact(summary, removedCount)接线是通的。 🔬 app.ts:2850 的注释还写明了它的来历:
「此前
SessionStore.appendCompact定义了却从不被调用(死代码),JSONL 里从无压缩记录。 观察者转调appendCompact,使压缩状态可观测」
也就是说:它曾经是死代码,已经被修好了。 现在 0 条的原因是 这 51 个会话里没有一次触发过上下文压缩(会话都不够长,p50 只有 76 KB)。
同一个现象(0 条),两种完全不同的结论:
tool_result | context_compact | |
|---|---|---|
| 落盘条数 | 0 | 0 |
| 有调用点吗 | ❌ 类型上不可达 | ✅ 接线通畅 |
| 结论 | 死分支,该删或该改注释 | 功能正常,只是没被触发 |
| 该做什么 | 清理 | 什么都不用做(想验证就跑个长会话) |
这是本文最重要的方法论之一,面试时抛出来含金量很高:
「看到一个指标是 0,先分清是「没接通」还是「没发生」。 这两者的修法完全相反:前者要修代码,后者要构造场景去验证。 判据是回到调用链:有生产调用点 → 是「没发生」;没有 → 是「没接通」。 只看落盘数据,这两种情况长得一模一样。」
4.8 metadata:所有「不是消息」的状态都走这里
📊 实测 302 条 metadata,按 key 分布:
| key | 条数 | 存什么 |
|---|---|---|
file_changes | 122 | 改过哪些文件 + 最后用的什么工具 |
usage_stats | 53 | 整会话的 token / 成本聚合快照 |
todo_state | 53 | Todo 列表 |
side_call_stats | 50 | 影子调用(标题生成、摘要等)的成本 |
agent_setting | 22 | 用的哪个 agent 定义 |
goal_state | 1 | 目标状态 |
trace_session_id | 1 | 关联到 trajectory 目录(见 §12.4) |
📊 真实的 file_changes:
{
"key": "file_changes",
"value": {
"files": ["/Users/.../components/DecayModal.tsx"],
"lastTool": "edit",
"count": 1
}
}📊 真实的 usage_stats(截断):
{
"totalCostUSD": 0.036602319074001986,
"sideCostUSD": 0.00013512324400000045,
"totalAPIDuration": 20278.945166999998,
"totalToolDuration": 16,
"modelUsage": {
"glm-5.2": {
"inputTokens": 33275,
"stockPromptTokens": 33275,
"cumulativePromptTokens": 100381,
"outputTokens": 486,
"cacheReadInputTokens": 61888,
"requests": 3,
"costUSD": 0.036602319074001986,
"cacheSavingsUSD": 0.03052006719999804
}
}
}metadata 的语义是「覆盖式」:同一个 key 出现多次,恢复时取最后一条。 所以上面 122 条 file_changes 不是 122 批不同的改动, 而是同一个累积集合被反复覆写 —— 每次新改一个文件就把完整列表重写一遍。
🔬 这是刻意的:
app.ts:4736的注释说「未新增文件…也刷新 lastTool,但仅在有新增时 才落盘,避免同一文件反复编辑产生大量冗余 metadata 记录」。也就是说它做了去重 —— 反复编辑同一个文件不会反复落盘,只有集合变大时才写一条。 这是「覆盖式语义 + append-only 存储」的必然妥协: append-only 存不了「覆盖」,只能靠「再写一条、读时取最后」来模拟, 于是必须自己做去重,否则文件会被同一份状态的 N 个版本撑爆。
注意 usage_stats 里那三个 token 字段,它们是 §13.8 那个陷阱的伏笔:
| 字段 | 口径 | 混用会怎样 |
|---|---|---|
inputTokens | stock:最后一次调用的原始值 | —— |
stockPromptTokens | stock:最后一次调用的完整输入 | 用它算命中率 → 算出 444% |
cumulativePromptTokens | flow:所有调用累加 | 用它显示「当前上下文多大」→ 严重虚高 |
一个是「此刻的水位」,一个是「累计流过的水量」。用水位除以水量,得到的是个没有意义的数。
4.9 session_end:只有 33% 的会话有
📊 这是本章最有信息量的一个实测结果:
51 个会话文件
├── 有 session_end 的:17 个(33%)
└── 没有的: 34 个(67%)三分之二的会话没有正常结束记录。
这不是 bug,这就是现实:用户直接关终端、Ctrl+C、笔记本休眠、进程被 OOM kill —— 这些路径都不会走到「优雅退出」的代码。
🔬 而 sid-code 对此有明确认知,
store.ts:236-238的注释:// 进程退出兜底:无论从哪条路径 process.exit(),退出前把缓冲区清空落盘。 // 覆盖不到 SIGKILL/硬崩溃——与 CC 自身的设计取舍一致,这一点做不到更好。「这一点做不到更好」—— 这是一句很诚实的注释。
SIGKILL是内核直接干掉进程, 用户态代码一行都跑不到,没有任何 exit hook 能救。
这个 33% 有两个重要后果:
- 不能用「有没有 session_end」来判断会话是否可恢复。 67% 的会话都没有它, 但它们的历史是完整的(append-only 的功劳)。
- 中断检测不能依赖显式标记。 这直接推出了 §11 的做法: 看最后一条记录的类型来推断上次发生了什么,而不是找一个「我正常退出了」的标记。
📊 顺带验证了一件事:那 17 个有 session_end 的文件,session_end 都在物理末行。 说明写入顺序是可靠的(这依赖 §6.2 的「关键记录立即同步落盘」)。
4.10 会话文件的大小分布
📊 实测 51 个文件:
min = 661 B p50 = 76 KB p95 = 626 KB max = 975 KBp50 才 76 KB,但 p95 是它的 8 倍。 这是个典型的长尾分布 —— 大部分会话很小,少数会话大得多。
这个形状决定了 §6.4 和 §7.5 的两个优化:
- p50 这么小 → 大部分会话直接整读最快,没必要一律上流式
- p95/max 有近 1 MB → 但必须为大文件准备一条路,否则那 5% 的会话会拖出内存尖峰
🔬 sid-code 的阈值就是按这个形状定的(store.ts:174-175):
/** P1-6:JSONL 文件超过此字节数时走流式逐行读取(避免"巨串 + 行数组"双份内存尖峰)。
* 4MB 约对应数千条记录,小于此值一次性读取更快、开销可忽略。 */
const JSONL_STREAM_THRESHOLD_BYTES = 4 * 1024 * 1024;4 MB 这个阈值,远高于实测 max(975 KB)。 意味着本机所有会话都走整读路径 —— 流式路径在这台机器上一次都没被触发过。
这又是一个「代码在但没被触发」的例子(和 §4.7 同类)。 它是合理的:那是为极端情况准备的安全阀。但要诚实地说清楚: 「有流式读取」和「流式读取被验证过」是两件事。 想验证它,得专门造一个 >4 MB 的会话文件去跑(§15 阶段 5 会讲怎么做)。
4.11 本章自检
- 为什么
-c(continue)只扫当前项目目录,而--resume <id>跨项目扫? - 92% 的
user_message其实是工具结果。这个事实会让哪两类统计出错? tool_result和context_compact落盘条数都是 0,但结论完全不同。判据是什么?metadata是覆盖式语义,但存储是 append-only。这个矛盾靠什么弥合?代价是什么?- 67% 的会话没有
session_end。这个事实如何决定了中断检测的实现方式? stockPromptTokens和cumulativePromptTokens混用会算出什么样的错数?
§5 ★ parentUuid 链表:为什么不能直接按行读
这一章讲本领域最核心的一个机制。它看起来是多余的(「文件里行本来就有顺序啊」), 但那个直觉是错的,而它错在哪里正是这一章要讲清的东西。
5.1 先问:文件里的行顺序,不就是对话顺序吗?
看起来是。你 append 第 1 条、第 2 条、第 3 条,读的时候按行读,顺序天然正确。
在单进程、无分叉、无压缩的理想情况下,确实如此。 那三个前提破掉任何一个,就不成立了。
5.2 破坏前提一:两个进程同时写同一个文件
场景:你开了两个终端,都 --resume 同一个会话 id。 两个进程都往 20260821-104135.jsonl 追加。
appendFileSync 是原子的(单次 write 不会被切断,一行不会写成半行), 但两个进程交替 append 的结果是行的物理交叉:
物理文件(按行读) 实际发生的两条独立对话
──────────────────────── ─────────────────────────
1. session_start 进程 A:改 auth 模块
2. user: "改 auth 模块" 进程 B:写文档
3. user: "写文档" ← B
4. assistant: "好,我看看 auth"
5. assistant: "文档我起个草" ← B
6. user: [tool_result 代码]
7. user: [tool_result 文档] ← B按行顺序读出来的东西是这样的:
用户:改 auth 模块
用户:写文档
助手:好,我看看 auth
助手:文档我起个草
用户:[auth 的代码]
用户:[文档的内容]这是一份语义错乱的历史。 而且注意它的失效形态有多恶劣:
- ❌ 不报错。每一行都是合法 JSON,解析全部成功。
- ❌ 看起来像真的。你恢复出来一看,「哦有对话有工具结果,正常」。
- ❌ 模型会认真对待它。它会试图理解「为什么我同时在改 auth 又在写文档」, 然后给出一个基于错误前提的回答。
这就是本领域最典型的失效形态:静默的语义损坏。 数据没坏、程序没崩、 解析没错,但恢复出来的东西是错的。这比崩溃难查一百倍 —— 崩溃会告诉你哪一行出问题,语义损坏什么都不告诉你。
5.3 解法:每条记录记住「我的前一条是谁」
🔬 sid-code 给每条记录盖两个戳(store.ts:69-75):
interface ChainFields {
/** 本条记录的唯一 ID */
uuid: string;
/** 前一条记录的 uuid;null 表示链头(新会话起点) */
parentUuid: string | null;
}🔬 写入时(store.ts:903-917):
private appendRecord(partial: SessionRecordInput): void {
this.ensureMaterialized();
const uuid = crypto.randomUUID();
const record = { ...partial, uuid, parentUuid: this.lastUuid }; // ← 指向上一条
this.lastUuid = uuid; // ← 我成为下一条的 parent
const line = JSON.stringify(record) + "\n";
// ... 写盘
}每个进程各自维护自己的 lastUuid。 于是刚才那个交叉写入的文件变成:
物理行 uuid parentUuid
───────────────────────── ──── ──────────
1. session_start A1 null
2. user: "改 auth" ← 进程A A2 A1
3. user: "写文档" ← 进程B B1 A1 ← B 也接在 A1 后面
4. assistant: "看 auth" ← A A3 A2
5. assistant: "起草" ← B B2 B1
6. user: [auth 代码] ← A A4 A3
7. user: [文档内容] ← B B3 B2现在文件里其实是两条链(一棵树,从 A1 分叉):
A1 (session_start)
╱ ╲
A2 → A3 → A4 B1 → B2 → B3
(进程 A 的对话) (进程 B 的对话)5.4 恢复:从物理末行往回走
🔬 关键在于恢复算法(rebuildRecordOrder,store.ts:1046-1101):
① 从【物理最后一行】开始(那是最新写入的那条)
② 记住它的 parentUuid,往前扫
③ 只采纳 uuid == 期望 parentUuid 的行,其他行【跳过】
④ 一直走到 parentUuid == null(链头)
⑤ 把结果反转 → 得到正序的一条完整链代码(🔬 精简自 store.ts:1078-1099):
const chain = [tail];
const seenUuids = new Set([tail.uuid]);
let expectedParentUuid = tail.parentUuid ?? null;
for (let i = tailIdx - 1; i >= 0 && expectedParentUuid !== null; i--) {
const rec = JSON.parse(lines[i]); // 坏行 catch 后 continue
if (rec.uuid !== expectedParentUuid) continue; // ← 外部分支,跳过
if (seenUuids.has(rec.uuid)) { // ← 环检测
log.warn("SESSION", `检测到会话记录链出现环(uuid=${rec.uuid}),提前截断恢复内容`);
break;
}
seenUuids.add(rec.uuid);
chain.push(rec);
expectedParentUuid = rec.parentUuid ?? null;
}
chain.reverse();套用到上面那个交叉文件:末行是 B3,于是回溯得到 A1 → B1 → B2 → B3 —— 进程 B 的那条完整对话,进程 A 的行全被跳过。
拿到的是一份语义正确的历史(虽然只是其中一条分支)。 对比一下这两种结果:
| 按行读 | 沿链回溯 | |
|---|---|---|
| 得到什么 | 两条对话混在一起 | 其中一条完整对话 |
| 语义 | ❌ 错乱 | ✅ 正确 |
| 完整性 | 「全都有」但没用 | 少了另一条分支 |
这是个刻意的取舍:宁可少,不可错。 拿到一条正确的历史(哪怕丢了另一条分支), 远好过拿到一份混合的假历史。
🔬 源码注释把这个取舍说得很清楚(
store.ts:927-932):「多进程意外同时 append 同一文件时(如重复 resume 同一会话),只有与链尾在同一条 parentUuid 链上的记录会被采纳,外部分支的物理行被跳过,避免把两段不相关对话拼接成 一份语义错乱的历史喂给模型。」
5.5 顺便白拿到的三个能力
链式结构不只解决并发交叉。它还免费带来三个能力 —— 这是「选对数据结构」的典型收益:一个结构解决多个问题。
① 环检测(防死循环)
上面代码里的 seenUuids。如果链意外形成环(A → B → A), 不检测就会无限循环,进程挂死。
🔬 sid-code 把它标为 P2-11,说明是专门立项修的。 📄 而研究口径提到 Claude Code 的同款代码里也有环检测, 研究文档的判断是:「说明生产中真的出现过 parentUuid 链形成环的 bug。 这不是防御性编程,是踩过坑后加的。」
(⚠️ 这个判断是那份文档的推测,不是我能验证的事实。但推理方向是对的: 没人会预先给一个「理论上不可能」的情况写检测代码。)
② 分叉(fork)
想「从这个会话的历史开一个新会话,原会话不动」? 链结构天然支持:拷前 N 条,新会话从第 N 条继续接。
🔬 sid-code 的 forkHistoryFrom(store.ts:411):
// 溯源锚点先落,确保即便后续拷贝中途失败也能看出「这是一次分叉」
this.appendMetadata("forked_from", {
sessionId: srcSessionId,
...(srcTailUuid ? { uuid: srcTailUuid } : {}),
messageCount: messages.length,
});注意「溯源锚点先落」这个顺序 —— 先写「我是从 X 分叉来的」,再拷历史。 这样即使拷到一半崩了,你也能从文件里看出「这是一次分叉的残骸」, 而不是一个来历不明的半截会话。
这是 append-only 系统里的一个通用手法:先写意图,再做事。 和 WAL 是同一个思想(§3.3)。
③ 抗坏行
因为回溯是按 uuid 匹配而不是按行号,中间某行损坏了只是「匹配不上,跳过」。 链会在坏行处断掉(拿到部分历史),但不会解析出错乱的内容。
5.6 一个必须记住的细节:resume 时要接上旧链尾
这是最容易漏、且漏了会静默出错的一个点。
--resume 时,新进程要往旧文件继续追加。它的第一条记录的 parentUuid 该填什么?
如果填 null(当成新链头),那么:
旧文件:A1 → A2 → A3 (上次的对话)
续写: B1(parentUuid=null) → B2 ← 另起了一条链!
恢复时从末行 B2 回溯 → B1 → parentUuid=null → 停
结果:只拿到 B1、B2 两条,【上次的对话全丢了】形态:resume 成功了,但历史是空的。 不报错,就是「怎么它不记得刚才的事」。
🔬 sid-code 的处理(store.ts:466-467 注释):
「P0-1:续写前先读取旧文件尾部记录的 uuid,作为本进程新记录的链尾起点—— 否则续写的记录会以
parentUuid=null另起一条断链,恢复时无法串联成一条完整历史。」
所以 resumeSession 要做三件事,缺一不可:
| 做什么 | 不做会怎样 |
|---|---|
把 currentFile 指向旧文件 | 另开新文件 → 历史碎片化成两个文件 |
不写 session_start | 一个文件里两个链头,恢复时行为不确定 |
读出旧文件末行的 uuid 作为 lastUuid | 断链,历史全丢 |
📊 实测印证:51 个文件里 session_start 恰好 51 条 —— 每个文件恰好一个链头, 说明 resume 确实没有重复写 session_start。
(这是个不错的一致性自查:session_start 条数 == 文件数。 不等于就说明要么有文件没有链头,要么有文件有多个 —— 两种都是 bug。)
5.7 一个反直觉的偏离:sid-code 刻意不在压缩处截断
这一节含金量很高,因为它是**「不抄」的一个明确案例**。
背景:长会话会撑爆上下文窗口,所以要「压缩」(compact)—— 把前面几十轮总结成一段摘要,用摘要替换原始消息。
📄 研究口径说 Claude Code 的做法是:在链里插一个 compact boundary 节点, 它的 parentUuid 设为 null(主动断链)。恢复时回溯到 boundary 就停 —— 于是自动只恢复「压缩之后」的内容,压缩前的老历史不再加载。读取时还能跳过大文件的前半部分,是个性能优化。
听起来很合理。但 sid-code 刻意没这么做。 🔬 store.ts:12-15 的注释:
「P2-8 compact boundary:仅作为诊断性元数据保留(
isBoundary),不用于截断恢复内容—— 与 CC 不同,sid-code 已有明确修复历史(B2 方案A / bug②)证明"压缩处截断恢复"会 导致 resume 后历史丢失,此处刻意不复现该问题」
🔬 更完整的理由在 store.ts:578-585:
「早期实现在压缩处清空 messages,导致 resume 后历史丢失(bug②)。压缩效果本就已反映在 后续写入的真实消息流里(sid-code 的压缩多为截断/管道压缩而非稳定的 LLM 摘要, 未必有可靠摘要文本兜底),保留完整真实消息流才是"最忠实、无损"的恢复方式。」
关键在括号里那句。 拆开看这个论证:
| Claude Code | sid-code | |
|---|---|---|
| 压缩产物 | 一段可靠的 LLM 摘要 | 可能只是截断(未必有摘要文本) |
| 在 boundary 截断 → 丢掉的老历史 | 由摘要兜底,信息还在 | 没有兜底 → 信息真的丢了 |
| 因此该不该截断 | 可以 | 不可以 |
同一个机制,在两个系统里正确性不同 —— 因为前置条件不同。
这是本文最重要的方法论之一:
抄一个设计前,要先确认它依赖的前置条件在你这边也成立。 compact boundary 截断恢复这个设计,隐含依赖「压缩一定产出可靠摘要」。 这个前提在 CC 成立、在 sid-code 不成立,所以照抄的结果是丢历史。
而且注意失效形态:它不会报错。你 resume 一下,历史空了, 你会以为是别的地方出了问题。
代价要说清楚(不能只讲好话):sid-code 因此放弃了大文件跳读优化。 🔬 store.ts:1112-1114 承认了这一点:
「注意:这里仍会把所有行收进内存数组——因为链式重建需要按 uuid 回溯, 无法真正做到"只读尾部"(尾行的 parentUuid 可能指向文件任意位置)。」
用「读取性能」换「永不丢历史」。 📊 而实测数据支持这个取舍: p95 才 626 KB,全量读进内存毫无压力(§4.10)。在当前数据规模下, 这个代价是零。 如果会话涨到几十 MB,这笔账要重算。
5.8 链表 vs 数组:完整对照
| 能力 | 数组(按行读) | parentUuid 链 |
|---|---|---|
| 实现复杂度 | ✅ 极简 | ⚠️ 要盖戳 + 回溯 + 环检测 |
| 多进程交叉写入 | ❌ 静默语义错乱 | ✅ 只采一条链 |
| 分叉(fork) | ⚠️ 只能从末尾切 | ✅ 从任意节点分 |
| 中间行损坏 | ⚠️ 跳过后顺序仍可能错 | ✅ 断链,但不错乱 |
| 死循环风险 | ✅ 没有 | ⚠️ 有(所以要环检测) |
| 大文件跳读 | ✅ 能只读尾部 N 行 | ❌ 做不到(parent 可能在任意位置) |
| 恢复成本 | O(n) 顺序 | O(n) 但要建 index |
链表不是「更好」,是「用可控的复杂度换掉一类静默错误」。 如果你的 agent 保证单进程、不需要分叉,数组完全够用 —— 别为了显得高级而上链表。
5.10 本章自检
- 两个进程同时 append 同一个 JSONL,为什么「每行都是合法 JSON」反而让问题更难查?
- 沿链回溯会丢掉另一条分支的内容。为什么这个「丢」比「全都读进来」更好?
resume时如果忘了接上旧链尾的 uuid,症状是什么?为什么它难被发现?- 为什么链式回溯做不到「只读文件尾部」?
- compact boundary 截断恢复这个设计,隐含依赖什么前置条件?在 sid-code 为什么不成立?
§6 写入侧:缓冲、关键记录、延迟创建
§2 那个 30 行版本每条消息都 appendFileSync 一次。这一章讲为什么那样不行、以及正确做法。
先说结论:写入侧的全部复杂度都来自一个矛盾 ——
写得越勤,崩溃时丢得越少(持久性好); 写得越勤,syscall 越多,UI 越卡(吞吐差)。
这两者不可兼得。所以真正的问题不是「选哪个」,而是「在哪里划线,以及哪些记录不许被这条线管」。
6.1 为什么不能每条都立即写盘
一轮 agent 对话会产生多少条记录?📊 实测:51 个会话 2302 条记录, 平均每个会话 45 条;但会话有长有短,长会话单轮就可能十几条 (一次并行调 5 个工具 = 1 条 assistant + 5 个结果块 + metadata)。
每条都 appendFileSync:
- 每次都是一个同步 syscall,会阻塞主线程
- 主线程被阻塞 = TUI 渲染卡顿、用户输入没反应
- 而且多数写入是小块(几百字节),syscall 的固定开销占比极高
解法:攒一批一次写。 🔬 sid-code 的做法(store.ts:169-212):
const FLUSH_INTERVAL_MS = 100;
const pendingWrites = new Map<string, string[]>(); // 每个文件一个队列
const flushTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** 非关键记录:入队,由 100ms 定时器批量落盘 */
function enqueueWrite(filePath: string, chunk: string): void {
let queue = pendingWrites.get(filePath);
if (!queue) { queue = []; pendingWrites.set(filePath, queue); }
queue.push(chunk);
if (!flushTimers.has(filePath)) {
const timer = setTimeout(() => flushFile(filePath), FLUSH_INTERVAL_MS);
timer.unref?.(); // ← 不阻塞进程自然退出
flushTimers.set(filePath, timer);
}
}
/** 把某个文件已排队的内容一次性落盘(批量合并 syscall) */
function flushFile(filePath: string): void {
const queue = pendingWrites.get(filePath);
if (!queue || queue.length === 0) return;
pendingWrites.set(filePath, []);
appendFileSync(filePath, queue.join("")); // ← 一次 syscall 写 N 条
}这个 trade-off 要说清楚:100ms 内崩溃,缓冲区里的记录会丢。
值得吗?算一下:
| 立即写 | 100ms 缓冲 | |
|---|---|---|
| syscall 次数 | 每条 1 次 | 每 100ms 1 次(可能合并十几条) |
| 主线程阻塞 | 频繁 | 稀疏 |
| 崩溃最坏丢失 | 0 | 最后 100ms 的记录 |
100ms 的对话内容 = 通常是 0 条到几条。 而模型生成一段回复要几秒, 所以「刚好在这 100ms 里崩溃且刚好有未落盘记录」的概率不高, 且丢失的是最后一两条,前面全在。
用「最坏丢最后 100ms」换「不卡 UI」。这笔账在交互式 CLI 里是划算的。
⚠️ 但它有前提:如果你的 agent 在做金融交易这类操作, 「丢最后一条」可能意味着「一笔转账记录不见了」。那就要每条
fsync。 同一个 trade-off,场景变了结论就反转。顺带说清一个常被混淆的点:
appendFileSync只保证写进了操作系统的页缓存, 不保证落到物理磁盘。真要抗「整机断电」得再调fsync。 所以严格说 sid-code 抗的是「进程崩溃」,不是「断电」。这个区分在面试里值得主动点出来。
6.2 关键记录必须绕过缓冲
这是本章最精巧的一处设计。
有些记录不能等 100ms —— 🔬 store.ts:122-124:
/** 关键记录类型:绕过写入缓冲,立即同步落盘(P0-3)——这两类记录界定文件生命周期
* (list()/loadLatest() 依赖它们存在与否判断会话边界),必须第一时间可见。 */
const CRITICAL_RECORD_TYPES = new Set(["session_start", "session_end"]);为什么这两类特殊? 因为别的功能靠它们判断会话边界:
--list-sessions靠session_start认出「这是个会话文件」-c(loadLatest)靠它决定「最近的会话是哪个」
如果 session_start 还在缓冲区里躺着,此刻另一个进程来 --list-sessions, 它看到的是一个空文件或不存在的文件 —— 你刚开的会话「不存在」。
🔬 处理(store.ts:214-222):
/** 关键记录:先把该文件已排队内容按序落盘,再同步写入本条——保证顺序且立即可见 */
function writeCritical(filePath: string, chunk: string): void {
flushFile(filePath); // ← 先把队列清空
appendFileSync(filePath, chunk); // ← 再写本条
}注意 flushFile 那一步,它是必须的。 如果直接 appendFileSync 而不先清队列:
队列里还有:[消息A, 消息B]
直接写 session_end
→ 文件顺序:... session_end, 消息A, 消息B ← 【顺序错了】session_end 会跑到它本该结束的消息前面去。而 §5 的链回溯是从物理末行开始的, 末行变成了「消息B」,链就从错误的地方起算。
通用原则:混用「缓冲写」和「直接写」两条路径时,直接写的那条必须先 flush 缓冲。 否则你破坏的是顺序,而顺序在 append-only 系统里就是全部的语义。
这是个很容易漏的 bug,而且它只在「队列非空时刚好写关键记录」这个时序下出现 —— 大部分时候队列是空的,测不出来。
6.3 进程退出兜底
100ms 定时器有个漏洞:用户在定时器触发前就退出了。
🔬 兜底(store.ts:229-239):
export function flushPendingSessionWrites(): void {
for (const filePath of [...pendingWrites.keys()]) flushFile(filePath);
}
// 进程退出兜底:无论从哪条路径 process.exit(),退出前把缓冲区清空落盘。
// flushFile 内部用 appendFileSync(同步),"exit" 事件处理器只能做同步工作,天然匹配。
// 覆盖不到 SIGKILL/硬崩溃——与 CC 自身的设计取舍一致,这一点做不到更好。
process.on("exit", flushPendingSessionWrites);三个细节都值得学:
① 为什么 exit 钩子里必须用同步写? Node 的 "exit" 事件里只能做同步工作 —— 异步回调根本不会被执行, 事件循环已经不转了。所以 flushFile 用 appendFileSync 不是随手写的, 它是为了能在 exit 钩子里用。
② 为什么用模块级单例而不是每个实例注册? 🔬 store.ts:162-167 注释:
「app.ts 恢复会话时会创建多个只读 SessionStore 实例(用于 load/loadSummary), 若每实例各自注册
process.on("exit"),长会话/多次 resume 场景下会累积监听器 (触发MaxListenersExceededWarning)。改为模块级单例彻底避免。」
③ 读路径也要 flush。 🔬 store.ts:225-228:
「读路径(load / getAllSessionFiles)读取前调用,保证读到的内容与刚写入的保持一致—— 缓冲只是「延迟落盘」,绝不能让读者看到落后于内存状态的数据。」
这一条是缓冲设计的完整性要求:一旦你引入缓冲,就等于在系统里造了两个真相 (内存队列 + 磁盘文件)。每一个读入口都必须先对齐它们, 漏掉任何一个读入口,那个入口就会看到过期数据。
这是个通用的排查提示:引入缓存/缓冲后,去把所有读入口列出来数一遍。 漏一个就是一个「偶尔读到旧数据」的幽灵 bug。
6.4 延迟创建文件:避免空文件
问题:用户敲 sid-code,看了一眼,Ctrl+C 退了。什么都没干。 如果启动时就建文件,磁盘上会留一个只有 session_start 的空会话。 攒几百个之后,--list-sessions 里全是垃圾。
🔬 解法(store.ts:360-385 + 894-901)—— 两段配合看:
startSession(sessionId, model, provider, cwd, forkedFromSessionId?): void {
this.currentFile = join(this.sessionDir, `${sessionId}.jsonl`);
this.materialized = false; // ← 还没真建
const uuid = crypto.randomUUID();
this.pendingStart = { type: "session_start", ..., uuid, parentUuid: ... };
// 链尾提前指向待写入的 session_start,即便文件还未 materialize,
// 后续记录的 parentUuid 也能正确指向它(写入顺序由 ensureMaterialized 保证)。
this.lastUuid = uuid; // ← 关键:链戳先立
}
private ensureMaterialized(): void {
if (this.materialized || !this.currentFile) return;
this.materialized = true;
if (this.pendingStart) {
writeCritical(this.currentFile, JSON.stringify(this.pendingStart) + "\n");
this.pendingStart = null;
}
}startSession 只在内存里准备好 session_start,不写盘。 等到第一条真实记录来了(appendRecord → ensureMaterialized),才先写 start、再写这条。
注意 this.lastUuid = uuid 那一行 —— 这是精妙之处。session_start 虽然还没落盘,但它的 uuid 已经生成并成为链尾。 所以下一条记录的 parentUuid 能正确指向它,链的完整性不受「延迟」影响。
如果不这么做,下一条记录会发现 lastUuid 是 null,把自己当链头 —— 又是 §5.6 那个断链 bug。
而 🔬 endSession(store.ts:610-627)收尾:
endSession(totalCostUSD, totalMessages): void {
// P2-9:会话从未 materialize(没有任何真实消息)→ 无需落盘任何内容,直接重置状态,
// 避免"打开即退出"留下空文件。
if (!this.materialized) {
this.currentFile = null;
this.pendingStart = null;
return; // ← 一个字节都没写,文件根本不存在
}
this.appendRecord({ type: "session_end", ... });
}「打开即退出」的会话,磁盘上根本不存在这个文件。 不是「建了再删」,是从未创建。
📄 研究口径说 Claude Code 有同款设计(
materializeSessionFile)。这是个通用手法: 把「创建资源」推迟到「第一次真正需要」的那一刻。 同样适用于日志文件、临时目录、数据库连接。顺带一提,它还有个附带好处:磁盘只读的情况下,看一眼就退出的用户不会报错 —— 因为压根没试着写。
6.5 写入侧完整流程图
把这一章拼起来:
appendMessage / appendMetadata / appendCompact
│
▼
appendRecord
│
├─▶ ensureMaterialized() ← ① 首次写才真建文件(并先写 session_start)
│
├─▶ 盖链戳:uuid = randomUUID()
│ parentUuid = lastUuid ← ② §5 的链
│ lastUuid = uuid
│
└─▶ 按记录类型分派:
│
├─ session_start / session_end(关键)
│ └─▶ writeCritical
│ ├─ flushFile() ← ③ 先清队列(保顺序!)
│ └─ appendFileSync ← 立即可见
│
└─ 其他(消息 / metadata / compact)
└─▶ enqueueWrite
└─ 入队 + 起 100ms 定时器(unref)
│
▼
flushFile:queue.join("") 一次 appendFileSync
另外三条 flush 触发路径:
· process.on("exit") ← ④ 退出兜底(必须同步写)
· 读路径调用前 ← ⑤ 防读到过期数据
· 写关键记录前 ← ③ 同上6.6 一个诚实的边界:抗不了什么
把做不到的说清楚,比只说做得到的更可信:
| 故障 | 能扛吗 | 为什么 |
|---|---|---|
Ctrl+C / 正常退出 | ✅ | exit 钩子 flush |
process.exit() 任意路径 | ✅ | 同上 |
| 未捕获异常导致退出 | ✅ | 仍会触发 exit 事件 |
SIGKILL / kill -9 | ⚠️ 丢最后 ≤100ms | 内核直接干掉,用户态代码一行都跑不到 |
| OOM 被系统杀 | ⚠️ 同上 | 同上 |
| 整机断电 | ⚠️ 可能丢更多 | appendFileSync 只保证进页缓存,未 fsync |
| 磁盘写满 | ⚠️ 记录丢失但不崩 | 🔬 flushFile 里 catch 住只 log.error |
📊 实测印证:67% 的会话没有 session_end(§4.9)—— 说明大部分退出走的不是优雅路径。但那些文件的历史都是完整的, 因为 append-only 保证了「已经写下的不会坏」。
这就是 append-only 的真正价值:它不保证「不丢最后一点」, 它保证「已经落盘的部分永远可用」。这两个承诺的强度差别很大, 而后者才是崩溃恢复真正需要的。
6.7 本章自检
- 为什么
writeCritical必须先flushFile再写?不这么做会破坏什么? - 为什么
exit钩子里的 flush 必须用同步 API? startSession里那行this.lastUuid = uuid为什么不能省?省了会出现哪个已知 bug?- 引入写入缓冲后,为什么每个读入口都必须先 flush?漏一个的症状是什么?
appendFileSync抗得住进程崩溃,但抗不住什么?为什么?
§7 ★ 读取侧:恢复的难点不是加载,是清洗
这一章是面试区分度最高的一章。原因很简单:几乎所有人都以为恢复就是 「读文件 → JSON.parse → 塞回内存」。
📄 而那份源码研究文档读完 Claude Code 后给出的判断是:
「60% 的恢复代码在处理边界情况——孤儿消息、断裂链表、旧格式兼容、 无效权限模式、并行工具调用的 DAG 拓扑。这些问题在概念层面完全不会被讨论, 但在生产中是最耗时间的部分。」
(⚠️ 「60%」是那份文档的目测比例,不是精确统计。但方向是对的, 这一章会用 sid-code 的实证支撑同一个结论。)
7.1 核心认知:磁盘上的数据一定是脏的
先建立这个心智模型。为什么持久化的数据必然不干净? 五个来源:
| 脏数据来源 | 具体形态 | 为什么必然发生 |
|---|---|---|
| 流式输出被中断 | 模型正在流式生成,用户 Ctrl+C。留下半截消息 | 用户随时可以打断,这是功能不是意外 |
| 工具调用没闭合 | 存了 tool_use(我要调工具),但结果还没回来就崩了 | 崩溃时刻是随机的,必然会落在这类中间态 |
| 格式演进 | 老文件是 v2 格式,新代码是 v3 | 只要产品还在迭代,就必然有旧文件 |
| 多进程交叉 | §5.2 那个物理行交叉 | 用户会开多个终端 |
| 半行 | 最后一行写了一半 | SIGKILL 的时刻不受控 |
注意这五条没有一条是「bug 导致的」。 它们全都是正常运行的必然产物。 所以「清洗脏数据」不是补救措施,它是恢复功能的主体部分。
这个认知转变很重要:
❌ 错的心智模型:「读文件 → 解析 → 完事,偶尔遇到坏数据处理一下」 ✅ 对的心智模型:「从一堆必然不干净的记录里,重建出一份保证合法的状态」
7.2 最要命的一类脏数据:工具调用没闭合
这一类值得单独详细讲,因为它的后果最硬:直接让 API 报 400,agent 一句话都说不出来。
协议规则:模型 API 要求 tool_use 和 tool_result 严格一一配对。
assistant: [tool_use id=abc name=read_file] ← 我要读文件
user: [tool_result id=abc content="..."] ← 这是结果崩溃可能发生在这两条之间。于是磁盘上留下:
assistant: [tool_use id=abc] ← 有请求
← 【结果永远不会来了】🔬 sid-code 给这两种不配对情况起了明确的名字(agent/message-invariants.ts:46-52):
export interface MessageHistoryIntegrity {
/** 是否完整(无孤儿 tool_use 且无游离 tool_result) */
intact: boolean;
/** 孤儿 tool_use 列表(缺对应 tool_result)——这是 OpenAI 400 的直接成因 */
orphans: OrphanToolUse[];
/** 游离 tool_result 列表(tool_result 无前置 tool_use)——同样违反协议 */
dangling: DanglingToolResult[];
}两种病,方向相反,都会 400:
| 名字 | 形态 | 怎么产生的 |
|---|---|---|
| orphan(孤儿 tool_use) | 有请求,没结果 | 工具执行时崩了 |
| dangling(游离 tool_result) | 有结果,没请求 | 截断历史时切在了配对中间 ← 这个最阴 |
第二种是自己造出来的,值得警惕。看这个场景:
上下文太长了,要截断,你写 messages.slice(-50) 取最后 50 条。看起来无害。 但如果第 50 条边界正好落在配对中间:
原历史: ... [assistant: tool_use abc] [user: tool_result abc] ...
↑ 切点在这里
slice(-50) 后: [user: tool_result abc] ... ← 结果在,请求被切掉了你自己制造了一个 dangling tool_result,然后 API 报 400。
🔬 sid-code 的修法是一个专门的安全切片函数(agent/message-invariants.ts:396-418):
export function safeSliceTail(messages: Message[], n: number, maxExpand = 5): Message[] {
if (messages.length <= n) return messages.slice();
let start = messages.length - n;
const minStart = Math.max(0, start - maxExpand);
// ① 向前扩展:只要窗口内存在游离 tool_result 且还能再纳入更早消息,就左移起点
while (start > minStart) {
if (checkMessageHistoryIntegrity(messages.slice(start)).dangling.length === 0) break;
start--; // ← 多带几条,把 tool_use 也纳进来
}
// ② 扩展耗尽仍有游离 → 从头部收缩,丢弃头部消息直到起点干净
while (start < messages.length) {
if (checkMessageHistoryIntegrity(messages.slice(start)).dangling.length === 0) break;
start++; // ← 反向:干脆把这个 tool_result 也丢掉
}
return messages.slice(start);
}两个方向都试,这个设计很值得学:
- 先向前扩展(多带几条历史,把缺失的
tool_use补进来)—— 优先保留信息 - 扩展 5 条还不干净 → 反向收缩(把这个孤零零的
tool_result也丢掉)—— 保证合法性
优先级是明确的:合法性 > 完整性。 宁可少几条历史,不能让 API 拒绝请求 —— 因为后者意味着 agent 彻底不能用了。
🔬 而 app.ts:3958 的注释指出这是个真实事故的修复:
「安全尾部切片:保证切片起点不落在游离 tool_result 上(Session 0427d1bd 400 根因)。
slice(-N)固定数量截断会切断tool_use/tool_result配对,留下游离tool_result→ 400。」
带着具体 session id 的注释,说明是从一个真实的 400 事故里定位出来的。
7.3 发现问题 vs 修数据:一个重要的架构分歧
sid-code 在这里有个很有意思的设计决策,面试时抛出来很能体现架构思维。
🔬 llm/protocol-sentinel.ts:1-19 的文件头:
协议完整性发送前关卡 — D1-1 + D3-2
本模块在【消费端发送出口】设统一关卡:
- D1-1:发送前扫描孤儿 tool_use,发现即 log.error + 落盘脏历史快照
- D3-2:把触发孤儿的 assistant 消息 + 周边 ±3 条单独落 protocol-violation-<ts>.json,
含 tool_call_id 配对明细,直接可验尸
【不违反 ADR-039】:本关卡是【只读校验 + 告警 + 落盘】,不在 convertMessages 修数据
(ADR-039 方案 B 否决的是"修数据")。脏数据仍由生产端负责不产生,消费端只负责发现并报警。关键在最后一句。 两条路线的对比:
| 方案 A:在出口修数据 | 方案 B(sid-code 选的):出口只校验 + 报警 | |
|---|---|---|
| 做法 | 发送前发现孤儿 → 自动补一个假 tool_result | 发现孤儿 → 记 error 日志 + 落盘现场,照原样发 |
| 短期效果 | ✅ 不 400 了 | ⚠️ 还是会 400 |
| 长期后果 | ❌ 真正的 bug 被永久掩盖 | ✅ bug 暴露,能被修 |
为什么「自动修」是错的? 因为孤儿 tool_use 的存在说明上游有 bug (某条路径把不配对的消息塞进了历史)。你在出口自动补齐, 症状消失了,但那条 bug 路径永远不会被发现,而它可能还在以别的方式造成损害。
这是个通用的架构原则,很值得记住:
「在错误的层修 bug」比「不修」更糟 —— 因为它把问题从「可见的故障」 变成了「不可见的错误状态」。 前者会被修,后者会一直存在。
sid-code 的处理是把这个原则做到了极致:不但不修,还专门落盘一份验尸材料 (出问题的消息 + 周边 ±3 条 + 完整配对明细),让人能直接定位上游是哪条路径。
并且它给测试和生产设了不同的严格度(🔬 protocol-sentinel.ts:16-19):
strict 模式(eval/test 下抛错让 CI 红,生产下告警+落盘不中断用户)测试里抛错(让 CI 红,逼你修),生产里只告警(不能因为一个协议问题就让用户用不了)。 同一个校验,两种严格度 —— 这个分档几乎总是对的。
7.4 「落盘验尸材料」这件事本身也会出事
这一段是个很好的二阶陷阱案例:为了排查问题而加的机制,自己成了问题。
🔬 protocol-sentinel.ts:56-62:
「P2-12:违规样本保留上限。
为什么需要(2026-08-14 实测):
protocol-violations/无任何保留策略,用户盘上 攒到 8255 个文件 / 32MB。落盘本身是对的(D3-2 的验尸现场), 但没有上限的采集等于慢性泄漏。」
8255 个文件。 这个数字有两层信息:
- 诊断机制必须自带保留策略。 任何「出问题就落盘一份」的设计, 都要问一句「出一万次会怎样」。
- 顺便暴露了另一件事:这个协议违例发生了至少 8255 次。 一个「理论上不该发生」的情况,实际上每天都在发生 —— 这本身就是最有价值的诊断信息。
通用原则:所有「异常时落盘/上报」的机制都要有上限(文件数、总字节、保留天数)。 否则它在异常高频时会把用户的盘写满 —— 在系统已经不健康的时候再补一刀。
7.5 读路径的其他几层清洗
除了配对,还有几类必须处理。这里把 sid-code 实际做的和研究口径提到的分开列:
🔬 sid-code 实际做的:
| 清洗 | 处理什么 | 在哪 |
|---|---|---|
| 坏行跳过 | 半行、非法 JSON | rebuildRecordOrder 里 try/catch + continue |
| 链外分支跳过 | 多进程交叉写入的行 | rec.uuid !== expectedParentUuid → continue |
| 环检测 | 链成环导致死循环 | seenUuids |
| 旧格式兜底 | v2 无 uuid 的文件 | 尾行无 uuid → 退化为线性解析 |
| 旧 JSON 格式 | v1 整文件一个 JSON | load() 里按扩展名分派 |
| 安全切片 | 截断切断配对 | safeSliceTail |
| cwd 不一致告警 | 跨项目恢复 | app.ts:3963-3984 |
注意「旧格式兜底」这一处的写法(🔬 store.ts:1065-1076):
if (!tail || typeof tail.uuid !== "string") {
// 旧格式或全部行都损坏 → 线性解析兜底
const records = [];
for (const line of lines) {
try { records.push(JSON.parse(line)); } catch { continue; }
}
return records;
}用「尾行有没有 uuid 字段」来判断格式版本,而不是读 session_start.version。 为什么?因为这个判断发生在解析之前 —— 此时还不知道哪行是 session_start。 用「尾行的形状」做判断,一次 JSON.parse 就能决定走哪条路。
🔬 注释还强调了兼容性承诺:「与改造前行为完全一致,零回归」。 这是给旧格式兜底路径的正确态度:新机制失效时,退回到老机制,而不是报错。
🔬 而消息层的清洗是一条独立的六层管道,在另一个文件里 (sdk/session-recovery.ts,237 行)。它的文件头注释把设计意图写得很清楚:
清洗管道(依次执行,每层只做一件事,方便排查恢复问题时定位是哪层丢的数据):
1. migrateLegacyFormats —— 格式迁移预留点
2. stripInvalidPermissionModes —— 清理引用了已下线权限模式的 _meta 字段
3. filterUnresolvedToolUses —— 过滤有 tool_use 但无对应 tool_result 的调用
4. filterOrphanedThinkingOnlyMessages —— 过滤流式中断残留的纯 thinking assistant 消息
5. filterWhitespaceOnlyAssistantMessages —— 过滤内容被清空后的空白 assistant 消息
6. validateContentBlockIntegrity —— 剔除缺失关键字段(id/name/tool_use_id)的不完整 block逐层看它们各自防什么 —— 每一层都对应一种真实的中断时序:
| 层 | 处理什么脏数据 | 什么时候产生 |
|---|---|---|
| 1 | 旧版本消息结构 | 版本演进(当前是预留点) |
| 2 | 已下线的权限模式枚举值 | 老会话存了新版本不认的值(§8.2 那个话题) |
| 3 | 孤儿 tool_use | 工具执行时崩了(§7.2) |
| 4 | 只有 thinking 没有正文的 assistant 消息 | 流式时用户在 thinking 之后、正文之前按了 Ctrl+C |
| 5 | 纯空白的 assistant 消息 | 上一层清空 content 之后的残壳;某些 API 拒绝空消息 |
| 6 | 缺 id/name/tool_use_id 的 block | 流式写到一半崩了,block 只有半个字段 |
注意第 4、5 层的因果关系 —— 第 5 层是在清理第 4 层的副产物。 清洗管道自己会产生新的脏数据:你把一条消息的 thinking 块删了, 它就变成一条空消息;空消息又是另一类违规。
这是管道式清洗的一个通用特征:层与层之间有依赖顺序, 后面的层要处理前面的层制造出来的残留。 所以顺序不能随便调 —— 把第 5 层挪到第 4 层前面,空壳消息就漏出去了。
而「每层只做一件事,方便排查恢复问题时定位是哪层丢的数据」这句注释 点出了分层的真正动机:不是为了代码优雅,而是为了可排查性。
当用户报「恢复后少了几条消息」,你需要能回答「是哪一层过滤掉的」。 🔬 所以每层都带一个 ctx.log(reason, detail) 回调, 统一打到 SESSION_RECOVERY 日志频道 —— 过滤动作本身是可观测的。
如果六层合成一个大函数,这个问题就永远查不清了。
7.5b 一个我没能验证的点:并行工具调用的 DAG
📄 研究口径提到 Claude Code 还有一个 100 多行的 recoverOrphanedParallelToolResults,它揭示了 §5 链表设计的一个潜在局限:
📄 研究口径:流式输出时,N 个并行 tool_use 会产生 N 条 assistant 消息 (相同 message.id,不同 uuid)。这时候链表实际上变成了 DAG(有向无环图)—— 一个节点分出 N 个子节点。而单链回溯只能走一条分支,其他 N-1 条就成了「孤儿」。
于是 Claude Code 有一个 100 多行的函数专门在读取时把它们捡回来。
这说明什么? §5 说「链表优于数组」,但那不是无代价的:
链表假设了「一条链」,而并行工具调用天然产生「多条分支」。 这个不匹配不能靠链表本身解决,只能在读取时做后处理补救。
sid-code 有没有同款问题?我去数了真实数据,答案是:没有,它天然规避了。
📊 统计 51 个会话里所有含 tool_result 的 user_message, 看每条记录里塞了几个 tool_result 块:
一条记录里的 tool_result 块数 | 记录条数 |
|---|---|
| 1 个 | 735 |
| 2 个 | 123 |
| 3 个 | 26 |
| 4 个 | 11 |
| 5 个 | 2 |
| 6 个 | 2 |
| 8 个 | 1 |
| 合计 | 900(其中 165 条装了多个) |
并行调用的 N 个结果被合并进了同一条记录(最多见到一条装 8 个)。
所以在 sid-code 里,「一次并行工具调用」= 链上一个节点,不是 N 个节点。 链始终是单链,从不分叉 —— DAG 那个问题在结构上就不存在。
为什么会有这个差异? 🔬 回到 query/loop.ts:4186:
deps.sessionStore?.appendMessage({ role: "user", content: toolResults });toolResults 是一个数组,整批一次写入 → 一条记录。 而 📄 研究口径说 CC 是流式逐块产生消息的,所以并行调用会散成多条。
同一个功能(并行工具调用),两种写入粒度,导致一方需要 100 多行的恢复补救、 另一方一行都不需要。
这是「批量写入」相对「逐条流式写入」的一个隐性收益 —— 而且它不是被设计出来的,是选了「整批 append」这个写法之后顺带得到的。
面试时这个对照很有说服力:数据结构的选择会决定你后面要不要写那 100 行补救代码。
7.6 一个漂亮的细节:读之前先 flush
🔬 store.ts:664-667:
if (resolved.endsWith(".jsonl")) {
// P0-3:读取前先把该文件的缓冲写入落盘,避免读到落后于内存状态的内容
// (缓冲只延迟落盘时机,绝不能改变"读到的就是最新写入"这一语义)。
flushFile(resolved);
const result = await this.loadFromJsonl(resolved);§6.3 已经提过这条,这里从读侧再强调一次,因为它是一个容易漏掉的完整性约束:
你引入缓冲,就是在系统里造了两个真相(内存队列 + 磁盘)。 每一个读入口都必须先对齐它们。 漏掉任何一个,那个入口就是一个 「偶尔读到旧数据」的幽灵 bug —— 而且它只在「缓冲区非空时刚好来读」的时序里出现, 你本地几乎测不出来。
排查这类问题的方法:把所有读入口 grep 出来,逐个确认前面有 flush。 不要靠「我记得都加了」。
7.7 本章的核心原则
把这一章折成四条可迁移的原则:
① 磁盘上的数据永远是脏的,清洗是主体不是补丁
不要假设你写下去的东西读回来还是完整的。中断、崩溃、版本演进、并发 必然产生脏数据 —— 这些都是正常运行的产物,不是 bug。
② 合法性 > 完整性
送给 API 的消息列表必须保证协议合法,哪怕代价是丢几条历史。 因为「少几条历史」是退化,「API 报 400」是失效。
③ 在错误的层修 bug 比不修更糟
出口发现脏数据 → 报警 + 落盘验尸材料,不要偷偷补齐。 偷偷补齐会把「可见故障」变成「不可见的错误状态」,而后者永远不会被修。
④ 写入时简单,读取时智能
写路径就是一次 append,几乎不可能出 bug。 所有复杂度推到读路径 —— 读路径的 bug 不会损坏数据,修了重试一次就行。
这四条的共同逻辑:把不可避免的复杂度赶到代价最低的那一侧。
7.9 本章自检
- 列举脏数据的五个来源。其中有几个是「bug 导致的」?
- orphan tool_use 和 dangling tool_result 有什么区别?哪一种是我们自己制造的?
safeSliceTail为什么要「先向前扩展,再反向收缩」两个方向都试?优先级是什么?- 为什么「在发送出口自动补齐孤儿」比「照原样发出去然后 400」更糟?
- 为什么「异常时落盘诊断材料」的机制必须自带保留上限?
- 「用尾行有没有 uuid 判断格式版本」而不是读
version字段,为什么?
§8 恢复到底恢复什么:十项状态清单
§1.3 说过「状态是复合的」。这一章给完整清单。
为什么这一章重要:面试问「持久化要存什么」,答「对话历史」只能拿到基础分。 能说出这张清单并解释每一项不存会出现什么具体症状,才是做过的人的答案。
8.1 完整清单(🔬 逐项在 sid-code 源码中确认)
| # | 状态 | 存在哪 | 不恢复会出现什么症状 |
|---|---|---|---|
| 1 | 对话消息 | JSONL 消息记录 | agent 完全不记得刚才聊了什么 |
| 2 | 模型 / provider | session_start + agent_setting | 恢复后换了个模型,行为和成本突变 |
| 3 | token 与成本累计 | metadata.usage_stats | 底部统计栏全部归零,看不出这个会话花了多少钱 |
| 4 | 改过哪些文件 | metadata.file_changes | agent 不知道自己改过什么,可能重复改或答错「你改了哪些」 |
| 5 | Todo 列表 | metadata.todo_state | 任务清单消失,多步任务失去进度感 |
| 6 | 工作目录 cwd | session_start.cwd | 相对路径全错,或跨项目串台 |
| 7 | effort / thinking 档位 | metadata.agent_setting | 推理强度变了,输出质量突变 |
| 8 | 目标状态(goal) | metadata.goal_state | 跨会话续做时失去目标意识 |
| 9 | 子代理未完成的 sidechain | 独立 <会话id>-<agentId>.jsonl | 被 kill 的子代理无法续跑,只能从头重来(§12) |
| 10 | worktree 状态 | .sid-code/session-config.json | 恢复后 cwd 不在 worktree 里,agent 在主仓干活 |
另外还有两项附加机制(不是「状态」但影响恢复质量):
| 机制 | 作用 | |
|---|---|---|
| 11 | 落盘进度文件 | ~/.sid-code/progress/<会话id>.md —— 🔬 app.ts 注释叫它「抗压缩、抗清理的外部进度记忆」 |
| 12 | 假设登记表 | metadata.hypothesis_ledger —— 排查类任务里「我验证过哪些假设」 |
第 11 项那个说法值得停一下:为什么进度要单独存一个 markdown 文件, 而不是靠对话历史?
因为对话历史会被压缩(长会话必然发生)。压缩之后,「我第 3 步做完了」这个信息 可能被摘要掉。而一个独立的 progress 文件不参与压缩, 所以它是压缩的幸存者 —— 这是一种用外部存储对抗上下文有限性的手法。
📄 研究口径也提到同一个思路:Anthropic 发现「summarization-as-compaction 不够用, 需要做 full context reset(从结构化 handoff 文件重建)」。 同一个问题,同一个方向的解法。
8.2 一个反直觉的设计:有一项刻意不恢复
这是本章最值得记的一段,面试抛出来含金量很高。
权限模式(permissionMode)—— sid-code 刻意不跨会话恢复。
🔬 app.ts:4097-4103 的注释:
「P0-2:permissionMode 不做隐式跨会话恢复(对齐 CC 安全红线)。 权限档位每会话重新裁定,一律回到 default 或 CLI 显式值。
此前只有
acceptEdits一个档位会跨会话静默复活,构成不一致的"半恢复"语义—— 用户上次开了acceptEdits,这次在完全不同上下文里 resume,会在不知情下失去"每次确认"保护。删除整个恢复块后,permissionMode 彻底不进恢复流程。」
拆开这个论证,它有三层:
① 安全状态不该被静默继承。 用户上周为了一个可信任务开了「自动接受编辑」。今天他 resume 这个会话, 可能是在完全不同的处境下(不同代码、不同风险)。 如果权限档位悄悄复活,他会在不知情的情况下失去保护。
② 「半恢复」比「不恢复」更糟。 原来的实现只恢复 acceptEdits 一个档位,其他档位不恢复 —— 这种不一致的语义最坑:用户无法形成正确的心智模型 (「它到底会不会记住我的权限设置?」)。
③ 修法是「删掉整个恢复块」,而不是「多恢复几个档位」。 方向选择很关键:把行为统一到保守的那一侧,而不是统一到方便的那一侧。
通用原则:
恢复的默认方向应该是「安全状态回到最保守值,工作状态尽量恢复」。
判据是问一句:「这项状态如果被错误地恢复了,最坏后果是什么?」
- 恢复错了 Todo 列表 → 用户看到一个过期清单,能自己发现
- 恢复错了权限模式 → agent 未经确认改了文件,用户发现时已经晚了
后果不对称 → 处理方式就该不对称。
🔬 而且注意它对旧数据的处理:「agent_setting.permissionMode 类型字段保留 (仅为兼容旧快照残留字段)—— 读到即忽略」。
保留字段定义,但忽略它的值。 这是处理「已废弃字段」的正确姿势: 不删定义(否则老文件解析可能报错),但在使用侧明确忽略。
8.3 恢复中的「归正」:状态可能已经无效了
另一个容易漏的点:存下来的值,恢复时可能已经不合法了。
🔬 app.ts 里 effort 档位的处理(注释精简):
「当前模型无效(如快照记于 claude 时段的
xhigh、现模型是 GLM),恢复后就会复现…恢复的档位 ${this.runtimeEffort} 对模型 ${this.config.model} 无效,已归正为 ${fixed}」
场景很具体:
- 你上次用 Claude,开了
xhigh推理档位,存进快照 - 这次恢复时默认模型换成了 GLM,GLM 没有
xhigh这个档位 - 直接用 → 请求带一个非法参数 → 报错或行为异常
所以恢复不是「读出来赋值」,还要「校验 + 归正」。
这类问题的通用形态:
持久化的值和它的有效性上下文,是分开演进的。 你存的是「值」,但值的合法性取决于「当时的环境」。 环境变了(换模型、升版本、改配置),老值可能就非法了。
所以任何「恢复一个枚举/配置值」的地方,都要问:这个值可能失效吗?失效了怎么办?
sid-code 的三种处理方式,对应三种不同情况:
| 情况 | 处理 | 例子 |
|---|---|---|
| 值非法但能归正 | 归正到最近的合法值 + 日志 | xhigh → GLM 的最高档 |
| 值已废弃 | 保留字段定义,忽略值 | permissionMode |
| 值是终态、恢复无意义 | 不恢复 | 🔬 goal「仅恢复非终态目标(complete/impossible 不恢复,已无意义)」 |
最后那条也很有意思:已经完成的目标不需要恢复。 恢复它只会让用户困惑(「这个目标不是做完了吗,怎么又出来了」)。 「能恢复」不等于「该恢复」。
8.4 优先级链:恢复值 vs 显式指定
当用户这次启动时显式指定了某个值,而快照里也存了一个,谁赢?
🔬 app.ts 注释给了明确规则:
「优先级:显式 env 覆盖 > 恢复的会话快照。env 覆盖会被 queryLoop 每轮重读, 天然最高优先,故仅当 env 未设时才用快照恢复,避免覆盖用户本次启动的显式意图。」
「本次的显式意图」永远优先于「上次的历史状态」。 这条规则很直观但容易写错 —— 因为代码里恢复逻辑和配置加载逻辑通常在两个地方,谁后执行谁就赢, 很容易变成「恢复覆盖了用户刚敲的参数」。
完整的优先级链:
CLI 参数 / 环境变量(本次显式)
↓ 没设才看
会话快照(上次的状态)
↓ 没有才看
默认值8.5 恢复失败一律不阻断
翻遍 restoreSession,🔬 每一段恢复都是这个形状:
try {
// ... 恢复某一项
} catch (e) {
log.warn("APP", `XXX 恢复失败(不阻断): ${(e as Error)?.message}`);
}出现频次极高的一个词:「不阻断」。goal、agent 设置、usage、 文件历史、todo、假设登记表、进度回注 —— 每一项都是独立 try/catch。
为什么这个模式是对的?
| 全部恢复或全部失败(事务式) | 逐项恢复,失败跳过(sid-code) | |
|---|---|---|
| 某项数据损坏时 | 整个会话打不开 | 该项退化为默认值,其余照常 |
| 用户感受 | 「我的会话坏了」 | 「Todo 列表没了,但对话都在」 |
恢复是一个「尽力而为」的操作,不是一个事务。 因为部分恢复的价值远高于零:拿回 95% 的状态比什么都拿不回来好太多。
⚠️ 但这个原则有边界,要说清楚:它适用于「附属状态」。 如果对话消息本身加载失败,那就不该静默继续 —— 那时候「恢复成功但历史是空的」比「明确报错」更糟(用户会以为 agent 失忆了)。
判据:这项失败后,用户能不能自己察觉?
- Todo 没了 → 看一眼就知道 → 可以静默降级
- 历史空了 → 用户可能以为是 agent 的问题 → 必须明确告知
8.6 一个贯通的细节:跨项目恢复告警
🔬 app.ts:3963-3984:
// P0-1:cwd 一致性告警(纵深防御)。会话已按项目物理分目录,`-c`/选择器天然按项目隔离,
// 这条几乎不会触发;但用户手工 `-r <ID>` 恢复他项目会话时,仍可能在项目 B 里跑起项目 A 的
// 会话(进程 cwd 仍是 B、工具在 B 下执行)。
const msg =
`⚠️ 跨项目恢复:本会话原属于项目 ${sessRoot},当前工作目录属于 ${curRoot}。` +
`工具将在当前工作目录下执行,历史中的文件路径可能与当前项目不匹配,请留意。`;三个点值得学:
① 「纵深防御」(defense in depth)。§4.2 已经用「物理分目录」让 -c 不可能串项目了。 这里再加一层告警,防的是另一条入口(手工 -r <ID>)。 一个不变量,在多个层面各设一道防线 —— 因为你没法保证将来不会新增第三条入口。
② 注释诚实地说了「这条几乎不会触发」。 这很重要 —— 它告诉后来的维护者:这段代码低频不代表没用,别因为「没见它触发过」就删掉。
③ 告警同时给了人和模型。 🔬 这条 note 会并入 combinedNote 注入上下文 —— 不只是打日志给用户看,还要让模型知道。 因为模型是实际干活的那个:它需要知道「历史里的路径可能对不上现在的目录」, 否则它会拿着历史里的路径去读文件,然后困惑于为什么读不到。
这是 agent 系统特有的一个设计维度,普通软件没有: 一个异常状态,你要考虑通知三方 —— 用户(决策)、日志(排查)、 以及模型(干活)。漏掉第三个,模型就会基于错误前提工作。
8.7 本章自检
- 十项状态里,哪一项是刻意不恢复的?理由的三层论证分别是什么?
- 为什么「只恢复
acceptEdits一个档位」比「全都不恢复」更糟? - 为什么已完成的 goal 不恢复?「能恢复」和「该恢复」的区别在哪?
- 恢复值和本次 CLI 显式参数冲突时谁赢?为什么这个规则容易写错?
- 「恢复失败不阻断」适用于哪类状态?它的边界在哪?判据是什么?
- 为什么跨项目恢复的告警要同时注入给模型,而不只是打日志?
§9 ★ Checkpoint:文件层的时间机器
前八章都在讲对话层。这一章讲文件层 —— 也就是 §0.2 那个警告里 「coding agent 圈说的 checkpoint」。
它解决的问题一句话:agent 把你的代码改坏了,怎么撤销?
9.1 为什么对话层的持久化解决不了这个问题
回到 §1.4 那张表:崩溃后改过的文件还在磁盘上。这是好事(不用恢复), 但也是坏事 —— 它意味着改动是不可逆的。
具体场景:
你:把这个函数改成 async
agent:[改了 6 个文件]
你:等等,这改错了,撤销
agent:……agent 拿什么撤销?它内存里可能还留着改动前的内容,但:
- 进程重启后没了
- 改了 20 个文件后,早期的原始内容早就不在上下文里了
- 就算在上下文里,让模型「凭记忆重写原文件」是灾难性的做法 (它会重新生成一个「大概长这样」的版本,而不是精确还原)
所以必须在改之前,把原始内容存到磁盘上。 这就是 checkpoint。
9.2 触发时机:改之前,不是改之后
🔬 query/tool-executor.ts:437-459:
// 收集所有 tool_use 块,保留原始顺序索引
const toolBlocks = content.map(...).filter(b => b.type === "tool_use");
// 收集本次工具调用会修改的文件路径(用于创建快照)
const affectedFiles = await getAffectedFiles(toolBlocks.map(t => t.block));
// 【在工具执行前】统一创建快照
if (affectedFiles.length > 0) {
const cpMgr = await getCheckpointManager(...);
const snapshotId = await cpMgr.createSnapshot(affectedFiles, toolNames, toolSummary);
}三个设计点:
① 执行前快照,而不是执行后。 这是显然的(执行后原内容就没了), 但值得说清它的含义:快照存的是「改动前的状态」, 所以「回滚到快照 sN」= 回到「产生 sN 那次工具调用之前」。
② 一次工具调用批次 = 一个快照(不是一个文件一个)。 agent 一轮可能并行调 5 个 edit 改 5 个文件。这 5 个文件的原始内容 被打包成一个 snapshot。理由:回滚的语义单位是「一次操作」, 用户想的是「撤销刚才那一步」,而不是「撤销刚才那一步里的第三个文件」。
(🔬 但也提供了单文件回滚 undoFile,见 9.7 —— 默认按批,可选按文件。)
③ 只有 affectedFiles.length > 0 才建快照。 只读工具(读文件、搜索)不触发。
9.3 一个容易漏的场景:bash 命令的破坏
这一节是 sid-code 做得比较细的一块,值得学它的思路。
问题:快照只对 write/edit 工具建。那 agent 通过 bash 跑 git reset --hard、rm file、git checkout . 造成的破坏怎么办?
🔬 checkpoint/bash-affected-files.ts:1-15 的文件头把问题说得很清楚:
「背景:checkpoint 原本只对 write/edit 工具快照,通过 bash 跑的
git reset --hard、git checkout .、rm file、git clean -fd造成的破坏无法被/undo回退—— 而这恰恰是最需要回退保护的操作。」
「而这恰恰是最需要回退保护的操作」 —— 这句话点出了问题的讽刺性: 危险性最高的操作,恰好落在保护范围之外。
🔬 解法是分层的:
分层:
- 精确可提取:rm <file> / git checkout <file> / mv <src> <dst>
→ 从 AST 提取显式路径
- 范围性破坏(无法逐文件提取):git reset --hard / checkout . / clean -fd
→ 改用「工作区级轻量快照」:git diff --name-only
+ git diff --cached --name-only 的文件集关键洞察在第二类:git reset --hard 会影响哪些文件?静态分析算不出来 —— 它取决于当前工作区有哪些改动。
所以换个问法:不问「这条命令会改哪些文件」,而问 「当前工作区有哪些文件是有改动的」(因为只有它们会被 reset 破坏)。 git diff --name-only 正好回答这个。
这是个很好的通用手法:当「精确预测影响范围」不可行时,退化为「快照所有可能受影响的东西」。 代价是可能多存一些(有些文件其实不会被改),但保证不漏。 在「回滚能力」这个场景下,多存的代价远小于漏存。
🔬 还有个细节:isGitClean 单独判断,因为 git clean -f 删的是未跟踪文件 —— 这些文件不在 git diff 里,得额外快照未跟踪文件集。分类要穷尽。
而且 🔬 触发条件是收敛的:
「只对破坏性命令(
matchGitDanger命中,或rm/mv等文件破坏命令)做提取, 不是所有 bash 都快照,避免每条命令都 IO。」
如果每条 bash 都做工作区快照,ls 也会触发一次 git diff + 文件读取。 这个开销在一个会跑几百条命令的会话里是不可接受的。
9.4 存储策略:full + diff 链
朴素做法:每次快照都存完整文件内容。问题:一个 3000 行的文件, 改 20 次 = 存 20 份完整内容。
🔬 sid-code 的策略(checkpoint/manager.ts:1-9 文件头):
存储策略:
- 第一次保存完整内容(>1KB 时 gzip 压缩 + base64)
- 后续保存增量 diff(LCS 算法)
- 每文件最多 50 个 checkpoint,总共最多 200MB,30 天自动清理
- 存储路径:~/.sid-code/checkpoints/<session-id>/具体实现(🔬 manager.ts:242-307,精简):
const currentContent = await file.text();
const lastContent = await this.getLatestContentForFile(filePath);
if (lastContent === null) {
// 第一次:保存完整内容
if (currentContent.length > compressThreshold) {
const compressed = Bun.gzipSync(Buffer.from(currentContent, "utf-8"));
snapshotFile.content = Buffer.from(compressed).toString("base64");
snapshotFile.compressed = true;
} else {
snapshotFile.content = currentContent;
}
this.index.latestFullMap[filePath] = snapshotId; // ← 记住基点
} else if (lastContent !== currentContent) {
// 后续:保存增量 diff
const diff = computeDiff(lastContent, currentContent);
files.push({ filePath, existedBefore: true, type: "diff", diff });
}
// 内容没变,跳过 ← ③ 无变化不存三个要点:
| 要点 | 做法 | 收益 |
|---|---|---|
| 第一次存 full | 完整内容(>1KB 走 gzip + base64) | 建立基点 |
| 后续存 diff | 只存差异 | 体积从 O(N × 文件大小) 降到 O(文件大小 + N × 改动量) |
| 内容未变则跳过 | lastContent !== currentContent 判断 | 反复触发同一个文件不产生垃圾 |
📊 实测印证(109 个索引,519 个快照,537 个文件条目):
full = 281(其中 gzip 压缩 143,未压缩 138)
diff = 256
diff 占比 = 47.7%接近一半是 diff。 说明这个优化确实在起作用 —— 如果全存 full, 体积大概会翻倍。
gzip 阈值 1KB 的意义:📊 实测 281 个 full 里 143 个走了压缩、138 个没走 —— 差不多一半一半。说明 1KB 这个阈值把「源代码文件」(通常几 KB 到几十 KB,压缩) 和「小配置/新建空文件」(不压缩)分开了。压缩小文件不划算 (base64 编码本身会让体积涨 1/3,小文件压缩后可能反而更大)。
9.5 恢复:从基点往前 apply diff
存成 full + diff 链,恢复时就要反过来重建。🔬 manager.ts:568-615(精简):
private async rebuildContentAtSnapshot(filePath, snapshotId): Promise<string | null> {
const targetIndex = this.index.snapshots.findIndex(s => s.id === snapshotId);
// ① 往前找到最近的 full 快照
let baseContent = "", baseSnapshotIndex = -1;
for (let i = targetIndex; i >= 0; i--) {
const fileInSnapshot = this.index.snapshots[i].files.find(f => f.filePath === filePath);
if (fileInSnapshot?.type === "full") {
baseContent = fileInSnapshot.compressed
? Buffer.from(Bun.gunzipSync(Buffer.from(fileInSnapshot.content, "base64"))).toString("utf-8")
: (fileInSnapshot.content || "");
baseSnapshotIndex = i;
break;
}
}
if (baseSnapshotIndex === -1) return null; // ← 链断了,重建不出来
// ② 从基点往目标逐步 apply diff
let content = baseContent;
const { applyDiff } = await import("./diff.ts");
for (let i = baseSnapshotIndex + 1; i <= targetIndex; i++) {
const f = this.index.snapshots[i].files.find(f => f.filePath === filePath);
if (f?.type === "diff" && f.diff) content = applyDiff(content, f.diff);
}
return content;
}注意 if (baseSnapshotIndex === -1) return null —— 这是整个 diff 链设计的致命弱点:
diff 只有在能找到它的 full 基点时才有意义。基点丢了,后面所有 diff 全都是垃圾。
这一条直接决定了下一节那个精巧的设计。
9.6 ★ 淘汰的陷阱:不能直接删最旧的
这一节是本章最有价值的部分,因为它是一个**「显然的做法是错的」**的例子。
需求:checkpoint 不能无限增长(📊 实测已经占了 12.6 MB,比会话历史还多)。 要淘汰。
显然的做法:LRU,删最旧的。
为什么错:最旧的那个很可能就是 full 基点。删了它,后面所有 diff 都成了孤儿 —— 📊 而实测有 47.7% 的条目是 diff。你删掉一个 full,可能一次废掉后面十几个快照。
而且注意这个失效形态有多恶劣:淘汰的时候不报错。等用户哪天敲 /undo, 才发现「回滚失败」—— 而此时数据已经删了,无法补救。
🔬 sid-code 的解法:重锚定(reanchor)。
manager.ts 里 evictPerFile 的核心逻辑:
while (entries.length > max) {
const oldest = entries[0];
if (oldest.file.type === "full") {
// 先重锚定后续 diff;无法重锚定则【停止淘汰该文件】
const ok = await this.reanchorFullForFile(filePath, oldest.snapshotIndex);
if (!ok) break;
}
this.removeFileEntry(filePath, oldest.snapshotIndex);
entries = this.collectFileEntries(filePath);
}重锚定做什么:删掉 full 之前,先把紧接它的那个 diff 重建成完整内容, 让它成为新的 full 基点。
删除前: [s1: full] → [s2: diff] → [s3: diff] → [s4: diff]
↑ 想删这个
直接删: [s2: diff] → [s3: diff] → [s4: diff]
↑ 【全是孤儿,一个都恢复不出来】
重锚定: ① 用 s1 + s2.diff 重建出 s2 时刻的完整内容
② 把 s2 改写成 full(内容 = 刚重建的)
③ 现在才能安全删 s1
结果: [s2: full] → [s3: diff] → [s4: diff] ✅ 链完整而且注意 if (!ok) break —— 重锚定失败就停止淘汰,宁可超出配额也不切链。
这是个很好的优先级判断:「数据可用」优于「配额达标」。
超出 200 MB 配额的后果是「占了点磁盘」;切断 diff 链的后果是 「用户的 undo 功能静默失效」。前者可见且轻微,后者不可见且严重。
🔬 另一个保护 —— 保留窗口下限(evictBySize):
// 保留窗口下限:至少保留最近 MIN_KEEP 个快照,避免把用户可见/可 restore 的近期快照删掉
const MIN_KEEP = 11; // /checkpoints 显示最近 10 条 + /undo 最近 1 条
while (this.serializedSize() > maxBytes && this.index.snapshots.length > MIN_KEEP) {
if (guard++ > 100000) break; // 防御性死循环阀
...
}MIN_KEEP = 11 这个数字是从 UI 反推的:/checkpoints 命令显示最近 10 条 (🔬 builtins.ts:549「只显示最近 10 条」),/undo 要用最近 1 条 → 所以至少留 11 个,否则用户会看到一个列出来但点不了的快照。
通用原则:淘汰策略的下限必须由 UI 可见范围决定。 「列表里显示 10 条」和「保留策略可能只留 5 条」是矛盾的 —— 这类矛盾在两个模块各自都正确、合在一起才出错,是最难查的一类 bug。
还有那个 guard++ > 100000 死循环阀:淘汰是个 while 循环, 如果某种边界情况下「删了但 size 没降」,就会无限转。 加一个计数阀,是防御性编程里划算的那种 —— 一行代码,防一个挂死。
9.7 三种回滚粒度
🔬 sid-code 提供三个入口:
| 命令 | 方法 | 语义 |
|---|---|---|
/undo | undo() | 撤销最近一次文件修改(回滚最新那个快照) |
/undo <文件路径> | undoFile(path) | 只回滚某一个文件 |
/checkpoints(别名 /cp) | listSnapshots() | 只列出最近 10 条快照,不动数据 |
/rewind(别名 /checkpoint) | → restoreToSnapshot() | 回退到任意指定快照(见 §10) |
注意 /checkpoint(单数)是 /rewind 的别名,这个细节很有意思。 🔬 builtins.ts:947-950 注释:
「P0-B2:
/checkpoint(单数)作为别名——CC 用户敲这个词期待的是「回退到某个检查点」, 语义落在本命令而非/checkpoints(复数,只列快照,别名/cp)。不加别名会让 CC 习惯落空。」
单数 = 动作(回退到一个检查点),复数 = 列表(看所有检查点)。 这是个很细的产品判断:用户从别的工具迁移过来时带着肌肉记忆, 别让同一个词在你这里指另一件事。
9.8 一个真实的 bug:fork 时快照的继承
--fork-session 从旧会话分叉出新会话。新会话该不该继承旧会话的文件快照?
该。 否则分叉出来的会话「无法回退到分叉点之前」—— 而分叉的常见动机恰恰是「我想试另一条路,不行就退回来」。
🔬 manager.ts:860-914 的 inheritFrom,三个细节都值得看:
async inheritFrom(srcSessionId: string): Promise<number> {
if (!srcSessionId || srcSessionId === this.sessionId) return 0;
// ① 已有快照 → 不做插入式继承(防 id 冲突 / 时序错乱)
if (this.index.snapshots.length > 0) {
log.warn("CHECKPOINT", `继承跳过:当前会话已有 ${this.index.snapshots.length} 个快照,不做插入式继承`);
return 0;
}
// ② 源可能是旧格式(files 而非 snapshots)——复用既有迁移逻辑
const srcIndex = parsed.files && !parsed.snapshots
? this.migrateLegacyIndex(parsed)
: parsed;
// ③ 深拷贝:两会话此后独立演进,改一边不影响另一边
this.index = {
sessionId: this.sessionId,
nextId: typeof srcIndex.nextId === "number" ? srcIndex.nextId : snapshots.length + 1,
snapshots: structuredClone(snapshots),
latestFullMap: structuredClone(srcIndex.latestFullMap ?? {}),
};
}① 「已有快照就不继承」:因为快照 id 是自增的(s1, s2...)。 往一个已有 s1–s5 的会话里插入源会话的 s1–s3,id 会撞。 判断「必须是空的才能继承」是最简单可靠的处理。
② 旧格式迁移复用:源会话可能是老版本格式(files 而非 snapshots)。 不另写一套解析,而是复用既有的迁移函数 —— 迁移逻辑只有一份, 不会出现「A 路径迁移对了、B 路径迁移漏了」。
③ structuredClone 深拷贝:如果浅拷贝,两个会话共享同一批 snapshot 对象。 在新会话里做重锚定(会改写 snapshot 的 type 和 content) → 源会话的快照被改了。这是典型的共享可变状态 bug。
🔬 注释特意点出「structuredClone 覆盖 diff 嵌套结构」—— 因为 diff.ops 是嵌套数组,浅拷贝或 {...obj} 都拷不干净。
④ 失败降级:
catch (e) {
log.warn("CHECKPOINT", `checkpoint 继承失败(新会话退化为空回退历史,不阻断)`);
return 0;
}又是 §8.5 那个模式:继承失败 → 新会话没有回退历史,但会话本身能用。
9.9 对话层 vs 文件层:完整对照
这张表把两层的差异集中起来,面试时能直接用:
| 维度 | 对话层(session/store.ts) | 文件层(checkpoint/manager.ts) |
|---|---|---|
| 存什么 | 消息事件流 | 文件改动前的内容 |
| 格式 | JSONL,一行一记录 | 单个 index.json,快照内联 |
| 写入语义 | append-only,永不改写 | ⚠️ 会改写(重锚定要改 snapshot 的 type/content) |
| 增长控制 | 不删(📊 max 975 KB) | 必须淘汰(配额 50/文件、200 MB、30 天) |
| 压缩 | 无 | gzip(>1KB 的 full) |
| 增量 | 无(每条独立) | ✅ diff 链(📊 47.7% 是 diff) |
| 崩溃安全性 | ✅ 强(append 天然) | ⚠️ 弱(整个 index.json 覆盖写) |
| 恢复失败后果 | 对话丢一部分 | undo 失效 |
| 触发时机 | 每条消息 | 只在有文件改动的工具调用前 |
注意「写入语义」那一行的对比 —— 这是两层最本质的差异:
对话层是 append-only,所以它天然崩溃安全。 文件层因为要做重锚定和淘汰,必须能改写已有数据,所以它把所有快照放在 一个 index.json 里整体覆盖写 —— 这意味着它没有 append-only 的崩溃安全性: 写 index.json 的过程中崩溃,可能损坏整个索引。
这一点我去查证了,结论是:确实没有做原子写。 🔬 manager.ts 的 saveIndex:
private async saveIndex(): Promise<void> {
if (!this.dirty) return;
const indexPath = join(this.baseDir, "index.json");
await Bun.write(indexPath, JSON.stringify(this.index, null, 2)); // ← 直接覆盖
this.dirty = false;
}没有临时文件 + rename。 也就是说:写这个文件的过程中进程被 kill, index.json 可能变成一个语法不合法的半截 JSON —— 该会话的整个回退历史全废。
📊 而这个文件不小:实测最大的 index.json 是 2.8 MB(32 个快照、8 个文件)。 写 2.8 MB 的窗口不算窄。
标准修法只有一行的差别:
// 写临时文件 → 原子 rename(同一文件系统内 rename 是原子操作)
await Bun.write(indexPath + ".tmp", JSON.stringify(this.index, null, 2));
renameSync(indexPath + ".tmp", indexPath);rename 在同一文件系统内是原子的:要么指向老文件,要么指向新文件, 不存在「指向半个文件」的中间态。 崩在写 .tmp 的时候, index.json 还是完好的旧版本(丢最后一次快照,但历史全在)。
这个对比很值得记,因为它揭示了 append-only 的真正价值来源:
对话层不需要操心原子写 —— 因为它从不覆盖。append 天然是「要么这行写进去了, 要么没写」,不存在「把已有数据写坏」的可能。 文件层一旦选了「可改写的单文件索引」,就必须自己补回原子性。
换句话说:append-only 的崩溃安全不是它多做了什么,而是它少做了「覆盖」这件事。 一旦你的设计需要覆盖,你就得把那份安全性手工挣回来。
9.10 本章自检
- 为什么 checkpoint 必须在工具执行前建,而不是执行后?
git reset --hard影响哪些文件无法静态分析。sid-code 用什么办法绕过这个不可能?- 为什么不能对所有 bash 命令都做快照?
- 淘汰时直接删最旧的快照会出什么事?为什么这个失效特别恶劣?
- 「重锚定」具体做什么?重锚定失败时为什么选择「停止淘汰」而不是「继续删」?
MIN_KEEP = 11这个数字是怎么来的?它体现了什么通用原则?- fork 继承快照时为什么必须深拷贝?浅拷贝会出什么事?
- 对话层和文件层在「写入语义」上最本质的差异是什么?它导致了什么后果差异?
§10 Rewind:把对话层和文件层锚在一起
§5–§8 讲对话层,§9 讲文件层。这一章讲它们怎么合起来用。
为什么需要这一章:单独回退任何一层都是半成品。
10.1 只回一层会出什么事
看两个具体场景。
场景一:只回对话,不回文件
第 3 轮:你让 agent 改 auth.ts,它改了
第 4 轮:你让它改 user.ts,它改了
第 5 轮:你说「回到第 3 轮之前」
只回对话 → 对话历史回到第 3 轮之前(agent 不记得改过这两个文件了)
但 auth.ts 和 user.ts 【磁盘上还是改过的】结果:agent 的记忆和磁盘现实脱节了。 它会读到已经被改过的代码, 却不知道是自己改的,可能得出「这代码本来就是这样」的错误结论,然后在此之上继续改。
场景二:只回文件,不回对话
只回文件 → auth.ts 和 user.ts 回到原始内容
但对话历史里还写着「我已经把它改成 async 了」结果:agent 以为自己做过的事,其实没做。 它可能跳过这一步, 或者困惑于「我明明改了,怎么代码里没有」。
两种脱节,方向相反,都会让 agent 基于错误前提工作。
核心认知:agent 的状态其实是「对话」和「世界」两份,它们必须同步回退。
这是 coding agent 特有的问题。一个纯聊天 bot 没有「世界状态」, 回退对话就是全部。而 coding agent 改了真实文件, 所以它有一份在自己进程之外的状态。
10.2 解法:回退点 = 两个锚点
🔬 session/rewind-manager.ts:20-31:
/** 单个回退点。 */
export interface RewindPoint {
/** 自增 id(从 1 开始,稳定标识,供 UI 选中)。 */
id: number;
/** 本轮用户消息在 ctxMgr.messages 中的下标(截断到此下标 = 回到该轮之前)。 */
messageIndex: number;
/** 登记时 CheckpointManager 的最新快照 id(空串 = 当时无文件快照,仅能回退对话)。 */
snapshotId: string;
/** 用户输入预览(截断展示用)。 */
inputPreview: string;
/** 登记时间戳(ms)。 */
timestamp: number;
}一个回退点同时记住两件事:
RewindPoint
╱ ╲
messageIndex snapshotId
(对话层锚点) (文件层锚点)
↓ ↓
截断 messages restoreToSnapshot
到这个下标之前 回滚文件到这个快照登记时机:每轮用户输入提交前。🔬 app.ts:7697:
this.rewindManager?.registerPoint(opts?.displayCommand ?? text, Date.now());🔬 registerPoint 的实现(rewind-manager.ts:86-101):
registerPoint(userInput: string, nowMs: number): RewindPoint {
const messageIndex = this.deps.getMessages().length; // ← 当前长度 = 本轮将插入的位置
const point: RewindPoint = {
id: this.nextId++,
messageIndex,
snapshotId: this.deps.getLatestSnapshotId(), // ← 当前最新文件快照
inputPreview: makePreview(userInput),
timestamp: nowMs,
};
this.points.push(point);
// 环形上限:超出则丢最旧
if (this.points.length > MAX_REWIND_POINTS) {
this.points.splice(0, this.points.length - MAX_REWIND_POINTS);
}
return point;
}messageIndex 取「当前数组长度」这个细节值得停一下: 它是本轮用户消息即将插入的下标。所以「截断到 messageIndex」 = 丢弃本轮及之后所有消息 = 回到本轮之前的状态。
用「即将插入的位置」而不是「已插入的位置」,语义更干净: slice(0, messageIndex) 直接就是答案,不用 ±1。能消掉的 off-by-one 就消掉。
10.3 三档回退模式
🔬 rewind-manager.ts:35-40:
export type RewindMode = "conversation" | "code" | "conversation-and-code";| 模式 | 做什么 | 什么时候用 |
|---|---|---|
conversation | 只截断对话,不动文件 | 「你理解错了,我重新说一遍」(代码改得没错) |
code | 只回滚文件,保留对话 | 「思路对,但这次改坏了,撤销文件我们再试」 |
conversation-and-code | 两者都回 | 「这一整步都不对,全撤」 |
中间那档最有意思,🔬 注释解释了它的动机:
「
code:仅回滚文件到该轮快照,保留对话(用户想留着上下文重试,只要撤销文件改动)。」
场景:agent 分析对了、方案对了,但具体改动写错了。你想让它保留分析过程 (那些上下文很值钱,重新分析要再花几万 token),只是把文件恢复了重写一次。
如果只提供「全回」一档,用户就得付出「丢掉正确的分析」的代价来换「撤销错误的改动」。 这三档的存在,是为了让回退的粒度匹配错误的粒度。
10.4 执行顺序:先文件,后对话
🔬 rewind-manager.ts:125-156(精简):
async rewindTo(id: number, mode: RewindMode, nowMs: number): Promise<RewindResult | null> {
const point = this.getPoint(id);
if (!point) return null;
// ① 先回滚文件
let filesRestored = 0, fileRestoreSkipped = false;
if (mode === "code" || mode === "conversation-and-code") {
if (point.snapshotId) {
const n = await this.deps.restoreToSnapshot(point.snapshotId);
if (n === null) fileRestoreSkipped = true;
else filesRestored = n;
} else {
fileRestoreSkipped = true; // ← 当时没有快照,只能回对话
}
}
// mode=code:只回滚文件,对话与回退点原样保留
if (mode === "code") {
return { point, mode, messagesDropped: 0, filesRestored, fileRestoreSkipped };
}
// ② 再截断对话
const msgs = this.deps.getMessages();
const messagesDropped = Math.max(0, msgs.length - point.messageIndex);
this.deps.setMessages(msgs.slice(0, point.messageIndex));
// ③ 清理该点及其后的回退点(它们对应的对话已被丢弃)
this.points = this.points.filter(p => p.messageIndex < point.messageIndex);
return { point, mode, messagesDropped, filesRestored, fileRestoreSkipped };
}为什么先文件后对话? 因为文件回滚是可能失败的(快照丢了、 文件被外部占用、diff 链断了 → §9.6)。如果先截断对话再回滚文件, 文件回滚失败时你已经丢掉了对话,两层的脱节比回退前更严重。
通用原则:多步操作中,把「可能失败的」放在「不可逆的」之前。 这样失败时还能保持在一个已知状态。
第 ③ 步也值得看 —— 🔬 注释:
「截断对话后清理所有落在该点之后(含该点)的回退点, 避免"回退后又能回退到已丢弃的未来"。」
「回退到已丢弃的未来」 —— 这个说法很精准。 你回到第 3 轮,第 4、5 轮的回退点还留着,用户就能选「回退到第 5 轮」—— 但第 5 轮的消息已经不存在了,messageIndex 指向数组外面。
这是所有「历史 + 回退」系统的通用陷阱(编辑器的 undo/redo 栈是同一个问题): 回退之后,被丢弃的那段历史的所有引用都必须失效。
10.5 一个精巧的架构选择:纯依赖注入
🔬 rewind-manager.ts:56-66:
/** 注入依赖:解耦 ctxMgr / checkpoint 具体实现,便于测试。 */
export interface RewindDeps {
getMessages: () => unknown[];
setMessages: (msgs: unknown[]) => void;
getLatestSnapshotId: () => string;
restoreToSnapshot: (snapshotId: string) => Promise<number | null>;
}注意 unknown[] —— 它甚至不关心消息长什么样,只管数组长度和切片。
🔬 设计动机(文件头注释):
「纯数据 + 注入式依赖(ctxMgr 取/设消息、checkpoint 取最新快照 id/恢复), 不 import App/UI,便于单测且不与并发编辑的 app.ts/App.tsx 抢占大文件。」
最后半句很实在:app.ts 是个上万行的大文件,多人(或多个 agent) 同时改它会冲突。把新功能做成一个独立的小模块 + 4 个注入函数, 只在 app.ts 里加两行接线 —— 把改动面缩到最小。
这是个工程现实层面的考虑,不是纯技术美学。📊 而它的收益可以量化: rewind-manager.ts 只有 171 行,而它有 241 行测试 (tests/session/rewind-manager.test.ts)—— 测试比实现还多。 这只有在「纯数据 + 注入依赖」的结构下才做得到:不需要起 TUI、不需要真文件、 不需要真模型,4 个 mock 函数就能覆盖所有分支。
10.6 环形上限:为什么是 30
🔬 rewind-manager.ts:68-71:
/** 回退点上限:只保留最近 N 个,防止长会话无限增长。 */
export const MAX_REWIND_POINTS = 30;对比 §9.6 的 MIN_KEEP = 11(快照保留下限)—— 这两个数字有个隐含关系:
回退点保留 30 个 ← 每个点引用一个 snapshotId
快照至少保留 11 个 ← 淘汰下限30 > 11,所以理论上可能出现:一个回退点还在列表里, 但它引用的快照已经被淘汰了。
这时候会怎样? 🔬 看代码:
const n = await this.deps.restoreToSnapshot(point.snapshotId);
if (n === null) fileRestoreSkipped = true; // ← 优雅降级
else filesRestored = n;restoreToSnapshot 返回 null(快照没了)→ 标记 fileRestoreSkipped = true, 对话照样回退,只是文件回不去了,并且如实告诉用户。
这个处理是对的:不假装成功,也不整个失败。 🔬 RewindResult 里专门有个字段传这个信息:
/** 文件回滚是否因无快照/未启用而跳过。 */
fileRestoreSkipped: boolean;通用原则:跨模块引用一个可能被淘汰的资源时, 必须处理「引用还在但资源没了」这个状态,并且如实上报。
三种错误做法:假装成功(用户以为文件回滚了)、整个失败(对话也回不了)、 静默跳过(用户不知道文件没回滚)。sid-code 选了第四种:部分成功 + 明确告知。
10.7 Esc Esc 与三个命令的关系
🔬 从源码看到的完整入口:
| 入口 | 行为 |
|---|---|
Esc Esc | 打开回退选择器(列出最近回退点 + 三档模式选择) |
/rewind(无参数) | 🔬 builtins.ts:963 → { kind: "dialog", dialog: "rewind" },同一个选择器 |
/rewind <n> | 🔬 向后兼容的脚本化快捷路径:只回退 n 轮对话,不弹面板、不动文件 |
/undo | 只撤销最近一次文件修改(§9.7,走 CheckpointManager,不经 RewindManager) |
🔬 builtins.ts:958-961 注释:
「P2-2:无参数 → 打开统一回退选择器(对标 CC 的
Esc Esc菜单)。 让用户在交互面板里选「回退点」+「仅对话 / 对话+代码」,实现代码/对话/两者的统一入口。」
「统一入口」是关键词。 在有了 RewindManager 之前, 「回退对话」和「回退文件」是两个互不相干的功能,用户得自己组合 (先 /undo 再 /rewind 3)—— 而且顺序还得对(§10.4), 用户凭什么知道该先回文件?
把两个正交能力合成一个用户可理解的操作,这是 RewindManager 的真正价值。 它自己只有 171 行,没有任何算法,它的价值全在「把两层锚在一起」这个抽象上。
10.8 本章自检
- 只回退对话不回退文件,agent 会陷入什么状态?反过来呢?
messageIndex为什么取「当前数组长度」而不是「本轮消息的下标」?- 三档回退模式里,
code(只回文件)解决的是什么具体场景? - 为什么执行顺序必须是「先回滚文件,再截断对话」?
- 「回退后又能回退到已丢弃的未来」是什么问题?怎么解决的?
MAX_REWIND_POINTS = 30和MIN_KEEP = 11之间有什么隐含的不一致?sid-code 怎么处理?
§11 中断检测与副作用的 exactly-once
§1.5 那句开场白说「持久化的目标是保证副作用恰好执行一次」。 前面十章讲的都是怎么存、怎么读。这一章讲读回来之后该干什么 —— 这是整个领域最容易被问深的一块。
11.1 恢复之后,agent 该做什么?
假设你已经完美恢复了对话历史。现在呢?
- 直接等用户输入?可是上次 agent 正在干活干到一半。
- 让 agent 接着干?可它怎么知道干到哪了。
- 让它重新开始?那就是 §1 说的重复副作用。
这三个选择对应三种完全不同的状态,而你必须先判断出是哪一种。 这就是「中断检测」。
关键难点:§4.9 已经证明了 —— 67% 的会话没有 session_end。 所以你不能靠一个「我正常退出了」的标记来判断。
那靠什么?靠最后一条记录的形状去推断。
11.2 三态判定:sid-code 的实现
🔬 sdk/session-recovery.ts:16-22 的注释定义了三种状态:
中断检测三态(对齐 CC 的 none / interrupted_prompt / interrupted_turn):
- none 正常结束:最后一条是 assistant 消息,或末尾 tool_result 全部
来自终结性工具(TERMINAL_TOOL_NAMES 白名单)
- interrupted_prompt 用户输入了但 Agent 还没开始回复(末尾是纯文本 user 消息)
- interrupted_turn Agent 执行完工具但还没来得及回复就被中断(末尾是非终结性
工具的 tool_result)🔬 判定逻辑(session-recovery.ts:207-236,几乎是原文):
const lastMessage = messages[messages.length - 1];
// ① 末尾是纯用户输入 → interrupted_prompt(用户问了但还没等到回复)
if (lastMessage.role === "user") {
const hasToolResult = lastMessage.content.some(b => b.type === "tool_result");
if (!hasToolResult) {
return {
messages: messages.slice(0, -1), // ← 注意:把这条摘下来
turnInterruptionState: { kind: "interrupted_prompt", message: lastMessage },
};
}
// ② 末尾是 tool_result 且没有后续 assistant 回复 → 工具执行完但被中断
const toolNames = findToolNamesForResults(messages, lastMessage);
const allTerminal = toolNames.length > 0 && toolNames.every(n => TERMINAL_TOOL_NAMES.has(n));
if (allTerminal) {
return { messages, turnInterruptionState: { kind: "none" } };
}
return { messages, turnInterruptionState: { kind: "interrupted_turn", lastToolNames: toolNames } };
}
// ③ 末尾是 assistant 消息 → 正常结束
return { messages, turnInterruptionState: { kind: "none" } };这段代码的精髓在于:它完全不需要任何「我崩了」的标记。
回想 §4.5 那个事实(92% 的 user_message 其实是工具结果)—— 正是这个结构让「看末尾一条」能区分出三种状态:
| 末尾一条 | 说明上次发生了什么 | 状态 |
|---|---|---|
assistant 消息 | 模型说完话了,在等用户 | 正常 |
user 消息,content 是纯文本 | 用户说了话,模型还没开口 | interrupted_prompt |
user 消息,content 含 tool_result | 工具跑完了,模型还没基于结果回复 | interrupted_turn |
「最后一条记录的形状」编码了「上次进行到哪一步」。 这是事件流存储的一个漂亮的附带性质:你不需要额外存进度,进度就在数据的形状里。
面试时这一点很值得讲,因为它体现了一个通用洞察:
在一个「严格交替」的协议里(user → assistant → user → ...), 「最后一条是什么角色」本身就是状态机的当前状态。 不需要额外的状态字段 —— 存了反而可能和数据不一致(两个真相)。
11.3 三态各自怎么处理
判断出来之后,三种状态的动作完全不同。🔬 app.ts:4255-4259 的注释:
· interrupted_turn(工具执行完但没回复就被中断)→ buildToolInterruptMarker
携带工具名,帮模型定位断点、不重复调用;
· interrupted_prompt(用户提问了但 Agent 没开始回复)→ 把该 user 提问重新挂到
历史末尾让模型直接作答(用户自己的提问就是最强的续接信号,无需再叠加标记);
· none → 通用续接标记 buildResumeMarker。逐个看,三种处理体现了三种不同的思路:
① none → 注入一个通用续接提示
🔬 store.ts 的 buildResumeMarker:
<system-reminder>
本次会话是从之前的对话恢复的续接会话(上方消息为之前的历史上下文)。请直接从上次中断处
继续,无需重新打招呼或重复询问已确认的信息。
(请勿向用户提及或复述本提醒)
</system-reminder>为什么需要这个? 因为模型看到的只是一串历史消息 —— 它不知道这些是「恢复来的」。没有这条提示,它可能:
- 重新打招呼(「你好!有什么可以帮你的?」)
- 重复确认已经问过的信息
注意最后那句「请勿向用户提及或复述本提醒」。 这是 prompt 工程的细节: 你给模型的元信息,不希望它转述给用户(否则用户会看到 agent 在自言自语 「我注意到这是一个恢复的会话」)。
② interrupted_turn → 携带工具名的专用提示
🔬 buildToolInterruptMarker:
你在上次运行中调用了「${toolsText}」,工具已执行完成(结果见上方历史),
但进程在你回复之前被中断。请直接依据上方工具结果继续完成任务,
不要重复调用相同工具,也无需重新打招呼。关键是「不要重复调用相同工具」 —— 这就是 §1.5 那个 exactly-once 目标 在这个层面的落地方式。
⚠️ 但要看清它的性质:这是提示模型不要重复,不是机制保证不重复。 和 Temporal 的 Activity 缓存(§3.2,机制上物理不可能重复执行)不是一个强度。
这个对比在面试里很值得主动说出来 —— 它显示你知道自己方案的强度边界:
| Temporal | sid-code | |
|---|---|---|
| 手段 | Event History 里有记录 → 直接返回记录值,不执行 | 告诉模型「已经执行过了,别再调」 |
| 保证强度 | 机制保证(代码路径上不可能重复) | 概率性(模型通常会听,但不保证) |
| 代价 | 确定性约束 + 运维集群 | 零 |
为什么 sid-code 敢用弱保证? 回到 §3.4 那个论证: 它的副作用主要是文件修改,而文件修改本身是幂等的 —— 把内容写成 X,写一次和写两次结果一样。
所以「模型重复调用一次 edit」的后果是「浪费一点 token」,不是「数据错了」。 风险和代价匹配,弱保证够用。
⚠️ 但这个论证有个明确的边界,必须说清: 一旦 agent 能调「非幂等」的工具(发消息、创建 issue、支付、发邮件), 这个弱保证就不够了。 那时候必须上真正的幂等键(见 11.4)。
面试时如果被追问「那你们怎么处理不幂等的工具」,正确答法是承认边界: 「当前工具集主要是文件操作和只读查询,幂等性天然成立。 如果要接入发消息/支付这类工具,就必须在工具层引入幂等键, 光靠提示模型是不够的。」
③ interrupted_prompt → 把用户的提问重新挂到末尾
这个处理最巧。用户上次问了个问题,模型还没来得及回答就崩了。
怎么办? 🔬 注释给了答案:「把该 user 提问重新挂到历史末尾让模型直接作答 (用户自己的提问就是最强的续接信号,无需再叠加标记)」。
注意代码里那个细节:
return {
messages: messages.slice(0, -1), // ← 先把这条从历史里【摘下来】
turnInterruptionState: { kind: "interrupted_prompt", message: lastMessage },
};先摘下来,再重新挂到末尾。 为什么不直接留在原位? 因为中间还要插入续接标记、进度笔记等内容(🔬 combinedNote)。 如果用户的提问留在原位,这些注入内容会跑到它后面 —— 模型看到的顺序就变成「用户问了 X,然后系统说了一堆元信息」, 最后一条不是用户的问题了,模型的注意力焦点会偏移。
把用户提问放在最后,是为了让它成为模型最直接响应的对象。
这也解释了为什么这一档不需要额外的续接标记: 用户的问题本身就是最清晰的指令。多加一个「你在恢复会话」的提示反而是噪音。
三档处理里,这一档做的事最少 —— 而这是对的。
11.4 幂等键:那个最容易答错的陷阱
这一节是面试的深水区,也是 📄 研究口径里最有价值的一个点。
朴素做法:给每个副作用操作生成一个唯一 id,服务端按这个 id 去重。
# ❌ 错的
def create_ticket(title):
idempotency_key = str(uuid.uuid4()) # ← 每次调用都是新的随机值
return api.post("/tickets", key=idempotency_key, title=title)为什么错:崩溃恢复后重跑,uuid.uuid4() 生成一个新的随机值 —— 服务端看到一个没见过的 key,认为这是一个新请求,于是又建了一个工单。
幂等键完全失效了。
📄 研究口径把这个叫「语义回滚攻击」(semantic rollback)—— 恢复机制本身成了副作用重复的原因。
正确做法:幂等键必须从状态派生,而不是随机生成。
# ✅ 对的
def create_ticket(state, title):
# 从「会话 id + 步骤序号 + 操作类型」确定性地导出
idempotency_key = hashlib.sha256(
f"{state.session_id}:{state.step_index}:create_ticket:{title}".encode()
).hexdigest()
return api.post("/tickets", key=idempotency_key, title=title)关键性质:重跑时,同样的 (session_id, step_index, 操作, 参数) 会导出同一个 key → 服务端识别为重复 → 去重生效。
判据一句话:
幂等键必须是「状态的函数」,不能是「时间或随机的函数」。
任何用
uuid4()、Date.now()、自增计数器(内存里的)生成的幂等键, 在崩溃恢复场景下一定失效。自测方法:问自己「如果这段代码从头再跑一遍,这个 key 会一样吗?」 不一样 → 这个幂等键是假的。
这也解释了 §3.2 为什么 Temporal 要强制确定性: 它把「从状态派生」这个要求上升成了整个 Workflow 的约束 —— 既然所有不确定的东西都被赶进了 Activity,那么 Workflow 里算出来的任何东西 (包括幂等键)都自动是「状态的函数」。
11.5 三层防线:一个完整的心智模型
把 exactly-once 这件事拆成三层,面试时可以直接用这个框架回答:
第一层:不要重复执行(预防)
· 幂等键从状态派生 → 服务端能识别重复
· Temporal:Activity 结果缓存,恢复时不重跑
· sid-code:提示模型「工具已执行完,不要重复调用」
第二层:重复执行了也没事(幂等设计)
· 文件写入天然幂等(写两次结果一样)
· 数据库 upsert 而非 insert
· sid-code 依赖的就是这一层(文件操作天然幂等)
第三层:出事了能回退(补偿)
· Checkpoint 文件快照 → /undo(§9)
· Saga 模式的补偿事务
· 但注意:【不是所有副作用都能补偿】——发出去的邮件收不回来三层的强度递减,成本也递减。 一个真实系统通常是混着用:
| 副作用类型 | 依赖哪层 |
|---|---|
| 改文件 | 第二层(天然幂等)+ 第三层(快照可回滚) |
| 调只读 API | 不用管(无副作用) |
| 创建工单 / 提交 PR | 第一层必须做(幂等键) |
| 发邮件 / 发消息 | 第一层必须做,且第三层做不到(发出去收不回) |
| 扣款 | 第一层必须做,第三层要设计补偿(退款) |
最后一行那个「发出去收不回」值得强调: 有些副作用是没有第三层的。 对这类操作,第一层就是唯一防线 —— 所以它必须做对,不能靠「万一重复了再补救」。
11.6 sid-code 的进程层信号:PID 探活
除了「看末尾记录形状」,还有一条独立的判断路径。
🔬 session/concurrent.ts:1-8:
并发会话注册(Spec 18 §4)
每个 sid-code 进程启动时把自己注册到 ~/.sid-code/sessions/<id>.json,
退出时注销。`/ps` 命令读取该目录列出所有活跃会话。
用 PID 探活清理崩溃残留的注册文件(stale)。思路:注册文件里存 PID。想知道那个会话是否还活着 → 检查那个 PID 还在不在。
🔬 sid-code 有个 isProcessAlive(pid),被 file-intent.ts 等模块复用。
为什么需要 PID 探活? 因为「注册文件还在」不等于「进程还活着」—— 崩溃时来不及注销。用 PID 探活把「文件存在」这个弱信号升级成「进程真的活着」这个强信号。
📄 研究口径提到 Claude Code 也有同类机制。而 🔬 sid-code 的 trace/pid-manager.ts:4-5 写明了它补的缺口:
目标:补充 crash-marker 无法覆盖的场景:
- SIGKILL (kill -9):没有 uncaughtException,crash-marker 无法落盘这就是纵深防御(§8.6)在崩溃检测上的应用:
| 机制 | 能检测什么 | 覆盖不了什么 |
|---|---|---|
session_end 记录 | 优雅退出 | 📊 67% 的会话(非优雅退出) |
crash marker(uncaughtException 时落盘) | 未捕获异常导致的崩溃 | SIGKILL(钩子跑不到) |
| PID 探活 | 上面两者都漏掉的(PID 不在了 = 进程死了) | 极端:PID 被新进程复用 |
| 末尾记录形状(§11.2) | 上次进行到哪一步 | 不判断「怎么死的」,只判断「停在哪」 |
四种机制,四个不同的盲区,互相补位。
注意最后一层的性质差异:前三个回答「上次是怎么结束的」, 第四个回答「上次停在哪一步」。恢复真正需要的是第四个 —— 知道「上次是被 kill 的」没什么用,知道「上次停在工具执行完但没回复」才能决定下一步动作。
这是个值得记住的区分:崩溃检测(怎么死的)和断点定位(停在哪)是两件事。 很多人在面试里会把它们混成一件,然后花力气去做前者 —— 但真正驱动恢复动作的是后者。
11.8 本章自检
- 为什么中断检测不能依赖
session_end标记?实测数据是多少? - 三态判定完全靠「末尾一条消息的形状」。这依赖协议的什么性质?
interrupted_prompt为什么要把用户的提问摘下来重新挂到末尾?留在原位会怎样?- 「提示模型不要重复调用工具」和 Temporal 的 Activity 缓存,保证强度差在哪?
- sid-code 敢用弱保证的前提是什么?这个前提在什么情况下失效?
- 为什么用
uuid4()做幂等键在崩溃恢复场景下一定失效?正确做法是什么? - 崩溃检测的四层机制各自的盲区是什么?其中哪一层才是真正驱动恢复动作的?
§12 子代理与多进程:一个会话不止一条历史
前十一章都假设了「一个会话 = 一条对话历史」。这一章打破这个假设。
12.1 为什么子代理需要自己的历史
现代 coding agent 会派子代理(sub-agent):主 agent 说「你去把这 20 个文件搜一遍, 告诉我结论就行」,子代理自己跑十几轮,最后只把结论返回主 agent。
为什么要这样? 因为子代理那十几轮的中间过程(读了什么、搜到什么) 不进主会话的上下文 —— 主 agent 只拿到结论。这是节省上下文的核心手法。
但这带来一个持久化问题:子代理的那十几轮对话存在哪?
🔬 session/sidechain.ts:1-10 的文件头把问题说得很清楚:
「主会话历史落在
sessions/<sessionId>.jsonl;子代理(SubAgent)的多轮内部对话 此前完全是内存态——被 kill 后无法单独恢复、只能从头重跑。 本模块给每个子代理开一份独立的 sidechain JSONL:sessions/<sessionId>-<agentId>.jsonl, 与主会话同目录、按文件名前缀归属主会话。」
注意「只能从头重跑」这个代价:子代理往往是最贵的部分 (它可能读了 20 个文件、烧了几万 token)。它被 kill 而无法恢复, 等于把最贵的那段工作扔了。
12.2 sidechain 的三个刻意简化
这一节很有教学价值,因为它展示了同一个问题在不同约束下的不同答案。
🔬 sidechain.ts:11-18:
设计取舍(相对主会话 store.ts 的轻量化):
- 复用主会话相同的 JSONL 事件溯源思路,但【不引入 uuid/parentUuid 链】——子代理对话
是单线程顺序推进(无并行分支、无 fork),线性追加已足够,恢复时按写入顺序还原即可。
- 直接 appendFileSync 落盘(无 100ms 缓冲队列):子代理每轮才写一次、写入频率低,
且被 kill 时缓冲队列反而会丢最后一轮——即时同步写对"抗中断恢复"更稳。
- 完成/失败时写一条 sidechain_end(status),恢复时据此过滤掉已正常结束的 sidechain,
只把「未见 sidechain_end」的视为中断、可恢复。逐条对照主会话,三个决定全都反过来了:
| 机制 | 主会话 | sidechain | 为什么反过来 |
|---|---|---|---|
| uuid/parentUuid 链 | ✅ 有(§5) | ❌ 没有 | 子代理单线程顺序推进,没有并发写入、没有 fork → 链要解决的问题不存在 |
| 100ms 写入缓冲 | ✅ 有(§6.1) | ❌ 没有,直接同步写 | 子代理写入频率低(每轮一次)→ 缓冲省不下什么;而被 kill 时缓冲反而会丢最后一轮 |
_end 标记的作用 | ⚠️ 只有 33% 有,不可依赖(§4.9) | ✅ 可依赖,用来判断是否需要恢复 | 子代理由程序控制生命周期(不是用户随手关终端),正常路径一定会写 |
第三条的差异最值得琢磨。
主会话的 session_end 不可靠,因为用户随时可能拔电源。 而子代理的 sidechain_end 是程序自己写的 —— 它完成、失败、被 abort 三种路径 都在代码控制内。所以「没有 sidechain_end = 异常中断」这个推断成立。
🔬 scanUnfinishedSidechains 就是靠这个判断的:
for (const line of lines) {
const rec = JSON.parse(line);
if (rec.type === "sidechain_start") { agentType = rec.agentType; ... }
else if (rec.type === "message") messageCount++;
else if (rec.type === "sidechain_end") ended = true;
}
// 正常结束的 sidechain 不算未完成,跳过通用原则:一个「结束标记」能不能作为判据,取决于「谁控制生命周期」。
- 程序控制 → 标记可靠(异常路径也在你的 catch 里)
- 用户/外部控制 → 标记不可靠(
SIGKILL不给你机会)同一个设计模式(写个 end 标记),在两个场景下可靠性完全不同。 这不是「哪个实现得好」的问题,是场景性质的差异。
第二条也值得单独说,因为它是个反直觉的优化方向:
主会话加缓冲是为了性能(一轮几十条记录,syscall 太多)。 而子代理写入频率低,缓冲省不下什么开销 —— 但它引入的风险(kill 时丢最后一轮) 是实打实的。
「优化」在收益消失后就只剩代价了。 照抄主会话的缓冲机制到这里, 会得到一个纯负收益的设计。
这类「照抄一个优化,但收益前提不成立」的错误很常见 —— 判据是:先算这个优化在新场景下能省多少,再看它的代价。
12.3 多进程并发:三个目录,三种语义
回到 §4.1 那张布局图。为什么进程层要用两个独立目录,而不塞进会话 JSONL?
因为三层的存储语义根本不同:
| 层 | 语义 | 进程崩溃后该怎样 |
|---|---|---|
对话层(sessions/) | 追加,历史不可改 | ✅ 保留(这就是要恢复的东西) |
文件层(checkpoints/) | 可覆盖(重锚定要改写) | ✅ 保留(还要用来 undo) |
进程层(active-sessions/、file-intents/) | 易失 | ❌ 应该失效(进程都死了,注册还在就是脏数据) |
「应该失效」这个要求,append-only 的文件天生做不到 —— 你写下去的东西就在那儿了,不会因为进程死了自动消失。
所以进程层必须用另一套机制:写一个带 PID 的文件, 用「PID 还在不在」来判断这条记录是否还有效。
🔬 session/concurrent.ts:32-36 有一句很实在的注释:
function sessionsDir(): string {
// 注意:不能用 ~/.sid-code/sessions/(SessionStore 在那里存会话 JSON/JSONL,
// 会和会话浏览器冲突)。活跃会话注册用独立目录。
return sidPaths.activeSessions();
}两种语义的数据不能混在一个目录里 —— 否则会话浏览器扫描 sessions/ 会把「活跃注册文件」也当成会话列出来。
通用原则:目录布局应该按「数据的生命周期语义」切分,而不是按「功能模块」切分。
同一个功能的数据可能有不同的生命周期(会话历史要长期保留、 会话的活跃注册要随进程消失),该拆; 不同功能的数据如果生命周期一致,可以放一起。
12.4 file-intent:一个刻意不做锁的设计
🔬 session/file-intent.ts:1-15:
文件编辑意图声明(并发冲突检测 Phase 1)
每个会话在开始编辑文件时,向共享存储声明意图。
其他会话在 Edit/Write 前检查是否有冲突。
设计决策:
- 按 sessionId 一文件(不是按被编辑文件一文件)——避免锁文件爆炸
- intent 不是 lock——不阻塞其他会话,只用于检测和告警
- 自动过期——lastAccessAt 超过 5 分钟未更新的 intent 视为过期(防止崩溃残留)「intent 不是 lock」是整个设计的核心,值得展开:
| 真正的锁(lock) | 意图声明(intent) | |
|---|---|---|
| 别人来改同一个文件 | 阻塞 / 报错 | 允许,但告警 |
| 持有者崩溃 | 死锁(除非有超时/租约) | 5 分钟自动过期 + PID 探活 |
| 实现复杂度 | 高(要处理死锁、重入、超时续租) | 低 |
| 适合 | 必须互斥的场景 | 人在场、能自己判断的场景 |
为什么 coding agent 该选 intent 而不是 lock?
因为你是文件的主人。你在终端 A 开着 agent 改 auth.ts, 在终端 B 又开一个 agent 也想改 —— 你可能就是故意的 (两个不同的改动方向,你想对比)。
工具阻止你操作自己的文件是越界的。 正确做法是: 告诉你「另一个会话 30 秒前也在改这个文件」,让你自己决定。
🔬 而 conflict-detector.ts 的分级也体现了这个思路:
// 判断严重程度:如果其他会话正在 write/edit,则为 critical;如果只是 read,则为 warning
const severity: ConflictSeverity = hasWriteOrEdit ? "critical" : "warning";以及:
export type ConflictAction = "stop" | "skip" | "continue";三个动作全都保留给用户选 —— 连 critical 都不强制 stop。
双重防过期机制(🔬 file-intent.ts:45 + 162):
① 时间过期:lastAccessAt 超过 5 分钟 → 视为过期
② PID 探活:isProcessAlive(intents.pid) 为 false → 立即清理为什么要两条? 因为各有盲区:
| 只靠时间过期 | 只靠 PID 探活 | |
|---|---|---|
| 进程刚崩溃 | ⚠️ 还要等 5 分钟才失效 | ✅ 立即发现 |
| 进程还活着但卡住了 | ✅ 5 分钟后失效 | ❌ 永远认为有效 |
| PID 被新进程复用 | ✅ 时间会兜底 | ⚠️ 误判为活着 |
两条机制的盲区互补 —— 这又是纵深防御(§8.6、§11.6)。 这个模式在本文出现了三次,可以当成一个通用套路记住: 一个判断,两个独立信号,各自的盲区不重叠。
12.5 一个跨存储的关联问题:trace_session_id
📊 §4.8 那张 metadata 表里有个只出现 1 次的 key:trace_session_id。它是干什么的?
🔬 store.ts:471-476 的注释解释了一个真实的桥接 bug:
「Bug3 桥接:resume 时 SessionStore 续写旧 id 的 jsonl,而 TraceCollector 用本进程 新生成的 id 写
trajectories/sessions/{新id}/(避免跨进程冲突)。 两套存储 sessionId 不一致会导致无法关联。 此处传入本进程 id(traceSessionId),续写时落一条 metadata 记录, 使旧会话 jsonl 能反查到对应的 trajectory 目录。」
问题的形状:resume 时,两套存储对「这个会话叫什么」的答案不一样:
SessionStore: 续写【旧】会话文件 sessions/xxx/20260821-104135.jsonl
TraceCollector: 写【新】目录 trajectories/sessions/20260831-183000/
↑ 本进程新 id两边都有正当理由:
- SessionStore 必须用旧 id(否则历史碎片化成两个文件,§5.6)
- TraceCollector 必须用新 id(否则两个进程往同一个 trace 目录写会冲突)
这不是谁的 bug,是两个正确的决定组合出的问题。
解法:落一条 trace_session_id metadata,在旧文件里记下新 id。 于是从会话文件能反查到 trajectory 目录。
这类问题在分布式/多存储系统里极常见,值得记住它的一般形式:
当同一个逻辑实体在不同存储里有不同的 id 时,必须显式落一条映射关系。 否则两边的数据都对,但关联不起来 —— 而「关联不起来」这件事 通常要到你想排查问题时才发现,那时候已经晚了。
📊 顺带看那个统计:
trace_session_id只出现 1 次(51 个会话)。 这符合预期 —— 只有 resume 过的会话才会有它。 一个 key 出现次数少,不代表它不重要。
12.6 恢复子代理:主会话恢复的一部分
🔬 app.ts 里 resume 流程会扫子代理:
「P2-10:扫描被恢复会话名下未完成的子代理 sidechain(上次被 kill/超时、无
sidechain_end)。 注意用sessionData.id(被恢复会话)——sidechain 文件名按被恢复会话前缀归属。」
「注意用 sessionData.id」这个提醒很重要,它就是 §12.5 那个 id 混淆的具体表现:
sidechain 文件名 = <被恢复会话id>-<agentId>.jsonl
↑ 不是本进程新 id!如果用了本进程的新 id 去扫,一个都找不到 —— 而且不会报错, 只是「没有未完成的子代理」。又是一个静默失效。
面试可以主动抛出这个观察:
「resume 场景下有个反复出现的坑:同一个流程里有两个 sessionId 在流动 (被恢复会话的旧 id、本进程的新 id)。每一处用到 sessionId 的地方 都要想清楚该用哪个 —— 用错不报错,只是静默地找不到东西。 我们的做法是在注释里逐处标明该用哪个,并落一条映射 metadata 打通两者。」
12.7 本章自检
- 子代理的中间对话为什么不进主会话上下文?它被 kill 而无法恢复的代价为什么特别大?
- sidechain 刻意不做 uuid 链、不做写入缓冲。两个决定各自的理由是什么?
- 为什么
sidechain_end可以作为判据,而主会话的session_end不行? - 为什么进程层的注册不能存进会话 JSONL?三层的语义差异是什么?
- 「intent 不是 lock」——为什么 coding agent 该选 intent?
- file-intent 的时间过期和 PID 探活,各自的盲区是什么?
trace_session_id解决的是什么问题?它的一般形式是什么?
§13 十个真实陷阱
这一章是本文面试区分度第二高的部分(第一是 §7)。每一条都不是「理论上可能」, 而是在源码注释里留了修复痕迹,或者我在写这份文档时自己踩到的。
读法建议:每条先看「症状」,自己想一下「为什么会这样」,再看解释。 能自己推出来的说明你已经掌握了前面的章节。
共同特征:⚠️ 十条里有九条不报错。 这是本领域的普遍规律 —— 持久化的 bug 极少以崩溃的形式出现,它们表现为「数据看起来对但其实错了」。
13.1 「代码里有」不等于「运行时会发生」
症状:你按源码写了一个解析器,处理 type: "tool_result" 的记录。 它永远匹配不到任何数据。
成因:§4.6 那个死分支。Role 只有 "user" | "assistant" 两个取值, 所以 store.ts:534-538 的第三个分支类型上不可达。📊 实测 2302 条记录里 tool_result 是 0 条。
判据(这条是本文最通用的方法论):
| 落盘条数 | 有生产调用点吗 | 结论 | 该做什么 |
|---|---|---|---|
| 0 | ❌ 没有 | 死代码 | 删掉,或改注释说明它不可达 |
| 0 | ✅ 有 | 功能正常,只是没被触发 | 构造场景去验证 |
必须回到调用链去分辨。 只看数据,这两种情况长得一模一样。 §4.7 的 context_compact 就是第二种(接线通畅,只是这批会话都不够长)。
13.2 我自己踩的坑:jq 的单流聚合会静默截断
症状:我第一次统计 metadata 分布,得到 98 条。换个方法再数,得到 302 条。 差了 3 倍。
成因:我用的是这条命令:
# ❌ 错的:所有文件 cat 成一个流喂给 jq
find . -name '*.jsonl' -exec cat {} \; | jq -r 'select(.type=="metadata") | .key'51 个文件里有一个文件的第 4 行是 jq 无法解析的。 jq 在那一行报错退出,后面的文件全部没统计。 而我看到的是一个「看起来正常」的输出。
正确做法(逐文件,坏行只影响那一个文件):
# ✅ 对的
for f in $(find . -name '*.jsonl'); do
jq -r 'select(.type=="metadata") | .key' "$f" 2>/dev/null
done | sort | uniq -c | sort -rn这条的价值不在 jq,在于一个通用教训:
统计命令的「静默截断」比「报错」危险得多。 报错你会去修, 截断你会当成结论用。
自查方法:用两种不同的方法数同一个数,对不上就说明有一种是错的。 我就是这么发现的(第二次用
grep -c数,结果不一样)。 本文所有数字都做了这个交叉验证。
13.3 我自己踩的第二个坑:grep 数记录类型会数进嵌套字段
症状:接着上一条,我改用 grep 数:
find . -name '*.jsonl' -exec grep -o '"type":"tool_result"' {} \; | wc -l
# → 11331133 条 tool_result。 可是记录级 jq 数出来是 0。哪个对?
成因:"type":"tool_result" 这个串也出现在 content 块里面:
{"type":"user_message","message":{"content":[{"type":"tool_result",...}]}}
↑ 记录级的 type ↑ 内容块级的 type — grep 分不清grep 是纯文本匹配,它不知道 JSON 的嵌套结构。 那 1133 条数的是内容块,不是记录。
两个数都对,但描述的是完全不同的东西:
| 数字 | 口径 | 含义 |
|---|---|---|
| 0 | 记录级 .type | JSONL 里没有 tool_result 这种行 |
| 1133 | 全文匹配 | 历史里有 1133 个 tool_result 内容块 |
通用原则:数一个结构化字段时,必须用结构化的方式数(jq),不能用 grep。 grep 快,但它会把「同名的嵌套字段」一起数进来。
判据:如果这个字段名可能在多个层级出现,grep 的结果就是不可信的。
13.4 那个「损坏」的文件其实没坏
症状:§13.2 里那个 jq 解析失败的文件,看起来是「崩溃产生的损坏数据」。
真相:我去查了那一行 —— 它是一个完全合法的 JSONL 记录, 只是包含一个孤立代理位(lone surrogate):
jq: parse error: Invalid \uXXXX\uXXXX surrogate pair escape at line 1, column 844用户输入的中文里带了个不完整的 emoji(\ud83d 没有配对的低位代理)。
关键验证(我实际跑的):
JS JSON.parse : ✅ OK (孤立代理位存在,码点 U+D83D)
python json.loads: ✅ OK
jq : ❌ FAILJavaScript 和 Python 都能解析,只有 jq 不行。
所以 sid-code 读这个文件完全没问题 —— 它用的是 JSON.parse。 「损坏」是我的工具的问题,不是数据的问题。
这条的教学价值极高,因为它是一个「差点写进文档的错误结论」:
我一开始准备写「实测 51 个文件里有 1 个损坏,印证了 §7 说的坏行必然存在」。 这个结论是错的 —— 数据没坏,是 jq 的 JSON 严格度比 JS 高。
判据:判断「数据坏了」之前,先换一个解析器试试。 如果生产代码用的解析器能读,那就没坏。 「我的工具读不了」和「数据坏了」是两件事。
(顺带这也是个真实的鲁棒性数据点:📊 51 个真实会话文件、2302 条记录, 用 JS JSON.parse 逐行解析,0 条失败。append-only + 同步写关键记录 在实际使用中确实没产生坏行。)
13.5 长会话的内存尖峰:链式回溯没法只读尾部
症状:会话文件涨到几十 MB 后,--resume 时内存飙升。
成因:§5.7 的代价。🔬 store.ts:1112-1114 承认了:
「注意:这里仍会把所有行收进内存数组——因为链式重建需要按 uuid 回溯, 无法真正做到"只读尾部"(尾行的 parentUuid 可能指向文件任意位置)。」
🔬 缓解措施是流式读取(readJsonlLinesStreaming),但注释说清了它只消除 「巨串」那一份拷贝,行数组本身还在内存里。
📊 当前不是问题:实测 max 975 KB,阈值 4 MB 从未被触发。 但这是个规模依赖的结论 —— 涨到几十 MB 这笔账要重算。
这类「当前无害」的设计债,正确的记录方式是把触发条件写下来: 「当 p95 会话超过 X MB 时,需要重新设计恢复路径(考虑 compact boundary 截断 或分片存储)」。只写「有性能风险」是没用的,要写「什么条件下变成真风险」。
13.6 启动清理会删掉你正要恢复的会话
这条是本章最精彩的一个陷阱,因为它的触发条件极其刻意。
🔬 cli.ts:1183-1186 的注释:
「⚠ 必须传
selfSessionId:清理会rmSync过期的 checkpoint 会话目录, 而本会话自己的registerSession()在下面:2212才跑 —— 这中间本会话不在活跃注册表里。 平时无害,但--resume一个 30 天前的旧会话时会把用户正要恢复的那个会话的 checkpoint 删掉。」
拆解这个 bug 的时序:
时刻 1: 启动,跑「过期清理」(30 天以上的 checkpoint 目录)
→ 此时活跃注册表里【没有】本会话(还没注册)
→ 清理逻辑认为「这个 30 天前的会话没人用」→ rmSync 删掉
时刻 2: registerSession() 才把本会话注册进去
时刻 3: 恢复那个 30 天前的会话
→ 对话历史在(sessions/ 不做过期清理)
→ 但 checkpoint 目录【刚被自己删了】→ /undo 失效为什么这个 bug 特别难发现?
- 只在 resume 30 天以上的旧会话时触发 —— 日常开发几乎不会
- 不报错。会话恢复成功,对话历史完整
- 症状要等到用户敲
/undo才显现,而且他会以为是「旧会话本来就没快照」
根因是一个时序假设:清理逻辑假设「活跃注册表是完整的」, 但本进程恰好在那个瞬间还没注册自己。
通用原则(这条很值得记):
任何「清理不活跃资源」的逻辑,都要问一句:「本进程自己算活跃吗?此刻登记了吗?」
这是一类典型的自指遗漏 —— 你写清理逻辑时想的是「别人的残留」, 忘了自己也在那个集合里,而且自己可能还没登记完。
修法是显式传
selfSessionId排除自己,而不是把注册提前 (提前注册会引入别的时序问题)。
13.7 格式演进:没预留版本号的补救
症状:你改了 JSONL 格式,老文件解析出来的字段是错的(或缺失),但不报错。
成因:老版本没存版本号,你无法判断这个文件是哪个格式。
🔬 sid-code 的处理(§4.3):
1.0 = 旧版全量 JSON
2.0 = JSONL 事件溯源(无链) ← session_start 【没有】 version 字段
3.0 = JSONL + uuid/parentUuid 链 ← 有 version: "3.0"用「字段缺失」本身作为版本信号:读到 session_start 没有 version → 隐含判定为 2.0(🔬 LEGACY_JSONL_VERSION)。
而 §7.5 那个更精巧的判断:用尾行有没有 uuid 决定走链式还是线性解析 —— 因为这个判断发生在解析之前,此时还不知道哪行是 session_start。
两条可迁移的经验:
① 从第一版就存版本号。 成本是一个字段,收益是「将来能安全地改格式」。 ② 已经没存的话,用「某个新字段是否存在」作为版本信号 —— 这是唯一不需要时光机的补救办法。
13.8 stock / flow 混用:算出 444% 的命中率
症状:算缓存命中率,得到 444%。
成因:§4.8 那三个字段的口径差异:
// ❌ 错:分子是累计值(flow),分母是末次快照(stock)
命中率 = cacheReadInputTokens(累加)/ stockPromptTokens(末次)
// ✅ 对:分子分母都用累计值
命中率 = cacheReadInputTokens(累加)/ cumulativePromptTokens(累加)为什么会算出超过 100% 的数:分子是「整个会话累计命中的 token」(几十万), 分母是「最后一次调用的输入」(几万)。用累计量除以瞬时量,结果没有意义。
判据:一个比率的分子分母必须是同一口径。
记忆锚点:stock 是水位(此刻多高),flow 是流量(一共流过多少)。 水位除以流量,得到的不是任何东西。
这个陷阱在成本/用量统计里极其常见,🔬 sid-code 专门在
session/state.ts的字段注释里逐个标了口径(stockPromptTokensvscumulativePromptTokens)—— 在类型定义处标口径,是防这类错的最有效手段, 因为用的时候鼠标一悬停就能看到。
13.9 一个真实的活 bug:适配器少传一个参数
这条是我在写这份文档时顺手发现的,值得作为方法论示范。
症状:📊 我去数 file_changes 记录里的 snapshotId 字段 —— 122 条记录,0 条有它(lastSnapshotId 与 snapshotIds 两个字段都是 0)。
⚠️ 这个数字我自己先踩了一次铁律 ①:我第一版用 jq 扫,数出来是 115; 换生产同款解析器(JS
JSON.parse)是 122 —— 差的 7 条在那个含孤立代理位的 文件里,jq 整个跳过了它(§13.2、§13.4)。所以附录 B.8 那条命令最终用的是 JS,不是 jq。这次两个数字指向同一个结论(0 条带锚点),所以 bug 判断没受影响。 但这是运气 —— 如果那 7 条里恰好有带
snapshotId的, jq 那版就会让我得出「这个功能是好的」这个反的结论。推论(值得单独记):用一个比生产更严格的解析器去证明「某字段不存在」, 是不成立的证明。 「落盘 0 条」这类判断必须用生产同款解析器。
追查过程(三步):
① 数据层:122 条 file_changes,snapshotId 出现 0 次
② 生产侧:tool-executor.ts:472
deps.recordFileChanges?.(affectedFiles, toolNames, snapshotId || undefined);
← 三个参数,snapshotId 确实传了
③ 消费侧:app.ts:4689
recordFileChanges: (files, toolName) => this.recordFileChanges(files, toolName),
← 【只声明了两个参数,第三个被丢掉了】而 🔬 app.ts:4720 的实现明明支持三个参数:
private recordFileChanges(files: string[], toolName: string, snapshotId?: string): void {
// ...
this.sessionStore.appendMetadata("file_changes", {
files: [...this.changedFiles],
lastTool: toolName,
count: this.changedFiles.size,
...(snapshotId ? { lastSnapshotId: snapshotId } : {}), // ← 永远不执行
...(this.changedFileSnapshotIds.length > 0
? { snapshotIds: [...this.changedFileSnapshotIds] } : {}), // ← 永远为空
});
}中间那个适配器箭头函数是个 2 参数函数,赋给了一个 3 参数的回调类型。 TypeScript 不会报错 —— 参数少的函数赋给参数多的函数类型是合法的 (这是 TS 刻意的设计,为了让 arr.map(x => x) 这类写法能用)。
后果:🔬 tool-executor.ts:470 那句注释描述的能力没有生效:
「拿不到「这批改动对应哪个快照」,跨会话无法把文件集反查回可回退的快照。」
也就是说:file_changes 记录了「改过哪些文件」,但丢了「对应哪个快照」。 恢复后想「把这批文件回退到改动前」,找不到锚点。
⚠️ 我没有修这个 bug(这份文档的任务是写文档,改代码是另一件事), 也没有全面验证它的所有后果 —— 但数据链条是清楚的: 生产侧传了,适配器丢了,落盘 0 条。
这条的方法论价值在于那个追查套路,它可以直接复用:
① 先看数据:这个字段落盘了几条? ← 0 条就有问题
② 再看生产侧:有人传值吗? ← 有 → 不是「没实现」
③ 再看中间层:每一跳都把值传下去了吗? ← 这里最容易漏
④ 最后看消费侧:拿到值以后用了吗?「一个字段落盘 0 条」是最廉价的 bug 探测器 —— 📊 一条 jq 命令就能扫全部会话,而且它探到的是类型系统抓不到的那类错误。
并且注意它和 §13.1 的区别 —— 三种「0 条」,三种结论:
tool_result | context_compact | snapshotId | |
|---|---|---|---|
| 落盘 | 0 条 | 0 条 | 0 条 |
| 有生产调用点 | ❌ 类型不可达 | ✅ 通 | ✅ 通 |
| 中间层传值 | —— | ✅ 通 | ❌ 断了 |
| 结论 | 死代码 | 正常,未触发 | 活 bug |
三个一样的现象,三个完全不同的结论。 这就是为什么 §13.1 那个判据要 「回到调用链」——而且要逐跳看,不能只看两端。
13.10 保留窗口与 UI 可见范围不一致
症状:/checkpoints 列出 10 条快照,用户选第 8 条 → 回滚失败。
成因:淘汰策略只保留了 5 条,但列表还在显示 10 条。
🔬 sid-code 的防法(§9.6):
const MIN_KEEP = 11; // /checkpoints 显示最近 10 条 + /undo 最近 1 条这个数字是从 UI 反推的。 两个模块(UI 显示范围、淘汰保留下限) 各自都「正确」,合起来才出错。
同类的还有 §10.6 那对:MAX_REWIND_POINTS = 30 vs MIN_KEEP = 11 —— 这两个数确实不一致,30 个回退点可能引用已被淘汰的快照。 sid-code 的处理不是把数字对齐,而是在使用点优雅降级 (restoreToSnapshot 返回 null → fileRestoreSkipped = true → 如实告诉用户)。
两种修法,各有适用场景:
修法 什么时候用 对齐数字( MIN_KEEP≥ UI 显示条数)两个数字都在你控制下,且能推出确定关系 使用点降级(引用失效时如实上报) 数字关系无法保证(如配额可配置、用户可改) 注意 sid-code 两种都做了 —— 这不矛盾:对齐是第一道防线,降级是兜底。 又是纵深防御(本文第四次出现这个模式)。
13.11 十条速查表
| # | 陷阱 | 症状 | 判据 / 修法 |
|---|---|---|---|
| 1 | 代码里有≠运行时会发生 | 解析器永远匹配不到 | 回到调用链:有生产调用点吗 |
| 2 | jq 单流聚合静默截断 | 数字偏小 3 倍 | 逐文件跑;两种方法交叉验证 |
| 3 | grep 数嵌套字段 | 数字偏大 | 结构化字段必须用 jq 数 |
| 4 | 把「工具读不了」当「数据坏了」 | 误判损坏率 | 换个解析器试;看生产代码用的那个 |
| 5 | 链式回溯无法只读尾部 | 大文件内存尖峰 | 记录触发条件,不只记录「有风险」 |
| 6 | 启动清理删掉自己要恢复的会话 | /undo 失效,不报错 | 清理逻辑必须排除自己 |
| 7 | 格式演进无版本号 | 静默解析错误 | 从第一版就存;已晚则用字段缺失当信号 |
| 8 | stock/flow 混用 | 命中率 444% | 分子分母同口径;在类型定义处标注 |
| 9 | 适配器少传参数 | 字段落盘 0 条,TS 不报错 | 逐跳检查传参链 |
| 10 | 保留窗口 < UI 可见范围 | 列表里有但点不了 | 对齐数字 + 使用点降级 |
⚠️ 十条里有九条不报错(唯一会报错的是第 8 条那种明显离谱的数字, 如果你恰好去看了的话)。
这不是巧合,是这个领域的本质:持久化的正确性无法靠「程序有没有崩」来验证, 只能靠去数据里数。所以附录 B 那些复跑命令不是附属品, 它们是这个领域唯一的验证手段。
§15 动手:五阶段路线图
看懂和做出来是两件事。这一章给一条能真的跑起来的路径。
为什么要动手:§13 那十个陷阱里,有九个只有自己踩过才记得住。 而且面试里 Q19(「讲一个你踩过的不报错的 bug」)只能靠亲身经历回答。
建议节奏:阶段 1–2 一个晚上,阶段 3 一个周末,阶段 4–5 按需。 每个阶段都是可用的,不要等全做完才跑。
阶段 1:能存能读(约 50 行)
目标:进程 kill 掉重启,对话还在。
做什么:
- 每条消息 append 一行 JSON 到
session.jsonl - 启动时读回来,坏行跳过(
try/catch里continue) - 跑起来,
kill -9,重启,验证历史还在
验收标准:
# 故意在文件末尾追加半行,验证仍能恢复
echo -n '{"role":"user","cont' >> session.jsonl
# 重启 → 应该正常恢复前面所有消息,只是忽略最后那半行这一步的关键领悟:如果你没写那个 try/catch,这一步就会失败 —— 数据完好但恢复功能失效。§2.2 说的「崩溃安全性一半在读侧」, 自己撞一次比读十遍都记得牢。
阶段 2:加元数据与中断检测(约 +80 行)
目标:恢复后 agent 知道「上次进行到哪」。
做什么:
- 加
session_start(含version字段 —— 从第一版就存,见 §13.7) - 加
metadata记录(覆盖式语义:同 key 取最后一条) - 实现三态中断检测(§11.2):看末尾一条消息的形状
- 三态各自注入不同的续接提示
验收标准(三种中断各造一次):
| 怎么造 | 期望判定 |
|---|---|
| 正常聊完一轮再退出 | none |
输入问题后立刻 kill -9(模型还没回复) | interrupted_prompt |
工具执行完、模型回复前 kill -9 | interrupted_turn |
第三种最难造,可以在工具执行完之后加一个 await sleep(10000), 然后在这 10 秒里 kill。
这一步的关键领悟:你会发现自己想加一个 lastStep: 7 字段来记进度, 然后发现不需要 —— 末尾一条消息的形状已经编码了这个信息。 加了反而会有两个真相不一致的风险(§11.2)。
阶段 3:文件层 checkpoint(约 +150 行)
目标:实现 /undo。
做什么:
- 工具执行前,把要改的文件的当前内容存进
checkpoints/<session>/index.json - 第一次存 full,后续存 diff(diff 算法可以先用最朴素的整行 LCS)
- 实现
undo():找到最近的 full 基点,往前 apply diff,写回文件 - 加淘汰,然后故意踩一次那个坑
关键练习(这一步最有价值的部分):
先写「直接删最旧的」,然后验证它是错的:
# 造一个 full + 3 个 diff 的链
# 然后触发淘汰删掉最旧的(full)
# 再试 undo → 应该失败亲手看到「淘汰不报错,但 undo 失效了」这个失效形态, 然后再去实现重锚定(§9.6)。这个顺序很重要 —— 先踩坑再修,你会永远记得为什么需要重锚定。
验收标准:
① undo 能还原文件内容(逐字节对比)
② 淘汰掉 full 基点后,undo 仍然能工作(重锚定生效)
③ 重锚定失败时,停止淘汰而不是继续删(宁可超配额)阶段 4:parentUuid 链与并发(约 +100 行)
目标:抗多进程交叉写入。
做什么:
- 给每条记录加
uuid/parentUuid - 恢复改为从物理末行沿
parentUuid回溯 - 加环检测(
seenUuids) - 实现 resume 续写:读旧文件末行 uuid 作为新记录的
lastUuid
关键练习一:亲手造出「语义错乱的历史」
# 开两个终端,同时 resume 同一个会话 id,各聊几轮
# ① 先用【按行读】恢复 → 你会看到两段对话被拼在一起
# ② 再用【沿链回溯】恢复 → 只拿到一条完整对话看到第 ① 步那份混合历史长什么样,是理解 §5 的最快方式。 注意它不报错 —— 这就是「静默语义损坏」的手感。
关键练习二:故意漏掉 resume 接链
把「读旧文件末行 uuid」那一步注释掉,然后 resume。 你会看到 agent 完全不记得之前的对话 —— 而程序一切正常、没有任何报错。 这是 §5.6 和 Q8 那个 bug 的真实手感。
关键练习三(可选,验证 §7.5b):
试试并行工具调用的结果是「合并成一条记录」还是「散成多条」。
- 合并 → 链始终单线,DAG 问题不存在
- 散开 → 你需要写恢复补救逻辑
📊 sid-code 是合并的(实测最多一条装 8 个 tool_result 块)。 这个差异决定了你后面要不要写那 100 行补救代码。
阶段 5:验证与压测(不写新功能,只验证)
目标:确认前四阶段真的对,而不是「看起来对」。
这一步不写新功能,但它是最能提升可信度的一步 —— §13 说了,这个领域的 bug 极少以崩溃形式出现。
① 结构一致性自查
# 每个文件恰好一个链头
[ "$(grep -c '"type":"session_start"' *.jsonl | ...)" = "文件数" ]
# parentUuid=null 的行每文件恰好 1 条
# 每条记录都有 uuid② 「落盘 0 条」扫描(§13.9 那个最廉价的 bug 探测器)
把每个你以为会被写入的字段扫一遍。0 条的逐个回到调用链分辨:
死代码(无生产调用点) / 没触发(有调用点但场景没出现) / 活 bug(中间层断了)③ 混沌测试:随机时刻 kill
for i in $(seq 1 50); do
启动 agent 跑一个固定任务 &
sleep $((RANDOM % 30))
kill -9 %1
resume → 检查:历史完整?配对完整?中断状态判对?
done50 次随机 kill 是这一步的核心。 单次手动测试碰不到那些刁钻时序 (比如「刚好在 flush 定时器触发前」、「刚好在写关键记录时」)。
④ 补验那些「代码在但没被触发」的路径
📊 sid-code 的流式读取阈值是 4 MB,而实测 max 只有 975 KB —— 这条路径从未被触发过。想验证它,得专门造一个超大会话文件:
# 造一个超过阈值的会话文件,验证流式路径真的走通
# 否则「有流式读取」和「流式读取被验证过」是两件事同类要补验的:重锚定路径、环检测路径、旧格式兜底路径。 这些都是低频但关键的代码 —— 平时不走,出事时才走, 所以必须专门造场景验证。
五阶段与章节对照
| 阶段 | 对应章节 | 产出 | 会踩到的坑 |
|---|---|---|---|
| 1 | §2 | 能存能读 | 忘了容忍坏行 |
| 2 | §4、§8、§11 | 元数据 + 中断检测 | 想加多余的进度字段 |
| 3 | §9 | /undo | 直接删最旧 → 切链 |
| 4 | §5、§7 | 抗并发 + 清洗 | resume 忘接链尾 |
| 5 | §13、附录 B | 验证 | 发现前四阶段的静默 bug |
做完这五阶段,§14 的 Q19(「讲一个你踩过的不报错的 bug」)你就有真实素材了 —— 而这道题是整个题库里最难靠背答案糊过去的一道。
附录 A · 术语速查(中英对照)
按字母序,方便查。每个词后面标了它在哪一章展开。
| 英文 | 中文 | 一句话 | 章节 |
|---|---|---|---|
| Append-only | 只追加 | 只往末尾加,永不修改已写内容 | §2 |
| At-least-once | 至少一次 | 可能重复,但不会丢 | §0.4 |
| Checkpoint | 检查点 | ⚠️ 两个意思:框架圈=对话状态;agent 圈=文件快照 | §0.2 |
| Compact boundary | 压缩边界 | 上下文压缩发生的位置标记 | §5.7 |
| Continue-As-New | 带状态重开 | Temporal 截断过长 Event History 的手法 | §3.2 |
| Dangling tool_result | 游离工具结果 | 有结果没请求(截断切错造成的) | §7.2 |
| Determinism | 确定性 | 同输入同输出。LLM 不满足 | §3.2 |
| Durable Execution | 持久化执行 | 让函数生命周期长于进程生命周期 | §3.2 |
| Event sourcing | 事件溯源 | 存「发生了什么」而非「现在是什么」 | §3.2 |
| Exactly-once | 恰好一次 | 副作用不重不漏。持久化的真正目标 | §1.5 |
| File intent | 文件意图 | 「我在改这个文件」的声明。不是锁 | §12.4 |
| Flow / Stock | 流量 / 存量 | 累计值 / 末次快照值。混用算出 444% | §13.8 |
| Fork | 分叉 | 拷历史开新会话,原会话不动 | §5.5 |
| Idempotency key | 幂等键 | ⚠️ 必须从状态派生,不能用 uuid4 | §11.4 |
| JSONL | 每行一 JSON | 能增量追加(JSON 数组不能) | §2.2 |
| Lone surrogate | 孤立代理位 | 不完整的 emoji 编码。jq 报错但 JS 能读 | §13.4 |
| Orphan tool_use | 孤儿工具调用 | 有请求没结果。OpenAI 400 直接成因 | §7.2 |
| parentUuid chain | 父链 | 每条记录指向前一条,抗并发交叉 | §5 |
| Reanchor | 重锚定 | 删 full 基点前,先把下个 diff 重建成 full | §9.6 |
| Replay | 重放 | 把事件流从头跑一遍重建状态 | §3.2 |
| Resume / Continue | 恢复 / 续接 | 按 id 恢复 / 恢复最近一个 | §0.3 |
| Rewind | 回退 | 往回退若干轮,丢弃后面的 | §10 |
| Rewind point | 回退点 | 同时记「对话第几条」+「文件哪个快照」 | §10.2 |
| Semantic rollback | 语义回滚 | 恢复时重新生成随机幂等键 → 副作用重复 | §11.4 |
| Sidechain | 子链 | 子代理的独立对话记录文件 | §12.1 |
| Snapshot | 快照 | 某一时刻的全量状态拷贝 | §0.2 |
| Time travel | 时间旅行 | 能跳到历史上任意一点 | §0.3 |
| WAL | 预写日志 | 先记意图再执行,崩溃后重放 | §3.3 |
附录 B · 可复跑命令(每条都实跑过)
这个附录是本文最实用的部分。 因为 §13 已经证明: 持久化的正确性无法靠「程序有没有崩」验证,只能去数据里数。
所有命令都在 ~/.sid-code/sessions/ 或 ~/.sid-code/checkpoints/ 下执行, 每条都在 2026-08-31 实跑过,输出附在下面。
B.0 先记住三条计数铁律(每条都是我踩出来的)
这三条不是通用建议,是 §13.2–13.4 那三个坑的直接产物:
① 绝不用「find -exec cat | jq」单流聚合
→ 一个坏行会让 jq 报错退出,后面的文件【静默】不统计(我算出 98 vs 真实 302)
→ 正确姿势:for 循环逐文件跑,坏行只影响那一个文件
② 数结构化字段绝不用 grep
→ grep 是纯文本匹配,会把【嵌套层级的同名字段】一起数进来
→ 我用 grep 数出 1133 个 tool_result,记录级真实值是 0(那 1133 个是 content 块)
③ 同一个数字必须用两种方法交叉验证
→ 对不上就说明有一种是错的。本文所有数字都做了这一步
→ 「判断数据坏了」之前先换个解析器:jq 读不了 ≠ 数据坏了(§13.4)B.1 先确认数据存在(规模概览)
cd ~/.sid-code/sessions
# 会话文件数
find . -name '*.jsonl' | wc -l | tr -d ' '
# 实测输出:51
# 大小分布(长尾分布,必须看分位不能看均值)
find . -name '*.jsonl' -exec wc -c {} \; | awk '{print $1}' | sort -n \
| awk '{a[NR]=$1} END{printf "n=%d min=%d p50=%d p95=%d max=%d\n", NR, a[1], a[int(NR*0.5)], a[int(NR*0.95)], a[NR]}'
# 实测输出:n=51 min=661 p50=76576 p95=626105 max=974713读法:p50 = 76 KB,p95 = 626 KB(8 倍),max = 975 KB。 典型长尾 —— 这个形状解释了 §4.10 为什么阈值定 4 MB(远高于 max,是安全阀而非常态路径)。
B.2 ★ 结构一致性自查(最值得抄的两条)
这两条是最廉价的正确性验证,一秒跑完,能抓住 §5.6 那个「resume 断链」的 bug。
# ① session_start 条数 应该 == 会话文件数(每个文件恰好一个链头)
files=$(find . -name '*.jsonl' | wc -l | tr -d ' ')
starts=$(find . -name '*.jsonl' -exec grep -c '"type":"session_start"' {} \; | awk '{s+=$1} END{print s}')
echo "文件数=$files session_start=$starts 一致=$([ "$files" = "$starts" ] && echo YES || echo NO)"
# 实测输出:文件数=51 session_start=51 一致=YES不一致意味着什么(两个方向都是 bug):
starts>files→ 某个文件有多个链头 → resume 时重复写了session_startstarts<files→ 某个文件没有链头 → 文件损坏或写入中断在第一行
# ② 每个文件的 parentUuid=null 应该恰好 1 条
for f in $(find . -name '*.jsonl'); do
n=$(grep -c '"parentUuid":null' "$f")
[ "$n" != "1" ] && echo "异常 $f = $n"
done
# 实测输出:(无输出 = 全部文件恰好 1 个链头 ✓)⚠️ 这条用了
grep,看似违反铁律 ②。这里可以用,因为parentUuid只在记录顶层出现,没有嵌套同名字段。 铁律 ② 的判据是「这个字段名会不会在多个层级出现」,不是「一律禁用 grep」。
B.3 记录类型分布(演示铁律 ①)
# ✅ 正确:逐文件 jq
for f in $(find . -name '*.jsonl'); do jq -r '.type' "$f" 2>/dev/null; done \
| sort | uniq -c | sort -rn
# 实测输出:
# 957 user_message
# 935 assistant_message
# 292 metadata
# 51 session_start
# 17 session_end# ❌ 错误对照:单流 jq(一个坏行导致后续全部静默丢失)
find . -name '*.jsonl' -exec cat {} \; | jq -r '.type' 2>/dev/null | sort | uniq -c
# 实测:metadata 只数出 98(真实 302),差 3 倍把这两条并排跑一次,是理解铁律 ① 最快的方式。
B.4 权威计数(用生产代码同款解析器)
当 jq 和 grep 结论冲突时,用 JS 仲裁 —— 因为 sid-code 自己用的就是 JSON.parse:
cd ~/.sid-code/sessions && bun -e '
const {readdirSync,statSync,readFileSync}=require("fs"),{join}=require("path");
const F=[];(function w(d){for(const e of readdirSync(d)){const p=join(d,e);
statSync(p).isDirectory()?w(p):e.endsWith(".jsonl")&&F.push(p)}})(".");
const rec={},meta={};let L=0,B=0;
for(const f of F)for(const l of readFileSync(f,"utf-8").split("\n")){
if(!l.trim())continue;L++;
try{const o=JSON.parse(l);rec[o.type]=(rec[o.type]||0)+1;
if(o.type==="metadata")meta[o.key]=(meta[o.key]||0)+1}catch{B++}}
console.log(`文件=${F.length} 总行=${L} JS解析失败=${B}`);
console.log("记录类型:",rec);console.log("metadata键:",meta);'实测输出:
文件=51 总行=2302 JS解析失败=0
记录类型: { session_start: 51, user_message: 977, assistant_message: 955,
metadata: 302, session_end: 17 }
metadata键: { agent_setting: 22, file_changes: 122, usage_stats: 53,
side_call_stats: 50, todo_state: 53, goal_state: 1, trace_session_id: 1 }⚠️ 注意
user_message三个数字都不同:957(jq)/ 977(JS)/ 1133(grep 数 tool_result)。
- 977 是对的(JS = 生产代码同款解析器)
- 957 是 jq 少数的(那一个含孤立代理位的文件被跳过了整个文件的部分行)
- 1133 数的根本不是记录(是 content 块,见 B.6)
三个数字,一个真相。 这就是为什么铁律 ③ 要求交叉验证。
B.5 坏行检测(并复核「是真坏还是工具读不了」)
# ① 先用 JS 数:生产代码能读几行
cd ~/.sid-code/sessions && bun -e '
const {readdirSync,statSync,readFileSync}=require("fs"),{join}=require("path");
const F=[];(function w(d){for(const e of readdirSync(d)){const p=join(d,e);
statSync(p).isDirectory()?w(p):e.endsWith(".jsonl")&&F.push(p)}})(".");
let L=0,B=0;for(const f of F)for(const l of readFileSync(f,"utf-8").split("\n")){
if(!l.trim())continue;L++;try{JSON.parse(l)}catch{B++}}
console.log(`总行=${L} JS解析失败=${B}`)'
# 实测输出:总行=2302 JS解析失败=0# ② 再用 jq 逐行数,找出「jq 读不了但 JS 能读」的行
for f in $(find . -name '*.jsonl'); do
n=0
while IFS= read -r line; do
n=$((n+1)); [ -z "$line" ] && continue
printf '%s' "$line" | jq -e . >/dev/null 2>&1 || echo "jq读不了: $f 行$n"
done < "$f"
done
# 实测输出:jq读不了: ./Users-...-iam-studio-fe/20260824-160354-ecf259b8.jsonl 行4两个结果放在一起读(这是本附录最重要的一处推理):
JS 解析失败 = 0 ← 生产代码读得了
jq 解析失败 = 1 行 ← 只有 jq 读不了
→ 结论:数据【没有坏】,是 jq 的 JSON 严格度比 JS 高(孤立代理位 U+D83D)如果只跑第 ② 条,就会得出「实测有 1 个文件损坏」这个错误结论 —— 我一开始差点这么写(§13.4)。
顺带这是个真实的鲁棒性数据点:51 个真实会话、2302 条记录, 用生产解析器0 条失败。append-only + 关键记录同步写,实际使用中确实没产生坏行。
B.6 演示铁律 ②:为什么不能用 grep 数嵌套字段
# ❌ grep 数 tool_result
find . -name '*.jsonl' -exec grep -o '"type":"tool_result"' {} \; | wc -l
# 实测输出:1133
# ✅ 只数【行首】的(记录级)
find . -name '*.jsonl' -exec grep -o '^{"type":"tool_result"' {} \; | wc -l
# 实测输出:0两个数都对,但口径完全不同:1133 个是 content 数组里的内容块, 记录级一条都没有(§4.6 那个死分支)。
B.7 ★ 验证「并行工具结果是合并还是散开」
这条回答 §7.5b 那个 DAG 问题 —— 决定你要不要写 100 行恢复补救代码:
cd ~/.sid-code/sessions && bun -e '
const {readdirSync,statSync,readFileSync}=require("fs"),{join}=require("path");
const F=[];(function w(d){for(const e of readdirSync(d)){const p=join(d,e);
statSync(p).isDirectory()?w(p):e.endsWith(".jsonl")&&F.push(p)}})(".");
const dist={};let tot=0,multi=0,maxN=0;
for(const f of F)for(const l of readFileSync(f,"utf-8").split("\n")){
if(!l.trim())continue;
try{const o=JSON.parse(l);if(o.type!=="user_message")continue;
const c=o.message?.content;if(!Array.isArray(c))continue;
const n=c.filter(b=>b.type==="tool_result").length;if(!n)continue;
tot++;dist[n]=(dist[n]||0)+1;if(n>1)multi++;maxN=Math.max(maxN,n)}catch{}}
console.log(`含tool_result的记录=${tot} 含多个的=${multi} 单条最多=${maxN}`);
console.log("每条里的块数分布:",dist)'实测输出:
含tool_result的记录=900 含多个的=165 单条最多=8
每条里的块数分布: { "1": 735, "2": 123, "3": 26, "4": 11, "5": 2, "6": 2, "8": 1 }读法:165 条记录装了多个 tool_result(最多 8 个)→ 并行调用的结果被合并进同一条记录 → 链始终单线,DAG 问题不存在。
B.8 「落盘 0 条」扫描(§13.9 那个 bug 探测器)
用 JS(生产同款解析器)扫,不用 jq —— 理由见下方的对照:
cd ~/.sid-code/sessions && bun -e '
const {readdirSync,statSync,readFileSync}=require("fs"),{join}=require("path");
const F=[];(function w(d){for(const e of readdirSync(d)){const p=join(d,e);
statSync(p).isDirectory()?w(p):e.endsWith(".jsonl")&&F.push(p)}})(".");
let tot=0,hasSnap=0,hasIds=0;
for(const f of F)for(const l of readFileSync(f,"utf-8").split("\n")){
if(!l.trim())continue;
try{const o=JSON.parse(l);
if(o.type!=="metadata"||o.key!=="file_changes")continue;
tot++;
if(o.value?.lastSnapshotId)hasSnap++;
if(o.value?.snapshotIds)hasIds++}catch{}}
console.log(`file_changes 总条数=${tot} 带 lastSnapshotId=${hasSnap} 带 snapshotIds=${hasIds}`)'
# 实测输出:file_changes 总条数=122 带 lastSnapshotId=0 带 snapshotIds=0⚠️ 为什么这里不用 jq —— 我自己踩的现场: 我第一版用 jq 逐文件扫,得到 0 / 115;JS 权威计数是 0 / 122。 差的 7 条在那个含孤立代理位的文件里,jq 整个跳过了它(§13.2、§13.4)。
这次两个数字指向同一个结论(都是 0 条带锚点),所以 bug 判断没受影响。 但这是运气 —— 如果那 7 条里恰好有带
snapshotId的, jq 那版就会让我得出「这个功能是好的」这个反的结论。所以「落盘 0 条」这类判断必须用生产同款解析器。 用一个比生产更严格的解析器去证明「某字段不存在」,是不成立的证明。
0/122 → 回到调用链逐跳查(§13.9 那个套路):
数据层:0 条
生产侧 tool-executor.ts:472 → 传了 3 个参数 ✅
中间层 app.ts:4689 → 箭头函数只声明 2 个参数 ❌ 【断在这里】
消费侧 app.ts:4720 → 实现支持 3 个参数 ✅这个套路可以直接套用到任何字段 —— 把「你以为会被写入的字段」逐个扫一遍。
B.9 Checkpoint 层统计
cd ~/.sid-code/checkpoints && bun -e '
const {readdirSync,readFileSync,statSync}=require("fs"),{join}=require("path");
let tot=0,snaps=0,full=0,diff=0,gz=0,maxSnap=0,n=0;
for(const d of readdirSync(".")){try{
const j=JSON.parse(readFileSync(join(d,"index.json"),"utf-8"));n++;
tot+=statSync(join(d,"index.json")).size;
const ss=j.snapshots||[];snaps+=ss.length;maxSnap=Math.max(maxSnap,ss.length);
for(const s of ss)for(const f of s.files||[]){
if(f.type==="full"){full++;if(f.compressed)gz++}else diff++}}catch{}}
console.log(`索引=${n} 总字节=${(tot/1048576).toFixed(1)}MB 快照=${snaps} 单会话最多=${maxSnap}`);
console.log(`full=${full}(压缩${gz}) diff=${diff} diff占比=${(diff/(full+diff)*100).toFixed(1)}%`)'实测输出:
索引=109 总字节=12.6MB 快照=519 单会话最多=91
full=281(压缩143) diff=256 diff占比=47.7%三个读法:
- 12.6 MB > 会话历史的 7.7 MB —— 文件快照比对话更占空间(§4.1 那个反直觉比例)
- diff 占 47.7% —— 增量存储确实在起作用(全存 full 体积大概翻倍)
- 281 个 full 里 143 个压缩 —— 1 KB 阈值把源码文件和小配置分开了(§9.4)
B.10 一致性交叉检查:两层存储对得上吗
# 会话数 vs checkpoint 索引数
s=$(find ~/.sid-code/sessions -name '*.jsonl' | wc -l | tr -d ' ')
c=$(find ~/.sid-code/checkpoints -name 'index.json' | wc -l | tr -d ' ')
echo "会话=$s checkpoint索引=$c"
# 实测输出:会话=51 checkpoint索引=109⚠️ 109 > 51,这个不等式本身就是信息,而且不是 bug。三个可能的原因:
| 原因 | 说明 |
|---|---|
| 会话被清理了,checkpoint 还在 | 两层的保留策略不同(sessions 不做过期清理,checkpoints 30 天清) |
| 只读会话不产生 checkpoint | 没改文件就没快照 → 反方向也可能出现 |
| §13.6 那个清理时序 | resume 旧会话时 checkpoint 可能被误删 |
这条演示了一个重要的排查纪律:
「两个数对不上」的第一反应不该是「有 bug」,而是先问「它们的分母口径一样吗」。 这两个集合不是包含关系 —— 各有各的生命周期策略。 所以「checkpoint 覆盖率 = 51/109」这个说法本身是没有意义的, 必须先说清「分母是哪一个集合、它为什么是那个大小」。
一页总结(可以贴在显示器上)
■ 目标
不是「保存进度」,是「保证副作用恰好执行一次」
因为 LLM 不确定 → 重跑不是重放,是另一次探索
■ 三层存储(按生命周期语义切分,不按功能模块)
对话层 sessions/*.jsonl append-only,永不改 → 崩溃后保留
文件层 checkpoints/index.json 可覆盖(要重锚定) → 崩溃后保留
进程层 active-sessions/、file-intents/ 易失 → 崩溃后应失效
■ 四条核心原则
① 写入时简单,读取时智能(把 bug 赶到不会造成永久损失的一侧)
② 合法性 > 完整性(宁可丢几条历史,不能让 API 400)
③ 在错误的层修 bug 比不修更糟(出口只报警落盘,不偷偷补数据)
④ 把不该发生的事做成物理上不可能(分目录,而不是加 if)
■ 两个关键机制及其代价
parentUuid 链 → 抗多进程交叉;代价:没法只读尾部
full+diff 链 → 省空间;代价:淘汰必须先重锚定,否则静默切链
■ 恢复三态(不靠 session_end,实测只有 33% 有)
末尾 assistant → 正常
末尾 user 纯文本 → 用户问了没答(把提问摘下来重挂末尾)
末尾 user 带 tool_result → 工具跑完没回复(提示别重复调用)
■ 十个陷阱里九个不报错
→ 所以唯一的验证手段是【去数据里数】(附录 B)
→ 三条计数铁律:逐文件跑 / 别用 grep 数嵌套字段 / 两种方法交叉验证
■ 面试回答模板
① 给矛盾 → ② 给具体失效场景 → ③ 给机制 → ④【主动交代代价和前提】
↑ 大多数人不会做这步,它显示判断力最后一句:这份文档里所有具体数字都是 2026-08-31 一台机器上的快照。 引用前先用附录 B 复跑。 数据会漂移,分母会变 —— 而「知道一个数字什么时候会失效」比「记住那个数字」重要得多。