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

Resume 与 Fork:恢复的不是同一件事

对比 running resume、cold resume、history resume 与 fork 的 thread 身份、历史来源、配置继承、持久化路径和运行中状态边界。

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

thread/resumethread/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_RESUMEAFTER_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/pathrollout 内原 idStoredThreadHistory -> InitialHistory::Resumed显式 override 优先,否则尽量合并 persisted model/provider/effort原 path 继续追加不能,旧请求和进程已不存在
未加载 id 的 resume(history)新 id调用者提供的 ResponseItem当前 config 与请求 override新 thread 的 path 或内存状态不能,内部按 Forked 创建
persistent fork新 id,并记录 source lineagesource 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 SessionResumed 沿用 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::ForkedSession::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。

至少输出五个字段:decisionidentitypathlineageruntime_state。先用下面这张行为矩阵固定合同:

场景decisionidentitypathlineageruntime_state
manager 命中,无 override mismatchrunning resume原 id校验后沿用 active path不变复用 live runtime
manager 命中,mismatch,idle cache 可 shutdowncold resume原 id原 rollout 继续追加不变shutdown 后重建
manager 命中,mismatch,但有 subscriber、loaded status 非 Idle 或 agent runningrunning resume,忽略 override原 id沿用 active path不变复用 live runtime
未命中,按 id/path 找到 rolloutcold resumerollout 原 id原 rollout 继续追加不变新建 runtime
已加载 id 的 resume(history)reject原 id原 path 不变不变原 runtime 不变
未加载 id 的 resume(history)history resume新 id新 path,或仅内存无 source lineage新建 runtime
persistent forkfork新 id新 rolloutforked_from_id = source id新建 runtime
ephemeral forkfork新 id无 pathforked_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 加一个工具