# 记录、回放与撤销

> wire.jsonl 的版本迁移与修复、blobref 卸载、静默回放的恢复契约，以及基于分支树的撤销与压缩边界。

- 作者：David（道雾轩）
- 专栏：Kimi Code 源码解读（https://daiw.org/manual/kimi-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/kimi-code/records-replay
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

## 记录

v1 的 `packages/agent-core/src/agent/records/` 已随 v1 一起删除，记录层现在是 agent-core-v2 的 `src/wire/`：

```text
packages/agent-core-v2/src/wire/
├── record.ts       WireRecord、AGENT_WIRE_RECORD_KEY = 'wire.jsonl'
├── journal.ts      IAgentJournal：追加、读取、分支切换
├── wire.ts         IWireService
├── wireService.ts  实现：迁移、修复、可恢复链
├── migration/      协议迁移 v1.1 … v1.5
├── repair.ts       损坏日志的截断修复
└── tree/           分支树（tree.ts / fork.ts）
```

每个 agent 把发生的事写成 `WireRecord` 流，落在 `wire.jsonl`——[transcript 冷重建](https://daiw.org/manual/kimi-code/transcript)的唯一真相源。v2 里「会落盘的事」就是声明了 `durable = true` 的 `Event2` 类（比如 `plan.revision`），存储走 `IAppendLogStore`（`persistence/backends/node-fs/appendLogStore.ts`）。

## Wire 协议版本迁移

```ts
export const WIRE_PROTOCOL_VERSION = '1.5';
```

记录格式会演进，而**用户的旧会话必须还能打开**。`wire/migration/migration.ts` 保留了与 v1 同名的一组函数：

- `isNewerWireVersion()`：记录来自**更新**的版本（降级打开）时，不迁移、不改写，原样读取。
- `resolveWireMigrations()`：算出从旧版到 1.5 要走哪几步（`migrateV1_0ToV1_1` … `migrateV1_4ToV1_5`），缺一步就抛 `WIRE_MIGRATION_MISSING`。
- `migrateWireRecord()`：逐条套用。

没有 `metadata` 头的老日志一律按 1.4 处理，补上 `migrateV1_4ToV1_5`。

## 修复

新增的 `wire/repair.ts` 面对另一种现实：磁盘写满、进程被杀，日志尾部坏了。`repairWireJournal()` 先把原文件备份成 `<key>.bak`（`wireJournalBackupKey()`），再用 `appendLog.rewrite()` 只保留有效前缀，并记一条 `corrupted wire journal truncated to its valid prefix` 警告。v0.39.0 的 CHANGELOG 里有一条相关修复（#3281）：会话日志被截断或损坏（比如磁盘写满）后无法恢复的问题已修复。

## blobref：大对象卸载

`agent/blob/agentBlobService.ts` 定义 `IAgentBlobService`：`offloadParts()` / `loadParts()` / `isBlobRef()`。实现里，内容片段中 data URI 形式的媒体，base64 载荷达到 4096 个字符（`DEFAULT_THRESHOLD`）就写进 `IBlobStore`（agent 作用域下的 `blobs`），URL 换成 `blobref:<mime>;<hash>`；读回时查不到就换成 `[media missing]`。

理由不变：记录流要能被**快速顺序扫描**，混着几 MB 的 base64 图片会让重建慢到不可用。[plan 文档](https://daiw.org/manual/kimi-code/transcript)走的也是同一个 blob 存储。

## 恢复的契约：从注释变成机制

v1 在 `records/index.ts` 里用注释声明「restore 只许重建内存状态，不许发 UI 事件」。v2 是零注释区，这条约定改由代码强制执行。

`IEventDispatcher.restore()`（`state/eventDispatcherService.ts`）的流程：`restorePhase` 置为 `'restoring'` → 先沿可恢复链（`wire.readRestorable()`）回放可撤销的状态，再按完整日志回放其余状态 → `rehydrateStates()` → 置为 `'ready'`，并只在第一次恢复后跑一遍 `onDidRestore` 钩子。回放时每条记录都以 silent 模式执行（`executeEvent(event, true, undoable)`）。`executeEvent()` 提交完状态后遇到 `silent` 就直接返回，走不到后面的 `wire.appendRecord()`（durable 事件落盘）与 `eventBus.publish()`（observable 事件广播）；fold 上下文的 `emit` 在 silent 下也直接返回（第 137 行）：

```ts
  emit(event: Event2<any>): void {
    if (this.silent) return;
    this.owner.enqueue(event);
  }
```

**回放既不会重复落盘，也派生不出新事件**——恢复时想发 UI 事件，物理上做不到。配套约束还有：`AgentModel` 只允许在构造时用 `on()` 注册 applier，状态只能在 applier 里替换；持久化参与者必须在 restore 之前挂上；认不出的记录类型跳过并上报 `WIRE_UNKNOWN_RECORD`。

## 回放

v1 的 `ReplayBuilder` 类没有了，取而代之的是一个纯函数（`agent/replayBuilder/fold.ts` 第 40 行）：

```ts
export function foldWireRecords(records: readonly WireRecord[]): WireReplayFold {
  const fold = new WireReplayFoldState();
  for (const record of migrateJournalRecords(records)) {
    fold.apply(record);
  }
  fold.finish();
  return { replay: fold.replay, toolStore: fold.toolStore };
}
```

先迁移、再折叠，产出 `replay`（消息、压缩、goal / plan / 配置 / 权限变化、审批结果）与 `toolStore`。node-sdk 的 v2 客户端恢复会话时就用它从 `wire.jsonl` 重建回放。一个细节：折叠到最后仍没有结果的工具调用，会被补上一条错误结果，内容以「Tool execution was interrupted before its result was recorded.」开头，提醒模型别假设它成功了。

## 撤销：从截断到分支

v2 的撤销不删记录，而是**切分支**。`wire/tree/` 把日志看成一棵树（主干 `MAIN_BRANCH = 'main'`）。`IAgentConversationUndoService.undo(turns)` 的步骤：

1. 要求 loop 静止（`tryAcquireQuiescence()`）、没有进行中的压缩、dispatcher 已恢复完毕；
2. `wire.switchBranch({ turns, fromTurnId })` 从分叉点开出新分支，写入 `agent.switched`，外加一条给旧读者看的兼容 `context.undo`（见 `buildUndoSwitchRecords()`）；
3. `dispatcher.restore()` 按新的可恢复链重建状态，并重置 loop 状态机；
4. 派发 `context.undone` 事件。

撤销边界还是那两条。`fork.ts` 的 `computeForkLine()` 先排除最后一条 `context.clear` 之前的全部记录，往回数回合时遇到压缩摘要就抛错（第 86 行）：

```ts
    if (message.origin?.kind === 'compaction_summary') {
      throw new ForkLineError('compaction_boundary', turns - remaining);
    }
```

失败原因只有 `'compaction_boundary' | 'insufficient'` 两种。[压缩](https://daiw.org/manual/kimi-code/compaction)是**有损**的，撤销到压缩之前在物理上就做不到；`availability()` 返回的 `stoppedAtCompaction` 让界面能提前告诉用户。TUI 的 `/undo` 注册在 `tui/commands/registry.ts`。

## 可视化调试

`apps/vis`（含 `server` 与 `web`）是**会话与回放的可视化调试工具**，由 `kimi vis` 启动，可带会话 id 直接打开。`apps/kimi-inspect` 则打 [kap-server](https://daiw.org/manual/kimi-code/kap-server) 的 `/api/v1/debug` 反射 RPC 面，现在还多了按 message protocol v3 渲染的会话视图，以及基于 `IDebugLedgerService` / `IDebugGraphService` / `IDebugCascadeService` 的 DI 视图。

**两个调试器合计约 3 万行 TypeScript**（v0.29.1 时本文统计是 1.7 万行）。agent 的行为是概率性的，没有好的回放与检视工具，排查「它为什么这么干」几乎不可能。

第 5 部分结束。下一篇进入 [kap-server 与协议](https://daiw.org/manual/kimi-code/kap-server)。
