源码篇第13章 先落盘,后可见——pi-durable

本章导读: 源码篇的收官加餐(书号 42,阅读位置在源码篇第12章之后)。读
packages/durable:session/session.ts的原子提交线、harness/scheduler.ts的任务检查点、harness/tool.ts的重放规则、storage/的耐久阶梯。这一章回答源码篇第5章留在最后的问题——进程死在工具执行到一半,重启后谁来收尾。pi-durable的 README 自称 Experimental:API 在版本间无通告变更;本章只描述v1.0.0快照的行为,不做接口稳定性承诺。
源码篇第5章读完,小A已经能说出 AgentSession 靠什么活过重启:每条消息同步追加进 JSONL,读取时跳过坏行。他把笔记本翻回那页的边界表,指着一行问老Z:"_persist 没有 fsync,坏行静默跳过——那如果进程正好死在工具执行到一半呢?会话文件里只有‘模型说要调这个工具’,没有结果。重启后谁来收尾?”
“源码篇第5章的答案是宽松的:读不懂的行跳过,中断的回合留在历史里等宿主处理。”老Z说,“pi-durable 走的是另一条路:任何一步先原子落盘再对外可见,重启后由任务检查点接着跑,或明确判定不能重跑。这一章把那条‘先落盘、后可见’的内核读一遍。”
本章依据上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32)、@earendil-works/pi-durable 1.0.0。包内规范文档自称 Pico5,发布包叫 pi-durable:docs/spec.md 是规范性契约,src/ 是它的实现。本章以 src/ 为已确认事实,spec 用于核对语义措辞。注意它的依赖只有 pi-ai、chord、diff 和 typebox——不依赖 pi-agent-core;coding-agent 只在被构建排除的 src/experimental/ 里使用它。它是与 Agent+循环+AgentSession 并列的另一种宿主内核,不是源码篇第5章那条链的换皮。
边界:
Storage.commit()只保证“已提交状态被 Session 发布”。断电耐久强度取决于 backend 配置;JSONL 默认 fsync 关闭,SQLite 使用synchronous=NORMAL。
全景:对话是日志,运行是任务
README 的 Quick Start 给了最小骨架:Harness.open(storage, { models, registry }, context) 打开一个存储上的 Session;root() 取根对话;submit({ type: "input", content }) 持久受理一个输入;submission.wait(context) 等它 answered。一次问答在存储里长这样:
submit(input) → pi.user 条目
pi.generation 任务 → pi.assistant(工具调用)
pi.tool 任务 × n → pi.tool-result × n (归 generation 所有,它等齐再继续)
pi.generation 任务 → pi.assistant(最终答案) → submission done四个概念各司其职:Entry 是不可变转录记录;Commit 是原子写;Document 是存在转录旁边的类型化 JSON 状态(pi.agent、pi.live、pi.inbox、pi.usage);Task 是带检查点的持久状态机。核心规则写在 spec 开头:所有变更走同一条原子提交线;只有已提交的状态可被观察。 没有“先显示后补写”的通道。
比喻:台账与工位
源码篇第5章的
AgentSession像收银员边收钱边往本子上记,本子写坏了跳过那行继续;pi-durable像银行柜台:每笔业务先盖章进总账,章没盖完客人根本看不到结果。两种“活过重启”,一种宽容,一种较真。
一条提交线,和它的三种失败
内核入口是 src/session/session.ts 的 #runCommit()。把它的分支逐段读出来:
// 提交主流程:回调 → 结算出原子写批 → Storage.commit → 内存采纳 → 发布。(session.ts 节选思路)
result = await change(tx); // 回调抛错 → settleFailure(),草稿丢弃,什么都没发生
const writes = await tx.settleSuccess();
if (writes.length === 0) { tx.discard(); return result; } // 空写批 = 未提交,不算持久化
seq = await this.#storage.commit(writes, withoutAbortSignal(context));
// 一旦存储受理,调用方取消不再中断结算
// 存储抛错:只有 StorageRejected(承诺未产生任何持久效果)不投毒;其余一律 #poison
documents = tx.adopt(seq); // 存储已提交而采纳失败 → 也投毒:内存落后于持久状态
this.#publish(seq, writes, documents, context);配合 src/errors.ts,三类错误给出三种恢复答案:
| 错误 | 含义 | 正确恢复 |
|---|---|---|
ReadAfterWrite | 事务在首个表写之后又去读表——确定性编程错误 | 把读取挪到写之前;这是代码缺陷不是运行时状况 |
StorageRejected | 存储在任何持久效果前整批拒绝 | 同一 Session 可安全继续,无需重开 |
| 其他存储失败 | 结果不确定 | Session 投毒,必须关闭重开:内存与持久状态已可能不一致 |
投毒是最能体现这套设计取向的一点:它宁可不干活,也不带着“不知道写没写进去”的状态继续。对照源码篇第5章的 SessionManager——那边坏行静默跳过、写入尽力而为;这边不确定即停机。两种哲学都在 Pi 的 1.0 快照里,读源码时要分清哪条承诺属于哪个运行时。
事务层还有一条使用侧约束:表读取必须发生在首次表写之前(先读齐再写),文档草稿在写批内可继续编辑,提交参数在 Storage.commit() settle 前被借用、之后归存储。这些都是 session/transaction.ts 与 spec §4 可核对的形状。
任务:状态机、效果三明治与检查点
defineTask(src/tasks.ts)定义一个任务:输入、检查点、结果、若干 handler 相位。调度器(src/harness/scheduler.ts)持久化的状态是 pending | running | waiting | completing | terminal,外加它自己写入的 faulted 与 orphaned。值得记住的语义有五条:
- 效果三明治:外部副作用被包在两次提交中间——先提交“意图”检查点,再执行外部动作,再提交结果。重开时停在“意图已提交、结果未提交”的相位,意味着效果可能已经发生,怎么处理由重放策略决定(下一节)。
- 无进展即 faulted:一个步骤回调若没改变检查点也没产出,调度器把任务记为
faulted——宁可显式失败,不静默空转。 - 重开调和:进程死时
running的任务重开后回到pending,检查点与中止标记原样保留;waiting继续等待它等的子任务;completing重新评估持有条件。 - 缺定义只阻塞:重启后若某任务类型的定义没装上,调度器给出派生的阻塞原因(
missing_task等),任务保持不终结;装上定义即恢复。这支撑“注册表与存储解耦”的部署方式。 - 归属树:任务拥有子任务和被它拥有的对话;中止自底向上先清算归属,
background: true创建的边界则能活过父任务的中止。faulted/orphaned是调度器的事实记录,不是业务结果。
README 的 Child Tasks 一节给了 waiting: { on, policy } 的例子(checkout 拥有四笔 payment、failFast);可运行对照在 test/examples/24-child-tasks.ts。
对话与提交:忙、排队与幂等
src/harness/submissions.ts 的 admitSubmission 是唯一的受理核心,规则可以逐条核对:
- 带
requestId的提交先在本对话查已有记录:同 ID 同形状返回原 submission,重复提交不会投两次;同 ID 不同形状抛错。这是崩溃后安全重试的入口。 - 对话忙(
pi.live.run存在)时按whenBusy分流:默认 follow-up 排进pi.inbox;steer插入当前工具轮之后加入进行中的 run;reject抛ConversationBusy且不落任何记录。 - 空闲的 input 追加
pi.user、记录 placed、启动pi.generation;空闲的 write 直接追加条目并 settle done。 wait()的检查与注册在同一条提交线上完成,不会漏掉两次读取之间发生的落定。
观察侧对应 README 的 Watching 一节:viewState() 给出结构化的只读状态,watch() 按提交推送精确的 Chord 操作;落后超过 100 帧改发一帧整体替换(observation.ts 的 MAX_PENDING_WATCH_FRAMES = 100);流式部分响应以 100 毫秒节流提交,且同一时间最多一笔提交在途;工具输出的提交间隔至少为 max(100ms, 写入量 / 100KiB/s)。这些是提交频率限制,不是崩溃损失上限:调度延迟、在途写入和存储耐久性都影响恢复边界;观察侧只显示已提交状态。
重放安全:这一章的心脏
src/harness/tool.ts 把一次工具调用做成任务,检查点只有两相:{ phase: "call" } 和 { phase: "execute", arguments, replay }。call 相位一口气完成解析工具、校验参数、跑 beforeTool 钩子,然后——关键一步——先原子提交“持久化意图”(最终参数加重放策略),再执行工具:
// harness/tool.ts(v1.0.0,节选):先提交"持久化意图"再执行
const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const;
return { status: "running", checkpoint: intent };进程若在 execute 相位之间死掉,重开时任务进入 execute 处理,判定规则只有这一行:
if (replay === "safe" && tool?.replay === "safe") {
// 存储的意图与当前声明都说 safe:清掉中断 attempt 已发布的进度,从头重跑
}
// 否则:给模型一条 interrupted 错误结果,
// "Tool <name> was interrupted and may have partially run",附已提交的部分输出两个条件缺一不可:意图里存的是当时的策略,当前注册表里工具现在还必须是 safe——策略收紧后旧意图不会继续被重放。重跑前还要清掉中断 attempt 发布的进度,从新开始上报。不是 safe 的,模型拿到 interrupted,明确知道“可能部分执行过”,由它决定下一步。
一个反直觉的事实:内置的 read/write/edit/bash 都没有声明 replay(src/tools/ 全文检索无 replay 出现),缺省即 unsafe——崩溃恢复时它们一律 interrupted,绝不重跑。README 的子代理示例展示了相反的一端:subagent 工具声明 replay: "safe",配合按 ownerTaskId 查已有子对话、由 api.taskId 派生 requestId,重跑找到同一个孩子、不重复投递。safe 不是给工具贴的标签,而是一整套幂等设计的承诺。
Harness 自己写入的错误码集合可在 tool.ts 与 spec §7.3 对照:tool_unavailable、invalid_arguments、blocked、interrupted、aborted、tool_error。前四个发生在执行之前或恢复之时,tool_error 是工具自己抛错的结果,aborted 带取消意图。默认输出限额与 coding-agent 同源:2000 行 / 50 KiB(truncate.ts)。
这一节的恢复边界不是纸面推演:test/harness-tools-recovery.test.ts 逐个测了意图已提交后 unsafe 中断、双方 safe 重跑、重跑用当前 cwd、执行前中断重跑钩子、进度清除后重跑等路径;生成任务的恢复(部分回答落定、retry 退避续跑、no_model)在 test/harness-generation-recovery.test.ts;任务级崩溃矩阵在 test/harness-tasks-recovery.test.ts。
存储耐久阶梯
| 后端 | 耐久承诺(快照原话核实) | 注意 |
|---|---|---|
MemoryStorage | 不落盘 | 演示与测试 |
SQLite(openNodeSqliteStorage) | journal_mode = WAL、synchronous = NORMAL:提交活过进程崩溃;断电或主机故障可能丢最新提交 | 每次 Session 提交对应一个 SQL 事务 |
JSONL(openNodeJsonlStorage) | 追加式目录:sidecar 先落盘(fsync 默认关,传 { fsync: true } 才在每个提交标记前刷盘),最后追加主标记——标记成功才算提交 | 恢复时清理撕裂尾行与未确认 sidecar;初始实现不做全局压缩 |
三条共同硬边界:同一时刻只有一个进程拥有存储,没有跨进程锁;结果不确定的存储失败会投毒 Session,必须重开。README 另记一笔成本:包根加载 TypeBox,未打包约 23 MB 峰值 RSS,tree-shaken 后约 4 MB。
和源码篇第5章对照着读
| 维度 | 源码篇第5章 AgentSession | pi-durable |
|---|---|---|
| 持久化模型 | 每事件同步 appendFileSync,懒建文件 | 所有变更走一条原子提交线,先落盘后可见 |
| 坏状态策略 | 读取宽松:坏行静默跳过 | 不确定即投毒:重开才能继续 |
| 中断回合 | 留在历史里,宿主自行处理 | 任务检查点续跑,或按重放策略给出 interrupted |
| 与循环的关系 | 产品层装配 Agent 的插口 | 不依赖 pi-agent-core,自带 generation/tool/compaction 任务 |
| 定位 | 发布 CLI 的实际会话内核 | 实验性独立内核,experimental 宿主使用 |
两条路线回答同一个问题——“会话怎么活过进程重启”——给出的却是两种工程人格:一条信任追加与宽容,一条坚持原子与显式。理解 Pi 1.0 的会话设计空间,需要两章都读。
本章没有覆盖:Chord 本体(packages/chord 的 CRDT/操作语义)、src/experimental/durable/ 宿主应用、断电情形下的持久性证明(README 明说最新提交可能丢失)、以及对不协作的扩展代码的强制终止(spec §13 非目标)。pi-durable 的 API 自述为实验性,升级到任何新版本后本章的符号与时序都要重验。
本章回看
概念篇第10章把 Memory 拆成“存什么、何时取、丢了怎么办”;源码篇第5章给了 AgentSession 的答卷:追加式 JSONL、宽松读取、宿主兜底。本章给的是另一份:单条原子提交线、不确定即停机、效果三明治加显式重放策略。两份答卷都回答“会话凭什么活过进程”,差异全部落在坏情况下——崩溃点不同,恢复行为就不同,而恢复行为是读源码读出来的,不是文档承诺出来的。
源码走查
- 从
src/session/session.ts的#runCommit出发,标出回调失败、空写批、StorageRejected、其他存储错误、采纳失败五条路径各自的后效;对照src/errors.ts的三行注释。 - 读
src/harness/tool.ts的call相位:从解析、校验、beforeTool到{ phase: "execute", replay }意图提交,指出“先持久化再执行”发生在哪一行。 - 跟
execute相位的重放判定:意图存储与当前声明为何缺一不可;重跑前为什么先清进度。再在src/tools/里 grepreplay,确认内置四工具全部缺省 unsafe 的含义。 - 读
admitSubmission:分别推演requestId重复、忙+follow-up、忙+steer、忙+reject、空闲 write 五种输入的落盘结果,与 README 的 Busy Conversations 一节对照。 - 打开
test/examples/13-recovery.ts与test/harness-tools-recovery.test.ts,用 faux provider 跑一遍,观察崩溃点不同产生的 interrupted / 重跑 / 续跑三种恢复。 - 对比
src/storage/jsonl/storage.ts的提交顺序(sidecar→可选 fsync→主标记)与src/storage/sqlite/node.ts的 PRAGMA 设置,写下各自“进程崩溃”与“断电”两列的真实承诺。 - 读
src/harness/scheduler.ts的重开调和:running→pending、waiting 保留、completing 重评各在哪段代码;missing definition 为何不终结任务。
