Resume 与 Fork:恢复的不是同一件事
对比 running resume、cold resume、history resume 与 fork 的 thread 身份、历史来源、配置继承、持久化路径和运行中状态边界。
thread/resume 和 thread/fork 的 response 都会返回一条可继续提交 turn 的 thread。从接口形状看,它们像两个相近操作:一个接着聊,一个复制后接着聊。
真正麻烦的是,连 resume 自己都不只有一种语义。目标 thread 仍在当前 app-server 进程里运行时,resume 是重新订阅同一个 live object;进程重启后,cold resume 才会从 rollout 重建 session;如果调用者直接传 history 且目标 id 未加载,名字虽然仍是 resume,core 却按 forked history 创建新 id。
上一章解释了 compaction checkpoint 怎样进入 rollout。这一章继续追同一份材料:谁沿用原 thread id,谁生成新 id,哪些配置能覆盖,哪些运行中状态根本不会回来。
可复现实验:同一份 rollout,分别 resume 与 fork
固定 tag 中已有一组很接近真实边界的 app-server 与 core 测试。我运行的是:
just test -p codex-app-server -E 'test(thread_resume_returns_rollout_history) | test(thread_fork_creates_new_thread_and_emits_started) | test(thread_fork_at_last_turn_id_keeps_only_terminal_prefix)'
just test -p codex-core \
compact_resume_and_fork_preserve_model_history_view
结果是 app-server 3 passed,core 1 passed。
三条 app-server 测试各自验证一段协议行为。thread_resume_returns_rollout_history 从手工构造的 rollout fixture 恢复原 thread;普通 fork 测试使用另一份 fixture,断言新 thread id 和 thread/started;带 lastTurnId 的测试独立启动三个 turn,再断言新 thread 只保留到第二个 completed turn。
core 测试走的是另一条直接调用 ThreadManager 的流程。它证明 resume 请求和 fork 请求都保留 compact 后的共同 prefix,但没有断言两份完整输入相同:最后一条 user input 分别是 AFTER_RESUME 与 AFTER_FORK。
这里必须停在测试真正覆盖的位置。app-server 的三份 fixture 与 core 的 compact 流程没有串成同一条端到端链,因此这些结果不能继续推出 thread.turns 投影与模型收到的 history 一定不会分叉。要证明这一点,还需要从同一条 app-server thread 完成 compact、resume 或 fork,再提交 turn,同时比较 response projection 与捕获到的模型请求。
先看身份与状态矩阵
| 入口 | thread id | 历史来源 | 配置与模型 | rollout path | 能否继续原 in-flight turn |
|---|---|---|---|---|---|
| running resume | 原 id | 当前 live CodexThread,stored thread 只辅助 response | 沿用 live config;不匹配 override 多数被忽略或促使 idle cache 走 cold path | 原 path | 能,实质是重新订阅 |
已加载 thread + resume(history) | 原 id | 请求拒绝,不把调用者 history 注入已加载对象 | 不适用 | 原 path 不变 | 原 runtime 不受影响 |
| cold resume by id/path | rollout 内原 id | StoredThreadHistory -> InitialHistory::Resumed | 显式 override 优先,否则尽量合并 persisted model/provider/effort | 原 path 继续追加 | 不能,旧请求和进程已不存在 |
未加载 id 的 resume(history) | 新 id | 调用者提供的 ResponseItem | 当前 config 与请求 override | 新 thread 的 path 或内存状态 | 不能,内部按 Forked 创建 |
| persistent fork | 新 id,并记录 source lineage | source rollout,可按 lastTurnId 截断 | 以 source cwd 重新加载当前 config,再应用 override | 新 path | 不能,只复制持久化历史 |
| ephemeral fork | 新 id,并记录 source lineage | 同 persistent fork | 同上 | 无 path | 不能,进程退出即消失 |
这张表最需要记住的不是字段,而是 ownership。running resume 复用 live runtime;其他路径都要创建新的 Session。fork 从来不是进程快照。
Running resume 是重新订阅
app-server 收到 thread/resume 后,先尝试用请求里的 id 找 ThreadManager 中的 live thread。找到以后,不会重放 rollout 创建第二个 session,而是把当前连接加入同一条 thread 的 listener,组合一份 resume response,再继续接收后续 event。
这条路径保留 active turn、pending approval 和正在流式输出的真实 runtime 状态,因为它们本来就还活着。请求携带 path 时,path 只负责和 active rollout path 做一致性校验;携带不匹配的 model、sandbox 等 override,也不会悄悄改掉正在运行的对象。
这里还有一条更早的拒绝分支:请求带 history,且同 id 已在 ThreadManager 中加载时,app-server 直接返回 invalid request。它不会检查 override,也不会把调用者提供的 history 注入 existing thread。只有 id 没命中已加载对象,history 才进入下游,并按 InitialHistory::Forked 创建新身份。
没有 override mismatch 时,ThreadManager 命中就足以进入 running resume。代码完成 path 一致性检查后直接复用现有 thread,不读取 subscriber、loaded status 或 agent status。
这三个状态只在 override mismatch 时参与决策。若 thread 同时满足没有 subscriber、loaded status 为 Idle、agent status 不是 Running,app-server 会等待 shutdown;shutdown 完成后移除 cache,再回到 cold resume。任一条件不满足,或者 shutdown 失败、超时,override 都被忽略,当前 live thread 继续承担 running resume。
Cold resume 先定位,再重建
非运行 thread 的输入优先级在 API 类型注释里写得很清楚:history > non-empty path > thread_id。常见的 id/path 路径会从 ThreadStore 读取 source rollout,包装成 InitialHistory::Resumed。
对 id/path 形成的 InitialHistory::Resumed,app-server 以历史中的 cwd 重新加载配置。调用者显式传入 model、provider、reasoning effort 等 override 时,以 override 为准;没有显式覆盖时,代码再尝试从 persisted SQLite metadata 合并最近已确认的模型配置。目标 id 未加载时,直接传入 history 会形成 InitialHistory::Forked,不会进入这条 persisted metadata fallback。配置解析完成后,ThreadManager::resume_thread_with_history 创建 live Session;Resumed 沿用 rollout 的 canonical thread id,并在原 rollout 上继续追加。
core 的 record_initial_history 调 reconstruction。它从后往前找最新仍有效的 replacement-history checkpoint 和 resume metadata,再按时间顺序重放 suffix。compaction、rollback、turn context 与 world state 都在这里恢复,而不是由 app-server 把 turns 数组翻译成 prompt。
flowchart TD
accTitle: running resume、cold resume 与 fork 的身份分支
accDescr: app-server 对 id/path resume 先查 ThreadManager;history 请求命中已加载 id 时拒绝,未命中时才进入 Forked history 并生成新 id。普通 running resume 无 override mismatch 时复用 live thread;mismatch 时只有可关闭的 idle cache 在 shutdown 成功后转 cold resume,其余情况忽略 override。fork 同样进入 Forked history 并生成新 id。
A["thread/resume"] --> B{"请求带 history?"}
B -- "是" --> C{"同 id 已在 ThreadManager?"}
C -- "是" --> X["拒绝:live thread 不能注入 history"]
C -- "否" --> J["InitialHistory::Forked"]
B -- "否" --> D{"ThreadManager 命中目标 id/path?"}
D -- "否" --> E["ThreadStore 读取 rollout"]
D -- "是" --> M{"override mismatch?"}
M -- "否" --> R["running resume:复用 live thread"]
M -- "是" --> Q{"无 subscriber、loaded Idle、agent 非 Running?"}
Q -- "否" --> W["running resume:忽略 override"]
Q -- "是" --> S{"shutdown 成功?"}
S -- "否" --> W
S -- "是" --> E
E --> F["InitialHistory::Resumed"]
F --> G["rollout reconstruction"]
G --> H["新 Session,沿用 thread id"]
I["thread/fork"] --> L["读取并可选截断 source rollout"]
L --> J
J --> K["新 Session,新 thread id"]
目标 id 未加载时,resume(history) 为什么生成新 id
前面的已加载检查未命中后,API 允许实验性客户端直接传一组 history。这条路径没有 source rollout 的 canonical identity,也没有 persisted metadata 可以可信合并。如果仍沿用请求里的 thread_id,就可能让两份无关历史占用同一个身份。
因此 app-server 把提供的 history 转成 InitialHistory::Forked。Session::new 看到 Forked 时生成新的 UUIDv7 thread id,创建新的 rollout;请求里的旧 id 只是入口参数,不成为新 thread 身份。
这也是“按名字猜行为”最危险的一处。thread/resume 方法名只表示客户端意图继续工作,不保证每种 input mode 都沿用 identity。判断身份要看最终进入 InitialHistory::Resumed 还是 Forked。
Fork 复制什么,不复制什么
fork 先按 path 或 thread id 找 source rollout。如果给出 lastTurnId,代码要求它是 rollout 中真实持久化的 canonical turn,不能是投影旧日志时合成的 id,也不能仍是 InProgress。截断点包含目标 turn,并在下一条 TurnStarted 之前切断。
随后 source history 被转换成 InitialHistory::Forked。新 session 记录 forked_from_id,生成新 id 和新 rollout。若 source 尾部仍在 turn 中而调用者没有指定 terminal boundary,fork 会追加与真实 interrupt 相同的 history marker 和 TurnAborted,避免新 thread 继承一段没有结束标记的半截 turn。
persistent fork 会把可持久化 source rollout items 复制进新文件;ephemeral fork 不创建 path,只在当前进程中保留重建历史。两者都不会复制旧模型请求的 socket、MCP connection、shell child、input queue、cancellation token、event subscriber 或实时音频流。
模型与配置不会按一种规则继承
cold resume 有 persisted metadata 合并逻辑:没有显式 model override 时,可以恢复最近确认的 model、provider 和 reasoning effort。running resume 已有 live config,请求中的 mismatch 不应原地生效。
fork 的出发点不同。app-server 用 source cwd 加载当前配置,再应用 fork request override;它没有自动走 cold resume 的 persisted-model fallback。结果可能是历史来自旧模型,fork 后第一轮却使用当前默认模型。
模型差异 warning 只出现在 InitialHistory::Resumed 分支。InitialHistory::Forked 同样执行 reconstruction,却不会发这条 resume warning。reconstruction 会为两条路径恢复最近存活 turn 的 previous_turn_settings;fork 首轮进入普通 sampling 前,如果新旧模型的 comp_hash 都存在且不同,或者切到更小 context window 且当前 token 已越过新上限,core 会先用 previous model 尝试 pre-turn compaction。fork 因此可能在第一轮正常请求前多出一次 compact,但这不表示它自动继承 source model。
所以排查“恢复后模型变了”时,要先问是哪条入口。把 running resume、cold resume 和 fork 的配置规则合并成一句“默认沿用原设置”,会在最需要可靠性的地方给出错误承诺。
源码依据
本章只讨论开源 app-server 与 core 的 thread 生命周期。不同客户端可以选择不返回完整 thread.turns,改用 excludeTurns 和分页;这影响网络 payload,不改变 core reconstruction 的模型 history。
现成测试分别证明了 rollout resume、新 id fork、terminal-prefix fork,以及 core 层 compact checkpoint 的 history prefix 合同。app-server fixtures 和 core compact 流程彼此独立,尚未证明同一条端到端恢复链上的 turns projection 与模型 history 始终一致。它们也没有证明任意外部进程、网络连接或临时审批会被恢复;源码结构说明这些 runtime service 会在新 Session 中重新创建。
失败边界
resume(history)的 id 已在ThreadManager:拒绝,不能把调用者 history 注入已加载 thread。- running resume 的 path 与 active path 不一致:拒绝,不能拿旧文件覆盖 live thread。
- cold resume 找到 SQLite metadata 但 rollout 不可读:无法重建 history,metadata 本身不够。
lastTurnId是 synthetic、已 rollback 掉或仍 in progress:fork 拒绝,不猜测边界。- source 尾部 mid-turn:fork 记录 interrupted boundary,但不会继续原来的工具和 stream。
- fork 后模型的
comp_hash变化,或 context window 下调且当前 token 越过新上限:第一轮 normal sampling 前可能先 compact。 - explicit override 与 persisted metadata 冲突:显式值优先;running thread 的 override 规则又不同。
- ephemeral fork:当前进程内可用,不应出现在持久化 thread list,也不能承诺重启恢复。
动手改一个地方
这次练习叫“只读恢复计划”。上一章已经把 checkpoint 与 replay 讲清楚,这里实现一个纯只读的 explain_recovery_plan:输入 resume/fork params、ThreadManager 命中结果、stored thread metadata 和当前 runtime snapshot;输出只描述计划,不调用 shutdown、resume、fork,也不写 rollout。
至少输出五个字段:decision、identity、path、lineage、runtime_state。先用下面这张行为矩阵固定合同:
| 场景 | decision | identity | path | lineage | runtime_state |
|---|---|---|---|---|---|
| manager 命中,无 override mismatch | running resume | 原 id | 校验后沿用 active path | 不变 | 复用 live runtime |
| manager 命中,mismatch,idle cache 可 shutdown | cold resume | 原 id | 原 rollout 继续追加 | 不变 | shutdown 后重建 |
| manager 命中,mismatch,但有 subscriber、loaded status 非 Idle 或 agent running | running resume,忽略 override | 原 id | 沿用 active path | 不变 | 复用 live runtime |
| 未命中,按 id/path 找到 rollout | cold resume | rollout 原 id | 原 rollout 继续追加 | 不变 | 新建 runtime |
已加载 id 的 resume(history) | reject | 原 id | 原 path 不变 | 不变 | 原 runtime 不变 |
未加载 id 的 resume(history) | history resume | 新 id | 新 path,或仅内存 | 无 source lineage | 新建 runtime |
| persistent fork | fork | 新 id | 新 rollout | forked_from_id = source id | 新建 runtime |
| ephemeral fork | fork | 新 id | 无 path | forked_from_id = source id | 新建 runtime |
| running resume 的 requested path 与 active path 不一致 | reject | 不变 | 不变 | 不变 | 不变 |
验收重点是计划与实际 handler 分支逐项一致。每个 case 都要同时断言 identity、path、lineage 和 runtime ownership;任何字段证据不足时返回 unknown 与原因,不能根据 method name 补全。这个练习不会重复实现 reconstruction,却能直接暴露客户端最容易混淆的恢复承诺。
这一章建立了什么
running resume 是重新订阅;cold resume 是从 durable rollout 重建同一身份;目标 id 未加载时,resume(history) 和 fork 都会创建新身份。已加载 id 的 history 请求会被拒绝,不会改写原 runtime。fork 复制的是可重放历史,不是运行中的进程状态。
到这里,读者已经能追完一次 task,也能解释长 thread 怎样保存、压缩和回来。接下来不再继续扩目录,而是拿真实改动检查这套理解能不能落地:第一项是给 Codex 加一个工具。