青雲的博客
深入浅出 DeepSeek Harness 第四部:事件账本——Session是append-only日志 第 25 章

Resume、Fork、Compaction——上下文改写术

追踪 Resume 从 JSONL 加载→SessionPreparation→freezeRestoredObject→session/end-seed 的完整所有权转移链路;Fork 从 SessionStore.fork() 验证 boundary→_forkSeed 拒绝 open turn→create 子 session 带 parentSession/seedLength header;Compaction 通过 surfaceOp replace 追加新事件遮蔽旧节点,两阶段 model-free prune + LLM summary,事务 compaction/start…compaction/end 包裹,toolPairingBalanced 保证不切断配对。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

Resume、Fork、Compaction 乍看像三个普通操作:读 JSON 恢复、深拷贝一份状态、删掉旧消息。但在 append-only 事件日志里,它们都不能修改或删除旧事件,最后全部要靠追加新事件完成。

这一章按 Mode B 走三条路径:从触发点开始,一跳一跳看数据怎么在组件之间流动,最后怎样落入日志、变成永久状态。

Resume、Fork、Compaction 看起来像三回事,其实守的是同一个原则:它们改写的不是旧事件本身,而是“未来从哪里继续追加、以及哪些旧节点在投影里继续可见”。

摊开说就是三句人话:

  1. Resume 不是把旧 session 重新“变活”,而是把磁盘上的事件接管进一个新的 live session,然后从 seed 末尾继续追加。
  2. Fork 不是克隆状态,而是从一段连续前缀事件重新长出一个子账本,并把 lineage 标清楚。
  3. Compaction 不是删旧消息,而是追加 surfaceOp: replace,让模型投影改看新节点,旧节点退到阴影里。

别把这章当三篇文章并排读。把这一条原则抓住,再去跟三条数据流,思路会干净很多。


第一条路径:Resume——从磁盘到 Agent 的所有权转移

触发:ctx.agents.resume({ resumeSessionId })

当你调用 resume 时,你传入一个持久化 session 的 id。你拥有的是磁盘上的 JSONL 文件——一组事件和一个 header。你需要把它变成内存中的 live session + 一个正在运行的 Agent。

第一跳:AgentLoop.resumeWith → persistence.prepare

AgentLoop.resume 拿到 sessionPersistence 服务后委托给 resumeWith。这个方法做的第一件事是构建一个复合 abort signal(owner unload + caller signal + factory teardown 三路取 any),然后用 raceAbortCall 调用 persistence.prepare(id, fusedSignal)

const fused = AbortSignal.any([
  ...options.signal === undefined ? [] : [options.signal],
  ownerAbort.signal,
  this.ownership.signal,
])
preparation = await raceAbortCall(
  () => persistence.prepare(id, fused),
  fused, id,
  (abandoned) => { abandoned[Symbol.dispose]() },
)

raceAbortCall 的关键在第四个参数:如果 abort 先于 prepare 完成,异步完成的 preparation 不会泄漏——它的 [Symbol.dispose]() 会被调用释放资源。

第二跳:persistence.prepare → Session.fromRestore

持久化后端(如 JSONL)从磁盘读出 header 和 events 数组。这些是新鲜分配的对象——刚从 JSON.parse 出来,没有任何其他代码持有引用。于是持久化后端把它们包进 SessionPreparation,内部调用 Session.fromRestore(id, seed, header)

fromRestoreSession.create 共享同一个构造函数,差别在 mode 参数:

create(snapshot 模式)fromRestore(restore 模式)
来源调用方借来的事件,可能还持有引用持久化层刚分配的、转移所有权的事件
拷贝策略snapshotJsonValue(递归校验+深拷贝)直接用 source,跳过拷贝
冻结策略deepFreeze(递归 Object.freeze)freezeRestoredObject(非递归栈式遍历)
header 校验snapshotSessionHeader(拷贝+校验)validateRestoredSessionHeader(原地校验+冻结)

freezeRestoredObject 用 while 循环代替递归——它处理的是可能很深的 JSON 树,用调用栈可能溢出:

function freezeRestoredObject<T extends object>(value: T): T {
  const pending: object[] = [value]
  while (pending.length > 0) {
    const current = pending.pop()!
    Object.freeze(current)
    for (const key in current) {
      const child = (current as Record<string, unknown>)[key]
      if (child !== null && typeof child === 'object') pending.push(child)
    }
  }
  return value
}

无论哪种模式,seed 数组里的每个事件都经过 surfaceManager.validateNext 增量验证——和 append() 时相同的 surface 转换规则。一个坏的 seed 事件不会悄悄通过,而是在构造时就失败。

第三跳:session/end-seed → firstLiveSeq → publication

构造函数处理完整个 seed 数组后:

  1. this.firstLiveSeq = this.log.length —— 标记”seed 在这里结束”
  2. 如果日志最后一个事件不是 session/end-seed,自动 append 一个

firstLiveSeq 是内存字段——它在进程内告诉你”从这个 seq 开始是本次 lifecycle 新写的”。但持久化后远程消费者看不到内存字段,所以需要 session/end-seed 这个日志内标记。反复打开同一个 session 不会重复追加(检查 log.at(-1)?.type !== 'session/end-seed')。

为什么 firstLiveSeq 和 header.seedLength 是两个不同的东西?

  • header.seedLength持久的 fork 血缘边界。一个 fork 出的子 session 被持久化后再 resume,它的 header.seedLength 永远是当初 fork 时的 seed 长度——2 个事件
  • firstLiveSeq本次进程的构造边界。resume 后它等于磁盘上全部事件数——可能是 200 个

测试明确验证了这一点:resume 后 header.seedLength 保持原始 fork 值,不被 resume seed 长度覆盖。

回到 resumeWithpersistence.prepare 返回后,检查 owner fiber 还活着、ownership 还 active,然后委托 setupAndPublish。这个方法调用 this.prepare(ownerCtx, id, agentOptions, session) 构建 ReactLoopAgent,执行可选的 setup 回调,最后 prepared.publish('resume')

publish 的顺序是硬编码的:enter(session)enter(agent)announce(session)announce(agent)emitAgentEvent('agent/session-start', { source: 'resume' })。任何一步 throw 都触发 prepared.dispose() 逆序拆除。

Resume 时间线

sequenceDiagram
    participant Caller as 调用方
    participant AL as AgentLoop
    participant Persist as SessionPersistence
    participant SP as SessionPreparation
    participant S as Session(fromRestore)
    participant Store as SessionStore
    participant Reg as AgentRegistry

    Caller->>AL: resume({ resumeSessionId })
    AL->>AL: 构建 fused AbortSignal (3路)
    AL->>Persist: prepare(id, fusedSignal)
    Persist->>Persist: 读JSONL (header + events)
    Persist->>S: Session.fromRestore(id, events, header)
    S->>S: validateRestoredSessionHeader
    S->>S: seed循环: validateNext + freezeRestoredObject
    S->>S: firstLiveSeq = log.length
    S->>S: append session/end-seed
    Persist->>SP: SessionPreparation.create(session)
    SP-->>AL: preparation
    AL->>AL: ownership.isActive()? fiber.assertActive()?
    AL->>AL: prepare(ownerCtx, id, opts, session)
    AL->>AL: setup?(agentCtx) → commit()
    AL->>Store: enter(session)
    AL->>Reg: enter(agent)
    AL->>Store: announce(session) → emit session/created
    AL->>Reg: announce(agent) → emit agent/created
    AL->>AL: emit agent/session-start {source:'resume'}
    AL-->>Caller: AgentHandle {agent, dispose}

Resume 容易走错的路

错误一:以为 resume 时 Session 用 structuredClone。 不用。fromRestore 直接接管所有权——freezeRestoredObject 冻结原对象,不分配副本。这是性能优化:一个 10 万事件的 session 如果 structuredClone 一次,开销巨大。

错误二:以为 owner dispose 只是”取消”——实际 Session 可能已在 store 里了。resumeWith 里 owner effect 的时序:如果 abort 发生在 persistence.prepare await 期间,preparation 还没回来,raceAbortCallreleaseAbandoned 回调会 dispose 迟到的 preparation。如果 abort 发生在 setup 期间,setupAndPublish 的 catch 块调 prepared.dispose() 拆除已经 enter 的 session 和 agent。两个阶段的拆除路径不同。

错误三:resume 时 delegationDepth 丢失。 测试 resume of a forked session preserves the lineage 明确验证:resume 后 header.delegationDepth 保持原值。如果丢了,一个 resumed 子 agent 会以为自己在顶层,绕过递归深度限制。


第二条路径:Fork——从源 Session 分叉出子 Session

触发:ctx.sessions.fork(source, boundary?, childSessionId?)

Fork 从一个 live session 的连续前缀创建一个新的 live session。子 session 的 seed 是源 session 事件的 [0, boundary] 切片。

第一跳:_resolveForkSource → 活性验证

如果你传入 SessionId,store 用 this.get(source) 查找;如果传入 Session 对象,它验证该对象确实是 store 的 live instance(不是 detached 的或被替换的)。三种拒绝码:

  • SESSION_NOT_FOUND:id 不在 store 里
  • SESSION_NOT_LIVE:对象的 id 在 store 里但对象不是那个 live 实例(stale 引用)
  • SESSION_ALREADY_EXISTS:子 session id 已经被占用(这个检查在验证 boundary 之前)

第二跳:_forkSeed → boundary 验证 + open turn 拒绝

_forkSeed 是 fork 的核心验证逻辑。boundary 默认是源 session 的最后一个事件的 seq。验证链:

  1. boundary 必须是非负安全整数 — 否则 INVALID_BOUNDARY
  2. boundary < events.length — 否则 “does not exist”
  3. events[boundary].seq === boundary — 否则 “does not match a contiguous event seq”(检测损坏日志)
  4. 不能在 open turn 内 — 在 [0, boundary] 范围内找最后一个 turn/startturn/end,如果是 turn/start 则抛 OPEN_TURN
const lastTurnBoundary = events.slice(0, boundary + 1)
  .findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastTurnBoundary?.type === 'turn/start') {
  throw new SessionForkError(
    `fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`,
    'OPEN_TURN',
  )
}

为什么不能在 open turn 内 fork?因为 turn 是 agent-loop 的原子执行单元。open turn 意味着可能有 tool-call 还没收到 result、assistant/message 还没追加、step 还没闭合。子 session 继承了一个”说了一半话”的状态,无法正确 replay。

第三跳:sessions.create(childId, { seed, meta }) → 子 session 构造

验证通过后,fork 调用 this.create(childSessionId, { seed, meta })。meta 包含三个字段:

  • cwd:继承源 session 的 cwd(如果有)
  • parentSession:源 session 的 id
  • seedLength:seed.length

create 内部调 prepare(构建 Session 对象)→ enter(进入 store)→ announce(发 session/created)。构造时 seed 走 snapshot 模式(因为源 session 还活着,调用方持有引用),每个事件 snapshotJsonValue + deepFreeze——子 session 的事件和源 session 完全隔离。

测试验证了这个隔离性:

expect(() => {
  firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
}).toThrow(TypeError)  // 冻结了,改不了

构造函数最后 append session/end-seed,子 session 的 firstLiveSeq 等于 seed 长度。此后子 session 可以独立 append 新事件、独立 flush、独立 resume。

Fork 时间线

sequenceDiagram
    participant Caller as 调用方
    participant Store as SessionStore
    participant Source as 源Session
    participant Child as 子Session

    Caller->>Store: fork(sourceId, boundary, childId)
    Store->>Store: childId已存在? → SESSION_ALREADY_EXISTS
    Store->>Store: _resolveForkSource(sourceId)
    Store->>Source: get events
    Store->>Store: _forkSeed(source, boundary)
    Store->>Store: 验证boundary范围/连续性
    Store->>Store: findLast turn/start|turn/end
    Note over Store: 如果是turn/start → throw OPEN_TURN
    Store->>Store: events.slice(0, boundary+1) = seed
    Store->>Store: create(childId, {seed, meta:{parentSession,seedLength,cwd}})
    Store->>Child: new Session(childId, seed, header)
    Child->>Child: seed循环: snapshotJsonValue + deepFreeze
    Child->>Child: surfaceManager.validateNext 每个事件
    Child->>Child: firstLiveSeq = seed.length
    Child->>Child: append session/end-seed
    Store->>Store: enter(child) + announce(child)
    Store-->>Caller: child Session

Fork 容易走错的路

错误一:试图 fork 一个正在运行的 session。 如果 agent 正在执行 step,turn 是 open 的,fork 会被 OPEN_TURN 拒绝。你必须等 turn 结束(turn/end 追加后)再 fork。

错误二:以为 fork 后修改源 session 会影响子 session。 不会。seed 是 snapshot——深拷贝+冻结。源 session 继续 append 新事件,子 session 不可见。

错误三:混淆 header.seedLength 和 firstLiveSeq。 fork 后子 session 的 header.seedLength === seed.length。如果这个子 session 被持久化后 resume,resume 后 firstLiveSeq 等于磁盘上全部事件数(可能远大于 seedLength),但 header.seedLength 保持原始 fork 时的值。这两个数字在不同层面有用。


第三条路径:Compaction——Surface 层的 Replace 操作

触发:surfaceOp: { op: 'replace', start, end }

Compaction 不是一个独立的”删除”系统——它是 Session.append() 的一种特殊调用模式。当你 append 一个带 surfaceOp: { op: 'replace', start: startSeq, end: endSeq } 的 surface-eligible 事件时,你告诉 SurfaceManager:“这个新事件替代了 surface 上从 startSeq 到 endSeq 的节点”。

旧事件不被修改、不被删除——它们的 seq 和 data 原封不动留在日志里。只是 surface 投影不再包含它们。deriveMessages() 看到的是新事件,不是旧事件。

第一跳:surfaceOp 验证 → SurfaceManager.validateNext

append() 收到一个 replace 类型的 surfaceOp 时,它先 snapshotJsonValue 整个操作对象(防止调用方后续修改),然后交给 surfaceManager.validateNext(event)

validateNext 调用 planSurfaceEvent,对 replace 操作验证:

  1. start 和 end 必须是当前 surface 上的节点state.nodes.indexOf(op.start) 不为 -1
  2. start 在 end 之前(在 surface 顺序里)startIdx <= endIdx
  3. sourceEventSeqs 必须覆盖全部 shadowed 节点assertProvenance 检查每个被 splice 掉的 seq 都在 sourceEventSeqs 里
  4. 如果是 tool/result 替换assertToolResultRewrite 额外检查只改了 content

验证是 plan-then-commit 的两阶段原子操作——先 planSurfaceEvent 生成 plan(不改状态),事件进入 log.push 后才 applySurfacePlan 改变 nodes 数组。如果验证失败,log 不变、surface 不变,你看到一个 throw。

// plan阶段
const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
// commit阶段(事件已进入log后)
applySurfacePlan(state, plan)

第二跳:nodes splice + replaceGeneration

applySurfacePlan 对 replace plan 执行:

state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
state.replaceGeneration += 1

单行 splice:把 [startIdx, endIdx] 范围的所有 seq 替换为新事件的 seq。之前有 3 个节点的位置变成 1 个。

replaceGeneration 是一个单调递增计数器。Session.deriveMessages() 用它判断缓存是否有效:

if (generation !== this.derivedGeneration) {
  this.derived = []
  this.derivedNodes = 0
  this.derivedGeneration = generation
}

每次 replace 发生,generation 变化,下次 deriveMessages() 会全量重建 derived 缓存。如果只有 append(不改 generation),则增量扩展。

第三跳:deriveMessages 投影变化

Replace 后,surface.nodes 可能从 [0, 1, 2] 变成 [3, 2](节点 0 和 1 被新事件 3 替代,节点 2 保留在后面)。deriveMessages() 遍历 nodes,对每个 seq 调 deriveEventMessage(log[seq]),组装最终的 Message 数组。

被替换的旧事件(seq 0、1)依然在 session.events 里,但 deriveMessages() 不再返回它们的消息——它们对模型不可见了。

这就是 Compaction 的核心语义:上下文窗口缩小,但审计日志完整保留。

Compaction 的两阶段实现

实际的 CompactionEngine 把上面的 replace primitive 包装成两阶段策略:

阶段一:Model-free tool-result pruning。 不调 LLM,直接把旧 tool/result 的 content 替换为空或简短标记。约束:assertToolResultRewrite 只允许改 message.content[0].content,其他字段(callId、isError、meta)必须和原事件完全相等。这一步可以单独缩减大量 token(一个 10KB 的文件读取结果变成 “[truncated]”)。

阶段二:LLM 摘要。 调用模型把一段多轮对话压缩成一条 user/message 摘要,用 surfaceOp: { op: 'replace', start, end } 替换整个范围。

事务包裹:compaction/start … compaction/end

摘要需要等 LLM 返回(可能几秒),期间对话可能继续进行。事务机制保证一致性:

  1. Append compaction/start — 标记事务开始,作为持久标记
  2. 异步执行 LLM 调用
  3. 摘要返回后,验证选中的 surface 范围没有变(节点还在原来的位置)
  4. 通过 → append replace 事件;失败 → 不 append replace
  5. 无论成功失败,append compaction/end — 带成功或错误信息

compaction/startcompaction/end 永远配对。测试验证了这一点:即使 LLM 超时或 surface 变了,end 也会被 append。日志里不会出现悬挂的 start。

Tool Pairing Balance

Compaction 选范围时有一个硬约束:不能把 tool-call 和它的 tool/result 切开。

toolPairingBalancedBefore(session, seq)toolPairingBalancedAfter(session, seq) 追踪 surface 上的 in-progress tool-call 计数:

  • 遇到 assistant/message 里的 tool-call block → 计数器 +N
  • 遇到 tool/result → 计数器 -1
  • 某个切点处计数器 > 0 → 有 tool-call 还没收到 result,不能在这里切

只有计数器为 0 的位置才是合法的 compaction 边界。这保证了模型不会看到”发起了工具调用但没有结果”的悬挂状态。

Context-Overflow 恢复

当 provider 返回 CONTEXT_WINDOW_EXCEEDED 错误时,agent-loop 触发一个特殊恢复路径:

  1. 记录当前 surface.replaceGeneration
  2. 调用 compactIfNeeded(agent, 'context-overflow', signal)
  3. 即使 LLM 摘要阶段失败,检查 replaceGeneration 是否增长——model-free prune 可能已经成功
  4. 如果 generation 增长(surface 确实缩短了),retry 请求
  5. 限制 maxOverflowRetries 次,防止无限循环

关键洞察:阶段一(prune)和阶段二(summary)是独立的。prune 成功但 summary 失败时,prune 产生的 replace 已经落入日志——surface 已经缩短了,retry 有意义。

Compaction 时间线

sequenceDiagram
    participant Loop as AgentLoop
    participant CE as CompactionEngine
    participant S as Session
    participant SM as SurfaceManager
    participant LLM as LLM Provider

    Loop->>CE: compactIfNeeded(agent, 'pressure')
    CE->>S: 读取 surface.nodes
    CE->>CE: toolPairingBalancedBefore/After 确定范围
    CE->>S: append compaction/start
    
    Note over CE: 阶段一: Model-free prune
    CE->>S: append tool/result (surfaceOp: replace, single node)
    SM->>SM: nodes.splice + replaceGeneration++
    
    Note over CE: 阶段二: LLM summary
    CE->>LLM: 发送摘要请求
    LLM-->>CE: 摘要文本
    CE->>SM: 验证 start/end 还在 nodes 里
    CE->>S: append user/message (surfaceOp: {op:replace, start, end})
    SM->>SM: nodes.splice + replaceGeneration++
    
    CE->>S: append compaction/end {success: true}
    
    Note over Loop: 下次 deriveMessages()
    Loop->>S: deriveMessages()
    S->>SM: surface.nodes (已缩短)
    S->>S: 只投影 nodes 里的 seq
    S-->>Loop: 缩短后的 Message[]

三条路径的统一视角

Resume、Fork、Compaction 看起来是三个不同的操作,但它们共享一个根本机制:

操作怎么实现日志变化
Resume从磁盘读事件 → transfer ownership → 冻结 → append end-seed追加 session/end-seed(如果还没有)
Fork从源 session 取前缀 → snapshot → 创建新 session → append end-seed新 session 追加 session/end-seed
Compactionappend 新事件带 surfaceOp replace → surface splice追加 replace 事件 + bracket 事件

三个操作都不修改已有事件。这是 append-only 日志的核心不变量——它让 replay、审计、crash recovery 全部变得确定性。


容易踩的坑汇总

坑一:Resume 期间 owner dispose。 resumeWithpersistence.prepare 上 await 时,如果 owner fiber 被 dispose,fused signal abort,raceAbortCallreleaseAbandoned 回调确保迟到的 preparation 被 dispose。但如果你在 setup 回调里做了长时间操作,owner dispose 时 abort.signal 已经 aborted,raceAbort 会 reject setup 的 Promise——你的 setup 必须尊重传入的 signal。

坑二:Fork 在 open turn 内被拒。 如果你的 agent 正在运行(turn open),fork 会 throw OPEN_TURN。等 turn 结束,或者显式传入一个 turn/end 事件的 seq 作为 boundary。

坑三:以为 Compaction 删除了旧消息。 旧消息的 seq、data、surfaceOp 全部原封不动留在 session.events 里。deriveMessages() 不返回它们,但直接遍历 events 数组能看到。UI 如果要显示完整历史(包括被压缩的原文),必须读 events 而不是 deriveMessages。

坑四:sourceEventSeqs 不覆盖全部 shadowed 节点。 assertProvenance 检查 surface 上 [start, end] 范围里的每个 seq 都出现在 sourceEventSeqs 里。少了任何一个,append 会 throw。这保证审计链完整——你能从 replace 事件追溯到它替代了哪些原始事件。

坑五:tool/result 替换改了非 content 字段。 assertToolResultRewrite 做结构比较:原事件和替换事件除了 message.content[0].content 之外必须完全相等。连 meta 的数组内容都做递归结构比较(isDeepEqualJson)。你不能通过 replace 改变 tool 的 callId、isError 或 meta 键。

坑六:compaction/start 后 process crash 没有 end。 这是 crash recovery 需要处理的场景。正常流程里 finally 保证 end 一定被 append。但如果进程在 start 和 end 之间崩溃,下次 resume 时 repair 逻辑会合成一个带错误标记的 compaction/end(类似于 turn 的 interrupted 修复)。


验证实验

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"

# 验证 fromRestore 的 restore 模式路径
grep -n "static fromRestore" "$repo/packages/core/session/src/index.ts" -A5

# 验证 fork 拒绝 open turn
grep -n "OPEN_TURN" "$repo/packages/core/session/src/index.ts" -B3 -A2

# 验证 resume 保留 delegationDepth
grep -n "delegationDepth" "$repo/packages/core/agent-loop/tests/resume.spec.ts"

# 验证 surface replace splice 行为
grep -n "nodes.splice" "$repo/packages/core/session/src/surface.ts"

# 验证 replaceGeneration 驱动缓存重建
grep -n "replaceGeneration" "$repo/packages/core/session/src/index.ts"

# 验证 compaction start/end bracket
grep -rn "compaction/start\|compaction/end" "$repo/packages/compaction/" | head -10

收口:三条路径,一条不变量

这一章其实就干了一件事:把 Resume、Fork、Compaction 这三条路径的“数据怎么走”摊开给你看。

Resume 这条线是:磁盘 JSONL → persistence.prepareSessionPreparationSession.fromRestore(transfer ownership + freeze)→ session/end-seedprepare → enter → announceagent/session-start(resume)

Fork 这条线是:源 session events → _forkSeed(boundary 验证 + open turn 拒绝)→ sessions.create(snapshot + deepFreeze + end-seed)→ 子 session 带 parentSession/seedLength header。

Compaction 这条线是:surfaceOp: { op: 'replace', start, end }SurfaceManager.validateNext(plan 阶段)→ log.pushapplySurfacePlan(nodes splice + replaceGeneration++)→ deriveMessages() 触发缓存重建。

三条路走法不一样,但底下死守同一条不变量:日志只追加,不修改。 这也是它能在 crash、replay、fork、audit 面前保持一致的根。