雨天小六

读懂 Codex(8.18):Turn/Item Projection 的增量物化

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

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

Paginated Projection 不是把每条 JSONL 原样复制进 SQLite,而是把 TurnStarted/终态和 ItemCompleted 归约成可分页的 Turn/Item 行,并以物理位置记录增量进度。

本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:研究物化输入、Turn/Item 状态更新和事务幂等性。输入:从 projection byte offset 开始的完整 RolloutLine;状态所有者:thread_history_materialization + thread_history SQLite schema;成功结果:turns、items、summary 字段和新的 ProjectionState。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。

状态边界

问题本节答案
输入从 projection byte offset 开始的完整 RolloutLine
状态所有者thread_history_materialization + thread_history SQLite schema
成功产物turns、items、summary 字段和新的 ProjectionState
研究范围研究物化输入、Turn/Item 状态更新和事务幂等性

正常路径:先看顺序点

Turn/Item Projection 的增量物化的正常路径时序图,展示读取完整增量行、验证 paginated ordinal 连续、把事件归约成 Turn/Item changes、事务重查 expected position、写行并推进 checkpoint
图 8.18-1:读取完整增量行 → 验证 paginated ordinal 连续 → 把事件归约成 Turn/Item changes → 事务重查 expected position → 写行并推进 checkpoint。这张图标出本节的实际顺序点与状态所有者。

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

  1. 读取完整增量行。
  2. 验证 paginated ordinal 连续。
  3. 把事件归约成 Turn/Item changes。
  4. 事务重查 expected position。
  5. 写行并推进 checkpoint。

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

源码机制拆解

SessionMeta 决定起始范围

物化先读取 canonical meta,得到 history_base 和 subagent_history_start_ordinal。继承前缀已经由 child 创建路径写入时,早于 start ordinal 的记录不应重复投影为 child 本地 items。

TurnStarted 创建可进行 Turn

Start 记录 turn_id、开始时间、模型窗口与 rollout start position。后续 Complete/Aborted 只在当前状态仍为 inProgress 时安装终态和 end position,避免迟到重复事件来回改写。

ItemCompleted 形成结构化 Item 行

每个 TurnItem 带稳定 item id、所属 turn、创建 ordinal/timestamp 和当前 snapshot。重复完成项保留原创建位置,同时可防御性更新 snapshot。

Summary 是投影字段

Turn 列表需要 first user message、final agent message 等摘要,不必每页反向扫完整 JSON。投影在看到相应 Item 时更新 summary 关联。

坏完整行只推进物理 offset

已 newline-terminated 但无法解析的行不会产生 changes,却会让 offset 前进,和 canonical Loader 的“告警后继续”一致;partial line 则完全不推进。

Python 风格伪代码

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

async def materialize(path, projection):
    changes = []
    cursor = projection.byte_offset
    ordinal = projection.next_ordinal

    for raw, end_offset in read_complete_lines(path, cursor):
        cursor = end_offset
        line = try_parse(raw)
        if line is None:
            continue
        require(line.ordinal == ordinal)
        ordinal += 1
        changes.extend(reduce_rollout_item(line.item, line.ordinal, end_offset))

    new_state = ProjectionState(cursor, ordinal)
    async with db.begin_immediate() as tx:
        require(await tx.state(thread_id) == projection)
        for change in changes:
            await tx.apply(change)
        await tx.set_state(thread_id, new_state)

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

失败、取消与恢复

Turn/Item Projection 的增量物化的失败路径图,区分TurnComplete 重复到达、ItemCompleted 重复、ordinal 不连续、两 projector 同时追赶
图 8.18-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
TurnComplete 重复到达Turn 已是 terminal不得改写首次终态仅 inProgress 接受终态更新
ItemCompleted 重复已有 creation ordinal保留首次位置只更新可变 snapshot
ordinal 不连续投影输入缺行或错序事务前失败停止并诊断规范日志
两 projector 同时追赶都读到相同 expected只有一个事务成功推进另一个重读 state 后重算

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

必须保持的不变量

  • Turn creation position 一旦写入保持稳定
  • 终态只从 inProgress 迁移一次
  • Item 的稳定 ID 用于幂等更新
  • ProjectionState 与所有 changes 原子提交

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

设计取舍

关系投影提供高效分页和搜索,却复制了一部分派生状态;用位置 checkpoint 和幂等 reducer 把这份冗余限制为可重建缓存。

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

Mini Codex 复刻

实现纯函数 reduce(line)->changes,数据库事务只做 compare-position、upsert changes、advance;用重复行和竞争 projector 测试幂等。

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

源码导航

相邻测试也很重要:

本节结论

Paginated Projection 不是把每条 JSONL 原样复制进 SQLite,而是把 TurnStarted/终态和 ItemCompleted 归约成可分页的 Turn/Item 行,并以物理位置记录增量进度。

阅读导航

上一节:8.17 · 下一节:8.19

评论


← 返回文章列表