# Agent Note: Zstandard JSONL 会话日志

Status: implemented

[English](2026-07-19-zstandard-jsonl-session-logs.md) | 中文

## 问题

JSONL 持久化后端会逐字保留每个 `SessionEvent`，其中包括数量庞大的 `assistant/chunk` 记录。原始文本便于检查，但重复的 JSON 键和模型文本会增加存储与 I/O 开销。压缩编码必须保留既有的 append/fsync 提交边界、首次物化时的无冲突发布、崩溃修复以及仅元数据列举；如果每轮都重写整个压缩文件，就会失去这些属性。

编码还必须在部署边界上保持显式。快照 fixture 与外部逐行读取器需要原始 JSONL，而后端无法在同一根目录中安全猜测压缩产物与原始产物，也不能静默迁移预发布会话数据。

## 决策

### 配置与后缀归属

`dsh-session-persistence-jsonl` 接受 `compression?: 'zstd' | 'none'`，并将省略值显式解析为 `'zstd'`。Zstandard 产物使用 `.jsonl.zstd` 后缀；`'none'` 保留原有的换行分隔 UTF-8 `.jsonl` 表示。`SessionLocation.kind` 仍为 `'jsonl'`，因为两种编码承载同一逻辑记录格式；按照仓库的预发布拒绝且不迁移策略，`SESSION_FORMAT_VERSION` 仍为 `0`。

每个持久化根目录只归属于一种编码。一次性的发现预检会拒绝任何相反后缀，而针对性的加载、活跃采用、列举与物化路径会在最初空目录预检之后再次执行对应后缀检查。错误会指出不兼容产物，并要求部署选择匹配配置或单独根目录。系统不提供迁移、双重读取、双重写入或基于扩展名的兜底。

### 帧与写入路径

压缩产物是标准独立 [Zstandard 帧](https://datatracker.ietf.org/doc/html/rfc8878)的串联：第一个带校验和的帧只包含头部行，后续每个持久追加批次各占一个带校验和的帧。正常 agent loop 批次就是轮次提交，因此帧边界保留既有持久化检查点，同时不让存储层依赖轮次事件类型。

压缩使用 Node 内置的 [`zstdCompress` 与 `zstdDecompress`](https://nodejs.org/download/release/v22.19.0/docs/api/zlib.html)，仓库最低支持的 Node 22.19 已提供这些 API。后端启用 `ZSTD_c_checksumFlag`，其余采用 Node 默认值，不公开压缩级别调节项，也不增加依赖。Node 将该 API 标记为实验性，因此 Node 22.19、24 与 26 兼容性门禁会执行同一个辅助实现。

首次物化会在打开临时文件之前压缩两个初始帧，然后保留既有的写入、文件 `fsync`、避免冲突的硬链接发布与目录 `fsync` 顺序。后续批次也会先压缩，再打开目标并在 EOF 追加。捕获到写入或文件同步失败时，后端会截断到原有字节长度，同步回滚结果，再重新抛出错误，让协调器重试未变化的批次。

### 读取、列举与崩溃恢复

帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度，以及可选校验和尾部，但不会解释压缩块。后端独立且按顺序解压完整帧，由此验证各帧校验和，再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部，或者无效帧结构都属于损坏并拒绝加载。

列举只按有界分片读取到第一个完整帧可用为止，验证并解压该头部帧，绝不读取事件帧。因此，即使会话日志很大，专用头部帧仍能维持仅元数据列举。

最终帧内部遇到 EOF 属于可恢复的撕裂尾部。后端把已有帧前缀交给 Node 解码器，并保留其产出的每个完整、以换行结束的事件。修复从该帧起始字节截断，再追加一个新的带校验和帧，其中依次包含恢复出的完整事件，以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件，修复会丢弃该不完整帧并保留此前全部完整帧。

### 消费方与验证

CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配置。快照录制与回放组合显式选择 `'none'`，因为提交的 fixture 是回放与规范化过程使用的原始 JSONL 输入；普通运行时组合使用压缩默认值。

共享持久化契约与协调器契约会针对两种编码运行。后端测试覆盖标准帧与校验和互操作性、仅头部列举、追加回滚、编码不匹配拒绝、完整帧损坏，以及横跨头部、块和校验和尾部的最终帧撕裂。默认运行时、构建后二进制、headless、ACP 与 Python 冒烟测试会断言压缩后缀与 Zstandard 魔数，或解码头部；读取原始内容的测试则显式退出压缩。

## 考虑过的替代方案

- **每条 JSONL 记录一个帧**——不予采纳，因为它会让大量分片事件各自承担帧头与校验和开销，并让物理边界脱离持久追加批次。
- **每次追加都重写一个完整压缩流**——不予采纳，因为成本会随日志大小增长，而且替换操作会放弃追加/fsync 回滚和既有的无冲突物化机制。
- **跨追加使用流式压缩器**——不予采纳，因为编码器状态中断后不会留下可独立校验的追加单元，从而使有界列举与按帧起点修复更复杂。
- **增加外部原生 Zstandard 依赖**——不予采纳，因为受支持的 Node 最低版本已经提供所需编解码器；另一个原生产物会增加安装与可执行文件打包风险，却不增加必需行为。
- **公开压缩级别或继续默认使用原始 JSONL**——不予采纳，因为没有部署证据支持第二种调节策略，而 `'none'` 已为需要逐行读取的 fixture 与集成保留路径。

## 后果

- 普通会话根目录存储 `.jsonl.zstd`，并保留仅追加、fsync、回滚与中断轮次恢复语义。
- 原始 JSONL 仍是显式配置，但切换编码需要使用全新或单独根目录，或者选择与既有产物匹配的模式。
- 每个持久批次一个帧会增加有界的帧与校验和开销，同时支持仅头部列举和从精确追加边界开始修复。
- 外部工具必须理解串联的 Zstandard 帧，或者消费原始模式产物；Node 通用的一次性解压只读取第一个独立帧，因此后端读取会显式遍历各帧。
- 实现依赖 Node 的实验性内置 Zstandard API，但不增加 NPM 依赖；受支持版本兼容性门禁会暴露 API 漂移。
