持久化治理:版本、fork 邊界與拒絕解讀
日誌格式怎麼演進,分叉邊界怎麼定,讀不懂的數據寧可拒絕。
先玩再講。上方是磁盤上的一份會話日誌:一行 header 加一串事件。下面兩個加載器同時讀它:左邊是 DSH 的「拒絕解讀」式,讀不懂就報錯;右邊是很多系統的慣用做法,「best-effort 跳過」式,讀不懂就跳過接着讀。三個場景各有一份問題日誌,點播放,看同一份數據在兩種加載器手裏各是什麼下場。
coordinator.ts 第 79 與 1064 行的源碼模板。真實 DSH 沒有右邊這個 best-effort 加載器,它是用來對照的反面教材。背景一句話:DSH 的會話日誌是唯一真源,恢復、分叉、回放全從它派生(見 上一課的不變數)。真源要活得比任何一個版本的程式都久,所以格式演進不是小事:今天寫的日誌,明年的 harness 要能讀;反過來,新版本寫的日誌落到老版本手裏,老版本得知道自己讀不了。
DSH 的版本方案樸素到只有一個數字:SESSION_FORMAT_VERSION,當前是 0,定義在 packages/core/session/src/types.ts 第 56 行。沒有 1.2.3 這種大小版本。設計筆記的理由是:某一步升級能不能自動轉換,由那一步的升級器寫不寫得出來決定,兩級編號等於提前承諾了一件設計時根本不知道的事。
升不升版本的標準很明確:當且僅當老版本運行時無法在語義上完全正確地處理新日誌時,才必須升。「解析不報錯」不算數,能讀完但重建出錯誤的會話,這就是讀錯了。拿不準就升,因為一個近似恆等的升級器幾乎零成本,漏升一次卻會讓老版本靜默讀壞數據。
打開一份存儲的日誌時,先比版本號,三種結果對應三種完全不同的處理:
最值得咂摸的是「拒絕,並説明方向」這一格。早先的 assertVersion 對任何版本不匹配都拋同一條含糊的錯誤,改動之後報錯分方向:日誌比你新,明説「由更新的 harness 寫入,請升級」,並附上原始日誌文件的路徑;日誌比你舊但升級鏈斷了,就説「本構建沒有它的升級路徑」。用戶看到的永遠是「該升級了」,絕不是「文件損壞」。數據明明沒壞,報損壞是冤枉它。
往舊讀的方向還有個細節。舊日誌被新版本打開,升級器鏈只在記憶體裏逐級轉換,看一眼不落盤;只有用戶真的繼續這個會話,轉換結果才原子替換寫回磁盤,原文件留備份。設計筆記否決過「查看時自動遷移落盤」:打開即改寫等於把讀操作變成破壞性寫操作,轉換器有 bug 會在瀏覽時損壞日誌。
版本號管結構變更,管不了詞彙增長:事件的種類由掛了哪些插件決定,一個整數描述不了它。DSH 的方案是逐事件標記。讀取器遇到不認識的事件類型,預設整個會話拒絕恢復,除非那條事件的信封上帶着寫入方聲明的 ignorable: true。已知詞彙清單 KNOWN_SESSION_EVENT_TYPES 不是手寫的,由腳本從全倉庫所有事件聲明合併生成,共 44 個類型,連同 946 行的持久化事件目錄 docs/persistence-catalog.zh.md 一起,有專門的校驗腳本保證不過期。
點解預設必需、唔記得寫標記寧願拒絕過頭?設計筆記把這筆賬算得很清楚。忘寫 ignorable 的後果是一個本可恢復的會話被拒絕打開,用戶不爽,是體驗問題;反過來預設可忽略,同樣的疏忽會靜默恢復出一個內容殘缺的會話,模型接着在錯誤的歷史上繼續工作,是安全事故。演示的場景 A 就是後者的現場:跳過一條裝着用戶消息的未知事件,恢復出來的對話裏助手在回答一個不存在的問題。兩種失敗不對稱,防綫自然偏向吵鬧的那邊。
版本是一個單調整數不分大小版本。能不能自動升級由那一步的升級器存在與否表達,編號方案不提前承諾。當前 SESSION_FORMAT_VERSION = 0。
未知事件預設必需讀取器拒絕解讀含未知類型的日誌,除非事件帶 ignorable: true。方向性拒絕優於 best-effort parse,靜默跳過就是讀錯。
fork 邊界寫兩份header 的 seedLength 是持久的血統邊界;日誌裏的 session/end-seed 事件給只拿到存儲字節的讀者用。seed.length 兩個都替代不了。
分叉一個會話,就是把源會話到某個穩定位置為止的事件深拷貝一份,當作子會話的種子。麻煩在於邊界:子會話日誌的前半段是繼承來的種子,後半段才是自己寫的,兩段在字節層面長得一模一樣。分界綫係邊度?
直覺答案是數一數構造時種子有幾條,也就是 seed.length。這個答案錯得很隱蔽:恢復的會話拿完整存儲日誌當構造種子,seed.length 算出的邊界會隨着每次重新打開往後跑;header 裏的 seedLength 才一直保留着最初 fork 時的值。所以 DSH 把邊界寫了兩份:第一份在 header,fork() 創建子會話時把 parentSession 和 seedLength 寫進創建元數據;第二份在日誌裏,帶種子的會話把 session/end-seed 事件作為自己的第一次即時寫入追加在種子之後,專門服務只拿得到存儲字節的消費方。
這條邊界事件解決的問題很具體。種子歷史裏可能有一個沒配對的 compaction/start,佢到底係「上個生命週期崩喺壓縮中途」定「而家喺度壓縮」?光看字節分不出來。有了 session/end-seed,在它之前的未配對開啓標記一律屬於已結束的生命週期。類型定義的 JSDoc 裏還有一句狠話:Session 的構造函式是唯一合法寫入方,插件擅自追加一條,等於把它之前的所有即時工作靜默歸類成種子歷史。
順帶一句大日誌的恢復成本。恢復一個 130 萬事件、62 MiB 壓縮數據的會話,DSH 全程不物化整份明文,這輪優化把恢復准入從約 600ms 壓到 263ms(Agent Note 2026-08-05)。校驗和凍結一項沒省:持久存儲屬於運行時邊界,防綫本身不動。
「按方向區分」在源碼裏是一個五行的小函式 sessionFormatVersionRefusal:版本號比自己大,文案是「由更新的 harness 寫入,請升級 harness 打開」;比自己小又沒有升級路徑,文案是「本構建沒有它的升級路徑」。這個函式被協調器的加載檢查和各存儲後端共用,後端在解碼任何結構之前就先用它拒絕外來版本,保證用戶看到的永遠是「請升級」,絕不是「損壞」。演示左側那條紅色報錯就是它的原文。
出處:packages/session/session-persistence/src/coordinator.ts 第 77 至 81 行,核對日期 2026-08-13。
「未知事件預設拒絕」的守衞更短,整個就一個循環:類型在清單裏,或者寫入方標了 ignorable,放行;否則拋出,報錯裏帶上事件類型、seq 位置和「很大機會來自更新的 harness」的方向提示:
private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
for (const event of events) {
if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
}
}
deepseek-harness-master,核對文件 packages/session/session-persistence/src/coordinator.ts,核對日期 2026-08-13。程式碼塊保留源碼原文。Grok Build
會話摘要讀取走的是標準 serde 反序列化路徑。persistence.rs 第 700 到 725 行的 resume 預讀循環裏,讀不出或解析不了的 summary.json 直接 continue 跳過,不報錯不留痕。
會話相關的 serde 結構體沒有一處標 deny_unknown_fields,預設靜默丟棄未知字段:新版本加的字段被舊版本讀一遍再寫回,就沒了。這是快速迭代產品的常見取捨,只是它把格式演進的正確性交給了「新老版本別混用」這個假設。
Claude Code
會話以 .jsonl 存在 ~/.claude 下,支持 resume 和查看。書稿材料(claude-code-sourcemap 的 study 章節)覆蓋了啓動、上下文管理和可觀測性,但沒有出現會話日誌格式版本協商或未知記錄拒絕機制的還原程式碼。
基於已公開證據,讀到不認識的數據時的行為未知。閉源產品可以靠「客戶端總是最新版」兜底;DSH 是開源基建,各版本會長期共存,兜底假設不成立,所以把拒絕規則寫進了讀取器。
給你的插件事件選一個預設值
你寫了個 DSH 插件,往會話日誌裏追加自定義事件 myplugin/audit,記錄每次工具調用的審計信息。推演兩種情況:不標 ignorable,用戶把日誌拷到一台沒裝你插件的同版本 harness 上打開,會發生什麼?(提示:KNOWN_SESSION_EVENT_TYPES 由倉庫內聲明生成,倉庫外插件的事件按構造就在清單之外。)標了 ignorable: true 又會怎樣,你嘅審計資訊喺重建裏面去咗邊度?兩種選擇各適合什麼樣的事件,用「掉咗佢會唔會改到日誌其餘部分嘅解讀」這把尺子量一量。
seedLength 加日誌裏的 session/end-seed,seed.length 誰也替代不了。