Skip to content

Part II · 源码篇 / Ch 13

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

小A用印章把发光的任务卡先盖进厚账册,老Z在旁递上下一张;身后传送带上三枚齿轮任务依次排队,一枚被气泡封存,远处灯塔与三格存储架守望。

本章导读: 源码篇的收官加餐(书号 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。一次问答在存储里长这样:

text
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()。把它的分支逐段读出来:

ts
// 提交主流程:回调 → 结算出原子写批 → 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。值得记住的语义有五条:

  1. 效果三明治:外部副作用被包在两次提交中间——先提交“意图”检查点,再执行外部动作,再提交结果。重开时停在“意图已提交、结果未提交”的相位,意味着效果可能已经发生,怎么处理由重放策略决定(下一节)。
  2. 无进展即 faulted:一个步骤回调若没改变检查点也没产出,调度器把任务记为 faulted——宁可显式失败,不静默空转。
  3. 重开调和:进程死时 running 的任务重开后回到 pending,检查点与中止标记原样保留;waiting 继续等待它等的子任务;completing 重新评估持有条件。
  4. 缺定义只阻塞:重启后若某任务类型的定义没装上,调度器给出派生的阻塞原因(missing_task 等),任务保持不终结;装上定义即恢复。这支撑“注册表与存储解耦”的部署方式。
  5. 归属树:任务拥有子任务和被它拥有的对话;中止自底向上先清算归属,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 钩子,然后——关键一步——先原子提交“持久化意图”(最终参数加重放策略),再执行工具:

ts
// 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 处理,判定规则只有这一行:

ts
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章 AgentSessionpi-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、宽松读取、宿主兜底。本章给的是另一份:单条原子提交线、不确定即停机、效果三明治加显式重放策略。两份答卷都回答“会话凭什么活过进程”,差异全部落在坏情况下——崩溃点不同,恢复行为就不同,而恢复行为是读源码读出来的,不是文档承诺出来的。

源码走查 ​

  1. 从 src/session/session.ts 的 #runCommit 出发,标出回调失败、空写批、StorageRejected、其他存储错误、采纳失败五条路径各自的后效;对照 src/errors.ts 的三行注释。
  2. 读 src/harness/tool.ts 的 call 相位:从解析、校验、beforeTool 到 { phase: "execute", replay } 意图提交,指出“先持久化再执行”发生在哪一行。
  3. 跟 execute 相位的重放判定:意图存储与当前声明为何缺一不可;重跑前为什么先清进度。再在 src/tools/ 里 grep replay,确认内置四工具全部缺省 unsafe 的含义。
  4. 读 admitSubmission:分别推演 requestId 重复、忙+follow-up、忙+steer、忙+reject、空闲 write 五种输入的落盘结果,与 README 的 Busy Conversations 一节对照。
  5. 打开 test/examples/13-recovery.ts 与 test/harness-tools-recovery.test.ts,用 faux provider 跑一遍,观察崩溃点不同产生的 interrupted / 重跑 / 续跑三种恢复。
  6. 对比 src/storage/jsonl/storage.ts 的提交顺序(sidecar→可选 fsync→主标记)与 src/storage/sqlite/node.ts 的 PRAGMA 设置,写下各自“进程崩溃”与“断电”两列的真实承诺。
  7. 读 src/harness/scheduler.ts 的重开调和:running→pending、waiting 保留、completing 重评各在哪段代码;missing definition 为何不终结任务。