6af289d283
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 登记两条:运行标识要带齐现在文件名里那五维,以及这份意图日志和它那份 逐步轨迹是两样东西不要混。
237 lines
11 KiB
Python
237 lines
11 KiB
Python
"""存储接缝的行为契约。
|
|
|
|
这份文件是「一次运行的日志到底保证什么」的权威(`CLAUDE.md` §0)。两个已知实现形态差别
|
|
很大——一个逐行追加本地文件,一个写关系数据库——所以下面每一条都只说行为,不碰形态。
|
|
|
|
**行为的理由不在这里。** 崩溃恢复为什么这么设计见 `design/0002`,写入粒度与前缀持久性见
|
|
`design/0005`。这里只断言结果。
|
|
|
|
## 标成 `xfail` 的那两条
|
|
|
|
它们是**已知没有机器兜底的承诺**,不是还没写的测试。标成会失败的测试而不是写一句注释,是为了
|
|
让它们在每次跑套件时都被看见;`strict=True` 是配套的:哪天真的验得了、测试过了,它会以 XPASS
|
|
报错,逼人回来把标记连同说明一起删掉。它们不带 fixture,否则会被「实现还没有」那个跳过挡住,
|
|
于是「验不了」就伪装成了「还没轮到」。
|
|
|
|
剩下那条曾经答不上的——没有动作的步 `StepCompleted.result_id` 填什么——已经由 `design/0006`
|
|
决策七答掉(可为空,且为空当且仅当动作结果也为空),对应的测试已经改写成真断言。
|
|
"""
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.contract
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 一、写进去的读得回来
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_written_intent_is_readable(store, records):
|
|
"""写一条意图,读回整份日志时它必须在里面。
|
|
|
|
这是全套最基本的一条:意图日志的全部意义是「比进程活得久」,写了读不回来,后面每一条
|
|
恢复语义都建立在空气上。
|
|
"""
|
|
intent = records.model_call_intent(run_id="r1", call_index=0, result_id="m0")
|
|
await store.write_intent(intent)
|
|
|
|
log = await store.read_log("r1")
|
|
|
|
assert intent in log.intents
|
|
|
|
|
|
async def test_log_of_unknown_run_is_empty_not_an_error(store):
|
|
"""读一个从没写过的运行标识,得到一份空日志,而不是异常。
|
|
|
|
`run` 在开工前要判断「这个标识是不是已经有日志了」,靠的就是这一条。如果读不存在的
|
|
运行会抛异常,那个判断就得写成捕获异常——而捕获异常来做流程控制,会把真正的存储故障
|
|
一起吞掉。
|
|
"""
|
|
log = await store.read_log("never-written")
|
|
|
|
assert log.started is None
|
|
assert log.intents == ()
|
|
assert log.finished is None
|
|
|
|
|
|
async def test_two_runs_do_not_leak_into_each_other(store, records):
|
|
"""两个运行标识各写各的,互相看不见对方的记录。
|
|
|
|
端口不持有「当前运行」的隐式状态,这条测试是那个要求的外部可观测形式。一个有隐式当前
|
|
运行的实现会在并发下把 A 的意图写进 B 的日志,而那种错在单线程测试里永远不出现。
|
|
"""
|
|
a = records.model_call_intent(run_id="run-a", call_index=0, result_id="m0")
|
|
b = records.model_call_intent(run_id="run-b", call_index=0, result_id="m0")
|
|
await store.write_intent(a)
|
|
await store.write_intent(b)
|
|
|
|
assert (await store.read_log("run-a")).intents == (a,)
|
|
assert (await store.read_log("run-b")).intents == (b,)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 二、四态:恢复靠「意图有没有 / 结果有没有」判定
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_intent_without_result_is_readable_as_such(store, records):
|
|
"""写了意图、没写结果,读回来必须能看出「这个 ID 没有结果」。
|
|
|
|
这是四态表里「状态未知」那一档的输入。存储不负责判定,但它必须让判定问得出口——恢复
|
|
要按预分配的 ID 精确地问,而不是模糊匹配去猜哪条结果对应哪次执行。
|
|
"""
|
|
intent = records.action_intent(run_id="r1", call_index=0, result_id="a0")
|
|
await store.write_intent(intent)
|
|
|
|
log = await store.read_log("r1")
|
|
|
|
assert intent in log.intents
|
|
assert all(step.result_id != "a0" for step in log.steps)
|
|
|
|
|
|
async def test_result_without_intent_is_visible_to_the_reader(store, records):
|
|
"""只写结果不写意图,读回来必须原样可见,存储自己不许修复也不许拒收。
|
|
|
|
「有结果没意图」是日志损坏,处置是拒绝续跑——但那个判断归恢复逻辑,不归存储。存储在
|
|
这里悄悄补一条意图或者拒绝这次写入,都会让损坏变得不可见,而不可见的损坏会被当成
|
|
正常数据继续用下去。
|
|
"""
|
|
result = records.model_call_result(run_id="r1", result_id="orphan", reply=records.reply())
|
|
await store.write_model_call_result(result)
|
|
|
|
log = await store.read_log("r1")
|
|
|
|
assert log.model_results == (result,)
|
|
assert log.intents == ()
|
|
|
|
|
|
async def test_failed_model_call_is_recorded_as_a_result_not_as_nothing(store, records):
|
|
"""模型调用失败也要落一条结果记录,否则恢复会把它读成「状态未知」。
|
|
|
|
失败这件事是确定的:调用发出去了、失败了、库记了一条步。如果这时不写结果条目,恢复
|
|
只看见「意图有、结果无」,走重放策略——而这次调用的状态一点都不未知。下游按停止原因
|
|
做的统计会照单收下这个错误。
|
|
"""
|
|
result = records.model_call_result(run_id="r1", result_id="m0", reply=None, failure="连接超时")
|
|
await store.write_model_call_result(result)
|
|
|
|
(readback,) = (await store.read_log("r1")).model_results
|
|
|
|
assert readback.reply is None
|
|
assert readback.failure == "连接超时"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 三、原子写
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_action_result_and_step_land_together(store, records):
|
|
"""动作结果与步记录一次原子落地:读回来要么两者都在,要么都不在。
|
|
|
|
不原子的话,崩在两者之间会让那一步的历史文本永远丢失,而恢复判定会把它读成「执行完了,
|
|
跳过」——恢复出来的消息序列比不中断跑完时少一轮,后面每一步都跟着偏。
|
|
|
|
**这条测试只能验「一起可见」,验不了「一起不可见」。** 见本文件末尾那条。
|
|
"""
|
|
step = records.step_completed(
|
|
run_id="r1", result_id="a0", action_outcome=records.outcome(), 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].action_outcome is not None
|
|
|
|
|
|
async def test_step_without_an_action_is_still_recorded(store, records):
|
|
"""没有动作的步照样留痕:解析失败、模型调用失败、最终回答三种都算一步。
|
|
|
|
预算对等要求它们计入步数——它们确实消耗了一次模型调用。丢掉那一步还会丢掉模型在出故障时
|
|
说了什么,而那正是排查「环境坏了还是模型写了危险代码」最需要的。
|
|
|
|
这条曾经写不出来:那时 `StepCompleted.result_id` 是必填字符串,而这一步没写过动作意图、
|
|
没有预分配的 ID,随便编一个会让恢复读到一条对不上任何意图的记录,按四态表最后一行判成
|
|
日志损坏。`design/0006` 决策七把它改成可为空,并要求**它为空当且仅当动作结果也为空**,
|
|
这个洞才补上。存储要能原样存下这个形状。
|
|
"""
|
|
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
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 四、运行的开始与结束
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_run_finished_is_visible_before_the_result_is_returned(store, records):
|
|
"""「这次运行结束了」这个标记由库写下,而且写在把结果交给调用方之前。
|
|
|
|
另一条路有个具体的失败场景:结果由项目落盘的话,「跑完了、库返回了、项目存的时候崩了」
|
|
这种情况下,重启后日志显示最后一步有结果、没有结束标记,而项目那边什么都没有。续跑会
|
|
重复执行最后一步的副作用,不续跑就丢掉一次已经花完钱的运行。歧义来自结果跨了两个存储。
|
|
"""
|
|
finished = records.run_finished(run_id="r1", result=records.result(run_id="r1"))
|
|
await store.write_run_finished(finished)
|
|
|
|
assert (await store.read_log("r1")).finished == finished
|
|
|
|
|
|
async def test_run_started_carries_the_parameter_snapshot(store, records):
|
|
"""运行开始记录带着这次的参数快照,续跑时拿它与当前装配比对。
|
|
|
|
没有它,用同一个运行标识换一份定义续跑,前几步与后几步会来自两个不同的配置而全程零
|
|
报错——那正是要到统计阶段才分不清哪些行是真的那类损坏。
|
|
"""
|
|
started = records.run_started(run_id="r1", parameter_snapshot={"model": "m-1"})
|
|
await store.write_run_started(started)
|
|
|
|
assert (await store.read_log("r1")).started.parameter_snapshot == {"model": "m-1"}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 五、这套测试**验不了**的两条承诺
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.xfail(reason="已知缺口:这条承诺没有机器兜底", strict=True)
|
|
def test_atomicity_under_crash_is_not_checkable_here():
|
|
"""原子性的另一半——「崩在中间时两者都不可见」——这一层验不了。
|
|
|
|
要验它得在写入过程中把进程杀掉,而契约测试跑在一个进程里、面对的是一个已经装配好的
|
|
实现,没有位置插入那次崩溃。给端口加一个「故意在这里失败」的钩子能验,但那个钩子会
|
|
变成公共 API 的一部分,而它只为测试存在。
|
|
|
|
结论是这条承诺**没有机器兜底**,只能靠 `CLAUDE.md` §3 那轮对抗审查看实现。把这件事
|
|
写成一条会失败的测试而不是一句注释,是为了让它在每次跑套件时都被看见。
|
|
"""
|
|
pytest.fail(
|
|
"已知缺口:原子写的「一起不可见」这一半没有机器检查。"
|
|
"落地时要在 stores 的 unit 测试里用可注入的故障点覆盖,"
|
|
"并在 design/0005 决策二登记这条契约测试覆盖不到。"
|
|
)
|
|
|
|
|
|
@pytest.mark.xfail(reason="已知缺口:这条承诺没有机器兜底", strict=True)
|
|
def test_prefix_durability_is_not_checkable_here():
|
|
"""前缀持久性同样验不了,理由更硬一层。
|
|
|
|
它说的是「第 k 次写入被确认持久时,前 k-1 次也已经持久」,而「已经持久」是掉电之后
|
|
才看得出来的性质。在一个进程里读得回来,不等于它落了盘。
|
|
"""
|
|
pytest.fail(
|
|
"已知缺口:前缀持久性没有机器检查。两个已知形态天然满足它"
|
|
"(同一文件的追加写、同一连接上顺序提交的事务),"
|
|
"所以它实际是对实现形态的约束,落地时靠评审看,不靠这套测试。"
|
|
)
|