@deepseek-ai/dsh-session-persistence 0.1.2-rc.1 → 0.1.5-alpha.1

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/README.md
5
- README.md: 6d430173b985d7e32b615e0b42713a925bc8b344
6
- README.zh.md: a3b6cd90fa4ccba2b28f0e81517bbabab0ce047e
5
+ README.md: cbc57651ff17801e75bf8cccafb44e1a3df69e7d
6
+ README.zh.md: 4fa3ae43de8f0be50afb498f87a9ceaa2a2c23c7
package/README.md CHANGED
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
9
9
 
10
10
  ## Summary
11
11
 
12
- `dsh-session-persistence` stores a session's event log durably, reloads it on resume, and lists stored sessions through the backend-neutral `ctx.sessionPersistence` service. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type. `SessionHeader.isSeeded` makes lineage visible to lightweight listing, while the exact `inheritedEventCount` accompanies every body-bearing storage read and prepared Session. A backend owns its storage, while the service owns append-only logs, contiguous sequence numbers, crash recovery that preserves an interrupted turn instead of truncating it, and durable writes that resolve only after the batch is safe. The shipped JSONL provider implements this service with one artifact per Session; third-party providers may implement the same contract without changing the loop or model.
12
+ This package lets applications persist and resume session event logs through a backend-independent API. Readers can create, open, inspect, list, append to, read, flush, and close stored sessions while preserving contiguous append-only history. A completed flush is the durability barrier; readers never receive torn tails or invalid records, and only one writer per session is allowed within a backend instance. Use the shipped [JSONL backend](../session-persistence-jsonl/README.md) for one compressed log per session, or implement another backend with the same observable guarantees.
13
13
 
14
14
  ## Table of Contents
15
15
 
@@ -25,33 +25,44 @@ English | [中文](README.zh.md)
25
25
  <a id="use-this-package"></a>
26
26
  ## Use this package
27
27
 
28
- Mount one persistence backend to make sessions durable. The backend registers itself as `ctx.sessionPersistence`; nothing else in the composition changes — the loop, resume, and replay all call the same service.
28
+ Mount one persistence backend to make sessions durable. The backend registers itself as `ctx.sessionPersistence` and routes every published session's live events into that session's active write handle; agent-loop — the production publication point for sessions — acquires each session's write handle before publication, so nothing else in the composition changes.
29
29
 
30
30
  ### Choosing a backend
31
31
 
32
- The seam ships the [JSONL](../session-persistence-jsonl/README.md) backend. It stores one append-only `.jsonl.zstd` artifact per Session and returns its absolute path from `locate(meta)`. A third-party backend may implement the service directly; the [backend contract](#understand-the-implementation) below is what it must honor.
32
+ The seam ships the [JSONL](../session-persistence-jsonl/README.md) backend: one append-only `.jsonl.zstd` log per session. A third-party backend may implement the service directly; the [backend contract](#understand-the-implementation) below is what it must honor.
33
33
 
34
34
  ### What the service provides
35
35
 
36
- With a backend mounted, you can store a session's events durably, reload the stored log, and list what is stored:
36
+ With a backend mounted, five service methods address stored sessions:
37
37
 
38
38
  ```text
39
- await ctx.sessionPersistence.create(meta, inheritedEventCount) // cut required when meta.isSeeded
40
- await ctx.sessionPersistence.ensureMaterialized(session) // persist an empty resumable session
41
- await ctx.sessionPersistence.append(id, events) // durably persist a batch
42
- const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(id)
43
- const headers = await ctx.sessionPersistence.list() // every stored session
39
+ const handle = await ctx.sessionPersistence.create(header) // store a new session, take write ownership
40
+ const handle = await ctx.sessionPersistence.open(id, 'write') // claim single-writer ownership of an existing session
41
+ const reader = await ctx.sessionPersistence.open(id, 'read') // observe without ownership
42
+ const snap = await ctx.sessionPersistence.stat(id) // header + revision (+ eventCount / sizeBytes) without a log read
43
+ const all = await ctx.sessionPersistence.list() // one snapshot per visible stored session
44
+ await ctx.sessionPersistence.flush() // backend-wide durability barrier over every active write handle
44
45
  ```
45
46
 
46
- `append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create(meta, inheritedEventCount)` remains lazy; `meta.isSeeded: true` requires the sibling exact cut, while unseeded metadata may omit it and rejects a nonzero value. The first materializing batch for a seeded session must reach the complete inherited prefix, so storage never exposes metadata whose cut exceeds its log. A lifecycle frontend calls `ensureMaterialized` only when an empty session must itself appear in durable listing without inventing an event. `load` returns an immutable balanced log and commits any needed crash recovery; `inspect` reads the same complete view without committing recovery. `readFrom` accepts a `SessionLogOffset` and returns a detached `SessionEventSuffix` carrying that `fromSeq`, the unchanged inherited cut, and only stored events at or after the cut. A session's artifact location (`locate`) resolves without filesystem I/O.
47
+ Service-level `flush()` drains every active write handle's routed events and materializes its session, exactly as each handle's own `flush` would; failures aggregate per session as an `AggregateError` without abandoning the sweep, and a handle closed mid-sweep counts as flushed because close itself drains durably.
48
+
49
+ Every log read and write flows through the returned `SessionHandle`; there are no id-addressed append or load methods. `handle.read(offset?, length?)` returns `{ eventState, events }`: the outer slice belongs to the caller, while `eventState` distinguishes an exclusively `detached` event graph from a `shared-frozen` graph that may also reside in a backend cache. The producer establishes this state and slices preserve it even when empty. Both states are safe to adopt without copying; a consumer that needs mutable events clones them first. Reads never include a torn tail, repeated reads on one handle never observe an older state than a prior read, and a write handle reads its own successful appends. `handle.append(events)` appends a contiguous batch whose first `seq` equals the stored next-seq; persistence is best-effort on resolution — the batch is accepted, ordered, and visible to reads on this backend instance, and only a resolved `flush` promises it survives a crash (the shipped JSONL backend happens to persist each batch immediately). `handle.flush()` is the durability barrier and also materializes an empty created session so it becomes durably listable. `handle.close()` is idempotent and uncancellable: a read handle frees local resources; a write handle completes pending durability and releases write ownership. Once an `append` or `flush` resolves, reads started afterwards on the same backend instance — on any handle, or through `stat`/`list` — observe at least that prefix.
50
+
51
+ ### Ownership and visibility
52
+
53
+ `create` and `open(id, 'write')` take in-process single-writer ownership: a second write open while an owner is active rejects with `SessionAlreadyOwnedError`, `create` on an occupied id rejects with `SessionAlreadyExistsError`, and a mutation on a `read` handle rejects with `SessionReadOnlyError` — one handle type, runtime refusal. Any operation on a closed handle rejects with `SessionHandleClosedError`, and `SessionOwnershipLostError` marks a write handle whose ownership is permanently gone (close it and reopen). A created session is observable in this process from the moment `create` resolves, while the backend may defer physical materialization until the first `append` or `flush`; other processes see only materialized sessions, and a session that never materialized before a crash never existed.
54
+
55
+ ### The live write path and shutdown drain
56
+
57
+ The backend owns the live write path: it installs the session listeners once and routes every published session's events by id to that session's active write handle — `session/event` copies into a bounded internal batching window, `session/flush` is the immediate durability and error-observation barrier, and `session/disposed` runs the final drain and closes the handle. A published session without an active write handle persists nothing. A background write failure retains its events in order, pauses the automatic path, and is logged; the next explicit flush retries and rejects loudly. `close()` itself drains the routed buffer through the still-open storage before releasing ownership, so backend teardown's close sweep keeps application shutdown lossless even though root-fiber disposal runs fibers' disposers concurrently.
47
58
 
48
59
  ### Resuming and crash recovery
49
60
 
50
- Resume is `load` plus session preparation: the stored log comes back with its header lineage and exact inherited cut intact, so ownership checks do not infer the cut from a marker or the full restore length. A session that crashed mid-turn reloads with its interrupted final turn preserved and balanced: `load` appends synthetic `tool/result` and `turn/end {interrupted}` closers for unanswered calls instead of dropping the events — a single turn can be large, and those events were durably written before the crash. Only a never-fully-written torn tail fragment is discarded.
61
+ Persistence returns the physically valid log; semantic repair belongs to the reader. A session that crashed mid-turn keeps its open final turn — a single turn can be large, and those events were durably appended before the crash; only the incomplete fragment of a never-acknowledged torn tail is discarded — complete records recovered from it are durably rewritten by the write path before the handle's first new append. Resume (agent-loop) reads the stored log through its write handle, computes `interruptedTurnClosers` — synthetic `tool/result` errors, any open `step/end`, and `turn/end {interrupted}` — and appends them through the same handle as an ordinary batch. Read-only observers (session-query) balance an interrupted cold log with the same closers in memory only.
51
62
 
52
63
  ### Failures and recovery
53
64
 
54
- A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SESSION_FORMAT_VERSION` remains v0 and this build provides no format-migration path; a newer version instructs the operator to upgrade the harness. The decoder accepts only the bounded same-version record variants named below. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`. A `load` on an id still bound to a live session first flushes its snapshot and rejects while its turn is open; a cold load applies recovery.
65
+ A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SessionHandle` exposes only current logical v1 records; a provider must convert any supported historical storage before returning a handle, and the shipped JSONL provider migrates released v0 through its static catalog. A newer format instructs the operator to upgrade the harness. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`.
55
66
 
56
67
  -----
57
68
 
@@ -65,33 +76,35 @@ This section explains how the seam realizes durable storage and how backends plu
65
76
 
66
77
  ### Design concept
67
78
 
68
- The package is the Service Definition of a capability seam with two halves. The abstract `SessionPersistence` service is the public contract; a `PersistenceCoordinator` provides backend-neutral orchestration for buffering, serialization, materialization, repair, adoption, and quiescent disposal. The JSONL provider implements the small durable primitives for stored reads, append, repair, and listing; a third-party provider may reuse the same coordinator or implement the service directly.
79
+ The package is a seam, not a backend framework: it exports the abstract `SessionPersistence` service, the `SessionHandle` contract, the stable error classes consumers catch, the pure stored-record validation helpers (`storage-contract`), and the branded revision — nothing else. Each provider owns its complete storage runtime (handle class, mutation ordering, single-writer bookkeeping, live-event routing, teardown), and two shared test suites — `runPersistenceContract` and `runLiveWritePathContract` under `tests/` — pin the observable behavior every provider must agree on. Deliberate consequence: providers may resemble each other where their storage happens to be similar, but no implementation machinery crosses the package boundary.
69
80
 
70
81
  ### The invariants every backend honors
71
82
 
72
- - **Append-only; a crashed turn is closed, not truncated.** Flushed events are never rewritten; `load` preserves an interrupted final turn and durably appends synthetic closers.
73
- - **Contiguous `seq`.** A gap in the middle of the log rejects; `append`'s first `seq` must equal the stored next-seq.
74
- - **Lossless JSON data.** Batches pass the shared one-pass lossless-JSON boundary; non-serializable payloads reject at the append site.
75
- - **Durability.** `append` resolves only once the batch is durable.
83
+ - **Append-only, contiguous `seq`.** Committed events are never rewritten; `append`'s first `seq` must equal the stored next-seq, and a gap rejects.
84
+ - **A torn physical tail never reaches a reader.** It belongs to an append that never resolved; the write path truncates it durably before its first new append.
85
+ - **Lossless JSON data.** Batches and headers pass the shared one-pass validate-and-snapshot boundary (`materializeAppendBatch`/`materializeCreateHeader`); non-serializable payloads reject at the call site.
86
+ - **Durability.** `append` persists best-effort; `flush` — per handle or service-wide — is the barrier that promises storage and also materializes an empty session.
87
+ - **Fail-closed reads.** `validateStoredEvents` refuses unknown event vocabulary and retired pre-release shapes; `assertVersion` refuses foreign format versions.
88
+ - **Single writer per backend instance.** The provider's in-process claim is taken at `create`/`open('write')` and released at handle close.
76
89
 
77
90
  ### Source map
78
91
 
79
92
  | File | Role |
80
93
  |---|---|
81
- | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `SessionPersistence` service and re-exported metadata types |
82
- | [`src/coordinator.ts`](src/coordinator.ts) | Shared write orchestration: batching, serialization, repair, adoption, disposal, format refusal |
83
- | [`src/write-behind.ts`](src/write-behind.ts) | The per-session bounded write controller and flush barrier |
84
- | [`src/preparations.ts`](src/preparations.ts) | Bounded retention of unpublished Session preparations for resume reuse |
94
+ | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `SessionPersistence` service and re-exported seam vocabulary |
95
+ | [`src/handle.ts`](src/handle.ts) | The `SessionHandle` contract: read/append/flush/close semantics and freshness rules |
96
+ | [`src/storage-contract.ts`](src/storage-contract.ts) | Shared validation: version gate, fail-closed vocabulary, batch materialization, contiguity |
97
+ | [`src/errors.ts`](src/errors.ts) | Stable handle/ownership failures and format refusals |
85
98
  | [`src/revision.ts`](src/revision.ts) | The branded opaque revision token |
86
99
  | — | 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. |
87
100
 
88
101
  ### The write path at a glance
89
102
 
90
- Each `session/event` copies the event into its session's controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. Expiry starts one durable append; events admitted during that write form a separately bounded follow-up batch. `session/flush` cancels the wait and drains through quiescence, so the loop uses it as the ordering and error-observation checkpoint before the next turn. A rejected background write retains its events and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately.
103
+ Each `session/event` for the writer's session copies into that handle's internal buffer. The first pending event starts a fixed batching window; later events join without resetting its deadline. Expiry drains the pending prefix through the handle's mutation chain; events admitted during a drain coalesce into the next chained batch, in order. `session/flush` cancels the wait and drains through quiescence, then runs `handle.flush()`, so the loop uses it as the ordering and error-observation checkpoint before the next turn. A rejected background drain retains its events and pauses the automatic timer; explicit flush, writer close, or backend teardown retries immediately and rejects loudly. Constructor seed events never emit `session/event`, so a seed appended through the handle before publication is never re-enqueued.
91
104
 
92
- ### Stored-record compatibility
105
+ ### Stored-record validation
93
106
 
94
- Backend reads normalize only the explicitly supported v0 record variants before validating current records. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR adoption. Reads do not rewrite stored records, and later appends use current v0. The [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) notes own these bounded exceptions; they are not a general format-migration promise.
107
+ The seam's shared helpers validate current logical v1 records, and appends write only the current format ([rationale](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)). Historical decoding and immutable successor publication belong inside each provider before it returns a handle. Every backend runs `storage-contract` validation on handle reads and write-open priming, refusing an unknown event type as `SessionFormatUnsupportedError` and a malformed current record as `SessionPersistenceCorruptionError`, with the raw-log `SessionLocation` attached when the backend keeps one artifact per session.
95
108
 
96
109
  </details>
97
110
  -----
@@ -101,9 +114,10 @@ Backend reads normalize only the explicitly supported v0 record variants before
101
114
 
102
115
  Read these pages when the package-level contract is not enough. They move from the shared durability model to the shipped backends and the decision evidence.
103
116
 
104
- - [Session persistence subsystem](../../../docs/subsystems/persistence.md) — the full service contract, flush checkpoint, crash recovery, and generated Cordis API.
117
+ - [Session persistence subsystem](../../../docs/subsystems/persistence.md) — the full service contract, handle semantics, flush checkpoint, crash recovery, and generated Cordis API.
118
+ - [Handle-based persistence Agent Note](../../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) — the seam design and its ownership model.
105
119
  - [JSONL persistence backend](../session-persistence-jsonl/README.md) — the shipped per-session-file backend.
106
- - [Session checkpoint policy](../session-checkpoint-policy/README.md) — the plugin that flushes through this service at semantic boundaries.
120
+ - [Session checkpoint policy](../session-checkpoint-policy/README.md) — the plugin that flushes through `session/flush` at semantic boundaries.
107
121
  - [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
108
122
 
109
123
  -----
@@ -132,9 +146,12 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov
132
146
 
133
147
  These limits define where the seam's guarantees stop. They are current package constraints, not a task backlog.
134
148
 
149
+ - **Write ownership is in-process only** — the provider's writer table excludes a second writer inside one backend instance; the durable cross-process lease is the planned next layer on the same handle shape, and until it lands another process must not write the same session.
150
+ - **A backend plugin reload under live sessions fails their writers loudly** — a reloaded backend cannot serve handles the old instance issued; writes fail until the sessions restart, and nothing silently re-adopts the logs.
151
+ - **Only handle-acquired sessions persist** — `ctx.sessions.create` + `session/flush` alone stores nothing; agent-loop is the production acquisition point, and tests seed storage through `create`/`append`/`close`.
135
152
  - **No deletion or retention API** — pruning stored sessions is out-of-band backend maintenance.
136
- - **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale.
137
- - **Synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it.
153
+ - **`list()` is unpaginated and unfiltered** — it returns every stored session's snapshot; fine for local stores, unindexed at scale.
154
+ - **Synthetic closers are the only crash story** — resume appends `interruptedTurnClosers` through the write handle; there is no partial-turn resume that continues an interrupted turn instead of closing it.
138
155
 
139
156
  <a id="dev-note"></a>
140
157
  ### Dev Note
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@ kind: "package-reference"
9
9
 
10
10
  ## 概述
11
11
 
12
- `dsh-session-persistence` 通过后端无关的 `ctx.sessionPersistence` 服务持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型。`SessionHeader.isSeeded` 让轻量列表可见血缘,而精确的 `inheritedEventCount` 随每次带正文的存储读取与 prepared Session 一同传输。后端拥有自己的存储,而服务拥有仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。随产品交付的 JSONL provider 用每个 Session 一份产物实现该服务;第三方 provider 可以实现同一约定,而不改变 loop 或模型。
12
+ 本包让应用通过后端无关的 API 持久存储并恢复会话事件日志。读者可以创建、打开、检查、列出、追加、读取、刷新和关闭已存储会话,同时保持连续且仅追加的历史记录。只有完成 flush 才构成持久性屏障;读取方不会收到撕裂尾部或无效记录,并且每个后端实例内每个会话只允许一个写入方。若希望每个会话使用一份压缩日志,可选用随产品交付的 [JSONL 后端](../session-persistence-jsonl/README.zh.md);也可以实现具备相同可观察保证的其他后端。
13
13
 
14
14
  ## 目录
15
15
 
@@ -25,33 +25,44 @@ kind: "package-reference"
25
25
  <a id="use-this-package"></a>
26
26
  ## 使用本包
27
27
 
28
- 挂载一个持久化后端即可让会话持久化。后端把自己注册为 `ctx.sessionPersistence`;组合中的其他部分不变——loop、恢复与回放调用的是同一个服务。
28
+ 挂载一个持久化后端即可让会话持久化。后端把自己注册为 `ctx.sessionPersistence`,并把每个已发布会话的实时事件路由进该会话的活跃写句柄;agent-loop——会话在生产环境中的发布点——在发布之前获取每个会话的写句柄,因此组合中的其他部分不变。
29
29
 
30
30
  ### 选择后端
31
31
 
32
- seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端。它把每个 Session 存为一份仅追加 `.jsonl.zstd` 产物,并由 `locate(meta)` 返回绝对路径。第三方后端可以直接实现该服务;必须遵守的[后端约定](#understand-the-implementation)见下文。
32
+ seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端。它把每个 Session 存为一份仅追加 `.jsonl.zstd` 产物。第三方后端可以直接实现该服务;必须遵守的[后端约定](#understand-the-implementation)见下文。
33
33
 
34
34
  ### 服务提供什么
35
35
 
36
- 挂载后端后,你可以持久存储会话事件、重新加载已存储日志并列出已存储内容:
36
+ 挂载后端后,五个服务方法寻址已存储会话:
37
37
 
38
38
  ```text
39
- await ctx.sessionPersistence.create(meta, inheritedEventCount) // cut required when meta.isSeeded
40
- await ctx.sessionPersistence.ensureMaterialized(session) // persist an empty resumable session
41
- await ctx.sessionPersistence.append(id, events) // durably persist a batch
42
- const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(id)
43
- const headers = await ctx.sessionPersistence.list() // every stored session
39
+ const handle = await ctx.sessionPersistence.create(header) // store a new session, take write ownership
40
+ const handle = await ctx.sessionPersistence.open(id, 'write') // claim single-writer ownership of an existing session
41
+ const reader = await ctx.sessionPersistence.open(id, 'read') // observe without ownership
42
+ const snap = await ctx.sessionPersistence.stat(id) // header + revision (+ eventCount / sizeBytes) without a log read
43
+ const all = await ctx.sessionPersistence.list() // one snapshot per visible stored session
44
+ await ctx.sessionPersistence.flush() // backend-wide durability barrier over every active write handle
44
45
  ```
45
46
 
46
- `append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create(meta, inheritedEventCount)` 保持惰性;`meta.isSeeded: true` 要求单独的精确 cut,unseeded metadata 可以省略它并拒绝非零值。seeded 会话的首个物化批次必须到达完整继承前缀,因此存储绝不公开 cut 超过日志的 metadata。只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复;`inspect` 读取同一份完整视图但不提交恢复。`readFrom` 接受 `SessionLogOffset`,并返回分离的 `SessionEventSuffix`,其中携带该 `fromSeq`、不变的继承 cut,以及 cut 位置或之后的存储事件。会话的产物位置(`locate`)不经文件系统 I/O 即可解析。
47
+ 服务级 `flush()` 排空每个活跃写句柄已路由的事件并把其会话实体化,效果与各句柄自己的 `flush` 完全相同;失败按会话聚合为一个 `AggregateError` 而不中途放弃清扫,清扫途中被关闭的句柄视同已 flush,因为 close 本身会持久排空。
48
+
49
+ 每一次日志读写都流经返回的 `SessionHandle`;不存在按 id 寻址的 append 或 load 方法。`handle.read(offset?, length?)` 返回 `{ eventState, events }`:外层 slice 属于调用方,`eventState` 则区分独占的 `detached` event graph 与可能同时保存在 backend cache 中的 `shared-frozen` graph。该状态由生产者建立,slice 即使为空也会保留原状态。两种状态都能直接接管而无需复制;需要修改 event 的 consumer 必须先 clone。Read 绝不包含撕裂尾部,同一句柄上的重复读取绝不会观察到比先前读取更旧的状态,写句柄也能读到自己成功的 append。`handle.append(events)` 追加一个连续批次,其第一个 `seq` 等于已存储 next-seq;完成时的持久化是尽力而为的——批次被接受、有序,并对同一后端实例上的读取可见,只有完成的 `flush` 才承诺它在崩溃后依然存在(交付的 JSONL 后端恰好会立即持久化每个批次)。`handle.flush()` 是持久性屏障,同时把空的已创建会话实体化,使其可被持久列出。`handle.close()` 幂等且不可取消:读句柄释放本地资源;写句柄完成待处理的持久化并释放写所有权。一旦某次 `append` 或 `flush` 完成,其后在同一后端实例上开始的读取——无论经由任何句柄,还是经由 `stat`/`list`——至少能观察到该前缀。
50
+
51
+ ### 所有权与可见性
52
+
53
+ `create` 与 `open(id, 'write')` 取得进程内单写者所有权:在持有者活跃期间第二次以写模式打开会以 `SessionAlreadyOwnedError` 拒绝,对已占用 id 执行 `create` 会以 `SessionAlreadyExistsError` 拒绝,在 `read` 句柄上执行修改会以 `SessionReadOnlyError` 拒绝——一种句柄类型,运行时拒绝。对已关闭句柄的任何操作会以 `SessionHandleClosedError` 拒绝,`SessionOwnershipLostError` 标记写所有权已永久丢失的写句柄(关闭并重新打开)。已创建的会话自 `create` 完成之刻起即可在本进程内被观察到,而后端可以把物理实体化推迟到第一次 `append` 或 `flush`;其他进程只能看到已实体化的会话,一个在崩溃前从未实体化的会话等于从未存在。
54
+
55
+ ### 实时写路径与关闭排空
56
+
57
+ 实时写路径由后端自持:它一次性安装会话监听器,把每个已发布会话的事件按 id 路由到该会话的活跃写句柄——`session/event` 复制进有界的内部批处理窗口,`session/flush` 是即时的持久性与错误观察屏障,`session/disposed` 执行最终排空并关闭句柄。没有活跃写句柄的已发布会话不做任何持久化。后台写入失败时按序保留其事件、暂停自动路径并记入日志;下一次显式 flush 会重试并响亮地拒绝。`close()` 本身会先经由仍然打开的存储排空路由缓冲区再释放所有权,因此即便根 fiber 的 dispose 并发运行各 fiber 的 disposer,后端 teardown 的关闭清扫也能保证应用关闭不丢数据。
47
58
 
48
59
  ### 恢复与崩溃恢复
49
60
 
50
- 恢复就是 `load` 加会话准备:存储日志连同其 header 血缘与精确继承切点一起返回,因此所有权检查不从标记或完整恢复长度推断切点。中途崩溃的会话重新加载时,其被中断的最终轮次会保留并保持平衡:`load` 为未获回答的调用追加合成 `tool/result` 与 `turn/end {interrupted}` closer,而不是丢弃事件——单个轮次可能很大,而这些事件在崩溃前已持久写入。只有从未完整写入的撕裂尾部碎片会被丢弃。
61
+ 持久化返回物理上有效的日志;语义修复属于读方。中途崩溃的会话保留其未闭合的最终轮次——单个轮次可能很大,而这些事件在崩溃前已持久追加;只有从未确认的撕裂尾部中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在句柄的第一次新 append 之前持久重写。恢复(agent-loop)通过其写句柄读取已存储日志,计算 `interruptedTurnClosers`——合成 `tool/result` 错误、任何未闭合的 `step/end`,以及 `turn/end {interrupted}`——并把它们作为普通批次通过同一句柄追加。只读观察方(session-query)仅在内存中用同样的 closer 配平被中断的冷日志。
51
62
 
52
63
  ### 失败与恢复
53
64
 
54
- 当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SESSION_FORMAT_VERSION` 保持 v0,本构建不提供格式迁移路径;更高版本会要求操作者升级 harness。解码器只接受下文点名的有限同版本记录变体。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。对仍绑定到活动会话的 id 执行 `load`,会先刷新其快照并在轮次开放时拒绝;冷 load 应用恢复。
65
+ 当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SessionHandle` 只暴露当前逻辑 v1 记录;provider 必须在返回句柄前转换任何受支持的历史存储,随产品交付的 JSONL provider 会通过静态 catalog 迁移已发布 v0。更高格式会要求操作者升级 harness。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。
55
66
 
56
67
  -----
57
68
 
@@ -65,33 +76,35 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi
65
76
 
66
77
  ### 设计理念
67
78
 
68
- 本包是能力 seam 的 Service Definition,分两半。抽象的 `SessionPersistence` 服务是公开约定;`PersistenceCoordinator` 为缓冲、串行化、物化、修复、接管与完全停稳的 dispose 提供后端无关编排。JSONL provider 实现存储读取、追加、修复与列出所需的小型持久原语;第三方 provider 可以复用同一 coordinator,也可以直接实现该服务。
79
+ 本包是 seam,而不是后端框架:它只导出抽象 `SessionPersistence` 服务、`SessionHandle` 约定、消费方捕获的稳定 error 类、纯函数的存储记录校验辅助(`storage-contract`)以及带品牌类型的 revision——再无其他。每个 provider 拥有自己完整的存储运行时(句柄类、修改排序、单写者记账、实时事件路由、teardown),`tests/` 下的两套共享测试套件——`runPersistenceContract` 与 `runLiveWritePathContract`——钉住每个 provider 都必须一致的可观察行为。有意为之的后果:各 provider 在存储恰好相似之处可以彼此相像,但没有任何实现机制跨越包边界。
69
80
 
70
81
  ### 每个后端必须遵守的不变量
71
82
 
72
- - **仅追加;崩溃轮次会被关闭,而非截断。** 已 flush 事件绝不重写;`load` 保留中断的最终轮次并持久追加合成 closer。
73
- - **连续 `seq`。** 日志中间的缺口会被拒绝;`append` 的第一个 `seq` 必须等于已存储 next-seq。
74
- - **无损 JSON 数据。** 批次经过共享单遍无损 JSON 边界;无法序列化的载荷在 append 处被拒绝。
75
- - **持久性。** `append` 只在批次持久后返回。
83
+ - **仅追加,连续 `seq`。** 已提交事件绝不重写;`append` 的第一个 `seq` 必须等于已存储 next-seq,缺口会被拒绝。
84
+ - **撕裂的物理尾部绝不到达读取方。** 它属于一次从未完成的 append;写路径在第一次新 append 之前将其持久截断。
85
+ - **无损 JSON 数据。** 批次与 header 经过共享的单遍校验并快照边界(`materializeAppendBatch`/`materializeCreateHeader`);无法序列化的载荷在调用处被拒绝。
86
+ - **持久性。** `append` 尽力而为地持久化;`flush`——逐句柄或服务级——是承诺存储并同时把空会话实体化的屏障。
87
+ - **失败即关闭的读取。** `validateStoredEvents` 拒绝未知事件词汇与已废弃的预发布形态;`assertVersion` 拒绝外来格式版本。
88
+ - **每个后端实例单写者。** provider 的进程内认领在 `create`/`open('write')` 时取得,在句柄关闭时释放。
76
89
 
77
90
  ### 源码地图
78
91
 
79
92
  | 文件 | 职责 |
80
93
  |---|---|
81
- | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SessionPersistence` 服务与重新导出的元数据类型 |
82
- | [`src/coordinator.ts`](src/coordinator.ts) | 共享写入编排:批处理、串行化、修复、接管、dispose、格式拒绝 |
83
- | [`src/write-behind.ts`](src/write-behind.ts) | 每会话有界写入控制器与 flush 屏障 |
84
- | [`src/preparations.ts`](src/preparations.ts) | 为恢复复用而有界保留的未发布 Session 准备结果 |
94
+ | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SessionPersistence` 服务与重新导出的 seam 词汇 |
95
+ | [`src/handle.ts`](src/handle.ts) | `SessionHandle` 约定:read/append/flush/close 语义与新鲜度规则 |
96
+ | [`src/storage-contract.ts`](src/storage-contract.ts) | 共享校验:版本门、失败即关闭词汇表、批次实体化、连续性 |
97
+ | [`src/errors.ts`](src/errors.ts) | 稳定的句柄/所有权失败与格式拒绝 |
85
98
  | [`src/revision.ts`](src/revision.ts) | 带品牌类型的不透明修订值 token |
86
- | — | 不发布运行时不变式伴生入口;协调器断言存储/活动身份与 cwd。 |
99
+ | — | 不发布运行时不变式伴生入口;持久化正确性需要后端往返与崩溃尾部测试;本包不暴露可持续观察的进程内关系。 |
87
100
 
88
101
  ### 写入路径概览
89
102
 
90
- 每个 `session/event` 把事件复制到其会话的 controller。第一个待处理事件开启固定批处理窗口;后续事件加入但不重置截止时间。窗口到期后启动一次持久追加;该次写入期间接纳的事件形成另一个独立有界的后续批次。`session/flush` 取消等待并排空至完全停稳,因此 loop 在下一轮次前把它用作排序与错误观察检查点。被拒绝的后台写入保留其事件并暂停自动重试;新事件开启新窗口,而显式 flush 或后端拆卸会立即重试。
103
+ 写入器会话的每个 `session/event` 都复制进该句柄的内部缓冲。第一个待处理事件开启固定批处理窗口;后续事件加入但不重置截止时间。窗口到期后经由句柄的修改链排空待处理前缀;排空期间接纳的事件按顺序合并进下一个链上的批次。`session/flush` 取消等待并排空至完全停稳,随后运行 `handle.flush()`,因此 loop 在下一轮次前把它用作排序与错误观察检查点。被拒绝的后台排空保留其事件并暂停自动计时器;显式 flush、写入器 close 或后端 teardown 会立即重试并响亮地拒绝。构造 seed 事件绝不发出 `session/event`,因此发布前通过句柄追加的 seed 绝不会被重新入队。
91
104
 
92
- ### 存储记录兼容
105
+ ### 存储记录校验
93
106
 
94
- 后端读取只会在校验当前记录之前,规范化明确支持的 v0 记录变体。协调器对 `load`、`inspect`、`readFrom`、无所有者状态认领与 HMR 接管使用同一份规范化视图。读取不会重写已存记录,后续追加使用当前 v0。[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md)笔记规定这些有限例外;它们不构成通用格式迁移承诺。
107
+ seam 的共享辅助函数校验当前逻辑 v1 记录,append 只写当前格式([理由](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。历史解码与不可变后继发布属于各 provider 内部,并在其返回句柄前完成。每个后端都在句柄读取与写 open 预热时运行 `storage-contract` 校验,把未知事件类型作为 `SessionFormatUnsupportedError` 拒绝,把 malformed 当前记录作为 `SessionPersistenceCorruptionError` 拒绝,并在后端为每个会话保留一份产物时附上原始日志的 `SessionLocation`。
95
108
 
96
109
  </details>
97
110
  -----
@@ -101,9 +114,10 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi
101
114
 
102
115
  当包级约定不够用时阅读以下页面。它们从共享持久性模型逐步进入随产品交付的后端与决策证据。
103
116
 
104
- - [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——完整服务约定、flush 检查点、崩溃恢复与生成的 Cordis API。
117
+ - [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——完整服务约定、句柄语义、flush 检查点、崩溃恢复与生成的 Cordis API。
118
+ - [基于句柄的持久化 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)——seam 设计及其所有权模型。
105
119
  - [JSONL 持久化后端](../session-persistence-jsonl/README.zh.md)——随产品交付、按会话存储文件的后端。
106
- - [会话检查点策略](../session-checkpoint-policy/README.zh.md)——在语义边界上经由本服务刷新的插件。
120
+ - [会话检查点策略](../session-checkpoint-policy/README.zh.md)——在语义边界上经由 `session/flush` 刷新的插件。
107
121
  - [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
108
122
 
109
123
  -----
@@ -132,9 +146,12 @@ seam 不添加提示词或 schema。恢复会将已存储的表层事件还原
132
146
 
133
147
  这些限制界定 seam 保证的终点。它们是当前包约束,不是任务积压。
134
148
 
149
+ - **写所有权仅限进程内**——provider 的写入器表只在单个后端实例内排除第二个写入方;持久的跨进程租约是计划在同一句柄形态上叠加的下一层,在它落地之前另一进程不得写入同一会话。
150
+ - **在有活跃会话时重载后端插件会使其写入器响亮地失败**——重载后的后端无法服务旧实例签发的句柄;写入会持续失败直到会话重启,没有任何机制静默重新接管日志。
151
+ - **只有通过句柄获取的会话才会持久化**——仅靠 `ctx.sessions.create` + `session/flush` 不存储任何内容;agent-loop 是生产环境的获取点,测试通过 `create`/`append`/`close` 播种存储。
135
152
  - **无删除或保留接口**——剪枝已存储会话属于带外后端维护。
136
- - **`list()` 无分页且无过滤**——它返回每个已存储会话的 header;适合本地存储,大规模时无索引。
137
- - **合成 closer 是唯一崩溃方案**——后端必须在 load 时合成 `tool/result`/`step/end`/`turn/end` closer;没有继续中断轮次而不先关闭它的部分轮次恢复。
153
+ - **`list()` 无分页且无过滤**——它返回每个已存储会话的快照;适合本地存储,大规模时无索引。
154
+ - **合成 closer 是唯一崩溃方案**——恢复通过写句柄追加 `interruptedTurnClosers`;没有继续中断轮次而不先关闭它的部分轮次恢复。
138
155
 
139
156
  <a id="dev-note"></a>
140
157
  ### 开发备注