青雲的博客
拆开 Codex 第四部分 长会话如何保持秩序 第 05 章

同一段对话,为什么同时存在三种状态

从一次冷恢复反推 ContextManager、rollout JSONL 与 SQLite 元数据投影各自保存什么、何时写入,以及谁负责还原模型工作历史。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

关闭 Codex,再从历史列表打开一条 thread,模型仍能接着前面的上下文工作。看到这个现象,很容易得出一个顺手的解释:Codex 把聊天记录存进 SQLite,resume 时再读出来。

这个解释正好把三个状态混成了一份。进程活着时,下一次模型请求读取的是 ContextManager;进程退出后,可重放的正文来自 rollout JSONL;SQLite 主要保存 thread id、rollout path、标题、时间、模型等可查询投影。它能帮你快速找到一条 thread,却不是 prompt 的正文数据库。

总览里已经提过这三个对象不在同一条读取链上。这一章不再列目录,而是从一次冷恢复倒着追:进程内 history 消失以后,究竟靠什么把模型工作集重新建出来?

可复现实验:比较两种 append 契约

固定在 rust-v0.144.6 的一次性 checkout 后,我先跑了 thread store 里两条挨着的测试:

just test -p codex-thread-store -E 'test(raw_append_items_does_not_update_sqlite_metadata) | test(live_thread_observes_appended_items_into_sqlite_metadata)'

结果是 2 passed, 0 failed

两条测试各自创建独立的 store 和 thread,并分别追加同类型的 user item。第一条绕过 LiveThread,直接向 local store 追加 rollout items;rollout 中已经有可重放正文,SQLite 里仍没有对应 row。第二条调用 LiveThread::append_items;rollout 写完后,metadata sync 从另一条 user item 提取 preview、first user message 等字段,SQLite 才出现 thread 投影。

这组契约对照比直接打开数据库更有用。它证明 JSONL 正文写入和 SQLite metadata 更新是两个步骤,也证明“SQLite 没有 row”不等于“rollout 没有可重放正文”。

三种状态各自回答什么

先把名字说准。

表示活多久主要内容主要读取者不能据此推出什么
ContextManager当前 live sessionrecord_items 过滤并按 truncation policy 处理的 ResponseItem,token 信息,context/world-state baseline下一次 sampling、compaction、rollback不等于原始事件流,也不保证进程退出后仍存在
rollout JSONLthread 的持久化生命周期经过 persistence policy 的 RolloutItem 追加日志resume、fork、审计、兼容读取不是每个 core event 的完整镜像,也不会因 compaction 自动缩短
SQLite state DB可重建的查询索引thread metadata、rollout path、标题、时间、模型、状态投影list/read、按 id 定位、快速筛选不保存普通 ResponseItem 正文,不能单独恢复 prompt

所以“同一段对话存在三份”也不够准确。更接近源码的说法是:同一条 thread 有三种表示,它们为了不同读路径保留不同信息。

一次写入怎样分成三条路

sampling 收到模型 item 后,常见入口是 Session::record_conversation_items。它先调用 prepare_conversation_items_for_history,再持有 state lock,把 item 交给 ContextManager::record_items。这一步完成后,当前进程已经能在下一次 sampling 读到新历史。

随后才是持久化。相同 item 被包装成 rollout items,交给 LiveThread 和 local store。local writer 先经过 persistence policy;没有持久化价值的 transient event 会被丢掉。接受的 item 进入 RolloutRecorder 后台队列,再通过 flush barrier 等待 JSONL writer 完成。

只有 JSONL 先落到可读边界,LiveThread 才观察这批 persisted items,计算 metadata patch 并更新 SQLite。源码专门留下了一句注释:不能让 SQLite 跑在 JSONL 前面。否则 list 能查到 thread,resume 却找不到对应正文。

flowchart TD
  accTitle: 一批 conversation items 的内存、rollout 与 SQLite 写入顺序
  accDescr: ResponseItem 先写入进程内 ContextManager,再经持久化策略进入 JSONL;JSONL flush 完成后,LiveThread 才处理 SQLite 派生元数据。客户端 raw event 要等 rollout append 返回后才发送。

  A["ResponseItem"] --> B["prepare_conversation_items_for_history"]
  B --> C["ContextManager.record_items"]
  C --> D["下一次 clone_history().for_prompt()"]
  C --> E["persist_rollout_items"]
  E --> F["persistence policy"]
  F -->|"接受"| G["RolloutRecorder queue"]
  G --> H["JSONL flush barrier"]
  H --> I["ThreadMetadataSync"]
  I --> J["SQLite metadata update / no-op"]
  J --> L["rollout append 返回"]
  F -->|"无可持久化 item"| L
  L --> K["raw response item event"]

这不是数据库事务。record_conversation_items 返回前,内存 history 已改;local store 的 flush 是文件 writer 的 durability boundary,但不是 fsync 承诺;SQLite 更新失败也不会反向抹掉 JSONL。排障时必须分别问:模型工作集更新了吗,rollout 可重放了吗,metadata 投影跟上了吗?

History 不是原样塞给模型

ContextManager 内部确实是一个从旧到新的 Vec<ResponseItem>,但这不代表每个收到的 item 都会原样进入下一次请求。

record_items 先跳过非 API message,再按当前模型的 truncation policy 处理工具输出等可能过大的内容。真正构造请求时,clone_history() 先取得当前 snapshot,for_prompt 再在这份克隆上 normalize history;如果模型不接受图片,相应 image 也会从 message 和 tool output 中移除。

这解释了一个经常让调试变乱的现象:rollout 里能看到某个 item,不代表下一次 prompt 里有完全相同的对象。要查模型究竟看到了什么,应该追 clone_history().for_prompt();要查恢复材料是否存在,才去看 rollout。

SQLite 为什么仍然重要

SQLite 主要加速 thread 查询,并为 metadata-only 读取提供 summary。thread list、按 id 读取、归档过滤、名称和 git metadata 都可以优先使用这份投影;state DB 不可用或缺 row 时,local store 仍能扫描 rollout 并解析其中的 metadata,只是查询成本和可用字段不同。

read_thread 的逻辑很能说明职责:它先尝试读取 SQLite metadata。include_history=false 时,row 可以直接构造 StoredThread summary;实现会在 rollout 可读时尝试补充 preview,但读取失败不妨碍返回已有 summary。只有 include_history=true 时,SQLite 中的 path 才必须先验证确实属于目标 thread,随后 attach_history_if_requested 再从该 path 加载 JSONL items。

state 提取器也把边界写得很窄:SessionMetaTurnContext、部分 EventMsg 和 user item 会提取内容字段;普通 ResponseItemCompactedWorldState 不会被当成 transcript row 写进去。不过 LiveThread 观察到这些 item 时仍可能触发节流后的 updated_at touch。

冷恢复怎样重新得到模型工作集

进程退出后,原来的 ContextManager 已经不存在。cold resume 先用 thread id 或 path 找到 StoredThread 和 rollout,再把 items 包装成 InitialHistory::Resumed 交给新的 Session

新 session 不会把 JSONL 每一行直接塞回 history。apply_rollout_reconstruction 要理解 compaction checkpoint、rollback、turn context 和 world state,得到一份重建后的 history、previous turn settings 与 baseline。旧图片在这时被重新准备,但持久化 rollout 保持原样。最后 SessionState::replace_history 才安装新的工作集。

因此 resume 成功至少包含两个不同证明:找到了 durable rollout;又按照当前版本的 replay 规则重建出了可供模型使用的 history。只看到 thread 出现在列表里,只能证明发现路径识别到了它;这条路径可能来自 SQLite,也可能来自 rollout 扫描回退,不能据此判定 SQLite 查询成功。

源码依据

本章引用的是 local thread store 主路径。remote thread store 可以实现相同接口,但不能把本章的 JSONL/SQLite 顺序直接外推到远端 backend。客户端 event stream 也不属于这里的三种状态:它是实时观察面,是否持久化仍由 rollout policy 决定。

固定 tag 的现成测试只给出了两个写入边界:raw store append 可以只有 JSONL,没有 SQLite row;LiveThread append 才会同步 metadata。冷恢复部分依据 read_threadInitialHistory::Resumed 的源码链。现有实验没有证明断电时的 fsync 语义,也没有模拟 SQLite 文件损坏后的所有 repair 路径。

失败边界

  • 内存 history 已更新、JSONL append 失败:当前进程可能继续工作,重启后却缺少这一段。
  • JSONL 已 flush、SQLite 更新失败:按 path 仍可能恢复,list/id 也可能扫描回退,但快速索引会缺失或陈旧。
  • SQLite row 存在、rollout path 已移动或损坏:metadata 可见不代表正文可读,代码会尝试验证并回退。
  • rollout 中有 item、for_prompt 中没有:可能是 API message 过滤、输出截断、图片能力或 reconstruction 规则造成,不应先判定“历史丢了”。
  • raw event 已发送给客户端:只说明消费者看过,不能代替 rollout durability。

动手改一个地方

先别写自动修复。给 local store 加一个只读 audit_thread_projection(thread_id) 更容易把边界测清楚:

  1. 从 rollout 提取当前 metadata;
  2. StateRuntime::get_thread 读取 SQLite row;
  3. 复用现有 metadata diff 逻辑,返回 missingstale(fields)in_sync;把 updated_at touch 的比较结果单列,不混进内容字段差异;
  4. 不调用 reconcile,不更新 row,也不改 JSONL。

测试要覆盖缺 row、陈旧 title/path,以及普通 ResponseItem 只触发 updated_at、不产生内容字段差异。先完成 append,再记录 rollout hash 和 SQLite row;audit 前后分别复算与深比较,确认时间字段也没有被 audit 自己改动。

这个练习的重点不是做一个诊断命令,而是强迫实现者把 durable source 与 derived projection 分开。只有先做到只读地指出差异,后续 repair 才有明确的所有权。

这一章建立了什么

模型继续工作依赖 ContextManager,重启恢复依赖 rollout,快速查找依赖 SQLite 投影。三者相关,但没有哪一个是另外两个的完整副本。

这里还留下一个看似矛盾的地方:rollout 是 append-only,live history 却可以被整体替换。下一章会沿 Compacted { replacement_history } 这条记录解释,一次 history 重写怎样留在追加日志里