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>
This commit is contained in:
2026-08-09 12:02:38 -04:00
parent 4f8812fa82
commit 766f7a0290
3 changed files with 198 additions and 7 deletions
@@ -0,0 +1,165 @@
# 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`
一致。存储接缝这次改了方法的切法,落地时六个方法的英文名连同其余接缝一起过人类门。
+27 -7
View File
@@ -402,13 +402,31 @@ GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任
留下了记录;工具注册表上的完成标记是 agent 自己宣布的(调了某个被标记的工具即视为达成)。
两者都由库消费,不各开接缝。
**存储**(挂定义)。六个方法,全部带运行标识,端口不持有「当前运行」的隐式状态——一个有
隐式当前运行的端口在并发下会把 A 的意图写进 B 的日志。两个形态:一种逐行写本地 jsonl
文件,一种写关系数据库
**两者共用一个停止原因,靠步记录上的完成信号那一列区分。** 停在「目标达成」而那一列为
「已完成」的走的是环境那条,为「未完成」的只可能是完成标记被触发。这个区分不能靠「工具名
落在注册表的完成标记里」来做——注册表不在轨迹文件里,而事后分析手上常常只有那个文件。
写入粒度是契约的一部分:两条意图记录各自单独落地;**动作结果与步记录必须作为一次原子写入
落地**,要么都可见、要么都不可见。后者不原子的话,崩在两者之间会让那一步的历史文本永远丢失,
而恢复判定会把它读成「执行完了,跳过」
**观察那一项是回填进历史的那段文本,不是环境返回的原文。** 执行器丢掉了多少由「被截断的
字符数」单独记。存原文的话,恢复时得拿原文重跑一遍截断逻辑才能得到历史,而截断逻辑会随
版本变——那正是记录逐步结果要消掉的那类重算
**存储**(挂定义)。六个方法:写运行开始、写一条意图、写模型调用结果、写动作结果与步记录、
读回整份日志、写运行结束。全部带运行标识,端口不持有「当前运行」的隐式状态——一个有隐式
当前运行的端口在并发下会把 A 的意图写进 B 的日志。两个形态:一种逐行写本地 jsonl 文件,
一种写关系数据库。
写入粒度是契约的一部分:两条意图记录各自单独落地;**动作结果与步记录一次原子落地**,要么
都可见、要么都不可见。不原子的话,崩在两者之间会让那一步的历史文本永远丢失,而恢复判定会
把它读成「执行完了,跳过」。**这条不靠散文约束,靠方法的切法**——两者由同一个方法收下,
「只写了一半」在签名上无从表达。那个方法的动作结果一项可以为空(模型调用失败的步、解析
失败的步照样有步记录),但必填、无默认值。
**存储实现必须保证前缀持久性:第 k 次写入被确认已经持久时,第 1 到 k-1 次写入也已经持久。**
少了它,模型调用结果还在缓冲区而后面那条动作意图(屏障)已经落盘,恢复会把一次明明成功了
的模型调用记成「状态未知」——而它成功的证据就在同一份日志里,那条动作意图正是从它的返回值
解释出来的。两个已知形态天然满足:同一个文件的追加写,`fsync` 一次刷掉之前全部;同一个连接
上顺序提交的事务,先提交的先持久。代价是排除了「不同记录类型写进彼此无序的多个后端」这种
形态,现在没有消费者要它。
**事件出口**(挂定义)。投递失败由库捕获、记日志、把失败计数加一,然后继续跑。失败不再
转成一条事件从同一个出口发出去——那会自我喂食,一个持续失败的出口会让失败处理路径变成
@@ -508,7 +526,8 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几
第八、九、十一节要补上英文名。
**停止判定的顺序、停止原因的取值、两个预算计数的语义、步记录的字段清单不在本文件里。**
这四样已经定了,在 `../design/0004-stopping-and-step-record.md`;落地之后权威转移到代码——按
这四样已经定了,在 `../design/0004-stopping-and-step-record.md`,字段表经
`../design/0005-storage-atomicity-and-record-fields.md` 修订过;落地之后权威转移到代码——按
`../../CLAUDE.md` §0,公共类型的字段与枚举取值的权威是 `src/polyloop/`,不另写参考文档复述。
那份 design doc 记的是第一版为什么定成这样,不是查字段的地方。
@@ -532,6 +551,7 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几
| 崩溃恢复承诺什么、为什么不承诺原子性、重放策略为什么由工具声明 | `../design/0002-step-level-resume.md` |
| 公共 API 分几层、五个接缝为什么是这五个、依赖规则为什么这么定 | `../design/0003-public-api-shape.md` |
| 停止原因为什么是这十个、判定为什么按这个顺序、步记录为什么是这些字段 | `../design/0004-stopping-and-step-record.md` |
| 存储的方法为什么这么切、为什么要前缀持久性、步记录那三处为什么改 | `../design/0005-storage-atomicity-and-record-fields.md` |
边界的当前裁决清单(哪些在界内、哪些在界外)在 `scope.md`,那份是常青的,会随新消费者
接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。
+6
View File
@@ -128,6 +128,12 @@ PolyLoop 提供的是「把模型响应解释成动作、最终回答或无效
dissect 的解析器会把第一个代码围栏之后的文字丢掉,所以「模型说了什么」和「下一轮模型看见
什么」是两个不同的量。混用会让统计口径出错。
**「分开记」记的是一段文本加一个数字,不是两段文本。** dissect 两侧都这么做:模型那侧存
`raw_output`(解析器截断之后的,正是回填进历史的那段)加 `content_chars`(模型可见输出的
全长);观察那侧存 `observation`(进历史的那段)加 `observation_truncated_chars`。照「两段
文本」读会得出「库少了一个字段」的结论,那个结论是错的,见
`../design/0005-storage-atomicity-and-record-fields.md` 决策三。
**八、可见回复与推理段的长度按字符记,不依赖上游上报。**
实测中转网关会用本地分词器补算并整体替换用量对象,把明细一起吃掉——某次标定里 24 次调用
的推理 token 全部没上报。字符数直接数,不受上报与否影响。