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
@@ -0,0 +1,100 @@
# Design 0012 · 模型调用接缝的网关适配器
**日期** 2026-08-10 · **状态** 待确认
**落实** `0003-public-api-shape.md` 决策四里的模型调用接缝,与 `../../CLAUDE.md` §1.5
「模型调用一律走 PolyGateway,不在本项目里另写重试 / 限流 / 熔断 / 缓存 / 遥测」。
**触及** `../../src/polyloop/adapters/`,以及 `../../tests/integration/`——那一层到现在还是空的,
因为「连真 PolyGateway 的是 integration」(`../../CLAUDE.md` §1.9),而在这之前没有任何东西连它。
## 这份适配器要跨的那条缝
一边是本库的 `ModelCall``ModelReply`:消息是内容块序列、绑定是字符串映射、失败以异常
表达。另一边是网关的 `GatewayClient.chat`:消息是 `list[dict[str, Any]]`、返回一个有二十来个
字段的 `LLMResponse`、失败抛一族它自己的异常。
**缝两边的形状都不归我们定**,所以这份文档定的全是「怎么对上」,不是「该长什么样」。
## 决策一:收一个已经装配好的客户端,不自己装配
```python
GatewayModelClient(client=..., settings=...)
```
网关的装配入口收十几个参数(限流器、熔断器、缓存后端、遥测记录器、重试与背压策略……),
而**那些全是治理配置,按 §1.5 不归本库管**。适配器自己调那个工厂等于替项目决定了这些,而且
项目常常要在多个用途之间共享同一个限流器和缓存——它自己装配才做得到。
**代价是调用方多写一行。** 接受,因为另一条路是把网关的工厂签名抄进我们的签名里:他们加一个
参数,我们就得跟着加一个,而漏跟的表现是「这个参数传不进去」。
## 决策二:同时收一份配置,只为算出可复现的模型身份
`parameters()` 要回答「这次运行用的是哪个模型配置」,续跑时逐字段比对。但**客户端不公开
它的源列表与 scope**(构造时收下,只留在内部),所以从客户端本身问不出这个答案。
于是适配器同时收那份 `GatewaySettings`,用**网关自己的** `build_model_fingerprint(sources)`
算指纹。那个函数是它的公开函数,语义是「本 scope 会用哪些(模型、请求形态)组合」,并且把
采样参数与推理开关也算进去——把 temperature 从 0 改成 1 之后重启,指纹会变。
**用它而不是我们自己拼一串**,因为模型身份怎么算是网关的事:他们哪天认为某个新字段也该参与
身份,改在他们那里,我们跟着变。自己拼的话,那个定义会和他们的悄悄分叉。
**残留风险照实认下:客户端与配置必须真的是同一对。** 传一个客户端加另一份配置,指纹会说谎,
而续跑守卫就白设了。库验不了这件事——客户端不公开它是按哪份配置装的。这条写进那个类的
docstring,让传参的人看得见。
## 决策三:消息按块拼成一个字符串
`Message.content` 是内容块序列,网关那边一条消息的 `content` 是一个值。第一版只有文本块,
所以把它们按顺序拼起来——不加分隔符,因为块之间本来就没有分隔符这个概念,加了就是往模型看见
的文字里塞东西。
将来有图片块时,这里改成网关/供应商的多模态数组形态。**那是加分支,不是改签名**,正是
`0003` 决策六把内容定成序列而不是裸字符串换来的。
## 决策四:绑定里只有网关认得的那两个键会传下去,其余留给参数快照
`ModelCall.binding` 是项目自己的坐标(某个下游有五维),网关只有 `session_id`
`parent_card_id` 两个槽位放得下这类东西。适配器把这两个键传下去,其余的键**不传**。
**这不是静默丢弃。** 绑定的首要消费者是参数快照——它的全部键值都进运行开始记录
`0006` 决策三),续跑时逐字段比对。也就是说那些键已经被记下来了,只是网关那边没有对应的
格子。适配器再报一次错,等于要求项目为了适配一个网关而裁剪自己的坐标系。
**认不得的键不报错,也是因为报错的那条路更糟**:项目换一个网关就要改绑定,而绑定同时是
续跑守卫的输入——改它会让所有在跑的运行续不上。
## 决策五:网关的异常原样穿出去
不捕获、不翻译、不重试。`session` 那一层已经定了模型调用失败怎么处置:记一条带失败说明的
结果记录、记一步、以模型调用失败收尾(`0004` 决策三 C 档),而失败说明取的是异常的类名与文本
——网关的异常类名(`AllSourcesExhausted``CircuitOpenError``GovernanceBackendError`……)
本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
**重试尤其不能做。** 网关内部已经有重试、退避、换源、熔断,外面再套一层会让两套预算重叠计费
——那正是网关自己在 1.1.1 里修掉的那类 bug。
`asyncio.CancelledError` 同样原样穿出去,它继承 `BaseException`,不会被任何 `except Exception`
接住。
## 决策六:空串的调用标识映射成空值
`LLMResponse.call_id` 的类型是 `str`,而本库的 `ModelReply.call_id``str | None` 且**绝不为
空串**——空串是个看起来合法的键,连表时静默匹配不上。所以拿到空串就映射成空值。
## 留给后续的
**响应里那些字段本库不带走,靠调用标识连过去。** 网关的响应有二十来个字段(用量、延迟、
缓存命中、成本、供应商实际报告的模型串……),而 `ModelReply` 只取三个:调用标识、可见回复、
推理段。其余的留在网关自己的账目里,两边靠调用标识连表。
这条对**可复现性**尤其要紧:`model_reported` 是供应商在响应体里报的模型串,它和配置里那个
别名可能分叉(供应商把别名指向新权重时),而实验复现必须认这个串。它不进本库的轨迹,但它在
网关的账目里,按调用标识连得上——这正是 `ModelReply.call_id` 那句「与账目之间的连接键」的
用处。
**流式的中间事件本库拿不到,也不要。** 网关的 `chat` 默认走流式但返回的是一个完整响应;
本库的接缝是「一次调用返回一次回复」,中间的增量属于观察通道,等事件集那份 design doc 定了
再看要不要透出去。
+58 -25
View File
@@ -72,10 +72,17 @@ class JsonlRunStore:
它满足 `polyloop.ports.RunStore`,但不显式继承那个 Protocol:结构化子类型不需要继承。
"""
__slots__ = ("_directory", "_write_all")
__slots__ = ("_directory", "_locks", "_write_all")
def __init__(self, *, directory: Path | str) -> None:
self._directory = Path(directory)
#: 每个运行标识一把锁,把同一个文件上的写串起来。
#:
#: 一条记录可能由不止一次 `os.write` 写完(`os.write` 允许短写),而 `O_APPEND` 只保证
#: 每一次 `os.write` 的追加位置原子,保证不了「一条逻辑行整体原子」。两个协程同时往同一
#: 个文件写时,一次短写会让两条记录交错成一段谁也解不开的字节。锁把这件事挡在进程内;
#: 跨进程那一半靠运行开始记录的独占创建挡(见 `write_run_started`)。
self._locks: dict[str, asyncio.Lock] = {}
#: **可注入的故障点**,见 `_write_all_bytes` 的 docstring。做成实例属性而不是方法,
#: 是因为 `__slots__` 让方法替换不掉,而替换它正是那条测试唯一的做法。
self._write_all = _write_all_bytes
@@ -169,8 +176,10 @@ class JsonlRunStore:
tag = _TAGS[type(record)]
line = json.dumps({RECORD_KEY: tag, **encode(record)}, ensure_ascii=False) + "\n"
path = self._path(record.run_id)
# 写入与 `fsync` 都是阻塞调用,而 `fsync` 在忙盘上可以到几十毫秒。直接在事件循环里做
# 会把同一个循环上所有并发运行一起卡住。
lock = self._locks.setdefault(record.run_id, asyncio.Lock())
async with lock:
# 写入与 `fsync` 都是阻塞调用,而 `fsync` 在忙盘上可以到几十毫秒。直接在事件循环里
# 做会把同一个循环上所有并发运行一起卡住。锁按运行标识分,所以不同运行照样并行。
await asyncio.to_thread(
self._write_line, path, line.encode("utf-8"), fsync=fsync, exclusive=exclusive
)
@@ -192,6 +201,24 @@ class JsonlRunStore:
os.fsync(descriptor)
finally:
os.close(descriptor)
if exclusive:
_fsync_directory(path.parent)
def _fsync_directory(directory: Path) -> None:
"""把新建文件的目录项刷下去。
`os.fsync(fd)` 刷的是那个文件的内容,刷不到「这个目录里多了一个文件」这条目录项。掉电之后
内容可能在、而文件根本不存在——那时 `read_log` 走「文件不存在」返回空日志,驱动入口据此
判成一次全新的运行,于是一次已经开始过、可能已经花过钱的运行静默没了留痕。
只在新建文件时做:往已有文件追加不改目录项。
"""
descriptor = os.open(directory, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _write_all_bytes(descriptor: int, payload: bytes) -> None:
@@ -211,30 +238,33 @@ def _write_all_bytes(descriptor: int, payload: bytes) -> None:
def _parse(raw: bytes, run_id: str) -> RunLog:
"""把一份文件内容还原成日志。
**第一条解不开的行就是日志的结尾**,它后面还有内容就不是撕裂而是损坏。追加写只在末尾产生
撕裂;中间出现读不了的字节意味着别的东西动过这个文件,那时跳过那一行接着读会拼出一份少了
几条记录、看起来却完整的日志,而恢复会照它做判断
**判据是「这一行有没有被换行终结」,不是「它能不能解析」。** 一次写入是先写整行再由调用方
等到它返回,所以文件末尾那段没有换行的字节对应的那次写**从来没有被确认过**——按契约它就是
没发生,丢掉它正是「要么都可见、要么都不可见」的落地方式
照「能不能解析」判会漏掉一个很具体的场景:短写正好写完了整个 JSON 对象、只差最后那个换行。
那段字节解得开,于是一条从没被确认的动作意图被当成有效记录读回来,恢复据此判成「状态未知」
并可能重放——而那个动作其实一定没执行过,因为调用方是在写意图返回之后才去执行的。
**被换行终结的行必须解得开**,解不开就是损坏,直接报错。追加写只在末尾产生撕裂;一条完整
终结的行读不了,说明别的东西动过这个文件,那时跳过它接着读会拼出一份少了几条记录、看起来
却完整的日志,而恢复会照它做判断。
"""
started: RunStarted | None = None
intents: list[Intent] = []
model_results: list[ModelCallResult] = []
steps: list[StepCompleted] = []
finished: RunFinished | None = None
torn_at: int | None = None
for number, chunk in enumerate(raw.split(b"\n"), start=1):
chunks = raw.split(b"\n")
# 文件以换行结尾时最后一段是空的;不以换行结尾说明最后那次写没写完。
terminated = chunks[:-1] if chunks and chunks[-1].strip() else chunks
for number, chunk in enumerate(terminated, start=1):
if not chunk.strip():
# 空行不携带记录,也不是撕裂的证据。
continue
if torn_at is not None:
raise DecodeError(
f"运行 {run_id!r} 的日志第 {torn_at} 行读不了,而第 {number} 行还有内容。"
"追加写只在末尾产生撕裂,中间读不了说明这个文件被别的东西动过"
)
record = _decode_line(chunk)
if record is None:
torn_at = number
continue
record = _decode_line(chunk, run_id=run_id, number=number)
if isinstance(record, RunStarted):
started = record
elif isinstance(record, Intent):
@@ -255,22 +285,25 @@ def _parse(raw: bytes, run_id: str) -> RunLog:
)
def _decode_line(chunk: bytes) -> object | None:
"""解一行。解不开返回 `None`——调用方据此判断它是不是撕裂的尾行
def _decode_line(chunk: bytes, *, run_id: str, number: int) -> object:
"""解一条被换行终结的行。解不开就是损坏,直接报错
**认不得的类型标签不算撕裂,直接报错。** 一行完整的 JSON 带着一个我们不认识的标签,说明
这份日志是别的版本或者别的东西写的,不是被杀在写一半。
这里不再有「解不开就当撕裂尾行」那条路——撕裂由有没有换行判定,进不到这个函数。
"""
where = f"运行 {run_id!r} 的日志第 {number}"
try:
payload = json.loads(chunk)
except (UnicodeDecodeError, json.JSONDecodeError):
return None
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DecodeError(
f"{where}读不了({type(exc).__name__})。它是被换行终结的完整一行,"
"说明这个文件被别的东西动过——追加写只在末尾产生撕裂"
) from exc
if not isinstance(payload, dict) or RECORD_KEY not in payload:
return None
raise DecodeError(f"{where}没有 {RECORD_KEY!r} 标签,这份文件不是本库写的")
tag = payload[RECORD_KEY]
decoder = _DECODERS.get(tag)
if decoder is None:
raise DecodeError(f"日志里出现认不得的记录类型 {tag!r},这份文件不是本库写的")
raise DecodeError(f"{where}的记录类型 {tag!r} 认不得,这份文件不是本库写的")
return decoder(payload)
+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",
]
+93
View File
@@ -12,6 +12,7 @@ from pathlib import Path
import pytest
from polyloop import stores
from polyloop.serialization import DecodeError
from polyloop.stores import RECORD_KEY, JsonlRunStore
from polyloop.types import (
@@ -394,3 +395,95 @@ def test_the_directory_does_not_enter_the_parameter_snapshot(tmp_path: Path) ->
记进去只会在换一台机器、挂载点变了的时候报出一次假的漂移,而那次续跑其实完全正常。
"""
assert JsonlRunStore(directory=tmp_path).parameters() == {"kind": "jsonl"}
async def test_a_torn_tail_that_happens_to_parse_is_still_dropped(tmp_path: Path) -> None:
"""短写正好写完整个 JSON、只差最后那个换行——这条记录照样不算数。
判据是「有没有被换行终结」,不是「能不能解析」。照后者判会漏掉一个很具体的场景:那次写
从来没有被确认过(调用方那个 await 还没返回),而它会被当成一条有效的动作意图读回来,
恢复据此判成「状态未知」并可能重放——可那个动作一定没执行过,因为调用方是在写意图返回
之后才去执行的。
"""
store = JsonlRunStore(directory=tmp_path)
await store.write_run_started(_started())
intact = json.dumps(
{
"record": "intent",
**{
"run_id": "r1",
"kind": "action",
"call_index": 0,
"result_id": "a0",
"replay_policy": "never",
},
},
ensure_ascii=False,
)
with _log_file(tmp_path).open("a", encoding="utf-8") as handle:
handle.write(intact) # 完整 JSON,但没有换行
log = await store.read_log("r1")
assert log.started == _started()
assert log.intents == ()
async def test_a_complete_bad_line_is_corruption_even_at_the_end(tmp_path: Path) -> None:
"""被换行终结的坏行是损坏,哪怕它在文件末尾。
当成撕裂尾行吞掉的话,一份被外部追加过一行垃圾的日志会读成「少了一条记录但看起来完整」,
而恢复会照它做判断。
"""
store = JsonlRunStore(directory=tmp_path)
await store.write_run_started(_started())
with _log_file(tmp_path).open("a", encoding="utf-8") as handle:
handle.write("{}\n") # 合法 JSON、完整终结,但没有类型标签
with pytest.raises(DecodeError, match="标签"):
await store.read_log("r1")
async def test_creating_the_log_file_syncs_the_directory_entry(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""新建文件要把目录项也刷下去,往已有文件追加则不必。
`os.fsync(fd)` 刷的是文件内容,刷不到「这个目录里多了一个文件」这条目录项。掉电之后内容
可能在、而文件根本不存在——那时 read_log 走「文件不存在」返回空日志,驱动入口判成一次全新
的运行,于是一次已经花过钱的运行静默没了留痕。
这条持久性没有别的进程内可观测形态,所以只能盯着那次调用本身。
"""
synced: list[Path] = []
monkeypatch.setattr(stores, "_fsync_directory", synced.append)
store = JsonlRunStore(directory=tmp_path)
await store.write_run_started(_started())
assert synced == [tmp_path]
await store.write_intent(_intent())
assert synced == [tmp_path] # 追加不改目录项
async def test_concurrent_writes_to_one_run_do_not_interleave(tmp_path: Path) -> None:
"""同一个运行标识上的写被串起来,一条记录不会被另一条切开。
一条记录可能由不止一次 os.write 写完(os.write 允许短写),而 O_APPEND 只保证每一次
os.write 的追加位置原子。两个协程同时写同一个文件时,一次短写会让两条记录交错成一段谁也
解不开的字节。
"""
store = JsonlRunStore(directory=tmp_path)
real = store._write_all # noqa: SLF001
def _short_write(descriptor: int, payload: bytes) -> None:
"""每次只写一半,逼出「一条记录两次 write」那个窗口。"""
real(descriptor, payload[: len(payload) // 2])
real(descriptor, payload[len(payload) // 2 :])
store._write_all = _short_write # noqa: SLF001
await asyncio.gather(*(store.write_intent(_intent(call_index=index)) for index in range(6)))
log = await store.read_log("r1")
assert len(log.intents) == 6