第五部:会话怎样保存、恢复并继续
这一部把会话主干拆成 live History、append-only rollout 与 SQLite 投影,再追踪 compaction checkpoint,区分 running resume 的 live 重连、cold resume 的原身份重建、resume(history) 与 fork 的新身份创建,最后进入 Memory、Goal 和 app-server 协议投影。

展开阅读路线与实验入口
读这一部之前,需要知道什么
第四部没有向这里交接一份笼统的“外部能力状态”。一次 MCP、Dynamic Tool 或 Code Mode 调用可能产生模型可见的 ResponseItem,也可能只发出进度事件;Skill 的正文进过 prompt,也不代表它被单独持久化;Plugin 加载结果更不能直接当作会话历史。到了第五部,先停止追问能力从哪里进入,改问一个更具体的问题:当前进程退出后,刚才发生的事实还剩在哪一层?
这六章围绕同一组会话事实展开,但不假装它们来自同一次连续运行。第 25 至 27 章追踪写入、压缩和四条不同的继续路径;第 28、29 章转向 Memory 与 Goal 这两类独立扩展状态;第 30 章最后检查 app-server 投影。各章使用彼此独立的 fixture,只有源码版本、问题边界和阅读依赖是共享的。
这里说“三种状态”,只指会话主干的三个观察面:
| 观察面 | 当前用途 | 先排除的误解 |
|---|---|---|
| live History | 为下一次请求提供原始材料的 live ResponseItem 工作集 | 规范化 projection 在采样边界的 detached clone 上完成;它不是 JSONL 文件的内存缓存,也不负责列表查询 |
| append-only rollout | 保存可重放的 RolloutItem 与 replacement checkpoint | 压缩不会就地改短旧 JSONL |
| SQLite metadata | 保存 thread 的可查询 metadata 投影 | 一行 thread metadata 不是下一轮 prompt |
Memory 的抽取、语料文件与使用回写,以及 Goal 的 objective、status、预算和 accounting,都在主干之外另有 owner。本部不会把 ExtensionData、Memory、Goal、app-server 的 ThreadState 硬塞进“第四种 History”。先把证据边界守住,后面的恢复语义才不会变成“从数据库把一切读回来”这种含混说法。
这一部负责讲清什么
六章先从“写下什么”走到“同一个 live Thread 怎样重连、旧 rollout 怎样恢复、新 identity 怎样创建”,再转向跨 Turn 的 Memory 与 Goal,最后抵达客户端可消费的协议投影。Memory 与 Goal 放在这些继续路径之后,是因为它们都跨 turn,却不共享 History 的数据模型或恢复合同。
| 章节 | 要回答的问题 | 主要 owner / artifact | 本章不允许外推的结论 |
|---|---|---|---|
| 第 25 章 会话三层 | 同一次写入怎样落到 live History、rollout 与 metadata | ContextManager、RolloutRecorder、LocalThreadStore | client event stream 就是 durable history |
| 第 26 章 Compaction | 上下文快满时被替换的究竟是哪份状态 | CompactTask、replacement history、CompactedItem | rollout 被截短,或累计 token usage 被清零 |
| 第 27 章 Resume/Fork | running/cold/resume(history) 与 fork 怎样选择材料和身份 | InitialHistory、RolloutReconstruction、ThreadManager | running resume 和 resume(history) 也从 rollout 重建,或 fork 会复制 live runtime |
| 第 28 章 Memory | rollout 怎样经过抽取、合并、读取、引用与使用回写 | Memory SQLite pipeline、$CODEX_HOME/memories、ExtensionData | Memory 是一段更长的 History |
| 第 29 章 Goal | objective 怎样在 turn 结束后继续计时、记账和改变状态 | GoalRuntimeHandle、GoalAccountingState、thread_goals | Goal 是 planner、DAG,或从对话中自动推断出的任务状态 |
| 第 30 章 App-server | Core 的 Op/Event 怎样成为 JSON-RPC 响应、通知和投影 | processor、ThreadStateManager、bespoke event handling | app-server 拥有 Agent loop 或 durable transcript |
flowchart LR
accTitle: 会话从运行时状态到恢复与协议投影
accDescr: live History、append-only rollout 与 SQLite metadata 各自保存不同事实;compaction 建立 checkpoint,running resume 重连 live Thread,cold resume 从 rollout 重建原身份,resume(history) 与 fork 创建新身份;Memory 与 Goal 保持独立状态,app-server 只负责协议投影
STATE["live History<br/>append-only rollout<br/>SQLite metadata"]
CONTINUITY["compaction checkpoint<br/>running rejoin / cold resume<br/>resume(history) / fork"]
EXTENSIONS["Memory / Goal<br/>独立状态 owner"]
PROJECTION["app-server<br/>协议投影"]
STATE -->|阅读前提| CONTINUITY
CONTINUITY -->|各条继续路径的身份边界| EXTENSIONS
EXTENSIONS -->|交出可投影状态| PROJECTION
图里的实线是本部的阅读依赖,不是一条数据搬运流水线。Memory 不由 compaction summary 自动生成,Goal 也不从 reconstructed History 推断;app-server 可以投影 thread、turn 和 Goal 等协议状态,却不把整个 Memory corpus 复制给每个 client。真正的数据关系要回到各章的 owner:rollout 是 Memory phase 1 的候选输入,Goal 写入专用 SQLite 表,app-server 则把请求映射成 Core Op,再把 Core Event 投影为 wire notification。
一次普通对话写入时,record_conversation_items 先更新 live state,再请求 rollout persistence,最后发送 raw response items。这个顺序只描述该函数里的三个动作;SQLite metadata 还要经过 live observation 的独立路径,不能从 send_raw_response_items 推出已经持久化成功。
压缩改变的是模型接下来使用的 history view,同时向 rollout 追加带 replacement_history 的 CompactedItem。重建时再从最新仍然有效的 replacement checkpoint 与其后缀恢复;旧 JSONL 行仍然留在 append-only 记录中。
第 27 章把公开入口拆成四条合同。running resume 在 processor 前段重连现有 live thread,不进入 InitialHistory 重建;cold resume 从 rollout 得到带原 conversation_id 和 path 的 ResumedHistory;resume(history) 把调用方给出的非空 ResponseItem 列表包装成 Forked 并创建新 identity,这份请求输入不是 durable rollout;fork 读取 source rollout,也创建新 identity。运行中的 transport、task handle 和订阅关系不在这些可重放材料里。
Memory 与 Goal 进一步证明了“跨 turn”不足以定义同一种状态。Memory extension 按 config 决定是否贡献 prompt 和 dedicated tools;Goal extension 在 thread start 建 runtime,在 resume 恢复 accounting,在 idle 时尝试继续 active goal。两者只共享 extension lifecycle,不共享存储格式或状态机。
第 30 章才把视角移到协议边界。turn/start 校验并映射输入后提交 Op::UserInput;Core 后续发出的 TurnStarted event 再被映射成带 thread_id 的 turn/started notification。processor 与 projection 都可替换,Agent loop 仍在 Core。
六章共用哪套实验
实验固定在同一个 tag 与 commit,但不在原始 checkout 里直接消耗数十 GB 构建缓存或留下 lockfile 噪声。这个 tag 的 workspace packages 已是 0.144.6,仓库中的 Cargo.lock 仍把一批本地 package 写成 0.0.0;Cargo 首次校准会产生机械 diff。下面先验证源码身份,再用 git archive 生成 disposable 副本并离线更新 workspace lock。所有 test result 都要同时记录完整测试名、running 1 test 或 nextest 的实际执行计数,以及 skip 文本;命令退出码为 0 但 running 0 tests,仍然不能算验证通过。
在一个独立、支持 pipefail 的 bash 或 zsh 中执行准备脚本,并在同一个 shell 中运行第 25 至 30 章的命令。不要复用其他部的 ARCHIVE_DIR 或覆盖它们的 EXIT trap;直接打开某一章时,也要先回到这里创建第五部自己的副本。
set -euo pipefail
export SOURCE_ROOT="${CODEX_SOURCE_DIR:-/tmp/codex-handbook-final-rust-v0.144.6}"
export COMMIT=5d1fbf26c43abc65a203928b2e31561cb039e06d
export ARCHIVE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-part5.XXXXXX")"
export ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
trap 'rm -rf "$ARCHIVE_DIR"' EXIT
test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$COMMIT"
test "$(git -C "$SOURCE_ROOT" describe --tags --exact-match HEAD)" = rust-v0.144.6
test -z "$(git -C "$SOURCE_ROOT" status --porcelain)"
git -C "$SOURCE_ROOT" archive "$COMMIT" | tar -x -C "$ARCHIVE_DIR"
cd "$ARCHIVE_CODEX_RS"
cargo update --workspace --offline
cargo metadata --locked --format-version 1 --no-deps >/dev/null
--offline 要求本机 Cargo registry/cache 已经包含这份 lockfile 的依赖。缺少缓存时,失败发生在实验准备阶段,不能算某一章的状态合同失败;应先补齐依赖,再重新导出副本。
下面选取十三个锚点,分成六组。它们共同覆盖“写入—压缩—重建—扩展状态—协议投影”,但不是一个共享 fixture,更不能把某个 mock server 测试的通过推广为所有 provider、filesystem 或客户端都成立。
| 章节 | 精确命名测试 | 这条测试直接证明什么 |
|---|---|---|
| 25 | raw_append_items_does_not_update_sqlite_metadata | raw JSONL append 不会顺带更新 SQLite thread metadata |
| 25 | live_thread_observes_appended_items_into_sqlite_metadata | LiveThread 观察 accepted append 后更新 first message、preview 与 title |
| 26 | auto_compact_runs_after_token_limit_hit | mock token usage 越过 limit 后触发一次自动压缩,并影响后续 model input |
| 26 | compact_resume_and_fork_preserve_model_history_view | compact、cold resume 与 fork 后的模型可见前缀保持预期 |
| 26 | thread_compact_start_triggers_compaction_and_returns_empty_response | thread/compact/start 返回空响应,并投影同 id 的 started/completed item |
| 27 | thread_resume_returns_rollout_history | app-server 从 fake rollout 恢复 thread metadata、turn 与 user item |
| 27 | thread_fork_creates_new_thread_and_emits_started | fork 生成新 id、不改原 rollout,并发送新 thread 的 started notification |
| 27 | thread_fork_at_last_turn_id_keeps_only_terminal_prefix | lastTurnId 切出的 fork 只保留指定 completed prefix 与 lineage |
| 27 | thread_fork_ephemeral_remains_pathless_and_omits_listing | ephemeral fork 没有 path,保留 copied turns,且不进入 thread listing |
| 28 | memories_startup_phase2_tracks_workspace_diff_across_runs | phase 2 用 workspace diff 合并新 Stage 1 输入并重置 baseline |
| 29 | thread_resume_rehydrates_active_goal_idle_accounting | resume 后 active Goal 恢复 wall-clock accounting,并发出更新事件 |
| 29 | update_goal_can_block_and_accounts_final_progress | update_goal 在写 blocked 前结算当前 turn token progress |
| 30 | thread_start_creates_thread_and_emits_started | thread/start 返回持久 thread,并随后发出内容一致的 started notification |
在 disposable 副本的 codex-rs 目录运行:
: "${ARCHIVE_CODEX_RS:?先执行本部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-thread-store -E \
'test(raw_append_items_does_not_update_sqlite_metadata) | test(live_thread_observes_appended_items_into_sqlite_metadata)'
just test --locked -p codex-core --test all auto_compact_runs_after_token_limit_hit
just test --locked -p codex-core --test all compact_resume_and_fork_preserve_model_history_view
just test --locked -p codex-app-server --test all \
thread_compact_start_triggers_compaction_and_returns_empty_response
just test --locked -p codex-app-server --test all thread_resume_returns_rollout_history
just test --locked -p codex-app-server --test all \
thread_fork_creates_new_thread_and_emits_started
just test --locked -p codex-app-server --test all \
thread_fork_at_last_turn_id_keeps_only_terminal_prefix
just test --locked -p codex-app-server --test all \
thread_fork_ephemeral_remains_pathless_and_omits_listing
just test --locked -p codex-memories-write \
memories_startup_phase2_tracks_workspace_diff_across_runs
just test --locked -p codex-goal-extension \
thread_resume_rehydrates_active_goal_idle_accounting
just test --locked -p codex-goal-extension \
update_goal_can_block_and_accounts_final_progress
just test --locked -p codex-app-server --test all \
thread_start_creates_thread_and_emits_started
前三个 compaction 集成测试有显式 network-disabled gate。它们使用本机 loopback mock server,并不访问真实 OpenAI 服务,但 sandbox 若统一标记 network disabled,测试仍会提前返回;日志里出现 Skipping test because network is disabled 只能记为 skipped。auto_compact_runs_after_token_limit_hit 在 Windows 使用四个 Tokio worker,在其他平台用两个,这是 fixture 防止 SSE starvation 的调度差异,不是 compaction 语义差异。
Memory 的 phase 2 锚点没有 test-level skip,但依赖 Git、SQLite、临时目录和本地 mock HTTP server;Goal 两条测试不请求模型网络,不过 resume accounting 测试会真实等待约 1.1 秒。app-server 的 start/resume/fork 测试都依赖初始化完成的 TestAppServer 和临时 CODEX_HOME。这些前置失败要单独记成 fixture 或平台失败,不能改写成对应状态合同不成立。
哪些机制暂时不讲
本部停在 app-server 的协议投影,不展开具体客户端怎样消费它。TUI 如何把 event stream 折叠成 cell、进度条和最终状态,留给第 31 章;这里出现 ThreadState 只用于解释 app-server 的短期投影与排序,不把它当成 UI state,也不把它写回 rollout 冒充 durable history。
subagent 与多 Agent 也暂不展开。第 27 章会严格区分 forked_from_id、新 thread identity 与 copied history,但不会从 fork 推出 agent ownership。一个 spawned Agent 可以持有独立 thread,不代表每个 thread 都是 Agent;第 32、33 章再追 spawn、wait、steer、mailbox 和 lineage。
Realtime conversation 留到第 34 章。Responses WebSocket 的普通请求传输,与 Realtime 独立 conversation、音频队列和关闭终态不是同一条协议;本部只在 rollout reconstruction 中保留 realtime_active 等恢复 metadata,不用它解释实时会话生命周期。Hooks 与最后的验证闭环分别由第 35、36 章接手。
还有一个容易越界的地方:Memory pipeline 会启动内部 consolidation agent,Goal 在 idle 时能继续 active objective。这两件事都不等于本部已经解释多 Agent 调度。当前只追它们各自的 state owner、持久化条件和失败边界;spawned thread 怎样被父线程控制,仍要等第六部。
读完这一部,你应该能做什么
面对“Codex 重启后为什么还能继续”这类问题,应该先把“继续”拆成可验证动作:
- 想知道下一轮模型看见什么,检查
ContextManager的 replacement history 与 rollout reconstruction,不读 SQLite preview 猜 prompt; - 想知道一次“继续”用了什么,先区分 running resume、cold resume、
resume(history)与 fork:running resume 重连 live Thread,cold resume 与 fork 读取 durable rollout,resume(history)接受调用方ResponseItem,不拿 client notification 当持久化确认; - 想知道 fork 是否独立,比较新旧 thread id、rollout path、
forked_from_id与 terminal prefix,不寻找被复制的 socket 或 task handle; - 想知道 Memory 为什么没生效,依次检查 feature/config、root-session eligibility、State DB claim、phase 1/2 artifacts、read citation 与 usage writeback;
- 想知道 Goal 为什么停住,检查专用 row、actor、version guard、accounting mode 与谁有权改变 status,不从最后一条 assistant message 推断;
- 想知道 app-server 为什么少一条通知,沿
ClientRequest -> processor -> Core Op -> Core Event -> projection -> threadId notification定位,而不是修改 Agent loop。
读到这里,继续会话的边界已经清楚:Codex 可以重连仍在运行的 live Thread,可以从 append-only rollout 重建原 identity,也可以用调用方 ResponseItem history 或 source rollout 创建新 identity。resume(history) 的请求材料不是 durable rollout,fork 也不复制 source 的 live runtime。在这些边界之外,Memory 与 Goal 按各自条件跨 turn 工作,app-server 再把 Core 状态交给外部 client。终端界面、多个 Agent、Realtime 和最终证据闭环属于第六部。
源码工作台
核心目录
| 目录 / 文件 | 本部只用它回答什么 |
|---|---|
codex-rs/core/src/context_manager | live model history、normalization、replacement 与 history version |
codex-rs/core/src/session | conversation item 写入顺序、compaction trigger 与 rollout reconstruction |
codex-rs/rollout | canonical RolloutItem、JSONL recorder 与可重放合同 |
codex-rs/thread-store | live recorder、rollout path、SQLite metadata observation 与兼容读取 |
codex-rs/state | thread projection、Memory pipeline rows 与 thread_goals |
codex-rs/ext/memories、codex-rs/memories | Memory 的 prompt/tool extension、phase 1/2、citation 与 usage |
codex-rs/ext/goal | Goal lifecycle actor、accounting、tool authority 与 idle continuation |
codex-rs/app-server-protocol、codex-rs/app-server | JSON-RPC 类型、request processor、subscription 与 Core event projection |
这些目录里有不少同名词。history 可能指模型可见序列、InitialHistory 或 app-server 返回的 turns;state 可能指 session mutex、SQLite runtime 或 client projection。工作台先找类型的 owner 和 durable artifact,再看调用者,不用目录名替代结论。
先抓住各层的核心类型
| 层 | 核心类型 / artifact | 最先检查的边界 |
|---|---|---|
| live History | ContextManager、ResponseItem、history_version | 当前模型 view 是否被 replace、rollback 或 normalization 改写 |
| rollout | RolloutRecorder、RolloutItem、CompactedItem | item 是否真正 append/flush,checkpoint 是否带 replacement |
| SQLite projection | LocalThreadStore、ThreadMetadata、StateRuntime | 当前路径需要 query index,还是能从 JSONL 兼容读取 |
| reconstruction | RolloutReconstruction、InitialHistory、ResumedHistory | 用哪个 checkpoint、后缀、identity、path 与 lineage |
| Memory | StageOneOutput、phase 2 claim、Memory corpus、MemoryCitation | final corpus、pipeline row 与 process-local ExtensionData 分开 |
| Goal | ThreadGoal、GoalRuntimeHandle、GoalAccountingState | objective/status row、actor runtime 与 accounting baseline 分开 |
| app-server projection | ClientRequest、processor、ThreadStateManager、ServerNotification | request/response、Core event 与 durable item 分开 |
ThreadStateManager 保存连接与 thread 的投影关系,并给 extension event sink 一条不需要 await 的 listener command 通道。它解决的是连接订阅、排序和短期 view,不是第三份 durable transcript。
Feature 成熟度
固定版本里 MemoryTool 是 Experimental 且默认关闭;Goals 是 Stable 且默认开启。这个差异只描述 feature catalog,不说明当前 thread 已满足存储与宿主条件。Memory 还要同时满足 memories.use_memories、root/non-ephemeral eligibility、State DB 和 rate-limit guard;Goal 工具还要有 persistent thread state,并排除 review subagent。
RemoteCompactionV2 在 catalog 中是 Stable 且默认开启,但 CompactTask 仍先看 provider 是否走 remote compaction,再在 remote v2、legacy remote 与 local summary 三条实现之间选择。Stage 不能替代 provider capability、config 或实际测试。
持久化与平台条件
| 机制 | 生效条件或平台限制 | 失败时不能外推的结论 |
|---|---|---|
| rollout / SQLite | rollout JSONL 可在无 SQLite 时兼容读取;快速 list/read 与 Goal/Memory pipeline 需要 State DB | SQLite 不可用等于 rollout 已丢失 |
| compaction | provider、feature、token scope 与 local/remote 实现共同决定;三条集成测试受 network gate | skipped 测试证明压缩通过,或 remote/local 合同完全相同 |
| resume / fork | running resume 要有 live Thread;cold resume/fork 要有可读 rollout;resume(history) 要有非空调用方 ResponseItem | 四条路径都从 rollout 重建,或新 identity 会复制 live runtime |
| Memory startup | 非 ephemeral、root agent、MemoryTool 开启且 State DB 可用;还要通过 rate-limit guard | feature 开启就一定生成 corpus |
| Memory consolidation | 内部 agent 被设为 ephemeral,关闭 Memory/Apps/Plugins/递归协作,只写 memory root 且无网络 | consolidation agent 拥有普通 root-session 权限 |
| Goal | feature 开启、persistent thread state 可用;review subagent 与 ephemeral thread 无 Goal 工具 | Stable 意味着每种 thread 都能创建 Goal |
| app-server | 连接先 initialize;request serialization、notification opt-out 与 experimental API 按连接生效 | client 收到 notification 等于 rollout 已 durable |
Memory startup 的入口把 ephemeral、feature-off 和 non-root agent 直接排除,State DB 不可用也会停止;consolidation agent 随后被收紧为只写 memory root、无网络、不可递归委派。它可以修改最终 corpus,但不能借此访问普通会话的全部宿主能力。
Goal 的 model-facing update_goal 只能写 complete 或 blocked;pause、resume、usage-limit 与 budget-limit 由 user/system 侧控制。app-server 对 ephemeral thread 还会因为没有 materialized rollout 或 State DB 直接拒绝 Goal 操作。这两个边界防止模型把自己的工具调用升级为完整生命周期控制权。
工作台到这里停止。先进入第 25 章固定三层状态的写入顺序;完成第 30 章后,再把 client projection 交给第 31 章,把 thread identity 与 lineage 交给第 32 章。
同一段对话,为什么同时存在三种状态
从一次 live 写入和一次冷读取拆开 ContextManager、rollout JSONL 与 SQLite ThreadStore 投影,解释三者的 owner、写入顺序、读取合同和故障边界。
关闭 Codex,再从历史列表打开一条 thread,前面的对话仍然能回来。最顺手的解释是:聊天记录写进 SQLite,resume 时再读出来。本章先把它改名为“持久化承载面”:这里的三层指 live history、rollout JSONL 和 SQLite projection,不是第 9 章的 prompt-preparation projection。
固定版本的实现并不是这条线。进程活着时,下一次模型请求读取的是 session 里的 ContextManager;进程退出后,可重放正文来自 rollout JSONL;SQLite 保存的是 thread metadata 投影,主要服务 list、read 和筛选。三者描述同一条 thread,却没有哪一个是另外两个的完整副本。
本章只解释会话主干的三种表示,也不接收上一章的一揽子能力加载结果。只有实际成为 conversation item 或 metadata update 的事实,才会沿本章核对的写入路径出现;是否出现必须看具体调用,不能从能力已经加载反推。Memory、Goal 等独立状态留给后续各章。
三个 owner,回答三个问题
Live history:下一次 sampling 带什么
ContextManager 持有按时间排列的 ResponseItem,还持有 history_version、token 信息、reference context 和 world-state baseline。它由当前 Session 的 state owner 管理,服务 sampling、rollback、compaction 和本轮 context diff。
这份 history 可以整体替换。replace 覆盖 items、递增 history version,并清掉 world-state baseline。它因此是一份可重写的工作集,不是 append-only 文件的内存映射。至于 record_items 怎样过滤、for_prompt 怎样 normalize,已经由第 9 章完整解释;本章只保留 owner 边界。
本节源码依据(2 处)
Rollout JSONL:重启后拿什么 replay
local ThreadStore 的注释直接把 JSONL 定义成 durable replay format。它保存经过 persistence policy 接受的 RolloutItem,包括 session metadata、turn context、response items、compaction checkpoint 和一部分 lifecycle event。这里的 durable 是相对于进程内 history 而言:writer 的 flush 是队列和文件 writer 的完成边界,不是断电后的 fsync 承诺。
rollout 也不是 core event stream 的逐事件镜像。某个客户端看过 notification,不能推出对应对象已经进入 JSONL;某个对象进入 JSONL,也不能推出它会原样进入下一次 prompt。
SQLite:怎样快速找到 thread
同一段注释把 SQLite state DB 定义成 queryable metadata index。SessionMeta、TurnContext、token/user/goal/settings 事件可以改变 metadata;普通 ResponseItem、Compacted 和 WorldState 不会被当作 transcript row 写进去。SQLite row 可以保存 path、cwd、model、reasoning、preview、title 和时间等投影,但它不是可独立重建 prompt 的正文数据库。
本节源码依据(2 处)
把三层放在一起,边界会更清楚:
| 表示 | 主要 owner | 生命周期 | 主要读取者 | 单独不能证明什么 |
|---|---|---|---|---|
live ContextManager | SessionState | 当前进程中的 session | sampling、compaction、rollback | 重启后仍存在;rollout 已落盘 |
| rollout JSONL | ThreadStore / RolloutRecorder | thread 的持久化生命周期 | resume、fork、审计、兼容读取 | SQLite 投影已同步;每项都进 prompt |
| SQLite state DB | StateRuntime | 可重建的查询索引 | list/read、按 id 定位、筛选 | 普通 response 正文存在;仅凭 row 可恢复 history |
一次写入为什么分成三步
常规模型 item 进入 Session::record_conversation_items 后,顺序是固定的:先准备 item 并在 state lock 内写入 live history;再把同一批 response item 包成 rollout item 持久化;最后才发送 raw response item event。客户端实时观察面排在 persistence 调用之后,但 persistence 错误会被记录并吞掉,所以收到 raw event 仍不是 durability receipt。
local writer 对 accepted item 调 record_canonical_items,随后等待 recorder.flush()。LiveThread 要等 store append 成功返回,才用同一批经过 persistence filter 的 items 更新 ThreadMetadataSync,并在确实产生 patch 时写 SQLite。代码里的目标很具体:不要让 SQLite metadata 跑在 canonical JSONL 前面。
flowchart TB
accTitle: Conversation item 的三层写入顺序
accDescr: response item first updates live ContextManager, then accepted rollout items are appended and flushed to JSONL, then LiveThread observes persisted items and updates SQLite metadata, while raw client events are sent after the persistence call returns
ITEM["ResponseItem"] --> PREPARE["prepare items"]
PREPARE --> LIVE["SessionState / ContextManager"]
LIVE --> PERSIST["persist_rollout_response_items"]
PERSIST --> POLICY{"persistence policy"}
POLICY -->|accepted| JSONL["canonical JSONL append + flush"]
POLICY -->|empty| RETURN["append returns"]
JSONL --> OBSERVE["LiveThread metadata observation"]
OBSERVE --> SQLITE["SQLite metadata patch or no-op"]
SQLITE --> RETURN
RETURN --> EVENT["raw response item event"]
本节源码依据(3 处)
这三步不是一个跨内存、文件和 SQLite 的事务。它们只建立了偏序:live 先于 persistence 调用,local JSONL flush 先于本批 metadata update。后一步失败不会自动回滚前一步。
persist_rollout_items 就体现了这个边界。LiveThread::append_items 返回错误时,session 只写 error log,不把错误向上抛。当前进程仍可能继续使用已经更新的 history,也会继续发 raw event;重启后却可能缺少这一段。反过来,JSONL 已经 append、SQLite patch 失败时,rollout 仍可能按 path 被读取,快速索引则会陈旧。
本节源码依据(2 处)
两条 append 测试把合同钉死
固定 checkout:
tag rust-v0.144.6
commit 5d1fbf26c43abc65a203928b2e31561cb039e06d
在 codex-rs 运行计划指定的对照测试:
: "${ARCHIVE_CODEX_RS:?先执行第五部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-thread-store -E 'test(raw_append_items_does_not_update_sqlite_metadata) | test(live_thread_observes_appended_items_into_sqlite_metadata)'
nextest 的实际结果是 2 tests run: 2 passed, 89 skipped。这里的 89 是表达式没有选中的其他测试;两条目标测试都真实执行,没有 network-disabled early return。
第一条直接调用 raw LocalThreadStore::append_items,flush 后读取 SQLite,断言 row 仍为 None。第二条通过 LiveThread::append_items 写同类 user item,随后断言 SQLite 中的 first_user_message、preview 和 title 都等于输入。它们证明的是两种 API 合同,不是“SQLite 偶尔慢一点”:raw store append 只负责 history,LiveThread 才负责 append 后的 metadata observation。
本节源码依据(2 处)
读 thread 时,索引和正文仍是两条路
read_thread 会优先尝试 SQLite metadata。include_history=false 时,row 可以先构造 summary;实现还会在 rollout 可读时尝试补充 preview,但这不等于从 SQLite 取 transcript。include_history=true 时,代码先验证 SQLite 中的 rollout path 确实存在且属于目标 thread,然后 attach_history_if_requested 才从该 path 加载 items。
如果 SQLite row 缺失、查询失败,或者 path 不能通过验证,local store 会回到 rollout 定位和解析路径。这个 fallback 让 SQLite 成为可重建索引,而不是唯一真相;同时也说明“thread 出现在列表里”不能用来判定到底命中了 SQLite 还是扫描了 rollout。
本节源码依据(3 处)
故障时,先问哪一个 owner 还成立
| 观察 | 已经证明 | 仍未证明 | 下一步证据 |
|---|---|---|---|
| 下一次 sampling 带上新 item | live history 已更新 | rollout、SQLite 已成功 | 检查 rollout append/error log |
| rollout 出现新 JSONL 行 | replay material 已写到 writer boundary | SQLite 已同步;断电后必存 | 查 metadata row;不要把 flush 写成 fsync |
| SQLite row 有 preview/path | metadata projection 可查询 | path 可读;history 完整 | 验证 path 与 thread id,再加载 rollout |
| 客户端收到 raw item/event | observer 看过该事件 | durable rollout 已形成 receipt | 读取 canonical rollout |
rollout 有 ResponseItem | replay log 保留了该 item | prompt projection 原样保留 | 追第 9 章的 clone_history().for_prompt() |
这张表也给出了 owner 边界:SessionState 可以改 live history,ThreadStore 可以追加 replay,StateRuntime 可以更新投影。读者若把它们都叫“保存聊天”,故障排查只会剩下一句“历史丢了”,而源码里的失败其实发生在不同层。
交给下一章的问题
目前还有一个表面矛盾没有解释:rollout 只追加,live history 却允许整体 replace。compaction 正是这两个合同相遇的地方。本章已经划清两边的 owner,但不在这里提前解释 replacement history 怎样成为恢复基线。
下一章从 compaction 的触发条件开始,继续追上下文快满时究竟换掉了什么,哪些 token 没有清零,以及 append 失败后为什么不能假装恢复合同仍然完整。