refactor(session): fold the session family into packages/session/
git mv the 12 packages from session-persistence/, session-projection/, session-title/, and telemetry/ into one session/ group per the regrouping RFC; merge the four group READMEs into one bilingual triplet; rewrite the group segment in tsconfig references (intra-group references shorten to ../<pkg>), tsconfig.base.json paths/globs, knip.json keys, vitest include, gate scripts, and authored doc/note citations; regenerate module graph, doc graphs, catalogs, and the lockfile importer keys. No npm names change. Full unit suite: 8779 passed; the 18 reported failures reproduce as env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify timeouts under parallel load) — each passes in isolation with NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
6
packages/session/session-persistence/README.i18n.yaml
Normal file
6
packages/session/session-persistence/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence/README.md
|
||||
README.md: 89c7cd5ebaff6f9ce9df9b60a50121dff4ddeeb5
|
||||
README.zh.md: 1ef7eb6c6c507167f61bb5df7c4ce8183aac5d0d
|
||||
83
packages/session/session-persistence/README.md
Normal file
83
packages/session/session-persistence/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# @deepseek-ai/dsh-session-persistence
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface.
|
||||
|
||||
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.
|
||||
|
||||
## Service API (`ctx.sessionPersistence`)
|
||||
|
||||
| Method | Contract |
|
||||
|---|---|
|
||||
| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. |
|
||||
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after supported same-version shape upgrades and commit 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, malformed shapes, and unknown `version` reject. |
|
||||
| `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. |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The detached physical-suffix primitive: 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 a supported old shape requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that fold only the tail past a watermark. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
|
||||
## Invariants every backend must honor
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Durability.** `append` returns only once the batch is durable.
|
||||
|
||||
## The write coordinator
|
||||
|
||||
`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).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Backend reads normalize the exact supported same-version shapes before current-shape validation. 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 unavailable cancellation provenance. The coordinator uses the same normalized 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 shape. 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
|
||||
|
||||
| Hook | Role |
|
||||
|---|---|
|
||||
| `name` | Backend label for the dispose-failure `AggregateError`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
|
||||
| `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). |
|
||||
| `list(signal?)` | List all stored metadata, observing optional cancellation. |
|
||||
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
|
||||
|
||||
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. This freshness check does not add cross-process writer exclusion. 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).
|
||||
|
||||
## Metadata and location types
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Resumed conversation history
|
||||
|
||||
#### What the model sees
|
||||
|
||||
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.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens during ordinary persistence. Resume restores retained history cost and pays the current request envelope normally; each repaired call adds the quoted retained error text.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Persistence does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append without rewriting earlier history.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No deletion or retention surface** — pruning stored sessions is out-of-band backend maintenance.
|
||||
- **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale.
|
||||
- **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.
|
||||
83
packages/session/session-persistence/README.zh.md
Normal file
83
packages/session/session-persistence/README.zh.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# @deepseek-ai/dsh-session-persistence
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是用于持久保存会话的抽象 seam(`ctx.sessionPersistence`)。它定义持久化后端做什么:持久存储、重新加载和列出会话,而不规定如何实现。它与 `dsh-bash` 能力 seam 模板一致(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包提供抽象服务,同级包提供具体实现,消费方注入接口。
|
||||
|
||||
持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。
|
||||
|
||||
## 服务 API(`ctx.sessionPersistence`)
|
||||
|
||||
| 方法 | 契约 |
|
||||
|---|---|
|
||||
| `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 |
|
||||
| `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
|
||||
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
|
||||
| `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复使用的精确未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 |
|
||||
| `load(id): Promise<{ meta; events }>` | 在升级受支持的同版本形状后返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的形状和未知 `version` 会被拒绝。 |
|
||||
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;已经实时存在的视图则是当前不可变快照,可能包含打开的 turn。基于协调器的实现会在有界 LRU 中保留精确的冷未发布 Session,供后续 `prepare` 使用,但已存储 revision 变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 脱离的物理后缀原语:返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非受支持的旧形状需要前缀上下文才能完成规范化;顺序后端(JSONL)解析整个产物并向前跳过。用于只续折水位之后尾部的 checkpoint 消费方。 |
|
||||
| `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
|
||||
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 |
|
||||
|
||||
## 每个后端必须遵守的不变量
|
||||
|
||||
- **仅追加;崩溃轮次会被关闭,而非截断。** 已 flush 事件绝不重写。崩溃可留下未关闭最终轮次,其事件真实且可能很大;`load` 保留它们,并持久追加合成 closer(为每个未回答 assistant 调用添加按风险分类错误 `tool/result`,再添加 `step/end?`+`turn/end {interrupted}`),以平衡日志,并确保重新载入的历史仍是有效的提供方 transcript(文本记录)。只丢弃从未完整写入的撕裂尾部碎片。
|
||||
- **连续 seq。**`load` 拒绝日志中间的 `seq` 缺口/解析错误;`append` 的第一个 `seq` 必须等于已存储 next-seq。
|
||||
- **JSON 可序列化数据。**`append` 通过共享单遍无损 JSON 边界实体化每个直接/回放批次。实时 `Session` 事件已深度冻结,但写入协调器仍将每个事件复制到持久化自有缓冲区。
|
||||
- **持久性。**`append` 只在批次持久后返回。
|
||||
|
||||
## 写入协调器
|
||||
|
||||
`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的有界写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose(资源释放)。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)、[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)和[有界批处理决策](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)。
|
||||
|
||||
每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件。后台写入失败只记录一次日志,保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并在失败再次发生时向调用方暴露失败。
|
||||
|
||||
崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源 revision 仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留精确 Session,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
|
||||
|
||||
后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 重构前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会在不虚构无法获得的取消来源的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 重构前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
|
||||
|
||||
实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。
|
||||
|
||||
无副作用 `locate`、轻量 `listSnapshots` 和按 id 查询的 `readStoredRevision` 仍由后端负责,因为它们描述存储拓扑和 revision 身份,而非写入编排。`listSnapshots(signal?)` 将调用方传入的同一个信号传给后端发现流程,使观察者可在不脱离该工作的情况下取消。
|
||||
|
||||
`PersistenceBackend<TornMarker>` 钩子(协调器与存储之间的唯一 seam):
|
||||
|
||||
| 钩子 | 职责 |
|
||||
|---|---|
|
||||
| `name` | dispose 失败 `AggregateError` 的后端标签。 |
|
||||
| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于恢复/加载、非修改式 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;`revision` 精确标识返回的 header 和事件;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 |
|
||||
| `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定 revision。它使用与 `loadStored` 相同的 revision 表示;id 不存在时返回 `undefined`。 |
|
||||
| `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非变更、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 |
|
||||
| `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 |
|
||||
| `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和实时接管(仅截断)使用。 |
|
||||
| `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 |
|
||||
| `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待。 |
|
||||
|
||||
协调器断言已存储 id,并在修复或实时接管前比较已存储/实时 cwd。其 `inspect()` 路径取得新鲜后端值的所有权,只验证和冻结一次,并在不调用 `commitRepair` 的情况下最多保留配置数量的未发布 Session。只有保留源的 revision 仍等于 `readStoredRevision` 时,系统才会复用或修复它;否则协调器会重新读取。该新鲜性校验不会增加跨进程写入排他。持久日志在一次读取与复核往返内保持不变时,revision 重试才能收敛;持续的外部写入可能延迟 `load`、`inspect` 或 `prepare`。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同非变更检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)。
|
||||
|
||||
## 元数据与位置类型
|
||||
|
||||
从 `dsh-session` 重新导出:`SessionHeader`(不可变会话元数据:`version`、`id`、`createdAt`、`cwd?`、`parentSession?`、`seedLength?`、`origin?`、`delegationDepth?`)。`SessionLocation` 是 `{ readonly kind: string; readonly path: string }`;其 path 是绝对后端目标,不证明产物已存在或包含未 flush 轮次。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 恢复的对话历史
|
||||
|
||||
#### 模型所见
|
||||
|
||||
该 seam 不添加提示词或 schema。恢复会将已存储的表层事件还原为消息历史;已存储请求 header 重建较早调用,新 loop 则为下一次请求组合当前系统提示词、工具和会话前缀。崩溃修复将没有持久调用的 assistant 请求标记为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,其文本允许模型重试只读或幂等工作,但要求验证副作用或询问用户,而不是盲目重试。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
普通持久化期间为零 token。恢复后会重新计入保留历史的 token 用量,并照常计入当前请求 envelope 的 token 用量;每个已修复调用都会增加一段以引用形式保留的错误文本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
持久化不修改实时请求前缀。只有当重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果仅追加,不重写较早历史。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无删除或保留接口**:剪枝已存储会话是带外后端维护。
|
||||
- **`list()` 无分页且无过滤**:它返回每个已存储会话的 header;适合本地存储,大规模时无索引。
|
||||
- **修复时合成 closer 是唯一崩溃方案**:后端必须在 load 时合成 `tool/result`/`step/end`/`turn/end` closer;没有继续中断轮次而不先关闭它的部分轮次恢复。
|
||||
42
packages/session/session-persistence/package.json
Normal file
42
packages/session/session-persistence/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-persistence",
|
||||
"description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
1273
packages/session/session-persistence/src/coordinator.ts
Normal file
1273
packages/session/session-persistence/src/coordinator.ts
Normal file
File diff suppressed because it is too large
Load Diff
203
packages/session/session-persistence/src/index.ts
Normal file
203
packages/session/session-persistence/src/index.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Durable session-persistence seam (`ctx.sessionPersistence`). Backends store
|
||||
* {@link SessionEvent}s as the event-sourced log and carry non-replayable
|
||||
* {@link SessionHeader} metadata separately.
|
||||
* @module @deepseek-ai/dsh-session-persistence
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistenceRevision } from './revision.ts'
|
||||
|
||||
// Re-export the metadata vocabulary so consumers import it from the seam.
|
||||
export type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
export { SessionPersistenceRevision } from './revision.ts'
|
||||
|
||||
/** Lightweight immutable source identity returned without loading a full log. */
|
||||
export interface SessionPersistenceSnapshot {
|
||||
/** Detached metadata for one materialized session. */
|
||||
header: SessionHeader
|
||||
/** Opaque source-qualified token that changes whenever this stored log changes. */
|
||||
revision: SessionPersistenceRevision
|
||||
}
|
||||
|
||||
/** Immutable logical session prepared from persistence or a live owner. */
|
||||
export interface SessionInspection {
|
||||
/** Validated immutable session metadata. */
|
||||
readonly meta: SessionHeader
|
||||
/** Validated contiguous logical event log. */
|
||||
readonly events: readonly SessionEvent[]
|
||||
}
|
||||
|
||||
// The backend-agnostic write-path orchestration first-party backends compose.
|
||||
export {
|
||||
DEFAULT_PREPARED_SESSION_CACHE_SIZE,
|
||||
DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
|
||||
MAX_WRITE_BATCH_DELAY_MS,
|
||||
PersistenceCoordinator,
|
||||
SessionPersistenceCorruptionError,
|
||||
} from './coordinator.ts'
|
||||
export type {
|
||||
PersistenceBackend,
|
||||
PersistenceCoordinatorOptions,
|
||||
StoredPrefix,
|
||||
StoredSuffix,
|
||||
} from './coordinator.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionPersistence: SessionPersistence
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A backend-resolved, per-session local artifact location. The path is an
|
||||
* absolute target path and can name an artifact that has not materialized yet.
|
||||
* Consumers must treat it as a location hint, never as an authorization token.
|
||||
*/
|
||||
export interface SessionLocation {
|
||||
/** Backend-specific artifact kind, for example `jsonl`. */
|
||||
readonly kind: string
|
||||
/** Absolute path to this session's backend-owned artifact. */
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable append-only session storage. Implementations preserve contiguous,
|
||||
* losslessly JSON-serializable events; {@link append} resolves only after
|
||||
* durability, and {@link load} balances a complete interrupted tail without
|
||||
* rewriting committed events.
|
||||
*/
|
||||
export abstract class SessionPersistence extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessionPersistence')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve this backend's independent local artifact for a session without
|
||||
* reading, creating, flushing, or otherwise materializing it. Backends such
|
||||
* as SQLite that do not own one artifact per session return `undefined`.
|
||||
* @param meta - the immutable session header whose artifact is requested.
|
||||
* @returns the backend-specific absolute location, when one exists.
|
||||
*/
|
||||
abstract locate(meta: SessionHeader): SessionLocation | undefined
|
||||
|
||||
/**
|
||||
* Register a new session's metadata. A backend MAY defer the physical write
|
||||
* until the first {@link append} (lazy materialization), in which case a
|
||||
* created-but-never-appended session is absent from {@link list}
|
||||
* — abandoned sessions leave nothing behind.
|
||||
* @param meta - the immutable header (id, version, cwd, lineage) to record.
|
||||
*/
|
||||
abstract create(meta: SessionHeader): Promise<void>
|
||||
|
||||
/**
|
||||
* Durably persist a batch of events. Honors the append-only and contiguous-
|
||||
* seq contracts: the first event's `seq` MUST equal the stored next-seq
|
||||
* (after `load` has durably closed any interrupted turn). Rejects non-JSON-
|
||||
* serializable `event.data` with an error naming the offending event type.
|
||||
* @param id - the session the batch belongs to.
|
||||
* @param events - the contiguous batch to persist, in seq order.
|
||||
*/
|
||||
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
|
||||
|
||||
/**
|
||||
* Prepare the exact unpublished Session used by resume. Implementations may
|
||||
* reuse object graphs retained by an earlier {@link inspect} after confirming
|
||||
* their durable revision is still current; disposal releases an unpublished
|
||||
* reservation. Revision retries require the durable log to remain unchanged
|
||||
* for one read/check round trip; continuous external writers may delay completion.
|
||||
* @param id - persisted session to prepare.
|
||||
* @param signal - optional cancellation for preparation work.
|
||||
* @returns one owned unpublished Session preparation.
|
||||
*/
|
||||
async prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation> {
|
||||
signal?.throwIfAborted()
|
||||
const loaded = await this.load(id)
|
||||
signal?.throwIfAborted()
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) {
|
||||
throw new Error('cannot prepare a session: SessionStore is not configured')
|
||||
}
|
||||
return SessionPreparation.create(sessions.prepare(id, {
|
||||
seed: loaded.events.map(event => structuredClone(event)),
|
||||
meta: structuredClone(loaded.meta),
|
||||
seedSource: 'persistence',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an immutable balanced logical view and commit any required cold
|
||||
* recovery. A complete interrupted final turn is preserved and durably
|
||||
* closed with missing tool errors plus any open step and turn boundaries;
|
||||
* only a torn final record is discarded. Unknown versions and corruption in
|
||||
* the committed prefix reject. Implementations MUST NOT crash-repair an
|
||||
* identity still bound to a live Session: a balanced live log may return as a
|
||||
* durable snapshot, while an open live turn rejects. Returned values may be
|
||||
* shared with immutable live or prepared state and must not be mutated.
|
||||
* Revision-based implementations may wait for one stable read/check round trip.
|
||||
* @param id - the persisted session to reload.
|
||||
* @returns the header and a log ending on a balanced `turn/end`.
|
||||
*/
|
||||
abstract load(id: SessionId): Promise<SessionInspection>
|
||||
|
||||
/**
|
||||
* Inspect an immutable logical session without committing recovery or
|
||||
* publishing it. A cold complete interrupted turn receives synthetic closers
|
||||
* in memory and a torn physical tail remains untouched. An already-live
|
||||
* Session instead yields its current immutable snapshot, which may contain an
|
||||
* open turn and its `session/end-seed` boundary. Coordinator-backed
|
||||
* implementations retain the exact cold unpublished Session for bounded
|
||||
* reuse by a later {@link prepare}. A stale ready source is reloaded; a source
|
||||
* already committing or reserved for resume remains exclusive, and inspection
|
||||
* may borrow its immutable view. Callers borrow only the immutable header and
|
||||
* log. Continuous external writers may delay revision convergence.
|
||||
* @param id - the persisted session to inspect.
|
||||
* @param signal - optional cancellation for queued and backend read work.
|
||||
* @returns the validated header and current logical event log.
|
||||
*/
|
||||
abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>
|
||||
|
||||
/**
|
||||
* Read the stored events from `fromSeq` onward — the read-from-seq
|
||||
* primitive for read models that resume from a watermark (e.g. a persisted
|
||||
* projection cache folding only the tail past its checkpoint). Unlike
|
||||
* {@link inspect}, it is a detached physical suffix read: no preparation
|
||||
* cache, torn-tail truncation, synthetic closers, or coordinator-state
|
||||
* publication. Only events from the valid contiguous stored prefix are
|
||||
* returned, so a torn fragment never reaches the caller. `fromSeq` at or
|
||||
* beyond the stored prefix returns an empty event list (never an error).
|
||||
* Backends whose medium can seek by seq
|
||||
* (SQLite) read only the suffix; sequential media (JSONL, both encodings)
|
||||
* still parse the whole artifact and skip forward — the primitive bounds
|
||||
* what is RETURNED and refolded, not every backend's physical read.
|
||||
* @param id - the persisted session to read.
|
||||
* @param fromSeq - first event seq to include; a non-negative safe integer.
|
||||
* @param signal - optional cancellation for queued and backend read work.
|
||||
* @returns the header and the stored events with `seq >= fromSeq`.
|
||||
*/
|
||||
abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal):
|
||||
Promise<{ meta: SessionHeader; events: SessionEvent[] }>
|
||||
|
||||
/**
|
||||
* Lightweight listing from metadata, without a full-log parse.
|
||||
* @param signal - optional cancellation for backend listing work.
|
||||
* @returns one header per materialized session.
|
||||
*/
|
||||
abstract list(signal?: AbortSignal): Promise<SessionHeader[]>
|
||||
|
||||
/**
|
||||
* List materialized sessions with cheap per-log change tokens.
|
||||
*
|
||||
* Repeated observations of an unchanged log return the same revision. A
|
||||
* successful mutating {@link load} repair changes the next listed revision.
|
||||
* Revisions also distinguish independently backed stores so backend-local
|
||||
* counters cannot compare equal across different persistence sources.
|
||||
* @param signal - optional cancellation for backend snapshot-listing work.
|
||||
* @returns one header and opaque revision per materialized session without loading full logs.
|
||||
*/
|
||||
abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>
|
||||
}
|
||||
|
||||
export default SessionPersistence
|
||||
30
packages/session/session-persistence/src/invariant.ts
Normal file
30
packages/session/session-persistence/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence`.
|
||||
* @module @deepseek-ai/dsh-session-persistence/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-persistence-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests;
|
||||
* this package exposes no continuously observable in-process relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
348
packages/session/session-persistence/src/preparations.ts
Normal file
348
packages/session/session-persistence/src/preparations.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Bounded sharing and exclusive reservation of unpublished Sessions.
|
||||
* @module @deepseek-ai/dsh-session-persistence/preparations
|
||||
*/
|
||||
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
interface PreparedSource {
|
||||
readonly session: Session
|
||||
}
|
||||
|
||||
type PreparationPhase = 'loading' | 'ready' | 'committing' | 'reserved'
|
||||
|
||||
interface PreparationEntry<Source, CommitState> {
|
||||
readonly id: SessionId
|
||||
readonly result: Promise<Source>
|
||||
phase: PreparationPhase
|
||||
source?: Source
|
||||
reservation?: SessionPreparationReservation<Source, CommitState>
|
||||
reservationSettled?: Promise<void>
|
||||
settleReservation?: () => void
|
||||
}
|
||||
|
||||
/** One exclusively held prepared source and its committed persistence state. */
|
||||
export interface SessionPreparationReservation<Source, CommitState> {
|
||||
readonly entry: PreparationEntry<Source, CommitState>
|
||||
readonly source: Source
|
||||
readonly state: CommitState
|
||||
}
|
||||
|
||||
/** Per-coordinator cold-read sharing, exclusive reservation, and ready-entry LRU. */
|
||||
export class SessionPreparations<Source extends PreparedSource, CommitState> {
|
||||
private readonly entries = new Map<SessionId, PreparationEntry<Source, CommitState>>()
|
||||
|
||||
constructor(private readonly capacity: number) {}
|
||||
|
||||
/**
|
||||
* Whether this pool currently knows about an unpublished identity.
|
||||
* @param id - session identity.
|
||||
* @returns whether an entry exists for the identity.
|
||||
*/
|
||||
has(id: SessionId): boolean {
|
||||
return this.entries.has(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe one prepared source, sharing an in-flight read for the same id.
|
||||
* @param id - session identity.
|
||||
* @param load - cold loader used when no entry exists.
|
||||
* @param signal - optional cancellation signal while waiting.
|
||||
* @returns the shared prepared source.
|
||||
*/
|
||||
async inspect(
|
||||
id: SessionId,
|
||||
load: () => Promise<Source>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Source> {
|
||||
const entry = this.entryFor(id, load)
|
||||
const loaded = signal === undefined
|
||||
? await entry.result
|
||||
: await observeQueuedAbort(entry.result, signal)
|
||||
const source = entry.source ?? loaded
|
||||
if (this.entries.get(id) === entry && entry.phase === 'ready') this.touch(entry)
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve one ready source after committing its pending durable repair.
|
||||
* @param id - session identity.
|
||||
* @param load - cold loader used when no entry exists.
|
||||
* @param commit - durable repair and cursor-state commit.
|
||||
* @param signal - optional cancellation signal while waiting.
|
||||
* @returns the exclusive reservation, or undefined if its entry was invalidated.
|
||||
*/
|
||||
async reserve(
|
||||
id: SessionId,
|
||||
load: () => Promise<Source>,
|
||||
commit: (source: Source) => Promise<{ source: Source; state: CommitState } | undefined>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionPreparationReservation<Source, CommitState> | undefined> {
|
||||
const entry = this.entryFor(id, load)
|
||||
await (signal === undefined ? entry.result : observeQueuedAbort(entry.result, signal))
|
||||
while (this.entries.get(id) === entry && entry.phase !== 'ready') {
|
||||
const settled = entry.reservationSettled
|
||||
/* v8 ignore next -- committing/reserved transitions install this waiter synchronously. */
|
||||
if (settled === undefined) throw new Error(`session "${id}" preparation lost its reservation waiter`)
|
||||
if (signal === undefined) await settled
|
||||
else await observeQueuedAbort(settled, signal)
|
||||
}
|
||||
if (this.entries.get(id) !== entry) return undefined
|
||||
const source = entry.source as Source
|
||||
const reservationSettled = Promise.withResolvers<void>()
|
||||
entry.phase = 'committing'
|
||||
entry.reservationSettled = reservationSettled.promise
|
||||
entry.settleReservation = reservationSettled.resolve
|
||||
let committed: { source: Source; state: CommitState } | undefined
|
||||
try {
|
||||
committed = await commit(source)
|
||||
} catch (error: unknown) {
|
||||
this.remove(entry)
|
||||
throw error
|
||||
}
|
||||
if (committed === undefined) {
|
||||
this.remove(entry)
|
||||
return undefined
|
||||
}
|
||||
entry.source = committed.source
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
this.makeReady(entry)
|
||||
throw error
|
||||
}
|
||||
if (this.entries.get(id) !== entry) return undefined
|
||||
const reservation: SessionPreparationReservation<Source, CommitState> = {
|
||||
entry,
|
||||
source: committed.source,
|
||||
state: committed.state,
|
||||
}
|
||||
entry.phase = 'reserved'
|
||||
entry.reservation = reservation
|
||||
return reservation
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the exact reservation for Session publication, rejecting aliases.
|
||||
* @param session - exact Session candidate for publication.
|
||||
* @returns its reservation, or undefined when no preparation exists.
|
||||
*/
|
||||
reservationFor(session: Session): SessionPreparationReservation<Source, CommitState> | undefined {
|
||||
const entry = this.entries.get(session.id)
|
||||
if (entry === undefined) return undefined
|
||||
if (entry.phase === 'reserved'
|
||||
&& entry.source?.session === session
|
||||
&& entry.reservation !== undefined) {
|
||||
return entry.reservation
|
||||
}
|
||||
throw new Error(`cannot publish session "${session.id}": persisted state already owns this identity`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a reservation after its exact Session has attached.
|
||||
* @param reservation - reservation to consume.
|
||||
*/
|
||||
attach(reservation: SessionPreparationReservation<Source, CommitState>): void {
|
||||
const { entry } = reservation
|
||||
if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation) {
|
||||
throw new Error(`session "${entry.id}" preparation is no longer reserved`)
|
||||
}
|
||||
this.remove(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a reservation whose caller only needs the committed inspection.
|
||||
* @param reservation - reservation to consume.
|
||||
*/
|
||||
discard(reservation: SessionPreparationReservation<Source, CommitState>): void {
|
||||
const { entry } = reservation
|
||||
if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation) return
|
||||
this.remove(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a reusable unpublished reservation to the ready LRU.
|
||||
* @param reservation - reservation to release.
|
||||
* @param reusable - whether the source remains valid for reuse.
|
||||
*/
|
||||
release(
|
||||
reservation: SessionPreparationReservation<Source, CommitState>,
|
||||
reusable: boolean,
|
||||
): void {
|
||||
const { entry } = reservation
|
||||
if (this.entries.get(entry.id) !== entry
|
||||
|| entry.reservation !== reservation
|
||||
|| entry.phase !== 'reserved') return
|
||||
if (!reusable) {
|
||||
this.remove(entry)
|
||||
return
|
||||
}
|
||||
delete entry.reservation
|
||||
this.makeReady(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard a prepared view after the durable log changes.
|
||||
* @param id - changed session identity.
|
||||
*/
|
||||
invalidate(id: SessionId): void {
|
||||
const entry = this.entries.get(id)
|
||||
if (entry !== undefined) this.remove(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard an exact stale ready source without disturbing an exclusive owner.
|
||||
* @param id - changed session identity.
|
||||
* @param expected - exact source observed before its revision check.
|
||||
* @returns whether the source was discarded, retained by a reservation, or is absent.
|
||||
*/
|
||||
discardReady(id: SessionId, expected: Source): 'discarded' | 'retained' | 'missing' {
|
||||
const entry = this.entries.get(id)
|
||||
if (entry === undefined || entry.source !== expected) return 'missing'
|
||||
if (entry.phase !== 'ready') return 'retained'
|
||||
this.remove(entry)
|
||||
return 'discarded'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject writes while an unpublished Session exclusively reserves the id.
|
||||
* @param id - session identity to check.
|
||||
*/
|
||||
assertWritable(id: SessionId): void {
|
||||
const phase = this.entries.get(id)?.phase
|
||||
if (phase === 'committing' || phase === 'reserved') {
|
||||
throw new Error(`cannot append session "${id}" while its persisted preparation is reserved`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a completed entry for an already-serialized append adoption.
|
||||
* @param id - adopted session identity.
|
||||
* @returns the prepared source, or undefined when no ready entry exists.
|
||||
*/
|
||||
takeReady(id: SessionId): Source | undefined {
|
||||
const entry = this.entries.get(id)
|
||||
if (entry === undefined || entry.phase !== 'ready' || entry.source === undefined) return undefined
|
||||
this.remove(entry)
|
||||
return entry.source
|
||||
}
|
||||
|
||||
private entryFor(
|
||||
id: SessionId,
|
||||
load: () => Promise<Source>,
|
||||
): PreparationEntry<Source, CommitState> {
|
||||
const existing = this.entries.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
const deferred = Promise.withResolvers<Source>()
|
||||
const entry: PreparationEntry<Source, CommitState> = {
|
||||
id,
|
||||
result: deferred.promise,
|
||||
phase: 'loading',
|
||||
}
|
||||
this.entries.set(id, entry)
|
||||
let loading: Promise<Source>
|
||||
try {
|
||||
// Start immediately so a same-tick serialized append queues behind this
|
||||
// read. The deferred result settles only after the entry becomes ready.
|
||||
loading = load()
|
||||
} catch (error: unknown) {
|
||||
this.remove(entry)
|
||||
deferred.reject(error)
|
||||
return entry
|
||||
}
|
||||
void loading.then((source) => {
|
||||
if (this.entries.get(id) === entry) {
|
||||
entry.source = source
|
||||
this.makeReady(entry)
|
||||
}
|
||||
deferred.resolve(source)
|
||||
}, (error: unknown) => {
|
||||
this.remove(entry)
|
||||
deferred.reject(error)
|
||||
})
|
||||
return entry
|
||||
}
|
||||
|
||||
private makeReady(entry: PreparationEntry<Source, CommitState>): void {
|
||||
if (this.entries.get(entry.id) !== entry) return
|
||||
entry.phase = 'ready'
|
||||
const settle = entry.settleReservation
|
||||
delete entry.reservationSettled
|
||||
delete entry.settleReservation
|
||||
settle?.()
|
||||
this.touch(entry)
|
||||
}
|
||||
|
||||
private remove(entry: PreparationEntry<Source, CommitState>): void {
|
||||
if (this.entries.get(entry.id) !== entry) return
|
||||
this.entries.delete(entry.id)
|
||||
const settle = entry.settleReservation
|
||||
delete entry.reservationSettled
|
||||
delete entry.settleReservation
|
||||
settle?.()
|
||||
}
|
||||
|
||||
private touch(entry: PreparationEntry<Source, CommitState>): void {
|
||||
this.entries.delete(entry.id)
|
||||
this.entries.set(entry.id, entry)
|
||||
let readyCount = 0
|
||||
for (const candidate of this.entries.values()) {
|
||||
if (candidate.phase === 'ready') readyCount += 1
|
||||
}
|
||||
if (readyCount <= this.capacity) return
|
||||
for (const [id, candidate] of this.entries) {
|
||||
if (candidate.phase !== 'ready') continue
|
||||
this.entries.delete(id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a queued observer a prompt cancellation view without cancelling shared work.
|
||||
* @param operation - shared operation whose settlement remains authoritative.
|
||||
* @param signal - observer-local cancellation signal.
|
||||
* @param started - whether the operation has crossed its cancellation cutoff.
|
||||
* @returns the operation result or the observer's prompt cancellation.
|
||||
*/
|
||||
export function observeQueuedAbort<T>(
|
||||
operation: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
started: () => boolean = () => false,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (callback: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
callback()
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
if (started()) return
|
||||
finish(() => {
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
} catch (reason: unknown) {
|
||||
rejectObservation(reject, reason)
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- a native AbortSignal emits abort only after becoming aborted. */
|
||||
reject(new Error('queued observation abort event lacked an aborted signal'))
|
||||
})
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
operation.then(
|
||||
(value) => { finish(() => { resolve(value) }) },
|
||||
(reason: unknown) => {
|
||||
finish(() => { rejectObservation(reject, reason) })
|
||||
},
|
||||
)
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
}
|
||||
|
||||
/** Preserve an exact loader or AbortSignal reason, including legacy non-Error values. */
|
||||
function rejectObservation(reject: (reason?: unknown) => void, reason: unknown): void {
|
||||
reject(reason)
|
||||
}
|
||||
18
packages/session/session-persistence/src/revision.ts
Normal file
18
packages/session/session-persistence/src/revision.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Opaque revision identity for lightweight persistence observations. */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Backend-owned token that identifies both one storage source and one revision
|
||||
* of a persisted session log.
|
||||
*/
|
||||
export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'>
|
||||
|
||||
/**
|
||||
* Brand a backend revision for the provider-neutral persistence contract.
|
||||
* @param value - backend-owned opaque revision representation.
|
||||
* @returns the same runtime string with persistence-revision identity.
|
||||
*/
|
||||
export function SessionPersistenceRevision(value: string): SessionPersistenceRevision {
|
||||
return value as SessionPersistenceRevision
|
||||
}
|
||||
159
packages/session/session-persistence/src/write-behind.ts
Normal file
159
packages/session/session-persistence/src/write-behind.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Bounded per-session write batching for the shared persistence coordinator.
|
||||
* @module @deepseek-ai/dsh-session-persistence/write-behind
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Dependencies and scheduling policy for one live session's write controller. */
|
||||
export interface SessionWriteBehindOptions {
|
||||
/** Maximum intentional batching wait after an idle queue receives work. */
|
||||
readonly maxDelayMs: number
|
||||
/** Persist one stable ordered prefix; resolves only after backend durability. */
|
||||
readonly write: (events: readonly SessionEvent[]) => Promise<void>
|
||||
/** Observe a detached background write failure without rejecting the producer. */
|
||||
readonly reportBackgroundFailure: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one live session's pending events, fixed batching deadline, active write,
|
||||
* failure retention, and explicit quiescence barrier.
|
||||
*/
|
||||
export class SessionWriteBehind {
|
||||
private pending: SessionEvent[] = []
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private active: Promise<void> | undefined
|
||||
private barrier: Promise<void> | undefined
|
||||
private deadlineExpired = false
|
||||
private automaticPaused = false
|
||||
|
||||
/**
|
||||
* @param options - fixed scheduling policy and durable batch sink.
|
||||
*/
|
||||
constructor(private readonly options: SessionWriteBehindOptions) {}
|
||||
|
||||
/** Whether this controller owns queued events or an active durable write. */
|
||||
get hasWork(): boolean {
|
||||
return this.pending.length > 0 || this.active !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy one event into the persistence-owned queue and start a fixed deadline
|
||||
* when the automatic path is idle.
|
||||
* @param event - frozen live event to retain independently of its producer.
|
||||
*/
|
||||
enqueue(event: SessionEvent): void {
|
||||
const wasEmpty = this.pending.length === 0
|
||||
this.pending.push(structuredClone(event))
|
||||
if (this.barrier !== undefined) return
|
||||
if (this.automaticPaused) {
|
||||
this.automaticPaused = false
|
||||
this.deadlineExpired = false
|
||||
this.armTimer()
|
||||
} else if (wasEmpty) {
|
||||
this.armTimer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the batching wait and durably drain through a quiescent point.
|
||||
* Concurrent callers join the same barrier.
|
||||
* @returns a promise that rejects if the barrier's durable retry fails.
|
||||
*/
|
||||
flush(): Promise<void> {
|
||||
if (this.barrier !== undefined) return this.barrier
|
||||
this.cancelTimer()
|
||||
this.deadlineExpired = false
|
||||
this.automaticPaused = false
|
||||
const barrier = Promise.withResolvers<void>()
|
||||
this.barrier = barrier.promise
|
||||
void this.drainBarrier(barrier.resolve, barrier.reject)
|
||||
return barrier.promise
|
||||
}
|
||||
|
||||
/** Cancel the current automatic deadline without draining retained work. */
|
||||
cancelAutomaticWait(): void {
|
||||
this.cancelTimer()
|
||||
this.deadlineExpired = false
|
||||
}
|
||||
|
||||
/** Start the one fixed window for the current pending prefix. */
|
||||
private armTimer(): void {
|
||||
this.timer = setTimeout(() => { this.onDeadline() }, this.options.maxDelayMs)
|
||||
}
|
||||
|
||||
/** Cancel any pending automatic deadline. */
|
||||
private cancelTimer(): void {
|
||||
if (this.timer === undefined) return
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
|
||||
/** Start a background write now, or remember that an active write used the budget. */
|
||||
private onDeadline(): void {
|
||||
this.timer = undefined
|
||||
if (this.active !== undefined) {
|
||||
this.deadlineExpired = true
|
||||
return
|
||||
}
|
||||
this.startBackground()
|
||||
}
|
||||
|
||||
/** Start one detached write whose failure is reported and retained. */
|
||||
private startBackground(): void {
|
||||
const active = this.startWrite(true)
|
||||
void active.then(() => { this.continueAutomatic() }, () => {})
|
||||
}
|
||||
|
||||
/** Continue immediately after an over-budget active write, otherwise keep its timer. */
|
||||
private continueAutomatic(): void {
|
||||
if (this.barrier !== undefined || this.pending.length === 0) return
|
||||
if (this.deadlineExpired) {
|
||||
this.deadlineExpired = false
|
||||
this.startBackground()
|
||||
}
|
||||
}
|
||||
|
||||
/** Await overlapping work, drain to quiescence, and settle the shared barrier. */
|
||||
private async drainBarrier(resolve: () => void, reject: (reason?: unknown) => void): Promise<void> {
|
||||
try {
|
||||
const overlapping = this.active
|
||||
if (overlapping !== undefined) {
|
||||
await Promise.allSettled([overlapping])
|
||||
this.automaticPaused = false
|
||||
}
|
||||
while (this.pending.length > 0) await this.startWrite(false)
|
||||
} catch (error: unknown) {
|
||||
this.barrier = undefined
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
// Close admission to this barrier in the same job that observes the empty
|
||||
// queue, before resolving callers. A later enqueue therefore starts its own
|
||||
// automatic window instead of being stranded behind a settled barrier.
|
||||
this.barrier = undefined
|
||||
resolve()
|
||||
}
|
||||
|
||||
/** Start one stable pending prefix, retaining it in order if durability fails. */
|
||||
private startWrite(background: boolean): Promise<void> {
|
||||
const batch = this.pending.splice(0)
|
||||
this.cancelTimer()
|
||||
this.deadlineExpired = false
|
||||
const operation = Promise.resolve().then(() => this.options.write(batch))
|
||||
const active = operation
|
||||
.catch((error: unknown) => {
|
||||
this.pending = batch.concat(this.pending)
|
||||
this.cancelTimer()
|
||||
this.deadlineExpired = false
|
||||
this.automaticPaused = true
|
||||
if (background) this.options.reportBackgroundFailure(error)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
this.active = undefined
|
||||
})
|
||||
this.active = active
|
||||
return active
|
||||
}
|
||||
}
|
||||
432
packages/session/session-persistence/tests/contract.ts
Normal file
432
packages/session/session-persistence/tests/contract.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Reusable contract test for any {@link SessionPersistence} backend. A backend
|
||||
* package imports {@link runPersistenceContract} and calls it with a factory
|
||||
* that yields a fresh, empty backend (and a teardown), so every backend is held
|
||||
* to the same append-only / contiguous-seq / lazy-materialization / crash
|
||||
* semantics. The JSONL backend's own spec adds file-specific tests on top.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-persistence/tests/contract
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId, MessageId, createMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
|
||||
/** A backend under test plus its teardown. */
|
||||
export interface ContractBackend {
|
||||
persistence: SessionPersistence
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Build a minimal {@link SessionHeader} for a session id. */
|
||||
export function meta(id: string, cwd?: string): SessionHeader {
|
||||
return {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId(id),
|
||||
createdAt: 1000,
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** A well-formed one-turn event log (contiguous seqs from 0). */
|
||||
export function oneTurnLog(): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: 1, time: 2, data: freezeMessage({
|
||||
id: MessageId('one-turn-user'),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 3, time: 4, data: {
|
||||
turn: 1, step: 1,
|
||||
message: freezeMessage({
|
||||
id: MessageId('one-turn-assistant'),
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Append recorded events to a live session while forwarding surface metadata verbatim. The broad
|
||||
* `SessionEvent` union makes the typed marker optional, but the runtime guard must still reject a
|
||||
* surface event whose fixture omitted it; this helper never synthesizes a default.
|
||||
*/
|
||||
export function appendLog(session: Session, events: readonly SessionEvent[]): void {
|
||||
for (const e of events) {
|
||||
const se = e as SessionEvent<SurfaceEventType>
|
||||
if (se.surfaceOp !== undefined) {
|
||||
const intent: SurfaceIntent = {
|
||||
surfaceOp: se.surfaceOp,
|
||||
...se.sourceEventSeqs !== undefined ? { sourceEventSeqs: se.sourceEventSeqs } : {},
|
||||
}
|
||||
session.append(e.type, e.data, intent)
|
||||
} else {
|
||||
session.append(e.type, e.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the backend-agnostic contract suite. `make()` MUST return a fresh, empty
|
||||
* backend each call.
|
||||
*/
|
||||
export function runPersistenceContract(name: string, make: () => Promise<ContractBackend>): void {
|
||||
describe(`SessionPersistence contract: ${name}`, () => {
|
||||
it('round-trips a session: create + append → load returns identical meta and byte-identical events', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s1', '/work')
|
||||
const log = oneTurnLog()
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, log)
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' })
|
||||
expect(loaded.events).toEqual(log)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a fractional creation timestamp without reserving its session id', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = { ...meta('fractional-created-at'), createdAt: 1.5 }
|
||||
await expect(persistence.create(m))
|
||||
.rejects.toThrow('session metadata createdAt must be a non-negative safe integer')
|
||||
|
||||
const valid = meta('fractional-created-at')
|
||||
await persistence.create(valid)
|
||||
await persistence.append(valid.id, oneTurnLog())
|
||||
expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('interrupted')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5)
|
||||
// A second turn that crashed mid-flight: turn/start + step/start were
|
||||
// durably written, but no step/end / turn/end ever arrived.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
])
|
||||
const beforeRepair = (await persistence.listSnapshots())
|
||||
.find(snapshot => snapshot.header.id === m.id)?.revision
|
||||
|
||||
const inspected = await persistence.inspect(m.id)
|
||||
const afterInspect = (await persistence.listSnapshots())
|
||||
.find(snapshot => snapshot.header.id === m.id)?.revision
|
||||
expect(afterInspect).toBe(beforeRepair)
|
||||
expect(inspected.events.map(e => e.type)).toEqual([
|
||||
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end',
|
||||
'turn/start', 'step/start', 'step/end', 'turn/end',
|
||||
])
|
||||
|
||||
// load PRESERVES the interrupted turn's events (a turn can be huge — they
|
||||
// must not be truncated) and closes the orphaned turn with synthetic
|
||||
// boundary events: step/end (the step was open) then turn/end {interrupted}.
|
||||
const loaded = await persistence.load(m.id)
|
||||
const afterRepair = (await persistence.listSnapshots())
|
||||
.find(snapshot => snapshot.header.id === m.id)?.revision
|
||||
expect(afterRepair).not.toBe(beforeRepair)
|
||||
expect(loaded.events.map(e => e.type)).toEqual([
|
||||
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
|
||||
'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers
|
||||
])
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
|
||||
const last = loaded.events.at(-1)!
|
||||
expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
|
||||
|
||||
// The closed log is durable and continuable: a fresh append continues at
|
||||
// the balanced length (seq 10), and a reload round-trips identically.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await persistence.load(m.id)
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('crash recovery: an unstarted assistant tool request gets a retryable synthetic result', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('interrupted-toolcall')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5)
|
||||
// Turn 2 crashed AFTER the assistant message asked for a tool call but
|
||||
// BEFORE the tool/result was written (the loop runs tools after logging
|
||||
// the assistant message — a process killed mid-tool lands exactly here).
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 8, time: 9, data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('call-x'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
])
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
// The orphaned call is answered by a synthetic error tool/result BEFORE
|
||||
// step/end + turn/end {interrupted}, so the step (and turn) are balanced
|
||||
// and a resumed session derives a valid transcript (no dangling call).
|
||||
expect(loaded.events.map(e => e.type)).toEqual([
|
||||
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
|
||||
'turn/start', 'step/start', 'assistant/message', 'tool/result', 'step/end', 'turn/end', // turn 2
|
||||
])
|
||||
const synthetic = loaded.events.find(e => e.type === 'tool/result')
|
||||
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
|
||||
message: {
|
||||
source: { kind: 'tool', callId: CallId('call-x') },
|
||||
content: [{ type: 'tool-result', toolCallId: CallId('call-x'), isError: true }],
|
||||
},
|
||||
error: { code: TOOL_NOT_STARTED },
|
||||
})
|
||||
// The synthetic result carries the SAME callId as the orphaned tool-call,
|
||||
// so deriveMessages() pairs them — no provider-invalid dangling call.
|
||||
const call = loaded.events.findLast(e => e.type === 'assistant/message')
|
||||
const callId = call?.type === 'assistant/message'
|
||||
&& call.data.message.content.find(b => b.type === 'tool-call')
|
||||
expect(callId && callId.type === 'tool-call' && callId.id).toBe(CallId('call-x'))
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('crash recovery: a recorded tool call with no result tells the model to assess retry risk', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('unknown-tool-outcome')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 3, data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('call-risk'), name: 'write', arguments: '{}' },
|
||||
],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'tool/call', seq: 3, time: 4, data: { turn: 1, step: 1, callId: CallId('call-risk'), name: 'write', arguments: '{}' } },
|
||||
])
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
const synthetic = loaded.events.find(e => e.type === 'tool/result')
|
||||
expect(synthetic?.type === 'tool/result' && synthetic.data.error).toEqual({
|
||||
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
|
||||
})
|
||||
if (synthetic?.type !== 'tool/result' || synthetic.data.message.content[0].content[0]?.type !== 'text') {
|
||||
throw new Error('expected a text tool result')
|
||||
}
|
||||
expect(synthetic.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
|
||||
expect(synthetic.data.message.content[0].content[0].text).toContain('if it may have side effects, first verify external state or ask the user')
|
||||
const resumed = Session.create(m.id, loaded.events, loaded.meta)
|
||||
const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result'))
|
||||
expect(resumedResult?.content[0]).toMatchObject({
|
||||
type: 'tool-result', toolCallId: CallId('call-risk'), isError: true,
|
||||
})
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('list() excludes a created-but-never-appended (zero-event) session', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
await persistence.create(meta('empty'))
|
||||
expect((await persistence.list()).map(m => m.id)).not.toContain(SessionId('empty'))
|
||||
expect((await persistence.listSnapshots()).map(snapshot => snapshot.header.id))
|
||||
.not.toContain(SessionId('empty'))
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects pre-aborted observation reads with the exact cancellation reason', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const reason = new Error('persistence observation cancelled')
|
||||
const controller = new AbortController()
|
||||
await expect(persistence.listSnapshots(controller.signal)).resolves.toEqual([])
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(persistence.list(controller.signal)).rejects.toBe(reason)
|
||||
await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason)
|
||||
await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal))
|
||||
.rejects.toBe(reason)
|
||||
await expect(persistence.readFrom(SessionId('cancelled-read-from'), 0, controller.signal))
|
||||
.rejects.toBe(reason)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('readFrom returns exactly the stored suffix from the requested seq, without mutating the log', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('read-from', '/work')
|
||||
const log = oneTurnLog()
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, log)
|
||||
|
||||
const whole = await persistence.readFrom(m.id, 0)
|
||||
expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' })
|
||||
expect(whole.events).toEqual(log)
|
||||
|
||||
const suffix = await persistence.readFrom(m.id, 3)
|
||||
expect(suffix.events).toEqual(log.slice(3))
|
||||
expect(suffix.events[0]?.seq).toBe(3)
|
||||
|
||||
// At/past the stored end: an empty tail, never an error.
|
||||
await expect(persistence.readFrom(m.id, log.length)).resolves.toMatchObject({ events: [] })
|
||||
await expect(persistence.readFrom(m.id, log.length + 100)).resolves.toMatchObject({ events: [] })
|
||||
|
||||
// Non-mutating: an interrupted-turn log is served as stored, no closers.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
])
|
||||
const tail = await persistence.readFrom(m.id, 6)
|
||||
expect(tail.events.map(event => event.type)).toEqual(['turn/start'])
|
||||
|
||||
await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found')
|
||||
await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer')
|
||||
await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer')
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('lists stable lightweight revisions that change after an append', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s2')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog())
|
||||
expect((await persistence.list()).map(x => x.id)).toContain(m.id)
|
||||
const first = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
|
||||
const repeated = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
|
||||
expect(first).toBeDefined()
|
||||
expect(repeated?.revision).toBe(first?.revision)
|
||||
|
||||
await persistence.append(m.id, [{
|
||||
type: 'turn/start',
|
||||
seq: 6,
|
||||
time: 7,
|
||||
data: { turn: 2 },
|
||||
}])
|
||||
const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
|
||||
expect(changed?.revision).not.toBe(first?.revision)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('append rejects a batch whose first seq does not match the stored next-seq', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s3')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog()) // seqs 0..5, next-seq = 6
|
||||
// A re-append of an already-stored seq must be rejected, not duplicated.
|
||||
const restated = oneTurnLog()
|
||||
await expect(persistence.append(m.id, restated)).rejects.toThrow()
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('append rejects a mid-batch seq gap', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s4')
|
||||
await persistence.create(m)
|
||||
const gapped: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // gap: missing seq 1
|
||||
]
|
||||
await expect(persistence.append(m.id, gapped)).rejects.toThrow()
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('append rejects non-JSON-serializable event data, naming the event type', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
// Every value `isJsonValue` rejects must be rejected by the backend, not just BigInt —
|
||||
// otherwise a backend could pass this contract while still accepting values that
|
||||
// corrupt the durable round-trip. Each value is carried in a plugin-added field on one
|
||||
// user message so the contract covers the complete JSON-value boundary.
|
||||
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
|
||||
cyclic['self'] = cyclic
|
||||
const badValues: unknown[] = [
|
||||
1n, // BigInt
|
||||
undefined, // dropped by JSON.stringify
|
||||
Infinity, // → null
|
||||
() => 0, // function
|
||||
Symbol('s'), // symbol
|
||||
new Map(), // exotic object
|
||||
cyclic, // circular ref
|
||||
]
|
||||
for (const [i, bad] of badValues.entries()) {
|
||||
// A fresh session per value isolates each rejection (a rejected append
|
||||
// must leave no state behind, but isolating keeps the assertion clean).
|
||||
const mi = meta(`s5-${i}`)
|
||||
await persistence.create(mi)
|
||||
const events = [
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: {
|
||||
id: MessageId(`invalid-json-${i}`),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'x' }],
|
||||
source: { kind: 'user' },
|
||||
extra: bad,
|
||||
},
|
||||
},
|
||||
] as unknown as SessionEvent[]
|
||||
await expect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/)
|
||||
}
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
1432
packages/session/session-persistence/tests/coordinator-contract.ts
Normal file
1432
packages/session/session-persistence/tests/coordinator-contract.ts
Normal file
File diff suppressed because it is too large
Load Diff
1915
packages/session/session-persistence/tests/persistence.spec.ts
Normal file
1915
packages/session/session-persistence/tests/persistence.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
360
packages/session/session-persistence/tests/preparations.spec.ts
Normal file
360
packages/session/session-persistence/tests/preparations.spec.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/** Unit coverage for unpublished Session preparation ownership and sharing. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { observeQueuedAbort, SessionPreparations } from '../src/preparations.ts'
|
||||
|
||||
interface PreparedSource {
|
||||
readonly session: Session
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
function prepared(label: string): PreparedSource {
|
||||
return { session: Session.create(SessionId(label)), label }
|
||||
}
|
||||
|
||||
function committed(source: PreparedSource): Promise<{ source: PreparedSource; state: string }> {
|
||||
return Promise.resolve({ source, state: source.label })
|
||||
}
|
||||
|
||||
describe('SessionPreparations inspection', () => {
|
||||
it('shares in-flight and ready sources, then invalidates them', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(2)
|
||||
const id = SessionId('shared-inspection')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const load = vi.fn(() => gate.promise)
|
||||
const first = preparations.inspect(id, load)
|
||||
const second = preparations.inspect(id, load, new AbortController().signal)
|
||||
const source = prepared(id)
|
||||
|
||||
expect(preparations.has(id)).toBe(true)
|
||||
gate.resolve(source)
|
||||
await expect(first).resolves.toBe(source)
|
||||
await expect(second).resolves.toBe(source)
|
||||
await expect(preparations.inspect(id, load)).resolves.toBe(source)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
|
||||
preparations.invalidate(id)
|
||||
preparations.invalidate(id)
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a shared load alive when its first observer cancels', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('cancelled-first-observer')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const load = vi.fn(() => gate.promise)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('first observer cancelled')
|
||||
const first = preparations.inspect(id, load, controller.signal)
|
||||
const joined = preparations.inspect(id, load)
|
||||
|
||||
controller.abort(reason)
|
||||
await expect(first).rejects.toBe(reason)
|
||||
const source = prepared(id)
|
||||
gate.resolve(source)
|
||||
await expect(joined).resolves.toBe(source)
|
||||
await expect(preparations.inspect(id, load)).resolves.toBe(source)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('evicts completed loads whose observers cancelled before readiness', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const firstId = SessionId('cancelled-ready-first')
|
||||
const secondId = SessionId('cancelled-ready-second')
|
||||
const firstGate = Promise.withResolvers<PreparedSource>()
|
||||
const secondGate = Promise.withResolvers<PreparedSource>()
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const first = preparations.inspect(firstId, () => firstGate.promise, firstController.signal)
|
||||
const second = preparations.inspect(secondId, () => secondGate.promise, secondController.signal)
|
||||
|
||||
firstController.abort(new Error('first observer cancelled'))
|
||||
secondController.abort(new Error('second observer cancelled'))
|
||||
await expect(first).rejects.toThrow('first observer cancelled')
|
||||
await expect(second).rejects.toThrow('second observer cancelled')
|
||||
|
||||
firstGate.resolve(prepared(firstId))
|
||||
await firstGate.promise
|
||||
secondGate.resolve(prepared(secondId))
|
||||
await secondGate.promise
|
||||
await Promise.resolve()
|
||||
|
||||
expect(preparations.has(firstId)).toBe(false)
|
||||
expect(preparations.has(secondId)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes failed and invalidated in-flight loads without changing their observers', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const failedId = SessionId('failed-inspection')
|
||||
const failure = new Error('load failed')
|
||||
await expect(preparations.inspect(failedId, () => Promise.reject(failure))).rejects.toBe(failure)
|
||||
expect(preparations.has(failedId)).toBe(false)
|
||||
|
||||
const invalidatedId = SessionId('invalidated-inspection')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const inspection = preparations.inspect(invalidatedId, () => gate.promise)
|
||||
preparations.invalidate(invalidatedId)
|
||||
const source = prepared(invalidatedId)
|
||||
gate.resolve(source)
|
||||
await expect(inspection).resolves.toBe(source)
|
||||
expect(preparations.has(invalidatedId)).toBe(false)
|
||||
|
||||
const rejectedId = SessionId('invalidated-rejection')
|
||||
const rejectedGate = Promise.withResolvers<PreparedSource>()
|
||||
const rejected = preparations.inspect(rejectedId, () => rejectedGate.promise)
|
||||
preparations.invalidate(rejectedId)
|
||||
rejectedGate.reject(failure)
|
||||
await expect(rejected).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('removes a load that throws before returning its promise', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('synchronous-load-failure')
|
||||
const failure = new Error('synchronous load failure')
|
||||
|
||||
await expect(preparations.inspect(id, () => { throw failure })).rejects.toBe(failure)
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('evicts ready entries while leaving reserved entries alone', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const reservedA = await preparations.reserve(
|
||||
SessionId('reserved-a'),
|
||||
() => Promise.resolve(prepared('reserved-a')),
|
||||
committed,
|
||||
)
|
||||
const reservedB = await preparations.reserve(
|
||||
SessionId('reserved-b'),
|
||||
() => Promise.resolve(prepared('reserved-b')),
|
||||
committed,
|
||||
)
|
||||
expect(reservedA).toBeDefined()
|
||||
expect(reservedB).toBeDefined()
|
||||
|
||||
await preparations.inspect(SessionId('ready-c'), () => Promise.resolve(prepared('ready-c')))
|
||||
preparations.release(reservedA!, true)
|
||||
expect(preparations.has(SessionId('reserved-b'))).toBe(true)
|
||||
expect(preparations.has(SessionId('ready-c'))).toBe(false)
|
||||
expect(preparations.has(SessionId('reserved-a'))).toBe(true)
|
||||
|
||||
preparations.discard(reservedB!)
|
||||
preparations.invalidate(SessionId('reserved-a'))
|
||||
})
|
||||
|
||||
it('discards only the exact ready source and retains exclusive reservations', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const ready = prepared('discard-ready')
|
||||
expect(preparations.discardReady(ready.session.id, ready)).toBe('missing')
|
||||
await preparations.inspect(ready.session.id, () => Promise.resolve(ready))
|
||||
expect(preparations.discardReady(ready.session.id, prepared('different'))).toBe('missing')
|
||||
expect(preparations.discardReady(ready.session.id, ready)).toBe('discarded')
|
||||
|
||||
const reserved = await preparations.reserve(
|
||||
ready.session.id,
|
||||
() => Promise.resolve(ready),
|
||||
committed,
|
||||
)
|
||||
expect(preparations.discardReady(ready.session.id, ready)).toBe('retained')
|
||||
preparations.release(reserved!, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPreparations reservation', () => {
|
||||
it('waits for an existing reservation, republishes the exact Session, and attaches once', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(2)
|
||||
const id = SessionId('reservation-wait')
|
||||
const source = prepared(id)
|
||||
const first = await preparations.reserve(id, () => Promise.resolve(source), committed)
|
||||
expect(first).toBeDefined()
|
||||
expect(preparations.reservationFor(source.session)).toBe(first)
|
||||
expect(() => preparations.reservationFor(Session.create(id))).toThrow(/cannot publish/)
|
||||
expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/)
|
||||
|
||||
let secondSettled = false
|
||||
const secondPromise = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed)
|
||||
.then((reservation) => {
|
||||
secondSettled = true
|
||||
return reservation
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(secondSettled).toBe(false)
|
||||
|
||||
preparations.release(first!, true)
|
||||
const second = await secondPromise
|
||||
expect(second?.source).toBe(source)
|
||||
preparations.attach(second!)
|
||||
expect(preparations.reservationFor(source.session)).toBeUndefined()
|
||||
expect(() => { preparations.attach(second!) }).toThrow(/no longer reserved/)
|
||||
preparations.discard(second!)
|
||||
preparations.release(second!, true)
|
||||
expect(() => { preparations.assertWritable(id) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('supports abortable reservation waits without cancelling the held reservation', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('abortable-reservation-wait')
|
||||
const first = await preparations.reserve(id, () => Promise.resolve(prepared(id)), committed)
|
||||
const controller = new AbortController()
|
||||
const reason = { kind: 'cancelled' }
|
||||
const waiting = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed, controller.signal)
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
controller.abort(reason)
|
||||
await expect(waiting).rejects.toBe(reason)
|
||||
expect(preparations.reservationFor(first!.source.session)).toBe(first)
|
||||
preparations.release(first!, false)
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('removes a failed commit and wakes another waiter as invalidated', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('failed-commit')
|
||||
const commitStarted = Promise.withResolvers<undefined>()
|
||||
const commitGate = Promise.withResolvers<{ source: PreparedSource; state: string }>()
|
||||
const source = prepared(id)
|
||||
const failure = new Error('commit failed')
|
||||
const first = preparations.reserve(id, () => Promise.resolve(source), () => {
|
||||
commitStarted.resolve(undefined)
|
||||
return commitGate.promise
|
||||
})
|
||||
await commitStarted.promise
|
||||
expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/)
|
||||
const second = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed)
|
||||
|
||||
commitGate.reject(failure)
|
||||
await expect(first).rejects.toBe(failure)
|
||||
await expect(second).resolves.toBeUndefined()
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns a post-commit cancellation to the ready pool', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('post-commit-cancel')
|
||||
const source = prepared(id)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel after commit')
|
||||
|
||||
await expect(preparations.reserve(id, () => Promise.resolve(source), async (value) => {
|
||||
controller.abort(reason)
|
||||
return { source: value, state: value.label }
|
||||
}, controller.signal)).rejects.toBe(reason)
|
||||
|
||||
expect(preparations.takeReady(id)).toBe(source)
|
||||
expect(preparations.takeReady(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not revive an invalidated commit after post-commit cancellation', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('invalidated-commit-cancel')
|
||||
const source = prepared(id)
|
||||
const commitStarted = Promise.withResolvers<undefined>()
|
||||
const commitGate = Promise.withResolvers<undefined>()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel invalidated commit')
|
||||
const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => {
|
||||
commitStarted.resolve(undefined)
|
||||
await commitGate.promise
|
||||
return { source: value, state: value.label }
|
||||
}, controller.signal)
|
||||
|
||||
await commitStarted.promise
|
||||
preparations.invalidate(id)
|
||||
controller.abort(reason)
|
||||
commitGate.resolve(undefined)
|
||||
await expect(reservation).rejects.toBe(reason)
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not reserve an entry invalidated while its commit succeeds', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('invalidated-successful-commit')
|
||||
const source = prepared(id)
|
||||
const commitStarted = Promise.withResolvers<undefined>()
|
||||
const commitGate = Promise.withResolvers<undefined>()
|
||||
const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => {
|
||||
commitStarted.resolve(undefined)
|
||||
await commitGate.promise
|
||||
return { source: value, state: value.label }
|
||||
})
|
||||
|
||||
await commitStarted.promise
|
||||
preparations.invalidate(id)
|
||||
commitGate.resolve(undefined)
|
||||
|
||||
await expect(reservation).resolves.toBeUndefined()
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns undefined when a load is invalidated before reservation', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('invalidated-reservation')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const reservation = preparations.reserve(id, () => gate.promise, committed)
|
||||
preparations.invalidate(id)
|
||||
gate.resolve(prepared(id))
|
||||
await expect(reservation).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips pending adoption and accepts a ready source exactly once', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('take-ready')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const inspection = preparations.inspect(id, () => gate.promise)
|
||||
expect(preparations.takeReady(id)).toBeUndefined()
|
||||
const source = prepared(id)
|
||||
gate.resolve(source)
|
||||
await inspection
|
||||
expect(preparations.takeReady(id)).toBe(source)
|
||||
expect(preparations.takeReady(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects publication while only an inspection exists', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const source = prepared('inspection-publication')
|
||||
await preparations.inspect(source.session.id, () => Promise.resolve(source))
|
||||
expect(() => preparations.reservationFor(source.session)).toThrow(/cannot publish/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('observeQueuedAbort', () => {
|
||||
it('relays fulfillment and rejection exactly', async () => {
|
||||
const signal = new AbortController().signal
|
||||
await expect(observeQueuedAbort(Promise.resolve('value'), signal)).resolves.toBe('value')
|
||||
const failure = { kind: 'failed' }
|
||||
const rejected = Promise.withResolvers<never>()
|
||||
rejected.reject(failure)
|
||||
await expect(observeQueuedAbort(rejected.promise, signal)).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('rejects promptly with an exact abort reason and ignores later settlement', async () => {
|
||||
const operation = Promise.withResolvers<string>()
|
||||
const controller = new AbortController()
|
||||
const reason = { kind: 'aborted' }
|
||||
const observed = observeQueuedAbort(operation.promise, controller.signal)
|
||||
controller.abort(reason)
|
||||
await expect(observed).rejects.toBe(reason)
|
||||
operation.resolve('late')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('observes a pre-aborted signal through the default start predicate', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('pre-aborted')
|
||||
await expect(observeQueuedAbort(new Promise<never>(() => {}), controller.signal))
|
||||
.rejects.toBe('pre-aborted')
|
||||
})
|
||||
|
||||
it('lets an operation that already started own cancellation settlement', async () => {
|
||||
const operation = Promise.withResolvers<string>()
|
||||
const controller = new AbortController()
|
||||
const observed = observeQueuedAbort(operation.promise, controller.signal, () => true)
|
||||
controller.abort(new Error('too late'))
|
||||
operation.resolve('owned')
|
||||
await expect(observed).resolves.toBe('owned')
|
||||
})
|
||||
})
|
||||
275
packages/session/session-persistence/tests/write-behind.spec.ts
Normal file
275
packages/session/session-persistence/tests/write-behind.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SessionWriteBehind } from '../src/write-behind.ts'
|
||||
|
||||
/** Minimal ordered event fixture; batching does not interpret event vocabulary. */
|
||||
function event(seq: number): SessionEvent<'turn/start'> {
|
||||
return {
|
||||
type: 'turn/start',
|
||||
seq,
|
||||
time: seq,
|
||||
data: { turn: seq + 1 },
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('SessionWriteBehind', () => {
|
||||
it('uses one fixed window from the first queued event and owns its copy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const batches: SessionEvent[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => { batches.push(structuredClone(events) as SessionEvent[]) },
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
const first = event(0)
|
||||
|
||||
controller.enqueue(first)
|
||||
first.data.turn = 99
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(49)
|
||||
expect(batches).toEqual([])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(batches).toEqual([[
|
||||
expect.objectContaining({ seq: 0, data: { turn: 1 } }),
|
||||
expect.objectContaining({ seq: 1 }),
|
||||
]])
|
||||
expect(controller.hasWork).toBe(false)
|
||||
})
|
||||
|
||||
it('coalesces twenty events admitted ten milliseconds apart into one 200 ms batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => { batches.push(events.map(item => item.seq)) },
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
for (let seq = 1; seq < 20; seq += 1) {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
controller.enqueue(event(seq))
|
||||
}
|
||||
expect(batches).toEqual([])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(batches).toEqual([Array.from({ length: 20 }, (_, seq) => seq)])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('makes concurrent flushes one immediate barrier that drains admitted tails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (batches.length === 1) await gate.promise
|
||||
},
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
const first = controller.flush()
|
||||
const second = controller.flush()
|
||||
expect(second).toBe(first)
|
||||
await Promise.resolve()
|
||||
expect(batches).toEqual([[0]])
|
||||
|
||||
controller.enqueue(event(1))
|
||||
gate.resolve(true)
|
||||
await first
|
||||
expect(batches).toEqual([[0], [1]])
|
||||
expect(controller.hasWork).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('starts a new window for work admitted after an already-quiescent barrier', async () => {
|
||||
vi.useFakeTimers()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => { batches.push(events.map(item => item.seq)) },
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
const barrier = controller.flush()
|
||||
controller.enqueue(event(0))
|
||||
await barrier
|
||||
expect(batches).toEqual([])
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(batches).toEqual([[0]])
|
||||
expect(controller.hasWork).toBe(false)
|
||||
})
|
||||
|
||||
it('starts an over-budget tail immediately after the active write', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (batches.length === 1) await gate.promise
|
||||
},
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(batches).toEqual([[0]])
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(batches).toEqual([[0]])
|
||||
|
||||
gate.resolve(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(batches).toEqual([[0], [1]])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('keeps a tail deadline that has not expired when the active write finishes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (batches.length === 1) await gate.promise
|
||||
},
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
gate.resolve(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(batches).toEqual([[0]])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
expect(batches).toEqual([[0]])
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(batches).toEqual([[0], [1]])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('pauses automatic retries after failure and preserves order for new work', async () => {
|
||||
vi.useFakeTimers()
|
||||
const failure = new Error('storage unavailable')
|
||||
const report = vi.fn()
|
||||
const batches: number[][] = []
|
||||
let attempt = 0
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (++attempt === 1) throw failure
|
||||
},
|
||||
reportBackgroundFailure: report,
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(report).toHaveBeenCalledWith(failure)
|
||||
expect(controller.hasWork).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(batches).toEqual([[0]])
|
||||
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
expect(batches).toEqual([[0]])
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(batches).toEqual([[0], [0, 1]])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('observes an overlapping background failure and retries it inside flush', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const report = vi.fn()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (batches.length === 1) {
|
||||
await gate.promise
|
||||
throw new Error('transient')
|
||||
}
|
||||
},
|
||||
reportBackgroundFailure: report,
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
const first = controller.flush()
|
||||
const second = controller.flush()
|
||||
gate.resolve(true)
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined])
|
||||
expect(batches).toEqual([[0], [0]])
|
||||
expect(report).toHaveBeenCalledOnce()
|
||||
expect(controller.hasWork).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces a barrier failure without detached logging and retains its batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const failure = new Error('durability failed')
|
||||
const report = vi.fn()
|
||||
const batches: number[][] = []
|
||||
let attempt = 0
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (++attempt === 1) throw failure
|
||||
},
|
||||
reportBackgroundFailure: report,
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await expect(controller.flush()).rejects.toBe(failure)
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(controller.hasWork).toBe(true)
|
||||
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(batches).toEqual([[0], [0, 1]])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('retains a failed batch larger than the engine call-argument limit', async () => {
|
||||
const failure = new Error('durability failed')
|
||||
const batchSize = 150_000
|
||||
const sizes: number[] = []
|
||||
let attempt = 0
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
sizes.push(events.length)
|
||||
if (++attempt === 1) throw failure
|
||||
},
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
for (let seq = 0; seq < batchSize; seq += 1) controller.enqueue(event(seq))
|
||||
await expect(controller.flush()).rejects.toBe(failure)
|
||||
expect(controller.hasWork).toBe(true)
|
||||
|
||||
await controller.flush()
|
||||
expect(sizes).toEqual([batchSize, batchSize])
|
||||
expect(controller.hasWork).toBe(false)
|
||||
})
|
||||
})
|
||||
27
packages/session/session-persistence/tsconfig.json
Normal file
27
packages/session/session-persistence/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user