# 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` 一致。存储接缝这次改了方法的切法,落地时六个方法的英文名连同其余接缝一起过人类门。