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 還沒寫,這時拔電。
推演三件事:恢復能撿回哪一段歷史;列表卡片上的標題會不會變;缺的那一行基綫,回放時會被置成什麼。