feat(stores): 落成逐行追加的日志存储,契约套件第一次真的在跑

design 0011(待确认)定了六条:一次运行一个文件且文件名就是运行标识(不转义不哈希,按标识
去目录里找文件是最自然的用法;标识必须是安全文件名,否则 ../ 会把文件写到目录外面);一行
一条记录加一个 record 类型标签(serialization 编出来的载荷没有元信息键,标签是存储这层加的,
record 从此是保留键);第一条解不开的行就是日志结尾、它后面还有内容就是损坏;fsync 只在
运行开始、两条意图、运行结束四处(其余两处靠前缀持久性兜);写入走 to_thread;运行开始记录
用 O_EXCL 兜住跨进程撞车。

契约套件里那条 test_step_without_an_action_is_still_recorded 转成真断言——它标着 xfail 的
理由是「StepCompleted.result_id 在 0006 里是必填字符串」,而 0006 决策七早就把它改成可为空
并加了不变量。xfail 8→7,跳过 24→14。

原子写「一起不可见」那一半按契约套件的点名在这一层补上了:给实现留一个可注入的故障点
(一个可替换的「把这些字节写进去」),测试把它换成写一半就抛异常,断言那条记录整条不可见。
前缀持久性仍然验不了(掉电才看得出来),继续登记为已知缺口。

调研三条实据写进了 0011:两个下游 fsync 全仓零处(一个的 SQLite 还开着 synchronous=NORMAL),
所以这条比它们都严、代价是每步两次 fsync;一个下游的轨迹检查器同样是「碰到第一条坏行就放弃
整个文件」;另一个下游踩过「文件名少一维导致两个阶段静默互相覆盖」,O_EXCL 把那类静默覆盖
变成显式失败。这几条我自己逐条核过——那份调研的 subagent 承认它编过一句「我抽查过了」。

migrations/dissect.md 登记两条:运行标识要带齐现在文件名里那五维,以及这份意图日志和它那份
逐步轨迹是两样东西不要混。
This commit is contained in:
2026-08-10 03:38:31 -04:00
parent c5c1e68706
commit 6af289d283
6 changed files with 1037 additions and 60 deletions
+25 -23
View File
@@ -6,18 +6,15 @@
**行为的理由不在这里。** 崩溃恢复为什么这么设计见 `design/0002`,写入粒度与前缀持久性见
`design/0005`。这里只断言结果。
## 写这份文件时撞出来的、`design/0006` 还答不上的问题
## 标成 `xfail` 的那两条
每一条都在下面对应的测试标成 `xfail`,摘要里每次都看得见,但不把套件拖红——**一个永远
红的套件会训练所有人忽略红**。它们也都不带 fixture,否则会被「实现还没有」那个跳过挡住,
于是「答不上来」就伪装成了「还没轮到」。
它们是**已知没有机器兜底的承诺**,不是还没写的测试标成会失败的测试而不是写一句注释,是为了
让它们在每次跑套件时都被看见;`strict=True` 是配套的:哪天真的验得了、测试过了,它会以 XPASS
报错,逼人回来把标记连同说明一起删掉。它们不带 fixture,否则会被「实现还没有」那个跳过挡住,
于是「验不了」就伪装成了「还没轮到」。
`strict=True` 是配套的:哪天这个洞被补上、测试真的能过了,它会以 XPASS 报错,逼人回来把
这个标记连同这段说明一起删掉
1. 没有动作的那些步,`StepCompleted.result_id` 填什么。
2. 「重放」是把动作再执行一次,还是把上次的结果填回去。
3. 原子性与前缀持久性能不能写成契约测试。
剩下那条曾经答不上的——没有动作的步 `StepCompleted.result_id` 填什么——已经由 `design/0006`
决策七答掉(可为空,且为空当且仅当动作结果也为空),对应的测试已经改写成真断言
"""
import pytest
@@ -149,22 +146,27 @@ async def test_action_result_and_step_land_together(store, records):
assert log.steps[0].action_outcome is not None
@pytest.mark.xfail(reason="design/0006 答不上,见 docstring", strict=True)
def test_step_without_an_action_is_still_recorded():
"""没有动作的步照样留痕:解析失败、模型调用失败、环境故障三种都算一步。
async def test_step_without_an_action_is_still_recorded(store, records):
"""没有动作的步照样留痕:解析失败、模型调用失败、最终回答三种都算一步。
dissect 的预算对等要求它们计入步数——它们确实消耗了一次模型调用。丢掉那一步还会丢掉
模型在出故障时说了什么,而那正是排查「环境坏了还是模型写了危险代码」最需要的。
预算对等要求它们计入步数——它们确实消耗了一次模型调用。丢掉那一步还会丢掉模型在出故障时
说了什么,而那正是排查「环境坏了还是模型写了危险代码」最需要的。
**这条现在写不出来。** 这一步没写过动作意图,所以没有预分配的结果 ID,而
`StepCompleted.result_id` 在 `design/0006` 里是必填的字符串。照那个形状写,恢复读到
一条对不上任何意图的记录,按 `design/0002` 四态表最后一行判为「日志损坏,拒绝续跑」
——而这本该是一次恢复成 `llm_error` 正常终止的运行
**它不带 fixture,所以不会被「实现还没有」那个跳过挡住。** 挡住了它就看起来像「还没
轮到」,而它是「答不上来」,两者要分得开。
这条曾经写不出来:那时 `StepCompleted.result_id` 是必填字符串,而这一步没写过动作意图
没有预分配的 ID,随便编一个会让恢复读到一条对不上任何意图的记录,按四态表最后一行判成
日志损坏。`design/0006` 决策七把它改成可为空,并要求**它为空当且仅当动作结果也为空**,
这个洞才补上。存储要能原样存下这个形状
"""
pytest.fail("StepCompleted.result_id 对没有动作意图的步没有定义")
step = records.step_completed(
run_id="r1", result_id=None, action_outcome=None, step=records.step(step_idx=0)
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].result_id is None
assert log.steps[0].action_outcome is None
# --------------------------------------------------------------------------