断点、分叉与压缩:不重写历史的三种接管
resume 不是拉起旧进程,而是先以 write 模式夺门、冷读日志、用 interruptedTurnClosers 把中断的尾 turn 补上 synthetic 错误结果与边界事件,再以 eventState=detached 重建会话。fork 不是复制文件,而是从父内存切一个前缀、盖一个 inherited 标记、按 forked 原因补 open-turn closers。compaction 不是删历史,而是在只追加的日志上先落一条 durable compaction/start 锁,再做一次表面 replace 把 N 个节点并成一个 summary 节点。
Written in Chinese
第 0 秒,你昨晚 Ctrl+C 掉一个跑了三小时的 agent,今天说”继续”。你以为 resume 是”把昨天那个进程拉起来”——其实进程早没了,运行时做的第一件事是以 write 模式打开你的日志,在本进程内把写权夺下来,防止别的人同时 resume 同一个 id。然后它冷读一遍,发现最后一个 turn 是断的(一个 tool call 记了开始、没有结果),当场合成三条事件(错误 tool/result、step/end、turn/end)追加进去,再把”日志 + 补的这三条”当 seed 重建会话对象。你以为的”恢复现场”,本质是在你只追加的账本末尾补了几笔、再重新建账。
这一章接着第 26 章的就地修补与第 27 章的版本翻译:账本没坏、格式也新了之后,运行时怎样在不重写历史的前提下,完成接管(resume)、分叉(fork)与压缩(compaction)。
Resume:先夺门,再冷读,再补缺口
resume() 的入口先看一件事:持久化后端有没有挂。没挂直接拒绝——“cannot resume: session persistence is not configured (load a dsh-session-persistence backend)“。挂上之后进 resumeWith,它的顺序值得逐行看:
- 三路合成的取消信号:
AbortSignal.any([options.signal, ownerAbort.signal, this.ownership.signal])——调用方取消、owner fiber 在打开过程中卸载、工厂 teardown,任何一路都能让永不返回的后端调用被收回。 - write 先夺门:
persistence.open(id, 'write')用raceAbortCall赛跑——一旦被放弃,拿到的句柄立刻关闭。注释写明动机:“Taking write ownership FIRST excludes a concurrent resume of the same id (in this process, a live agent’s handle holds the claim)”。 - 冷读 + 语义修补:
handle.read(0)拿回物理合法日志,然后interruptedTurnClosers(persisted)——非空就通过同一个句柄批量 append。注意注释里的分工:物理修补是持久化层的事,语义修补是 agent 层的事。 - 重建 seed:
sessions.prepare(id, { seed: [...persisted, ...closers], meta: structuredClone(header), inheritedEventCount: handle.inheritedEventCount, eventState: coldRead.eventState })——closers 直接并入 seed,不是后补。 - 发布:
setupAndPublish(..., 'resume', owned, parentAgent),句柄所有权移交。
为什么夺门要放在读之前? 因为 resume 的并发对手不是”另一个进程”(那是文件锁的事),而是本进程内另一个 fiber 也在 resume 同一个 id。如果先读后夺门,两个 fiber 都读完了才发现冲突,冷读的成本已经白花;write 句柄是本进程内声明式的”活着的代理持有它”,先拿到它,第二个 resume 在 open 处就被挡住。
本节源码依据(5 处)
Session 重建:create 快照深冻,fromRestore 零拷贝只校验
seed 进构造函数后,两条路区别很大。Session.create 走 'snapshot' mode:每个 seed 事件过一遍 snapshotJsonValue(lossless JSON 往返),再 deepFreeze——慢,但是给”借来的、可能还在别人手里”的 seed 上保险。Session.fromRestore 走 'detached' | 'shared-frozen':docstring 写明”validated without copying or freezing events”——持久化层交上来的事件本来就是它独占的或已经冻好的,重建只是校验。
校验是逐事件、与 live append 同一条规则:信封检查、seq 必须从 0 连续(否则报”seed must be contiguous from 0”)、surfaceManager.validateNext 增量过一遍——seed 过不了表面状态机,就说明这个日志根本不是持久化层写得出来的形状,当场拒。
isSeeded 会话有一组硬契约:header.isSeeded 为真就必须显式传 seed 与 inheritedEventCount,否则分别报”seeded session requires an explicit constructor seed”与”seeded session requires an inherited event count”;非 seeded 会话 inheritedEventCount 必须为 0;切点位置由一个 session/end-seed {inherited:true} 事件标记,构造函数还要保证切点之后没有第二个 inherited 标记。恢复路径(fromRestore)最后若末尾不是 end-seed,会补一个普通的 session/end-seed {}——注意它不带 inherited: true,因为 inherited 标记只属于 fork 切点。
| 路径 | mode | seed 处理 | end-seed 标记 | 典型来源 |
|---|---|---|---|---|
Session.create | 'snapshot' | 逐事件快照 + deepFreeze | 新 seeded child 补 {inherited:true} | 新建、fork、借 seed 回放 |
Session.fromRestore | 'detached'/'shared-frozen' | 零拷贝,只校验信封/连续/表面 | 末尾缺则补普通 {} | 从磁盘恢复 |
为什么恢复模式敢不冻结? 因为它赌的是持久化层已经把”独占所有权”做到位了——这正是 SessionSeedEventState 两种取值的含义:detached 是交出来后与原主彻底脱钩,shared-frozen 是双方共享一份本来就冻好的对象。重建层再深冻一次,纯属重复劳动;而新建模式面对的是”随手借来的对象”,不冻就要出事。同一套校验规则、两种所有权假设——这是这套设计的全部讲究。
本节源码依据(5 处)
Fork:从父内存切一个前缀出来,不碰父文件
fork(source, boundary?, childId?) 的流程很短,但每一步都在防错位:
- 子 id 占用先查:
get(childId)已有 →SessionForkError('... already exists', 'SESSION_ALREADY_EXISTS')——注意这一步排在所有父校验之前,因为它最便宜。 - 解析父:字符串 id 找不到 →
SESSION_NOT_FOUND;传 Session 对象但 store 里活的不是它 →SESSION_NOT_LIVE(防”拿一个过期对象当父”)。 - 切前缀:
snapshotEvents()复制一份,_forkBoundary校验 boundary——必须是非负安全整数、不越界、与连续 seq 对齐,否则INVALID_BOUNDARY。 - 造 seed:
buildForkSeed(events, boundary)。 - 建子:
create(childId, { seed, inheritedEventCount: boundary+1, meta: { cwd, parentSession: 父id, isSeeded: true } })。
buildForkSeed 就三行核心:拷贝 events.slice(0, boundary+1);追加一个 session/end-seed {inherited:true}(时间戳复用切分点那事件的时间,保持确定性);最后拼上 openTurnClosers(prefix, {kind:'forked'})。如果父会话的 open turn 里切了一刀,子会话继承到的就是半拉 tool call——fork 不拒绝这种切点,而是按 forked 原因合成错误结果,并在措辞里明说:“The parent session may have completed it after the fork point.”
| fork 拒绝码 | 触发条件 |
|---|---|
SESSION_ALREADY_EXISTS | 子 id 在 store 里已有(最先查) |
SESSION_NOT_FOUND | 父 id 不在活 store |
SESSION_NOT_LIVE | 传了 Session 对象但 store 里活的不是它 |
INVALID_BOUNDARY | boundary 非负安全整数不成立 / 越界 / 与连续 seq 不对齐 |
为什么 fork 不拒绝 open turn? 因为分叉的语义恰恰是”在不确定处开一条平行线”。父会话可能在切分点之后才把那个 tool call 收尾——子会话若直接报错拒绝,就把”并行探索”这个最常见的用途堵死了。合成一条”结果未知、父可能已完成、只读/幂等才可重试”的错误结果,把判断权交还给子 agent,是比硬拒绝有用得多的失败处理。
本节源码依据(5 处)
openTurnClosers:崩溃与分叉共用的那套收尾算法
崩溃恢复和 fork 中切是两个完全不同的事故,却共用同一个 openTurnClosers(events, cause)。它扫一遍事件,维护 pendingCalls 表:turn/start 开 turn、turn/end 关 turn、step/end 清空 pending、assistant/message 里的 tool-call block 入表、tool/call 记它的 seq、tool/result 出表。扫完若 turn 还开着,就从最后一个真实事件的 seq+1 与时间戳开始,补出:每个 pending call 一条 isError 的 tool/result(带 {started:'ToolOutcomeUnknownError'} / {notStarted:'ToolNotStartedError'}),一个 step/end,一个 turn/end。
| 原因 | turn/end reason | 已记录开始的措辞 | 未记录开始的措辞 |
|---|---|---|---|
{kind:'interrupted'} | 崩溃恢复 | “…interrupted after it was recorded…outcome is unknown…” | “…interrupted before the Harness recorded it as started…” |
{kind:'forked'} | 中切分叉 | “The history inherited by this branch records this tool call starting…The parent session may have completed it after the fork point.” | “…has no record of this tool call starting. The parent session may have executed it after the fork point.” |
两种原因共享错误码(TOOL_NOT_STARTED / TOOL_OUTCOME_UNKNOWN)与算法,只在措辞与 turn/end reason 上分叉。模型看到的错误文本里都嵌着同一条行动准则:“retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state…Do not retry blindly.”——这是把”不要瞎重试”写进了账本,下一个接管者一打开就能读到。
本节源码依据(4 处)
Compaction:先落锁,再摘要,最后 replace
压缩事务 compactSurfaceRegion 的顺序里藏着一个关键约束:选 span 与校验入口状态是纯读的,紧接着就同步 append compaction/start——注释写明”Selection and validation are read-only. Idle/log validation and compaction/start are synchronously adjacent, so the durable opening marker is the compaction lock before summarization yields.”也就是说,异步摘要还没开始 yield,锁已经在账本上了。
入口状态是倒扫一次日志得到的三件事:当前 open turn、最近未配对的 compaction/start、最近的 session/end-seed。assertCompactionInactive 的豁免规则很妙:unmatched start 只有”没有更近的 end-seed 边界压过它”才算活着的锁——如果上次压缩的进程在 start 之后、commit 之前整个会话被 fork/恢复切开了,那个 start 属于上一生命周期,本周期不该再报 busy。
事务体里:自动压缩(owner 非空)必须身处一个 open turn;手动压缩(owner=null)若发现 open turn 直接 busy。摘要异步跑、可取消;失败时”every later failure makes exactly one compaction/end attempt”——而且故意留 unmatched start 可察觉,不把它抹平。提交时一次 surface replace 把 span 换成单个 summary 节点,最后 append compaction/end。
本节源码依据(4 处)
表面 replace 的三条不变量与 tool-pairing 平衡
压缩提交时那条 surfaceOp: {op:'replace', startSeq, endSeq} 事件,在表面折叠机 planSurfaceEvent 里要过三道闸:
- 遮蔽全覆盖:
assertSourceEventReferences——replace 事件的sourceEventSeqs必须是稠密整数数组、不得重复、全部早于自身 seq,且必须包含每一个被遮蔽的表面节点(缺谁报谁)。 - tool/result 只许改 content:
assertToolResultRewrite——replace 的 shadowedSeqs 必须恰好一个、原节点必须是 tool/result、除 message.content 外所有字段深等,否则”may change only content”。 - 节点 0 保护:
assertSystemHeadRewrite——如果被替换区间盖住表面节点 0,而节点 0 是 system/message,那么 replace 事件自己也必须是 system/message、且恰好只盖它一个——压缩 range 不准遮蔽系统提示词。
提交后 replaceGeneration += 1——这个计数是 tool-pairing 平衡缓存的失效信号。平衡算的是”N 个表面节点 N+1 个切分点,每个切分点处有没有未配对的 tool call 跨过去”;BalanceCache 按表面世代增量折叠,表面一旦被 replace 重写,旧缓存整轮作废重建——所以压缩选 span 时永远以”当前表面位置”为准,而不是按历史 seq 顺序。
本节源码依据(5 处)
小结
- resume 的本质是补账再建账:write 夺门 → 冷读 → closers 补尾 turn → seed 重建,全程不改旧事件,只在后面追加。
- fork 的本质是切前缀:父会话一行不动,子拿一个带 inherited 标记的前缀 + forked closers 开新账;open turn 不拒绝,合成”结果未知”错误交还给子 agent 判断。
- compaction 的本质是表面层 replace:durable start 先落锁,摘要后一次 replace 并节点;物理日志仍只追加,失败故意留 unmatched start 可察觉。
- 三条路径共用一种失败美学:把不确定写成账本上明确的一行(错误结果、错误码、unmatched 锁),而不是悄悄抹平——下一个接管者(人或 agent)永远知道发生了什么。
下一章进入第五部,看账本上长出来的第一种”信念”:第 29 章 Goal 为什么是一串事件而不是一个状态对象。
只读验证
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD # 应输出 477b4f420553e8a52c2fbccc464d7561b239c443
# resume 夺门顺序
grep -n "Taking write ownership FIRST" "$repo/packages/core/agent-loop/src/index.ts"
# fork 四拒绝码
grep -n "SESSION_ALREADY_EXISTS\|INVALID_BOUNDARY" "$repo/packages/core/session/src/index.ts"
# 双原因 closers
grep -n "kind: 'interrupted'\|kind: 'forked'" "$repo/packages/core/session/src/repair.ts"
# compaction 锁
grep -n "compaction already in progress" "$repo/packages/compaction/compaction-basic/src/region.ts"