崩溃之后接着跑:读 pi harness-v2 的持久化 agent 运行时设计
原文在 earendil-works/pi 的 harness-v2 分支,packages/agent/docs/harness-v2.md,3446 行。
它要解决的问题一句话说得清:一个被接受的 prompt 是一个持久操作。进程死了,新进程打开同一个会话,从最后一个安全边界接着跑;崩溃能产生的每一种状态都是可恢复的。
这件事听上去像是「加个 WAL 就完了」,但 agent 运行时有几个别处没有的约束,让它变成一个相当漂亮的设计题:模型请求贵且不可重放、工具调用有外部副作用、上下文只能在尾部增长否则 KV cache 全废、而所有这些效果都发生在同一个「回合」里。这份文档的价值不在于它用了什么高级机制——它一个多对象原子写都没有——而在于它把崩溃安全收敛成了一条可以逐点检查的规则。
下面是我整理出的主线。
先看它拒绝做什么
文档开篇的兼容性政策就把话说死了:
旧的 coding-agent v3 JSONL 会话必须能打开并恢复成空闲。这是唯一的向后兼容要求。其余所有格式和 API 都可以破坏。我们不为其他任何东西写迁移、schema 版本或转换路径。
非目标那一节同样克制,而且每一条都点明了代价:
- 不保证 hook 副作用 exactly-once。 hook 的返回值要等到消费它的记录或 entry 提交才算持久;在那次提交之前崩溃,hook 会被重跑。hook 自己发的 HTTP 请求、写的文件,harness 一概看不见。需要崩溃安全的外部效果,hook 自己做成幂等的,比如用 operation id 做键。
- 不做 provider 流续传。 部分流永不落盘,中断的流式请求要么重试要么放弃。但 deferred 请求是另一回事,它在范围内——provider 立刻返回一个 handle、稍后再给结果(Responses API 的
background: true、各家的 batch API),那个 handle 会像普通 assistant 消息一样被持久化。 - 不做多写者。 一个会话同一时刻只有一个 harness 在写,由服务层保证。看起来像多写者的负载(同一段历史上的并行线程)由 lane 覆盖。
- 不做复制。 一个会话只活在一个地方。
把非目标写到这个精度,本身就是设计质量的一部分:读者知道哪些状态是不用去想的。
会话是四种状态,不是一棵树
tree (共享,只增) lanes
a ── b ── c ── d main → d (操作日志: …)
└── e ── f slack:171943… → f (操作日志: …)
全局事实: name = "Refactor auth", label(b) = "checkpoint-1"
- 树 —— 对话本身。带
parentId的 entry:消息、模型/思考等级/工具激活的变更、压缩摘要、分支摘要、自定义 entry。树是共享且被动的,不属于任何 lane,只增长,entry 永不修改也永不删除。 - Lane —— 工作发生的地方。一条 lane 是一个名字加一个叶子(未来的工作会从这个 entry 往下接)。每个会话都有
main,应用可以按外部身份再建(Slack 的 thread id、邮件的 thread id)。 - Lane 操作日志 —— 发生了什么、还必须发生什么。每条 lane 一串扁平的、按时间排的记录序列:操作开始、步骤尝试、工具启动、消息入队、操作结束。持久性就实现在这里,这些记录存在的唯一理由是让新进程能在崩溃后接着干;正常执行时没有任何东西读它们。
- 全局事实 —— 会话级的、最新写入者赢的值:会话名、entry 标签。不在树里,但按追加历史保存,读的时候取最新。
四部分共用一条单调递增的 seq。
主动与被动的划分是这里的关键抽象:树和全局事实是被动的共享数据,谁都能读;lane 是主动的,它独占自己的叶子、自己的操作日志(至多一个未关闭的操作)、自己的队列和待写入。两条 lane 从不共享其中任何一项。
不变量清单里有一条特别值得抄:
操作日志记录永不影响树。删掉所有操作日志,剩下的是一份完整有效的对话。
记录之所以不是树 entry,理由说得很直接:它们描述的是执行而不是对话,绝不能进入模型上下文、转录、分支查询或分叉;而且在一条 lane 内部,它们的顺序本身就是它们的含义,加父指针不增加任何信息。
Lane:checked out 的 git 分支
文档给的类比很准:最接近的现有概念是一条在自己 worktree 里被 checkout 的 git 分支——一个附着在位置上的名字,被新工作推进,可以移到任何 entry 而不重写历史,且永不被 checkout 两次。和 git 直觉的一个差别是:导航可以把 lane 移到任何 entry,不只是向前。
一条 lane 拥有:它的叶子、它的操作日志(至多一个未关闭操作,第二个操作会被拒绝,其他 lane 不受影响)、它的队列、以及它的配置视图。
配置视图那条最有意思:模型、思考等级、激活的工具,都是这条 lane 叶子背后那条路径上的 entry。于是两条 lane 可以跑不同的模型,彼此完全不知道对方存在。工具实现、资源、流选项是 harness 全局的,只有「激活哪些」是 per-lane 的。
具体到用法:一个 Slack channel 是一个 session,每个 thread 是一条 lane。交互式的 pi 只用一条 lane,UI 里根本不暴露这个概念。子 agent 可以跑在父会话的第二条 lane 上——想要隔离时才用 fork。
核心机制一:意图在前,结果带同一个 id
整份文档的地基是一句话:
效果之前:写一条意图记录,写明将要发生什么、以及它会产出哪些 id。效果之后:把结果作为 entry 追加,用的正是那些 id。
推论是这一句:
不存在多记录原子性,也不需要。 每条记录、每个 entry 各自独立地持久。意图与结果之间崩溃,留下的是一条未兑现的意图;恢复按意图类型逐个决定:完成它、重试它,或者用一个合成结果关掉它。
一条意图被满足,当且仅当带着它预分配 id 的 entry 存在。预分配的 id 存在但内容不同,那是损坏。
预分配 id 长这样——parentId、seq、timestamp 都由存储在追加时分配,entry 挂到 lane 当时的叶子上:
type ProvisionedEntry<T extends Entry = Entry> =
T extends Entry ? Omit<T, "parentId" | "seq" | "timestamp"> : never;
这条规则的妙处在于它把「崩溃恢复」从一个需要事务的问题,降级成了一个点查问题:拿意图里写的 id 去查 entry 在不在。不需要两阶段提交,不需要 undo log,不需要任何跨对象的原子写——整份设计里一个都没有。
记录一共九种:
| 记录 | 什么时候写 | 作用 |
|---|---|---|
operation_started |
操作被接受时 | 接受边界。所有在接受前决定的事都持久在这里;它自己的 id 就是这个操作的 runId |
abort_requested |
abort() resolve 时 |
请求标记,不是终态;后面还要走对账 |
operation_finished |
关闭操作 | completed / aborted / failed / declined |
step_attempt |
每次可重试步骤的尝试之前 | 「我要做这件事,这是第 n 次」。尝试计数是持久的,崩溃重启循环也无法把它清零 |
tool_started |
before_tool 和校验通过之后、工具执行之前 |
assistantEntryId + toolIndex 是持久的调用身份 |
queue_enqueued |
队列项被接受时 | 载荷走这里,entry 在消费点才出现 |
queue_cancelled |
消费前撤回一个队列项 | 没有这条记录,崩溃会把这个项复活 |
write_deferred |
步骤执行中请求的写入 | 下一个检查点再应用 |
usage |
每次 provider 请求结算时 | 成本账本,见下文 |
有两处「不写记录」的地方同样是设计:被拦截或非法的工具调用不写 tool_started——没有效果启动,就不需要意图;拦截这件事本身作为一条 isError: true 的工具结果 entry 持久下来。在那个 entry 之前崩溃,丢掉的只是一个决定,恢复时重新做一遍就是了。工具步骤也不需要结果记录——它的结果 entry 就是完整的持久结果。
核心机制二:上下文只在尾部增长
这是我认为整份文档里最容易被忽略、但解释力最强的一条不变量:
在一条 lane 的多次请求之间,provider 上下文只在尾部增长。在上一次请求的尾部之前做插入,会让 provider 的 KV cache 从那一点起全部失效,并让 token 成本翻倍。
所有「运行中的写入要延迟到检查点」的机制,根因都在这里。文档给的例子很干净:
R step_attempt 请求在途,上下文止于用户消息 U
session.appendMessage(M) 调用方在这里 resolve
R write_deferred 完整载荷,预分配 id
E assistant message A provider 缓存的是 [.., U, A]
E message M 检查点应用这次写入;尾部追加
直接追加 M 会产出 [.., U, M, A]:一个合法的 provider 序列,但它让 KV cache 从 M 起全部失效,而且制造出一份声称「A 看见了 M」的假转录——A 其实没看见。检查点同时挡住了这两件事。
压缩是唯一被批准的例外:它拿一次全量缓存失效,换一个更小的上下文。
检查点做三件事,顺序固定:应用待写入 → 消费排队的 steering 消息 → 如果下一次请求装不下就压缩。
带着这条不变量再看输入通道的划分就顺了。两种机制把输入送进运行中的 lane,区别在 abort 行为:
- 队列承载对话意图。
steer纠正当前工作,followUp追加「模型本来要停下时」的工作,nextRun给这条 lane 的下一次运行留种。steering 和 follow-up 在 abort 时死掉,载荷退还给调用方;next-run 存活。 - 延迟写承载事实。步骤执行中请求的 entry 和配置变更。它们在 abort 时存活,甚至在取消过程中也会被应用。
两者都在接受时就持久:接受调用先把带完整载荷的记录写进操作日志,然后才 resolve。树 entry 是后写的,写在它被应用或消费的位置——也就是模型第一次看见它的位置。如果进程死在接受和树写入之间,恢复会读到记录并补上追加。被接受的输入永不丢失。
崩溃点:工具执行那张表
Part II 用「轨迹」的形式把每种动作写了什么、在哪些点崩溃会怎样列了个遍。工具执行那段最能说明这套方法:
E assistant message, 调用 c1, c2
X1 before_tool 之前 c1 没有任何持久物
H before_tool(c1)
X2 决定已做出,什么都没写 同 X1
R tool_started(c1)
X3 工具执行中
H after_tool(c1)
X4 hook 被打断 持久状态同 X3
E tool result c1
X5 结果已持久 c1 完成
| 崩溃点 | 持久状态 | 恢复 |
|---|---|---|
| X1, X2 | 无记录,无结果 | 走完整正常路径;before_tool(再次)运行 |
| X3, X4 | 有 tool_started,无结果 |
重放安全(记录里 和 当前声明都说 safe):用持久化的参数重新执行,对新结果跑 after_tool。否则:写一个合成的 "interrupted" 结果,不跑任何 hook |
| X5 | 结果 entry 存在 | 跳过 c1;c2 处在 X1 |
工具用 replay?: "never" | "safe" 声明自己的重放安全性,省略即 never。判定要求两边都说 safe:tool_started 记录里快照下来的那个声明,以及当前代码里工具的声明。这个「且」不是多余的——它覆盖的是「上次运行时这个工具是幂等的,这次部署改了实现」这种情况。
批次里的每个调用在自己的崩溃点上单独对账,按源顺序处理。并行批次也一样:tool_started 是在顺序的 phase-1 里按源顺序写的,所以批次中途崩溃留下的是一个源顺序的前缀——有些有结果,有些没有,上面那张表逐个适用。
三个反直觉的判断
成本的持久性不能依赖结果的持久性
usage 是唯一存在「结果记录」的地方,理由讲得很硬:
可重试的步骤,恰恰就是那些设计上会产出「永远不会成为 entry 的响应」的步骤——失败的尝试、耗尽的重试系列、被丢弃的溢出响应——而它们的花费不能跟着一起消失。
所以每一次 provider 请求都在任何分类、重试决定或丢弃之前先写一条 usage 记录结算掉。工具报告的、hook 报告的用量各自在自己的 entry 旁边写记录;harness 看不见的部分(对账、估算、修正),应用自己追加 adjustment 记录,负数合法。
然后是三层的干净分离:
- entry 上的
usage字段是不可变快照,产出这个 entry 的那次(或那几次)响应,追加时写一次,之后永不再动; - 一个 entry 的有效成本是读时查询:所有 lane 中绑定到它 id 的
usage记录之和,基础值加调整值; - 会话成本是所有
usage记录之和。
于是恢复可以诚实地重复计费——重试的步骤、被重放的工具,每次执行各写一条记录,因为两次都真的被计费了。
而且 usage 记录在归约里是隐形的:它们是会计,从不是编排。这一条让它不增加任何恢复状态,不增加崩溃矩阵的任何一行。
length 是有歧义的,不能用上下文百分比去猜
模型返回 length,意思是「生成在某个输出边界停下了」,但那个边界要么是意图中的输出上限(压缩救不了),要么是更小的上下文或 provider 限制(压缩能救)。分类的办法是拿实际输出用量(含推理 token)去比意图输出上限:
function isRecoverableLength(message: AssistantMessage, desiredMaxOutput: number): boolean {
if (message.stopReason !== "length") return false;
// 达到调用方或模型意图中的上限,这是真正的输出限制停止。
if (desiredMaxOutput > 0 && message.usage.output >= desiredMaxOutput) return false;
// 停在意图上限之下:上下文压力,或 provider 侧截断。
return true;
}
基准必须是意图值,不能是实际发出去的值:有的 provider 干脆拒绝显式的输出上限(OpenAI Codex 后端对 max_output_tokens 直接返回 400),而 Pi 自己又会把另一些夹到剩余上下文里。用发出去的值当基准,这个判断就废了。
文档还给了三个实测形状来校准:上下文被夹过的请求返回 16 个推理 token 而意图是 128k(可恢复);小米/Qwen 那种 length 加零输出(可恢复);显式 1024 上限被用满(真停止)。整个判断里没有一处上下文百分比启发式。
可恢复的响应被丢弃,而不是入树。这一步很关键:像可重试的错误一样,它从不成为 entry,于是重试时——无论是活跃重试还是崩溃之后——都不用从上下文里擦掉任何东西。它的预分配 id 保持未兑现,它的成本已经在结算时写进账本了。
护栏是「每次对话输入只允许恢复一次」:只有当本次运行最新被消费的对话消息之后,没有更新的 overflow 原因压缩尝试时,溢出压缩才可以启动。窗口内的第二个可恢复响应会追加放弃错误 entry 并让运行失败。length 响应永远不重置这个护栏,只有被消费的对话输入会。
导航先移动
带摘要的树导航,提交点是「移动」这一步:
navigateTree(target, { summarize: true, label: "before-refactor" })
R operation_started kind navigation;目标、预分配摘要 id、标签
H before_navigation 可以拒绝,或直接提供摘要
R step_attempt step branch_summary —— hook 提供了就跳过
摘要文本生成 只在内存里
L lane 移动 → target 一次存储写入;提交点
E 分支摘要 entry 挂到 lane 的叶子上——现在是 target,
所以摘要落在目标分支上
G label 来自意图;最新赢,幂等
R operation_finished completed
移动先提交,之后每一次写入都挂在持久状态上。整个设计里不存在任何多对象原子写。
而「移动到底发生了没有」永远可判定,靠的是接受阶段拒绝 target === sourceLeafId:lane 的叶子等于 intent.targetId,当且仅当移动已提交。
移动和 operation_finished 之间,读者会看到 lane 在目标位置上带着一个未关闭的导航——这是一个可恢复的状态,不是非法状态。这个区分很重要:这套设计里没有「不一致的中间态」,只有「还没做完的中间态」。
恢复:有界发现,纯归约,续跑
打开会话时,每条 lane 独立恢复。Restore 只读,从不追加,也从不启动效果。
恢复不是全量扫日志,而是从带索引的发现开始:
findOpenOperations(lane, { limit: 2 })返回未完成的operation_started,最新在前。零条 = 空闲,一条 = 挂起,两条 = 损坏。- 空闲的 lane,一次索引查询找到最新的 run 类型
operation_started,再在它之上做类型过滤的队列查询,重建待处理的nextRun项。 - 挂起的 lane,未关闭的操作选出两次有界读取:这条 lane 从那个
operation_started起的记录,以及这条 lane 自己的 entry(从叶子回溯到操作锚点sourceLeafId——恰好就是这个操作追加过的那些 entry)。
每一次扫描都被未关闭的操作或仍然相关的空闲队列所限定,而不是被会话总历史、也不是被另一条 lane 的活动所限定。
从这两次读取归约出 lane 状态:是否在中止、用掉了几次尝试、溢出恢复用过没有、工具批次的状态、有没有未兑现的 deferred handle、最新的自有 entry、待处理的队列项和待写入、缺失的初始消息、结构性操作的目标 entry 在不在。
然后是这段的点睛之笔:
同样的规则在活跃时也在跑:正常执行期间,harness 一边写一边在内存里更新这个状态;restore 则从存储重新算出来。状态和记录不可能不一致,因为状态就定义为它们的归约。
而且它不止是定义,还有一个在生产里跑的自检:
不动点自检。 当
resume()完成、挂起或关闭它的操作时,harness 从存储重新计算归约,并把结果的laneState和活跃的LaneState比对。不一致就是损坏,直接让 harness 进入 fault——写者与归约器的漂移在它发生的当下就被抓住,而不是等到下一次崩溃。这个检查很便宜(就是 restore 做的那两次有界读),而且在生产里运行,不只在测试下。
一致性不靠纪律维持,靠定义加自检。这是我从这份文档里带走的第一条。
把并发收敛成「只有两种历史」
Part III 加了两个机制,它们不引入任何新的持久化语义:效果边界让每个崩溃点可停,lane 变更线关掉 check-then-act 竞态。
先说竞态。文档的观察是:
这个设计里的每一个竞态都是同一个形状:从 lane 状态做出一个决定,一次
await过去,然后把这个已经过时的决定提交成持久写入。
修法是结构性的,而不是逐个加锁。每条 lane 有一条进程内的 FIFO——就是一条 promise 链——每一个依赖状态的决定都在这条链上的一个 job 里提交:
let tail: Promise<unknown> = Promise.resolve();
function mutateLane<T>(job: () => Promise<T>): Promise<T> {
const result = tail.then(job);
tail = result.then(() => undefined, () => undefined);
return result;
}
一个 job 是:对活跃 LaneState 做校验 → 至多一次持久写 → 更新 LaneState。 别的什么都不干。provider 请求、工具执行、hook、退避重试从不在 job 里跑,它们跑在 job 之间——而这恰恰就是为什么每一次提交都要在自己的 job 里重新校验一遍。
结论:
因为 job 一次只跑一个,一条 lane 上两个并发的操作恰好只有两种可能的历史——
[A, B]或[B, A]——而且两者都是有定义的结果。不存在第三种交错的历史。
比如 steer 撞上 run 结束,只有这两种:
steer 先 finish 先
R queue_enqueued R operation_finished
tryFinishRun → continue steer() → NoActiveRun
E user message
... 运行继续
R operation_finished
文档列了一张穷尽的竞态目录,12 行,每行写明两种合法历史和强制它们的机制。Tier C 测试对每一行的两个方向都测。
第 10 行是唯一消不掉的:abort 撞上在途的 provider/工具效果——效果可能已经发生,但结果永远没回来。
答案是第 5 节的意图记录加上重放策略——和应对崩溃是同一个答案。
把「远程调用超时」和「进程崩溃」认成同一个问题、用同一套机制解决,这是我带走的第二条。
drive: "manual":让每个崩溃点都可停,而且是机械导出的
procedure 执行的每一个效果都穿过一个注入的 Effects 句柄 fx:持久写入(appendEntry、appendRecord、moveLane、setFact)、条件提交(tryFinishRun、consumeQueueItem、applyPendingWrite、commitRunEndFollowUp、finishOperation)、外部效果(streamAssistant、executeTool、fetchDeferred、cancelDeferred)、拦截与时间(runHook、sleep)。
这份方法清单就是完整的崩溃点目录:在其中一次调用之前或之后停下来,恰好就是第 6 节的一个 X 状态。
drive: "automatic" 下 fx 直通,零开销。drive: "manual" 下同一个句柄被包进一个闸门:每次方法调用都先泊住,并暴露一个 JSON 安全的描述;peekAction() 看下一个泊住的动作,executeAction() 恰好放行那一个,runToCompletion() 一路放到操作结束。
关键在于:**生产和测试跑的是同一套 procedure,drive 模式只控制边界。**没有第二台状态机,没有测试专用的执行路径。
有两条让手动模式真正可用的语义:
- 闸门是可重入的。 一个被放行的动作可能再调另一个
fx方法——比如stream_assistant内部的transform_context、before_payload、after_response。嵌套调用泊成它自己的动作,驱动方先观察并放行它,外层动作才继续。于是每个 hook 都是独立的崩溃边界,而不会死锁。 - lane 表面不受闸门管辖。 procedure 泊住的时候,测试照样能调
steer()、abort()、session.appendMessage(),它们的 job 立刻在变更线上跑。竞态目录每一行的两个方向,就是靠「在executeAction()之前还是之后调这个表面方法」构造出来的。
崩溃模拟就是在选定的边界上 close(),然后重开同一个后端并 resume:
每个泊住的调用都以
HarnessClosed拒绝,本地操作 promise 拒绝,其他什么都不提交。持久状态恰好是已放行效果的那个前缀——这正是崩溃点的定义。
而崩溃点是机械导出的,不是手挑的:把第 6 节每条轨迹用手动模式驱动一遍,每一次 executeAction() 之后给后端拍快照,然后把每个快照都重开并 resume()——而且每个快照跑两遍恢复,证明「半完成的恢复」是安全的。往一条轨迹里加新效果,崩溃覆盖自动就有了。
deferred:挂起和崩溃在存储里长得一模一样
后台/批量 provider 请求返回的是一个 handle 而不是内容。pi-ai 用 stopReason: "deferred" 的 assistant 消息携带它,像任何 assistant 消息一样持久化。
R step_attempt 流选项请求了 deferred 执行
E assistant message stop reason deferred,携带 handle
lane 挂起;prompt() 以 outcome "suspended" resolve
... 几小时过去,也许换了个进程 ...
resume() lane 路径上最新的 entry 是一个没有后继的
deferred assistant 消息 → handle 未兑现,去兑现它
fetchDeferred(model, handle) model 和 handle 都来自那个 entry
E assistant message 真正的结果
运行正常继续
这段最漂亮的地方是这一句:
挂起的 lane 在存储里和崩溃的 lane 无法区分:一个未关闭的操作,它最新的 entry 是一个没有后继的 deferred assistant 消息。
于是不需要第二套机制。restore 把它列为挂起,resume() 去检查 handle。兑现不写意图记录——它不启动任何新的模型工作,而一个已提交的后继 entry 就阻止了第二次 fetch。
每次 resume() 执行一次 fetch,三种结局:pending(provider 又返回 deferred,除了可能的 usage 记录什么都不写,lane 重新挂起,轮询节奏是应用策略)、ready(正常的 assistant 消息,作为后继追加,运行继续)、terminal(provider 返回 error,或 fetch 本身 reject——harness 把 reject 转成同样的错误消息形式,追加后运行失败,永不自动发起替代请求)。
存储:三个后端一套契约
契约里有两条我认为通用性最强:
没有通用的条件写。 单写者加上 lane 变更线,让普通追加和指针/事实更新的 compare-and-set 变得不必要。lane 的「未关闭操作」投影是那个狭窄的例外:启动一个操作会条件性地把它从
null设成 run id,更新失败就意味着这条 lane 已经忙了。
任何写入失败都让 harness 进入 fault。存储被留下的是一个有效前缀。
JSONL 后端一行就是一次逻辑变更,一行就是原子单位。撕裂的尾行是那次死在中途的追加,打开时截断掉——那次写入从未被确认,什么都没丢;而中间的坏行是损坏,直接拒绝打开。这个区分很干净:能确定「没被确认」的就静默修掉,不能确定的就绝不静默修复。
SQLite 那部分有个我很喜欢的细节。它维护一个私有的分支读缓存,靠两条不变量撑住:
- 每个 entry 至少在一个分支里。
- tip 唯一 —— 一个分支只会终止于刚刚被创建的那个 entry(扩展和复制都把一个全新的 entry 放在末尾),所以没有两个分支共享 tip。于是「有分支终止于 X 吗」是一次点查,返回 0 或 1 行。
追加时用这个 tip 测试决定「扩展现有分支」还是「复制一条路径」。四种情况里第四种是全文最有说服力的一段:
情况 4 —— 一个分支仍然终止于某个已经有子节点的 entry。
接情况 2:B1:[a b c u], B2:[a b c d];t1 导航走了,main 导航到 c。
main 追加 e(7): c 有子节点(u、d)—— 但 tip 测试问的是对的问题:
有分支「终止于」c 吗?没有 → 复制。
如果确实有一个分支终止在那里(它的延续已经跑到了另一个分支的副本上),
tip 测试就会扩展它 —— 一行,而不是一次路径复制。
has-children 测试会白白复制,tip 测试永远不会。
「有子节点吗」和「有分支终止于此吗」,直觉上是同一个问题,实际上不是。前者会在一类真实场景里做无谓的 O(路径长度) 复制。
遥测:显式传参,因为没有哪个运行时的环境上下文能当核心抽象
核心代码不使用
AsyncLocalStorage、全局当前 span 状态,或任何运行时特定的上下文 API:pi 要跑在 Node、Bun、浏览器和 worker 里,所以没有任何一个运行时的环境上下文机制能成为核心抽象。
适配器内部可以用环境上下文(比如 OTel 适配器激活它原生的子上下文,好让 HTTP 自动插桩正确挂上去),但 pi 总是显式传父 span。每个 Effects 方法都把父上下文当普通参数收下,并行的工具用各自的子 span,因此也用各自的父上下文。
schema 那部分的做法值得一说:两个 schema 就是普通的可序列化数据对象,defineTelemetrySchema() 是一个纯类型的恒等函数,不是校验运行时。span 名、属性类型、必填键、字面量取值集合都从这个值里推断出来,编译期检查每一次 pi 写出的 start/end/event 调用;生产环境不做任何 schema 校验。文档由这些 schema 对象生成。
安全那条写得很实:默认属性只带 schema 声明的标识符、名字、计数、时长、停止原因、状态码和用量,绝不携带 prompt、completion、工具参数、工具输出、文件内容、provider payload、header 或凭据。而且测试断言的是「不存在」,不仅仅是「被脱敏」。
三者的分工也说清了:事件是公开的实时观察,hook 能改变执行,遥测是被动的进程内诊断。
测试三层,各测一个断言
- Tier A —— 归约与续跑。 通过公开的
SessionAPI 把某个崩溃状态的记录和 entry 预填进去,打开 harness,调resume(),断言持久化的结果。覆盖每一个 X1–X5 工具状态、每种 replay 声明组合、批次里每个源顺序位置、截断批次证明不执行、每个持久点前后的 abort、终态失败标记、每种 deferred 结局……以及「把同一个前缀跑两遍恢复」。 - Tier B —— 写者一致性。 Tier A 假设了活跃执行会写出正确的前缀,Tier B 去验证这一点:拿一个插了桩的
Session记录每一次 entry、record、lane 移动、事实、hook,然后和第 6 节的轨迹逐行比对。它抓的是那个要命的回归类:一个效果在它的意图记录之前启动。 Tier B 还把 append-only 不变量做成了可执行断言——一次运行里,每个 provider 请求的消息列表都是上一次的精确前缀,唯一的例外是跨过压缩 entry 那一次被批准的失效。把 KV cache 纪律从散文变成一个会红的测试。 - Tier C —— 确定性交错。 手动驱动打真的
AgentHarness、假 provider、真后端。闸门是唯一的测试钩子,没有第二台机器。
这份文档同时是一张施工工单
第 20 节是我没预料到的部分:它不只是设计,它还是一个多人(多 agent)并行施工的调度表。
认领协议是:包的复选框为空、所有依赖已打勾、且没有活跃 reservation 占着这个包或重叠的主文件时,才可认领;认领方式是在条目上方加一行 **Reserved: <package-id> by @<username>.**,单独提交,commit message 固定格式,这个提交合进 main 之后才算认领成功;如果有冲突的 reservation 先落地了,撤掉自己的重选。
然后是一张穷尽的公有方法归属表,规则一句话:
一个包在拥有列出的语义和测试之前,不得把
HarnessNotImplemented从方法上摘掉。
配套的第一个包 F0 的验收标准,我觉得每个做脚手架的人都该抄:
盘点每一个公有方法。只保留那些在没有操作运行时的情况下也确实正确的行为,比如不可变的 harness 全局配置副本和直接的叶子读取。其他每一个占位实现都必须以
HarnessNotImplemented拒绝,而不是返回空快照、空闲状态、或者 no-op 的驱动/等待成功。验收:一个表驱动的脚手架测试覆盖每个公有方法,证明没有任何未完成的方法报告出看起来合理的成功。
「未完成的东西必须显式失败,而不是看起来合理地成功」——这条对 agent 写代码尤其重要,因为一个返回空数组的 stub 会让下游的所有测试都变绿。
还有一条排程纪律:每个包自带它的 Tier A 恢复用例、Tier B 精确轨迹、相关的事件/hook、以及 Tier C 交错,而不是把测试推到最后。运行时的合并道是严格串行的 H0 → H1 → … → O4,理由写得很直白:防止对 agent-harness.ts 的并发重写、给每个公有方法指派归属、并确保每条活跃路径都只在它的归约器、遥测、拦截和效果边界都存在之后才落地。
读完带走的三条
一,把崩溃安全变成一条可以逐点检查的规则,比任何事务机制都便宜。 「意图在前,结果带同一个 id;意图被满足当且仅当带那个 id 的 entry 存在」——这一条规则替掉了两阶段提交、undo log 和所有跨对象原子写。整份设计里一个多对象原子写都没有,而它覆盖的崩溃点比多数用了事务的系统还多。
二,把并发的不确定性收敛成「只有两种合法历史」,然后两个方向都测。 不是给每个共享状态加锁,而是把「所有依赖状态的决定」统一收到一条 per-lane 的 FIFO 上,让「决定与提交之间不许有 await」变成结构性事实。剩下那个真正消不掉的竞态(外部效果的结果没回来),用和崩溃完全相同的机制解决。
三,让状态的定义就是记录的归约,然后在生产里持续比对这个不动点。 内存状态和持久记录之间的漂移,是这类系统最典型也最难查的 bug——通常表现为「几天后某个会话恢复出来不对」。这份设计的做法是让两者根本不是两个东西(状态被定义为记录的归约),再加一个在生产里跑的自检,在漂移发生的那一刻就 fault 掉。查一个「立刻炸」的 bug 和查一个「下次崩溃才炸」的 bug,成本差一个数量级。