Record the pi durable review and the four recovery fixes

Why: Pico5's package is a contract sketch, but its commit and recovery rules match four verified gaps in how runs survive a hard kill.
Evidence: Reviewed earendil-works/pi @ c7cdb460a, packages/durable and docs/pico-v5.md, against the skeleton section 4.2 and the current backend store, run manager, and SSE handler.
Impact: Documents only. The record rejects taking the package as a dependency and lists what the matching backend task changes.
Refs: Dev feature/pi-durable-borrow
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 14:27:18 +00:00
co-authored by Cursor
parent 76044b5895
commit 02a3441eb7
@@ -0,0 +1,83 @@
# 调研:pi `packages/durable`(Pico5)有什么可以借鉴
- 状态:记录(2026-10-03);owner:weichen;任务:Dev 仓 `feature/pi-durable-borrow`
- 对象:本机参考克隆 `/home/weichen/projects/Agent-System/.refs/pi`,`origin` 为 `https://github.com/earendil-works/pi.git`,HEAD `c7cdb460aa8a0cebef3446c4166729b8a0d97ead`(2026-09-21,`fix(coding-agent): reject bug reports offline`)。包版本 `@earendil-works/pi-durable` 0.86.1,MIT(仓库根 LICENSE 版权 2025 Mario Zechner;`packages/durable/package.json` 的 `license` 为 MIT)。
- 对照的本仓事实以 Dev `a5c9fd3385` 为准。库层对照的是在审提案 [2026-10-02-agent-stack-completion.md](../proposals/2026-10-02-agent-stack-completion.md)(Documents PR #34,撰写时未合入)及其审计记录。
结论先说:**不把这个包或 Chord 当成依赖引进来。** 它现在只有记录契约和一份内存存储。值得拿走的是几条恢复规则,而且用我们已有的表和事件词汇就能落地。本任务在 Backend 修四个已经核实的缺陷;库层规则写在这里,等提案合入后再去链接,不改那份在审提案。
---
## 1. 这个包实际有什么
`packages/durable` 的 README 写明公开 API 是持久记录契约和一份脱离调用方的内存存储。交接文档 `docs/pico-v5-handoff.md` 的状态栏写着:旧的 pico、pico4 原型已删,pico3 还在,**No Pico5 implementation exists**。24 个实现包里,代码只覆盖第 1 包:
| 文件 | 内容 |
|---|---|
| `src/types.ts` | `ConversationRecord`、`EntryRecord`(`model` / `data` / `head` / `edits`)、`Input` 四态、`TaskRecord` / `TaskState` / `TaskOutcome`、`DocumentRecord`、`Storage` |
| `src/memory-storage.ts` | `MemoryStorage`:原子提交、只读副本、分叉历史扫描、任务整行替换 |
| `test/memory-storage.test.ts` | 12 个存储行为测试 |
| `docs/pico-v5.md` | 规范。Session、文档、任务、收件箱、存储后端都在这里,没有对应实现 |
同仓里真正跑起来的同类代码在别的包,不在 `durable`:
- `packages/agent/src/harness/pico3/`:实验内核,约 8000 行,导出路径是 `@earendil-works/pi-agent-core/experimental/pico3`。交接文档要求只把它当参考,不要搬它的外观接口。
- `packages/agent/src/harness/session/types.ts`:工具调用状态含 `effect_pending`,并带 `replay: "never" | "safe"`。这是 Pico5「先记意图再执行」在现行 harness 里的落地,比规范更值得对照。
- `packages/coding-agent` 的会话仍是 JSONL 树,**不依赖** `@earendil-works/pi-durable`。
- 依赖的 `@earendil-works/chord` 0.86.1 也是 MIT,但 `packages/chord/PLANNING.md` 写明 **not a stable public API contract yet**。
因此评级是 **Reject:不引入 `@earendil-works/pi-durable`,也不引入 Chord。** 语言栈是 TypeScript,我们的执行与存储在 Go;包本身没有 Session、调度器或 SQLite 后端可以链接。
## 2. 规范里值得单独拿走的规则
Pico5 的核心句(`docs/pico-v5.md` 开头):一次 Session 提交原子地写下不可变条目、完整任务记录和文档;只有提交成功的状态可被观察到。下面每条都标了我们这边的落点。
### 2.1 采纳,本任务在 Backend 实施
这四条不改变骨架设计 §4.2「不做透明恢复」。它们补的是那条决策已经承诺、但硬杀路径没有做到的事。
| 规则 | Pico5 出处 | 我们的缺口(修复前) | 落点 |
|---|---|---|---|
| 重开时把活着的任务收成终态 | 规范 §5.1:打开时把残留的 `running` 改回可调度;未知任务种类收成 `orphaned` | `InterruptRunning` 只在收到 SIGINT/SIGTERM 时遍历**内存里**的 run(`app.Run` 停机路径)。进程被硬杀后,库里的 `pending` / `running` / `waiting_hitl` 没有启动扫描。前端 `activeRun` 把一切非终态都当成进行中,outbox 因此不再发送 | 启动对账。SQLite 扫描全部;PostgreSQL 只扫描本实例(外加升级前 `instance_id` 为空的行)。收成 `interrupted`,未决 HITL 以 `cancelled` 关闭,发出已有的 `run.failed`(码 `run.interrupted`)。不新增 Core 状态 |
| 输入先准入,再落库;用请求键去重 | 规范 §6:`requestId` 在写入前按会话去重;忙时 `reject` 则什么都不写 | `postMessage` 先 `AppendMessage` 再 `StartConversationRun`。槽位被占时返回 409,用户消息已经在库里。前端把同一条放回 outbox 队首,回合结束后再发一次,于是出现两条用户消息,第一条没有 run | 回合和工作流先抢槽位,忙则 409 且不写。`messages.request_id` 部分唯一索引。重复请求返回已有消息和 run。命令路由仍不占槽位 |
| 终态一次提交 | 规范 §1 不变量 1、§5.3:终态任务、结果条目、输入结算在同一次提交 | `finishRun` 先 `UpdateRun`,再 `AppendMessage`;消息失败被跳过。可能留下 `completed` 且没有助手消息 | `SettleRun` 在一个事务里更新 run 并追加助手消息。失败则把 run 收成 `failed`(码 `run.settle_failed`)并发 `run.failed`,不再静默 |
| 只发布已提交的状态;慢消费者靠持久游标收敛 | 规范 §1 不变量 2–3;§9.2 积压过多时用一份完整快照替换未送达的增量 | 事件是先落盘再 `Publish`,这一侧已经符合。缺口在总线:订阅缓冲满时直接丢掉,连接不断。浏览器不会带 `Last-Event-ID` 重连,注释里的「重放补齐」不会发生。另外 `EventsAfter` 只取一页 1000 条,更早的积压被跳过 | 缓冲满则关闭该订阅。SSE 看到通道关闭就结束响应,由 EventSource 重连。重放按页读到不足一页为止 |
多实例约束来自 [双档存储提案](../proposals/2026-09-30-dual-tier-storage-and-multitenancy.md) 的 D24:run 在所属实例的内存里执行,跨实例不迁移。所以 PostgreSQL 上的对账不能扫描别的实例正在跑的 run。实例标识来自启动配置 `instance`(环境变量 `AGENT_SYSTEM_INSTANCE`),缺省是「主机名:监听地址」。
### 2.2 试点,写给库层,本任务不改代码
这些规则对得上 agent-stack 提案的 N3(可恢复 RunState)、Q1(图执行检查点)和审计 Q4(恢复会重跑 `running` 步骤)。提案合入后再开任务,在提案 §2.1 / §2.2 加链接。不要把 Pico5 的状态词原样加进我们的结果枚举:提案写了跨库结果状态对齐 A2A,不自造第四套词。下面的 `faulted` 用原因码表达。
| 规则 | Pico5 出处 | 对应我们的缺口 | 试点时怎么用 |
|---|---|---|---|
| 重放取交集:存储时的策略和当前声明都是 safe 才重跑;否则写一条 interrupted 结果。当前的 safe 不能把当初的 unsafe 升级成可重跑 | 规范 §7.2;现行代码在 `harness/session/types.ts` 的 `effect_pending` | 审计 Q4:`Resume` 会把 `running` 步骤再执行一次。`contract` 有 `SideEffects`,但没有工具级的 replay 位,而且 contract 还没接到 runtime | Native 的工具声明加 replay 位,缺省 unsafe。Squad 的 attempt id 恢复时读这个位:unsafe 写中断结果,不调用执行器 |
| 意图和结果分两次提交。一轮没有持久进展就失败 | 规范 §5.1 第 6 步、§5.2 effect sandwich | 审计 N1:预算用尽时 assistant 已经落盘,工具结果没有。重试会再打出副作用 | 工具调用先落「即将执行」再执行,执行后落结果。恢复时按上一行的交集决定重跑还是写中断结果。无进展用现有失败状态加原因码,不新增 `faulted` 枚举 |
| 先写者胜出的 memo,和检查点分开,终态时丢掉 | 规范 §5.2 | 审计 Q1/Q2:批准和预算不是原子落盘,恢复后丢失或再次挂起 | 放在步骤记录上,候选插入和读出赢家同一次写入。批准、修复预算用它,不要再靠散落字段 |
| 内存实现是语义参照,SQLite / JSONL 跑同一套一致性测试 | 规范 §10 末、§11.1;`MemoryStorage` 的测试就是这套测试的雏形 | Squad 的文件 run 和 platform SQLite 各记一份步骤状态(提案 Q1 验收已经要求合成一份) | 先有内存参照和共用测试,再写文件与 SQLite 后端。不要每个后端自己发明恢复语义 |
| 多文件写入用「先写侧车、再写主标记、标记成功才对外可见」 | 规范 §11.3 | 审计 Q3:`writeJSON` 直接 `os.WriteFile`,崩溃留下半截 JSON | 文件档的检查点用这个协议。没有 fsync 时只承诺扛住进程崩溃,不承诺扛住断电;这一点写进文档,不要暗示成完整耐久 |
| 压缩不改历史:追加一条带 `head` 的摘要,旧条目还在 | 规范 §2.1、§8 | 骨架设计:窗口外原文不删,滚动摘要是远期。Native 的 compaction 会按条数裁尾,审计 N7 指出可能切断工具配对 | 压缩产物是新条目加下界指针,回放从指针开始。不要重写 transcript 文件 |
### 2.3 暂缓,以及什么情况下再看
| 规则 | 为什么现在不拿 | 重新评估的条件 |
|---|---|---|
| 透明续跑(从检查点接着做,而不是标记中断后让用户重发) | 骨架 §4.2 明确不做,避免分布式续跑。N3 还没落地 | N3 的 RunState 可恢复已经合入,并且有「杀进程后工具配对仍成立」的测试 |
| 服务端 steer / follow-up 队列 | 直连路径是 409 + 浏览器 outbox。`agent-native` 的 `AgentSession` 已有队列,但没有接到 HTTP | harness 提案 P1:直连和小队共用同一个回合循环 |
| 崩溃后把未写完的助手输出收成一条 interrupted 消息 | 增量在 150ms 缓冲里,终态消息只在 run 结束时写。恢复它要定义「事件残片算不算消息」 | 终态原子提交稳定之后,如果用户确实需要看到半截回复 |
| 把系统提示增量写进对话记录(`SystemMessage.sections` / `toolsAdded`) | 我们的系统段由 ctxasm 每次组装,不进消息表。pi-ai 已有这个类型,但是另一套回放 | 只有当我们要把「当时实际给模型的系统和工具」做成可回放事实时 |
| 带作用域、历史和分叉策略的类型化文档,以及 Chord 增量订阅 | 我们已有持久 seq + Query 快照 + SSE 重放,订阅模型够用。Chord API 不稳定 | 出现真正的多文档、可分叉、要按历史时点读取的产品需求 |
| 会话分叉(fork)和所有权树(owner 管中止,parent 管历史) | 领域模型没有会话分叉。子 run 用 `parent_run_id`,取消靠 context 级联 | 产品要做「从某一条消息开分支」或跨进程的子树取消 |
## 3. 本任务实施了什么
对应 §2.1,不改 Core 契约:
- Backend 迁移 v14:`runs.instance_id`,`messages.request_id` 及 `(conversation_id, request_id)` 部分唯一索引。
- 启动对账 `ReconcileInterrupted`,与停机时的 `InterruptRunning` 共用收尾。
- 发消息先查 requestId、回合与工作流先抢槽位再落库。
- `SettleRun` 单事务写 run 终态和助手消息。
- 事件总线在订阅缓冲满时关闭连接;SSE 按页重放直到不足一页。
- Frontend 数据层在发送和 outbox 重发时携带同一个 requestId。界面不变。
不在本任务:透明续跑、服务端 steer、把半截输出收成消息、修改 Documents PR #34。