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 保证不切断配对。
Resume、Fork、Compaction 乍看像三个普通操作:读 JSON 恢复、深拷贝一份状态、删掉旧消息。但在 append-only 事件日志里,它们都不能修改或删除旧事件,最后全部要靠追加新事件完成。
这一章按 Mode B 走三条路径:从触发点开始,一跳一跳看数据怎么在组件之间流动,最后怎样落入日志、变成永久状态。
Resume、Fork、Compaction 看起来像三回事,其实守的是同一个原则:它们改写的不是旧事件本身,而是“未来从哪里继续追加、以及哪些旧节点在投影里继续可见”。
摊开说就是三句人话:
- Resume 不是把旧 session 重新“变活”,而是把磁盘上的事件接管进一个新的 live session,然后从 seed 末尾继续追加。
- Fork 不是克隆状态,而是从一段连续前缀事件重新长出一个子账本,并把 lineage 标清楚。
- 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)。
fromRestore 和 Session.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 数组后:
this.firstLiveSeq = this.log.length—— 标记”seed 在这里结束”- 如果日志最后一个事件不是
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 长度覆盖。
回到 resumeWith:persistence.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 还没回来,raceAbortCall 的 releaseAbandoned 回调会 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。验证链:
- boundary 必须是非负安全整数 — 否则
INVALID_BOUNDARY - boundary < events.length — 否则 “does not exist”
- events[boundary].seq === boundary — 否则 “does not match a contiguous event seq”(检测损坏日志)
- 不能在 open turn 内 — 在
[0, boundary]范围内找最后一个turn/start或turn/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 的 idseedLength: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 操作验证:
- start 和 end 必须是当前 surface 上的节点 —
state.nodes.indexOf(op.start)不为 -1 - start 在 end 之前(在 surface 顺序里) —
startIdx <= endIdx - sourceEventSeqs 必须覆盖全部 shadowed 节点 —
assertProvenance检查每个被 splice 掉的 seq 都在 sourceEventSeqs 里 - 如果是 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 返回(可能几秒),期间对话可能继续进行。事务机制保证一致性:
- Append
compaction/start— 标记事务开始,作为持久标记 - 异步执行 LLM 调用
- 摘要返回后,验证选中的 surface 范围没有变(节点还在原来的位置)
- 通过 → append replace 事件;失败 → 不 append replace
- 无论成功失败,append
compaction/end— 带成功或错误信息
compaction/start 和 compaction/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 触发一个特殊恢复路径:
- 记录当前
surface.replaceGeneration - 调用
compactIfNeeded(agent, 'context-overflow', signal) - 即使 LLM 摘要阶段失败,检查
replaceGeneration是否增长——model-free prune 可能已经成功 - 如果 generation 增长(surface 确实缩短了),retry 请求
- 限制
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 |
| Compaction | append 新事件带 surfaceOp replace → surface splice | 追加 replace 事件 + bracket 事件 |
三个操作都不修改已有事件。这是 append-only 日志的核心不变量——它让 replay、审计、crash recovery 全部变得确定性。
容易踩的坑汇总
坑一:Resume 期间 owner dispose。 resumeWith 在 persistence.prepare 上 await 时,如果 owner fiber 被 dispose,fused signal abort,raceAbortCall 的 releaseAbandoned 回调确保迟到的 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.prepare → SessionPreparation → Session.fromRestore(transfer ownership + freeze)→ session/end-seed → prepare → enter → announce → agent/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.push → applySurfacePlan(nodes splice + replaceGeneration++)→ deriveMessages() 触发缓存重建。
三条路走法不一样,但底下死守同一条不变量:日志只追加,不修改。 这也是它能在 crash、replay、fork、audit 面前保持一致的根。