fix(stores): 修 Codex 对抗审查报的五条,其中两条同一根因

最实的一条:读取端只要一段能解析成 JSON 就收下,没检查它后面有没有换行。而短写完全可能
正好写完整个 JSON 对象、只差那个换行——那次写从来没被确认过(调用方的 await 还没返回),
按契约就是「没发生」,但它会被当成一条有效的动作意图读回来,恢复据此判成「状态未知」并可能
重放,而那个动作一定没执行过(调用方是在写意图返回之后才去执行的)。

判据改成「这一行有没有被换行终结」,不是「能不能解析」。同一个改动顺带修掉第三条:一行完整
终结的坏行(比如被外部追加的 {})此前会被当成撕裂尾行吞掉,读成「少了一条记录但看起来完整」
的日志;现在终结过的行解不开就是损坏,直接报错。

其余三条:
- 新建日志文件不 fsync 父目录。os.fsync(fd) 刷的是文件内容,刷不到「这个目录里多了一个
  文件」这条目录项;掉电后内容可能在而文件不存在,read_log 走「文件不存在」返回空日志,
  驱动入口判成全新运行,一次已经花过钱的运行静默没了留痕。只在新建时刷。
- 同一运行标识上的并发写会交错:一条记录可能由不止一次 os.write 写完,而 O_APPEND 只保证
  每次 write 的追加位置原子,保证不了一条逻辑行整体原子。按运行标识加锁串起来(不同运行
  照样并行),跨进程那一半仍靠独占创建挡。有一条用短写逼出那个窗口的测试。
- 往返测试的 TOTAL_WRITES 是硬编码,而且漏写结束标记它发现不了(恢复会把最后一步之后那次
  停止判定重演一遍,得出同样结果)。加一条把十次写的记录类型序列整个钉死的测试。
This commit is contained in:
2026-08-10 03:52:35 -04:00
parent 7f6066701e
commit aeb575e0f7
4 changed files with 299 additions and 34 deletions
+45 -6
View File
@@ -58,30 +58,33 @@ class _CrashingStore:
self._inner = inner
self._crash_after = crash_after
self.writes = 0
#: 逐条记下写的是哪种记录,用来验 TOTAL_WRITES 那个常量不是拍脑袋的。
self.kinds: list[str] = []
def _tick(self) -> None:
def _tick(self, record: object) -> None:
self.writes += 1
self.kinds.append(type(record).__name__)
if self.writes > self._crash_after:
raise _CrashError(f"{self.writes} 次写之前进程没了")
async def write_run_started(self, record) -> None:
self._tick()
self._tick(record)
await self._inner.write_run_started(record)
async def write_intent(self, record) -> None:
self._tick()
self._tick(record)
await self._inner.write_intent(record)
async def write_model_call_result(self, record) -> None:
self._tick()
self._tick(record)
await self._inner.write_model_call_result(record)
async def write_step_completed(self, record) -> None:
self._tick()
self._tick(record)
await self._inner.write_step_completed(record)
async def write_run_finished(self, record) -> None:
self._tick()
self._tick(record)
await self._inner.write_run_finished(record)
async def read_log(self, run_id: str):
@@ -372,3 +375,39 @@ async def test_a_resumed_log_survives_a_second_resume(tmp_path: Path) -> None:
assert result.stop_reason is StopReason.TASK_COMPLETED
assert [step.step_idx for step in result.steps] == [0, 1]
async def test_the_write_sequence_is_exactly_what_the_crash_matrix_assumes(tmp_path: Path) -> None:
"""把上面那个崩溃矩阵依赖的常量验一遍,顺便钉住四次写的顺序与收尾。
`TOTAL_WRITES` 要是和实际写入次数对不上,矩阵就会漏掉最后几个边界而没有任何人看得见。
更要紧的是最后那一条:**结束标记必须在把结果交给调用方之前写下**。漏写它的话,上面每一条
往返测试照样会绿——恢复会把最后一步之后那次停止判定重演一遍,得出同样的结果——所以那件事
只能在这里单独钉。
"""
counting = _CrashingStore(JsonlRunStore(directory=tmp_path), crash_after=10**6)
await run(
_definition(counting),
_request(
_ScriptedExecutor(),
_registry(replay_policy=ReplayPolicy.SAFE),
model_replay=ReplayPolicy.SAFE,
),
)
assert counting.writes == TOTAL_WRITES
assert counting.kinds == [
"RunStarted",
# 第一步:模型意图 → 模型结果 → 动作意图 → 步记录(后两者一次原子落地)
"Intent",
"ModelCallResult",
"Intent",
"StepCompleted",
# 第二步同上
"Intent",
"ModelCallResult",
"Intent",
"StepCompleted",
"RunFinished",
]