rollout 14835 行 + thread-store 29563 行 = 4.4 万行,只为了"把对话存下来"。本章解释这个数字:存下来只是起点,能列、能搜、能恢复、能分叉、能回滚、能归档、能压缩才是终点。
RolloutRecorder 的文档注释直接给了检查方法 [源码 rollout/src/recorder.rs:77]:
Writes canonical session rollout items to JSONL. Rollouts are recorded as JSONL and can be inspected with tools such as:
文件名格式:rollout-<时间戳>-<会话 UUID>.jsonl,存在 ~/.codex/sessions/ 下。
JSONL(每行一个 JSON)而不是一个大 JSON,好处是:追加写不用重写整个文件、进程崩溃只丢最后一行、可以用 jq / grep / tail 直接看。
RolloutItem 有这些变体 [源码 rollout/src/policy.rs:9]:
| 变体 | 内容 |
|---|---|
| ResponseItem | 发给模型 / 模型返回的条目 |
| EventMsg | 第 3 章那 80 种事件 |
| SessionMeta | 会话元数据 |
| TurnContext | 每轮的上下文配置 |
| WorldState | 第 6 章的世界状态快照 |
| Compacted | 压缩检查点 |
| SecurityRiskScore | 安全风险评分 |
| InterAgentCommunication | agent 之间的通信(第 14 章) |
注意这里同时存了两个层次的东西:ResponseItem(模型看到的)和 EventMsg(用户看到的)。它们不是同一份数据的两种视图——比如工具执行的中间输出流(ExecCommandOutputDelta)只在事件里有,而模型只看到最终结果。
保留 TurnContext / WorldState / Compacted 的理由写在注释里:
Persist Codex executive markers so we can analyze flows (e.g., compaction, API turns).
为了事后能分析流程。 光有对话内容分析不出"为什么这里压缩了""这一轮用的什么策略"。
is_persisted_rollout_item(item, history_mode) 是一道过滤 [源码 rollout/src/policy.rs:9],且过滤规则依赖 ThreadHistoryMode(第 3 章的 Op::ThreadSettings 可以改它)。
这是隐私和体积的平衡点:某些模式下不存完整历史。
Codex 的会话持久化不止 rollout 文件一层:
JSONL 是真源,SQLite 是索引。 这是一个经典且正确的分层——索引可以随时从真源重建(代码里有 STARTUP_BACKFILL_* 常量,就是启动时回填索引 [源码 rollout/src/state_db.rs:32]):
启动时最多等 30 秒回填,超时就先用着——不能因为索引没建好就不让用户开始工作。
因为这些操作在 JSONL 上做不了:
用户有几百个会话时,遍历几百个 JSONL 文件去排序是不可接受的。
[源码 rollout/src/compression.rs:18]
用 zstd 压缩冷文件。三个工程细节:
并发的文件系统操作必须假设"我读的时候它可能正在变"。
rollout/src/reverse_jsonl_scanner.rs 是一个从文件末尾往前读的 JSONL 扫描器 [源码]:
为什么需要它?因为最常见的查询是"这个会话最后说了什么"——用于会话列表的预览、codex resume --last、判断会话是否还有未完成的轮次。
一个跑了三小时的会话可能有几十 MB 的 JSONL。从头读到尾去拿最后一条,是纯粹的浪费。
ScanOutcome::Rejected 那个变体也值得注意:扫到一行解析不出来,不是错误,是一种正常结果。因为 JSONL 里混着多种类型的条目,反向找"最后一条用户消息"时,中间那些不是用户消息的行本来就该被跳过。
可迁移的判断 ㉑ append-only 日志要配一个反向读取器。 写是顺序追加、读是"最近的 N 条",这两个方向天然不对称。只做正向读取的日志系统,最常见的查询就是最慢的那个。
thread-store/src/local/ 下的模块名就是一份功能清单 [源码]:
| 模块 | 功能 |
|---|---|
| create_thread / read_thread / delete_thread | 基本 CRUD |
| list_threads / search_threads | 列表与搜索 |
| paginated_fork | 分叉 |
| revert_thread | 回滚 |
| archive_thread / unarchive_thread | 归档 |
| move_thread_to_section / thread_sections | 分组 |
| projects | 按项目组织 |
| thread_history / thread_history_materialization | 历史物化 |
| rollout_migration / rollout_lineage / thread_rollout_resolver | 迁移与血缘 |
| / |
RolloutRecorderParams::Create 里有一个字段的注释解释了回滚的实现 [源码 rollout/src/recorder.rs:98]:
回滚不修改原文件,而是新建一个 rollout 文件,线程 ID 保持不变。
这是 append-only 存储的正确用法:Op::ThreadRollback { num_turns: 3 }(第 3 章)不会去删 JSONL 的最后几行,而是开一个新文件,从旧文件里取前面的部分。旧文件仍然完整,回滚本身是可追溯的。
文件名里的 _<rollout_id> 后缀就是这个血缘关系的编码,rollout_lineage.rs 负责解析它。
paginated_fork —— 从一个会话的某一点分出一条新线程。RolloutRecorderParams::Create 里对应的字段:
对应协议里的 thread/fork RPC(第 3 章)。
分叉和回滚是同一个能力的两种用法:回滚是"在同一条线程上退回去",分叉是"从某点开一条新线程"。底层都是"读旧 rollout 的前 N 项,写进新文件"。
RolloutRecorderParams::Create 的字段清单本身就说明了"一个会话的身份"包含什么 [源码 rollout/src/recorder.rs:93-118]:
每一个字段都对应前面某一章讲过的东西。 会话恢复不是"把消息读回来",而是把整个运行时配置还原到当初的状态。
第 6 章讲的 base_instructions 三级优先级里的第二级(会话历史)就在这里落地——SessionMeta 里存着当初的指令,恢复时优先用它。
writer_lock.rs + live_writer.rs 处理的是:同一个会话可能被多个进程打开(TUI 一个、IDE 插件一个、app-server 一个)。谁能写?
第 2 章讲 arg0 时提到的锁文件是同一类问题。只要允许多实例,就得处理写冲突。
这一节是本教程的引申,不是 Codex 的功能。
Codex 把 rollout 存成结构化 JSONL、带完整的事件流和执行上下文,客观上产生了一份高质量的 agent 行为语料:每一次工具调用、每一次审批、每一次压缩、每一次失败重试都有记录。
本仓的 sessions/(Codescope 问答留存)和 GBrain/(研究摘要留存)解决的是同一类需求——让 agent 的工作过程可检索。差别在于:
| Codex rollout | 本仓 sessions/ | |
|---|---|---|
| 粒度 | 每个事件 | 每个主任务一篇 |
| 格式 | JSONL(机器读) | Markdown(人读 + 入 GBrain) |
| 生成 | 运行时自动 | Hook / 扩展自动 + agent 补结论 |
| 索引 | SQLite | GBrain 向量检索 |
两者都印证了一件事:agent 的价值有相当一部分在它跑过的过程里,不只在最终答案里。
① 磁盘。 一个活跃用户几个月能攒出几个 GB。所以有冷压缩、有归档、有 RolloutConfig 配置保留策略。
② 隐私。 rollout 里有完整的代码内容、命令输出、可能还有环境变量。is_persisted_rollout_item 的过滤 + ThreadHistoryMode 是缓解,但默认情况下你的 ~/.codex/sessions/ 是一个高敏感目录。
③ 4.4 万行的维护成本。 而且它是最难改的那一类代码——格式一旦发布就要向后兼容(rollout_migration.rs 1300 行就是明证)。
下一章讲怎么给它加东西:MCP、技能、钩子、插件、Code Mode 五条扩展通道。
| 并发写入 |
| update_thread_metadata(2316 行) | 元数据更新 |
| pending_thread_metadata | 延迟元数据 |