深入浅出 DeepSeek Harness 第四部:事件账本——Session是append-only日志 第 28 章

断点、分叉与压缩:不重写历史的三种接管

resume 不是拉起旧进程,而是先以 write 模式夺门、冷读日志、用 interruptedTurnClosers 把中断的尾 turn 补上 synthetic 错误结果与边界事件,再以 eventState=detached 重建会话。fork 不是复制文件,而是从父内存切一个前缀、盖一个 inherited 标记、按 forked 原因补 open-turn closers。compaction 不是删历史,而是在只追加的日志上先落一条 durable compaction/start 锁,再做一次表面 replace 把 N 个节点并成一个 summary 节点。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,你昨晚 Ctrl+C 掉一个跑了三小时的 agent,今天说”继续”。你以为 resume 是”把昨天那个进程拉起来”——其实进程早没了,运行时做的第一件事是以 write 模式打开你的日志,在本进程内把写权夺下来,防止别的人同时 resume 同一个 id。然后它冷读一遍,发现最后一个 turn 是断的(一个 tool call 记了开始、没有结果),当场合成三条事件(错误 tool/result、step/end、turn/end)追加进去,再把”日志 + 补的这三条”当 seed 重建会话对象。你以为的”恢复现场”,本质是在你只追加的账本末尾补了几笔、再重新建账。

这一章接着第 26 章的就地修补与第 27 章的版本翻译:账本没坏、格式也新了之后,运行时怎样在不重写历史的前提下,完成接管(resume)、分叉(fork)与压缩(compaction)。

三条接管路径:Resume 列——open(id,'write') 先夺写权排除并发,read(0) 冷读,interruptedTurnClosers 补错误结果与 step/end、turn/end(interrupted)后批量 append,prepare(seed+closers) 以 eventState=coldRead 零拷贝重建;Fork 列——子 id 占用先查否则 SESSION_ALREADY_EXISTS,snapshotEvents 复制并校验 boundary 连续存在,buildForkSeed 合成前缀 + end-seed(inherited) + forked 原因 closers,最后以 isSeeded/parentSession/inherited 切点建子会话;Compaction 列——validateSurfaceRegion 按表面位置选 span 两端平衡,inspectEntryState 查未配对 start,append compaction/start 同步相邻即持久锁,summarize 失败也恰好补一次 end(error),commit 时 surface replace 把 span 并成单个 summary 节点;底部红条是 append-only 铁律:历史永不重写,恢复是追加 closers,分叉是复制前缀,压缩是表面层 replace 而物理日志仍只追加。

Resume:先夺门,再冷读,再补缺口

resume() 的入口先看一件事:持久化后端有没有挂。没挂直接拒绝——“cannot resume: session persistence is not configured (load a dsh-session-persistence backend)“。挂上之后进 resumeWith,它的顺序值得逐行看:

  1. 三路合成的取消信号:AbortSignal.any([options.signal, ownerAbort.signal, this.ownership.signal])——调用方取消、owner fiber 在打开过程中卸载、工厂 teardown,任何一路都能让永不返回的后端调用被收回。
  2. 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)”。
  3. 冷读 + 语义修补:handle.read(0) 拿回物理合法日志,然后 interruptedTurnClosers(persisted)——非空就通过同一个句柄批量 append。注意注释里的分工:物理修补是持久化层的事,语义修补是 agent 层的事。
  4. 重建 seed:sessions.prepare(id, { seed: [...persisted, ...closers], meta: structuredClone(header), inheritedEventCount: handle.inheritedEventCount, eventState: coldRead.eventState })——closers 直接并入 seed,不是后补。
  5. 发布: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 切点。

路径modeseed 处理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?) 的流程很短,但每一步都在防错位:

  1. 子 id 占用先查:get(childId) 已有 → SessionForkError('... already exists', 'SESSION_ALREADY_EXISTS')——注意这一步排在所有父校验之前,因为它最便宜。
  2. 解析父:字符串 id 找不到 → SESSION_NOT_FOUND;传 Session 对象但 store 里活的不是它 → SESSION_NOT_LIVE(防”拿一个过期对象当父”)。
  3. 切前缀:snapshotEvents() 复制一份,_forkBoundary 校验 boundary——必须是非负安全整数、不越界、与连续 seq 对齐,否则 INVALID_BOUNDARY。
  4. 造 seed:buildForkSeed(events, boundary)。
  5. 建子: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_BOUNDARYboundary 非负安全整数不成立 / 越界 / 与连续 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 里要过三道闸:

  1. 遮蔽全覆盖:assertSourceEventReferences——replace 事件的 sourceEventSeqs 必须是稠密整数数组、不得重复、全部早于自身 seq,且必须包含每一个被遮蔽的表面节点(缺谁报谁)。
  2. tool/result 只许改 content:assertToolResultRewrite——replace 的 shadowedSeqs 必须恰好一个、原节点必须是 tool/result、除 message.content 外所有字段深等,否则”may change only content”。
  3. 节点 0 保护:assertSystemHeadRewrite——如果被替换区间盖住表面节点 0,而节点 0 是 system/message,那么 replace 事件自己也必须是 system/message、且恰好只盖它一个——压缩 range 不准遮蔽系统提示词。

提交后 replaceGeneration += 1——这个计数是 tool-pairing 平衡缓存的失效信号。平衡算的是”N 个表面节点 N+1 个切分点,每个切分点处有没有未配对的 tool call 跨过去”;BalanceCache 按表面世代增量折叠,表面一旦被 replace 重写,旧缓存整轮作废重建——所以压缩选 span 时永远以”当前表面位置”为准,而不是按历史 seq 顺序。

本节源码依据(5 处)

小结

  1. resume 的本质是补账再建账:write 夺门 → 冷读 → closers 补尾 turn → seed 重建,全程不改旧事件,只在后面追加。
  2. fork 的本质是切前缀:父会话一行不动,子拿一个带 inherited 标记的前缀 + forked closers 开新账;open turn 不拒绝,合成”结果未知”错误交还给子 agent 判断。
  3. compaction 的本质是表面层 replace:durable start 先落锁,摘要后一次 replace 并节点;物理日志仍只追加,失败故意留 unmatched start 可察觉。
  4. 三条路径共用一种失败美学:把不确定写成账本上明确的一行(错误结果、错误码、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"