青雲的博客
拆开 Codex 第五部:会话怎样保存、恢复并继续 第 27 章

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

把 running resume、cold resume、resume(history)、persistent fork、ephemeral fork 和 lastTurnId 放进同一张行为矩阵,说明哪些身份与 durable history 被保留,哪些 live resource 从未被复制。

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

第 26 章把 replacement history 和 surviving suffix 交给恢复路径。接下来最容易出现的误会是把所有“继续一段历史”的入口都叫 resume:只要能看到旧消息,就以为 thread id、rollout path 和进程里的 live 对象也都被原样搬过来了。

固定版本的实现先区分两个问题:要不要重新加入原来的 live thread,以及要不要用可重放材料创建一个新的 threadthread/resume 可以回答前一个问题,也可以在 cold 情况下重建同一个身份;带 history 的 resume 实际走的是 fork 语义。thread/fork 则明确创建新身份,区别只在于来源历史如何截断、是否写新 rollout,以及响应是否投影 turns。

flowchart TB
  accTitle: Resume 与 Fork 的身份和历史分流
  accDescr: thread resume first probes a running thread; a running id rejoins the live object, while a non-running request selects caller history, a rollout path, or a thread id to obtain replay material. History resume and fork convert replayable items into a new thread, optionally truncating at a terminal turn; persistent forks materialize a new rollout and ephemeral forks stay pathless.
  REQUEST["thread/resume or thread/fork"] --> PROBE{"running thread?"}
  PROBE -->|yes, resume by id| REJOIN["rejoin existing live thread"]
  PROBE -->|no| SOURCE{"history > path > thread_id"}
  SOURCE --> RESUME["InitialHistory::Resumed\noriginal id + rollout path"]
  SOURCE --> HISTORY["InitialHistory::Forked\nitems supplied by caller"]
  REQUEST --> FORK["fork: read durable rollout"]
  FORK --> CUT["lastTurnId terminal prefix\nor interrupted snapshot"]
  CUT --> NEW["new thread id"]
  HISTORY --> NEW
  NEW --> PERSIST{"ephemeral?"}
  PERSIST -->|no| JSONL["new rollout path + listing"]
  PERSIST -->|yes| PATHLESS["pathless response projection"]

本章的行为矩阵先给结论,再逐条回到 source。这里的“history”泛指可重放材料:cold resume 与 fork 从 rollout items 取材;resume(history) 接收调用方给出的 ResponseItem,再由 processor 包成 RolloutItem::ResponseItem。它不包括当前 session 的 sockets、子进程、审批请求、订阅者或 pending queue。

先看协议:来源有明确优先级

Resume 的三个来源

ThreadResumeParams 的协议注释把来源写成三种:按 thread_id 从磁盘读、直接使用调用方提供的 history、按 path 读 rollout。对 non-running thread,优先级是 history > non-empty path > thread_id;一旦使用 history 或非空 path,thread_id 只是请求字段,不再决定被读取的对象。空字符串 path 会先被反序列化成 absent。

这不是实现细节,而是 API 合同。客户端同时传 history 和一个旧 id 时,不能期待服务端把两份历史合并;同时传 path 和 id 时,也不能把 id 当作 path 的校验目标,除非该 thread 已经在运行。

本节源码依据(2 处)

Fork 的输入也不是“当前内存”

ThreadForkParams 只允许按 id 或 path 读取一个 durable source;非空 path 会让 thread_id 被忽略。last_turn_id 是可选的 inclusive boundary,且协议明确禁止引用 in-progress turn。ephemeral 只改变新 thread 的 persistence 选择,不改变 fork 先读取 source history 的事实。

本节源码依据(2 处)

行为矩阵:同样是“继续”,身份并不相同

入口新 thread idhistory 来源配置来源rollout pathtransient runtime
running resume(按 id)保留现有 idlive listener 从已加载 source 读取并回复现有 live config;不接受会改变运行对象的 history保留 active path原 thread 的 listener、subscribers、pending turn 仍在
cold resume(id)保留 persisted idstate/rollout 读取后构造 Resumedpersisted metadata + 请求 overrides原 path(若有)新建 Session、listener、clients;旧进程对象不在
cold resume(path)保留 path 指向的 thread idpath 解析出的 rollout同上,以 history cwd 为基线该 path新建 runtime
resume(history)生成新 id调用方给的 ResponseItem,转成 Forked当前 config + resume overrides由新 thread 的 persistence 决定新建 runtime,无 source listener
persistent fork生成新 idsource rollout,可按 terminal lastTurnId 截断fork overrides + source cwdSession 启动时创建新 path新建 runtime;source 不变
ephemeral fork生成新 idsource rollout,可截断fork overrides,ephemeral=trueNone,不进入 durable listing新建 runtime;响应直接从 copied history 投影

矩阵中唯一直接“回到原 live 对象”的是第一行。其他行都要经过 ThreadManager 的新 spawn 或新 Session 构造。即便 cold resume 保留 thread id,它也不是把旧进程的锁、socket 或 child process 复活,而是用 durable history 再建一个拥有相同身份的 runtime。

Resume:同一个 id 也有两条路径

Running resume 是 rejoin,不是 reload

thread_resume_inner 先调用 resume_running_thread。如果 thread_id 已对应一个 live thread,processor 会验证请求 path(若存在)与 active rollout path 一致,再把 persisted history 和响应命令交给已有 listener;这个分支在 thread_manager 之外完成,不会调用 cold resume_thread_with_history

如果 running thread 收到 history,processor 直接拒绝,错误是“cannot resume … with history while it is already running”。如果 path 与 active path 不一致,也直接拒绝。对 live object 的配置 override,代码会检查 mismatch;在仍有观察者或无法安全卸载时保持 rejoin 语义,而不是偷偷创建第二个同 id runtime。

本节源码依据(2 处)

Cold resume 读取 rollout,再保留原 id

没有 running object 时,processor 按 id 或 path 调 read_stored_thread_for_resume,要求 thread 未 archived、history 已加载。stored_thread_to_initial_history 将 durable items 和 rollout path 包进 InitialHistory::Resumed { conversation_id, ... }。随后 resume_thread_with_history 进入 ThreadManager,而 Session::newResumed 直接使用 conversation_id 作为 thread id。

这条路径还会读取 persisted metadata。model、provider、cwd 等请求字段先组成 overrides,再与 state DB 中的 metadata 合并;没有显式 reasoning override 时,恢复逻辑会按 persisted metadata 的缺省情况调整 reasoning effort。它是“同身份的新 runtime”,不是“旧 config 对象的反序列化”。

本节源码依据(4 处)

resume(history) 名字像 resume,类型却是 Forked

调用方直接传 history 时,协议只接受非空 ResponseItem 列表。processor 把每一项包成 RolloutItem::ResponseItem,返回 InitialHistory::Forked;进入 Session 构造后,这个 variant 不使用请求里的 threadId,而是生成新的 ThreadId

这里要把公开入口与内部通用类型分开。InitialHistory::Forked 本身可以容纳 SessionMeta,它的 forked_from_id() 也能从这类内部 rollout item 提取 lineage;但 thread/resume.history 的元素类型是 ResponseItem,processor 又只会包成 RolloutItem::ResponseItem。所以客户端不能通过这个字段携带 SessionMeta,这条入口也不会凭空得到 source lineage。

因此 resume(history) 不是“把一段内存 history 塞回原 thread”。它是一个由客户端提供 replay material 的新 thread,协议把它标成 unstable/experimental 也与这个边界相符。

本节源码依据(3 处)

Fork:复制 replay material,再重新建立边界

先截断,再把 source 视为 interrupted snapshot

thread_fork_inner 先从 thread store 读 source history。指定 lastTurnId 时,truncate_rollout_after_turn_id 生成包含目标 turn 的 prefix;没有指定时保留完整 persisted items。随后 processor 总是以 InitialHistory::Resumed 包装这份 source history,并传入 ForkSnapshot::Interrupted

Interrupted 在这里表示 fork 要把一个可能停在 turn 中间的 durable snapshot 整理成新 thread 可以继续的边界,并不断言 source thread 一定被用户中断。fork_history_from_snapshot 会把 Resumed 转成 Forked;如果 snapshot 检测到 active turn 未结束,就追加与真实 interrupt 相同的 TurnAborted 事件,以及当前配置允许的 interrupted-turn marker。已经在 terminal boundary 的 source 不会凭空多出一条 aborted turn。

本节源码依据(3 处)

新 thread 的 Session 从零建立 live resources

fork_thread_with_initial_history 先从 InitialHistory 计算 source_thread_id,再调用 spawn_threadSession::new 随后为 Forked history 生成新的 thread id,创建新的 ExtensionData、persistence task、state-db handle 和其他 session services。源码传入的是 config、history、source lineage 和 environment selection;没有把 source thread 的 listener channel、MCP client、shell child、approval queue 或 subscriber 集合作为 fork 参数。

这也是为什么 fork 的正确描述是“复制 durable history,重新启动 runtime”。只有 running resume 分支会把请求交给已有 listener;fork 的最后一步则新建 listener、发送新的 thread/started,并把 source 的 rollout 文件保持不变。

本节源码依据(3 处)

Persistent 与 ephemeral:差在 durability,不差在 lineage

Persistent fork 会马上有自己的 rollout

config.ephemeral 没有被设置时,new SessionLiveThread::create,并把 forked_from_id、session metadata、history mode 和初始 window 写入新 thread。fork processor 随后按新 path 读取 metadata/turns,构造 ThreadForkResponse,最后发出 thread/started。新 path 与 source path 不同,source 文件不会被修改。

Thread 协议模型也把这几件事分开暴露:id 是 child identity,session_id 是 session tree 的 session,forked_from_id 是 lineage,path 只是可选的磁盘位置,ephemeral 明确表示不会 materialize 到磁盘。不能只看 forked_from_id 就推断 child 已经持久化,也不能只看 path 就推断它与 source 是同一个 thread。

Ephemeral fork 没有 path,但仍有 copied turns

ephemeral=true 时,Session persistence future 返回 None,fork response 构造器不去读取新 rollout,而是从 history_items 直接生成 preview 和 turns。它仍然分配新 id、设置 forked_from_id、建立新 runtime;只是没有 durable path,也不会进入 thread listing。后续是否能继续执行,取决于这个 live ephemeral thread,而不是某个可重新打开的 JSONL。

本节源码依据(4 处)

lastTurnId:只接受可重放的 terminal boundary

协议里的 lastTurnId 是 inclusive:目标 turn 以及它之前的 turns 留在 child,之后的 turns 被省略。实现没有按 API response 中投影出的 turns 直接切数组,而是先从 raw rollout 构建 turns,再找具有相同 id 的 persisted TurnStarted event。这样可以拒绝三个危险输入:不存在的 id、只有 projection 才有的 synthetic id,以及仍为 InProgress 的 turn。

找到合法目标后,函数从目标 TurnStarted 之后寻找下一个 TurnStarted,把 prefix 截在下一个 turn 开始之前;如果目标是最后一个 turn,就保留到 rollout 末尾。由于 build_turns_from_rollout_items 已经考虑 rollback,rolled-back turn 也不会被当作稳定 boundary。失败返回 InvalidRequest,不会创建 child。

本节源码依据(2 处)

实验:四个 history 断言,两个不同的证据等级

四条 app-server 测试真实通过

在第五部导言从固定源码 commit 导出的 disposable 副本里运行:

: "${ARCHIVE_CODEX_RS:?先执行第五部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
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

四条目标测试都实际执行,结果各为 1 passed, 0 failed。它们分别证明:

  • resume 从 fake rollout 返回原 thread id、absolute path、preview 和一条 completed user turn;
  • 普通 fork 生成不同 id、新 path、forked_from_id,发出 thread/started,且 source rollout 字节不变;
  • 以第二个 terminal turn 为 lastTurnId 时,child 只含前两个 turns,第三个 turn id 不出现在新 rollout。
  • ephemeral fork 标记 ephemeral=true、保持 pathless、保留 copied turns,但不出现在 thread/list
本节源码依据(3 处)
macOS app-server resume 与 fork 定向测试结果

这张 macOS 终端图记录的是新增 ephemeral 锚点之前的三项校准:thread_resume_returns_rollout_historythread_fork_creates_new_thread_and_emits_startedthread_fork_at_last_turn_id_keeps_only_terminal_prefix,实际结果为 3 tests run: 3 passed, 944 skipped。正文随后补上的 thread_fork_ephemeral_remains_pathless_and_omits_listing 已在当前四项命令中执行,但没有被这张旧图展示。图和测试都不能证明 fork 会复制进程快照、MCP 连接、shell 子进程、pending input 或 live stream,也不能覆盖所有 transport、损坏 rollout 和并发 resume 边界。

Compaction integration test 的限制仍然要保留

第 26 章记录的 compact_resume_and_fork_preserve_model_history_view 是更接近模型可见 history 的端到端测试,但它在网络禁用时会在启动 wiremock 前 early return。因此本章不把它的绿色 1 passed 当成 resume/fork 行为的新证据;上面的四条 app-server 测试才是本次在 sandbox 中真实跑完的 identity/path/turn-prefix 锚点。两类测试可以互补,却不能互相替代。

失败边界:哪一层失败,child 是否出现

情况结果不应得出的结论
running resume 带 historyInvalidRequest服务端会把 history 合并进 live thread
running resume 的 path 与 active path 不同InvalidRequestpath 只是提示,不需要一致
cold resume 读到 archived threadInvalidRequestarchived row 可以直接启动新 runtime
history 为空InvalidRequest空 history 等价于 InitialHistory::New
fork 的 lastTurnId 不存在/非 persisted/in-progressInvalidRequest,不 spawn childsynthetic UI turn 可以作为 durable boundary
persistent fork rollout append 失败new live state 与 durable listing 可能分离response 出现就代表新 JSONL 一定存在
ephemeral fork新 id、无 path、无 listing没有 path 就没有 copied history
source snapshot 在 turn 中间child history 追加 aborted boundarysource thread 本身被回滚或被修改

其中最后两行尤其容易被混写。ForkSnapshot::Interrupted 只加工 child 的 InitialHistory;它不回写 source rollout。persistent child 的 append 错误也属于新的 ThreadStore durability 边界,不能用 thread/started notification 代替 JSONL 验证。

本节源码依据(4 处)

交给第 28 章:Memory 只消费哪一种历史

第 25 章把 live ContextManager、rollout JSONL 和 SQLite projection 分成三个 owner;第 26 章又说明 replacement 必须以 checkpoint 进入 durable replay。到这里,四条“继续”路径不能再压成一句 resume/fork:running resume 重连现有 live thread;cold resume 从 rollout 重建原 identity;resume(history) 用调用方给出的 ResponseItem history 创建新 identity,这份请求材料本身不是 durable rollout;fork 则从 source rollout 创建新 identity。除 running resume 直接 rejoin live object 外,后三条都会建立新的 runtime;没有一条会复制旧进程状态。

第 28 章的 Memory会从这里接收 identity/path/lineage 矩阵,但只消费 durable rollout,不读取 running listener 的 live history,也不会把 resume(history) 请求里的调用方 history 直接当作 rollout,更不会把 ephemeral fork 当成可跨进程的候选来源。下一章要继续追问:同一份 rollout 为什么能进入 Memory 的提取 pipeline,却不会自动变成长 History。

本节源码依据(2 处)