雨天小六

读懂 Codex(8.8):JSONL 尾部换行、坏行跳过和 Parse Error

· 更新于 2026-08-03 · 专栏:读懂 Codex

#Codex#Agent Runtime#持久化#Rollout#软件架构

JSONL 的恢复策略把“行边界安全”和“每行内容有效”分开:追加前补齐缺失换行,读取时跳过空行与坏行并计数,但绝不把坏行解释成完整记录。

本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:研究行级容错与追加尾部修复,不讨论压缩文件。输入:可能缺末尾换行、包含空行或无效 JSON 的 Rollout;状态所有者:open_rollout_for_append、ensure_rollout_is_newline_terminated 与 loader;成功结果:不粘连旧尾部的新追加行,以及尽可能多的可解析记录。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。

状态边界

问题本节答案
输入可能缺末尾换行、包含空行或无效 JSON 的 Rollout
状态所有者open_rollout_for_append、ensure_rollout_is_newline_terminated 与 loader
成功产物不粘连旧尾部的新追加行,以及尽可能多的可解析记录
研究范围研究行级容错与追加尾部修复,不讨论压缩文件

正常路径:先看顺序点

JSONL 尾部换行、坏行跳过和 Parse Error的正常路径时序图,展示检查非空文件最后一个字节、缺换行则先追加 newline、新记录按 JSON+newline 写入、Loader 逐行解析、坏行告警计数并继续
图 8.8-1:检查非空文件最后一个字节 → 缺换行则先追加 newline → 新记录按 JSON+newline 写入 → Loader 逐行解析 → 坏行告警计数并继续。这张图标出本节的实际顺序点与状态所有者。

这条路径可以压缩成五步:

  1. 检查非空文件最后一个字节。
  2. 缺换行则先追加 newline。
  3. 新记录按 JSON+newline 写入。
  4. Loader 逐行解析。
  5. 坏行告警计数并继续。

图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。

源码机制拆解

换行是物理隔离保证

若上次崩溃留下无 newline 尾部,直接追加会把两个 JSON 对象粘成一条永久坏行。open-for-append 先确保非空文件以 newline 结束,再允许新写。

坏行不会阻断全部历史

load_rollout_items 对空行无操作;serde 解析失败会 warning、增加 parse error 数量并继续后续行。这保护后面的完整 Turn 不被一个局部损坏遮蔽。

容错不等于无损修复

Loader 无法知道半截 JSON 原本是什么,也不会猜测字段。跳过意味着该行对应的语义确实可能缺失,调用方仍应把 parse error 当成数据完整性信号。

首个有效 Meta 仍是硬前提

普通坏行可以越过,但 Loader 需要得到 canonical SessionMeta 才能确认 identity/history mode。没有有效头的文件不能作为正常 Thread 历史。

写路径以完整行为确认单位

JsonlWriter 先序列化整个对象,附加 newline,再 write_all 并 flush。这样 Reader 可以把 newline 作为完整候选记录边界,而不是解析任意字节前缀。

Python 风格伪代码

下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:

async def open_for_append(path):
    file = await open_read_write(path)
    if await file.length() > 0:
        last = await file.read_byte_at(file.length - 1)
        if last != NEWLINE:
            await file.append(NEWLINE)
    return file

async def load_jsonl(path):
    items, parse_errors = [], 0
    for raw_line in await read_lines(path):
        if raw_line.strip() == b'':
            continue
        try:
            items.append(parse_rollout_line(raw_line))
        except ParseError:
            parse_errors += 1
            warn_bad_line()
            continue
    require_canonical_session_meta(items)
    return items, parse_errors

阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。

失败、取消与恢复

JSONL 尾部换行、坏行跳过和 Parse Error的失败路径图,区分尾部无换行直接追加、中间一行损坏、首个 SessionMeta 损坏、把 skip bad line 当已修复
图 8.8-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
尾部无换行直接追加旧半行与新 JSON 粘连两条语义一起不可解析追加前补 newline
中间一行损坏前后完整行仍存在局部记录丢失记录 parse error 后继续
首个 SessionMeta 损坏无法确认 Thread 身份整个加载失败报告无有效 canonical meta
把 skip bad line 当已修复语义缺口仍存在恢复状态可能少 Call/Turn向诊断层暴露错误计数

这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。

必须保持的不变量

  • 每次正常追加从新的行边界开始
  • 解析失败不能生成伪造 RolloutItem
  • 坏行之后的好行仍可读取
  • canonical SessionMeta 缺失时不得静默返回空历史

这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。

设计取舍

跳过坏行优先可用性而非强一致审计;它能恢复尽可能多的历史,却必须明确告诉使用者存在无法还原的语义缺口。

源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。

Mini Codex 复刻

实现 ensure_newline_tail 与 tolerant loader;用空行、中间坏 JSON、末尾半行和损坏首行四组 fixture 分别断言。

复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。

源码导航

相邻测试也很重要:

本节结论

JSONL 的恢复策略把“行边界安全”和“每行内容有效”分开:追加前补齐缺失换行,读取时跳过空行与坏行并计数,但绝不把坏行解释成完整记录。

阅读导航

上一节:8.7 · 下一节:8.9

评论


← 返回文章列表