← blog 技术原理与实践 · 2026-10-06

pi durable 是怎么把「杀掉进程也能续跑」做出来的

读 pi 仓库 packages/durable 的源码实现:存储层的原子提交、会话层的事务协议、任务状态机的 checkpoint 与崩溃恢复入口、100ms 节流的部分产出提交,以及三层幂等设计,并修正「数据库式存储 + 状态机可追溯」这类总结里不准确的部分。

Short read

「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 日志,但设计了一个「提交点」:

  1. sidecar 文件先写:可变内容(task 完整 JSON、document 的 base/delta)追加到 task-<id>.jsonl、doc-<id>.jsonl,可选 fsync()(options.fsync)。
  2. main 文件最后追加一个 marker:只含该提交的轻量 writes 描述(指针),fsync 与否决定是否抗断电。
  3. 内存态在 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 的 request phase 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 的接线。

Sources

No external sources for this entry.

Related

2026-10-09 · blog

拆解 Codex 的 /goal:持久化目标系统、自动续跑与状态一致性

在 Codex 里输入 /goal 得到的不是一条注入模型的 prompt,而是一台持续运转的状态机:TUI 命令穿过 app-server RPC 落进独立 SQLite,运行时逐 token 计量消耗,线程空闲时自动开始下一轮,直到目标完成、卡住或预算耗尽。本文从 TUI 到 SQL 逐层拆开这台机器,重点讲它的核心架构决策——状态写入拆成「模型可写的子集」和「系统独占的派生状态」两条路径,一致性靠 SQL 层守卫与原子计量保证,而不是信任模型的自报。技术原理与实践 · 14 min read
2026-10-06 · blog

pi session tree 的实现原理:/tree、/fork、/clone 到底动了什么

读 pi 源码(session-manager、agent-session、agent-session-runtime、interactive-mode)拆解 session tree 的实现:append-only JSONL 的 id/parentId 指针、leafId 活动指针、getBranch 的路径提取,以及 /tree 移动指针、/fork 提取路径到新文件、/clone 复制活动分支三者的真实差异,并修正「fork 仍在原文件」「clone 复制子树」两处常见误解。技术原理与实践
2026-10-05 · blog

从 fire-and-forget 到可恢复发布:mini-agent 博客链路的持久化状态机重构

一次完整的架构重构复盘:用持久化发布状态机、Transactional Outbox、隔离 Git worktree 和 rebase/CAS,替换掉 fire-and-forget 任务、不可靠的文件 watcher 和"pull --rebase 赌运气"的同步方式,并记录收敛过程中被 review 和故障注入揪出来的每一个真实缺陷。技术原理与实践 · 25 min read
On this page 总览:三个层次的分工一、存储层:原子提交怎么保证「要么全落盘,要么全没有」SQLite 后端(storage/sqlite)JSONL 后端(storage/jsonl)—— 双阶段提交协议会话层(session/transaction.ts + session.ts)二、「已经发生的」怎么找回:一切皆为不可变记录三、「正在发生的」怎么续跑:任务 = 持久化状态机崩溃恢复的确切动作(harness/scheduler.ts)工具调用的中断语义四、「正在发生的」的精细控制:100ms 流的渐进提交五、幂等:为什么恢复后不会「重复干活」六、边界与仍然会丢的东西(README 明示)七、和主 CLI 的关系八、对一份流行总结的两处修正一句话总结实现原理