Files
PolyLoop/research-wiki/design/0005-storage-atomicity-and-record-fields.md
T
iomgaa 766f7a0290 docs(design): 落成 0005,修订存储接缝的原子写与步记录三处
第二轮 Codex 对抗审查报的五条全部核实成立,0003/0004 已冻结,修订新写一份。
五条都不改任何决策的结论,改的是结论到形状之间那一段的落实。

决策一:步记录加一个布尔的完成信号列。0004 决策二承诺靠它区分两条完成通路,
而决策四的字段表里没有这个字段。它自己就够区分,不必查注册表——
事后分析手上常常只有轨迹文件。
决策二:存储接缝六个方法重切一刀,把「按预分配 ID 写一条结果」拆成
「写模型调用结果」与「写动作结果与步记录」。原子性从散文变成签名里不可表达其他形态。
决策三:观察那一列的口径改成「回填进历史的那段文本」,不加字段。
dissect 的 render_messages 把 step.observation 原样套模板发出,
raw_output 也是解析器截断后的版本——两侧同构,都是一段文本加一个数字。
决策四:作废 0004 词表里「一个可能取不到的完成信号」,那是上一轮 fatal bug 的原话。
决策五:存储接缝加前缀持久性。少了它,一次明明成功的模型调用会被恢复
记成「状态未知」,而它成功的证据就在同一份日志里。

结论已回写 architecture.md 第九节与决策索引;migrations/dissect.md 需求七
补上「分开记的是文本加数字,不是两段文本」,堵掉这次审查里出现过的误读。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:02:38 -04:00

166 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design 0005 · 存储接缝的原子写、前缀持久性,与步记录的三处修订
**日期** 2026-08-09 · **状态** 已接受(2026-08-09 项目负责人确认)
**取代** `0003-public-api-shape.md` 决策四「存储接缝」那一段里的方法清单——六个方法怎么切
改了一刀(见决策二)。那份文档的其余部分**全部继续有效**,包括存储接缝挂在定义上、全部方法
带运行标识、端口不持有隐式当前运行、不设存在性查询,以及写入粒度三条的其余两条。
**取代** `0004-stopping-and-step-record.md` 三处:「几个词的最短解释」里动作执行接缝那一条
(见决策四)、决策二里「看完成信号字段区分两种完成」那句所依赖的前提(见决策一)、决策四
字段表里「观察」那一行的口径与字段总数(见决策三)。那份文档的其余部分**全部继续有效**,
包括十个停止原因的取值、判定顺序 A 到 H、两个预算计数的语义。
**补充** `0003-public-api-shape.md` 决策四:给存储接缝加一条它没写的要求——前缀持久性
(见决策五)。
**触及** `../explanation/architecture.md` 第九节里「存储」与「动作执行」两段。本文件的结论
要回写到那里,不回写这份文档就是死的(`../README.md` 第 3 节)。
**本文件写完时状态是「待确认」,2026-08-09 由项目负责人确认后转为「已接受」。** 它要过
`../../CLAUDE.md` §2 那道人类门,因为改的是一个公共 Protocol 的方法清单与一个持久化结构的
字段集合。所以在确认之前存储接缝与步记录这两样都不写代码,现在这条阻塞解除。
## 背景
`0003``0004` 在 2026-08-08 被确认,随即送了第二轮 Codex 对抗审查——第一轮审的是有 bug
的版本,修完的形状没人看过。第二轮报了五条,逐条核实全部成立。
这五条的共同点是:**它们都不改任何一个决策的结论,改的是结论到形状之间那一段的落实**。
比如「动作结果与步记录必须原子」这个结论没人反对,但六个方法的切法让实现者没有任何办法
兑现它。这类问题在纯文档阶段几乎抓不到,因为读者会顺着结论读过去,默认形状能承载它。
`0003``0004` 已经冻结,按 `../README.md` 的规矩改不了,所以修订新写一份。
## 决策一:步记录加一个「完成信号」字段
`0004` 决策二写着:`task_completed` 底下压着两个可信度不同的信号源,要区分是哪一种,看那
一步的步记录——环境信号那种的完成信号字段为「已完成」。但决策四的字段表里**没有这个字段**。
失败场景不需要构造:一次 dissect 的 AppWorld 运行在第 N 步停在 `task_completed`,一次 GovDoc
的运行也在第 N 步停在 `task_completed`,事后分析打开两份轨迹,按文档去找那一列,找不到。
**加一个布尔字段,新增字段从四个变五个。** 口径是这一步的动作执行接缝返回的完成信号原值;
没有动作的步(模型调用失败、解析失败)恒为「未完成」。
**它自己就够区分两条完成通路,不需要再查注册表。** 停止原因是 `task_completed` 而这一列为
「已完成」,走的是环境那条;同样的停止原因而这一列为「未完成」,那只可能是完成标记被触发。
这一点重要,因为事后分析手上往往只有轨迹文件——dissect 的公共设定规定反思模型读取证据的
方式是 agentic 文件探索,那份 jsonl 离开数据库也得能读懂。靠「工具名 + 注册表里哪些工具被
标了完成」来区分的话,注册表不在文件里,这个区分就只有当时在场的人做得出来。
**不改成拆两个停止原因取值。** `0004` 决策二论证过为什么共用一个:「这次运行为什么停」的
答案是同一个。那条论证本文不动。
## 决策二:存储接缝的六个方法重新切一刀
`0003` 决策四给的六个方法是:写运行开始、写一条意图、按预分配 ID 写一条结果、写一条步记录、
读回整份日志、写运行结束。同一节又要求「动作结果与步记录必须作为一次原子写入落地」。
**这两句话互相矛盾,而矛盾的一侧是可执行的那一侧。** 实现者照方法清单写,动作结果走「写
一条结果」、步记录走「写一条步记录」,就是两次调用。jsonl 形态下这是两次 append:第一次
写完 fsync、第二次还没写,进程被杀。恢复时按 `0002` 的四态表看到「动作意图有、结果有」,
判为「执行完了,跳过」——而那一步的步记录不存在,历史文本永远丢了。这正是上一轮抓出来的
那个 bug`0003` 自己在同一段把它的后果写清楚了,然后把能产生它的形状留在了方法清单里。
散文写「必须原子」拦不住任何人。**要拦住就得让「只写一半」在签名上不可表达。**
**新的六个方法:写运行开始、写一条意图、写模型调用结果、写动作结果与步记录、读回整份日志、
写运行结束。** 方法数没变,「按预分配 ID 写一条结果」被拆成两个——一个收模型调用结果,
一个同时收动作结果与步记录。两个都仍然按预分配 ID 写。
**「写动作结果与步记录」的动作结果那一项可以是空,但必填、无默认值。** 模型调用失败的步、
解析失败的步照样有步记录,只是没有动作(`0004` 里「步」的定义就是这么写的)。不给默认值是
因为 `../../CLAUDE.md` §6 那条——默认参数会掩盖「这一步到底有没有动作」这个关键判断,而
调用方每次显式传一个空值,这件事就在调用点看得见。
**代价有两条。** 一是模型调用结果与动作结果不再共用一个方法,jsonl 形态下两段实现几乎一样,
实现者要写两遍。二是将来如果出现第三种有副作用的执行,得再切一个方法而不是复用现成的。
两条都接受:换来的是「写了一半」这个状态在类型上不存在,而它是上一轮那个 bug 的唯一入口。
**不设「这个 ID 的结果存在吗」这类存在性查询这条继续有效**,理由没变——它可以由「读回整份
日志」推出来,端口少一个方法就是少一份永久合同。
## 决策三:「观察」这一列存的是进历史的那段文本,不加字段
`0004` 决策四的字段表把「观察」的口径写成「环境返回的完整原文」。而 `0003` 决策七加「逐步
结果」这类记录,为的正是让恢复**只读不算**——手上要有前几步进历史的文本,否则重建不出下
一轮的消息序列。这两句话对不上:如果存的是完整原文,而进历史的是截断之后的版本,恢复就
得拿原文重跑一遍截断逻辑,跟重跑解释器是同一类问题。
**查了 dissect 的实现,那一列存的本来就是进历史的文本。** `harness/agent/memory.py`
`render_messages``step.observation` 原样套进包装模板发出去;`harness/agent/loop.py` 组装
`Step``observation_truncated_chars` 恒为 0(今天不截断)。模型那一侧同构:存下来的
`raw_output` 是解析器截断之后的文本,正是回填进历史的那段,而模型可见输出的全长由
`content_chars` 这个数字单独记。
于是 `../migrations/dissect.md` 需求七「模型原文与真正进入历史的文本必须分开记,环境观察
同理」的正确读法是:**分开记的是「文本」和「被丢掉了多少」,不是两段文本。** 两侧都是
一段文本加一个数字。
**所以改口径,不加字段:「观察」= 这一步回填进历史的那段观察文本;被丢掉了多少由
「观察被截断的字符数」单独记。** 字段总数不变。
**包装模板换了会不会让恢复出的历史不一样?不会,那条路已经堵上了。** 观察包装模板是请求级
参数(`0003` 决策三的参数表里有它),而运行开始记录携带合并后的参数快照,恢复时读回来与
当前装配比对,不一致直接报错。
**代价是环境返回的完整原文不进轨迹。** 哪天有消费者要分析「被丢掉的那部分里有什么」,现在
的字段给得出数量给不出内容。登记为已知缺口,不预留字段——按 `../../CLAUDE.md` §6,预留一个
没人用的字段等于替一个不存在的需求做了决定。
## 决策四:`0004` 术语表里动作执行接缝那一条作废
`0004` 第 26 行到 27 行写着动作执行接缝「返回状态、观察、以及一个可能取不到的完成信号」。
**「可能取不到」正是上一轮那个 fatal bug 的原始表述**,同一份文档的决策三已经把它否掉了,
词表没跟着改。
失败场景是实现者从头读:第 26 行学到完成信号是「可能取不到」的,于是写成一个可空类型,
GovDoc 那种没有环境完成信号的执行器返回空;停止判定如果沿用旧解释,GovDoc 的每一次运行
都会在第一步撞 `env_error` 终止。上一轮那个 bug 原样复活。
**替换成:库交给它一个动作,它返回五个字段——状态(已执行 / 未执行 / 环境故障)、观察、
观察是不是库合成的、完成信号(布尔)、被截断的字符数。**
单独列成一条决策而不是当勘误处理,有两个理由。`0004` 冻结了,本来就改不了;而且这句话不是
笔误,它是一个已经被推翻的设计的残留,照它写出来的代码会错得很具体。
## 决策五:存储接缝加一条前缀持久性要求
`0003` 决策四的写入序列表里,第 2 次写(模型调用结果)与第 4 次写(动作结果与步记录)都标
着「不是屏障」,理由是它们后面紧跟的不是副作用。这个理由只看了紧挨着的下一步,而**再下一步
就是新一轮的模型调用意图,那是屏障**。
失败场景:第 N 步的模型调用结果写下去了但底层还没落盘,动作意图(屏障)落了盘,动作执行了。
崩溃。恢复时读到「模型调用的意图有、结果无」,按四态表判为**状态未知**,模型调用的重放策略
默认是绝不重放,于是这一步被记成一次状态不明的模型调用。**但它其实成功了**——证据就在同一
份日志里,那条动作意图是从它的返回值解释出来的,没有它根本写不出来。结果是轨迹里多出一条
假的「状态未知」,而下游按停止原因和这类状态统计失败率。
**加一条:存储实现必须保证前缀持久性——当第 k 次写入被确认已经持久时,第 1 到 k-1 次写入
也已经持久。** 屏障因此不只保证自己落盘,也保证它之前的一切都落了盘。
两个已知形态天然满足它:jsonl 逐行追加同一个文件,`fsync` 一次就把之前所有内容刷下去;
关系数据库在同一个连接上顺序提交事务,先提交的先持久。
**代价是排除了一种实现形态**:把不同记录类型写进彼此之间没有顺序保证的多个后端(意图写一个
内存队列、步记录写另一个数据库)。没有消费者要这么做,dissect 是一个文件、GovDoc 是一个库,
现在把这条路封掉的成本接近零;等它真的出现再放开,那时至少知道要为它补什么。
## 这五条为什么第一轮没被发现
第一轮 Codex 审的是有 bug 的版本,它的注意力被那两条致命问题吃掉了——完成信号的空值语义、
恢复漏掉的那个窗口。修完之后形状变了,而新形状带来的问题只有对着新形状才看得见:完成信号
从三态收成布尔,才产生了「步记录里那一列到底存不存在」的问题;「允许合并」改成「必须原子」,
才让方法清单跟契约的矛盾浮出来。
**修复本身会产生新的审查面,这是修复的常态,不是这次特殊。** 记在这里是为了让下一次不要
默认「修完就完了」——`../../CLAUDE.md` §3 点名的四类高风险产物,每改一轮都得重新过一遍那道闸。
## 留给后续的
**「被丢掉的那部分观察里有什么」查不到。** 见决策三的代价。等有消费者提出来再定。
**公共类型的英文名与接缝的具体签名仍然没定。** 本文件通篇用中文概念名,与 `0003``0004`
一致。存储接缝这次改了方法的切法,落地时六个方法的英文名连同其余接缝一起过人类门。