「pi durable」指的是 pi 团队(earendil-works)在 2026-10-01 发布的新包 @earendil-works/pi-durable(v1.0.0,2026-10-05 已迭代到 1.0.4)。来源是 pi 仓库的 packages/durable 目录(README + CHANGELOG + 设计文档 spec.md)。它不是一个新 CLI,而是一个持久化(durable)agent 运行时:对话记录、每轮模型调用、工具执行过程、以及应用自定义状态,全部先写入存储、提交成功后才对外展示;进程中途死掉,重新打开存储就能从断点继续跑完未完成的工作。
本文不重复功能清单,而是拆它在代码层面怎么把「杀掉进程也能找回并续跑」变成现实。为了让解释到位,我把仓库里的核心实现文件逐个拉下来读了:存储层(SQLite schema 与提交)、会话层(事务协议)、调度层(恢复入口)、生成层(流式提交)。
总览:三个层次的分工
┌─ 存储层 (Storage) 原子提交 + 崩溃时数据不撕裂
├─ 会话层 (Session) 单条 mutation line,提交成功后对外可见
└─ 运行时层 (Harness) 一切工作都归为"持久化的任务状态机"
核心思想一句话:每条 agent 工作都先落盘、再对外,且把「正在进行」拆成小步,每步结束前提交一个 checkpoint。这样崩溃点永远落在两个已提交状态之间,恢复就是把状态机指针挪回最后一个 checkpoint 继续跑。
一、存储层:原子提交怎么保证「要么全落盘,要么全没有」
SQLite 后端(storage/sqlite)
- 整个 schema(
migrations.ts)里,commit()是一次db.transaction(...)——所有 writes(追加条目、更新任务、改文档)在一个 SQLite 事务里完成,BEGIN…COMMIT的原子性就是提交边界。 - README 写明配置:WAL 模式 +
synchronous = NORMAL。进程崩溃(kill -9、段错误)时已提交事务全部存活;只有断电/主机故障时可能丢最新一个 commit。 - 全局计数器
durable_metadata(next_id, next_seq)每提交一次next_seq + 1;每个记录带commit_seq,所以能回答「某个 entry 时刻的文档是什么样」(snapshotAsOf)。 record_ids表防 ID 碰撞;task/submission 用ON CONFLICT(id) DO UPDATE做状态覆盖写——状态更新也是事务里的 upsert。
JSONL 后端(storage/jsonl)—— 双阶段提交协议
它是 append-only 日志,但设计了一个「提交点」:
- sidecar 文件先写:可变内容(task 完整 JSON、document 的 base/delta)追加到
task-<id>.jsonl、doc-<id>.jsonl,可选fsync()(options.fsync)。 - main 文件最后追加一个 marker:只含该提交的轻量 writes 描述(指针),fsync 与否决定是否抗断电。
- 内存态在 marker 落盘后才
apply()。
崩溃恢复 recover() 时:
- 读 main 的 markers,校验
seq严格递增(损坏检测); - 扫描所有 sidecar,建索引,只采用被某个 marker 引用的 sidecar 记录。
结论:如果死在「sidecar 已写、marker 未写」之间,那些 sidecar 数据被整体丢弃——内存态一点没变,等于这次提交从未发生。这就是 append-only 日志上的原子性。
会话层(session/transaction.ts + session.ts)
-
SessionImpl用一条 mutation line(#enqueue的 Promise 链)把所有 commit 串行化,且「只有已提交状态可被观察」(Only committed state is observable)。 -
一次提交的完整路径
#runCommit:回调 → tx.settleSuccess()(prepare 所有文档变更、组装原子批次) → await storage.commit(writes) ← 唯一落盘点 → tx.adopt(seq) ← 内存指针切换 → #publish(seq, writes, docs) ← 通知观察者 -
任一环失败 →
tx.discard()→ 所有已 prepare 的变更abort();存储已接受但 adopt 失败的罕见情况会让 session 进入 poison 状态,要求重开。 -
commit callback 内部还禁止「先写后读」(
ReadAfterWrite抛错),保证事务内读到的都是确定的提交前状态。
二、「已经发生的」怎么找回:一切皆为不可变记录
- 转录:
entries表只插不改(head是指针,指向链条位置);user / assistant / tool-result / system 全部是一条条不可变 entry,带commit_seq。重启后scanEntries原样读回完整对话。 - 任务:
tasks表每条任务就是一整条运行轨迹(kind、input、owner、status、checkpoint、outcome)。 - 文档:应用自定义状态(
pi.agent、pi.usage,以及扩展定义的自定义文档)都存成追加式 delta 链 + base(document_revisions表),base + apply(delta…)物化出当前值;checkpointWhen谓词在空闲时强制写一个 base 压缩链长。 - 队列:
submissions表(queued → placed → done/unanswered)既是对外承诺,也是断点。
所以「找回」没有魔法:要找回的东西从来就在存储里,重启后只是重新打开同一个文件。会话旁边还存了 pi.agent(该会话的模型/工具配置)、pi.live(运行中的生成与工具输出)、pi.inbox(排队消息)、pi.usage(token/费用)等内置文档。
三、「正在发生的」怎么续跑:任务 = 持久化状态机
这是最核心的部分。所有会话工作都被拆成内建任务(pi.generation 调模型、pi.tool 跑工具、pi.compaction 压缩),每个任务在 tasks 表里有一条记录,含:
state: { status: "running"/"pending"/"waiting"/"completing"/"terminal",
checkpoint: { phase, attempt, … } }
一次模型请求就是一个五阶段状态机(harness/generation.ts):
prepare → request → (retry | poll | tools) → terminal
prepare:渲染系统提示、装 tool 集,checkpoint 里记录attempt、是否在等 compaction;request:checkpoint 记录model、thinkingLevel、streamOptions、cutoff(这次请求包含的最新 entry id)——恢复后重发请求时用的是完全相同的消息尾巴和选项;retry:checkpoint 记录until(重试时刻),恢复后睡到该时刻再重发;poll:deferred 响应把 provider 的handle+pollAt存进 checkpoint,恢复后继续轮询;tools:checkpoint 记录assistant(工具调用轮的那条 assistant entry)、tools(已启动的工具任务列表)、pending(顺序轮里还没启动的调用)——中断恢复后会从列表继续启动剩余调用。
崩溃恢复的确切动作(harness/scheduler.ts)
Harness.open(storage) 的 openTasks() 里有一个专门的恢复事务:
for (const record of records) {
this.#live.set(record.id, record);
if (record.state.status === "running") {
tx.setTask(withState(record, { status: "pending", checkpoint: record.state.checkpoint }));
}
// failFast 等待者重新加入检查集
}
即:所有存活的 running 任务改回 pending,checkpoint 原样保留(waiting 的保留 on 依赖)。然后 harness.resume() → 调度器 #drain() → #reserve()(在同一事务里把任务重新标为 running、建立 invocation)→ #run() 循环:
for (;;) {
current = await #step(...) // 决策:abort 标记?fault?checkpoint 有进展?
await phases[current.checkpoint.phase](current, runtime, ctx) // 从断点 phase 继续
previous = { checkpoint }
}
调度器还会校验「本轮返回时必须 checkpoint 有实质进展」(jsonEqual(current.state.checkpoint, previous.checkpoint) 且无进展 → fault),防止死循环空转。
工具调用的中断语义
- 工具在第一轮提交里就把意图(intent)落盘,然后才执行
execute(); - 进程死在调用中间:工具声明了
replay: "safe"则重启后重跑(配合 requestId 幂等,找到同一个 child/submission);否则模型收到一个interrupted错误结果,结果里带上已经提交的部分输出; - 每轮调用各自是一个 durable 任务,所以工具间可以并行、可以等待。
任务之间还能组成依赖图,支持 failFast / allSettled 两种等待方式;子 agent(前台/后台 subagent)可 steer、可 Esc 中止、崩溃后同样能恢复。
四、「正在发生的」的精细控制:100ms 流的渐进提交
「正在发生的工作也能找回」靠的是 pi.live 文档 + 节流提交(generation.ts: streamResponse):
const interval = runtime.settings.progress.partialIntervalMs; // 默认 100ms
...
for await (const event of events) {
if (event.type === "done" || event.type === "error" || ...) continue;
pending = event.partial;
if (timer === undefined && inFlight === undefined) timer = setTimeout(flush, interval);
}
// finally: clearTimeout(timer); await inFlight;
- 流事件循环里,模型逐步产出的 partial 被节流:最多每 100ms 合并成一个 commit,写入
LiveDoc.generation.message(以 delta 形式提交,非常小); - 同一时刻最多一个 commit 在飞行(
inFlight单飞行),finally里await inFlight保证最后一块 partial 不会漏; live.generation.message里存的是模型 as-of 当前的完整 partial(provider 还在持续 mutate 它,所以拷贝copyJson后再提交);- 同样节流的还有工具运行中输出(
ToolSlot.output,outputIntervalMs参数)。
所以进程崩溃最多丢 100ms 的生成窗口,这是官方在 README 里明确写的:
Partial answers and tool output are committed at most every 100 ms by default, so a crash loses at most that window.
中断善后还有一个细节:如果模型已经产出了 partial 但没说完,settleSchedulerOutcome / 任务 abort handler 会把已提交的 partial 转成一条 stopReason: "aborted" 的 assistant entry(convertPartial)——转录保持完整,后续继续对话时模型能看到自己被砍掉的话,pi.usage 也照常记账。
五、幂等:为什么恢复后不会「重复干活」
- 投递幂等:
submissions表建了(conversation_id, request_id)唯一索引;submit({requestId: "greeting-1"})重复调用返回同一个 submission(again.id === submission.id)。恢复后按 ID 重新拿回 submission 继续wait()。 - 工具重跑幂等:
replay: "safe"的工具用同一个 requestId 创建 child/submission,重跑时找到同一个,不会双发副作用。 - request 重发一致:generation 的
requestphase checkpoint 存了streamOptions和cutoff,恢复后的新请求与中断前的请求读的是同一条转录尾巴。 - provider 会话亲和:每个 conversation 在
pi.provider里持久化一个 UUIDv7 作为sessionId传给 pi-ai(prompt cache / session affinity),重启、重试、compact、reset 后仍用同一身份。
六、边界与仍然会丢的东西(README 明示)
| 场景 | 结果 |
|---|---|
| 进程被 kill / 崩溃(SQLite WAL NORMAL) | 已提交全存活;丢失 ≤ 100ms 流窗口 |
| 断电 / 主机故障(SQLite) | 可能丢最新一个 commit |
| JSONL 不开 fsync | 依赖 OS 缓存;开 fsync: true 后提交即落盘 |
| 跨进程 | 单进程独占 storage,没有跨进程锁;另开进程写同一文件 = 未定义行为 |
| API 稳定性 | 官方标注 Experimental,checkpoint 格式/API 可能不预告地变 |
七、和主 CLI 的关系
- 它是一个独立的 npm 包:
npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord,你自己在 Node 里组装(类似 SDK);核心不依赖 Node,可在 Bun 或 Cloudflare Durable Objects 里跑。 - 仓库里另有一个实验性的小型 TUI 编码 agent(
packages/coding-agent/src/experimental/durable),是「pi-durable + pi 的 TUI/模型运行时」缝合的演示壳:node .../main.ts --continue直接续跑上次会话,会话存~/.pi/agent/experimental/durable-sessions/<cwd-hash>/<session>/session.sqlite。 - 官方 API 层面(本机装的 1.0.3)也引入了一批 pi-agent-core 的 v4 session API(durable operation records 等),但那是底层基础设施;「pi durable」这个新发布指的是上面这个包 / 实验 CLI。
- 扩展系统方面,它支持 tools / 系统提示词 sections / hooks(拦截或改写工具调用)/ wrappers,运行中可热重载;对话管理方面有压缩(compaction)、reset/handoff、fork,以及并发提交的队列策略(steer / follow-up / reject);UI 侧提供
viewState()/watch()拿到精确的结构视图与 commit ops,另有 coding-agent 风格的 agent events 流。
八、对一份流行总结的两处修正
有人把它的本质总结成三点:「把 agent 任务执行变成数据库式存储」「幂等与中断补偿」「状态机保证可控与可追溯」。三点都抓到了核心,但第一点和第三点各需要一处修正。
第 1 点,「数据库式的存储」只说对了一半。 它不只是「用数据库存任务」,而是把数据库的事务语义提升为整个 agent 运行时的执行协议:
- 不只是存储层有原子提交,「所有修改对外可见之前必须先提交」是语言级约束(
Only committed state is observable、事务内禁读己之写),连会话层都在维护一条 mutation line 串行化所有 commit; - 而且不止持久化「已完成」的工作——进行中的工作也持久化(100ms 节流提交 partial 到
pi.live),这才是「正在发生的工作也能找回」的关键。
第 3 点,状态机的作用不止是「可控与可追溯」,它本身就是续跑的载体。
- checkpoint 不只是审计用的记录,它就是恢复指针——崩溃后 scheduler 把所有
running任务带着原样 checkpoint 改回pending,然后从 checkpoint 记录的 phase 重新进入#run()循环; - 状态转换(running → pending → running)本身就是恢复机制的一部分,可追溯只是附带的结果(每一跳都有
commit_seq、不可变转录佐证)。
第 2 点总结得最准,不需要修正——幂等在三个层面(submission 投递、工具重跑、request 重发一致)都有对应实现,中断补偿则通过 interrupted 错误结果携带已提交的部分输出。
一句话版本:「存储事务化 + 状态机 checkpoint + 幂等补偿」三者叠起来,前两者回答「断在哪、从哪续」,第三者回答「续的时候不会重复干活」。
一句话总结实现原理
它把「agent 运行」编译成「不可变转录 + 原子提交 + 带 checkpoint 的任务状态机」,每 100ms 就把进行中的产出也提交进持久化文档;崩溃后重新打开存储,把所有 running 任务(带着原样 checkpoint)改回 pending 重新调度,于是对话、已发生的工作、甚至进行到一半的模型生成的末尾部分,都从最后一个已提交状态继续。
可继续深挖的方向:SQLite 的 delta 链物化细节、Decide 的优先级规则、实验 CLI --continue 的接线。