Resume 与 Fork:恢复的不是同一件事
把 running resume、cold resume、resume(history)、persistent fork、ephemeral fork 和 lastTurnId 放进同一张行为矩阵,说明哪些身份与 durable history 被保留,哪些 live resource 从未被复制。
第 26 章把 replacement history 和 surviving suffix 交给恢复路径。接下来最容易出现的误会是把所有“继续一段历史”的入口都叫 resume:只要能看到旧消息,就以为 thread id、rollout path 和进程里的 live 对象也都被原样搬过来了。
固定版本的实现先区分两个问题:要不要重新加入原来的 live thread,以及要不要用可重放材料创建一个新的 thread。thread/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 id | history 来源 | 配置来源 | rollout path | transient runtime |
|---|---|---|---|---|---|
| running resume(按 id) | 保留现有 id | live listener 从已加载 source 读取并回复 | 现有 live config;不接受会改变运行对象的 history | 保留 active path | 原 thread 的 listener、subscribers、pending turn 仍在 |
| cold resume(id) | 保留 persisted id | state/rollout 读取后构造 Resumed | persisted metadata + 请求 overrides | 原 path(若有) | 新建 Session、listener、clients;旧进程对象不在 |
| cold resume(path) | 保留 path 指向的 thread id | path 解析出的 rollout | 同上,以 history cwd 为基线 | 该 path | 新建 runtime |
resume(history) | 生成新 id | 调用方给的 ResponseItem,转成 Forked | 当前 config + resume overrides | 由新 thread 的 persistence 决定 | 新建 runtime,无 source listener |
| persistent fork | 生成新 id | source rollout,可按 terminal lastTurnId 截断 | fork overrides + source cwd | Session 启动时创建新 path | 新建 runtime;source 不变 |
| ephemeral fork | 生成新 id | source rollout,可截断 | fork overrides,ephemeral=true | None,不进入 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::new 对 Resumed 直接使用 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_thread。Session::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 Session 走 LiveThread::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 终端图记录的是新增 ephemeral
锚点之前的三项校准:thread_resume_returns_rollout_history、
thread_fork_creates_new_thread_and_emits_started 与
thread_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 带 history | InvalidRequest | 服务端会把 history 合并进 live thread |
| running resume 的 path 与 active path 不同 | InvalidRequest | path 只是提示,不需要一致 |
| cold resume 读到 archived thread | InvalidRequest | archived row 可以直接启动新 runtime |
| history 为空 | InvalidRequest | 空 history 等价于 InitialHistory::New |
fork 的 lastTurnId 不存在/非 persisted/in-progress | InvalidRequest,不 spawn child | synthetic 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 boundary | source 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。