JSONL 是真相,SQLite 是镜像
昨天关了终端,今天列表还在,对话也能接着改。这两件事看起来像同一份存储,落盘时却走两条轨。上轨按行追加,下轨只抄封面。中途拔电之后,能捡回来的永远是已经过了闸门的那几行。
本页解决的问题
先给结论「JSONL 是真相,SQLite 是镜像」要解决的关键问题是什么?
昨天关了终端,今天列表还在,对话也能接着改。这两件事看起来像同一份存储,落盘时却走两条轨。上轨按行追加,下轨只抄封面。中途拔电之后,能捡回来的永远是已经过了闸门的那几行。
跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。
为一个自动化步骤写清输入、负责人、审批和恢复动作。
一次运行成功了,却说不清发生了什么,也无法安全重放。
- 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 还没写,这时拔电。
推演三件事:恢复能捡回哪一段历史;列表卡片上的标题会不会变;缺的那一行基线,回放时会被置成什么。
「先玩一遍 · 边跑边落盘,然后拔电」的能力藏在每次交接里
「列表要快,恢复要对。一份文件很难同时满足。JSONL 追加便宜,用 jq 就能读。按工作区、置顶、归档去筛,它就不合适。SQLite 擅长这些过滤,却不该成为恢复时拼模型输入的地方」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「两件看起来相反的事故,其实指向同一条规则。」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
- Session 把 item 交给 LiveThread,失败只记日志 session/mod.rs L3753
- LiveThread 把原文切片交给 store live_thread.rs L203
- 白名单丢掉瞬时 EventMsg,执行标记一律留下 policy.rs L9
成功路径不能代表系统可靠
用「推演三件事:恢复能捡回哪一段历史;列表卡片上的标题会不会变;缺的那一行基线,回放时会被置成什么」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「先玩一遍 · 边跑边落盘,然后拔电」走到「思路一 · 追加日志当原文,派生表当封面」
「先玩一遍 · 边跑边落盘,然后拔电」先把问题落在「一次会话写下几件事,在你选的位置拔电 播放 单步 重置 拔电时机 闸门前 闸门后 两边写完 压缩中途 闸门是 JSONL 的 flush。过了闸门,恢复就能读到这一行。投影可以晚到,不能抢跑。 JSONL 原文 0 行过闸 SQLite 封面 空卡片 flush barrier 纸带还没走到闸门。 恢复读文件 还没拔电,先看纸带怎么往前走。 列表读镜像 卡片会抄标题、目录和路径,不抄整段对话。 逻辑轨迹 · 动画每一…」上;到了「思路一 · 追加日志当原文,派生表当封面」,讨论继续推进到「列表要快,恢复要对。一份文件很难同时满足。JSONL 追加便宜,用 jq 就能读。按工作区、置顶、归档去筛,它就不合适。SQLite 擅长这些过滤,却不该成为恢复时拼模型输入的地方」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「先玩一遍 · 边跑边落盘,然后拔电」:一次会话写下几件事,在你选的位置拔电 播放 单步 重置 拔电时机 闸门前 闸门后 两边写完 压缩中途 闸门是 JSONL 的 flush。过了闸门,恢复就能读到这一行。投影可以晚到,不能抢跑。 JSONL 原文 0 行过闸 SQLite 封面 空卡片 flush barrier 纸带还没走到闸门。 恢复读文件 还没拔电,先看纸带怎么往前走。 列表读镜像 卡片会抄标题、目录和路径,不抄整段对话。 逻辑轨迹 · 动画每一…
- 「思路一 · 追加日志当原文,派生表当封面」:列表要快,恢复要对。一份文件很难同时满足。JSONL 追加便宜,用 jq 就能读。按工作区、置顶、归档去筛,它就不合适。SQLite 擅长这些过滤,却不该成为恢复时拼模型输入的地方
- 「最后的要点」:闸门先赢,Paginated 才投影 thread_history live_writer.rs L337
最后的「最后的要点」把讨论落到「闸门先赢,Paginated 才投影 thread_history live_writer.rs L337」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。