fix(stores): 修 Codex 对抗审查报的五条,其中两条同一根因
最实的一条:读取端只要一段能解析成 JSON 就收下,没检查它后面有没有换行。而短写完全可能
正好写完整个 JSON 对象、只差那个换行——那次写从来没被确认过(调用方的 await 还没返回),
按契约就是「没发生」,但它会被当成一条有效的动作意图读回来,恢复据此判成「状态未知」并可能
重放,而那个动作一定没执行过(调用方是在写意图返回之后才去执行的)。
判据改成「这一行有没有被换行终结」,不是「能不能解析」。同一个改动顺带修掉第三条:一行完整
终结的坏行(比如被外部追加的 {})此前会被当成撕裂尾行吞掉,读成「少了一条记录但看起来完整」
的日志;现在终结过的行解不开就是损坏,直接报错。
其余三条:
- 新建日志文件不 fsync 父目录。os.fsync(fd) 刷的是文件内容,刷不到「这个目录里多了一个
文件」这条目录项;掉电后内容可能在而文件不存在,read_log 走「文件不存在」返回空日志,
驱动入口判成全新运行,一次已经花过钱的运行静默没了留痕。只在新建时刷。
- 同一运行标识上的并发写会交错:一条记录可能由不止一次 os.write 写完,而 O_APPEND 只保证
每次 write 的追加位置原子,保证不了一条逻辑行整体原子。按运行标识加锁串起来(不同运行
照样并行),跨进程那一半仍靠独占创建挡。有一条用短写逼出那个窗口的测试。
- 往返测试的 TOTAL_WRITES 是硬编码,而且漏写结束标记它发现不了(恢复会把最后一步之后那次
停止判定重演一遍,得出同样结果)。加一条把十次写的记录类型序列整个钉死的测试。
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user