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:
@@ -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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user