OpenAI Codex · 会话存储

JSONL 是真相,SQLite 是镜像

昨天关了终端,今天列表还在,对话也能接着改。这两件事看起来像同一份存储,落盘时却走两条轨。上轨按行追加,下轨只抄封面。中途拔电之后,能捡回来的永远是已经过了闸门的那几行。

课程目标读完能说清三件事。第一,会话列表和会话恢复为什么不读同一份盘。第二,一次追加为什么必须先让 JSONL 过闸,再投影到 SQLite。第三,拔电发生在闸门前、闸门后或压缩写到一半时,各会丢掉什么。
先玩一遍 · 边跑边落盘,然后拔电
一次会话写下几件事,在你选的位置拔电
拔电时机
闸门是 JSONL 的 flush。过了闸门,恢复就能读到这一行。投影可以晚到,不能抢跑。
JSONL 原文0 行过闸
SQLite 封面空卡片
flush barrier纸带还没走到闸门。
恢复读文件还没拔电,先看纸带怎么往前走。
列表读镜像卡片会抄标题、目录和路径,不抄整段对话。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. Session 把 item 交给 LiveThread,失败只记日志session/mod.rs L3753
  2. LiveThread 把原文切片交给 storelive_thread.rs L203
  3. 白名单丢掉瞬时 EventMsg,执行标记一律留下policy.rs L9
  4. 一行 JSON 加换行,write_all 后再 flushrecorder.rs L1968
  5. 闸门先赢,Paginated 才投影 thread_historylive_writer.rs L337
  6. 投影失败只 warn,下次按字节偏移续live_writer.rs L345
  7. 观察过滤结果,再打字面 metadata patchlive_thread.rs L212
  8. 恢复从文件逐行 decode,不从 threads 表拼历史recorder.rs L1009
  9. 列表无库或出错,退回扫 sessions 目录recorder.rs L547
点播放。会话边跑边落盘,你选的位置会拉闸。
恢复
列表
合同
教学示意:行数、标题和拔电位置为课程化设定,用来对照闸门前后的恢复差异。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 追加日志当原文,派生表当封面
它解决什么问题

列表要快,恢复要对。一份文件很难同时满足。JSONL 追加便宜,用 jq 就能读。按工作区、置顶、归档去筛,它就不合适。SQLite 擅长这些过滤,却不该成为恢复时拼模型输入的地方。

两件看起来相反的事故,其实指向同一条规则。state_5.sqlite 被删掉,列表空一阵又长回来,对话还在。你用手改库里的标题和 cwd,刷新后有时跟着变,有时又变回去。镜像可以重建,也可以被原文覆盖。原文丢了,镜像救不回来。

思路是什么

Codex 拆成两轨。Session 不直接碰文件,它把已经构造好的 item 交给当前的 LiveThread。没有 live handle,或者 append 失败,turn 本身不因此中断,错误只进日志。

出处:codex-rs/core/src/session/mod.rs 第 3753 至 3759 行

LiveThread 先按策略过滤一份观察用的副本,交给 store 的仍是原始切片。store 自己再跑一遍白名单。流式增量、审批、警告这些瞬时 EventMsg 进不了 JSONL。Compacted、TurnContext、WorldState、SessionMeta 一律留下。

写的顺序固定。先让 JSONL 落盘,再投影到 SQLite。投影失败可以下次重做。JSONL 失败,SQLite 不能顶上去。

Session LiveThread JSONL rollout 一行一条,flush 之后才算过闸 SQLite 镜像 threads 行只抄标题、目录、路径 恢复、fork、压缩回放 只读 JSONL,按行 decode 再重建 列表、搜索、置顶分区 走镜像;库不可用就退回扫目录
教学化结构图:同一条 item 先盖进纸带,再抄到卡片。两条读路径从此分开。
为什么长期成立

追加写和随机查要的物理形状不一样。绑在同一份格式上,要么列表变慢,要么每次追加都改整份文档。拆开之后,写路径可以先保证原文,再修镜像。换语言重写,合同还是这句。

思路二 · 镜像可以落后,不能超前
它解决什么问题

如果先写 SQLite 再补 JSONL,进程死在两步中间,列表里会出现点不开的会话。用户看见标题,点进去没有对应行。这种不一致比列表暂时为空更难查。

思路是什么

Paginated 模式下,注释把 SQLite 写成可重建视图。flush 屏障必须先赢,投影可以落后,不能超前。durable_write 返回 Ok 之后,materialize_to_sqlite 才许开始。投影出错只打 warn。

出处:codex-rs/thread-store/src/local/live_writer.rs 第 335 至 347 行

落到字节的那一步,一行 JSON 加一个换行,write_all 后再 flush。这里的 flush 是 tokio 文件缓冲,源码没有再调 sync_all。进程被立刻杀掉时,最后几行可能停在内核页缓存里。下次打开会补换行,坏掉的半行计进 parse_errors

出处:codex-rs/rollout/src/recorder.rs 第 1968 至 1974 行

恢复、fork、压缩回放只读 JSONL。列表优先走 SQLite。库不存在、打开失败、回填未完成,一律退回扫 ~/.codex/sessions/ 目录,并打稳定指标 codex.sqlite.fallback.count

出处:codex-rs/rollout/src/recorder.rs 第 547 至 559 行

一次追加的时间线 write_all flush 闸门 投影到 SQLite 闸门前拔电 最后一行可能还在页缓存,恢复读不到 闸门后拔电 恢复能捡回这一行,列表封面可以晚一拍 屏障在这里,投影不许越过它
教学化时序图:同一条写入,拔电位置决定恢复能看见哪一行。
为什么长期成立

可重建的东西允许丢,不允许抢跑。两边绑进同一个事务,镜像一慢,原文也写不进去。允许落后、禁止超前,是日志加派生表的通用合同。

思路三 · 压缩合同写在文件里
它解决什么问题

压缩会换掉一段历史。如果 Compacted、WorldState、TurnContext 只活在内存或只活在 SQLite,删库之后窗口号和基线一起丢。用户以为模型忘了,加载器其实只是没读到那三行。

思路是什么

压缩先改内存历史,再按 Compacted、WorldState、TurnContext 的顺序落盘。WorldState 必须跟在 replacement history 后面,因为它是这份新历史的基线。

出处:codex-rs/core/src/session/mod.rs 第 3417 至 3427 行

恢复时反过来读。从后往前扫,碰到带 replacement_history 的 Compacted 就切断更早的后缀,并清掉更早的 TurnContext 基线。再正序重放 WorldState,full 快照重置基线,patch 往上合并。

出处:codex-rs/core/src/session/rollout_reconstruction.rs 第 155 至 188 行

这三类对列表几乎无用。apply_rollout_item 碰到 Compacted 和 WorldState 是空操作。镜像不是全文索引,是列表和筛选要用的字段。标题来自 UserMessage,不从模型的 ResponseItem 猜。

出处:codex-rs/state/src/extract.rs 第 14 至 34 行

原文先落盘。封面可以重建。
为什么长期成立

恢复合同写在文件里。改持久化形状等于改恢复合同。仓库根的 AGENTS.md 把从已有 rollout 恢复会话列进破坏性变更检查清单。文件还在,会话就能按合同重放。

横向对比 · 同一份历史,三种落点

DSH:同一种日志,两种后端

DeepSeek Harness 的持久化单元就是内存里的 SessionEvent。JSONL 和 SQLite 实现同一份 SessionPersistence seam,换后端换的是存储原语,不换日志语义。header 带 SESSION_FORMAT_VERSION = 0。版本不对,或出现未知且未标 ignorable 的事件,直接拒绝解读,错误叫 SessionFormatUnsupportedError

静默残缺比报错更难查,DSH 选择报错。Codex 选择尽量打开,未知形状靠 serde 失败计入 parse_errors,有 item 时仍会尽量建 builder。

两侧均已核对源码 · 2026-08-22 · DSH · 会话持久化

Claude Code:一份 JSONL,没有会话镜像库

当前会话路径是 projects/<project>/<sessionId>.jsonl。追加是同步 appendFileSync,一行 JSON 加换行,权限 0o600。列表走 getSessionFilesLite,读文件头尾,不经过 SQLite。

单文件读取有 50 MB 上限,注释写明会话 JSONL 可以长到数 GB,调用方必须先退出以免撑爆内存。Codex 把这个扫盘代价挪到启动回填。两边都承认 JSONL 会涨,一个读到上限就拒绝整文件读入,一个靠回填工人把封面重新抄进库。

两侧均已核对源码 · 2026-08-22
课堂练习
01

压缩写到一半时拔电

一次会话已经写下 SessionMeta、一条用户消息、一条助手回复。接着压缩开始,Compacted 已经 flush,WorldState 还没写,这时拔电。

推演三件事:恢复能捡回哪一段历史;列表卡片上的标题会不会变;缺的那一行基线,回放时会被置成什么。

Takeaway:JSONL 挡住历史丢失。SQLite 挡住列表太慢。回填挡住镜像空了。fallback 挡住镜像撒谎。任何一层都可以失败。默认让列表降级,不要让恢复降级。