# Agent Note: 大型会话 JSONL 恢复流水线

Status: implemented
Archived: 2026-09-04

[English](2026-08-05-large-session-jsonl-restore-pipeline.md) | 中文

## 问题

恢复已存储会话会激活该会话，并在 agent（智能体）运行前物化完整且权威的事件日志。处理大型 JSONL 产物时，这个一次性操作会产生几项不必要的开销：每个独立 Zstandard 帧都会创建并关闭一个解码上下文；解码后的明文会汇总成整份日志的缓冲区和字符串，再进行重复扫描；刚解析出的事件还会进入面向借用值或循环引用值设计的通用快照与深度冻结路径。

一份代表性性能剖析包含 61.8 MiB Zstandard 数据、97.1 MiB 明文和 1,307,073 个事件。恢复路径必须降低 CPU 与内存开销，同时保持校验和验证、已提交区域损坏检测、撕裂尾部恢复、序列与 `surface` 校验，以及会话日志不可变性。

## 决策

恢复过程是一条从持久化产物进入 `Session.fromRestore` 的所有权转移流水线。压缩产物仍作为源缓冲区驻留，但解码与扫描阶段会增量消费上一阶段的输出，不会保留整份日志的明文或解析副本；最终事件数组是唯一完整的已解码表示。

### 帧解码

Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解码专用首帧并将其解析为会话头部，后续明文帧则按顺序产出并送入 JSONL 扫描器。

`ZstdFrameDecoder` 为可互换的同步实现提供统一生命周期。首选实现会探测受支持 Node 22、24 与 26 的流结构，在所有完整帧之间复用一个私有原生解码上下文和临时缓冲区，最后只关闭一次。如果私有结构不可用，工厂会选择使用公共 `zstdDecompressSync` 的实现，并保持相同的迭代器和校验和错误约定。迭代器产出的临时视图会在进入下一次迭代前被消费。

累计帧处理时间约达 500 ms 后，异步读取器会在下一帧边界让出事件循环，并在继续前观察取消信号。单个帧仍是不可分割的同步操作。完整帧必须通过帧结束与校验和验证；只有结构上不完整的最终帧才使用既有前缀解码器进行恢复。

### 增量 JSONL 扫描

`SessionLogScanner` 使用 `Buffer.indexOf(0x0A)` 在原始缓冲区中查找换行，只把完整记录转换为 UTF-8 并交给 `JSON.parse`。扫描器会跨解码写入保留不完整记录；由于私有解码器可能复用输出缓冲区，它只复制这个片段。扫描过程不会构造整份明文缓冲区或字符串，也不会构造行数组或第二份解析记录数组。

扫描器在遇到第一条无法解析的记录或序列缺口后停止保留事件，但会继续检查后续完整记录。后续出现 `turn/end`，说明问题位于已提交区域，系统会拒绝该日志。处理完所有完整帧后，如果仍存在未决的解析错误、序列错误或部分记录，Zstandard 读取器同样会拒绝日志；只有结构上撕裂的最终帧才能提供可恢复后缀。该撕裂帧产出的完整记录会经过同一扫描器，并保持既有修复偏移量与恢复事件语义。

### 恢复准入

持久化把刚物化的当前 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的 tree；历史 packed row 与相邻 migration 已经生成新分配的 v2 settlement。restore-only path 使用一次 `for...in` 与 `switch` 校验固定 event envelope，按 event discriminant 执行当前表示检查，并通过显式 `pending` array 迭代冻结 owned object graph，不使用 cycle-tracking set。`surface` 校验记录一次 transition plan，并在同一个 candidate event 进入 log 时提交该 plan。

普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照，并使用支持循环检测的通用深度冻结。因此，这项特化仅改变持久恢复，不会放宽调用方所有值的准入要求。

## 考虑过的替代方案

- **每帧执行一次异步原生操作**：不予采纳，因为对于包含大量小型持久化批次的日志，调度与回调开销占据主要部分。协作式同步解码只在周期性让出边界支付这类开销。
- **同步处理完整日志且不让出事件循环**：不予采纳，因为整个恢复期间都无法响应取消或推进事件循环。帧边界让出机制无需拆分编解码操作，就能保留有界的观察点。
- **扫描前拼接全部明文**：不予采纳，因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录，并会重新扫描撕裂帧前缀。
- **实现流式 JSON 解析器**：不予采纳，因为 JSONL 已提供记录边界；使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构，无需自行维护另一套解析器或改变 JSON 语义。
- **冻结恢复事件时共享一个 `WeakSet`**：不予采纳，因为 JSON 物化不可能产生循环引用，而该集合会对每个对象增加一次查找，并在遍历期间保留完整对象图。
- **跳过恢复值的校验或冻结**：不予采纳，因为持久存储属于运行时边界，而 Session 读取方法承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作，而不是将其移除。

## 后果

在代表性性能剖析中，增量扫描将 JSONL 扫描时间从约 598 ms 降至 397 ms，峰值 RSS 从约 1,494 MiB 降至 1,060 MiB。恢复准入将 `Session.fromRestore` 从 604–608 ms 降至约 263 ms，其中 `assertSessionEventEnvelope` 从约 77 ms 降至 13 ms。这些数据用于描述优化输入，不构成运行时上限。

快速解码器依赖运行时探测的 Node 内部接口，但接口不兼容时会改用公共实现，不会改变正确性。系统会在协作式帧边界让出点观察取消信号；截止时间并不是单个帧内部严格的挂钟时间上限。完整事件数组仍会驻留内存，因为它是活跃会话的权威日志；该流水线移除的是重复表示，并未对这份状态做分页。

测试会强制执行两种解码器实现，比对帧顺序和损坏处理行为，覆盖协作式取消与撕裂尾部恢复，并保留既有会话信封、`surface` 与不可变性约定。
