会话持久化 · rollout、thread-store 与 SQLite
每个线程落盘为 sessions 目录下一个只追加的 JSONL 文件(rollout),它是唯一的真相;SQLite 里的线程列表与分页历史都是可以重建的投影。本篇看文件布局与行格式、后台写入任务与跨进程写锁、legacy 与 paginated 两种历史格式各记什么,以及冷数据压缩和输入历史 history.jsonl。
会话持久化 · rollout、thread-store 与 SQLite
上一篇把 ThreadStore 当作线程的持久化边界。这一篇打开它的默认实现 LocalThreadStore,看数据最终落在哪里、以什么格式、按什么顺序写。涉及 codex-rollout(JSONL 读写)、codex-thread-store 的 local/ 目录(把 JSONL 与 SQLite 组织成线程存储)、codex-state(SQLite),以及管输入历史的 codex-message-history。
用户看到的样子
会话文件的位置、codex resume 与 codex migrate-rollouts 等命令、history.persistence 等配置,见手册会话管理。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 长什么样
/// 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。
写入:一个后台任务,一把跨进程锁
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 的主次在这里写得很清楚:
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 逐一列出了规则:
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 篇提到的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 强调“抄本即真相”:只追加的 JSONL 记录发生过的一切,压缩只是运行时的投影。Codex 把这条原则用了两遍:rollout JSONL 是真相,模型上下文的压缩结果以 compacted 条目追加进去而不改写旧行;SQLite 里的列表与分页表又是 JSONL 的投影,写入顺序保证它只会落后、不会超前,坏了可以重建。Grok Build 的 Sessions 一篇里,只追加的 updates.jsonl 同样是回放的真相源。
上一篇:ThreadManager 与 CodexThread · 线程的生与死 · 下一篇:codex exec 与 SDK · 没有界面的用法