进程终止以后,运行中的 Session、异步任务、Channel 和文件句柄都会消失。Codex 仍能恢复一条 Thread,并不是因为它把整个 Session 序列化到了磁盘,而是因为运行状态中有一部分被转换成了可重放记录。恢复过程创建新的运行时对象,再从这些记录计算出模型历史、上一轮设置、World State 和上下文窗口身份。
理解这套机制的第一个困难,是源码中同时存在多种看起来都像“历史”或“状态”的对象。ContextManager 有一份 ResponseItem 序列,Rollout 也包含 ResponseItem;SQLite 可以列出 Thread 和 Turn,AgentGraphStore 又能列出子 Thread。如果只按数据形状判断,很容易把它们当成同一份状态的不同副本。
它们并不是简单的副本关系。
本节先建立一套状态所有权模型,并回答四个问题:
- Context History 与 Rollout 都包含
ResponseItem,二者为什么不能互换; ThreadStore与本地 JSONL Rollout 分别处于哪一层;- 哪些 SQLite 数据是可追赶的投影,哪些是独立状态域;
- AgentGraph 为什么只能恢复父子身份,不能单独恢复子 Session。
白名单、写入屏障、分页投影和重建算法将在后续小节展开。本节只建立这些机制共同依赖的状态 所有权模型。
权威性取决于问题
分布式系统通常会讨论某份数据是否为 source of truth。对 Codex 而言,这个问题必须补全宾语:它对什么事实具有权威性?
例如,下面四个问题需要访问四种不同的状态:
- 下一次模型请求应携带哪些
ResponseItem? - 进程重启后,应当用哪些记录重建这条 Thread?
- Thread 列表页应显示什么标题、预览和最近更新时间?
- 某个父 Thread 直接创建了哪些子 Thread?
第一个问题由运行中的 Context History 回答;第二个问题由 ThreadStore 提供的持久历史回答;第三个问题通常由 SQLite 中的查询状态回答;第四个问题由 AgentGraphStore 回答。不存在一份同时对这四个问题都具有最终解释权的“总状态”。
可以用三个属性区分它们:
- 所有者:哪个组件可以合法地修改这份状态;
- 生命周期:状态只在进程内存在,还是跨进程保存;
- 恢复用途:状态用于构造模型输入、重建会话、加速查询,还是恢复拓扑。
表 8.1-1 汇总了本章涉及的五个核心概念。其中 ThreadStore 是持久化契约,不是一份具体状态;
把它单列出来,是为了避免把逻辑 Rollout 与本地 JSONL 文件混为一谈。
| 概念或状态 | 主要所有者 | 生命周期 | 主要用途 | 对什么事实具有权威性 |
|---|---|---|---|---|
| Context History | Session 内的 ContextManager | 当前 Session | 构造下一次模型请求 | 当前模型可见的工作集 |
| Rollout | ThreadStore 的持久化实现 | 跨进程 | Resume、Fork、Rollback 和诊断 | 本地 Thread 的规范重放记录 |
| ThreadStore | Core 与存储实现之间的 trait | 跨实现 | 创建、追加、刷新、读取和派生 Thread | 持久历史的访问与生命周期契约 |
| SQLite State / History Projection | StateRuntime 与本地 Store | 跨进程 | 列表、搜索、分页读取及独立状态域 | 查询视图;部分表另有独立所有权 |
| AgentGraph | AgentGraphStore | 跨进程 | 恢复父子关系和边状态 | Thread 的创建拓扑 |
这里需要特别区分 Rollout 与 ThreadStore。Rollout 是持久记录的协议和逻辑序列;ThreadStore 是 Core 依赖的存储中立接口。本地实现把 Rollout 写成 JSONL,并可投影到 SQLite;其他实现可以采用不同介质,只要满足同一组创建、追加、刷新和读取语义。
常见误区
“Rollout 是 JSONL 文件”只对当前本地实现成立。Core 代码依赖的是
ThreadStore,而不是某个文件路径或 SQLite 表。
Context History 是模型请求的工作集
ContextManager 内部维护一个按时间排序的 Arc<Vec<ResponseItem>>。它的直接消费者是下一次模型请求,因此它关心的是模型可见性,而不是历史审计。
源码中的几个操作体现了这种定位:
record_items只接收适合进入 API 历史的条目,并在写入时截断过大的工具输出;for_prompt在快照副本上执行规范化,补齐缺失的 Call/Output 配对,并移除当前模型不支持的图片或音频内容;replace用于 Compaction 或 Rollback 等历史重写,同时增加history_version;drop_last_n_user_turns从模型工作集中裁掉最近若干个用户 Turn,并在必要时清除已经失效的上下文比较基线。
这些操作都说明 Context History 可以被主动改写。一次 Compaction 可以用较短的 replacement history 替换原工作集;一次 Rollback 可以让最近几个 Turn 不再进入后续 Prompt。被替换的旧条目是否仍存在于磁盘,是 Rollout 层的问题,不由 ContextManager 决定。
因此,Context History 不能充当审计日志。它只能说明“这个 Session 现在准备把什么发给模型”,不能单独证明“这条 Thread 曾经发生过什么”。
Rollout 保存可重放语义
RolloutItem 是持久历史的顶层协议类型。当前定义包含以下变体:
RolloutItem
├── SessionMeta
├── ResponseItem
├── InterAgentCommunication
├── InterAgentCommunicationMetadata
├── Compacted
├── TurnContext
├── WorldState
└── EventMsg
这些变体共同保存恢复 Session 所需的语义,而不是保存旧 Session 对象本身。
ResponseItem 提供模型可见的消息、Reasoning、工具调用和工具输出;TurnContext 保存恢复上一轮模型设置所需的信息;WorldState 保存完整基线或基于基线的补丁;Compacted 可以携带 replacement history 和上下文窗口身份;少量 EventMsg 则标记 Turn 边界、回滚等恢复事件。
恢复器 reconstruct_history_from_rollout 会从新到旧识别 Turn 段、Rollback Marker 和可用的 Compaction Checkpoint,再把仍然有效的后缀按正序重放。它计算出的结果不只有模型历史,还包括上一轮设置、参考 TurnContext、World State 基线和上下文窗口编号。
这是一种事件重放,而不是对象反序列化:
persisted RolloutItem sequence
│
▼
reconstruction algorithm
│
├── model-visible history
├── previous turn settings
├── reference context
├── world-state baseline
└── context-window identity
Rollout 也不是运行时所有事件的完整录像。持久化策略只保留对未来重建有稳定意义的条目;文本增量、命令输出增量、审批等待和大量 UI 生命周期事件不会全部进入持久历史。8.2 将逐项分析这张白名单,8.3 再讨论实时 Event Stream 与 Rollout 的关系。
ThreadStore 定义持久化契约
ThreadStore trait 把 Core 与具体存储介质隔开。它的接口可以分为四组:
| 接口组 | 代表方法 | 语义 |
|---|---|---|
| 活跃生命周期 | create_thread、resume_thread、shutdown_thread、discard_thread | 建立或结束可追加的 Thread |
| 写入与屏障 | append_items、persist_thread、flush_thread | 追加记录,要求延迟状态物化,等待此前写入可读 |
| 历史读取与派生 | load_history、load_latest_model_context、prepare_fork | Resume、上下文恢复和 Fork |
| 查询与管理 | read_thread、list_threads、list_turns、archive_thread、delete_thread | 面向产品接口的查询和生命周期管理 |
LiveThread 是 Session 侧持有的活动句柄。它保存 thread_id、history_mode、ThreadStore 引用和元数据同步器,但不暴露底层文件句柄。Session 通过它追加原始 RolloutItem,本地 Store 再应用共享的持久化策略并执行实际写入。
LiveThread::append_items 的顺序值得单独指出:
- 调用 Store 持久化候选条目;
- 只有持久化成功后,才让
ThreadMetadataSync观察本批规范条目; - 如果形成元数据补丁,再更新标题、预览、cwd 或最近活动时间等查询字段。
这条顺序建立了一个重要约束:查询元数据可以落后于规范历史,但不应描述一批尚未成功写入的历史。8.16 会在本地 JSONL 与 SQLite 投影的实现中继续分析这一约束。
SQLite 中既有投影,也有独立状态域
“SQLite 不是重放权威”是一条有用的简化,但还不够准确。Codex 的 SQLite 状态需要分成两类看待。
第一类是从 Rollout 提取或追赶得到的查询状态,例如 Thread 元数据和 Paginated History Projection。它们服务列表、搜索和 Turn/Item 分页读取。投影落后时,可以继续扫描持久历史并补齐;投影中的缺行不能反过来证明规范历史不存在。
第二类是 Goal、Memory、Log、远程控制注册等独立状态域。它们虽然也使用 SQLite,却不是 Rollout 对话历史的普通副本。每个状态域有自己的写入接口、生命周期和恢复规则。
因此,判断一个 SQLite 表的权威性时,不能只看存储引擎。应当追踪写入它的组件以及读取它的业务问题:
SQLite is a storage engine
≠
one uniform consistency domain
本章谈到“SQLite 可重建”时,特指 Thread 元数据和分页历史等派生投影,不应推广到所有 SQLite 数据。
AgentGraph 只保存拓扑
AgentGraphStore 的职责比 ThreadStore 窄得多。它保存定向的父子边,并为边记录生命周期状态。接口提供四类操作:
- 插入或替换父子边;
- 更新某个子 Thread 入边的状态;
- 列出直接子节点;
- 按广度优先顺序列出后代。
这份状态能回答“谁创建了谁”,但不能回答“子 Thread 的模型历史是什么”。一个子节点即使仍以 Open 状态存在于图中,其 Session 也可能已经从内存卸载。调用者必须取得 child_thread_id,再通过 ThreadStore 恢复该 Thread 的持久历史。
反过来也一样。磁盘上存在一条完整的子 Rollout,并不能证明 AgentGraph 中仍有一条从当前父节点指向它的边。对话内容和创建拓扑是两个独立的一致性域。
注意
物理存储位置不能代替语义边界。即使某个本地实现把 Thread 元数据和 AgentGraph 边放在同一个 SQLite 文件中,它们仍由不同接口回答不同问题。
一次子 Thread 恢复经过哪些状态
下面用一个具体过程串联前面的概念。假设父 Thread 曾创建一个子 Thread,随后子 Session 因资源回收而离开内存。稍后,父 Thread 需要再次向它提交任务。
恢复过程可以分为四步:
AgentGraphStore根据父 ThreadId 找到目标子 ThreadId。此时只恢复了身份关系。ThreadStore打开该子 Thread 的持久记录,并返回恢复所需的RolloutItem。- Runtime 创建一个新的
Session,重新建立锁、Channel、Writer 和其他进程内资源。 - 重建器重放 Rollout,将结果安装到新的
ContextManager,随后才能构造下一次模型请求。
这个过程说明,拓扑恢复与会话恢复是协作关系,不是替代关系。它也解释了为什么“数据库里还能看到子节点”并不等于“原来的子 Agent 进程还活着”。
故障必须按状态域定位
当一次写入或恢复出现异常时,应先判断故障发生在哪个状态域,再决定修复手段。表 8.1-2 给出几种典型情况。
| 现象 | 受影响的状态域 | 可以推出什么 | 不能推出什么 |
|---|---|---|---|
| Thread 列表缺少最新预览 | SQLite 元数据投影 | 查询状态可能落后 | Rollout 一定丢失 |
| Paginated Turn 列表缺项 | History Projection | 投影位置或物化过程异常 | 对应 JSONL 行一定不存在 |
| Resume 后模型历史缺少工具结果 | Rollout 或重建算法 | 规范记录、解析或 Call/Output 修复需要检查 | UI 当时没有显示工具结果 |
| 图中存在 Open 子节点但无法直接提交 | AgentGraph 与运行时驻留状态 | 子 Thread 身份仍存在 | 子 Session 仍在内存 |
| UI 显示过一条命令输出增量 | Event Stream | 客户端曾收到瞬时事件 | 该增量会在 Resume 时重放 |
还有一条边界必须提前说明:文件修改、Git 提交、后台进程和网络请求不是这五类会话状态的一部分。Rollout 可以记录工具调用及其结果,但不会因此获得撤销外部副作用的能力。后续的 Rollback 只改变有效会话历史,不等同于工作区 Undo。
设计取舍:按语义拆分权威
把所有状态写进同一个对象或同一组数据库表,短期看会减少接口数量,却会把四种不同的变化频率 绑在一起:模型工作集需要频繁重写,规范历史需要追加和兼容,查询投影需要索引与修复,拓扑则 需要独立遍历和生命周期状态。
Codex 选择按语义拆分权威。代价是一次操作可能跨越多个组件,故障排查也必须确认完成到了哪一层; 收益是每个状态域可以采用与其读取模式匹配的数据结构和一致性保证。后续章节看到的白名单、Writer Task、投影检查点和冷恢复,都是这项取舍的具体结果。
可用于核对实现的不变量
阅读后续实现时,可以用以下不变量检查自己的理解:
- Context History 可以被重写,因此不能单独充当历史审计。
- 对本地 Thread 而言,恢复语义来自 ThreadStore 读取的规范 Rollout,而不是 SQLite 查询投影。
- 派生投影可以落后于规范历史,但不能用投影覆盖或伪造缺失的规范记录。
- AgentGraph 边证明父子身份关系,不证明某个
Session仍驻留内存。 - 存储介质不是语义边界;同一个 SQLite 文件可以包含多个具有不同所有者的状态域。
- 会话历史不负责自动回滚文件系统、Git、进程和网络副作用。
源码与测试证据
| 位置 | 可核对的合同 |
|---|---|
codex-rs/core/src/context_manager/history.rs | 模型工作集、Prompt 规范化、历史替换和回滚裁剪 |
codex-rs/protocol/src/protocol.rs | RolloutItem、CompactedItem 与 WorldStateItem 的协议形状 |
codex-rs/core/src/session/rollout_reconstruction.rs | 从 Rollout 计算模型历史、设置、World State 和窗口身份 |
codex-rs/thread-store/src/store.rs | 存储中立的 Thread 生命周期、写入、读取和查询接口 |
codex-rs/thread-store/src/live_thread.rs | 规范写入成功后再同步查询元数据的顺序 |
codex-rs/state/src/lib.rs | SQLite 元数据镜像与 Goal、Memory、Log 等状态接口 |
codex-rs/agent-graph-store/src/store.rs | 父子边、边状态和后代遍历接口 |
codex-rs/thread-store/src/local/thread_history_materialization_tests.rs | 落后投影从规范历史继续物化 |
codex-rs/agent-graph-store/src/local.rs | 父子边更新、状态过滤和广度优先遍历 |
本节边界
Codex 的持久化系统不是一个 Session 快照,也不是一张包办所有状态的数据库表。它由几类用途不同的状态和一层存储契约共同组成:
- Context History 是当前模型请求的可变工作集;
- Rollout 是恢复算法消费的规范记录;
ThreadStore定义持久历史的访问和生命周期契约;- SQLite 既承载可追赶的查询投影,也承载 Goal、Memory、Log 等独立状态域;
AgentGraphStore对父子拓扑负责,但不保存子 Thread 的对话内容。
建立这些边界后,下一个问题才有明确形式:哪些运行时对象有资格进入规范 Rollout?8.2 将从 RolloutItem 的顶层类型开始,分析 Persistence Policy 如何把可重放语义与瞬时交互信号分开。
源码导航
ContextManager的模型历史工作集RolloutItem协议定义ThreadStore存储中立契约LiveThread的持久化生命周期- SQLite State 的职责声明
AgentGraphStore拓扑接口- Rollout 重建算法
评论
登录后即可评论