JSONL 是真相,SQLite 是镜像
昨天关了终端,今天列表还在,对话也能接着改。这两件事看起来像同一份存储,落盘时却走两条轨。上轨按行追加,下轨只抄封面。中途拔电之后,能捡回来的永远是已经过了闸门的那几行。
- Session 把 item 交给 LiveThread,失败只记日志session/mod.rs L3753
- LiveThread 把原文切片交给 storelive_thread.rs L203
- 白名单丢掉瞬时 EventMsg,执行标记一律留下policy.rs L9
- 一行 JSON 加换行,write_all 后再 flushrecorder.rs L1968
- 闸门先赢,Paginated 才投影 thread_historylive_writer.rs L337
- 投影失败只 warn,下次按字节偏移续live_writer.rs L345
- 观察过滤结果,再打字面 metadata patchlive_thread.rs L212
- 恢复从文件逐行 decode,不从 threads 表拼历史recorder.rs L1009
- 列表无库或出错,退回扫 sessions 目录recorder.rs L547
列表要快,恢复要对。一份文件很难同时满足。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 不能顶上去。
追加写和随机查要的物理形状不一样。绑在同一份格式上,要么列表变慢,要么每次追加都改整份文档。拆开之后,写路径可以先保证原文,再修镜像。换语言重写,合同还是这句。
如果先写 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 行
可重建的东西允许丢,不允许抢跑。两边绑进同一个事务,镜像一慢,原文也写不进去。允许落后、禁止超前,是日志加派生表的通用合同。
压缩会换掉一段历史。如果 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。
Claude Code:一份 JSONL,没有会话镜像库
当前会话路径是 projects/<project>/<sessionId>.jsonl。追加是同步 appendFileSync,一行 JSON 加换行,权限 0o600。列表走 getSessionFilesLite,读文件头尾,不经过 SQLite。
单文件读取有 50 MB 上限,注释写明会话 JSONL 可以长到数 GB,调用方必须先退出以免撑爆内存。Codex 把这个扫盘代价挪到启动回填。两边都承认 JSONL 会涨,一个读到上限就拒绝整文件读入,一个靠回填工人把封面重新抄进库。
两侧均已核对源码 · 2026-08-22压缩写到一半时拔电
一次会话已经写下 SessionMeta、一条用户消息、一条助手回复。接着压缩开始,Compacted 已经 flush,WorldState 还没写,这时拔电。
推演三件事:恢复能捡回哪一段历史;列表卡片上的标题会不会变;缺的那一行基线,回放时会被置成什么。