第12章:会话持久化 —— rollout 与线程恢复
约 7 分钟 · 更新于 2026-09-01
第12章:会话持久化 —— rollout 与线程恢复
rollout 14835 行 + thread-store 29563 行 = 4.4 万行,只为了"把对话存下来"。本章解释这个数字:存下来只是起点,能列、能搜、能恢复、能分叉、能回滚、能归档、能压缩才是终点。
一、rollout:一个 JSONL 文件
1.1 长什么样
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:
text
$ jq -C . ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl
$ fx ~/.codex/sessions/rollout-…jsonl
文件名格式:rollout-<时间戳>-<会话 UUID>.jsonl,存在 ~/.codex/sessions/ 下。
JSONL(每行一个 JSON)而不是一个大 JSON,好处是:追加写不用重写整个文件、进程崩溃只丢最后一行、可以用 jq / grep / tail 直接看。
1.2 存的是什么
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).
为了事后能分析流程。 光有对话内容分析不出"为什么这里压缩了""这一轮用的什么策略"。
1.3 不是所有东西都存
is_persisted_rollout_item(item, history_mode) 是一道过滤 [源码 rollout/src/policy.rs:9],且过滤规则依赖 ThreadHistoryMode(第 3 章的 Op::ThreadSettings 可以改它)。
这是隐私和体积的平衡点:某些模式下不存完整历史。
二、四层存储
Codex 的会话持久化不止 rollout 文件一层:
text
┌───────────────────────────────────────────────────┐
│ thread-store(29563 行) │
│ 线程的业务语义:创建/恢复/分叉/回滚/归档/搜索/分组 │
├───────────────────────────────────────────────────┤
│ state(SQLite,21920 行 + rollout/state_db.rs) │
│ 索引与元数据:列表、排序、游标分页、按项目分组 │
├───────────────────────────────────────────────────┤
│ rollout(JSONL 文件,14835 行) │
│ 真源:完整的条目与事件流 │
├───────────────────────────────────────────────────┤
│ 冷归档(.zst) │
│ 后台压缩旧文件 │
└───────────────────────────────────────────────────┘
JSONL 是真源,SQLite 是索引。 这是一个经典且正确的分层——索引可以随时从真源重建(代码里有 STARTUP_BACKFILL_* 常量,就是启动时回填索引 [源码 rollout/src/state_db.rs:32]):
rust
const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_secs(1);
const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
启动时最多等 30 秒回填,超时就先用着——不能因为索引没建好就不让用户开始工作。
2.1 为什么需要 SQLite
因为这些操作在 JSONL 上做不了:
- 按最近活动时间排序列出所有会话
- 按项目/目录分组
- 全文搜索历史会话
- 游标分页(Cursor / SortDirection / ThreadSortKey [源码 rollout/src/state_db.rs:5-8])
用户有几百个会话时,遍历几百个 JSONL 文件去排序是不可接受的。
2.2 冷压缩
rust
const COMPRESSED_SUFFIX: &str = ".zst";
const MAX_NOT_FOUND_RETRIES: usize = 3;
const OPEN_ROLLOUT_LINE_READER_RETRY_DELAY: Duration = Duration::from_millis(50);
const TEMP_SUFFIX: &str = ".tmp";
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Starts a best-effort background job that compresses cold local rollout files.
[源码 rollout/src/compression.rs:18]
用 zstd 压缩冷文件。三个工程细节:
- best-effort(尽力而为) —— 压缩失败不影响任何功能
- 临时文件 + 计数器 —— 先写 .tmp 再原子改名,避免读到写了一半的文件
- MAX_NOT_FOUND_RETRIES + 重试延迟 —— 读取时文件可能正好在被压缩/改名,所以读失败要重试
并发的文件系统操作必须假设"我读的时候它可能正在变"。
三、反向扫描:从末尾读起
rollout/src/reverse_jsonl_scanner.rs 是一个从文件末尾往前读的 JSONL 扫描器 [源码]:
rust
const READ_CHUNK_SIZE: usize = 64 * 1024;
pub enum ScanOutcome<T> {
/// The record was valid JSON and deserialized as the requested type.
Parsed(T),
/// The record was not valid JSON for the requested type.
Rejected(serde_json::Error),
}
/// Read-only scanner for newline-delimited JSON records, starting from the end.
pub struct ReverseJsonlScanner<R> { … }
为什么需要它?因为最常见的查询是"这个会话最后说了什么"——用于会话列表的预览、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 | 迁移与血缘 |
| live_writer / writer_lock | 并发写入 |
| update_thread_metadata(2316 行) | 元数据更新 |
| pending_thread_metadata | 延迟元数据 |
4.1 回滚会新建文件
RolloutRecorderParams::Create 里有一个字段的注释解释了回滚的实现 [源码 rollout/src/recorder.rs:98]:
rust
/// Overrides the rollout ID encoded in the filename.
///
/// Normally this is `None`, so the filename is
/// `rollout-<timestamp>-<conversation_id>.jsonl`. `thread/revert` sets it, producing
/// `rollout-<timestamp>-<conversation_id>_<rollout_id>.jsonl`, because revert keeps the
/// thread ID stable while creating a new immutable rollout file.
rollout_id_override: Option<RolloutId>,
回滚不修改原文件,而是新建一个 rollout 文件,线程 ID 保持不变。
这是 append-only 存储的正确用法:Op::ThreadRollback { num_turns: 3 }(第 3 章)不会去删 JSONL 的最后几行,而是开一个新文件,从旧文件里取前面的部分。旧文件仍然完整,回滚本身是可追溯的。
文件名里的 _<rollout_id> 后缀就是这个血缘关系的编码,rollout_lineage.rs 负责解析它。
4.2 分叉
paginated_fork —— 从一个会话的某一点分出一条新线程。RolloutRecorderParams::Create 里对应的字段:
rust
forked_from_id: Option<ThreadId>,
parent_thread_id: Option<ThreadId>,
对应协议里的 thread/fork RPC(第 3 章)。
分叉和回滚是同一个能力的两种用法:回滚是"在同一条线程上退回去",分叉是"从某点开一条新线程"。底层都是"读旧 rollout 的前 N 项,写进新文件"。
4.3 会话恢复带回来什么
RolloutRecorderParams::Create 的字段清单本身就说明了"一个会话的身份"包含什么 [源码 rollout/src/recorder.rs:93-118]:
rust
session_id, conversation_id,
rollout_id_override, forked_from_id, parent_thread_id,
source, thread_source, originator,
base_instructions, // ← 第 6 章:恢复时用当初的指令
dynamic_tools, // ← 第 8 章:动态工具在线程启动时确定并持久化
selected_capability_roots, // ← 第 9 章:执行环境
multi_agent_version, // ← 第 14 章
history_mode, history_base, subagent_history_start_ordinal,
initial_window_id, // ← 第 7 章:token budget 的窗口编号
每一个字段都对应前面某一章讲过的东西。 会话恢复不是"把消息读回来",而是把整个运行时配置还原到当初的状态。
第 6 章讲的 base_instructions 三级优先级里的第二级(会话历史)就在这里落地——SessionMeta 里存着当初的指令,恢复时优先用它。
4.4 并发写入
writer_lock.rs + live_writer.rs 处理的是:同一个会话可能被多个进程打开(TUI 一个、IDE 插件一个、app-server 一个)。谁能写?
第 2 章讲 arg0 时提到的锁文件是同一类问题。只要允许多实例,就得处理写冲突。
五、rollout 的另一个消费者:GBrain 式的语料
这一节是本教程的引申,不是 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 行就是明证)。
七、动手复核
bash
# 1. 看你自己的 rollout
ls -lh ~/.codex/sessions/ | head
jq -C . ~/.codex/sessions/rollout-*.jsonl | head -50
# 2. 数一下一个会话里各类条目的分布
jq -r 'keys[0]' ~/.codex/sessions/rollout-*.jsonl | sort | uniq -c | sort -rn
# 3. 恢复一个会话
codex resume # 选择器
codex resume --last # 最近一个
bash
cd codex/codex-rs
# 4. 持久化的过滤规则
sed -n '1,40p' rollout/src/policy.rs
# 5. 回滚为什么新建文件
sed -n '93,110p' rollout/src/recorder.rs
# 6. 反向扫描器
sed -n '1,30p' rollout/src/reverse_jsonl_scanner.rs
# 7. thread-store 的功能清单
ls thread-store/src/local/
# 8. 两层存储的规模
for c in rollout thread-store state; do
echo -n "$c: "; find $c -name '*.rs' -print0 | xargs -0 cat | wc -l
done
八、总结
- rollout 是 JSONL 真源,SQLite 是可重建的索引,冷文件后台 zstd 压缩。索引回填有超时上限——不能因为索引没好就不让用户干活
- 同时存 ResponseItem(模型看到的)和 EventMsg(用户看到的),外加 TurnContext / WorldState / Compacted 这些执行标记,为的是事后能分析流程
- 反向 JSONL 扫描器:append-only 日志的最常见查询是"最近的 N 条",只做正向读取就等于把最常见的查询做成最慢的
- 回滚不改旧文件,而是新建一个 rollout,线程 ID 不变——append-only 存储的正确用法,回滚本身可追溯
- 会话恢复还原的是整个运行时配置,不只是消息:base_instructions、动态工具、能力根、历史模式、窗口编号
- 代价是磁盘、隐私和 4.4 万行的向后兼容负担
下一章讲怎么给它加东西:MCP、技能、钩子、插件、Code Mode 五条扩展通道。
- 第11章-审批与策略-从静态规则到模型判官
- 第13章-扩展面-MCP技能钩子插件与CodeMode
- 第3章-协议先行-SQEQ队列与多前端 —— 事件流的来源
- Pi 教程第 10 章
- dsh 教程第 8 章