@deepseek-ai/dsh-session-persistence-jsonl 0.1.2-alpha.5 → 0.1.3-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md
5
- README.md: d3dfaf8affb142979ede69d86bbf0b1587e8c39c
6
- README.zh.md: 15fc815523355674a1f325b604dd2655f56f7f58
5
+ README.md: cce62bf98f1e906d8800b71e215e53b96bee8044
6
+ README.zh.md: 6bc5f574ecd7266375039858cc9974e47618cc74
package/README.md CHANGED
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
9
9
 
10
10
  ## Summary
11
11
 
12
- `dsh-session-persistence-jsonl` stores each session in its own append-only JSONL log — checksummed Zstandard frames by default, raw newline-delimited lines when compression is disabled. It serves the same logical `SessionEvent` stream as any persistence backend, so choosing it changes nothing for the agent loop, the model, or replay; compression, packing, and crash recovery are storage-internal details. Choose it when consumers need a per-session artifact on disk: `locate(meta)` returns the transcript path, and the logs are readable as plain lines when `compression: 'none'` is selected. A root directory is the one required configuration; durability, lazy materialization, and interrupted-turn recovery come with the backend.
12
+ `dsh-session-persistence-jsonl` stores each session in a current append-only JSONL log and retains immutable historical format generations — checksummed Zstandard frames by default, raw newline-delimited lines when compression is disabled. It serves the current logical `SessionEvent` stream through persistence handles, so format migration, compression, historical decoding, and crash recovery remain storage-internal details. Choose it when consumers need a per-session file on disk; the logs are readable as plain lines when `compression: 'none'` is selected. A root directory is the one required configuration; durability, lazy materialization, released-v0/v1 migration, and torn-tail crash recovery come with the backend.
13
13
 
14
14
  ## Table of Contents
15
15
 
@@ -45,34 +45,37 @@ Choose this backend when consumers benefit from one artifact per session — nav
45
45
  | Field | Default | Meaning |
46
46
  |---|---|---|
47
47
  | `root` | required | Root directory for all session files |
48
- | `packChunks` | `true` | Write eligible `assistant/chunk` runs as packed rows; `false` keeps one event per line for diagnostics |
49
48
  | `compression` | `'zstd'` | Physical encoding: `'zstd'` checksummed frames, or `'none'` newline-delimited UTF-8 text |
50
- | `preparedSessionCacheSize` | `5` | Cold session preparations retained for resume reuse |
51
- | `writeBatchMaxDelayMs` | `200` | Fixed live-event coalescing window, in milliseconds |
49
+
50
+ Live-event write batching is not configuration: the batching window is the seam's internal scheduling policy inside each write handle.
52
51
 
53
52
  The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-persistence-jsonl) is the exhaustive source for every accepted field and its JSDoc.
54
53
 
55
54
  ### On-disk layout
56
55
 
57
- Each session gets a session-owned directory under a readable project directory; the first logical line is the private v0 physical header, followed by one storage record per logical event (or one packed chunk row per eligible run). Its optional numeric `seedLength` remains byte-compatible: absence decodes to `SessionHeader.isSeeded: false`, while zero or a positive value decodes to `isSeeded: true` plus the exact `inheritedEventCount`. Storage records use the lossless provenance representation described below:
56
+ Each session gets a session-owned directory under a readable project directory. Every canonical generation starts with a physical header whose version equals its filename. Current v2 stores one physical row per durable event; the frozen v0 and v1 readers also understand their historical packed Assistant-delta rows. V2 stores `isSeeded` in the header and derives the inherited cut from the last tagged `session/end-seed` marker, while historical codecs translate their numeric `seedLength`. The format catalog completes that translation before a handle exposes current logical values. Current storage records use the lossless provenance representation described below:
58
57
 
59
58
  ```text
60
59
  <root>/
61
60
  --<normalized-cwd>--/ # readable project directory (or _no-cwd/)
62
61
  <encoded-id>/ # session-owned directory
63
- session.jsonl.zstd # default: checksummed header frame + append frames
64
- session.jsonl # only with compression: 'none'
62
+ session.jsonl.zstd # released v0, compressed root
63
+ session.v1.jsonl.zstd # released v1, compressed root
64
+ session.v2.jsonl.zstd # released v2, compressed root
65
+ session.jsonl # released v0, raw root
66
+ session.v1.jsonl # released v1, raw root
67
+ session.v2.jsonl # released v2, raw root; later versions use vN
65
68
  ```
66
69
 
67
- Session ids are injectively escaped to one safe path segment before use (no traversal, no collision). The normalized cwd keeps the project directory readable for navigation; cwd strings that normalize alike share a project directory while session ids still select distinct session directories. `locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved directories, performing no filesystem I/O.
70
+ Session ids are injectively escaped to one safe path segment before use (no traversal, no collision). The normalized cwd keeps the project directory readable for navigation; cwd strings that normalize alike share a project directory while session ids still select distinct session directories. Runtime operations select the numerically highest canonical generation, and format-refusal diagnostics name that absolute path so an operator can find the raw log a build refused to interpret.
68
71
 
69
72
  ### Durability and crash semantics
70
73
 
71
- A session is materialized lazily: `create(meta)` writes nothing, and the first `append` writes and `fsync`s the encoded header and first batch through a no-overwrite publish — so a created-but-never-appended session leaves nothing on disk unless a lifecycle consumer calls `ensureMaterialized`, which publishes one header frame without an event. Flushed events are never rewritten; each subsequent batch appends lines or one compressed frame, and a caught write or sync failure rolls the file back to its prior length. After a crash, `load` preserves an interrupted final turn: it keeps the complete decoded records of an incomplete last frame, truncates from that frame's start, and re-encodes the records with the synthetic tool, step, and turn closers required by the shared persistence contract. Only a never-fully-written torn tail is discarded; checksum, decompression, or structural failure in the committed prefix rejects as corruption.
74
+ A session is materialized lazily: `create(header)` writes nothing and returns the owned write handle, and the handle's first `append` writes and `fsync`s the encoded header and first batch through a no-overwrite publish — so a created-but-never-appended session leaves nothing on disk unless its owner calls `handle.flush()`, which publishes one header frame without an event. Each subsequent batch appends lines or one compressed frame and `fsync`s before the append resolves; a caught write or sync failure rolls the file back to its prior length. Committed events are never rewritten. After a crash, the stored log keeps its interrupted final turn every record in the committed prefix survives, and the resuming reader appends synthetic closers through its write handle. An incomplete final raw line is discarded. A torn final Zstandard frame contributes only its complete decoded JSONL records; a write handle truncates the torn bytes and durably rewrites those recovered records before its first new batch. Checksum, decompression, or structural failure in a complete committed frame rejects as corruption.
72
75
 
73
76
  ### Reading the logs
74
77
 
75
- `inspect(id)` returns an immutable balanced view with its exact inherited cut without committing recovery. `readFrom(id, fromOffset)` accepts a `SessionLogOffset`, returns stored events at or past that offset, and retains the same cut beside the suffix; sequential media like JSONL parse the whole artifact and skip forward. Header-only listing exposes `isSeeded` without reading event bodies. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
78
+ `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry `sizeBytes` and a best-effort stat-derived revision for the selected file. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
76
79
 
77
80
  -----
78
81
 
@@ -86,18 +89,21 @@ This section explains the physical encoding and write path; the observable contr
86
89
 
87
90
  ### Design concept
88
91
 
89
- The backend is a thin storage layer over the shared [PersistenceCoordinator](../session-persistence/README.md#understand-the-implementation): it loads stored records, appends batches, commits repairs, and delegates lifecycle orchestration to the coordinator. Its physical identity is a file revision: device, inode, size, and nanosecond timestamps identify one log and change after append or repair, which is what `listSnapshots` and retained-preparation validation use.
92
+ The backend owns its complete storage runtime (`src/storage.ts`): `JsonlSessionHandle` carries the per-handle mutation chain, the routed live-event buffer with its fixed batching window and single-flight drain, monotonic reads, and idempotent close; a tracker holds the in-process single-writer claims, the open-handle set teardown sweeps, and the created-but-unmaterialized pending sessions the backend's own session listeners route into. Historical body reads share one per-session Decode/Migrate preparation, and a bounded revision-keyed memo lets an immediate observe-to-resume handoff reuse that parse; the backend deep-freezes each event graph once before memoization, so later handle reads reuse it without copying or freezing. Only a write open publishes the prepared successor. The package deliberately exposes only its default plugin export plus configuration types — the concrete class is not a named export, so consumers couple to `ctx.sessionPersistence`, and the shared seam suites (`runPersistenceContract`/`runLiveWritePathContract`) pin its observable behavior. Its change token is a best-effort file revision: device, inode, size, and nanosecond timestamps identify one log for `stat`/`list`, for the stable-read loop that retries a read torn by a concurrent append, and for the pre-publication source check.
90
93
 
91
94
  ### Physical encoding
92
95
 
93
- The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, then one checksummed frame per durable append batch, using Node's built-in Zstandard API at its default compression level (no level knob). `sourceEventSeqs` uses a lossless storage representation: consecutive runs of at least three sequence numbers become `[start, end]` pairs, any other list stays verbatim, and reading expands the exact in-memory array. Listing reads and validates only the header frame. `compression: 'none'` keeps the same storage-form logical lines without frame compression. A root belongs to one encoding: startup discovery and targeted lookup reject the opposite suffix, and there is no format or compression migration, mixed-root fallback, or dual write. When `packChunks` is enabled, an eligible run of ≥3 consecutive same-block `assistant/chunk` delta events becomes one packed row (`text-chunks`/`reasoning-chunks`/`tool-call-chunks`) whose `seq0`/`time0` and per-member `dt` gaps reconstruct every member exactly; the lossless codec lives in `dsh-session` and reading is layout-blind, so packed, unpacked, and mixed files load identically.
96
+ The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, then one checksummed frame per durable append batch, using Node's built-in Zstandard API at its default compression level (no level knob). Current v2 writes one event per row; `sourceEventSeqs` uses a lossless storage representation in which consecutive runs of at least three sequence numbers become `[start, end]` pairs, any other list stays verbatim, and reading expands the exact in-memory array. Historical migration reuses one Zstandard decoder, passes parsed rows through stateful format stages, and streams current records through one compression context in about 1 MiB main-thread slices while retaining only final current events, bounded decoder state, and the required sequence-remap table. Listing reads and validates only the header frame. `compression: 'none'` keeps the same storage-form logical lines without frame compression. A root belongs to one encoding: startup discovery and targeted lookup reject generations with the other suffix; format migration preserves the configured encoding, while compression conversion, mixed-root fallback, and dual write remain unsupported. Frozen v0 and v1 codecs retain their packed-row decoders solely for historical generations.
94
97
 
95
98
  ### Source map
96
99
 
97
100
  | File | Role |
98
101
  |---|---|
99
- | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, backend class, coordinator wiring |
100
- | [`src/format.ts`](src/format.ts) | Log path derivation, header encoding, record scanning, packed-row layout |
102
+ | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, the backend service class, and file storage primitives |
103
+ | [`src/storage.ts`](src/storage.ts) | The JSONL handle, routed live-event buffer, in-process writer bookkeeping, listeners, teardown |
104
+ | [`src/format.ts`](src/format.ts) | Log path derivation, header encoding, and current record scanning |
105
+ | [`src/generation.ts`](src/generation.ts) | Single-pass historical restore, bounded stage encoding, source revision check, and exclusive successor publication |
106
+ | [`src/migration-verifier.ts`](src/migration-verifier.ts) | Worker lifecycle for staged and competing-generation verification |
101
107
  | [`src/zstd.ts`](src/zstd.ts) | Zstandard frame compression, decoding, and frame scanning |
102
108
  | [`src/win32.ts`](src/win32.ts) | Windows write-through publish and directory creation |
103
109
  | — | No runtime invariant companion is published; persistence correctness requires backend round-trip and crash-tail tests; this package exposes no continuously observable in-process relation. |
@@ -115,6 +121,7 @@ Read these pages when the package-level contract is not enough. They move from t
115
121
  - [Session persistence seam](../session-persistence/README.md) — the service contract this backend implements.
116
122
  - [Project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) — the layout tradeoff behind project and session directories.
117
123
  - [Zstandard JSONL session logs](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md) — the checksummed-frame encoding rationale.
124
+ - [Released Session format migrations](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) — immutable generations, adjacent migration edges, and publication rules.
118
125
 
119
126
  -----
120
127
 
@@ -125,7 +132,7 @@ Read these pages when the package-level contract is not enough. They move from t
125
132
 
126
133
  #### What the model sees
127
134
 
128
- JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Raw `assistant/chunk` records do not duplicate messages.
135
+ JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Embedded Assistant streams and log-only attempts do not duplicate messages.
129
136
 
130
137
  #### Token effect
131
138
 
@@ -142,11 +149,11 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
142
149
 
143
150
  These limits define when this backend is a poor fit or needs special operational care. They are current package constraints, not a task backlog.
144
151
 
145
- - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate or fresh root, or selecting raw mode; the pre-release format has no migration.
152
+ - **Format migration preserves the configured encoding and supports only the catalogued chain** — this build migrates released v0 or v1 to current v2; changing compression requires a separate root, and retained predecessors do not provide automatic fallback or downgrade support.
146
153
  - **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading.
147
154
  - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when external line readers are required.
148
155
  - **Nothing deletes session files** — logs accumulate under `root` until removed externally; the seam has no deletion API.
149
- - **One live writer per session** — append and repair are coordinated only inside the owning backend instance; another instance or process must not write the same session until that owner reaches quiescent disposal.
156
+ - **One live writer per session** — the write-handle claim excludes a second writer inside the owning backend instance, and a kernel lock (non-blocking `flock(2)` on `session.lock`; on Windows a named kernel semaphore derived from that path, with no filesystem footprint) excludes every other instance and process; the lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write, so an unmaterialized session leaves no filesystem footprint. A crashed holder's lock dies with its process, so its session is writable again immediately, while a live-but-wedged holder blocks writers until its process exits (on POSIX, removing the lock file forfeits that exclusion; release itself never removes it). Advisory `flock` is unreliable on some network filesystems (NFSv3), and the Windows semaphore name is per login session.
150
157
  - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement.
151
158
 
152
159
  <a id="dev-note"></a>
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@ kind: "package-reference"
9
9
 
10
10
  ## 概述
11
11
 
12
- `dsh-session-persistence-jsonl` 把每个会话存为一份仅追加 JSONL 日志——默认以带校验和的 Zstandard 帧存储,禁用压缩时以换行分隔的原始文本行存储。它提供与任何持久化后端相同的逻辑 `SessionEvent` 流,因此选择它不会改变 agent loop、模型或回放的任何行为;压缩、打包与崩溃恢复都是存储内部细节。当消费方需要按会话的磁盘产物时选择它:`locate(meta)` 返回 transcript 路径,选择 `compression: 'none'` 后日志可作为纯文本按行读取。根目录是唯一必填配置;持久性、延迟实体化与中断轮次恢复都随后端提供。
12
+ `dsh-session-persistence-jsonl` 把每个会话存为当前的仅追加 JSONL 日志,并保留不可变的历史格式 generation——默认以带校验和的 Zstandard 帧存储,禁用压缩时以换行分隔的原始文本行存储。它通过持久化句柄提供当前逻辑 `SessionEvent` 流,因此格式迁移、压缩、历史解码与崩溃恢复仍是存储内部细节。当消费方需要按会话的磁盘文件时选择它;选择 `compression: 'none'` 后日志可作为纯文本按行读取。根目录是唯一必填配置;持久性、延迟实体化、已发布 v0/v1 迁移与撕裂尾部崩溃恢复都随后端提供。
13
13
 
14
14
  ## 目录
15
15
 
@@ -45,34 +45,37 @@ kind: "package-reference"
45
45
  | 字段 | 默认值 | 含义 |
46
46
  |---|---|---|
47
47
  | `root` | 必填 | 所有会话文件的根目录 |
48
- | `packChunks` | `true` | 把符合条件的 `assistant/chunk` 连续段写为打包行;`false` 为诊断保留每事件一行 |
49
48
  | `compression` | `'zstd'` | 物理编码:`'zstd'` 带校验和帧,或 `'none'` 换行分隔 UTF-8 文本 |
50
- | `preparedSessionCacheSize` | `5` | 为恢复复用而保留的冷会话准备结果数量 |
51
- | `writeBatchMaxDelayMs` | `200` | 实时事件的固定聚合窗口,单位为毫秒 |
49
+
50
+ 实时事件的写入批处理不是配置:批处理窗口是该 seam 在每个写句柄内部的调度策略。
52
51
 
53
52
  生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-persistence-jsonl)是每个受支持字段及其 JSDoc 的穷尽式真源。
54
53
 
55
54
  ### 磁盘布局
56
55
 
57
- 每个会话在可读项目目录下获得一个会话自有目录;第一个逻辑行是私有 v0 物理 header,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行)。其可选数字 `seedLength` 保持字节兼容:缺席解码为 `SessionHeader.isSeeded: false`,零或正值解码为 `isSeeded: true` 加精确 `inheritedEventCount`。存储记录使用下文所述的无损来源序列表示:
56
+ 每个会话在可读项目目录下获得一个会话自有目录。每个规范 generation 都以版本与文件名一致的物理 header 开始。当前 v2 为每个持久事件存储一行;冻结的 v0 v1 reader 也能理解其历史 packed Assistant delta 行。V2 在 header 中存储 `isSeeded`,并从最后一个带标记的 `session/end-seed` 推导 inherited cut;历史 codec 则转换其数字 `seedLength`。格式 catalog 会在句柄暴露当前逻辑值之前完成该转换。当前存储记录使用下文所述的无损来源序列表示:
58
57
 
59
58
  ```text
60
59
  <root>/
61
60
  --<normalized-cwd>--/ # readable project directory (or _no-cwd/)
62
61
  <encoded-id>/ # session-owned directory
63
- session.jsonl.zstd # default: checksummed header frame + append frames
64
- session.jsonl # only with compression: 'none'
62
+ session.jsonl.zstd # released v0, compressed root
63
+ session.v1.jsonl.zstd # released v1, compressed root
64
+ session.v2.jsonl.zstd # released v2, compressed root
65
+ session.jsonl # released v0, raw root
66
+ session.v1.jsonl # released v1, raw root
67
+ session.v2.jsonl # released v2, raw root; later versions use vN
65
68
  ```
66
69
 
67
- 会话 id 在使用前被单射转义为一个安全路径段(无遍历、无冲突)。规范化 cwd 让项目目录保持可读、便于导航;规范化相同的 cwd 字符串共享项目目录,而会话 id 仍选择不同会话目录。`locate(meta)` 返回已解析目录内固定 transcript 的 `{ kind: 'jsonl', path }`,不执行任何文件系统 I/O。
70
+ 会话 id 在使用前被单射转义为一个安全路径段(无遍历、无冲突)。规范化 cwd 让项目目录保持可读、便于导航;规范化相同的 cwd 字符串共享项目目录,而会话 id 仍选择不同会话目录。runtime 操作选择数值最高的规范 generation,格式拒绝诊断会点名该绝对路径,让操作者能找到构建拒绝解读的原始日志。
68
71
 
69
72
  ### 持久性与崩溃语义
70
73
 
71
- 会话延迟实体化:`create(meta)` 不写入任何内容,第一次 `append` 通过无覆盖发布写入并 `fsync` 编码后的 header 与第一批——因此已创建但从未 append 的会话不留下任何磁盘内容,除非生命周期消费方调用 `ensureMaterialized`,以无事件的单个 header 帧发布它。已 flush 事件绝不重写;后续每个批次追加行或一个压缩帧,捕获到写入或同步失败时把文件回滚到之前的字节长度。崩溃后,`load` 保留被中断的最终轮次:保留不完整最后帧中完整解码的记录,从该帧开头截断,并按共享持久化约定的要求,用合成工具、步骤与轮次 closer 重新编码这些记录。只有从未完整写入的撕裂尾部被丢弃;已提交前缀中的校验和、解压或结构失败以损坏拒绝。
74
+ 会话延迟实体化:`create(header)` 不写入任何内容并返回持有的写句柄,句柄的第一次 `append` 通过无覆盖发布写入并 `fsync` 编码后的 header 与第一批——因此已创建但从未 append 的会话不留下任何磁盘内容,除非其所有者调用 `handle.flush()`,以无事件的单个 header 帧发布它。后续每个批次追加行或一个压缩帧,并在 append 完成前 `fsync`;捕获到写入或同步失败时把文件回滚到之前的字节长度。已提交事件绝不重写。崩溃后,已存储日志保留被中断的最终轮次——已提交前缀中的每条记录都保留下来,由执行恢复的读方通过其写句柄追加合成 closer。不完整的最终原始行会被丢弃。撕裂的最终 Zstandard 帧只贡献其中完整解码出的 JSONL 记录;写句柄会截掉撕裂字节,并在第一次新批次之前持久重写这些恢复出的记录。完整已提交帧中的校验和、解压或结构失败以损坏拒绝。
72
75
 
73
76
  ### 读取日志
74
77
 
75
- `inspect(id)` 返回带精确继承切点的不可变平衡视图,不提交恢复。`readFrom(id, fromOffset)` 接受 `SessionLogOffset`,返回该偏移及之后的已存储事件,并在后缀旁保留同一切点;JSONL 这类顺序介质解析整个产物并向前跳过。仅 header 的列表读取不读事件正文即可公开 `isSeeded`。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
78
+ `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。Backend 在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的 stat 派生修订号。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
76
79
 
77
80
  -----
78
81
 
@@ -86,18 +89,21 @@ kind: "package-reference"
86
89
 
87
90
  ### 设计理念
88
91
 
89
- 该后端是共享 [PersistenceCoordinator](../session-persistence/README.zh.md#understand-the-implementation) 之上的一层薄存储:它加载已存储记录、追加批次、提交修复,并把生命周期编排委托给协调器。其物理身份是文件修订值:device、inode、size 与纳秒时间戳标识一份日志,并在追加或修复后改变,这正是 `listSnapshots` 与保留准备结果校验所使用的身份。
92
+ 该后端拥有自己完整的存储运行时(`src/storage.ts`):`JsonlSessionHandle` 承载逐句柄修改链、带固定批处理窗口与 single-flight 排空的已路由实时事件缓冲、单调读取与幂等 close;一个 tracker 持有进程内单写者认领、teardown 清扫所遍历的打开句柄集合,以及后端自己的会话监听器所路由进的已创建但未实体化待定会话。历史正文读取共享每个 Session 唯一的一次 Decode/Migrate preparation,按 revision 为键的有界 memo 让紧接的观察到恢复交接复用该解析;backend 在 memo 化前只对每个 event graph 深度冻结一次,因此后续 handle read 无需复制或再次冻结。只有写 open 才发布准备好的后继。本包有意只暴露默认插件导出与配置类型——具体类不是具名导出,因此消费方只耦合 `ctx.sessionPersistence`,其可观察行为由共享 seam 测试套件(`runPersistenceContract`/`runLiveWritePathContract`)钉住。其变更令牌是尽力而为的文件修订值:device、inode、size 与纳秒时间戳标识一份日志,供 `stat`/`list`、在并发 append 撕裂读取时重试的稳定读取循环,以及发布前源检查使用。
90
93
 
91
94
  ### 物理编码
92
95
 
93
- 默认产物是独立 [Zstandard 帧](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带校验和帧,后跟每个持久 append 批次一个带校验和帧,使用 Node 内置 Zstandard API 的默认压缩级别(无级别开关)。`sourceEventSeqs` 使用无损存储形式:至少包含三个序列号的连续段会变成 `[start, end]` 区间对,其他列表原样保留;读取时会展开回精确的内存数组。列表只读取并验证 header 帧。`compression: 'none'` 保留相同的存储形式逻辑行,但不使用帧压缩。一个根只属于一种编码:启动发现与定向查找会拒绝相反后缀,且不提供格式或压缩迁移、混合根回退或双写。启用 `packChunks` 时,符合条件的 ≥3 个连续同 block `assistant/chunk` delta 事件连续段会变成一行打包行(`text-chunks`/`reasoning-chunks`/`tool-call-chunks`),其 `seq0`/`time0` 与各成员的 `dt` 间隔精确重建每个成员;无损 codec 位于 `dsh-session`,读取与布局无关,因此打包、非打包与混合文件加载结果一致。
96
+ 默认产物是独立 [Zstandard 帧](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带校验和帧,后跟每个持久 append 批次一个带校验和帧,使用 Node 内置 Zstandard API 的默认压缩级别(无级别开关)。当前 v2 为每个事件写一行;`sourceEventSeqs` 使用无损存储形式:至少包含三个序列号的连续段会变成 `[start, end]` 区间对,其他列表原样保留;读取时会展开回精确的内存数组。历史迁移会复用一个 Zstandard decoder,让已解析行流经有状态格式 Stage,并通过一个压缩 context 以约 1 MiB 主线程分片流式写入当前记录,同时只保留最终当前事件、有界 decoder 状态与必需的序号重映射表。列表只读取并验证 header 帧。`compression: 'none'` 保留相同的存储形式逻辑行,但不使用帧压缩。一个根只属于一种编码:启动发现与定向查找会拒绝使用另一后缀的 generation;格式迁移保留已配置编码,而压缩转换、混合根回退与双写仍不受支持。冻结的 v0 v1 codec 仅为历史 generation 保留 packed-row decoder。
94
97
 
95
98
  ### 源码地图
96
99
 
97
100
  | 文件 | 职责 |
98
101
  |---|---|
99
- | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、后端类、协调器接线 |
100
- | [`src/format.ts`](src/format.ts) | 日志路径派生、header 编码、记录扫描、打包行布局 |
102
+ | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、后端服务类与文件存储原语 |
103
+ | [`src/storage.ts`](src/storage.ts) | JSONL 句柄、已路由实时事件缓冲、进程内写入者记账、监听器、teardown |
104
+ | [`src/format.ts`](src/format.ts) | 日志路径派生、header 编码与当前记录扫描 |
105
+ | [`src/generation.ts`](src/generation.ts) | 单遍历史还原、有界 stage 编码、源 revision 检查与排他后继发布 |
106
+ | [`src/migration-verifier.ts`](src/migration-verifier.ts) | stage 与竞争 generation 校验的 Worker 生命周期 |
101
107
  | [`src/zstd.ts`](src/zstd.ts) | Zstandard 帧压缩、解码与帧扫描 |
102
108
  | [`src/win32.ts`](src/win32.ts) | Windows write-through 发布与目录创建 |
103
109
  | — | 不发布运行时不变式伴生入口;身份在存储层强制。 |
@@ -115,6 +121,7 @@ kind: "package-reference"
115
121
  - [会话持久化 seam](../session-persistence/README.zh.md)——本后端实现的服务约定。
116
122
  - [项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md)——项目与会话目录布局背后的取舍。
117
123
  - [Zstandard JSONL 会话日志](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md)——带校验和帧编码的理由。
124
+ - [已发布 Session 格式迁移](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)——不可变 generation、相邻迁移边与发布规则。
118
125
 
119
126
  -----
120
127
 
@@ -125,7 +132,7 @@ kind: "package-reference"
125
132
 
126
133
  #### 模型看到什么
127
134
 
128
- JSONL 存储不会向实时请求提供提示词或 schema。加载会恢复已存储的表层历史,并保留之前的请求 header 用于重建;新 loop 组合当前 envelope。恢复会用 `TOOL_NOT_STARTED` 平衡没有持久调用的 assistant 请求;持久调用无结果时则变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能的副作用或询问用户。原始 `assistant/chunk` 记录不会重复生成消息。
135
+ JSONL 存储不会向实时请求提供提示词或 schema。加载会恢复已存储的表层历史,并保留之前的请求 header 用于重建;新 loop 组合当前 envelope。恢复会用 `TOOL_NOT_STARTED` 平衡没有持久调用的 assistant 请求;持久调用无结果时则变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能的副作用或询问用户。嵌入式 Assistant stream 与仅日志 attempt 不会重复生成消息。
129
136
 
130
137
  #### Token 影响
131
138
 
@@ -142,11 +149,11 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope
142
149
 
143
150
  这些限制说明本后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。
144
151
 
145
- - **只加载已配置编码和当前 `SESSION_FORMAT_VERSION`(v0)**——更改压缩需要独立或全新根,或选择原始文本模式;预发布格式没有迁移。
152
+ - **格式迁移保留已配置编码,且只支持 catalog 中的链**——本 build 把已发布 v0 或 v1 迁移到当前 v2;更改压缩需要独立根,保留的前任不提供自动 fallback 或 downgrade 支持。
146
153
  - **平铺文件存储布局不加载**——加载前使用独立根,或将预发布产物移入项目/会话目录布局。
147
154
  - **压缩文件不能直接按行读取**——使用后端加载;或在写入新根前选择 `compression: 'none'`,供外部行读取方使用。
148
155
  - **不删除会话文件**——日志在 `root` 下累积,直到外部移除;seam 无删除接口。
149
- - **每会话一个活动写入方**——append 与修复只在所属后端实例内协调;在该所有者达到完全停稳的 dispose 前,另一实例或进程不得写入同一会话。
156
+ - **每会话一个活动写入方**——写句柄认领在所属后端实例内排除第二个写入方,内核锁(`session.lock` 上的非阻塞 `flock(2)`;Windows 上为由该路径派生的命名内核信号量,零文件系统足迹)排除其他所有实例与进程;锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取,因此未物化的会话不留任何文件系统足迹。崩溃持有者的锁随其进程消亡,会话立即可再写入,而活着但卡死的持有者会阻塞写入方直到其进程退出(POSIX 上删除锁文件即放弃该排他;释放本身从不删除它)。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠,Windows 信号量名按登录会话隔离。
150
157
  - **POSIX 实体化需要硬链接支持**——第一次 append 使用 `link()`,使同 id 竞态失败而不覆盖已提交日志;Windows 使用无替换 write-through rename。
151
158
 
152
159
  <a id="dev-note"></a>