Skip to content

G2|会话持久化:JSONL 是真相,Thread Store 是可分页的命 ​

先别把 sqlite 当成第二种 transcript ​

读完 B5,下一件错事是打开 State DB,以为标题、搜索结果和模型上下文是同一份数据。然后 fork 被理解成「另存为同一个 JSONL」,revert 被理解成「删掉后面几行」,codex queue 被理解成 B1 的 pending_input。

判断先说清楚:能 resume 模型上下文的是 rollout JSONL;能在 IDE 里列出、分页、归档、排队的是 Thread Store。 A2 的 resume / fork / interrupt 是运行时切口。这一章是它们落在磁盘上之后长什么样。B5 只钉「事件流 vs 索引」;深挖在这里。


1. Thread Store 是存储无关的句柄,不是第二套 Session ​

thread-store 文件头(thread-store/src/lib.rs:1-5):应用代码只该拿着 ThreadId;实现负责把它解析成本地 rollout、RPC 或别的 backing store。store.rs 上的 trait 是 list / read / resume / fork / revert / archive / search / attachments / sections——产品表面的会话操作面,不是 run_turn。

本机实现是 LocalThreadStore。它下面仍是 B5 那只 StateDbHandle 加 JSONL 文件。sqlite 起不来,list 会瘸;JSONL 坏了,标题救不回历史。两套坏了的修法不同:修索引可以 backfill,修真相只能从 rollout 或备份。

B1 的 steer / mailbox 活在当前进程。关掉终端,pending 一起没。跨进程、可 resume 的那条队是 QueueStore(thread-store/src/queue_store.rs:13-14):按 thread 排序的用户消息,落 sqlite,有条数帽。B1 已经写过上限 100 和「空闲才开新 Turn」。G2 只补一句:那是 Thread Store 的职责,不是 InputQueue 换皮。

没有这层句柄会怎样?TUI、IDE、codex resume 各扫一遍 sessions/,fork 和 list 的语义每端各写一份。Session 负责活着的循环;Store 负责这条命在磁盘上还能被找到。


2. resume / fork / revert:磁盘语义,不是三个按钮 ​

运行时切口在 A2。落盘之后:

Resume 还是同一个 ThreadId。Store 按 id 找到当前 rollout 路径,把 JSONL 重放进 Session。warm resume(进程里还活着)不走这条。

Fork 不是另存为。paginated_fork.rs:15-18 的 prepare 先把源 thread 落盘、解析 lineage,再按 ForkBoundary 切。新 thread 新 id。源文件继续活着。

Revert 更绝。revert_thread.rs:15-18:给未加载的分页 thread 新建一份不可变 rollout,旧文件不动;唯一可变的 cutover 是 sqlite 里这条 thread 的路径指针。注释写明:保留前缀,后面当没发生过,但磁盘上还能找到旧文件。

没有「新文件 + 改指针」会怎样?两个 Session 写穿同一份 JSONL;或者 revert 真的 truncate 文件,fork 出去的宇宙和主线一起被改。A2 说生命周期必须是协议;G2 说协议背后必须是 不可变文件 + 可变指针。


3. 分页历史跟的是 lineage,不是一个文件从头读到尾 ​

RolloutLineage(local/rollout_lineage.rs:23-25)是本地唯一跟着 SessionMeta.history_base 走的抽象:一条分页 thread 的历史,由若干 不可变 rollout 区间 拼起来。fork 之后,子 thread 的前缀指向父文件的一段,自己的新 turn 写在新文件里。

所以「打开会话」不是 cat session.jsonl。list / search / 翻更早的 turn,走 Store 的分页 API(ListItems / ListTurns / LoadThreadHistory)。模型采样仍只看 B4 从当前 lineage 投影出来的 fragment,不会把整棵祖先文件灌进 Prompt。

没有 lineage 会怎样?fork 只能整份拷贝,磁盘和 prompt cache 一起炸;或者子 thread 直接 append 父文件,revert 一个、两个宇宙一起没。


4. migrate-rollouts 是搬家,不是日常命令 ​

旧会话是一份线性 JSONL。新世界是分页 history + sqlite 投影。local/rollout_migration.rs:1-9 把状态机写在文件头:找文件、判断够不够格、加锁、canonicalize 到暂存 JSONL、投影进 sqlite、校验,再原子 publish。不变量:要么留下原来的 legacy rollout,要么留下可恢复的分页 rollout。

CLI codex migrate-rollouts(cli/src/migrate_rollouts.rs:21-25)默认只报告谁能迁;--apply 才发布。这是维护命令,不是 /resume 的别名,也不是用户每次开机该跑的仪式。

没有迁移层会怎样?新旧格式在 sessions/ 里并存,IDE 的 thread/list 漏掉一半;或者启动时默默改写用户文件,失败留下半份 sqlite。搬家必须可报告、可暂停、可恢复。


5. Trace bundle:另一份文件,给 replay/viewer 用,不进热路径 ​

附录 C 提过 resume 重放的是替换后的 JSONL,「trace 记下 input_history 和 replacement_history」——那份 trace 住在 rollout-trace crate 里,文件头自己写明分工:这个 crate 拥有 trace schema,codex-core 只依赖一个很薄的 writer API,语义重放和 viewer 投影都留在 codex-core 之外。也就是说 trace bundle 是 第三份产物,和 JSONL(真相)、sqlite(索引)都不是同一件事:它不参与 resume 时的上下文重建,纯粹是给排障、回放工具用的旁路记录。三份东西各自能坏、各自能修,不要把 trace 缺失当成「会话丢了」。


6. 结语:带走一句话 ​

能 resume 的是事件流,能在 IDE 里翻出来的是 Thread Store 的索引和 lineage——fork / revert 靠新的不可变文件加改指针,不要把 sqlite 当成第二种 transcript。