# 会话持久化 · rollout、thread-store 与 SQLite

> 每个线程落盘为 sessions 目录下一个只追加的 JSONL 文件（rollout），它是唯一的真相；SQLite 里的线程列表与分页历史都是可以重建的投影。本篇看文件布局与行格式、后台写入任务与跨进程写锁、legacy 与 paginated 两种历史格式各记什么，以及冷数据压缩和输入历史 history.jsonl。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/rollout-and-storage
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 会话持久化 · rollout、thread-store 与 SQLite

[上一篇](https://daiw.net/manual/codex-source/thread-manager)把 `ThreadStore` 当作线程的持久化边界。这一篇打开它的默认实现 `LocalThreadStore`，看数据最终落在哪里、以什么格式、按什么顺序写。涉及 `codex-rollout`（JSONL 读写）、`codex-thread-store` 的 `local/` 目录（把 JSONL 与 SQLite 组织成线程存储）、`codex-state`（SQLite），以及管输入历史的 `codex-message-history`。

## 用户看到的样子

会话文件的位置、`codex resume` 与 `codex migrate-rollouts` 等命令、`history.persistence` 等配置，见手册[会话管理](https://daiw.net/manual/codex/sessions)。`CODEX_HOME` 由环境变量 `CODEX_HOME` 指定（必须是已存在的目录），否则是 `~/.codex`；SQLite 文件所在目录依次取配置项 `sqlite_home`、环境变量 `CODEX_SQLITE_HOME`，默认就是 `CODEX_HOME`。按代码整理，主要的落盘内容如下：

| 位置 | 内容 |
| --- | --- |
| `sessions/YYYY/MM/DD/rollout-<时间>-<线程 id>.jsonl` | 每个线程一个 rollout；`thread/revert` 生成的新文件在线程 id 后再接 `_<rollout id>`；压缩后多一个 `.zst` 后缀 |
| `archived_sessions/` | 归档线程的 rollout，归档时文件被挪到这里 |
| `session_index.jsonl` | 线程改名记录，只追加，读取时从文件尾往前找、最新的一条生效 |
| `history.jsonl` | 输入框的提交历史 |
| `thread-writer-locks/<线程 id>.lock` | 跨进程的写入锁 |
| `state_5.sqlite` 等 7 个 SQLite 文件 | 线程元数据、分页历史、日志、目标、记忆、消息队列 |

## 一行 rollout 长什么样

```rust
/// One persisted rollout JSONL record.
///
/// This intentionally does not implement Deserialize: JSONL readers must use
/// codex_rollout's canonical parser so nested decimal values survive the flattened envelope.
#[derive(Serialize, Clone, JsonSchema)]
pub struct RolloutLine {
    pub timestamp: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ordinal: Option<u64>,
    #[serde(flatten)]
    pub item: RolloutItem,
}
```

（`codex-rs/history/src/lib.rs:345`）

每行是一个 JSON 对象：`timestamp`、可选的 `ordinal`，再平铺一个 `{"type": ..., "payload": ...}`。`type` 取 `session_meta`、`response_item`、`event_msg`、`turn_context`、`compacted`、`token_usage_record` 等 snake_case 名字，对应 `RolloutItem` 的各个变体。注释里的“不实现 Deserialize”有来由：`codex_rollout::decode_rollout_line` 先把 `timestamp`、`ordinal` 摘出来再解析其余部分，绕开 serde 在平铺结构里重放带小数的数值时的已知问题（注释附了两个上游 issue 链接）。新建的 rollout 第一行是 `session_meta`：线程与会话 id、分叉来源、cwd、originator、CLI 版本、来源、模型提供方、基础指令、动态工具、历史格式等，写入时顺带采集 cwd 所在 Git 仓库的提交、分支与远端地址。`ordinal` 只出现在 paginated 格式里，从 0 开始逐行递增；引用式分叉的子线程从源线程历史的结束序号接着往下编。这份行格式另有 JSON Schema：`codex app-server generate-internal-json-schema` 导出的就是 `RolloutLine`。

## 写入：一个后台任务，一把跨进程锁

```mermaid
flowchart LR
  SE[Session] -->|RolloutItem| LT[LiveThread<br/>按策略过滤，同步元数据]
  LT --> LS[LocalThreadStore]
  LS --> RR[RolloutRecorder<br/>后台写入任务]
  RR --> J[rollout JSONL<br/>唯一真相]
  LS -->|仅 paginated| TH[thread_history_1.sqlite<br/>turn 与 item 投影]
  LS -->|元数据| ST[state_5.sqlite<br/>threads 表]
  J -.->|数据库损坏或缺失时回填| ST
```

`RolloutRecorder`（`codex-rs/rollout/src/recorder.rs:86`）不直接写文件，而是把 `AddItems`、`Persist`、`Flush`、`Shutdown`、`Discard` 几种命令送进一条容量 256 的通道，由独占文件句柄的后台任务依次执行。新线程的文件是惰性创建的：recorder 刚建好时并不打开文件，追加的条目先攒在任务的内存里，直到第一次 `Persist`，或者带着待写条目的 `Flush`，才真正创建文件，先写 `session_meta`，再写攒下的条目。写失败时任务进入恢复模式，重新打开文件再试一次，条目留在缓冲区里等下次重试。同一个线程只能有一个写入者：`WriterLockCoordinator` 在 `CODEX_HOME/thread-writer-locks/` 下为每个线程建一个锁文件并加操作系统文件锁，另一个进程再来就会得到 `thread ... already has an active writer`。

`LocalThreadStore` 每次追加都经过 `write_and_project`，JSONL 与 SQLite 的主次在这里写得很清楚：

```rust
    if matches!(history_mode, ThreadHistoryMode::Legacy) {
        durable_write(&recorder, write_op).await?;
    } else {
        let rollout_path = recorder.rollout_path();
        // SQLite is a rebuildable view. The flush barrier must win before projection starts so it
        // can lag JSONL after failure, but can never get ahead of canonical history.
        durable_write(&recorder, write_op).await?;
        if let Err(err) = super::thread_history_materialization::materialize_to_sqlite(
            store,
            rollout_id,
            rollout_path,
        )
        .await
        {
            warn!("failed to project durable rollout for {thread_id}: {err}");
        }
    }
```

（`codex-rs/thread-store/src/local/live_writer.rs:339`）

先让 JSONL 刷盘成功，再把新增的行投影进 SQLite；投影失败只记警告，因为 SQLite 可以落后于 JSONL，但绝不能跑到它前面。

## 哪些东西会落盘

不是每个事件都值得写进 rollout。`codex-rs/rollout/src/policy.rs` 逐一列出了规则：

```rust
pub fn should_persist_event_msg(ev: &EventMsg, history_mode: ThreadHistoryMode) -> bool {
    match ev {
        EventMsg::ItemCompleted(event) => {
            // Paginated rollouts store TurnItems.
            // Legacy rollouts keep only items with no lossless raw ResponseItem or legacy
            // equivalent.
            matches!(history_mode, ThreadHistoryMode::Paginated)
                || matches!(
// ...
        EventMsg::TokenCount(_)
        | EventMsg::ThreadGoalUpdated(_)
        | EventMsg::ThreadRolledBack(_)
        | EventMsg::TurnAborted(_)
        | EventMsg::TurnStarted(_)
        | EventMsg::TurnComplete(_)
        | EventMsg::ThreadSettingsApplied(_) => true,

        // Only persist these legacy events when the thread's history mode is Legacy.
        // New, paginated rollouts persist ItemCompleted events with TurnItems.
        EventMsg::UserMessage(_)
        | EventMsg::AgentMessage(_)
```

（`codex-rs/rollout/src/policy.rs:94`）

两种历史格式的差别就在这里。legacy 格式靠 `UserMessage`、`AgentMessage`、`McpToolCallEnd` 等旧事件记录对话；paginated 格式改为记录规范化的 `ItemCompleted`（里面是完整的 `TurnItem`），每一行都有序号，才能被投影成可分页的表。两种格式都会记轮次的开始、结束、中断与用量；各种增量、`*Begin` 事件、审批请求、警告之类的临时事件一律不写。模型的 `ResponseItem` 除了 `AdditionalTools`、`CompactionTrigger` 与未知类型，基本都会写。`ThreadStore` trait 的默认历史格式是 legacy，`LocalThreadStore` 覆盖成 paginated，所以本地新建的线程默认走分页格式；已有线程沿用 `session_meta` 里记下的格式，旧线程可以用 `codex migrate-rollouts` 迁移。

## SQLite：可以重建的投影

`codex-state` 的文档开宗明义：它从 JSONL rollout 里抽取元数据，镜像进本地 SQLite。`SqliteConfig` 登记了 7 个运行时数据库：

- **`state_5.sqlite`**：核心是 `threads` 表，最初一版有 id、rollout 路径、时间、来源、模型提供方、cwd、标题、归档标记、Git 信息等列，此后的迁移（目录里共 57 个）又陆续加了历史格式、名字、分区、项目等，支撑线程列表、搜索、改名与归档。列线程有两条路：调用方要求只查 SQLite 时直接查表；否则先扫描 rollout 文件，把扫到的线程在表里修补一遍，再从表里取这一页，没有 SQLite 时直接返回扫描结果。数据库还没回填完成时，启动流程会先扫描已有的 rollout 把元数据补进去。
- **`thread_history_1.sqlite`**：`thread_turns`、`thread_items` 两张表加上投影进度表 `thread_history_projection_state`（记着下一行在 JSONL 里的字节偏移与序号），是 paginated 线程的 `thread/turns/list`、`thread/items/list` 的数据来源。每行 rollout 的投影由[第 7 篇](https://daiw.net/manual/codex-source/app-server-protocol)提到的 `project_rollout_line` 完成。
- 其余五个：`logs_2.sqlite` 存本地日志（一个 tracing 层把日志送进容量 2048 的队列，按 512 条或每 10 秒批量写入，反馈上传时从这里取）；`goals_1.sqlite` 存线程目标；`memories_1.sqlite`、`memories_v2_1.sqlite` 存记忆；`queue_1.sqlite` 存排队等待发送的用户消息。

连接统一用 WAL 日志模式、`synchronous = NORMAL`、5 秒 busy timeout、最多 5 个连接。迁移用 sqlx，但刻意设置 `ignore_missing: true`：注释说这是为了让旧版本的二进制能打开被并行运行的新版本迁移过的数据库，已知版本仍按校验和核对。数据库坏了也不怕：app-server 启动时遇到 SQLite 损坏，会把坏文件挪进备份目录，“rebuild it from saved data”，并提示 `Codex rebuilt its local database.`。能这样做，正是因为真相在 JSONL 里。

跨线程的全文搜索也不走 SQLite：`search_rollout_matches` 调用 ripgrep 在 `sessions` 或 `archived_sessions` 目录里搜转义成 JSON 字符串形式的关键词，找不到 ripgrep 时自己逐文件扫描。

## 冷数据压缩与迁移

功能开关 `local_thread_store_compression` 打开后，后台任务会把 7 天前（`MIN_ROLLOUT_AGE`）的 rollout 用 zstd 3 级压缩成 `.jsonl.zst`，同时最多两个压缩任务，靠 `CODEX_HOME` 下的标记文件避免重复运行；读取方透明地同时支持两种文件，要追加时先解压回普通 JSONL。这个开关与后台迁移旧 rollout 的 `background_paginated_rollout_migration` 在 0.158.0 都处于开发阶段、默认关闭；实验性 RPC `rollout/compress` 可以手动触发一次压缩。

## 输入历史：history.jsonl

输入框按上方向键、`Ctrl+R` 翻找的历史，与 rollout 无关，由 `codex-message-history` 维护：每行一个 `{"session_id","ts","text"}`。写入时先在内存里拼好整行，用咨询式文件锁（最多重试 10 次、每次间隔 100 毫秒）保证多个 TUI 进程的写入不交错，Unix 上文件权限是 0600。`history.persistence = "none"` 时不写；设了 `history.max_bytes`，超限后从最旧的行删起，一次删到上限的 80%（`HISTORY_SOFT_CAP_RATIO`），免得下一次写入马上又要修剪。

## 和《从 LLM 到 Coding Agent》对照

那本书的 [session-history](https://daiw.net/manual/llm-to-agent/session-history) 强调“抄本即真相”：只追加的 JSONL 记录发生过的一切，压缩只是运行时的投影。Codex 把这条原则用了两遍：rollout JSONL 是真相，模型上下文的压缩结果以 `compacted` 条目追加进去而不改写旧行；SQLite 里的列表与分页表又是 JSONL 的投影，写入顺序保证它只会落后、不会超前，坏了可以重建。[Grok Build 的 Sessions](https://daiw.net/manual/grok-build/sessions) 一篇里，只追加的 `updates.jsonl` 同样是回放的真相源。

---

上一篇：[ThreadManager 与 CodexThread · 线程的生与死](https://daiw.net/manual/codex-source/thread-manager) · 下一篇：[codex exec 与 SDK · 没有界面的用法](https://daiw.net/manual/codex-source/exec-and-sdk)
