同一段对话,为什么同时存在三种状态
从一次冷恢复反推 ContextManager、rollout JSONL 与 SQLite 元数据投影各自保存什么、何时写入,以及谁负责还原模型工作历史。
关闭 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 session | 经 record_items 过滤并按 truncation policy 处理的 ResponseItem,token 信息,context/world-state baseline | 下一次 sampling、compaction、rollback | 不等于原始事件流,也不保证进程退出后仍存在 |
| rollout JSONL | thread 的持久化生命周期 | 经过 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 提取器也把边界写得很窄:SessionMeta、TurnContext、部分 EventMsg 和 user item 会提取内容字段;普通 ResponseItem、Compacted、WorldState 不会被当成 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_thread 与 InitialHistory::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) 更容易把边界测清楚:
- 从 rollout 提取当前 metadata;
- 从
StateRuntime::get_thread读取 SQLite row; - 复用现有 metadata diff 逻辑,返回
missing、stale(fields)或in_sync;把updated_attouch 的比较结果单列,不混进内容字段差异; - 不调用 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 重写怎样留在追加日志里。