@crazx/dsh-session-persistence 0.1.1-rc.2.zw.1 → 0.1.2-alpha.3.zw.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/README.md
5
- README.md: abd879c9c1012b6b67989500fd4e8c4714b1f161
6
- README.zh.md: 15be052681a780365f0d2b33d3ecc75847d21d7a
5
+ README.md: ae44ec5b7603f4b36b0f32f040cf9d685b7c08e9
6
+ README.zh.md: 44112d6aad80b39e169561cce509e2012722f5e7
package/README.md CHANGED
@@ -1,74 +1,121 @@
1
+ ---
2
+ description: "The durable session-storage seam for users and maintainers choosing a persistence backend, resuming sessions, or building a backend against the shared service contract."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-session-persistence
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Session persistence is a capability seam. The abstract `SessionPersistence` service (`ctx.sessionPersistence`) is its Service Definition. It requires a persistence backend to store, reload, and list sessions durably without defining the storage implementation. The seam follows the `dsh-shell` roles ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): this package owns the Service Definition, a sibling package owns the Service Provider, and Consumers inject the service.
10
+ ## Summary
6
11
 
7
- The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here.
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 — and non-replayable metadata (format version, working directory, lineage, seed boundary) travels separately as `SessionHeader`. 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.
8
13
 
9
- ## Service API (`ctx.sessionPersistence`)
14
+ ## Table of Contents
10
15
 
11
- | Method | Contract |
12
- |---|---|
13
- | `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. |
14
- | `supportsRawArtifacts: boolean` | State explicitly whether this backend exposes one verbatim artifact per session. Consumers check this capability before calling `readRaw`; `false` is not session absence. |
15
- | `readRaw(id, signal?): Promise<SessionRawArtifact \| undefined>` | Read a supported backend's own artifact text verbatim, decoded from its physical encoding but never reconstructed from events. `undefined` means only that the requested artifact is absent; an unsupported backend rejects. |
16
- | `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
17
- | `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
18
- | `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. |
19
- | `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. |
20
- | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. |
21
- | `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. |
22
- | `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
23
- | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
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.
29
+
30
+ ### Choosing a backend
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.
33
+
34
+ ### What the service provides
35
+
36
+ With a backend mounted, you can store a session's events durably, reload the stored log, and list what is stored:
37
+
38
+ ```text
39
+ await ctx.sessionPersistence.create(meta) // register a session
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, events } = await ctx.sessionPersistence.load(id) // reload on resume
43
+ const headers = await ctx.sessionPersistence.list() // every stored session
44
+ ```
45
+
46
+ `append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create` remains lazy; 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 view without committing recovery. Consumers that resume from a watermark can read only the events at or past a sequence number, and a session's artifact location (`locate`) resolves without filesystem I/O.
47
+
48
+ ### Resuming and crash recovery
49
+
50
+ Resume is `load` plus session preparation: the stored log comes back with its header lineage intact, so a resumed agent sees the same history and composition. 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.
51
+
52
+ ### Failures and recovery
24
53
 
25
- ## Invariants every backend must honor
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.
26
55
 
27
- - **Append-only; a crashed turn is closed, not truncated.** Flushed events are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (a risk-classified error `tool/result` per unanswered assistant call, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
28
- - **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq.
29
- - **JSON-serializable data.** `append` materializes each direct/replay batch through the shared one-pass lossless-JSON boundary. Live `Session` events are already deep-frozen, but the write coordinator still copies each event into a persistence-owned buffer.
30
- - **Durability.** `append` returns only once the batch is durable.
56
+ -----
31
57
 
32
- ## The write coordinator
58
+ <a id="understand-the-implementation"></a>
59
+ ## Understand the implementation
33
60
 
34
- `PersistenceCoordinator` owns per-id state and serialization, one bounded write controller per live session, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its stateful methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [flush-controller simplification](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md), and [bounded batching decision](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md).
61
+ <details>
62
+ <summary>Implementation internals — click to expand</summary>
35
63
 
36
- Each `session/event` copies its event into the session controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. The configured `writeBatchMaxDelayMs` bounds this intentional wait, not event-loop, initialization, serialized-operation, or backend latency. Events admitted during a write form a new bounded batch. `session/flush` cancels the wait and is a shared quiescence barrier that drains events admitted while it runs. A background failure is logged once, retains the ordered batch, and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately and surfaces a repeated failure.
64
+ This section explains how the seam realizes durable storage and how backends plug in; the observable contract is covered in [Use this package](#use-this-package) and the generated [Cordis API](../../../docs/subsystems/persistence.md#cordis-surface).
37
65
 
38
- Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
66
+ ### Design concept
39
67
 
40
- Backend reads convert the exact supported older records from the same format version before validating current records. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same converted view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current format. These are narrow import exceptions from 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) decisions, not a general v0 migration promise.
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.
41
69
 
42
- When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.
70
+ ### The invariants every backend honors
43
71
 
44
- The side-effect-free `locate`, lightweight `listSnapshots`, and per-id `readStoredRevision` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it.
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.
45
76
 
46
- The `PersistenceBackend<TornMarker>` hooks (the only contract between the coordinator and storage):
77
+ ### Source map
47
78
 
48
- | Hook | Role |
79
+ | File | Role |
49
80
  |---|---|
50
- | `name` | Backend label for the dispose-failure `AggregateError`. |
51
- | `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; `revision` identifies exactly the returned header and events; an opaque `tornMarker` is present iff a torn tail must be truncated. |
52
- | `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `loadStored` and returns `undefined` when the id is absent. |
53
- | `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. |
54
- | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
55
- | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
56
- | `list(signal?)` | List all stored metadata, observing optional cancellation. |
57
- | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
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 |
85
+ | [`src/revision.ts`](src/revision.ts) | The branded opaque revision token |
86
+ | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the coordinator asserts stored/live identity and cwd) |
58
87
 
59
- The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. Every append also re-reads that revision before writing: a log advanced by another harness process sharing the sessions root rejects with a concurrent-writer error instead of interleaving duplicate seqs that the next load would refuse as corruption, and a successful or rolled-back append re-establishes the baseline (a failed re-read after a committed append deletes it, degrading the guard to the in-memory cursor until the next confirmed read). The check is batch-granular — two writers racing inside one batch can still interleave; full exclusion would require a cross-process lock. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
88
+ ### The write path at a glance
60
89
 
61
- ## Metadata and location types
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.
62
91
 
63
- Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`, `origin?`, `delegationDepth?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn.
92
+ ### Stored-record compatibility
64
93
 
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.
95
+
96
+ </details>
97
+ -----
98
+
99
+ <a id="further-exploration"></a>
100
+ ## Further Exploration
101
+
102
+ 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
+
104
+ - [Session persistence subsystem](../../../docs/subsystems/persistence.md) — the full service contract, flush checkpoint, crash recovery, and generated Cordis API.
105
+ - [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.
107
+ - [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
108
+
109
+ -----
110
+
111
+ <a id="model-experience"></a>
65
112
  ## Model Experience
66
113
 
67
114
  ### Resumed conversation history
68
115
 
69
116
  #### What the model sees
70
117
 
71
- This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair marks an assistant request without a durable call as `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, whose text lets the model retry read-only or idempotent work but directs it to verify side effects or ask the user instead of retrying blindly.
118
+ The seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair marks an assistant request without a durable call as `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, whose text lets the model retry read-only or idempotent work but directs it to verify side effects or ask the user instead of retrying blindly.
72
119
 
73
120
  #### Token effect
74
121
 
@@ -80,7 +127,22 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov
80
127
 
81
128
  ## Known Limitations and Deferred Work
82
129
 
130
+ <a id="known-limitations-and-deferred-work"></a>
131
+
132
+
133
+ These limits define where the seam's guarantees stop. They are current package constraints, not a task backlog.
134
+
83
135
  - **No deletion or retention API** — pruning stored sessions is out-of-band backend maintenance.
84
136
  - **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale.
85
- - **Repair-time 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.
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.
86
138
  - **Cross-process writer exclusion is batch-granular** — the append-time revision check refuses a log another process advanced between batches, but two writers racing inside one batch can still interleave; full exclusion would require a cross-process lock.
139
+
140
+ <a id="dev-note"></a>
141
+ ### Dev Note
142
+
143
+ <details>
144
+ <summary>Working context for maintainers — click to expand</summary>
145
+
146
+ None.
147
+
148
+ </details>
package/README.zh.md CHANGED
@@ -1,74 +1,121 @@
1
+ ---
2
+ description: "面向用户与维护者的持久会话存储 seam 说明,用于选择持久化后端、恢复会话,或按共享服务约定构建后端。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-session-persistence
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- 会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-shell` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)):本包负责 Service Definition,同级包负责 Service Provider,Consumer 注入该服务。
10
+ ## 概述
6
11
 
7
- 持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。
12
+ `dsh-session-persistence` 通过后端无关的 `ctx.sessionPersistence` 服务持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型——不可回放的元数据(格式版本、工作目录、血缘、种子边界)作为 `SessionHeader` 单独传输。后端拥有自己的存储,而服务拥有仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。随产品交付的 JSONL provider 用每个 Session 一份产物实现该服务;第三方 provider 可以实现同一约定,而不改变 loop 或模型。
8
13
 
9
- ## 服务 API(`ctx.sessionPersistence`)
14
+ ## 目录
10
15
 
11
- | 方法 | 约定 |
12
- |---|---|
13
- | `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析每个会话的绝对产物目标。没有独立本地产物的后端返回 `undefined`。 |
14
- | `supportsRawArtifacts: boolean` | 明确说明该后端是否为每个会话暴露一份逐字工件。Consumer 在调用 `readRaw` 前检查此能力;`false` 并不表示会话缺失。 |
15
- | `readRaw(id, signal?): Promise<SessionRawArtifact \| undefined>` | 读取受支持后端自身的逐字工件文本;只解码物理编码,绝不从事件重建。`undefined` 仅表示所请求工件缺失;不支持的后端会拒绝。 |
16
- | `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
17
- | `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
18
- | `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose(资源释放)时将未发布 reservation 释放回有界缓存。 |
19
- | `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 |
20
- | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 |
21
- | `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 |
22
- | `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
23
- | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 |
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 挂载一个持久化后端即可让会话持久化。后端把自己注册为 `ctx.sessionPersistence`;组合中的其他部分不变——loop、恢复与回放调用的是同一个服务。
29
+
30
+ ### 选择后端
31
+
32
+ seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端。它把每个 Session 存为一份仅追加 `.jsonl.zstd` 产物,并由 `locate(meta)` 返回绝对路径。第三方后端可以直接实现该服务;必须遵守的[后端约定](#understand-the-implementation)见下文。
33
+
34
+ ### 服务提供什么
35
+
36
+ 挂载后端后,你可以持久存储会话事件、重新加载已存储日志并列出已存储内容:
37
+
38
+ ```text
39
+ await ctx.sessionPersistence.create(meta) // register a session
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, events } = await ctx.sessionPersistence.load(id) // reload on resume
43
+ const headers = await ctx.sessionPersistence.list() // every stored session
44
+ ```
45
+
46
+ `append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create` 保持惰性;只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复;`inspect` 读取同一视图但不提交恢复。从水位恢复的消费方可以只读取该序列号及之后的已存储事件,会话的产物位置(`locate`)不经文件系统 I/O 即可解析。
47
+
48
+ ### 恢复与崩溃恢复
49
+
50
+ 恢复就是 `load` 加会话准备:存储日志连同其头部血缘一起返回,因此恢复后的 agent(智能体)看到相同的历史与组装。中途崩溃的会话重新加载时,其被中断的最终轮次会保留并保持平衡:`load` 为未获回答的调用追加合成 `tool/result` 与 `turn/end {interrupted}` closer,而不是丢弃事件——单个轮次可能很大,而这些事件在崩溃前已持久写入。只有从未完整写入的撕裂尾部碎片会被丢弃。
51
+
52
+ ### 失败与恢复
24
53
 
25
- ## 每个后端必须遵守的不变量
54
+ 当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SESSION_FORMAT_VERSION` 保持 v0,本构建不提供格式迁移路径;更高版本会要求操作者升级 harness。解码器只接受下文点名的有限同版本记录变体。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。对仍绑定到活动会话的 id 执行 `load`,会先刷新其快照并在轮次开放时拒绝;冷 load 应用恢复。
26
55
 
27
- - **仅追加;崩溃轮次会被关闭,而非截断。** 已 flush 事件绝不重写。崩溃可留下未关闭最终轮次,其事件真实且可能很大;`load` 保留它们,并持久追加合成 closer(为每个未获回答的 assistant 调用添加一个带风险分类错误的 `tool/result`,再添加 `step/end?`+`turn/end {interrupted}`),以平衡日志,并确保重新载入的历史仍是有效的提供方 transcript(文本记录)。只丢弃从未完整写入的撕裂尾部碎片。
28
- - **连续 seq。**`load` 拒绝日志中间的 `seq` 缺口/解析错误;`append` 的第一个 `seq` 必须等于已存储 next-seq。
29
- - **JSON 可序列化数据。**`append` 通过共享单遍无损 JSON 边界实体化每个直接/回放批次。活动 `Session` 事件已深度冻结,但写入协调器仍将每个事件复制到持久化自有缓冲区。
30
- - **持久性。**`append` 只在批次持久后返回。
56
+ -----
31
57
 
32
- ## 写入协调器
58
+ <a id="understand-the-implementation"></a>
59
+ ## 理解实现
33
60
 
34
- `PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的有界写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)、[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md)和[有界批处理决策](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md)。
61
+ <details>
62
+ <summary>实现细节——点击展开</summary>
35
63
 
36
- 每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件。后台写入失败只记录一次日志,保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并在失败再次发生时向调用方暴露失败。
64
+ 本节说明 seam 如何实现持久存储以及后端如何接入;可观察约定见[使用本包](#use-this-package)与生成的 [Cordis API](../../../docs/subsystems/persistence.zh.md#cordis-surface)。
37
65
 
38
- 崩溃修复只适用于冷状态。对于已有活动会话的 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;活动会话中开放的轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR(热模块替换)接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
66
+ ### 设计理念
39
67
 
40
- 后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.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)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
68
+ 本包是能力 seam 的 Service Definition,分两半。抽象的 `SessionPersistence` 服务是公开约定;`PersistenceCoordinator` 为缓冲、串行化、物化、修复、接管与完全停稳的 dispose 提供后端无关编排。JSONL provider 实现存储读取、追加、修复与列出所需的小型持久原语;第三方 provider 可以复用同一 coordinator,也可以直接实现该服务。
41
69
 
42
- 活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。
70
+ ### 每个后端必须遵守的不变量
43
71
 
44
- 无副作用 `locate`、轻量 `listSnapshots` 和按 id 查询的 `readStoredRevision` 仍由后端负责,因为它们描述存储拓扑和修订身份,而非写入编排。`listSnapshots(signal?)` 将调用方传入的同一个信号传给后端发现流程,使观察者可在不脱离该工作的情况下取消。
72
+ - **仅追加;崩溃轮次会被关闭,而非截断。** 已 flush 事件绝不重写;`load` 保留中断的最终轮次并持久追加合成 closer。
73
+ - **连续 `seq`。** 日志中间的缺口会被拒绝;`append` 的第一个 `seq` 必须等于已存储 next-seq。
74
+ - **无损 JSON 数据。** 批次经过共享单遍无损 JSON 边界;无法序列化的载荷在 append 处被拒绝。
75
+ - **持久性。** `append` 只在批次持久后返回。
45
76
 
46
- `PersistenceBackend<TornMarker>` 钩子(协调器与存储之间的唯一约定):
77
+ ### 源码地图
47
78
 
48
- | 钩子 | 职责 |
79
+ | 文件 | 职责 |
49
80
  |---|---|
50
- | `name` | dispose 失败 `AggregateError` 的后端标签。 |
51
- | `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于恢复/加载、非修改式 inspect、活动会话接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;`revision` 精确标识返回的 header 和事件;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 |
52
- | `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定修订值。它使用与 `loadStored` 相同的修订值表示;id 不存在时返回 `undefined`。 |
53
- | `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非修改式、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 |
54
- | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 |
55
- | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和活动会话接管(仅截断)使用。 |
56
- | `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 |
57
- | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待其完成。 |
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 准备结果 |
85
+ | [`src/revision.ts`](src/revision.ts) | 带品牌类型的不透明修订值 token |
86
+ | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;协调器断言存储/活动身份与 cwd) |
58
87
 
59
- 协调器断言已存储 id,并在修复或活动会话接管前比较已存储/活动会话 cwd。其 `inspect()` 路径取得新鲜后端值的所有权,只验证和冻结一次,并在不调用 `commitRepair` 的情况下最多保留配置数量的未发布 Session。只有保留源的修订值仍等于 `readStoredRevision` 时,系统才会复用或修复它;否则协调器会重新读取。每次 append 在写入前也会重读该修订值:若持久日志被共享同一 sessions 根目录的另一个 harness 进程推进,写入会以并发写者错误大声拒绝,而不是交错出下一次加载会判为损坏的重复 seq;成功或已回滚的 append 会重新建立基线(已提交 append 后的重读失败会删除基线,把防护降级为进程内游标,直到下一次确认读取)。该校验以批次为粒度——在同一批次内竞速的两个写者仍可能交错;完全排他需要跨进程锁。持久日志在一次读取与复核往返内保持不变时,修订值重试才能收敛;持续的外部写入可能延迟 `load`、`inspect` 或 `prepare`。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同的非修改式检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)。
88
+ ### 写入路径概览
60
89
 
61
- ## 元数据与位置类型
90
+ 每个 `session/event` 把事件复制到其会话的 controller。第一个待处理事件开启固定批处理窗口;后续事件加入但不重置截止时间。窗口到期后启动一次持久追加;该次写入期间接纳的事件形成另一个独立有界的后续批次。`session/flush` 取消等待并排空至完全停稳,因此 loop 在下一轮次前把它用作排序与错误观察检查点。被拒绝的后台写入保留其事件并暂停自动重试;新事件开启新窗口,而显式 flush 或后端拆卸会立即重试。
62
91
 
63
- 从 `dsh-session` 重新导出:`SessionHeader`(不可变会话元数据:`version`、`id`、`createdAt`、`cwd?`、`parentSession?`、`seedLength?`、`origin?`、`delegationDepth?`)。`SessionLocation` 是 `{ readonly kind: string; readonly path: string }`;其 path 是绝对后端目标,不证明产物已存在或包含未 flush 轮次。
92
+ ### 存储记录兼容
64
93
 
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)笔记规定这些有限例外;它们不构成通用格式迁移承诺。
95
+
96
+ </details>
97
+ -----
98
+
99
+ <a id="further-exploration"></a>
100
+ ## 进一步探索
101
+
102
+ 当包级约定不够用时阅读以下页面。它们从共享持久性模型逐步进入随产品交付的后端与决策证据。
103
+
104
+ - [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——完整服务约定、flush 检查点、崩溃恢复与生成的 Cordis API。
105
+ - [JSONL 持久化后端](../session-persistence-jsonl/README.zh.md)——随产品交付、按会话存储文件的后端。
106
+ - [会话检查点策略](../session-checkpoint-policy/README.zh.md)——在语义边界上经由本服务刷新的插件。
107
+ - [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
108
+
109
+ -----
110
+
111
+ <a id="model-experience"></a>
65
112
  ## 模型体验
66
113
 
67
114
  ### 恢复的对话历史
68
115
 
69
- #### 模型所见
116
+ #### 模型看到什么
70
117
 
71
- 该 seam 不添加提示词或 schema。恢复会将已存储的表层事件还原为消息历史;已存储请求 header 重建较早调用,新 loop 则为下一次请求组合当前系统提示词、工具和会话前缀。崩溃修复将没有持久调用的 assistant 请求标记为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,其文本允许模型重试只读或幂等工作,但要求验证副作用或询问用户,而不是盲目重试。
118
+ seam 不添加提示词或 schema。恢复会将已存储的表层事件还原为消息历史;已存储请求 header 重建较早调用,新 loop 则为下一次请求组合当前系统提示词、工具与会话前缀。崩溃修复将没有持久调用的 assistant 请求标记为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,其文本允许模型重试只读或幂等工作,但要求验证副作用或询问用户,而不是盲目重试。
72
119
 
73
120
  #### Token 影响
74
121
 
@@ -76,11 +123,26 @@
76
123
 
77
124
  #### KV Cache 影响
78
125
 
79
- 持久化不修改当前请求前缀。只有当重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果仅追加,不重写较早历史。
126
+ 持久化不修改实时请求前缀。只有当重建历史、当前 envelope 与模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果仅追加,不重写较早历史。
127
+
128
+ ## 已知限制与延期工作
129
+
130
+ <a id="known-limitations-and-deferred-work"></a>
131
+
132
+
133
+ 这些限制界定 seam 保证的终点。它们是当前包约束,不是任务积压。
134
+
135
+ - **无删除或保留接口**——剪枝已存储会话属于带外后端维护。
136
+ - **`list()` 无分页且无过滤**——它返回每个已存储会话的 header;适合本地存储,大规模时无索引。
137
+ - **合成 closer 是唯一崩溃方案**——后端必须在 load 时合成 `tool/result`/`step/end`/`turn/end` closer;没有继续中断轮次而不先关闭它的部分轮次恢复。
138
+ - **跨进程写者排他为批次粒度**——append 时的修订值校验能拒绝在两批之间被其他进程推进的日志,但在同一批次内竞速的两个写者仍可能交错;完全排他需要跨进程锁。
139
+
140
+ <a id="dev-note"></a>
141
+ ### 开发备注
142
+
143
+ <details>
144
+ <summary>维护者的工作上下文——点击展开</summary>
80
145
 
81
- ## 已知限制与暂缓事项
146
+ 无。
82
147
 
83
- - **无删除或保留接口**:剪枝已存储会话是带外后端维护。
84
- - **`list()` 无分页且无过滤**:它返回每个已存储会话的 header;适合本地存储,大规模时无索引。
85
- - **修复时合成 closer 是唯一崩溃方案**:后端必须在 load 时合成 `tool/result`/`step/end`/`turn/end` closer;没有继续中断轮次而不先关闭它的部分轮次恢复。
86
- - **跨进程写者排他为批次粒度**:append 时的修订值校验能拒绝在两批之间被其他进程推进的日志,但在同一批次内竞速的两个写者仍可能交错;完全排他需要跨进程锁。
148
+ </details>
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
- import { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionPreparation, adoptSessionEvent, interruptedTurnClosers, snapshotJsonValue, snapshotSessionEvent } from "@deepseek-ai/dsh-session";
2
+ import { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionPreparation, adoptSessionEvent, interruptedTurnClosers, snapshotSessionEvent } from "@deepseek-ai/dsh-session";
3
3
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
4
+ import { snapshotJsonValue } from "@deepseek-ai/dsh-util-values";
4
5
  //#region lib/types/revision.js
5
6
  /** Opaque revision identity for lightweight persistence observations. */
6
7
  /**
@@ -12,6 +13,19 @@ function SessionPersistenceRevision(value) {
12
13
  return value;
13
14
  }
14
15
  //#endregion
16
+ //#region lib/types/errors.js
17
+ /** Stable failures exposed by the session-persistence service. */
18
+ /** The requested Session identity has no materialized durable log. */
19
+ var SessionPersistenceNotFoundError = class extends Error {
20
+ sessionId;
21
+ /** @param sessionId - absent durable Session identity. */
22
+ constructor(sessionId) {
23
+ super(`session "${sessionId}" not found`);
24
+ this.sessionId = sessionId;
25
+ this.name = "SessionPersistenceNotFoundError";
26
+ }
27
+ };
28
+ //#endregion
15
29
  //#region lib/types/preparations.js
16
30
  /**
17
31
  * Bounded sharing and exclusive reservation of unpublished Sessions.
@@ -47,6 +61,45 @@ var SessionPreparations = class {
47
61
  return source;
48
62
  }
49
63
  /**
64
+ * Borrow one prepared source and pin its ready entry against LRU eviction.
65
+ * @param id - session identity.
66
+ * @param load - cold loader used when no entry exists.
67
+ * @param signal - optional cancellation signal while waiting.
68
+ * @returns a caller-owned observation lease.
69
+ */
70
+ async borrow(id, load, signal) {
71
+ const entry = this.entryFor(id, load);
72
+ const pinned = this.entries.get(id) === entry;
73
+ if (pinned) entry.pins += 1;
74
+ let loaded;
75
+ try {
76
+ loaded = signal === void 0 ? await entry.result : await observeQueuedAbort(entry.result, signal);
77
+ } catch (error) {
78
+ if (pinned && this.entries.get(id) === entry) {
79
+ entry.pins -= 1;
80
+ if (entry.phase === "ready") this.touch(entry);
81
+ }
82
+ throw error;
83
+ }
84
+ const source = entry.source ?? loaded;
85
+ if (this.entries.get(id) !== entry) return {
86
+ source,
87
+ [Symbol.dispose]: () => {}
88
+ };
89
+ if (entry.phase === "ready") this.touch(entry);
90
+ let released = false;
91
+ return {
92
+ source,
93
+ [Symbol.dispose]: () => {
94
+ if (released) return;
95
+ released = true;
96
+ if (this.entries.get(id) !== entry) return;
97
+ entry.pins -= 1;
98
+ if (entry.phase === "ready") this.touch(entry);
99
+ }
100
+ };
101
+ }
102
+ /**
50
103
  * Reserve one ready source after committing its pending durable repair.
51
104
  * @param id - session identity.
52
105
  * @param load - cold loader used when no entry exists.
@@ -189,7 +242,8 @@ var SessionPreparations = class {
189
242
  const entry = {
190
243
  id,
191
244
  result: deferred.promise,
192
- phase: "loading"
245
+ phase: "loading",
246
+ pins: 0
193
247
  };
194
248
  this.entries.set(id, entry);
195
249
  let loading;
@@ -236,7 +290,7 @@ var SessionPreparations = class {
236
290
  for (const candidate of this.entries.values()) if (candidate.phase === "ready") readyCount += 1;
237
291
  if (readyCount <= this.capacity) return;
238
292
  for (const [id, candidate] of this.entries) {
239
- if (candidate.phase !== "ready") continue;
293
+ if (candidate.phase !== "ready" || candidate.pins > 0) continue;
240
294
  this.entries.delete(id);
241
295
  return;
242
296
  }
@@ -472,7 +526,7 @@ var SessionFormatUnsupportedError = class extends Error {
472
526
  * Direction-aware refusal text for a stored session whose format version this
473
527
  * build does not read. Shared by the coordinator's load-time check and by
474
528
  * backends that must refuse BEFORE decoding version-dependent structure (a
475
- * future format may not satisfy today's structural checks at all, and the
529
+ * future format may not satisfy this build's structural checks at all, and the
476
530
  * user must see "upgrade the harness", never "corrupt").
477
531
  * @param id - the stored session id, for message context.
478
532
  * @param version - the stored format version.
@@ -805,6 +859,23 @@ var PersistenceCoordinator = class {
805
859
  if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) return Promise.reject(/* @__PURE__ */ new TypeError("session metadata createdAt must be a non-negative safe integer"));
806
860
  return this.serialize(snapshot.id, () => this.createCore(snapshot));
807
861
  }
862
+ /**
863
+ * Materialize one exact live session without inventing a session event.
864
+ * @param session - live session already registered through the write path.
865
+ */
866
+ async ensureMaterialized(session) {
867
+ await this.flush(session);
868
+ await this.serialize(session.id, async () => {
869
+ const state = this.states.get(session.id);
870
+ /* v8 ignore next -- successful live flush always initializes the exact session state. */
871
+ if (state === void 0) throw new Error(`session "${session.id}" is not registered for persistence`);
872
+ if (state.materialized) return;
873
+ if (this.backend.materializeHeader === void 0) throw new Error("session persistence backend cannot materialize an empty session");
874
+ await this.backend.materializeHeader(state.meta);
875
+ state.materialized = true;
876
+ this.preparations.invalidate(session.id);
877
+ });
878
+ }
808
879
  async createCore(meta) {
809
880
  if (this.states.has(meta.id) || this.preparations.has(meta.id)) throw new Error(`session "${meta.id}" already exists in this backend`);
810
881
  if (await this.backend.loadStored(meta.id) !== void 0) throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`);
@@ -945,6 +1016,67 @@ var PersistenceCoordinator = class {
945
1016
  }
946
1017
  }
947
1018
  /**
1019
+ * Borrow one exact logical view while pinning its reusable prepared Session.
1020
+ * @param id - persisted session to observe.
1021
+ * @param signal - optional cancellation for preparation work.
1022
+ * @returns a disposable observation retaining the prepared source.
1023
+ */
1024
+ async borrowSession(id, signal) {
1025
+ for (;;) {
1026
+ signal?.throwIfAborted();
1027
+ if (this.retirements.has(id)) await this.waitForRetirement(id, signal);
1028
+ const live = this.ctx.sessions.get(id);
1029
+ if (live !== void 0) return {
1030
+ source: "live",
1031
+ inspection: this.inspectLive(live),
1032
+ [Symbol.dispose]: () => {}
1033
+ };
1034
+ const observation = await this.preparations.borrow(id, () => this.serialize(id, () => this.prepareCore(id)), signal);
1035
+ const source = observation.source;
1036
+ try {
1037
+ const attached = this.ctx.sessions.get(id);
1038
+ if (attached !== void 0) {
1039
+ observation[Symbol.dispose]();
1040
+ return {
1041
+ source: "live",
1042
+ inspection: this.inspectLive(attached),
1043
+ [Symbol.dispose]: () => {}
1044
+ };
1045
+ }
1046
+ const current = await this.serialize(id, () => this.isPreparedSourceCurrent(source, signal), signal);
1047
+ const published = this.ctx.sessions.get(id);
1048
+ if (published !== void 0) {
1049
+ observation[Symbol.dispose]();
1050
+ return {
1051
+ source: "live",
1052
+ inspection: this.inspectLive(published),
1053
+ [Symbol.dispose]: () => {}
1054
+ };
1055
+ }
1056
+ if (current || this.preparations.discardReady(id, source) === "retained") return {
1057
+ source: "prepared",
1058
+ inspection: source.inspection,
1059
+ revision: source.revision,
1060
+ preparedSession: source.session,
1061
+ [Symbol.dispose]: () => {
1062
+ observation[Symbol.dispose]();
1063
+ }
1064
+ };
1065
+ } catch (error) {
1066
+ observation[Symbol.dispose]();
1067
+ signal?.throwIfAborted();
1068
+ const attached = this.ctx.sessions.get(id);
1069
+ if (attached !== void 0) return {
1070
+ source: "live",
1071
+ inspection: this.inspectLive(attached),
1072
+ [Symbol.dispose]: () => {}
1073
+ };
1074
+ throw error;
1075
+ }
1076
+ observation[Symbol.dispose]();
1077
+ }
1078
+ }
1079
+ /**
948
1080
  * Read the stored events from `fromSeq` onward, detached and non-mutating
949
1081
  * (the read-from-seq primitive behind the service's `readFrom`). Runs on
950
1082
  * the same per-id chain as writes; a backend with the seek-capable
@@ -971,7 +1103,7 @@ var PersistenceCoordinator = class {
971
1103
  throw error;
972
1104
  }
973
1105
  signal?.throwIfAborted();
974
- if (suffix === void 0) throw new Error(`session "${id}" not found`);
1106
+ if (suffix === void 0) throw new SessionPersistenceNotFoundError(id);
975
1107
  this.assertStoredId(id, suffix.meta);
976
1108
  this.assertVersion(suffix.meta);
977
1109
  if (suffix.events.some(needsLegacyPrefix)) {
@@ -999,7 +1131,7 @@ var PersistenceCoordinator = class {
999
1131
  signal?.throwIfAborted();
1000
1132
  const stored = await this.backend.loadStored(id, signal);
1001
1133
  signal?.throwIfAborted();
1002
- if (stored === void 0) throw new Error(`session "${id}" not found`);
1134
+ if (stored === void 0) throw new SessionPersistenceNotFoundError(id);
1003
1135
  this.assertStoredId(id, stored.meta);
1004
1136
  this.assertVersion(stored.meta);
1005
1137
  const events = snapshotStoredEvents(stored.events, id);
@@ -1012,7 +1144,7 @@ var PersistenceCoordinator = class {
1012
1144
  /** Read, repair in memory, validate, and freeze one cold source once. */
1013
1145
  async prepareCore(id) {
1014
1146
  const stored = await this.backend.loadStored(id);
1015
- if (stored === void 0) throw new Error(`session "${id}" not found`);
1147
+ if (stored === void 0) throw new SessionPersistenceNotFoundError(id);
1016
1148
  try {
1017
1149
  const { meta, events, revision, tornMarker } = stored;
1018
1150
  this.assertStoredId(id, meta);
@@ -1079,7 +1211,7 @@ var PersistenceCoordinator = class {
1079
1211
  const state = this.states.get(session.id);
1080
1212
  /* v8 ignore next -- successful flush always publishes this live session's durable state */
1081
1213
  if (state === void 0) throw new Error(`session "${session.id}" lost persistence state during load`);
1082
- if (events.length === 0) throw new Error(`session "${session.id}" not found`);
1214
+ if (events.length === 0 && !state.materialized) throw new Error(`session "${session.id}" not found`);
1083
1215
  if (interruptedTurnClosers(events).length > 0) throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`);
1084
1216
  return Object.freeze({
1085
1217
  meta: state.meta,
@@ -1401,6 +1533,15 @@ var SessionPersistence = class extends Service {
1401
1533
  return Promise.reject(/* @__PURE__ */ new Error("this session persistence backend does not expose raw artifacts"));
1402
1534
  }
1403
1535
  /**
1536
+ * Ensure a live session has a durable header even when it has no events.
1537
+ * Ordinary sessions remain lazily materialized; lifecycle frontends call
1538
+ * this only when an empty session itself is a durable resumable resource.
1539
+ * @param _session - exact live session whose registered header is materialized.
1540
+ */
1541
+ ensureMaterialized(_session) {
1542
+ return Promise.reject(/* @__PURE__ */ new Error("this session persistence backend cannot materialize an empty session"));
1543
+ }
1544
+ /**
1404
1545
  * Prepare the exact unpublished Session used by resume. Implementations may
1405
1546
  * reuse object graphs retained by an earlier {@link inspect} after confirming
1406
1547
  * their durable revision is still current; disposal releases an unpublished
@@ -1424,4 +1565,4 @@ var SessionPersistence = class extends Service {
1424
1565
  }
1425
1566
  };
1426
1567
  //#endregion
1427
- export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, SessionFormatUnsupportedError, SessionPersistence, SessionPersistence as default, SessionPersistenceCorruptionError, SessionPersistenceRevision, sessionFormatVersionRefusal };
1568
+ export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, SessionFormatUnsupportedError, SessionPersistence, SessionPersistence as default, SessionPersistenceCorruptionError, SessionPersistenceNotFoundError, SessionPersistenceRevision, sessionFormatVersionRefusal };
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import { Context } from '@deepseek-ai/cordis';
8
8
  import { SessionPreparation } from '@deepseek-ai/dsh-session';
9
- import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session';
10
- import type { SessionInspection, SessionLocation } from './index.ts';
9
+ import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session';
10
+ import type { BorrowedSessionSource, SessionInspection, SessionLocation } from './index.ts';
11
11
  import type { SessionPersistenceRevision } from './revision.ts';
12
12
  /** Default number of detached session preparations retained by a coordinator. */
13
13
  export declare const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5;
@@ -44,7 +44,7 @@ export declare class SessionFormatUnsupportedError extends Error {
44
44
  * Direction-aware refusal text for a stored session whose format version this
45
45
  * build does not read. Shared by the coordinator's load-time check and by
46
46
  * backends that must refuse BEFORE decoding version-dependent structure (a
47
- * future format may not satisfy today's structural checks at all, and the
47
+ * future format may not satisfy this build's structural checks at all, and the
48
48
  * user must see "upgrade the harness", never "corrupt").
49
49
  * @param id - the stored session id, for message context.
50
50
  * @param version - the stored format version.
@@ -122,8 +122,8 @@ export interface PersistenceBackend<TornMarker = unknown> {
122
122
  /**
123
123
  * Optional seek-capable suffix read behind the service's `readFrom`: return
124
124
  * the header plus the stored events with `seq >= fromSeq` without reading
125
- * the whole log. A backend whose medium can address events by seq (SQLite)
126
- * implements this so `readFrom` scales with the suffix; sequential backends
125
+ * the whole log. A backend whose medium can address events by seq implements
126
+ * this so `readFrom` scales with the suffix; sequential backends
127
127
  * omit it and the coordinator falls back to {@link loadStored} plus a
128
128
  * forward skip. Non-mutating (no truncation, no closers). Validation of the
129
129
  * region strictly below `fromSeq` is limited to seq contiguity — the
@@ -142,6 +142,8 @@ export interface PersistenceBackend<TornMarker = unknown> {
142
142
  * @param signal - optional cancellation for backend read work.
143
143
  */
144
144
  loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined>;
145
+ /** Durably create an empty header-only session artifact. */
146
+ materializeHeader?(meta: SessionHeader): Promise<void>;
145
147
  /**
146
148
  * Durably append a CONTIGUOUS batch, lazily materializing the session first
147
149
  * when `!isMaterialized`. The materialize-write and the first event batch MUST
@@ -215,6 +217,11 @@ export declare class PersistenceCoordinator<TornMarker = unknown> {
215
217
  * @param meta - header to snapshot; duplicate tracked or persisted ids reject.
216
218
  */
217
219
  create(meta: SessionHeader): Promise<void>;
220
+ /**
221
+ * Materialize one exact live session without inventing a session event.
222
+ * @param session - live session already registered through the write path.
223
+ */
224
+ ensureMaterialized(session: Session): Promise<void>;
218
225
  private createCore;
219
226
  /**
220
227
  * Durably persist a batch of events. Honors the append-only and contiguous-seq
@@ -259,6 +266,13 @@ export declare class PersistenceCoordinator<TornMarker = unknown> {
259
266
  * @returns immutable prepared metadata and events; a live view may have an open turn.
260
267
  */
261
268
  inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>;
269
+ /**
270
+ * Borrow one exact logical view while pinning its reusable prepared Session.
271
+ * @param id - persisted session to observe.
272
+ * @param signal - optional cancellation for preparation work.
273
+ * @returns a disposable observation retaining the prepared source.
274
+ */
275
+ borrowSession(id: SessionId, signal?: AbortSignal): Promise<BorrowedSessionSource>;
262
276
  /**
263
277
  * Read the stored events from `fromSeq` onward, detached and non-mutating
264
278
  * (the read-from-seq primitive behind the service's `readFrom`). Runs on
@@ -0,0 +1,9 @@
1
+ /** Stable failures exposed by the session-persistence service. */
2
+ import type { SessionId } from '@deepseek-ai/dsh-session';
3
+ /** The requested Session identity has no materialized durable log. */
4
+ export declare class SessionPersistenceNotFoundError extends Error {
5
+ readonly sessionId: SessionId;
6
+ /** @param sessionId - absent durable Session identity. */
7
+ constructor(sessionId: SessionId);
8
+ }
9
+ //# sourceMappingURL=errors.d.ts.map
@@ -6,10 +6,11 @@
6
6
  */
7
7
  import { Context, Service } from '@deepseek-ai/cordis';
8
8
  import { SessionPreparation } from '@deepseek-ai/dsh-session';
9
- import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session';
9
+ import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session';
10
10
  import type { SessionPersistenceRevision } from './revision.ts';
11
11
  export type { SessionHeader } from '@deepseek-ai/dsh-session';
12
12
  export { SessionPersistenceRevision } from './revision.ts';
13
+ export { SessionPersistenceNotFoundError } from './errors.ts';
13
14
  /** Lightweight immutable source identity returned without loading a full log. */
14
15
  export interface SessionPersistenceSnapshot {
15
16
  /** Detached metadata for one materialized session. */
@@ -24,6 +25,22 @@ export interface SessionInspection {
24
25
  /** Validated contiguous logical event log. */
25
26
  readonly events: readonly SessionEvent[];
26
27
  }
28
+ /** A borrowed exact Session source returned from a cold materialization or concurrent live owner. */
29
+ export type BorrowedSessionSource = Disposable & ({
30
+ /** A reusable unpublished Session is pinned until this observation is disposed. */
31
+ readonly source: 'prepared';
32
+ /** Immutable header and logical event prefix observed together. */
33
+ readonly inspection: SessionInspection;
34
+ /** Durable revision represented by the prepared source. */
35
+ readonly revision: SessionPersistenceRevision;
36
+ /** Exact unpublished Session retained for a later {@link prepare}. */
37
+ readonly preparedSession: Session;
38
+ } | {
39
+ /** A live Session won source resolution while the persistence read was starting. */
40
+ readonly source: 'live';
41
+ /** Immutable live header and event prefix observed together. */
42
+ readonly inspection: SessionInspection;
43
+ });
27
44
  /** A backend's own raw artifact text for one session, verbatim. */
28
45
  export interface SessionRawArtifact {
29
46
  /** The session header parsed from the artifact's own first line. */
@@ -61,8 +78,8 @@ export declare abstract class SessionPersistence extends Service {
61
78
  constructor(ctx: Context);
62
79
  /**
63
80
  * Resolve this backend's independent local artifact for a session without
64
- * reading, creating, flushing, or otherwise materializing it. Backends such
65
- * as SQLite that do not own one artifact per session return `undefined`.
81
+ * reading, creating, flushing, or otherwise materializing it. A backend
82
+ * that does not own one artifact per Session returns `undefined`.
66
83
  * @param meta - the immutable session header whose artifact is requested.
67
84
  * @returns the backend-specific absolute location, when one exists.
68
85
  */
@@ -96,6 +113,13 @@ export declare abstract class SessionPersistence extends Service {
96
113
  * @param meta - the immutable header (id, version, cwd, lineage) to record.
97
114
  */
98
115
  abstract create(meta: SessionHeader): Promise<void>;
116
+ /**
117
+ * Ensure a live session has a durable header even when it has no events.
118
+ * Ordinary sessions remain lazily materialized; lifecycle frontends call
119
+ * this only when an empty session itself is a durable resumable resource.
120
+ * @param _session - exact live session whose registered header is materialized.
121
+ */
122
+ ensureMaterialized(_session: Session): Promise<void>;
99
123
  /**
100
124
  * Durably persist a batch of events. Honors the append-only and contiguous-
101
125
  * seq contracts: the first event's `seq` MUST equal the stored next-seq
@@ -149,6 +173,16 @@ export declare abstract class SessionPersistence extends Service {
149
173
  * @returns the validated header and current logical event log.
150
174
  */
151
175
  abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>;
176
+ /**
177
+ * Borrow one exact inspection while retaining any reusable prepared source.
178
+ * A cold observation must pin the exact prepared Session that a later
179
+ * {@link prepare} reserves. Implementations must not degrade this operation
180
+ * to a detached {@link inspect} result.
181
+ * @param id - persisted session to observe.
182
+ * @param signal - optional cancellation for preparation work.
183
+ * @returns a disposable immutable observation.
184
+ */
185
+ abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise<BorrowedSessionSource>;
152
186
  /**
153
187
  * Read the stored events from `fromSeq` onward — the read-from-seq
154
188
  * primitive for read models that resume from a watermark (e.g. a persisted
@@ -158,10 +192,10 @@ export declare abstract class SessionPersistence extends Service {
158
192
  * publication. Only events from the valid contiguous stored prefix are
159
193
  * returned, so a torn fragment never reaches the caller. `fromSeq` at or
160
194
  * beyond the stored prefix returns an empty event list (never an error).
161
- * Backends whose medium can seek by seq
162
- * (SQLite) read only the suffix; sequential media (JSONL, both encodings)
163
- * still parse the whole artifact and skip forward — the primitive bounds
164
- * what is RETURNED and refolded, not every backend's physical read.
195
+ * A backend whose medium can seek by seq may read only the suffix;
196
+ * sequential media such as JSONL still parse the whole artifact and skip
197
+ * forward. The primitive bounds what is returned and refolded, not every
198
+ * backend's physical read.
165
199
  * @param id - the persisted session to read.
166
200
  * @param fromSeq - first event seq to include; a non-negative safe integer.
167
201
  * @param signal - optional cancellation for queued and backend read work.
@@ -15,6 +15,12 @@ interface PreparationEntry<Source, CommitState> {
15
15
  reservation?: SessionPreparationReservation<Source, CommitState>;
16
16
  reservationSettled?: Promise<void>;
17
17
  settleReservation?: () => void;
18
+ pins: number;
19
+ }
20
+ /** A borrowed prepared source that remains outside ready-entry eviction until released. */
21
+ export interface PreparationLease<Source> extends Disposable {
22
+ /** Shared immutable prepared source. */
23
+ readonly source: Source;
18
24
  }
19
25
  /** One exclusively held prepared source and its committed persistence state. */
20
26
  export interface SessionPreparationReservation<Source, CommitState> {
@@ -41,6 +47,14 @@ export declare class SessionPreparations<Source extends PreparedSource, CommitSt
41
47
  * @returns the shared prepared source.
42
48
  */
43
49
  inspect(id: SessionId, load: () => Promise<Source>, signal?: AbortSignal): Promise<Source>;
50
+ /**
51
+ * Borrow one prepared source and pin its ready entry against LRU eviction.
52
+ * @param id - session identity.
53
+ * @param load - cold loader used when no entry exists.
54
+ * @param signal - optional cancellation signal while waiting.
55
+ * @returns a caller-owned observation lease.
56
+ */
57
+ borrow(id: SessionId, load: () => Promise<Source>, signal?: AbortSignal): Promise<PreparationLease<Source>>;
44
58
  /**
45
59
  * Reserve one ready source after committing its pending durable repair.
46
60
  * @param id - session identity.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@crazx/dsh-session-persistence",
3
3
  "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness",
4
- "version": "0.1.1-rc.2.zw.1",
4
+ "version": "0.1.2-alpha.3.zw.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,18 +32,21 @@
32
32
  ],
33
33
  "license": "MIT",
34
34
  "peerDependencies": {
35
- "@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
36
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
37
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
38
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
39
- "@deepseek-ai/cordis": "^4.0.1"
35
+ "@deepseek-ai/cordis": "^4.0.2",
36
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.3",
37
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3",
38
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.3",
39
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.3"
40
40
  },
41
41
  "devDependencies": {
42
- "@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
43
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@deepseek-ai/dsh-scope": "^0.1.1-rc.2",
45
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
46
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
47
- "@deepseek-ai/cordis": "^4.0.1"
42
+ "@deepseek-ai/cordis": "^4.0.2",
43
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.3",
44
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3",
45
+ "@deepseek-ai/dsh-scope": "^0.1.2-alpha.3",
46
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.3",
47
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.3"
48
+ },
49
+ "dependencies": {
50
+ "@deepseek-ai/dsh-util-values": "^0.1.2-alpha.3"
48
51
  }
49
52
  }