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 擋住映像撒謊。任何一層都可以失敗。預設讓列表降級,不要讓恢復降級。