Skip to content

6.5 Session 存储格式与会话树 ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:5.5 讲的是会话什么时候创建、什么时候写盘、怎么恢复——那是生命周期。本章讲数据本身:磁盘上一行到底有哪些字段、各种 entry 各记录什么、这些数据靠哪几个 interface 与存储介质解耦,以及为什么同一套会话逻辑能同时跑在文件、内存和 SQLite 上。仓库里其实有两种会话格式——pi 命令行今天写的 v3 格式(11 种 entry),和 pi-agent-core 的 harness 使用的 format 4(4 种 entry 加一组可覆盖的 value)——本章把两者放在一起对照着读。 前置知识:2.2 interface、type 与函数类型、2.3 union、字面量与可辨识联合、2.4 泛型、class 与类型收窄、5.5 Session 的创建、保存与恢复。 学习目标:① 说清 Storage、Session、SessionRepo 三个抽象各自的职责边界;② 逐字段读懂一行 v3 entry 与一行 format 4 事务,说出 v3 的 11 种 entry 在 format 4 里各去了哪里;③ 解释 branch tip、分支与 scanBranch 在 compaction 处截断的关系;④ 对照三个后端说出「抽象换来了什么」;⑤ 亲手造一个 v3 会话文件,用 Pi 的真实源码把它读出来,再亲眼看它被升级成 format 4。

建立直觉:把「存什么」和「存哪儿」拆开 ​

假设你要给一个 Agent 加持久化。最直接的写法是在保存消息的地方直接写 appendFileSync(path, JSON.stringify(msg))——packages/coding-agent/src/core/session-manager.ts 走的正是这条路(5.5 已经精读过它的 _persist,session-manager.ts:1160-1187)。这条路很短,代价是「会话的领域逻辑」和「文件系统调用」焊死在一起:想换成 SQLite,就得把 branch()、buildSessionContext() 这些跟文件毫无关系的方法一起重写一遍;想在单元测试里跑会话逻辑,就得真的建临时目录。

pi-agent-core 的 harness 把这件事拆成了三层:

📘 概念存储后端(Storage)
一个 interface,只回答「一次事务怎么原子地提交、写进去的东西怎么查回来」。它不知道什么叫 Agent、什么叫压缩,只知道三样东西:一棵靠 parentId 连起来、写入后永不修改的 entry 树;一组可以覆盖、可以删除的 value;一本只追加的用量账本。JSONL、内存、SQLite 各实现一份。
📘 概念领域层(Session)
一个 interface,外加一个现成实现 StorageBackedSession,里面包着一个 Storage。它知道「往某个分支追加一条消息」要写一条 type: "message" 的 entry、同时把分支的 tip 挪过去,并把这两步放进同一次事务。它只调 storage 的方法,从不碰文件系统。
📘 概念仓库层(SessionRepo)
一个 interface,管的是「一堆会话」而不是「一个会话」:新建、打开、列表、删除、fork。目录布局、文件命名、数据库路径这类问题都属于它。

类比一下:Storage 像数据库驱动,Session 像 ORM 里的领域模型,SessionRepo 像连接池加 DAO。三层各自可替换,接缝处只有类型。

🌱 初学者提示仓库里有两套 Session 实现
5.5 已经点明:pi 命令行界面(CLI,Command-Line Interface)今天用的是 coding-agent 自己的 SessionManager(同步、只写 JSONL v3、带版本迁移),而本章讲的三层抽象属于 pi-agent-core 的 AgentHarness。两者的目录编码与文件命名刻意保持一致,harness 还能读入 v3 文件并在第一次写入时把它升级成 format 4;反过来 CLI 读不了 format 4。本章末尾会用 grep 与实测把这件事说清楚。

三个抽象的契约 ​

Storage:存储后端要答应做的事 ​

earendil-works/pi@16787ad第 455–471 行在 GitHub 查看 ↗
存储后端契约:唯一的写入口是 commit,其余全是查询;11 个方法全部返回 Promise,并且都要求调用方显式传入 Context。
ts
// packages/agent/src/harness/session/types.ts:455-471(节选)
export interface Storage {
	commit(writes: Write[], context: Context): Promise<CommitResult>;
	getEntries(ids: string[], context: Context): Promise<Map<string, Entry>>;
	getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | undefined>;
	// …(省略:scanValues / readList / scanBranchStructure / scanUsage)
	scanBranch(query: StorageBranchScan, context: Context): Promise<Entry[]>;
	scanEntries(query: EntryScan, context: Context): Promise<Entry[]>;
	getStats(context: Context): Promise<SessionStats>;
	close(context: Context): Promise<void>;
}

有三个细节值得停一下。

第一,全部异步。 连纯内存实现的 getEntries 也返回 Promise(packages/agent/src/harness/session/memory.ts:62-65,里面只是一句 Promise.resolve(...))。从源码结构看,这是为了让 SQLite、远程存储这类天然异步的后端不必改接口——把同步实现包成 Promise 很容易,反过来不可能。代价是内存后端多了一堆无意义的 await。每个方法末尾那个 context 参数是 harness 的调用上下文(Context),用来携带取消信号与遥测父节点;官方文档说明它「从不是持久数据」(packages/agent/docs/harness.md §0.2)。

第二,写入只有一个口子:commit(writes)。 一次 commit 是一个事务,里面的写入有六种:插入 entry、插入用量行、设置 / 删除 value、追加 / 整体删除 list(session/types.ts:388-398,value 与 list 的写入形状在 session/values.ts:59-90)。storage 负责给每条写入盖上一个会话内严格递增的序号 seq 和一个毫秒时间戳(prepareStorageCommit,session/commit.ts:82-88),并在落盘前校验三件事:序号单调、entry 与用量行的 id 不重复、entry 的父节点必须已存在或在同一事务里先出现(validateCommittedWrites,session/commit.ts:90-116)。注意 id 不再由 storage 生成:Session 自带一个 id 生成器,默认就是 uuidv7(session/session.ts:237)。

第三,entry 没有 update 也没有 delete。 六种写入里,能改的只有 value 和 list;entry 和用量行一旦写入就永不修改、永不删除。官方文档把这概括成「三个存储」:entry 树只写一次、只追加,value 与 list 保存可变的当前状态,用量账本只追加(harness.md §0.3)。历史不可改写,不是靠纪律,是靠接口里根本没有那个操作;要改的「当前状态」(会话名、标签、分支 tip、模型配置)被专门挪进了 value。

Session:把「往分支追加」翻译成一次事务 ​

Session 本身是一个 interface(session/types.ts:530-555),核心包里的实现只有一个 StorageBackedSession(session/session.ts:224-475),三个仓库都用它包住各自的 storage。追加消息走的是这一段:

earendil-works/pi@16787ad第 422–454 行在 GitHub 查看 ↗
领域层追加一条 entry 的全过程:先拒绝 pending 状态的 assistant 消息、铸造新 id;然后在互斥的 mutate 回调里读出分支当前 tip,把「插入 entry」与「把 tip 挪到新 entry」放进同一次 commit。
ts
// packages/agent/src/harness/session/session.ts:422-454(节选)
async appendToBranch(name: string, entry: /* message 或 custom */, context: Context): Promise<string> {
	// …(省略:assertOpen,以及拒绝 stopReason 为 pending 的 assistant 消息)
	const id = this.idGenerator.next();
	await this.mutate(async (mutator) => {
		const tip = await mutator.getValue(branchTip(name), context);
		if (tip === undefined) throw new SessionInvariantError(`Unknown branch: ${name}`);
		await mutator.commit(
			[
				insertEntry({ id, parentId: tip.value, type: "message", message: entry.message }),
				setValueWrite(branchTip(name), id), // 即 values.ts 的 setValue,以别名导入
			],
			context,
		);
	}, context);
	return id;
}

(insertEntry 那一行在源码里是一个按 entry.type 二选一的表达式,这里只保留了 message 分支。)调用方通常不直接碰它,而是先拿到一个分支对象:session.branch("main") 返回的 Branch 上有 appendMessage 与 appendCustomEntry,两者都转调 appendToBranch(session/session.ts:210-220)。

mutate 是这一层最值得认识的机制。一个会话上所有「读—判断—写」都要排进同一条互斥队列 MutationLine(session/mutation-line.ts:1-23):mutate 拿到一个只允许提交一次的 SessionMutator,回调结束后在 finally 里释放(session/session.ts:263-270)。于是「读出 tip」和「以它为父写入新 entry」之间不可能被另一次追加插队:如果两次追加同时读到同一个旧 tip,它们会各自以它为父写入,树上就多出一个谁也没要的分叉。

Session 的另一半职责是读:Branch.findEntries() 从 tip 出发向根走(session/session.ts:196-201),而把一条路径投影成发给模型的消息数组,是一个完全不依赖 storage 的纯函数 buildSessionContext(session/context.ts:47-64),可以单独测试。

需要说清一个边界:entry 是在 storage 之上组装的,但组装者不止 Session。harness 的运行时在推进一次 Agent 运行时,也直接用 insertEntry 拼事务(例如 packages/agent/src/harness/runtime/drive/response.ts:342 写入模型回复)。storage 从不自己造 entry,它只负责盖上 seq / timestamp、校验、落盘,并顺手维护统计——InMemoryStorageState.applyValidated 会在应用写入时累加 message entry 的数量和用量行的总和(session/in-memory-storage-state.ts:111-139)。

SessionRepo:管一堆会话 ​

earendil-works/pi@16787ad第 592–602 行在 GitHub 查看 ↗
仓库层契约:五个方法都以「元数据」为标识而不是以文件路径为标识,因此 SQLite 后端可以用主键代替路径。
ts
// packages/agent/src/harness/session/types.ts:592-602
export interface SessionRepo<
	TMetadata extends SessionMetadata = SessionMetadata,
	TCreateOptions extends { id?: string; parentSessionId?: string } = SessionCreateOptions,
	TListOptions = void,
> {
	create(options: TCreateOptions, context: Context): Promise<Session<TMetadata>>;
	open(metadata: TMetadata, context: Context): Promise<Session<TMetadata>>;
	list(options: TListOptions | undefined, context: Context): Promise<TMetadata[]>;
	delete(metadata: TMetadata, context: Context): Promise<void>;
	fork(source: TMetadata, options: ForkOptions, context: Context): Promise<Session<TMetadata>>;
}

三个泛型参数是这个接口最值得看的地方。TMetadata 让 JSONL 后端的元数据带上 cwd/path/modifiedAt(packages/agent/src/harness/session/jsonl/types.ts:26-31),而所有后端共有的只有 id、createdAt、storageVersion 等几项(session/types.ts:473-480)。TCreateOptions 让 JSONL 的 create() 能强制要求传 cwd(jsonl/types.ts:33-35),内存版则不需要。换句话说:共性写在接口里,差异写进类型参数,调用方仍然只面对一个 SessionRepo。

具体后端的写法不完全一样:JsonlSessionRepo 直接写明 implements SessionRepo<JsonlSessionMetadata, JsonlSessionCreateOptions, JsonlSessionListOptions>(packages/agent/src/harness/session/jsonl/repo.ts:46-48),MemorySessionRepo 用默认参数 implements SessionRepo(memory.ts:334);SQLite 的 SqliteSessionRepo 没有写 implements,只是方法形状对齐(packages/session-backends/sqlite-node/src/sqlite/repo.ts:157)。fork 的选项也换成了一个可辨识联合:scope: "branch" 只复制一条分支的路径,scope: "tree" 复制整棵树和所有分支 tip(session/types.ts:562-590)。

图 6.5-1 Session 存储的三层抽象与三个后端
虚线空心箭头是「实现接口」,实线是「持有」。读这张图时请关注中间那一列:`StorageBackedSession` 只连到 `Storage` 这个 interface,没有任何一条线指向具体后端;`AgentHarness` 也只认 `Session` 接口。

图中每个类都能在源码中定位:Storage(session/types.ts:455)、Session(session/types.ts:530)、SessionRepo(session/types.ts:592)、StorageBackedSession(session/session.ts:224)、JsonlStorage(jsonl/storage.ts:40)、MemoryStorage(memory.ts:37)、SqliteStorage(packages/session-backends/sqlite-node/src/sqlite/storage.ts:49)、JsonlSessionRepo(jsonl/repo.ts:46)、MemorySessionRepo(memory.ts:334)、SqliteSessionRepo(packages/session-backends/sqlite-node/src/sqlite/repo.ts:157,它创建会话时同样 new StorageBackedSession(...),再套一层 SQLite 专用的生命周期包装,repo.ts:410-411)。最上面的 AgentHarness 通过构造参数拿到一个现成的 Session(AgentHarnessOptions.session,packages/agent/src/harness/agent-harness.ts:518-519)——它连 SessionRepo 都不认识,谁来建会话是应用层的事。

一行 entry 里有什么 ​

两种格式分开读。先读 pi 命令行今天真正写在 ~/.pi/agent/sessions/ 下的 v3,再读 harness 的 format 4。

v3:公共四字段与 11 种 entry ​

CLI 侧所有 entry 共享同一个基类(packages/coding-agent/src/core/session-manager.ts:57-62):

ts
export interface SessionEntryBase {
	type: string;            // 可辨识联合的判别字段
	id: string;              // 8 位十六进制短 id,仅需在本会话内唯一
	parentId: string | null; // 父节点;第一个 entry 为 null
	timestamp: string;       // ISO 时间戳
}

type 之外的三个字段就是全部的结构信息:id 定身份,parentId 连成树,timestamp 记时间。剩下的字段由各 entry 类型自己补。短 id 由 generateId 取 randomUUID() 的前 8 位、撞了就重试,最多 100 次(session-manager.ts:277-285)。

earendil-works/pi@16787ad第 183–195 行在 GitHub 查看 ↗
CLI 侧的 entry 联合共 11 种;文件第一行的 header(type: "session")不在联合里,它不是树上的节点。
type记录什么定义位置进上下文吗harness 能导入吗
message一条 AgentMessagesession-manager.ts:64-67进能
thinking_level_change思考等级切换session-manager.ts:69-72不进能,变成 lane 配置
model_change模型切换session-manager.ts:74-78不进能,变成 lane 配置
usage不属于任何 assistant 消息的用量(如 cache 预热)session-manager.ts:80-89不进不能
compaction上下文压缩(Context Compaction)检查点session-manager.ts:91-104进(摘要)能
branch_summary被放弃分支的摘要session-manager.ts:106-116进能
custom扩展(Extension)私有状态session-manager.ts:128-132不进能
custom_message扩展注入的消息session-manager.ts:159-165进能
context_edit对更早某条 entry 的「上下文改写」session-manager.ts:175-181改写目标不能
label给某个 entry 打书签session-manager.ts:135-139不进能,变成 value
session_info会话显示名session-manager.ts:142-145不进能,变成 value

最后一列来自 harness 的 v3 导入器:它逐行解析时只认 10 种 record——上表去掉 usage 与 context_edit,再加上一个 CLI 联合里没有的 active_tools_change——遇到别的 type 直接抛 Unsupported legacy v3 record type(packages/agent/src/harness/session/jsonl/legacy-v3.ts:203-217)。本章实践任务会亲手撞一次这个错误。

表里有两种 entry 值得多说一句。usage 记录的是「花了钱、但不是某条 assistant 消息」的用量,写入方目前是 prompt cache 预热(appendUsage,session-manager.ts:1242-1256;调用方在 packages/coding-agent/src/core/cache-warmer.ts:342),它计入会话总花费,但不进上下文。context_edit 则是只追加地改上下文:它指向更早的一条 user / assistant / toolResult / custom_message entry,replacement 为 null 时把目标从之后的模型上下文里省略,非 null 时只替换目标的 content(appendContextEdit,session-manager.ts:1358-1396)。原始 entry 一个字节不动,界面、导出、计费照旧;buildSessionProjection 在拼上下文时对每个目标取当前分支上最新的那条改写(session-manager.ts:543-574)。AgentSession 在溢出恢复时就是用一条 replacement: null 的 context_edit 把失败的那次 assistant 回复从后续上下文里拿掉(_omitRecoveryAttempt,packages/coding-agent/src/core/agent-session.ts:1015-1026),而不是去删历史。

compaction 在 v3 里靠 firstKeptEntryId 指回压缩前保留区间的起点;想「一条都不保留」时,appendCompaction(summary, null, tokensBefore) 会把这个字段填成压缩 entry 自己的 id(session-manager.ts:1276),于是重建上下文时找不到更早的起点,什么都不留。它还带一个可选的 systemMessage(session-manager.ts:103),把压缩那一刻完整的系统提示词与工具声明存成检查点。压缩本身留给 6.6 Context 构造与 Compaction。

format 4:4 种 entry,其余都成了 value ​

harness 的 entry 基类少了一个判别字段的「自由度」,多了一个序号(packages/agent/src/harness/session/types.ts:18-25):

ts
export interface EntryBase {
	id: string;              // UUIDv7,前 48 位是铸造时间
	parentId: string | null;
	seq: number;             // storage 在 commit 时分配,会话内严格递增
	timestamp: number;       // Unix 毫秒,同样由 storage 在 commit 时写入
	type: EntryType;         // 只有四种取值
	customType?: string;     // 仅 custom entry 有
}
earendil-works/pi@16787ad第 16–64 行在 GitHub 查看 ↗
format 4 的 entry 只有 message / compaction / branch_summary / custom 四种;compaction 的 retainedTail 是必填字段。

v3 的另外 7 种去了哪里?答案是:它们描述的都是「会变的当前状态」,而不是「发生过的对话」,于是被挪进了 value(地址构造函数都在 session/values.ts:158-195):

v3 里的 entryformat 4 里的去处
labelvalue pi.entry.label/<entryId>(entryLabel)
session_infovalue pi.session.name(sessionName)
model_change、thinking_level_change、active_tools_change一份整体的 lane 配置 value pi.lane.config/<lane>(laneConfig,类型 LaneConfiguration,session/types.ts:69-73)
custom_message一条 message entry,消息的 role 是 custom
usage用量账本里的一行(UsageRow,session/types.ts:379-386)
context_edit没有对应物

还有一个 v3 里没有落盘、format 4 里必须落盘的东西:当前位置。它不再是某条 entry,而是 value pi.branch.tip/<分支名>(branchTip),下一节细讲。

compaction 的 retainedTail: AgentMessage[] 是必填的(session/types.ts:33-41)。它把压缩时保留的尾部消息直接复制进 entry,于是这个节点变成一个自包含的检查点。官方文档说明这一点时说得很干脆:「context never reads past a compaction」(harness.md §2.1)。v3 的 firstKeptEntryId 在导入时会被换算成 retainedTail,format 4 既不暴露也不存这个字段(harness.md Appendix B)。

逐字段读一行 ​

一行 v3 的 message entry 在磁盘上长这样(本书为教学构造,不是运行输出):

json
{"type":"message","id":"aaaa0002","parentId":"aaaa0001","timestamp":"2026-07-30T10:00:01.000Z","message":{"role":"user","content":"first question","timestamp":1785000001000}}

外层四个字段是树结构,message 字段里装的才是发给大语言模型(LLM,Large Language Model)的东西。注意有两个 timestamp:外层是 entry 的 ISO 字符串,内层是消息自己的毫秒数——两套体系,别混。这也解释了 5.5 开头「三层状态」那一节的分层:entry 包着 message,而不是 message 带着 entry 字段。

同一条消息被 harness 升级成 format 4 之后,是下面这一行(本章实践任务的真实输出):

json
{"kind":"entry","id":"019fb277-74e8-711e-affe-d4cbba9a53d9","parentId":null,"seq":1,"timestamp":1785405601000,"type":"message","message":{"role":"user","content":"first question","timestamp":1785000001000}}

多出来的 kind: "entry" 说明这一行是一次写入而不是一个「节点」:format 4 的 JSONL 文件里一行对应一次 commit,写入只有一条时是一个 JSON 对象,有多条时是一个 JSON 数组(serializeJsonlTransaction,jsonl/io.ts:76-78)。同一个文件里还会出现 "kind":"value" 的行(设置分支 tip、lane 配置)和 "kind":"usage" 的行。外层 timestamp 也变成了毫秒数,和消息内层统一了;parentId 变成 null,原因见实践任务。

树:branch tip 与分支 ​

追加即长树 ​

在 v3 里,「当前叶子」是 SessionManager 内存里的一个字段,加载时粗暴地设成文件最后一个 entry(_buildIndex,session-manager.ts:1103-1122)。format 4 把它做成了一等公民:一个 Branch 就是一个有名字、可移动的 tip,存在 value pi.branch.tip/<name> 里;官方文档说,一个 Branch「恰好在它的 tip value 存在时存在」(harness.md §2.3)。

追加的规则上一节已经见过:appendToBranch 在同一次事务里「插入以旧 tip 为父的 entry」并「把 tip 设成新 entry」。没有一个单独的「叶子指针文件」,也没有「重放每一行来推算叶子」的函数——tip 就是一个 value,最后一次 set 说了算。

分支就是再多一个 tip ​

earendil-works/pi@16787ad第 355–368 行在 GitHub 查看 ↗
createBranch:在互斥回调里校验分支名未被占用、目标 entry 存在,然后只写一个 value——新分支的 tip。
ts
// packages/agent/src/harness/session/session.ts:355-368(节选)
async createBranch(name: string, at: string | null, context: Context): Promise<Branch> {
	// …(省略:assertOpen / 校验分支名)
	await this.mutate(async (mutator) => {
		if ((await mutator.getValue(branchTip(name), context)) !== undefined) {
			throw new SessionBranchExistsError(name);
		}
		if (at !== null && !(await mutator.getEntries([at], context)).has(at)) {
			throw new SessionUnknownTargetError(at);
		}
		await mutator.commit([setValueWrite(branchTip(name), at)], context);
	}, context);
	return this.getOrCreateBranchObject(name);
}

没有删除、没有重写,只有一次 setValue。之后往新分支 appendMessage,新 entry 自然挂在 at 下面,于是老路径成了兄弟分支。和 v3 最大的不同是:多个分支可以同时存在、各自有名字,main 也只是一个普通名字,没有谁是隐式的默认分支(harness.md §0.2、§2.8)。harness 在此之上再加一层 AgentLane:一个 lane 就是一个 Branch 再加上 lane 配置、lane 状态等 value,能在上面跑 Agent;让一个正在用的 lane 「跳回」树上的某个点并可选地写一条 branch_summary,是 harness 的 navigation 操作(OperationMeta.intent 的 "navigation" 分支,session/types.ts:83-89),属于 6.3 的范围。

沿 parentId 回溯 ​

读的一侧只有一个方法承担全部树遍历:

earendil-works/pi@16787ad第 273–301 行在 GitHub 查看 ↗
内存与 JSONL 后端共用的分支扫描:从 start 沿 parentId 走到根,按需要的方向排序,遇到 stopAtId 或 stopAtType 就(含该节点)停下,再按类型、游标与数量过滤。
ts
// packages/agent/src/harness/session/in-memory-storage-state.ts:277-291(节选)
const path: Entry[] = [];
let entry: Entry | undefined = start;
while (entry !== undefined) {
	path.push(entry);
	if (entry.parentId === null) break;
	entry = this.entries.get(entry.parentId);
	if (entry === undefined) throw new Error("Corrupt branch: missing parent");
}
if (query.order === "oldestFirst") path.reverse();

const stopped: Entry[] = [];
for (const candidate of path) {
	stopped.push(candidate);
	if (candidate.id === query.stopAtId || candidate.type === query.stopAtType) break;
}

构建上下文时的用法是官方文档写死的步骤:从 tip 以 newestFirst 顺序扫描、stopAtType: "compaction",再倒过来(harness.md §2.5)。因为 compaction 自带 retainedTail,扫描碰到最近的一个 compaction 就可以停,再往上的历史根本不读。纯函数 buildContextEntries 对一条完整路径做同样的事——只留最后一个 compaction 和它之后的 entry(session/context.ts:10-22)。对比 v3 的 buildContextEntries:它必须先走完整条路径,再回头找 firstKeptEntryId 把保留区间拼回来(session-manager.ts:476-517)。

图 6.5-2 format 4 的一棵 entry 树与两个分支 tip
实线是 parentId 构成的父子关系,虚线表示「不是父子、只是 value 引用」。这棵树正是本章实践任务会亲手造出来、再加上 alt 分支之后的样子。

请注意两件事。第一,树上只有三个 message 节点:原 v3 文件里的 model_change 与 thinking_level_change 已经不在树上,它们合并成了 LC 那一个 value。第二,E1 有两个孩子,这就是分支;两个 tip 分别指向两个孩子,scanBranch 从 main 的 tip 出发得到的是 E1 → E3,从 alt 出发得到的是 E1 → E2。两条时间线并存,谁也没有覆盖谁。

⚠️ 常见误解以为 value 与 custom entry 会被投影成消息
不会。sessionEntryToContextMessages(session/context.ts:31-45)只处理四种 entry:message 原样进入,但 stopReason 为 error、aborted、deferred 的 assistant 回复会被丢掉;compaction 变成一条摘要消息加上 retainedTail;branch_summary 在摘要非空时变成一条摘要消息;custom 默认返回空数组,只有调用方给它的 customType 注册了投影器(entryProjectors)才会进入上下文(session/context.ts:55-61)。分支 tip、lane 配置、标签、会话名这些 value 改变会话状态,但从不进入发给模型的消息数组。

三个后端 ​

JSONL:一行一次 commit ​

earendil-works/pi@16787ad第 138–151 行在 GitHub 查看 ↗
JSONL 后端的写入全貌:先让内存状态分配序号并校验,再用一次 appendFile 追加一行 JSON,最后把写入应用到内存索引。遗留 v3 文件的第一次非空写入走另一条升级路径。
ts
// packages/agent/src/harness/session/jsonl/storage.ts:138-151
private async applyCommit(writes: Write[], context: Context): Promise<CommitResult> {
	if (this.backing.kind === "v3" && writes.length !== 0) {
		return this.upgradeLegacyV3ToV4(this.backing.source, writes, context);
	}
	const prepared = this.storageState.prepareCommit(writes, this.now());
	if (prepared.writes.length !== 0) {
		fileValue(
			await this.fileSystem.appendFile(this.path, `${serializeJsonlTransaction(prepared.writes)}\n`, context),
			`Failed to append JSONL storage ${this.path}`,
		);
	}
	const stats = this.storageState.applyValidated(prepared.writes);
	return { ...prepared.result, stats: this.withImportedUsage(stats) };
}

先校验、再落盘、最后改内存——任何一步抛错,内存里都不会留下半条事务。官方文档把这个文件定位成「内存状态的重放配方,而不是状态本身」(harness.md §1.7):打开时逐行重放进内存,之后的查询全在内存里跑(openV4,jsonl/storage.ts:85-114)。崩溃恰好发生在写一行的中途怎么办?最后那半行会被整体丢掉——哪怕它是一个数组行、里面有好几条写入——并在接受新写入前把文件原子地改写成只含完整行的版本(splitCompleteLines 与 openV4 里的 torn 分支,jsonl/storage.ts:30-35、108-112)。于是「事务内不存在崩溃中间态」这句话在 JSONL 上也成立。

对比 5.5 精读过的 CLI 版 _persist:这里没有「等第一条 assistant 消息才建文件」的缓冲逻辑,JsonlSessionRepo.create 一开就把 header 原子地发布出去(jsonl/repo.ts:63-93)。一种看法是:harness 版更简单、崩溃时丢的更少,代价是会留下只有 header 的空会话文件;CLI 版换来干净的会话列表,代价是多一个 flushed 状态位和一个「路径已算出但文件不存在」的中间态。另一个代价在 value 上:SQLite 里一次 setValue 是原地覆盖,JSONL 里每一次 set 都要追加一行,被覆盖掉的旧行成了死字节。官方文档规定了用快照改写回收这些字节(J1),并明确标注「specified, not implemented」(harness.md §0.9)。

还有一处值得看的设计:这个 class 依赖的不是 node:fs,而是注入进来的 FileSystem 能力接口(jsonl/types.ts:20-24)。FileSystem 本体在 packages/agent/src/harness/types.ts:275-328,注释明确要求「操作方法绝不抛异常,所有失败都编码进返回的 Result」(types.ts:272-273)。于是 storage 里每次文件操作都套一层 fileValue(jsonl/io.ts:15-18),把 Result 翻译成异常。这个接口里有一个方法对存储格外关键:renameFile——「原子地重命名,目标存在时替换它」(types.ts:299-300)。建新会话、fork、截掉半行、v3 升级,全都走「先写 .tmp 临时文件、成功后一次 rename 覆盖」的 publishFileAtomically(jsonl/io.ts:81-104)。只要运行时能提供这组方法,同一份 JSONL 代码就能跑在 Node 之外。

读遗留 v3 文件是 JSONL 后端的另一半工作。打开时先只看第一行,按 header 形状分流:{"v":4,"kind":"header",…} 走 format 4,{"type":"session","version":3,…} 走 v3 导入(parseJsonlSessionHeader,jsonl/codec.ts:53-63)。v3 文件只读打开时一个字节都不改,内存里得到的是规范化之后的 entry 与 value;只有第一次非空写入才会触发 upgradeLegacyV3ToV4:把规范化后的全部内容、一条汇总历史用量的调整行(details: { source: "v3-import" })以及调用方的写入,一起原子地写成一个新的 format 4 文件替换原文件(jsonl/storage.ts:153-189)。官方文档把这套兼容规则写在 harness.md 的 Appendix B,第一句就是「Old v3 files must open unchanged」。

JsonlSessionRepo 负责目录:sessionDirectoryName(jsonl/repo.ts:36-38)与 CLI 侧的 getDefaultSessionDirPath(session-manager.ts:589-594)用的是同一个正则;文件名同样是「时间戳 _ sessionId .jsonl」(sessionFileName,jsonl/repo.ts:40-43,id 额外做了 encodeURIComponent)。list() 只读每个文件的第一行,header 解析不了就跳过这个文件(readSessionMetadata,jsonl/repo.ts:244-257)——一个坏文件不会毁掉整张会话列表,v3 文件也能出现在列表里。

fork 则把「选出要复制的东西」和「写新文件」拆得很干净:

earendil-works/pi@16787ad第 146–196 行在 GitHub 查看 ↗
跨文件 fork:先确定源(打开中的、已关闭的、遗留 v3 的三种),再按 ForkOptions 把选中的 entry 与 value 流式写进新文件,header 的 parentSessionId 指向源会话。

scope: "branch" 时,要复制哪一段路径由一个共享的纯函数决定(selectBranchFork,session/fork-policy.ts:8):不给 entryId 就取分支当前 tip;给了就必须在该分支的祖先链上;position: "before" 取目标的父节点,也就是「回到这条之前重来」。目标分支还必须是一个完整配置过的 AgentLane,只有 tip 的「数据分支」会被拒绝(harness.md §2.7)。不管哪种 scope,用量账本、运行中的操作状态、所有 pi.pending.* 都不会被复制,新会话的用量从零开始。内存版 fork 由 InMemoryStorageState.createFork(in-memory-storage-state.ts:141)经 selectForkPlan 调用同一个函数(in-memory-storage-state.ts:195-211),SQLite 版通过 createForkSnapshot(session/fork.ts:29,SQLite 在 repo.ts:87 调用)。三种后端「取哪些东西」完全一致,不同的只有「写到哪里去」。有一个限制:一个正打开着的 v3 文件不能直接作为 fork 源,必须先有一次非空写入把它升级成 format 4(resolveForkInput,jsonl/repo.ts:304-309)。

内存:测试与「不要落盘」的场景 ​

earendil-works/pi@16787ad第 37–60 行在 GitHub 查看 ↗
内存后端的类声明与 commit:所有状态放在一个 InMemoryStorageState 里,commit 排进一条 Promise 队列,在队列里分配序号、校验、同步地应用写入。
ts
// packages/agent/src/harness/session/memory.ts:48-60
async commit(writes: Write[], _context: Context): Promise<CommitResult> {
	if (this.state !== "open") throw new Error("MemoryStorage is closed");
	const result = this.commitQueue.then(() => {
		const prepared = this.storageState.prepareCommit(writes, this.now());
		const stats = this.storageState.applyValidated(prepared.writes);
		return { ...prepared.result, stats };
	});
	this.commitQueue = result.then(
		() => undefined,
		() => undefined,
	);
	return result;
}

把它和上一节的 JSONL applyCommit 放在一起看:两者共用同一个 InMemoryStorageState,JSONL 只是在「准备好」与「应用」之间多了一次 appendFile。这个共享类的注释写得很坦白:它「刻意不适合数据库后端和装不进内存的长会话」,那种后端应当查询带索引的持久状态(in-memory-storage-state.ts:72-78)——这正是 SQLite 后端另起炉灶的理由。

它今天被谁用?源码事实:packages/*/src 下 MemorySessionRepo 的使用者,除了 harness 自己的 session/ 目录,只有 packages/server/src/testing/host.ts:153 这个测试宿主。这正是抽象的用途:想要「跑一个 Agent 但什么都别写盘」,换一个仓库即可,不必给 SessionManager 加开关。

几个后端行为一致靠的不是人工审查,而是一套共享的一致性测试(conformance suite):createStorageConformance(packages/agent/src/harness/session/testing/conformance/storage.ts:134)返回一组与后端无关的用例,packages/agent/test/harness/memory-conformance.test.ts 与 jsonl-storage-conformance.test.ts 分别拿内存与 JSONL 的 storage 去跑,SQLite 包里的 test/storage-conformance.test.ts 也跑同一组。官方文档说明三种编码「all pass the same conformance suite」(harness.md §1.7)。想确认本章讲的行为不是杜撰,跑一跑这几个文件就够——不需要任何 API Key。

SQLite:一个独立包,默认不启用 ​

@earendil-works/pi-session-backend-sqlite-node 和其他 package 不同,它不在 packages/ 顶层,而在 packages/session-backends/sqlite-node/(packages/session-backends/sqlite-node/package.json:2):

earendil-works/pi@16787ad第 157–173 行在 GitHub 查看 ↗
SQLite 仓库实现:构造参数是「目录(或共享数据库路径)+ SQLite 工厂」,工厂由调用方注入,所以核心包不必依赖任何原生模块。

它从 @earendil-works/pi-agent-core 里 import 的正是本章前半段的东西——StorageBackedSession、branchTip、createForkSnapshot 以及 Entry、ForkOptions 等类型(repo.ts:3-4)。表结构(packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql)包含 sessions、entries、scalar_values、list_values、usage_ledger、branch_entries、branch_meta 七张表,文件头注释说明前四类(entries、scalar_values、list_values、usage_ledger)才是权威数据,branch_* 与 sessions 上的统计列只是可重建的投影与缓存。可写连接打开时设 journal_mode = WAL 与 busy_timeout = 5000(repo.ts:64-66),node:sqlite 适配器的每个写事务都以 BEGIN IMMEDIATE 开头(packages/session-backends/sqlite-node/src/index.ts:79)。默认每个会话一个 .sqlite 文件,也支持多个会话共用一个数据库(包内 README.md)。

官方文档说明它为什么单独成包:SQLite 后端与 node:sqlite 适配器放在独立包里,「这样核心包默认不会引入运行时内建模块或原生 SQLite 依赖;后端接受一个运行时特定的 SQLite 工厂,将来其他存储后端也可以各自发包」(packages/agent/README.md:13)。

定位需要严格分级。源码事实:全仓搜索 pi-session-backend-sqlite-node 与 SqliteSessionRepo,除包内自身外只出现在 packages/agent/README.md 与 packages/agent/docs/ 下的设计文档里;packages/coding-agent/package.json 与 packages/server/package.json 的依赖里都没有它。也就是说,本 commit 下它没有任何生产使用者。

coding-agent 消费了这些抽象吗 ​

答案分两半。正式发布的 pi 命令行:没有。 在锁定 commit 上运行

bash
grep -rln "harness/\|SessionRepo\|AgentHarness\|StorageBackedSession" packages/coding-agent/src \
  | grep -v "^packages/coding-agent/src/experimental\|^packages/coding-agent/src/cli/experimental"

结果为空(源码事实,你可以自己复核)。AgentSession(packages/coding-agent/src/core/agent-session.ts:328)的第二个只读字段就是 sessionManager: SessionManager(agent-session.ts:330),而 SessionManager 在文件头直接 import 了 Node 的同步文件 API——appendFileSync/openSync/writeFileSync 等十项来自 "fs"(session-manager.ts:15-27),没有任何一层能力接口挡在中间。而且它现在是 provider 上下文的唯一权威:AgentSession 在每次模型请求前都用 sessionManager.buildSessionProjection().messages 覆盖请求里的消息(_installAgentRequestProjection,agent-session.ts:607-625),直接改 agent.state.messages 不再影响下一次请求。

实验代码:已经在用。 同一个包的 src/experimental/ 下,server.ts:384、session-worker.ts:540 都 new JsonlSessionRepo(...),session-worker.ts:834 调 AgentHarness.create(...)。但 coding-agent 的 package.json 在发布文件列表里显式排除了 dist/experimental(packages/coding-agent/package.json:29-33),所以这条路不在你装到的 pi 里。

两套实现是什么关系?下面这张对照表能帮你在读任一侧源码时快速换算:

概念harness(pi-agent-core,format 4)CLI(pi-coding-agent,v3)
存储契约Storage(session/types.ts:455)无接口,逻辑内联在 class 里
领域层Session 接口(session/types.ts:530)与 StorageBackedSession(session/session.ts:224)SessionManager(session-manager.ts:987)
仓库层SessionRepo(session/types.ts:592)静态方法 create/open/continueRecent/forkFrom
追加消息Branch.appendMessage → appendToBranch(session/session.ts:210、422)SessionManager.appendMessage(session-manager.ts:1202)
当前位置命名的 branch tip,存为 value,可同时有多个单一 leaf,不落盘,加载时取最后一个 entry
移动位置createBranch(session/session.ts:355),或 harness 的 navigation 操作branch / resetLeaf(session-manager.ts:1572、1584)
模型与思考等级lane 配置 valuemodel_change / thinking_level_change entry
跨文件 forkSessionRepo.fork(jsonl/repo.ts:146)createBranchedSession(session-manager.ts:1625)
一行是什么一次 commit(可能是数组)一个 entry
idUUIDv78 位十六进制
同步/异步全异步全同步
版本format 4;能读 v3,首次写入时整体升级v3;v1→v2→v3 自动迁移并重写文件

从 v3 导入器的存在看,两套实现正在往一处收拢:harness 刻意复刻了同样的目录编码与文件命名,官方文档专门写了一节「Coding-agent v3-format compatibility」,实验性的 server 与 session worker 已经用 JsonlSessionRepo 打开同一个会话目录。据此推断(尚未在源码中直接证实):正式 CLI 将来会切换到 harness 这套抽象——但仓库里没有找到切换的开关、TODO 或时间表。至于今天,「两套 JSONL 完全互读」不成立,而且两个方向的原因不同:harness 的导入器不认识 v0.87 CLI 自己会写的 usage 与 context_edit(legacy-v3.ts:203-217),一个用过 cache 预热的会话就打不开;反过来,harness 一旦写入,文件就成了 format 4,CLI 的 loadEntriesFromFile 看到第一行不是 type: "session" 就返回空数组(session-manager.ts:662-667),SessionManager.open 随即报 Session file is not a valid pi session(session-manager.ts:1036-1040)。这两点实践任务都会让你亲眼看到。

图 6.5-3 一次 appendMessage 穿过三层的时序
自上而下按时间读。注意 `StorageBackedSession` 一共只跟 storage 说了两句话——读 tip、提交一次事务——而且没有一句提到文件。

这张图对应的源码:调用方那一步在 session/session.ts:210-212(harness 的 AgentLane.appendMessage 走的是同一个思路,只是还要顺带维护 lane 状态、在有操作运行时改成排队写入,packages/agent/src/harness/runtime/lane.ts:1916-1965);Session 的步骤在 session/session.ts:422-454,排队在 session/mutation-line.ts:6-17;storage 的写入在 jsonl/storage.ts:138-151,序号与校验在 session/commit.ts:82-116;最后的 Result 检查在 jsonl/io.ts:15-18。把 T 换成 MemoryStorage,只有 appendFile 与 Result 两步消失,前面完全一样——这就是抽象换来的东西。

与官方文档的出入 ​

5.5 已经逐条核对过 session-format.md,这里不重复。站在「数据与抽象」的角度补一条解释:两套实现各有一份自己的格式文档,读的时候要先分清读的是哪一份。

packages/coding-agent/docs/session-format.md 的 Source Files 一节(session-format.md:29-36)明确写出它对照的源码是 packages/coding-agent/src/core/session-manager.ts 等四个文件,没有一个来自 packages/agent/src/harness/;它的 Entry Types 一节(session-format.md:210-352)列的正好是 CLI 侧 SessionEntry 联合的 11 种,文档后半段的 API 清单(session-format.md:439-493)也只列 SessionManager 的方法。把它当作 v3 格式的权威说明是准确的。

format 4 的规范则在 packages/agent/docs/harness.md:Part 1 讲存储,Part 2 讲会话树,Appendix B 讲 v3 兼容。它自己把状态说得很清楚:format 4「仍是 WIP(尚未稳定)」,形状可能原地改变而不提供迁移(harness.md §0.9 末尾);§0.9 还列出了已写进规范但尚未实现的部分,与本章直接相关的是 J1——JSONL 的快照压缩,今天被覆盖掉的 value 行永远不会被回收。所以读 harness.md 时要对照 §0.9 的状态清单,不能把规范里的每一句都当成当前行为。

实践任务 ​

🛠 实践任务手工构造一个 v3 session.jsonl,用 harness 的真实 storage 读出来并升级

目标:不依赖任何 API Key,亲手写出一个合法的 v3 会话文件(含一个分支),用 JsonlStorage + StorageBackedSession 把它加载出来,观察 v3 的 entry 在 format 4 里变成了什么;再建一个新分支,亲眼看见整个文件被原子地升级成 format 4,并验证 CLI 从此读不了它。

为什么无需 Key:整条链路只碰文件系统。JsonlStorage.open(jsonl/storage.ts:74-83)、buildSessionContext(session/context.ts:47-64)都不接触 Provider(模型服务提供方)。

步骤 1 · 建目录与数据文件。在任意位置建一个空目录(例如 /tmp/pi-session-demo),在其中创建 demo.jsonl,写入下面 6 行——每行必须是完整的一行 JSON,不能折行:

{"type":"session","version":3,"id":"01930000-0000-7000-8000-0000000000aa","timestamp":"2026-07-30T10:00:00.000Z","cwd":"/tmp/pi-session-demo"}
{"type":"model_change","id":"aaaa0001","parentId":null,"timestamp":"2026-07-30T10:00:00.100Z","provider":"anthropic","modelId":"claude-sonnet-4"}
{"type":"thinking_level_change","id":"aaaa0005","parentId":"aaaa0001","timestamp":"2026-07-30T10:00:00.200Z","thinkingLevel":"off"}
{"type":"message","id":"aaaa0002","parentId":"aaaa0005","timestamp":"2026-07-30T10:00:01.000Z","message":{"role":"user","content":"first question","timestamp":1785000001000}}
{"type":"message","id":"aaaa0003","parentId":"aaaa0002","timestamp":"2026-07-30T10:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"answer A"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4","usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0,"totalTokens":15,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785000002000}}
{"type":"message","id":"aaaa0004","parentId":"aaaa0002","timestamp":"2026-07-30T10:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"answer B"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4","usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0,"totalTokens":15,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785000003000}}

注意最后两行的 parentId 都是 aaaa0002——它们是兄弟,这就是图 6.5-2 里那个分支。第 3 行的 thinking_level_change 不能省:导入器要从路径上最近的模型与思考等级记录拼出完整的 lane 配置,缺了其中任何一项,main 就只是一个没有配置的「数据分支」(缺工具记录则没关系,会补成 [])(harness.md Appendix B)。

步骤 2 · 写一个只读脚本 inspect.mts(放在同一个目录里;扩展名用 .mts,让 tsx 按 ES 模块处理顶层 await;把 PI 改成你本地 pi 仓库的绝对路径):

ts
const PI = "/绝对路径/到/pi";
const { BACKGROUND_CONTEXT: ctx } = await import(`${PI}/packages/agent/src/harness/context.ts`);
const { NodeExecutionEnv } = await import(`${PI}/packages/agent/src/harness/env/nodejs.ts`);
const { JsonlStorage } = await import(`${PI}/packages/agent/src/harness/session/jsonl/storage.ts`);
const { StorageBackedSession } = await import(`${PI}/packages/agent/src/harness/session/session.ts`);
const { branchTipInventoryPrefix, laneConfig } = await import(`${PI}/packages/agent/src/harness/session/values.ts`);
const { buildSessionContext } = await import(`${PI}/packages/agent/src/harness/session/context.ts`);

const env = new NodeExecutionEnv({ cwd: process.cwd() });
const storage = await JsonlStorage.open({ fileSystem: env, path: `${process.cwd()}/demo.jsonl` }, ctx);
const session = new StorageBackedSession(storage.header, storage);

console.log("legacy v3:", storage.isLegacyV3());
console.log("--- all entries (by seq) ---");
for (const e of await session.findEntries({ order: "asc" }, ctx)) console.log(" ", e.seq, e.type, e.id, "<-", e.parentId);
console.log("--- branch tips ---");
for (const v of await session.scanValues(branchTipInventoryPrefix(), ctx)) console.log(" ", v.address.key, "->", v.value);
console.log("lane config:", JSON.stringify((await session.getValue(laneConfig("main"), ctx))?.value));
const main = await session.branch("main", ctx);
const path = await main!.findEntries({ order: "oldestFirst" }, ctx);
console.log("context roles:", (await buildSessionContext(path, undefined, ctx)).map((m: any) => m.role));
console.log("stats:", JSON.stringify(await session.getStats(ctx)));
await session.close(ctx);

步骤 3 · 运行(在 demo 目录下执行,--tsconfig 让 tsx 使用 pi 仓库根的 paths 映射,把 @earendil-works/* 指向各包源码):

<pi 仓库根>/node_modules/.bin/tsx --tsconfig <pi 仓库根>/tsconfig.json inspect.mts

预期现象(本书在 pi v0.87.0 上真实运行所得,原样粘贴;id 的后半段是随机的,你的会不一样):

legacy v3: true
--- all entries (by seq) ---
  1 message 019fb277-74e8-7599-ad50-60d44e90bad6 <- null
  2 message 019fb277-78d0-7599-ad50-60d6ac88fae6 <- 019fb277-74e8-7599-ad50-60d44e90bad6
  3 message 019fb277-7cb8-7599-ad50-60d8cdc29083 <- 019fb277-74e8-7599-ad50-60d44e90bad6
--- branch tips ---
  main -> 019fb277-7cb8-7599-ad50-60d8cdc29083
lane config: {"model":{"provider":"anthropic","modelId":"claude-sonnet-4"},"thinkingLevel":"off","activeToolNames":[]}
context roles: [ 'user', 'assistant' ]
stats: {"messageCount":3,"usage":{"input":20,"output":10,"cacheRead":0,"cacheWrite":0,"totalTokens":30,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}}}

五处值得琢磨:① 文件里有 5 个节点,树上只剩 3 个 message,model_change 与 thinking_level_change 变成了 lane config 那一个 value;② 用户消息原来挂在 thinking_level_change 下面,那个节点被丢弃后它被重新挂到「最近的保留祖先」上——没有这样的祖先,所以 parentId 成了 null;③ 所有 id 都被重新铸造成 UUIDv7,前半段 019fb277-74e8、-78d0、-7cb8 依次相差 1000 毫秒,正是原 entry 的 ISO 时间戳;④ main 的 tip 指向 answer B,因为导入器把「文件里最后一个节点」当作 tip,context roles 只有 user 与 assistant 两项;⑤ messageCount: 3 统计的是**全部** message entry 而不是当前分支——统计口径和上下文口径不是一回事。再跑一次 inspect.mts,id 的随机后半段会变:只读打开时文件一个字节都没改,每次打开都重新导入一遍。

步骤 4 · 建一个分支,触发升级。再写一个 branch.mts,前 11 行与 inspect.mts 相同,之后改为:

const answerA = (await session.findEntries({ order: "asc" }, ctx))[1];
const alt = await session.createBranch("alt", answerA.id, ctx);
console.log("alt path:", (await alt.findEntries({ order: "oldestFirst" }, ctx)).map((e: any) => e.id));
await session.close(ctx);

用同样的命令跑 branch.mts,本书实测输出:

alt path: [
  '019fb277-74e8-711e-affe-d4cbba9a53d9',
  '019fb277-78d0-711e-affe-d4cdcd3432c6'
]

然后看 demo.jsonl:整个文件已经换了模样。本书实测第一行变成 format 4 的 header,接着是 3 行 entry、3 行 value(main 的 tip、lane 配置、lane 状态),最后一行是一个数组——导入的用量调整行和你这次写入的新 tip 放在同一次事务里:

{"v":4,"kind":"header","id":"01930000-0000-7000-8000-0000000000aa","createdAt":1785405600000,"storageVersion":1,"cwd":"/tmp/pi-session-demo","nextSeq":9}
…(省略:3 行 "kind":"entry" 与 3 行 "kind":"value")
[{"kind":"usage","id":"01a0ca30-ba1c-711e-affe-d4d13c4e8b77","usage":{"input":20,"output":10,…},"adjustment":true,"details":{"source":"v3-import"},"seq":7},{"kind":"value","op":"set","seq":8,"namespace":"pi.branch.tip","key":"alt","value":"019fb277-78d0-711e-affe-d4cdcd3432c6"}]

步骤 5 · 反向验证。再写一个 cli-open.mts,用 CLI 的 SessionManager 打开同一个文件:

const PI = "/绝对路径/到/pi";
const { SessionManager } = await import(`${PI}/packages/coding-agent/src/core/session-manager.ts`);
try {
  SessionManager.open(`${process.cwd()}/demo.jsonl`, process.cwd());
  console.log("CLI opened it");
} catch (e: any) {
  console.log("CLI open failed:", e.message);
}

本书实测:在步骤 4 之前跑,输出 CLI opened it;在步骤 4 之后跑,输出 CLI open failed: Session file is not a valid pi session: …/demo.jsonl。

如何判断成功:重新跑一次 inspect.mts,legacy v3 变成 false,branch tips 里多了 alt,而且这次的 id 和步骤 4 输出的一模一样、再跑也不变——因为它们已经被写进了文件。你能解释「为什么只读打开不改文件、第一次写入却改写了整个文件」(upgradeLegacyV3ToV4,jsonl/storage.ts:153-189),以及「为什么 tip 是一个 value 而不是一条 entry」——那就说明你真的读懂了这套设计。

常见错误:① 把脚本存成 .ts,在一个没有 package.json 的目录里会看到 Top-level await is currently not supported with the "cjs" output format(本书实测)——换成 .mts 即可;② header 的 version 不是 3,会抛 Invalid JSONL storage …: invalid header,其 cause 是 Unsupported JSONL session header(jsonl/io.ts:29-32、jsonl/codec.ts:62)——harness 只认 v3 与 format 4;③ 在文件末尾加一行 v0.87 CLI 自己会写的 {"type":"usage",…},会抛 Unsupported legacy v3 record type: usage(本书实测,legacy-v3.ts:216);④ 某行的 parentId 指向一个不存在的 id,会抛 Legacy v3 entry aaaa0002 has a missing or forward parent at line 4: …(本书实测)——注意这里与 CLI 的 loadEntriesFromFile 不同,harness 是**整个文件打不开**而不是跳过坏行。

对应源码位置:packages/agent/src/harness/session/jsonl/storage.ts:74-121(打开与分流)、:138-151(applyCommit)、:153-189(v3 升级)、packages/agent/src/harness/session/jsonl/legacy-v3.ts(v3 导入)、packages/agent/src/harness/session/session.ts:355-368(createBranch)、packages/agent/src/harness/session/context.ts:47-64(buildSessionContext)。

补充验证:想确认这些行为不是本书杜撰,在 pi 仓库根执行 npm test --workspace=@earendil-works/pi-agent-core -- test/harness/memory-conformance.test.ts test/harness/jsonl-storage-conformance.test.ts test/harness/jsonl-v3-migration.test.ts。本书在锁定 commit 上实测:3 个测试文件、120 个用例全部通过,无需任何 API Key。

本章小结 ​

  • Session 存储被拆成三层:Storage(存储后端契约,唯一写入口是事务 commit,全异步,entry 只增不改)、Session(领域层接口,StorageBackedSession 把「往分支追加」翻译成一次「插入 entry + 移动 tip」的事务,并用 MutationLine 串行化读改写)、SessionRepo(管一堆会话:create/open/list/delete/fork)。
  • 仓库里有两种格式。CLI 的 v3:一行一个 entry,公共四字段(type/id/parentId/timestamp)加 11 种 entry,其中 usage 记录非消息用量、context_edit 只追加地改写后续上下文。harness 的 format 4:一行一次事务,只有 4 种 entry,模型配置、标签、会话名、当前位置都变成了可覆盖的 value,用量进单独的账本;compaction 必带 retainedTail,是自包含检查点。
  • 树只靠两件事运转:写入侧「插入以 tip 为父的 entry 并移动 tip」,读取侧 scanBranch 沿 parentId 上溯并在最近的 compaction 处停下。分支 = 再多一个有名字的 tip,历史永不改写。
  • 三个后端:JSONL(重放配方,一行一次 appendFile,崩溃半行整体丢弃,靠注入的 FileSystem 与原子 renameFile 发布;能读 v3,首次写入时整体升级)、内存(与 JSONL 共用 InMemoryStorageState,用于测试与嵌入)、SQLite(独立包 pi-session-backend-sqlite-node,本 commit 下无生产使用者);三者跑同一套一致性测试。
  • 正式发布的 pi CLI 没有使用这套抽象(grep 可复核),SessionManager 仍是它上下文的唯一权威;实验性 server / session worker 已经在用。两种 JSONL 不能双向互读。
  • 关键术语:存储后端(Storage)、领域层(Session)、仓库层(SessionRepo)、entry(会话条目)、value(可覆盖的当前状态)、branch tip(分支末端指针)、事务(commit)、append-only(只追加)、能力接口(capability interface)、一致性测试(conformance suite)。
  • 关键源码索引:packages/agent/src/harness/session/types.ts(EntryBase:18、Entry:64、Storage:455、Session:530、SessionRepo:592)、session/values.ts(branchTip:158、laneConfig:160、sessionName:194、entryLabel:195)、session/session.ts(StorageBackedSession:224、createBranch:355、appendToBranch:422)、session/commit.ts:82-116、session/context.ts:10-64、session/in-memory-storage-state.ts(scanBranch:273)、jsonl/storage.ts(openV4:85、applyCommit:138、upgradeLegacyV3ToV4:153)、jsonl/repo.ts(sessionDirectoryName:36、fork:146)、jsonl/legacy-v3.ts:203-217、memory.ts(MemoryStorage:37、MemorySessionRepo:334)、packages/session-backends/sqlite-node/src/sqlite/repo.ts:157;CLI 侧 packages/coding-agent/src/core/session-manager.ts(SessionEntry:183、appendContextEdit:1358);规范 packages/agent/docs/harness.md;测试 packages/agent/test/harness/memory-conformance.test.ts、jsonl-storage-conformance.test.ts、jsonl-v3-migration.test.ts。
  • 自测问题:① Storage 里为什么没有 updateEntry?既然 entry 不能改,「会话名」「当前在哪」这类会变的东西存在哪里?② format 4 的「当前位置」和 v3 的 leaf 有什么不同?为什么 format 4 可以同时有好几个?③ 构建上下文时,scanBranch 为什么可以在遇到的第一个 compaction 处立刻停下?v3 为什么做不到?④ 把 JsonlSessionRepo 换成 MemorySessionRepo,使用 Session 的代码需要改几行?
  • 下一章:6.6 Context 构造与 Compaction——本章反复出现的 compaction entry 到底是怎么生成的、摘要由谁写。
  • 尚未展开:AgentHarness 如何用 pi.op.* 这组 value 把一次运行拆成可恢复的持久操作状态机(harness.md Part 3–4)属于 6.3 pi-agent-core:Agent 与循环 与 6.11 SDK:把 Pi 当作库使用 的范围;SQLite 后端的分段分支索引(branch_entries/branch_meta)与迁移机制、以及 custom entry 的投影器(entryProjectors)如何被扩展使用,留给 7.1 Extension 系统。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 因为 entry 树是 append-only(只追加) 的:commit 能写的六种东西里,entry 与用量行只能插入,不能更新也不能删除。这意味着历史永远不可改写——想「回到三步之前重来」不是删掉后面几条,而是在那个位置建一个新分支、往那里追加新 entry,两条时间线并存。会变的东西被专门挪到了 value:会话名是 pi.session.name,标签是 pi.entry.label/<id>,当前位置是 pi.branch.tip/<分支名>,模型与思考等级在 pi.lane.config/<lane>。value 可以覆盖、可以删除,而且只保留当前值,不保留历史。好处是历史随时可回溯、可分叉,「当前状态」又不必靠重放整条历史去推算;代价是 JSONL 文件只会越来越长(被覆盖的 value 行目前不会被回收)。
  2. v3 的 leaf 是 SessionManager 内存里的一个字段,不落盘,加载时直接取文件最后一个 entry;format 4 的当前位置是一个有名字的 value pi.branch.tip/<name>,和 entry 一样经事务写进存储、重启后原样恢复。因为它只是一个以分支名为键的 value,想要几个分支就写几个键——main 只是其中一个普通名字,harness 甚至可以让几条 lane 在同一棵树上各跑各的 Agent。
  3. 因为 format 4 的每个 compaction 都带着完整的 retainedTail:摘要加保留的尾部消息已经被复制进这个 entry,它是一个自包含的检查点,再往上的 entry 对上下文没有任何贡献,所以 newestFirst 扫描加 stopAtType: "compaction" 碰到它就停(in-memory-storage-state.ts:287-291)。v3 的 compaction 只存了一个 firstKeptEntryId 指针,保留的消息仍留在压缩点之前的原位,所以 buildContextEntries 必须先走完整条路径、再回头找到那个起点把区间拼回来(session-manager.ts:476-517)。
  4. 一行都不用改。调用方只依赖 Session 和 Branch 这两个接口;两个仓库返回的都是包着各自 storage 的 StorageBackedSession,行为由同一套一致性测试对齐。要改的只有构造仓库的那一行(以及 JSONL 版 create 要求的 cwd 参数)。这正是分三层的收益——领域层不知道自己写的是 JSONL 文件、内存 Map 还是 SQLite 表。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)