雨天小六

读懂 Codex(8.13):ThreadStore trait 的存储中立契约

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

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

Core 依赖的是 Thread 生命周期和历史能力,而不是 JSONL 目录布局;ThreadStore trait 把 Live 写入、冷读取、Fork 准备、查询和归档删除收束成存储中立契约。

本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:逐类说明 trait 能力与默认语义,不进入 LocalThreadStore 内部。输入:ThreadId、Create/Resume 参数、RolloutItem、查询与生命周期请求;状态所有者:dyn ThreadStore 实现;成功结果:LiveThread 后端、StoredThread/History、PreparedFork 和管理操作结果。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。

状态边界

问题本节答案
输入ThreadId、Create/Resume 参数、RolloutItem、查询与生命周期请求
状态所有者dyn ThreadStore 实现
成功产物LiveThread 后端、StoredThread/History、PreparedFork 和管理操作结果
研究范围逐类说明 trait 能力与默认语义,不进入 LocalThreadStore 内部

正常路径:先看顺序点

ThreadStore trait 的存储中立契约的正常路径时序图,展示Core 构造领域参数、trait 方法表达所需能力、实现选择物理存储、返回不泄漏内部句柄的领域对象、Core 按统一错误分类处理
图 8.13-1:Core 构造领域参数 → trait 方法表达所需能力 → 实现选择物理存储 → 返回不泄漏内部句柄的领域对象 → Core 按统一错误分类处理。这张图标出本节的实际顺序点与状态所有者。

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

  1. Core 构造领域参数。
  2. trait 方法表达所需能力。
  3. 实现选择物理存储。
  4. 返回不泄漏内部句柄的领域对象。
  5. Core 按统一错误分类处理。

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

源码机制拆解

Live 生命周期与冷读取分开

create_thread/resume_thread 建立唯一写者;append/persist/flush/shutdown/discard 操作活动 Thread。read_thread/load_history/load_latest_model_context 则面向可能没有 Session 的冷数据。

Fork 是两阶段能力

prepare_fork 计算边界、模型上下文与源预留,返回 PreparedFork;真正创建子 Thread 由更高层完成。默认实现可返回 Unsupported,因此 Core 不能假设每种 Store 都支持引用式 Fork。

查询 API 返回领域分页对象

list/search、list_turns/list_items/search_history 与 lookup 不让调用者拼 SQL 或路径。Cursor、SortKey、include_archived 等参数成为跨实现的协议语义。

管理操作带显式幂等/部分成功语义

delete_thread 对 not found 可按接口语义处理;delete_threads 要求严格批量预检。archive_threads 默认在首个成功后遇到错误可返回已完成前缀,Local 实现还会处理 descendants。

能力缺失是类型化错误

Unsupported、ThreadNotFound、Conflict、InvalidRequest 与 Internal 让上层区分功能不存在、并发所有权冲突和数据损坏,不把所有失败压成 I/O string。

Python 风格伪代码

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

class ThreadStore(Protocol):
    async def create_thread(self, params) -> StoredThread: ...
    async def resume_thread(self, params) -> StoredThread: ...
    async def append_items(self, thread_id, items) -> None: ...
    async def persist_thread(self, thread_id) -> None: ...
    async def flush_thread(self, thread_id) -> None: ...
    async def shutdown_thread(self, thread_id) -> None: ...
    async def discard_thread(self, thread_id) -> None: ...
    async def load_history(self, params) -> StoredThreadHistory: ...
    async def load_latest_model_context(self, params) -> StoredModelContext: ...
    async def prepare_fork(self, params) -> PreparedFork:
        raise Unsupported('prepare_fork')
    async def list_turns(self, params) -> TurnPage: ...
    async def archive_thread(self, params) -> None: ...
    async def delete_threads(self, params) -> list[ThreadId]: ...

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

失败、取消与恢复

ThreadStore trait 的存储中立契约的失败路径图,区分Store 不支持 prepare_fork、Core 依赖本地路径、并发 writer 冲突、批量归档中途失败
图 8.13-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
Store 不支持 prepare_fork基本读写仍可用返回 Unsupported上层选择 copied fork 或拒绝
Core 依赖本地路径替换实现无法满足抽象边界泄漏只通过 StoredThread/HistoryPosition 交互
并发 writer 冲突已有活动所有者Conflict 而非覆盖由调用者恢复/复用正确 Session
批量归档中途失败已归档前缀可能存在返回/记录部分成功调用者按结果而非全有全无处理

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

必须保持的不变量

  • trait 不要求上层理解 JSONL/SQLite
  • 活动写入按 ThreadId 维持唯一所有者
  • 冷读取不必恢复 Session
  • 不支持的能力必须显式返回而非伪实现

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

设计取舍

领域化 trait 方法数量较多,但它让内存测试 Store、本地 Store 与未来远程实现共享语义,而不是共享偶然的文件操作。

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

Mini Codex 复刻

先写 InMemoryThreadStore 通过同一契约测试,再接 JSONL 实现;Core 测试只注入 trait,不检查磁盘目录。

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

源码导航

相邻测试也很重要:

本节结论

Core 依赖的是 Thread 生命周期和历史能力,而不是 JSONL 目录布局;ThreadStore trait 把 Live 写入、冷读取、Fork 准备、查询和归档删除收束成存储中立契约。

阅读导航

上一节:8.12 · 下一节:8.14

评论


← 返回文章列表