id 和 parentId——所以它物理上是日志、逻辑上是树。你看到的对话,只是从某个叶子往根走的一条路径。这一个设计决定同时解释了三件事:
/tree 能来回跳。切分支就是换一个叶子指针,不需要改写任何已有数据。要小心的是:「会话文件里有」不等于「模型看得到」。文件是全树,上下文是一条路径,而路径还要再过 第 05 章 的两道闸。
export interface EntryBase {
type: string;
id: string;
seq: number; // shared sequence; read-side, storage-assigned
parentId: string | null; // storage-assigned: the appending lane's leaf
timestamp: number; // Unix ms, storage-assigned
}
seq、parentId、timestamp 都由存储层填,调用方给不了。parentId 取「当前 lane 的叶子」——树结构因此是追加的副产品,不需要调用方维护。树的形状不由任何人显式声明:你在哪个叶子上追加,新 entry 的父就是谁。types.ts:18 分叉发生在「把叶子指针移回中间某个 entry,然后继续追加」的那一刻。
CLI 写的是 v3:session-manager.ts:30
新的 harness 栈写的是 v4,头部结构完全不同——多了 parentSessionId,并保留一个专为读 v3 而生的兼容字段:jsonl/types.ts:47–57
export interface JsonlV4Header {
kind: "header";
version: 4;
id: string;
createdAt: number;
cwd: string;
parentSessionId?: string;
/** Preserved only when a v3 parent path could not be resolved to a session id. */
legacyParentSessionPath?: string;
metadata?: Record<string, JsonValue>;
}
这不是历史遗留,是正在进行的迁移的当前状态。harness-v2 设计文档把「旧 v3 会话必须能打开并恢复到 idle」列为唯一的向后兼容要求,其余格式和 API 全部允许破坏。harness-v2.md:3 完整判定见 第 08 章。
JSONL 编解码器维护两个类型集合,一个是对话内容,一个是执行痕迹:codec.ts:7 codec.ts:16
| entry(7 种) | record(9 种) | |
|---|---|---|
| 是什么 | 对话本身:消息、模型切换、压缩点、分支摘要 | 执行过程:操作起止、步骤尝试、工具启动、队列变更、用量 |
| 给谁看 | 模型 + 用户 + 回放 | 崩溃恢复 |
| v3 里有吗 | 有(对应 7 种 entry 的大部分) | 没有——这是 v4 新增的durability 底座 |
record 的存在就是「durable run」的物理基础:operation_started 没有配对的 operation_finished,就说明进程死在中间。存储接口专门为此提供了 findOpenOperations,注释把恢复语义写得很死:0 条 = idle,1 条 = 挂起,2 条 = 损坏。types.ts:317
存储层有三个实现——内存、JSONL、SQLite——它们跑同一个一致性测试套件:conformance.ts:92
| 后端 | 测试入口 |
|---|---|
| memory | memory.test.ts:8 |
| JSONL | jsonl.test.ts:79 |
| SQLite | sqlite-node/test/conformance.test.ts:24 |
SQLite 后端被刻意拆成独立包,理由写在 agent 包的 README 里:核心包不该默认拖进运行时内建模块或原生 SQLite 依赖,后端接受一个「运行时特定的 SQLite 工厂」,从而允许未来的其它后端各自成包。agent/README.md:13
锚点之后「两个版本并存」在 v1.0.0 结束了,结束的方式是 v4 那一侧被删除:7fd478a(2026-10-01) 随 harness 一起删掉了 packages/agent/src/harness/session/ 与 packages/session-backends。CLI 在 v1.2.0 上写的仍是 v3:session-manager.ts:41。「多后端先写一致性测试」这条做法在 experimental 的 pi-durable 里还能看到:它有内存、JSONL、SQLite(含 Cloudflare Durable Object 的 SQLite 变体)三类存储,自定义后端可以跑同一套共享测试,README.md:676。本书没有核对它与这里 v4 格式的关系。
export type ForkOptions = { scope?: "branch"; entryId?: string; position?: "before" | "at" } | { scope: "tree" };
"branch":只复制到某个 entry 为止的那条路径。"tree":整棵树。position 决定分叉点落在目标 entry 之前还是就在它上面。两种作用域对应两种意图:「从这里另起一条路试试」用 branch,「把整个会话另存一份」用 tree。类型层面用联合类型强制二选一——选了 tree 就不能再指定 entryId,因为那没有意义。
把当前对话序列化成一个 JSON 数组、每次覆盖写,实现更简单。选择追加日志换来的是:崩溃只丢最后一行而不是整个文件;写入是 O(1) 而不是 O(会话长度);以及最关键的——树结构几乎免费。覆盖式存储要支持分支,就得自己实现版本管理。
代价是读取要重放:打开一个长会话必须扫全文件再归约出当前路径。SQLite 后端存在的意义正在于此——同样的接口,把「扫全文件」换成索引查询。
这是 harness-v2 迁移最值得学的一点:durability 的底座(record 类型、三后端、一致性测试)已经全部落地并通过测试,而消费它的执行器还是空壳(第 08 章)。
顺序是有道理的。存储契约一旦定错,上层全要重写;而执行器可以在稳定的存储契约上分步替换。先把「怎么记录」定死并用一套一致性测试锁在三个实现上,再谈「怎么执行」——这是自底向上重写的标准做法,也是它进度看起来慢的原因。
设计文档明说:只保证旧 v3 会话能打开并恢复到 idle,其余格式和 API 全部可破坏,不写迁移、不做 schema 版本化、不提供转换路径。harness-v2.md:3
把兼容承诺压缩到一条,重写的自由度就最大化了。这个选择的前提是用户数据只有一类值得保(历史会话),其余(内部 API、SQLite schema、record 格式)都还没有外部依赖者——对一个一岁的项目成立,对一个五岁的项目多半不成立。
上面三小节的理由全是从代码和 harness-v2.md 这份内部工程文档反推的,「上游公开说过什么」这一格一直空着。锚定 SHA 之后 13 天,Earendil 发了立场文 《What is a Harness?》(2026-08-20):用户能在自己机器上跑自己的 harness,就保住了把工具变成自己的自由,以及 "keep local copies of the sessions that over time will constitute their correspondence with machines"。这是立场文,不是代码也不是仓库文档——它只能证明上游怎么讲自己的产品,证明不了任何一行代码为什么这么写。
它补上的坐标是:会话在上游叙事里是用户侧的通信录,不是厂商侧的用量日志。对得上的实现有两处——会话按 cwd 编码落在用户自己盘上的 ~/.pi/agent/sessions/ 下 session-manager.ts:474,文件是只追加的明文 JSONL 树 session-manager.ts:845。跳回旧分支、拿别的工具翻历史,都不经过服务端。
一致不等于因果,而且这两处的一致程度并不一样。「留在本机」是接口层面的:§2 那三个后端(内存 / JSONL / SQLite)都落在本机。「明文可读」只是当前默认后端的性质,不是契约——同一套一致性测试判定 SQLite 后端同样合格,而 SQLite 文件不是明文。换句话说:CLI 默认后端哪天换成 SQLite,存储契约一个字不用改,而「用 grep 读自己的会话」就没了。被锁死的是本地,不是可读。追加日志这个形状,本节开头已有更直接的解释(崩溃只丢最后一行、写入 O(1)、树免费),它们不需要产品意图也成立,不能倒过来算成意图的产物。
AgentHarness 完工,历史会话也无法「恢复未完成的 run」——只能恢复到 idle。这正是兼容承诺措辞里 "restore idle" 三个字的含义。检查过但未发现问题:三个后端跑同一套一致性测试,且该套件覆盖 record 的追加、查询与非法查询拒绝(如 limit: 0、类型与 operationKind 冲突的组合)。conformance.ts:242–244 这类「三实现共用一套测试」的结构,是本章所有断言里我最有信心的部分。
parentId 的追加日志,树结构就是免费的。分支、回退、fork 全都变成「移动叶子指针」,不需要任何版本管理机制。
适用条件:你想支持对话分支或回退。纯线性对话用数组更简单。createSessionBackendConformance——这是「新后端不会悄悄少实现一半语义」的唯一保证。
适用条件:存储后端可插拔。单一后端时这套测试就是普通单测。场景一个会话压缩过 3 次,你用 /tree 跳回第一次压缩之前的某条消息。被压缩掉的那些原始消息还能看到吗?
B。会话文件只追加,压缩写的是一条新 entry。跳到压缩点之前的叶子,走的就是包含原始消息的那条路径。代价是磁盘占用不会因压缩而下降。
场景假设 AgentHarness 明天全部实现完。你用今天的 pi CLI 产生的会话文件,能恢复一个「崩溃在半路的 run」吗?
C。record(operation_started 等 9 种)是 v4 新增的。兼容承诺的原话就是 "must open and restore idle"。harness-v2.md:3
ac4ac9e(2026-08-07 快照)。