diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml similarity index 66% rename from .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml rename to .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index a6f873d0f8..1d5dbfcc3e 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: a61ceb9b2197a6dd8ed86c1c971373a2706607aa -2026-06-18-shared-persistence-write-coordinator.zh.md: 777d5f5972ac1096c2e3434f9e0ac5aec27e8c26 +2026-06-18-shared-persistence-write-coordinator.md: 5c324f2c0c2b951f664bcf92725a36c4975fd3a1 +2026-06-18-shared-persistence-write-coordinator.zh.md: 51ef76189cb9276214bf05bbd1dbd8e3755daa35 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md rename to .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.md index a61ceb9b21..5c324f2c0c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -1,6 +1,7 @@ # Agent Note: Shared persistence write coordinator Status: implemented +Archived: 2026-08-31 English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md rename to .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 777d5f5972..51ef76189c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -1,6 +1,7 @@ # Agent Note: 共享持久化写入协调器 Status: implemented +Archived: 2026-08-31 [English](2026-06-18-shared-persistence-write-coordinator.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 18173d7d3d..fa3abbea4f 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -10,6 +10,9 @@ "architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml": "sha256:7eb471a53b7bef104c57e9343b80d672763f062ecb086b01b318b65b488d3c02", "architecture/2026-06-15-turn-enclosure-invariant.md": "sha256:afefa3a268c84f26cf5461e08933245352a9e63cff688d3c398c8064a4ac6e85", "architecture/2026-06-15-turn-enclosure-invariant.zh.md": "sha256:c54fdac980abc922cdc252a8fef59e4bdd7567316c7fbb6f7dbc035e470d95fa", + "architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml": "sha256:3c5c22e9e6a63598ba648cad46d783af322cf3afd6021426a2d738f4b026bf65", + "architecture/2026-06-18-shared-persistence-write-coordinator.md": "sha256:d5242c770101086b6f0a0c40eab500d405ef4a98cae07e28d9ec21e89d94f90e", + "architecture/2026-06-18-shared-persistence-write-coordinator.zh.md": "sha256:3dce52e302600a0eea29b4821a2718b6bbc1ebe4c6c2ae372cd0cbe66cb05519", "architecture/2026-06-20-extract-example-app-packages.i18n.yaml": "sha256:d99b612cc1051c86d883d74737c72e921735e7a28e0b5e6351d3870c664bdcc4", "architecture/2026-06-20-extract-example-app-packages.md": "sha256:9c7aca3a1e9a1ccc3729961663bc649b90076e671cae23e3db8203305983ccce", "architecture/2026-06-20-extract-example-app-packages.zh.md": "sha256:19bd50232d9f25d35aa3f9dc72d9af0df457dd0eaca8b982d5aa625e5b95bcff", diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 3bdde9ece6..34927aeae7 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-06-14-session-persistence.md -2026-06-14-session-persistence.md: 50ec79de83f0cef4a3ec94b689cc25937e334016 -2026-06-14-session-persistence.zh.md: 7b66aed6f077ac484802cfa1e23e1ba7ac3ae985 +2026-06-14-session-persistence.md: a7e06af78c4a372be7a68f3e0f6dc18e38cbead1 +2026-06-14-session-persistence.zh.md: 6d458d4f4c31793212d674bb406204c3882a25ed diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 50ec79de83..a7e06af78c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -14,22 +14,22 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is a **capability seam** with an abstract Service Definition ([capability seams](2026-06-13-capability-seams.md), the `dsh-shell` template), not loop or core logic: -1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. +1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`open`/`stat`/`list`/`export`, with `create`/`open` returning per-session `SessionHandle`s that carry `read`/`append`/`flush`/`close` ([handle-based seam](2026-08-27-handle-based-session-persistence.md)). Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. 2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Eligible `assistant/chunk` delta runs use packed rows by default; [checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable. Key durable, contested choices: - **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. -- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service and coordinator remain available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut. +- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, persistence returns its contiguous, parseable events unmodified; the reader owns balancing — resume computes risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` (`interruptedTurnClosers`) and appends them through its write handle, while read-only observers add the same closers in memory. The synthetic results keep resumed provider transcripts valid. Only the incomplete fragment of a torn final append is discarded — complete records recovered from it are durably rewritten by the write path before its first new append; a parse error or sequence gap in the committed prefix is corruption and makes the session unloadable. +- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service remains available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` obtains the exact unpublished Session through `ctx.sessionPersistence.prepare()`, publishes it under the persisted id, and continues its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns reuse between history inspection and resume. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` opens the session's write handle, reads the stored log, and publishes the prepared Session under the persisted id, continuing its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns the unpublished-Session ownership window. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract. +Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no compatibility promise: reads validate current v0 records only, and retired same-version shapes refuse fail-closed ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index 7b66aed6f0..6d458d4f4c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -14,22 +14,22 @@ Status: implemented 持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑: -1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 +1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`open`/`stat`/`list`/`export`,其中 `create`/`open` 返回逐会话的 `SessionHandle`,句柄承载 `read`/`append`/`flush`/`close`([基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md))。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。 长期有效、存在争议的关键选择: - **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 -- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务与 coordinator 继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——resume 会为未应答的 assistant 调用计算按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`(`interruptedTurnClosers`),并通过其写句柄追加它们,而只读观察方仅在内存中添加同样的收尾事件。合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有撕裂的最终 append 中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在第一次新 append 之前持久重写;已提交前缀中的解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 打开该会话的写句柄,读取已存储的日志,并以持久化 id 发布准备好的 Session,继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义未发布 Session 的所有权窗口。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不作兼容承诺:读取只校验当前 v0 记录,已废弃的同版本形态会以 fail-closed 方式拒绝([导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml index f53ede9338..38f55f03e8 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md -2026-07-19-zstandard-jsonl-session-logs.md: 93fc20f931c75552352834b9340e7d38680d4254 -2026-07-19-zstandard-jsonl-session-logs.zh.md: d58f89430ab91de6beabba83c2a31f43e4a7d275 +2026-07-19-zstandard-jsonl-session-logs.md: 33486251a8b018cda61a2845a55218f13c38072b +2026-07-19-zstandard-jsonl-session-logs.zh.md: 5e6b4a1ed7cf5869e1700c2884898b9899452a30 diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md index 93fc20f931..33486251a8 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md @@ -32,7 +32,7 @@ A frame-boundary scanner reads the standard magic, variable header fields, block Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs. -EOF inside the final frame is a recoverable torn tail. After the scanner establishes that boundary, a dedicated prefix decoder uses `finishFlush: ZSTD_e_flush` so Node emits available plaintext without requiring frame or checksum completion; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames. +EOF inside the final frame is a torn tail. The frame belongs to an append that never resolved, so none of its records were acknowledged durable: repair truncates from that frame's starting byte, retains all prior complete frames, and appends the coordinator's synthetic tool, step, and turn closers as one new checksummed frame ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) owns dropping the earlier partial-plaintext salvage). ### Consumers and verification diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md index d58f89430a..5e6b4a1ed7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md @@ -32,7 +32,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量 列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。 -最终帧内部遇到 EOF 属于可恢复的撕裂尾部。扫描器确定该边界后,专用前缀解码器会使用 `finishFlush: ZSTD_e_flush`,使 Node 不必等到帧结束或读到完整校验和就能产出已有明文;其中每个完整且以换行结束的事件都会保留。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧。 +最终帧内部遇到 EOF 属于撕裂尾部。该帧属于一次从未完成结算的追加,因此其中没有任何记录被确认为持久:修复从该帧起始字节截断,保留此前全部完整帧,并把协调器生成的工具、步骤与轮次闭合事件作为一个新的带校验和帧追加(对早先部分明文抢救路径的移除由[导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)负责)。 ### 消费方与验证 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml index c20fc92181..e1483287f0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md -2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2 -2026-07-24-project-session-directories.zh.md: 932b1d29c41d2a854abfc0bab0e47a0ff8c96fe9 +2026-07-24-project-session-directories.md: a37f9231167822e409308f8da60f6c1e837c74d5 +2026-07-24-project-session-directories.zh.md: 469567764219d7baabea89bd94aecd81bd0e5ab3 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md index 0aa3f513d5..a37f923116 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md @@ -29,7 +29,7 @@ Case-insensitive filesystems can also make differently cased project keys refer The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure. -The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change. +The encoded session id names an ownership directory rather than the transcript itself. The backend's diagnostics-only `locate` hook resolves the fixed transcript path inside it for format-refusal messages ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) owns removing the consumer-facing path query). Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change. Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `/.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration. diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md index 932b1d29c4..4695677642 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -29,7 +29,7 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 -编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。 +编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。后端仅供诊断的 `locate` 钩子在其中解析固定的 transcript 路径,供格式拒绝消息使用(移除面向消费者的路径查询由[导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)负责)。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。 延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `/.jsonl*` 产物;预发布格式不提供自动数据迁移。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml index 3e3b610f73..e96c1b2e6b 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-05-session-preparation.md -2026-08-05-session-preparation.md: 50f1ea38e671c6aa7b0f4adaf2fecbf83decc23c -2026-08-05-session-preparation.zh.md: cd918d126b56b081bcc1b6aa43a10d662668102d +2026-08-05-session-preparation.md: 040c9f788173a7cedd91be33cbe7ced3ca758a06 +2026-08-05-session-preparation.zh.md: 44b609d488c6f8bb406370f9097eb2a085f7cc2b diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md index 50f1ea38e6..040c9f7881 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md @@ -6,63 +6,40 @@ English | [中文](2026-08-05-session-preparation.zh.md) ## Problem -Cold history inspection and Agent resume independently materialized the same persisted session log. For a large compressed log, each operation repeated the full read, decompression, parse, validation, freezing, and Session construction. Pagination could therefore pay the cold-read cost again, while making a history query activate an Agent would couple a read lifecycle to a live Agent with no natural retirement point. +Fresh creation and persisted resume reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together. -Fresh creation and persisted resume also reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together. +Cold history inspection and Agent resume also independently materialized the same persisted session log, which this note originally answered with a persistence-side prepared-Session cache; that half is superseded below. ## Decision -`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume obtains a preparation from `SessionPersistence.prepare()`. +`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume reads the stored log through the session's write handle, appends `interruptedTurnClosers`, and wraps `SessionStore.prepare(id, { seed, meta, seedSource: 'persistence' })` — the restoration branch that validates and freezes the transferred graphs in place. The Agent loop consumes both forms through one setup-and-publication pipeline: it acquires the preparation, builds the private Agent context around `preparation.session`, awaits optional setup, publishes that exact Session and Agent, and disposes the preparation on every exit. Publication transfers the live lifecycle to the existing Session and Agent stores; `SessionPreparation` itself owns no Agent behavior. This refines the publication boundary from the [Agent lifecycle and ownership decision](2026-06-18-agent-lifecycle-and-ownership-contracts.md) without replacing its ownership model. -## Persisted preparation lifecycle +## Superseded: the persistence-side preparation lifecycle -A coordinator-backed persistence implementation loads one cold source into a prepared Session. The backend transfers fresh, mutually unaliased metadata and events together with the source-qualified revision that identifies those exact values; the Session restore path validates and freezes the graphs in place instead of cloning them. The coordinator computes interrupted-turn closers and constructs the exact unpublished Session once. Its immutable header and balanced logical event log form the `SessionInspection` borrowed by readers, while the revision remains internal to persistence. - -`inspect(id, signal?)` does not mutate storage. Synthetic closers exist only in the prepared in-memory view, and a torn physical tail remains untouched. Same-id callers share an in-flight cold read. Once ready, the preparation may remain in a per-coordinator LRU whose capacity defaults to five and is configurable by first-party backends. Before reusing a retained source, the coordinator reads that id's current revision; a mismatch evicts a ready source and repeats the cold materialization. A source already committing or reserved for resume remains exclusively owned, so concurrent inspection borrows that immutable view until publication or release. - -`prepare(id, signal?)` exclusively reserves the prepared Session. It confirms the retained revision before committing any torn-tail and interrupted-turn repair, establishes the durable cursor, then returns a disposable preparation. A stale source is discarded and reloaded instead of being repaired or published. A successful repair also discards the pre-repair source and materializes the committed log again before reservation, so a newer revision is never associated with an older event graph. Another same-id preparation waits until the reservation is published or released. Publication accepts only the exact reserved Session and attaches the committed cursor without rebuilding its history. Failed setup or cancellation returns an unchanged unpublished Session to the LRU; mutation or attachment consumes the reservation. - -The legacy `load(id)` API uses the same preparation and repair machinery, then discards its reservation and returns the immutable logical view. It remains a compatibility API, not the history-to-resume reuse path. This lifecycle extends the [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) while preserving the storage and recovery rules owned by the [session persistence decision](2026-06-14-session-persistence.md). - -## History and resume reuse - -History reads use `inspect()`, so repeated pages borrow the same immutable prepared state without activating an Agent. A later resume uses `prepare()` and receives the exact Session retained by inspection; it does not read, decompress, parse, clone, validate, or freeze the complete log again. - -If the durable log changes after inspection, its revision changes. The next history read or resume discards a retained ready Session and materializes the new log, so an old event graph cannot be associated with a newer snapshot revision. A source already claimed by an in-flight resume is not evicted: its exclusive owner keeps it through publication or release, and concurrent history may borrow the same immutable view. - -Cold continuable-subagent access follows the same path. Descriptor authorization first inspects the child, then `ctx.agents.resume()` reserves and publishes the retained Session. This preserves the lifecycle and authorization rules in the [continuable subagent conversation decision](../feature/2026-07-28-continuable-subagent-conversations.md) while removing its duplicate cold read. +This note originally also gave persistence a `prepare(id)`/`inspect(id)` lifecycle: a coordinator-backed bounded LRU of cold unpublished Sessions with exclusive reservations, revision-checked reuse, and repair committed inside `prepare`/`load`, so history pagination and a later resume shared one cold materialization. The [handle-based persistence seam](2026-08-27-handle-based-session-persistence.md) deletes all of it: persistence exposes handles only, resume reads the log through its write handle and owns repair, and read-only observers (session-query) own their cold-Session cache keyed by the `stat().revision` change token. The read-reuse goal survives in that cache; the exclusive-reservation machinery does not, because the write handle's single-writer ownership is the exclusion resume actually needs. Resume pays one whole-log read through the handle where the prepared cache sometimes served a warm Session — an accepted cost recorded in the handle note. ## Boundaries -- `readFrom()` remains a detached physical-suffix API. It neither creates nor consumes a preparation, synthesizes logical closers, or joins the LRU. -- HMR adoption keeps the live Session authoritative and reads the stored prefix directly. It may truncate a torn physical fragment but never closes the live open turn as interrupted. -- The cache belongs to one persistence coordinator, not a process-global Session map. Live Sessions are owned by the existing stores and never occupy preparation capacity. -- A fresh create never claims a cold persisted preparation with the same id. Persistence collisions continue to reject. -- Third-party persistence implementations retain the abstract `prepare()` fallback through `load()`. They receive the same publication interface but gain exact-object reuse only when they override preparation. -- Revision validation establishes freshness at the reuse and repair-commit points; it does not add cross-process writer exclusion to a backend. Retries converge after the durable log remains unchanged for one read/check round trip, so continuous external writers can delay preparation. +- The preparation is one disposable ownership window, not a cache: disposal is synchronous and idempotent, and publication accepts only the exact prepared Session. +- A fresh create never claims a persisted identity implicitly. Persistence collisions continue to reject (`SessionAlreadyExistsError`, `SessionAlreadyOwnedError`). +- Live Sessions are owned by the existing stores; preparations hold only unpublished ones. ## Verification -The shared persistence contract pins non-mutating balanced cold inspection and later repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, revision-triggered refresh before history and resume, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Backend tests pin that full and lightweight reads use the same revision identity. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown. +Agent-loop tests pin the common publication pipeline across create, `createAgent`, and resume, including rollback on setup failure, cancellation, and teardown, and that disposal releases the write handle (reopening for write succeeds). Session-store tests pin the restoration branch's validate-and-freeze-in-place transfer. ## Alternatives considered -**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle. +**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle. This rationale still guards the session-query cold cache: observation never creates an Agent. -**Cache only `{ meta, events }`.** Rejected because resume would still reconstruct, validate, freeze, and copy a Session from the cached values. The exact unpublished Session is the reusable unit. +**Cache only `{ meta, events }`.** Rejected at the time because resume would still reconstruct a Session from the cached values. Under the handle seam this is exactly what the read side does — session-query caches a cold Session per revision for reads only — while resume rebuilds from the handle read, trading the warm-Session reuse for a single write-ownership door. -**Keep a process-global Session map.** Rejected because it would cross backend and runtime ownership boundaries, retain unbounded identities, and duplicate the live Session store. - -**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading, repair, reservation, and cursor attachment are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary. - -**Turn `readFrom()` into logical preparation.** Rejected because watermark consumers need a detached physical suffix and, on seek-capable backends, a bounded read. Recovery balancing and whole-Session reuse have different semantics. +**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading and Session construction are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary; the handle seam kept that split while moving repair to the loop's resume path. ## Consequences -One cold materialization can serve history pagination, subagent descriptor inspection, and a later resume. Ownership transfer removes redundant restoration clones, while the bounded per-coordinator LRU limits memory and avoids creating live Agents for queries. Create and resume share one publication protocol without merging Agent and Session responsibilities. - -The first cold inspection now pays the complete validation and Session-construction cost and may retain that unpublished Session until eviction. Persistence must coordinate reservation, append, repair, and publication, and callers must treat inspection values as immutable borrowed state. Backends that rely on the default `prepare()` remain correct but do not receive the reuse optimization. +Create and resume share one publication protocol without merging Agent and Session responsibilities, and every exit path disposes exactly one preparation. The persistence-side reuse consequences originally recorded here (shared cold materialization, LRU bounds, reservation coordination) now belong to the [handle note](2026-08-27-handle-based-session-persistence.md) and the session-query cache that replaced them. diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md index cd918d126b..44b609d488 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md @@ -6,63 +6,40 @@ Status: implemented ## 问题 -冷历史检查和 agent(智能体)恢复会分别实体化同一份持久会话日志。对于大型压缩日志,每次操作都会重新完整读取、解压、解析、验证、冻结并构造 Session。因此,历史分页可能反复承担冷读成本;如果改为由历史查询激活 agent,读取生命周期又会与缺少自然退出时机的实时 agent 耦合。 +新建和持久化恢复通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent。 -新建和持久化恢复也通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent。 +冷历史检查和 agent(智能体)恢复也曾分别实体化同一份持久会话日志,本 Note 最初以持久化侧的已准备 Session 缓存回答了这一半问题;那一半已在下文中被取代。 ## 决策 -`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复则从 `SessionPersistence.prepare()` 取得准备对象。 +`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复通过该会话的写句柄读取已存储的日志、追加 `interruptedTurnClosers`,再包装 `SessionStore.prepare(id, { seed, meta, seedSource: 'persistence' })`——即就地验证并冻结转移对象图的恢复分支。 agent loop(智能体循环)通过同一条设置与发布流水线消费这两种形式:先取得准备对象,围绕 `preparation.session` 构建私有 agent 上下文,等待可选设置完成,再发布该精确 Session 和 agent,并在所有退出路径上对准备对象执行 dispose(资源释放)。发布后,实时生命周期由现有 Session 与 agent 存储接管;`SessionPreparation` 本身不负责任何 agent 行为。 该机制细化了 [agent 生命周期与所有权决策](2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md)中的发布边界,但不替换其所有权模型。 -## 持久化准备生命周期 +## 已被取代:持久化侧的准备生命周期 -使用协调器的持久化实现会将一个冷源加载为准备完成的 Session。后端转移新鲜、彼此无别名的元数据和事件,以及标识这些精确值的来源限定 revision;Session 恢复路径直接验证并冻结这些对象图,不再复制。协调器计算中断轮次的 closer,并且只构造一次精确的未发布 Session。其不可变 header 与已配平的逻辑事件日志构成读取方借用的 `SessionInspection`,revision 则保留在持久化内部。 - -`inspect(id, signal?)` 不修改存储。合成 closer 只存在于准备完成的内存视图中,撕裂的物理尾部保持不变。同 id 调用方共享进行中的冷读。准备完成后,该对象可以进入每个协调器自己的 LRU;第一方后端可配置容量,默认保留五个。协调器复用保留源之前会读取该 id 的当前 revision;如果不匹配,就淘汰处于就绪阶段的源并重新完成冷实体化。已经进入提交或为恢复而预留的源仍由其所有者独占,因此并发检查会借用该不可变视图,直至发布或释放。 - -`prepare(id, signal?)` 独占预留准备完成的 Session。它先确认保留的 revision,再提交撕裂尾部和中断轮次修复、建立持久游标,最后返回可 dispose 的准备对象。陈旧源会被丢弃并重新读取,不会参与修复或发布。修复成功后也会丢弃修复前的源,并在预留前重新实体化已提交日志,以免把较新的 revision 关联到较旧的事件对象图。同 id 的另一个准备请求会等待当前预留发布或释放。发布只接受精确的预留 Session,并直接附接已提交游标,无需重建历史。设置失败或取消时,未发生变化的未发布 Session 会返回 LRU;发生变更或完成附接后,系统会消费该预留。 - -存量 `load(id)` API 使用相同的准备和修复机制,随后丢弃其预留并返回不可变逻辑视图。它保留为兼容 API,不承担历史到恢复的复用路径。该生命周期扩展了[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.zh.md),同时继续遵循[会话持久化决策](2026-06-14-session-persistence.zh.md)所规定的存储与恢复规则。 - -## 历史与恢复复用 - -历史读取使用 `inspect()`,因此重复分页可以借用同一份不可变准备状态,而不会激活 agent。后续恢复调用 `prepare()`,直接取得检查阶段保留的精确 Session;系统不会再次完整读取、解压、解析、复制、验证或冻结日志。 - -如果持久日志在检查后发生变化,其 revision 也会变化。下一次历史读取或恢复会丢弃保留且处于就绪阶段的 Session,并实体化新日志,因此旧事件对象图不会被关联到较新的快照 revision。已经由进行中恢复操作取得的源不会被淘汰:其独占所有者会持有它直至发布或释放,并发历史读取可以借用同一个不可变视图。 - -冷 continuable subagent 访问沿用同一路径。系统先检查子会话并完成 descriptor 授权,再由 `ctx.agents.resume()` 预留并发布保留的 Session。这样既遵循 [continuable subagent 会话决策](../feature/2026-07-28-continuable-subagent-conversations.zh.md)中的生命周期与授权规则,也消除了重复冷读。 +本 Note 最初还赋予持久化一个 `prepare(id)`/`inspect(id)` 生命周期:由协调器支撑的、装有冷未发布 Session 的有界 LRU,带独占预留、按 revision 校验的复用,以及在 `prepare`/`load` 内部提交的修复,使历史分页与后续恢复共享一次冷实体化。[基于句柄的持久化 seam](2026-08-27-handle-based-session-persistence.zh.md) 删除了这一切:持久化只暴露句柄,恢复通过其写句柄读取日志并自行负责修复,只读观察方(session-query)拥有自己的冷 Session 缓存,以 `stat().revision` 变更令牌为键。读取复用的目标在该缓存中得以延续;独占预留机制则没有延续,因为写句柄的单写者所有权正是恢复真正需要的排他手段。在已准备缓存有时能提供温 Session 的场景下,恢复要为通过句柄的一次全日志读取付出代价——这是句柄 Note 中记录的、已被接受的成本。 ## 边界 -- `readFrom()` 仍是脱离的物理后缀 API。它不会创建或消费准备对象,不会合成逻辑 closer,也不会进入 LRU。 -- HMR(热模块替换)接管继续以实时 Session 为权威,并直接读取已存储前缀。它可以截断撕裂的物理碎片,但绝不把实时开放轮次关闭为中断状态。 -- 缓存属于单个持久化协调器,而不是进程全局 Session map。实时 Session 由现有存储持有,绝不占用准备容量。 -- 新建流程绝不认领相同 id 的冷持久化准备对象。持久化冲突仍会被拒绝。 -- 第三方持久化实现继续获得通过 `load()` 实现的抽象 `prepare()` 回退。它们使用相同发布接口,但只有覆盖准备流程后才能复用精确对象。 -- Revision 校验在复用点和修复提交点建立新鲜度,但不会为后端增加跨进程 writer 排他。持久日志在一次读取与复核往返内保持不变后,重试才能收敛,因此持续的外部写入可能延迟准备。 +- 准备对象是一个可 dispose 的所有权窗口,而不是缓存:dispose 同步且幂等,发布只接受精确的已准备 Session。 +- 新建流程绝不隐式认领持久化身份。持久化冲突仍会被拒绝(`SessionAlreadyExistsError`、`SessionAlreadyOwnedError`)。 +- 实时 Session 由现有存储持有;准备对象只持有未发布的 Session。 ## 验证 -共享持久化约定规定冷检查不得修改存储且须保持配平,并覆盖后续修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、在历史读取与恢复前由 revision 触发刷新、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append,以及只允许发布预留 Session。后端测试覆盖完整读取与轻量读取使用同一 revision 身份。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和清理期间从检查到恢复的路径。 +agent loop 测试覆盖 create、`createAgent` 与 resume 之间的统一发布流水线,包括设置失败时的回滚、取消与清理,以及 dispose 会释放写句柄(重新以写模式打开可以成功)。Session store 测试覆盖恢复分支的就地验证并冻结的所有权转移。 ## 考虑过的替代方案 -**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。 +**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。该理由仍然守护着 session-query 冷缓存:观察绝不创建 agent。 -**只缓存 `{ meta, events }`。** 不采用,因为恢复仍需从缓存值重新构造、验证、冻结并复制 Session。真正可复用的单元是精确的未发布 Session。 +**只缓存 `{ meta, events }`。** 当时不采用,因为恢复仍需从缓存值重新构造 Session。在句柄 seam 下,这恰好是读取侧的做法——session-query 按 revision 为只读用途缓存一个冷 Session——而恢复则从句柄读取重建,以温 Session 复用换取唯一的写所有权之门。 -**维护进程全局 Session map。** 不采用,因为它会跨越后端和运行时所有权边界,无界保留身份,并与实时 Session 存储重复。 - -**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读、修复、预留和游标附接都属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界。 - -**把 `readFrom()` 改成逻辑准备流程。** 不采用,因为水位消费方需要脱离的物理后缀;对于可寻址后端,还需要限制实际读取范围。恢复平衡与完整 Session 复用具有不同语义。 +**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读与 Session 构造属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界;句柄 seam 保留了这一分工,同时把修复移入循环的恢复路径。 ## 后果 -一次冷实体化可以同时服务历史分页、subagent descriptor 检查和后续恢复。所有权转移去除了恢复阶段的冗余复制;每个协调器的有界 LRU 限制内存占用,也避免查询创建实时 agent。新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离。 - -首次冷检查需要承担完整验证与 Session 构造成本,并可能保留该未发布 Session 直至淘汰。持久化层必须协调预留、append、修复和发布;调用方必须把检查结果视为借用的不可变状态。依赖默认 `prepare()` 的后端仍然正确,但无法获得复用优化。 +新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离,且每条退出路径恰好 dispose 一个准备对象。本 Note 最初记录的持久化侧复用后果(共享冷实体化、LRU 上限、预留协调)如今归属于[句柄 Note](2026-08-27-handle-based-session-persistence.zh.md) 以及取代它们的 session-query 缓存。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml index 0ff8938b30..4a93d57022 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md -2026-08-08-bounded-session-persistence-write-batching.md: 20c16991b0be30ffe546a94c257bc65f86cb57eb -2026-08-08-bounded-session-persistence-write-batching.zh.md: ac0384f4e28175922f84d23296dfb13848cf5dd3 +2026-08-08-bounded-session-persistence-write-batching.md: 6fb44e494fc17bde08eb3132afe42ce73b5a4e47 +2026-08-08-bounded-session-persistence-write-batching.zh.md: 576764583dd8c465ae45866f62ef15773735961e diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md index 20c16991b0..6fb44e494f 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md @@ -20,19 +20,19 @@ The scheduling bound is deterministic. With an immediately resolving sink, the f ## Decision -The JSONL provider exposes `writeBatchMaxDelayMs`, a positive integer no greater than Node's timer limit. Its default is `200`. The provider resolves the value at load and passes it to `PersistenceCoordinator`; the coordinator remains the single owner of batching behavior. +The fixed window is the JSONL provider's constant `LIVE_WRITE_BATCH_MAX_DELAY_MS` (200 ms), an internal scheduling policy rather than configuration: the backend's own session listeners route live events by id into the active write handle's buffer, so batching never crosses the package boundary ([handle note](2026-08-27-handle-based-session-persistence.md)). -Each live Session receives a package-private `SessionWriteBehind`. When its pending queue changes from empty to non-empty, the controller starts one fixed window. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, the controller hands the complete pending prefix to the existing per-id serialization and `appendBatch` path. At most one write for a Session is active. Events admitted during that write form a new pending prefix with their own fixed deadline; if that deadline expires before the active write completes, the new prefix starts immediately after it. +Each active write handle owns its buffer directly. A routed event lands in the handle's pending array, and the first event of an idle buffer arms one fixed timer. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, a single-flight drain persists the pending prefix through the handle's mutation chain, which already serializes it against explicit appends. Events admitted during a drain pass coalesce into the next chained batch, in order. -`writeBatchMaxDelayMs` bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA. +The window bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA. -`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects. +`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement (`session/disposed`), the handle's close, and backend teardown's close sweep use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects. Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame without changing its on-disk format. -A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary. +A failed background drain retains its complete batch in order ahead of newer pending events, reports the failure once, and pauses the automatic timer. The next explicit drain — a `session/flush` barrier, service-level `flush()`, or close — retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary. -This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one controller per live Session, retained failed batches, per-id serialization, retirement, and quiescent disposal. The [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) remains the owner of the backend hook boundary. +This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one buffer owner per live Session, retained failed batches, retirement, and quiescent disposal. The coordinator and the separate write-behind controller that first hosted this behavior are deleted; the buffer, timer, and drain live on the provider's handle, and the [handle-based seam](2026-08-27-handle-based-session-persistence.md) owns the storage boundary they write through. ## Alternatives considered @@ -42,11 +42,11 @@ This decision supersedes only the immediate scheduling cadence in [Collapse live **Debounce from the latest event.** Rejected: a continuously streaming response could postpone its first write indefinitely. A fixed window from the first pending event provides a real upper bound on intentional coalescing wait. -**Implement the timer inside JSONL.** Rejected: scheduling, failure retention, flush races, and teardown are provider-neutral lifecycle concerns that belong in `PersistenceCoordinator`; an out-of-tree provider can reuse the same behavior. +**A shared provider-neutral controller component.** Rejected after one iteration shipped it: the handle's mutation chain already serializes writes, so a separate controller duplicated that ordering machinery. Each provider implements the buffer on its own handle, and the shared live-write contract suite pins the equivalent observable behavior for any provider. ## Verification -The controller tests use a fake clock to prove the fixed, non-resetting 200 ms window; immediate and shared flush barriers; events admitted during a barrier; an over-budget tail behind an active write; ordered failure retention; paused automatic retry; and explicit retry of an overlapping background failure. Coordinator tests run the controller through Session notifications, retirement, collision reclamation, and teardown. The JSONL suite retains storage-format, recovery, and shared persistence-contract coverage. +The shared live-write contract suite (`runLiveWritePathContract`) uses a fake clock to prove the fixed, non-resetting 200 ms window; the `session/flush` barrier and its loud failure surfacing; ordered failure retention with exactly-once recovery; the service-level `flush()` sweep with per-session failure aggregation; and the disposed/close/teardown drains. The JSONL suite retains its storage-format, recovery, and shared persistence-contract coverage. ## Consequences @@ -54,6 +54,6 @@ High-frequency event bursts normally produce fewer durable append operations whi This decision does not cap pending event count or bytes behind a slow provider, and it does not reduce the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule. -An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait. +An admitted event can remain only in memory during the fixed window, and then while scheduling or backend work is outstanding. Explicit durability boundaries remain unchanged and bypass the wait. -The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. `SESSION_FORMAT_VERSION` remains unchanged. +The handle gives the timer, active drain, pending prefix, retry pause, and barrier one owner; the backend's listeners own routing and lifecycle-driven drains. `SESSION_FORMAT_VERSION` remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md index ac0384f4e2..576764583d 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md @@ -20,19 +20,19 @@ JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fs ## 决策 -JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 Node 计时器上限的正整数,默认值为 `200`。provider 在加载时解析该值,再传给 `PersistenceCoordinator`;批处理行为仍只由协调器负责。 +固定窗口是 JSONL provider 的常量 `LIVE_WRITE_BATCH_MAX_DELAY_MS`(200 ms),它是内部调度策略而非配置:后端自己的会话监听器按 id 把实时事件路由进活跃写句柄的缓冲,因此批处理绝不跨越包边界([句柄 Note](2026-08-27-handle-based-session-persistence.zh.md))。 -每个活跃的会话都有一个包私有 `SessionWriteBehind`。当其待处理队列从空变为非空时,控制器会启动一个固定窗口。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,控制器会把完整的待处理前缀交给现有的按 id 串行化机制,并沿 `appendBatch` 路径写入。同一会话同时最多有一个活跃写入。该写入期间接纳的事件会形成新的待处理前缀,并拥有自己的固定截止时间;如果该截止时间在活跃写入完成前到期,新前缀会在前一次写入完成后立即开始写入。 +每个活跃写句柄直接拥有自己的缓冲。被路由的事件落入句柄的待处理数组,空闲缓冲收到的第一个事件会启动一个固定计时器。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,一次 single-flight 排空会把待处理前缀经由句柄的修改链持久化,该链本就将其与显式 append 串行化。排空进行期间接纳的事件会按顺序合并进下一个链上的批次。 -`writeBatchMaxDelayMs` 只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。 +该窗口只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。 -`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。 +`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役(`session/disposed`)、句柄的 close 与后端 teardown 的关闭清扫共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。 每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,而无需改变其磁盘格式。 -后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。 +后台排空失败后,其完整批次会按顺序保留在所有较新的待处理事件之前,该失败被报告一次,自动计时器随之暂停。下一次显式排空——`session/flush` 屏障、服务级 `flush()` 或 close——会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。 -本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个控制器、保留失败批次、按 id 串行化、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。后端钩子边界仍由[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.zh.md)定义。 +本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个缓冲所有者、保留失败批次、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。最初承载该行为的协调器与独立的 write-behind 控制器均已删除;缓冲、计时器和排空落在 provider 的句柄上,它们写入所经过的存储边界由[基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md) 定义。 ## 备选方案 @@ -42,11 +42,11 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No **按最新事件重置防抖窗口。** 不采纳:持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口,为主动合并等待提供了真正的上界。 -**在 JSONL 内实现计时器。** 不采纳:调度、失败保留、flush 竞态和 teardown 都是 provider 无关的生命周期问题,属于 `PersistenceCoordinator`;仓库外 provider 可以复用同一行为。 +**共享的 provider 无关控制器组件。** 曾在一次迭代中交付,随后不采纳:句柄的修改链本就串行化写入,独立控制器重复了这套排序机制。每个 provider 在自己的句柄上实现该缓冲,共享的实时写入约定测试套件为任何 provider 钉住等价的可观察行为。 ## 验证 -控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试,以及对重叠发生的后台失败进行显式重试。协调器测试会在会话通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。 +共享的实时写入约定测试套件(`runLiveWritePathContract`)使用假时钟证明固定且不会重置的 200 ms 窗口、`session/flush` 屏障及其失败的响亮暴露、有序保留失败批次并恰好恢复一次、带逐会话失败聚合的服务级 `flush()` 清扫,以及 disposed/close/teardown 的排空。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。 ## 后果 @@ -54,6 +54,6 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No 本决策不会限制因 provider 缓慢而积压的待处理事件数量或字节数,也不会减少解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。 -接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。 +接纳后的事件在固定窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。显式持久性边界保持不变,并会绕过等待。 -deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 保持不变。 +句柄统一负责计时器、活跃排空、待处理前缀、重试暂停和屏障;后端的监听器负责路由和生命周期驱动的排空。`SESSION_FORMAT_VERSION` 保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml index 884aa27385..44c31bc1cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md -2026-08-10-message-feedback-sidecar.md: eb1f8786f9ec0363b3c98d5b80a796b9c0fc0b4c -2026-08-10-message-feedback-sidecar.zh.md: 573d3b30e3bd14b492925f4a00424db588736943 +2026-08-10-message-feedback-sidecar.md: d047bebf47f844a6d88932c7e19a3952f43d94cc +2026-08-10-message-feedback-sidecar.zh.md: 4ee1b861a8013dacfd5eba40d9e30b23237be2e5 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md index eb1f8786f9..d047bebf47 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md @@ -16,9 +16,9 @@ A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes w Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it. -`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. +`put` accepts a target only when the observed log — a live owner's in-memory events, else the durable log through a persistence read handle — contains a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Observation is cold-safe: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `stat()` preflight classifies definite absence; a read failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. -Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. +Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically re-read from sequence zero through a fresh persistence read handle. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md index 573d3b30e3..4ee1b861a8 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md @@ -16,9 +16,9 @@ Status: implemented 每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。 -`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 +`put` 只在被观测的日志——live 持有者的内存事件,否则是经由持久化读句柄读取的持久日志——包含非空、append-origin 且 `MessageId` 与目标相同的 `assistant/message` 时才接受该目标。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。观测是 cold-safe 的:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `stat()` 预检明确不存在;已进入目录的 Session 若读取失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 -`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 +`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过新开的持久化读句柄从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index f717ba76e2..620239bf08 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: d35ed79dedd5592d15a27b0e1b952e66d80b268f -2026-08-18-session-history-and-event-transport.zh.md: 6e6ccf53e28c9a7ce76bb4aa5d80d94f39e11f10 +2026-08-18-session-history-and-event-transport.md: 10fdd9b256c27aadada97195c8dc5516b4485a43 +2026-08-18-session-history-and-event-transport.zh.md: bf110b5f1a2bea99f9aa086c66a616eddaa0a50e diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index d35ed79ded..10fdd9b256 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -163,7 +163,7 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup. -`SessionQuery.observeSession()` chooses an attached Session or borrows one prepared source from `SessionPersistence.borrowSession()`. The persistence preparation cache shares concurrent cold reads and pins the exact unpublished Session until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state. +`SessionQuery.observeSession()` chooses an attached Session or serves a cold one from the reader's own prepared cache, filled through a persistence read handle. The cache shares concurrent cold reads and pins an entry until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state. `session.list` never performs an unbounded cold-log scan. It uses cached projection hints when available and may fully observe only an individually stored artifact within the configured small-log byte limit to distinguish an abandoned blank Session. Missing or unreadable hints keep the row visible with unknown metadata. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 6e6ccf53e2..bf110b5f1a 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -163,7 +163,7 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。 -`SessionQuery.observeSession()` 选择 attached Session,或从 `SessionPersistence.borrowSession()` 借用 prepared source。Persistence preparation cache 共享并发冷读取,并在所有 observation lease 释放前固定同一个未发布 Session。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。 +`SessionQuery.observeSession()` 选择 attached Session,或从读取方自己的 prepared cache——经由持久化读句柄填充——提供冷 Session。该 cache 共享并发冷读取,并在所有 observation lease 释放前固定同一条目。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。 `session.list` 不会无界扫描冷日志。它优先使用缓存的 projection hint,仅在独立存储 artifact 不超过配置的小日志字节上限时,才可能完整观察日志以判断不确定的 blank 状态。hint 缺失或不可读时,列表仍保留该行,并把 metadata 视为未知。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index d8e1977615..df7268de5c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: 492640385215b059761b17a057328cc5c6d24bff -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 0b892a9cae2c999b4472dd46f19068e2b4139e60 +2026-08-25-session-observations-and-projection-owned-client-state.md: 554d003da1e1767b8955be7a067cbea716f130ef +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: a63b0414f8b0250e4cea95ac39a39aa2c62cd958 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 4926403852..554d003da1 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -32,8 +32,8 @@ flowchart LR Cache -->|"small miss"| Observe Observe --> Source{"live or cold"} Source --> Live["attached Session cut"] - Source --> Borrow["borrowSession"] - Borrow --> Prepared["SessionPreparations.borrow"] + Source --> Borrow["persistence read handle"] + Borrow --> Prepared["reader's prepared cache"] Live --> Mode{"all or none"} Prepared --> Mode Mode --> Snapshot["SessionObservation"] @@ -45,7 +45,7 @@ flowchart LR ### Observation is the point-read unit -`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise `SessionPersistence.borrowSession()` and `SessionPreparations.borrow()` share and pin one prepared Session, including an in-flight cold load. +`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise the reader's own prepared cache — keyed by `stat().revision` and pinned by observation leases — serves the cold Session, sharing one persistence read (`open(id, 'read')` + `read`) across concurrent observations, including an in-flight cold load. Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 0b892a9cae..a63b0414f8 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -32,8 +32,8 @@ flowchart LR Cache -->|"small miss"| Observe Observe --> Source{"live or cold"} Source --> Live["attached Session cut"] - Source --> Borrow["borrowSession"] - Borrow --> Prepared["SessionPreparations.borrow"] + Source --> Borrow["persistence read handle"] + Borrow --> Prepared["reader's prepared cache"] Live --> Mode{"all or none"} Prepared --> Mode Mode --> Snapshot["SessionObservation"] @@ -45,7 +45,7 @@ flowchart LR ### Observation 是 point read 单元 -`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则 `SessionPersistence.borrowSession()` 与 `SessionPreparations.borrow()` 共享并固定一份 prepared Session,包括尚未完成的冷加载。 +`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则由读取方自己的 prepared cache——以 `stat().revision` 为键、由 observation lease 固定——提供冷 Session,让并发 observation 共享同一次持久化读取(`open(id, 'read')` + `read`),包括尚未完成的冷加载。 每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml index 0a513e049f..2c0b6abd41 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml @@ -1,6 +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 .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md -2026-08-04-load-pre-react-loop-sessions.md: e95817ee60647ca002060a4f90c2263d4fe7ce42 -2026-08-04-load-pre-react-loop-sessions.zh.md: 98fb1f530f5168fc02b312775d1bb8e6d305b8f8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md +2026-08-27-handle-based-session-persistence.md: ced7a78178d3036fd5fa9a09ca49f8c751c3a169 +2026-08-27-handle-based-session-persistence.zh.md: e13b5b0e9e7286411b6d38f3b9dc0e86fa742a3d diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md new file mode 100644 index 0000000000..ced7a78178 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md @@ -0,0 +1,42 @@ +# Agent Note: Handle-based session persistence + +Status: implemented + +English | [中文](2026-08-27-handle-based-session-persistence.zh.md) + +## Problem + +The previous persistence seam owned far more than storage. A shared coordinator subscribed to `session/created`/`session/event`/`session/flush`/`session/disposed` and adopted any published session (ownerless claims, HMR re-seeding, stored-prefix adoption); a bounded prepared-Session LRU with exclusive reservations served resume and read-only observation from one cache; committing crash repair lived inside `load`/`prepare`; and optimistic revision read/check/read loops stood in for ownership, so a continuous external writer could livelock a read and nothing excluded a second writer. The service surface (twelve methods) mixed storage with Session construction and lifecycle. Cross-process write ownership — the next step — has no honest home in that shape: ownership belongs to an explicit per-session channel with an owner, not to a global listener. + +## Decision + +**The seam is five service methods returning or serving per-session handles.** `create(header)` stores a new session and returns its owned write handle; `open(id, 'read' | 'write')` opens an existing one; `stat(id)`/`list()` observe snapshots (`header`, opaque `revision`, optional `eventCount`/`sizeBytes` hints — the JSONL backend supplies `sizeBytes`) without reading logs; service-level `flush()` is one backend-wide durability barrier that drains and flushes every active write handle, aggregating per-session failures without abandoning the sweep. The seam carries no raw-artifact export: the WebUI ZIP download serializes the logical log (header line + events) from a read handle in `dsh-session-log-export`, so every backend exports identically and the JSONL-only 501 path is gone. A `SessionHandle` carries `read(offset?, length?)` (validated contiguous prefix slices, never a torn tail, monotonic per handle), `append` (contiguous; persistence is best-effort on resolution, and the shipped JSONL backend happens to persist each batch immediately), `flush` (the durability barrier, which also materializes an empty session), and idempotent uncancellable `close`. One handle type serves both accesses — a mutation on a read handle is a runtime `SessionReadOnlyError`, the deliberate convention of this codebase's other seams rather than a typed split. Single-writer ownership is enforced in-process by a registry (`SessionAlreadyOwnedError`); the durable cross-process lease is the planned next layer on the same shape. + +**The agent lifecycle owns handle acquisition; the backend owns the event-driven flow.** agent-loop — the sole production publication point for sessions — acquires the handle before publication (`create` for fresh sessions, appending any constructor seed through it; `open(id, 'write')` for resume) and closes it in the same memoized teardown that drains the loop. Because persistence already enforces one active write handle per session id, the backend installs the session listeners once and routes by id: `session/event` into the owning handle's bounded write-behind window (an internal scheduling policy, not configuration), `session/flush` as the durability and error-observation barrier, `session/disposed` as final drain and close. Nothing about the write path crosses the package boundary — no writer component, no batching configuration, no drain registry. Root-fiber disposal runs every fiber's disposers concurrently, so `close()` itself drains the routed buffer through the still-open storage; backend teardown's close sweep keeps application shutdown lossless regardless of which fiber unwinds first. The drain guarantees only buffered already-emitted events; a root dispose mid-turn still loses the turn's unemitted remainder by design — the next resume's `interruptedTurnClosers` repairs that tail durably. Sessions published outside the lifecycle no longer persist implicitly; nothing in production does that. + +**Semantic crash repair moved out of persistence.** Resume reads the physically valid log through its write handle, computes `interruptedTurnClosers`, and appends them (plus the constructor's `session/end-seed` marker) through the same handle as ordinary batches — repair is not a special storage entry point. Read-only observers (session-query) balance an interrupted cold log in memory only, and own their cold-Session cache keyed by `stat().revision`; the persistence-side prepared cache and revision convergence loops are deleted. + +**Visibility and freshness are explicit.** A created session is observable in-process from `create`; physical materialization may be deferred (a pure optimization) until the first append or flush, other processes see only materialized sessions, and a crash before materialization means the session never existed. Once an append or flush resolves, reads started afterwards on the same backend instance observe at least that prefix — the guarantee `message-feedback`'s durable-target check rides on. + +**Revision simplifies to a per-instance change token.** Equal tokens may be treated as an unchanged log; ownership churn never changes one. JSONL derives a best-effort token and `sizeBytes` from one `fs.stat`; a backend whose medium can count events cheaply may supply the `eventCount` hint instead. The session-list cold blank probe returns on this metadata (`coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`), restoring the capability removed with the path query. + +## Alternatives considered + +**Typed read/write handle classes (or overloads).** Rejected as the seam style: this codebase's seams prefer one access-tagged type with runtime refusal, and the split would double every consumer-facing type for one compile-time check. + +**Keeping the coordinator's adoption/HMR write path beside handles.** Rejected: adoption exists to guess ownership after the fact; with the lifecycle handing the handle over explicitly, a reloaded backend that cannot serve old handles fails the writer loudly instead of silently re-claiming logs, and a session with no handle is a composition bug surfaced by absent persistence rather than masked by adoption. + +**A service-level `append(id, events)` beside handles.** Rejected: an id-addressed write path bypasses ownership; every write flows through the owning handle so the future lease check has exactly one door. + +**Persistence-owned batching configuration.** Rejected: the batching window is internal write-path scheduling, not a deployment-varying choice, so it is a provider constant and no configuration knob exists anywhere. + +## Consequences + +Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). `SESSION_FORMAT_VERSION` stays 0. + +## Related + +- [Session persistence as an abstract service](2026-06-14-session-persistence.md) — the seam this reshapes; its interface list reflects the handle API. +- [Persistence export() and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) — the preparatory removals, including the blank probe this note's metadata restores. +- [Retain ignorable external session events](2026-08-30-retain-ignorable-external-session-events.md) — the read-side refusal contract, now shared through `storage-contract` helpers. +- [Bounded session-persistence write batching](2026-08-08-bounded-session-persistence-write-batching.md) — the batching semantics the routed write path preserves as internal scheduling policy. diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md new file mode 100644 index 0000000000..e13b5b0e9e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md @@ -0,0 +1,42 @@ +# Agent Note: 基于句柄的会话持久化 + +Status: implemented + +[English](2026-08-27-handle-based-session-persistence.md) | 中文 + +## 问题 + +先前的持久化 seam 承担的远不止存储。一个共享协调器订阅 `session/created`/`session/event`/`session/flush`/`session/disposed`,并接管任何已发布的会话(无主认领、HMR 重新播种、已存储前缀接管);一个带独占预留的有界已准备 Session LRU 用同一个缓存服务恢复与只读观察;提交式崩溃修复内嵌在 `load`/`prepare` 中;乐观的 revision 读取/复核/再读取循环充当所有权的替身,因此持续的外部写入方可能使读取活锁,而且没有任何机制排除第二个写入方。服务表面(十二个方法)把存储与 Session 构造和生命周期混在一起。跨进程写所有权——下一步——在那种形态里没有诚实的归宿:所有权属于一条有明确持有者的逐会话通道,而不属于一个全局监听器。 + +## 决策 + +**该 seam 是五个返回或供给逐会话句柄的服务方法。**`create(header)` 存储一个新会话并返回其持有的写句柄;`open(id, 'read' | 'write')` 打开一个已有会话;`stat(id)`/`list()` 观察快照(`header`、不透明 `revision`、可选的 `eventCount`/`sizeBytes` 提示——JSONL 后端提供 `sizeBytes`),而不读取日志;服务级 `flush()` 是一道后端范围的持久性屏障,排空并 flush 每一个活跃写句柄,逐会话聚合失败而不中途放弃清扫。该 seam 不承载原始工件导出:WebUI 的 ZIP 下载在 dsh-session-log-export 中从读句柄序列化逻辑日志(header 行 + 事件),因此每个后端的导出完全一致,仅 JSONL 可用的 501 路径也随之消失。`SessionHandle` 承载 `read(offset?, length?)`(经过验证的连续前缀切片,绝不返回撕裂尾部,逐句柄单调)、`append`(连续;完成时的持久化是尽力而为的,交付的 JSONL 后端恰好会立即持久化每个批次)、`flush`(持久性屏障,同时把空会话实体化)以及幂等且不可取消的 `close`。一种句柄类型同时服务两种访问——在读句柄上执行修改是运行时的 `SessionReadOnlyError`,这是本代码库其他 seam 的既定惯例,而非类型层面的拆分。单写者所有权由注册表在进程内强制(`SessionAlreadyOwnedError`);持久的跨进程租约是计划在同一形态上叠加的下一层。 + +**agent 生命周期负责获取句柄;后端负责事件驱动的流程。**agent-loop——会话在生产环境中唯一的发布点——在发布之前获取句柄(新建会话用 `create`,并通过它追加构造 seed;恢复用 `open(id, 'write')`),并在与排空循环相同的记忆化 teardown 中关闭它。由于持久化已保证每个会话 id 只有一个活跃写句柄,后端一次性安装会话监听器并按 id 路由:`session/event` 进入持有句柄的有界 write-behind 窗口(内部调度策略,而非配置),`session/flush` 作为持久性与错误观察屏障,`session/disposed` 作为最终排空并关闭。写路径没有任何部分跨越包边界——没有写入器组件,没有批处理配置,没有排空注册表。根 fiber 的 dispose 会并发运行每个 fiber 的 disposer,因此 `close()` 本身会经由仍然打开的存储排空已路由的缓冲;后端 teardown 的关闭清扫使应用关闭无论哪个 fiber 先解退都不丢数据。该排空只保证已发出并缓冲的事件;turn 中途的根 dispose 仍会按设计丢失该 turn 尚未发出的剩余部分——下一次恢复的 `interruptedTurnClosers` 会持久地修复这段尾部。在生命周期之外发布的会话不再隐式持久化;生产环境中没有任何地方那样做。 + +**语义崩溃修复移出了持久化。**恢复通过其写句柄读取物理上有效的日志,计算 `interruptedTurnClosers`,并把它们(连同构造器的 `session/end-seed` 标记)作为普通批次通过同一句柄追加——修复不是特殊的存储入口。只读观察方(session-query)仅在内存中配平被中断的冷日志,并拥有以 `stat().revision` 为键的冷 Session 缓存;持久化侧的已准备缓存与 revision 收敛循环被删除。 + +**可见性与新鲜度是显式的。**已创建的会话自 `create` 起即可在进程内被观察到;物理实体化(纯粹的优化)可以推迟到第一次 append 或 flush,其他进程只能看到已实体化的会话,实体化之前崩溃意味着该会话从未存在。一旦某次 append 或 flush 完成,其后在同一后端实例上开始的读取至少能观察到该前缀——这正是 `message-feedback` 持久目标检查所依赖的保证。 + +**revision 简化为逐实例变更令牌。**令牌相等可视为日志未变;所有权变动绝不会改变令牌。JSONL 通过一次 `fs.stat` 派生尽力而为的令牌与 `sizeBytes`;存储介质能够廉价统计事件数的后端可以改为提供 `eventCount` 提示。会话列表的冷空白探测回归到这些元数据之上(`coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`),恢复了随路径查询一起移除的能力。 + +## 考虑过的替代方案 + +**类型化的读/写句柄类(或重载)。**不作为该 seam 的风格采纳:本代码库的 seam 偏好带访问标记的单一类型加运行时拒绝,而拆分会为一个编译期检查让每个面向消费方的类型翻倍。 + +**在句柄旁保留协调器的接管/HMR 写路径。**不采纳:接管的存在是为了事后猜测所有权;当生命周期显式移交句柄后,无法服务旧句柄的重载后端会向写入器响亮地失败,而不是静默地重新认领日志,而没有句柄的会话是一个由持久化缺席暴露、而非被接管掩盖的组合缺陷。 + +**在句柄旁提供服务级 `append(id, events)`。**不采纳:按 id 寻址的写路径绕过所有权;每次写入都流经持有句柄,使未来的租约检查恰好只有一扇门。 + +**由持久化持有批处理配置。**不采纳:批处理窗口是写路径内部的调度策略,而非随部署变化的选择,因此它是 provider 常量,任何地方都不存在配置旋钮。 + +## 后果 + +恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。`SESSION_FORMAT_VERSION` 保持为 0。 + +## 相关 + +- [作为抽象服务的会话持久化](2026-06-14-session-persistence.zh.md)——本 Note 重塑的 seam;其接口列表已反映句柄 API。 +- [持久化 export() 与预发布读取路径精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)——预备性的移除,包括本 Note 的元数据所恢复的空白探测。 +- [保留可忽略的外部会话事件](2026-08-30-retain-ignorable-external-session-events.zh.md)——读取侧的拒绝约定,现经由 `storage-contract` 辅助函数共享。 +- [为会话持久化写入批处理设定上界](2026-08-08-bounded-session-persistence-write-batching.zh.md)——被路由写路径作为内部调度策略保留的批处理语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml index 8c92e95dac..a1a3a0b8ec 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md -2026-08-30-retain-ignorable-external-session-events.md: c796a8dab1a9d127473a341fc98bc9934429fbdf -2026-08-30-retain-ignorable-external-session-events.zh.md: 0c635b1082a31a0a35d01669ff9f933a1f218bee +2026-08-30-retain-ignorable-external-session-events.md: e79713cf16c627706939f33f6748eaa4821de870 +2026-08-30-retain-ignorable-external-session-events.zh.md: 8fddbfdd7e60716b0a78ef6b33de09935e9bd4d7 diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md index c796a8dab1..e79713cf16 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md @@ -12,7 +12,7 @@ That producer inventory did not cover a third-party plugin that currently depend ## Decision -The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, API transport, generated catalogs, and test fixtures. `PersistenceCoordinator` continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read. +The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, API transport, generated catalogs, and test fixtures. The persistence seam's stored-event validation (`validateStoredEvents`) continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read. The field is removable only after a replacement supports the current third-party plugin across event production, persistence, reload, and transport, with an explicit cutover for sessions already containing the marker. The [session log versioning decision](2026-08-10-session-log-version-mechanism.md) continues to own the default-required safety rule and format-version policy. diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md index 0c635b1082..8fddbfdd7e 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、API 传输、生成目录与测试 fixture。`PersistenceCoordinator` 继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。 +标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、API 传输、生成目录与测试 fixture。持久化 seam 的已存事件校验(`validateStoredEvents`)继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。 只有替代机制在事件生产、持久化、重新加载与传输中都支持当前第三方插件,并为已包含该标记的会话提供显式切换方案后,才能删除此字段。[Session log 版本决策](2026-08-10-session-log-version-mechanism.zh.md)继续定义默认读取必需的安全规则与格式版本策略。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md deleted file mode 100644 index 6d022cb4b3..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: Load sessions persisted before message identity - -Status: implemented - -English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md) - -## Problem - -The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL Sessions still held the immediately preceding forms: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-form validation rejected them before resume could construct a live `Session`. - -Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party provider without weakening validation for unrelated obsolete or malformed events. - -## Decision - -`PersistenceCoordinator` normalizes the four exact pre-identity message payloads after backend decoding and before current message validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message::` as the deterministic imported `MessageId`. A legacy `tool/result` content replacement inherits the imported id of its replacement target, preserving the current content-only rewrite invariant. - -The same normalization runs for `load`, `inspect`, an ownerless loaded state claiming its live session, and HMR prefix adoption. Prefix comparisons therefore compare the live current-shape seed with the same normalized stored view. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their existing rejection paths. - -The upgrade is read-only. Stored legacy records remain unchanged; a resumed session appends only current-shape events after them. Deterministic identities make repeated loads and a mixed legacy/current log reproduce the same message ids without a backend-specific rewrite transaction. - -## Alternatives considered - -**Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation. - -**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require an atomic replacement mechanism, and expand a read compatibility fix into a migration system. - -**Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends. - -## Consequences - -Pre-identity JSONL Sessions resume with their original message content, sources, assistant provider/model fields, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen. - -This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference and JSONL provider, including deterministic reload and tool-result replacement identity. - -## Related - -- [Create every message as an identified immutable value](../architecture/2026-07-28-identified-immutable-message-values.md) — owns the current message identity and immutability contract. -- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the append-only backend and resume boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md deleted file mode 100644 index 86439337b3..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 加载消息标识机制引入前持久化的会话 - -Status: implemented - -[English](2026-07-28-load-pre-identity-session-messages.md) | 中文 - -## 问题 - -带标识的不可变消息变更将四种持久化事件载荷替换为完整消息值。现有 v0 JSONL Session 仍保留紧邻该变更之前的表示:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些 Session 的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前表示验证会拒绝它们,导致恢复流程无法构造 live `Session`。 - -消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的 first-party provider 所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 - -## 决策 - -`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入用 `MessageId`:`legacy-message::`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。 - -同一项规范化也用于 `load`、`inspect`、无 owner 的已加载状态认领其活跃会话,以及 HMR(热模块替换)前缀接管。因此,前缀比较会将活跃会话的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。 - -这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。 - -## 考虑过的替代方案 - -**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。 - -**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储约定,还需要原子替换机制,并将一次读取兼容性修复扩大为迁移系统。 - -**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。 - -## 后果 - -消息标识机制引入前的 JSONL Session 可以恢复,并保留原始消息内容、来源、assistant 的 provider/model 字段、工具调用关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 - -这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享协调器约定会通过内存参考实现与 JSONL provider 验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。 - -## 相关 - -- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.zh.md):该记录负责当前的消息标识与不可变性约定。 -- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.zh.md):该记录负责仅追加后端与恢复边界。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index a1a69bb1af..a5e97e8e8e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: e4809575e03bbd74522b26a8a170ac558d6eee41 -2026-07-31-resume-selector-batch-projection.zh.md: 04646d266c87b96b7c28692663540ffe808d0082 +2026-07-31-resume-selector-batch-projection.md: aa99ecf323b44432e360402f072d89436b2778bd +2026-07-31-resume-selector-batch-projection.zh.md: ebc9d43677edc23004c32736db989d39e59ef152 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index e4809575e0..aa99ecf323 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -26,7 +26,7 @@ No session-query or session-persistence surface changed. The shipped TUI composi **Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications. -**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, provider, and query record type for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times. +**Surface a last-modified time through `list()`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, provider, and query record type for what the TUI can already derive from the stored log's file metadata. Reintroduce if a second consumer needs metadata activity times. **A bespoke persisted title index or TUI-local title cache.** Rejected: the session-projection cache already is the owned durable checkpoint system with an invalidation contract (`stateVersion`, identity binding, shrunk-log anchoring); mounting it beats adding a parallel cache. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index 04646d266c..ebc9d43677 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -26,7 +26,7 @@ session-query 与 session-persistence 的任何接口都未改变。随附的 TU **只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。 -**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、provider 和查询记录类型,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费方再引入。 +**通过 `list()`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、provider 和查询记录类型,而 TUI 已能从已存日志的文件元数据得到同样的信息。若出现第二个需要元数据活动时间的消费方再引入。 **专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效约定(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md deleted file mode 100644 index e95817ee60..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md +++ /dev/null @@ -1,40 +0,0 @@ -# Agent Note: Load sessions from the pre-react-loop format - -Status: implemented - -English | [中文](2026-08-04-load-pre-react-loop-sessions.zh.md) - -## Problem - -The react-loop simplification changed durable events while retaining `SESSION_FORMAT_VERSION` 0. Stored sessions from the change's base contain `steering/message` and `turn/start.trigger`; their terminal reasons also use coarse `aborted`, separate `disposed`, and two older error payloads. Current surface and turn invariants cannot replay those records directly. - -The new durable inbox is not part of this compatibility problem. The base emitted process-local inbox notifications but no `agent/inbox/*` session events, so replaying old history as pending work would resurrect already claimed or discarded prompts. - -## Decision - -`PersistenceCoordinator` recognizes the exact pre-react-loop shapes after backend decoding and projects them into the current read view. It removes the obsolete `turn/start.trigger`, converts `steering/message` to the same identified `user/message`, maps old failure facts into the current structured error, folds `disposed` into an aborted turn with the `disposed` cause, and represents coarse aborted records with the persistence-only `{ kind: 'legacy' }` cause because their caller is unavailable. - -The coordinator applies the projection to `load`, `inspect`, adoption, HMR prefix comparison, and `readFrom`. A seek-capable `readFrom` normally reads only its suffix; when that suffix contains a legacy event needing an earlier replacement identity, the coordinator loads and normalizes the complete prefix before returning the requested seq range. - -The importer does not synthesize inbox splices. A resumed pre-react-loop agent begins with empty pending lists, matching the base runtime's inability to persist pending inbox work. The stored artifact remains append-only and later events use the current format. - -## Alternatives considered - -**Treat the same-version records as unsupported.** This follows the pre-release default but strands sessions produced by the PR base even though the removed steering content and terminal facts have complete mappings. - -**Replay old inbox notifications into durable splices.** Those notifications were not session events and do not provide a trustworthy pending-state snapshot. Inferring insertions without every claim and discard would re-run consumed work. - -**Assign coarse aborted records to an existing caller.** Mapping them to `user`, `parent`, or `hook` would invent a caller that the old record did not name. A dedicated `legacy` cause keeps the stop classification without making a false audit claim. - -**Rewrite stored JSONL records.** A rewrite would violate the append-only contract and require atomic migration machinery for a read compatibility boundary. - -## Consequences - -Sessions written in the refactor's base format resume through the current AgentLoop with their steering content, turn boundaries, error facts, and stop classification intact. The shared coordinator contract covers in-memory and JSONL `load`/`inspect`/`readFrom`; an assembled JSONL Agent resume verifies that the historical transcript is visible while both new inbox lists start empty. - -This exception supports the base format, not intermediate formats produced during development of the refactor. In particular, it defines no migration for earlier experimental `agent/inbox/spliced` payloads. Exact-shape recognition keeps malformed current-looking records on their rejection path instead of guessing them into validity. - -## Related - -- [Load sessions persisted before message identity](2026-07-28-load-pre-identity-session-messages.md) — owns deterministic identities and the general read-only import boundary for another same-version format change. -- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns append-only backend storage and resume. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md deleted file mode 100644 index 98fb1f530f..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md +++ /dev/null @@ -1,40 +0,0 @@ -# Agent Note: 加载 react-loop 重构前格式的会话 - -Status: implemented - -[English](2026-08-04-load-pre-react-loop-sessions.md) | 中文 - -## 问题 - -react-loop 简化在保持 `SESSION_FORMAT_VERSION` 为 0 的同时更改了持久事件。该变更基线所存储的会话包含 steering(中途引导)事件 `steering/message`,以及 `turn/start.trigger` 字段;其终止原因还使用粗粒度 `aborted`、独立的 `disposed` 和两种旧版错误载荷。当前表层和轮次不变量无法直接回放这些记录。 - -新的持久 inbox 不属于此兼容性问题。该基线会发出进程本地 inbox 通知,但不会产生 `agent/inbox/*` 会话事件,因此将旧历史回放为待处理工作会让已经领取或丢弃的提示词再次执行。 - -## 决策 - -`PersistenceCoordinator` 会在后端解码后识别 react-loop 重构前的确切形状,并将其投影为当前读取视图。它移除已废弃的 `turn/start.trigger`,把 `steering/message` 转换为同一条带标识的 `user/message`,将旧版失败事实映射为当前结构化错误,把 `disposed` 折叠为带 `disposed` 原因的已中止轮次,并用仅供持久化导入使用的 `{ kind: 'legacy' }` 原因表示粗粒度中止记录,因为无法获得其调用方。 - -协调器会把该投影应用于 `load`、`inspect`、接管、HMR(热模块替换)前缀比较和 `readFrom`。可寻址的 `readFrom` 通常只读取后缀;如果后缀包含需要更早替换标识的旧版事件,协调器会先加载并规范化完整前缀,再返回所请求的 seq 范围。 - -导入器不会合成 inbox splice。恢复后的 react-loop 重构前 agent(智能体)从空的待处理列表开始,这与基线运行时无法持久化待处理 inbox 工作的行为一致。已存储产物仍然仅追加,后续事件使用当前格式。 - -## 考虑过的替代方案 - -**将同版本记录视为不受支持。** 这符合预发布阶段的默认立场,但会使 PR(Pull Request)基线产生的会话无法恢复,尽管已移除的 steering 内容和终止事实都有完整映射。 - -**将旧 inbox 通知回放为持久 splice。** 这些通知不是会话事件,也无法提供可信的待处理状态快照。如果无法获知每一次领取和丢弃,就推断插入操作,会让已消费的工作再次执行。 - -**将粗粒度中止记录归因于现有调用方。** 将其映射到 `user`、`parent` 或 `hook` 会凭空指定旧记录未注明的调用方。专用的 `legacy` 原因既能保留停止分类,也不会产生虚假的审计事实。 - -**重写已存储的 JSONL 记录。** 重写会违反仅追加约定,并要求为读取兼容边界建立原子迁移机制。 - -## 后果 - -以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。共享协调器约定覆盖内存与 JSONL 的 `load`/`inspect`/`readFrom`;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。 - -此例外支持基线格式,不支持重构开发期间产生的中间格式。具体而言,它没有为更早的实验性 `agent/inbox/spliced` 载荷定义迁移。通过确切形状识别,当前格式外观相似但结构错误的记录仍会走拒绝路径,不会被猜测性地转换为有效记录。 - -## 相关资料 - -- [加载消息标识机制引入前持久化的会话](2026-07-28-load-pre-identity-session-messages.zh.md):负责另一项同版本格式变更的确定性标识和通用只读导入边界。 -- [以抽象服务实现会话持久化](../architecture/2026-06-14-session-persistence.zh.md):负责仅追加后端存储和恢复。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml index 94a7268a28..51a7388005 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md -2026-08-13-bounded-cold-blank-verification.md: ab2f3b5a0534a98e02a3e2494ca2fff1223efe81 -2026-08-13-bounded-cold-blank-verification.zh.md: 5dc4b62f7ac43ebd3c4a8cef58f5da9af367520a +2026-08-13-bounded-cold-blank-verification.md: 8244b93641cfe576cd2f5b0c618ae69fbb215dd5 +2026-08-13-bounded-cold-blank-verification.zh.md: 2c7f392fa8fbd3fed0641fe2b95e717f76cd78fb diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md index ab2f3b5a05..8244b93641 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md @@ -14,15 +14,15 @@ The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Sess `dsh-api-session-controller` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. -A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its observed size is at most the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and folds exact list metadata from the stored prefix. Files above the threshold, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible. +A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank and are served `blank: false`, keeping the Session visible. The earlier physical-size probe — a `locate()` path plus a `coldBlankProbeMaxBytes` eligibility threshold gating an exact `readFrom(id, 0)` fold — is removed with the seam's path query ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md)); persistence snapshot metadata (`eventCount`/`sizeBytes` on `stat()`/`list()`) is the reintroduction path for exact cold verification. -`updatedAt` is the later of `createdAt` and `lastPromptAt`. An eligible artifact read supplies exact `lastPromptAt` at no additional I/O cost; other cache misses or stale checkpoints order the Session too old rather than promoting it from an unrelated file write. After each asynchronous cold read, the gateway checks the live store again and replaces the cold result with an attached summary when another request resumed that Session meanwhile. +`updatedAt` is the later of `createdAt` and `lastPromptAt`. A cache miss or stale checkpoint orders the Session too old rather than promoting it from an unrelated file write. ## Alternatives considered **Trust cached `blank: true`.** Rejected because the projection cache deliberately permits a persisted log to advance beyond its checkpoint. A crash or fail-soft write failure after the first `turn/start` would hide a real conversation and could make the client reuse it as New Session. -**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes. The physical-size eligibility check targets small historical artifacts that can be checked cheaply and degrades larger unknowns toward visibility. It intentionally does not add a persistence operation solely to make the threshold atomic with the read: concurrent growth may increase one probe's read cost, but the additional events can only preserve visibility or change a blank result to non-blank. +**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes; unverified cold entries degrade toward visibility instead. **Store blankness and recency in an authoritative persistence index.** Deferred because the shipped JSONL provider has an immutable first line and would require a second durable artifact with ordered updates. An out-of-tree provider may use its own index only with defined update atomicity, versioning, and recovery. The broader exact-index design remains in the [last-activity proposal](../../proposed/architecture/2026-07-29-durable-last-activity-index.md). @@ -30,8 +30,6 @@ A cold summary trusts cached `blank: false`, because a checkpoint prefix contain ## Consequences -Existing small blank JSONL artifacts are hidden without depending on projection-cache availability, and a stale cache cannot hide a stored `turn/start`. A cold list may read each artifact whose observed physical size is within the configured threshold when its cache does not already prove non-blank. The default threshold compares compressed bytes for the shipped Zstandard JSONL backend. +A stale cache cannot hide a stored `turn/start`, and a cold list performs no artifact I/O: cold rows are served from cached projections only. Blank cold Sessions without a cached non-blank projection remain visible, and missing or delayed recency cache entries fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened. -Blank artifacts above the threshold and blank Sessions on location-less backends remain visible. Missing or delayed recency cache entries for artifacts that are not read fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened. - -The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins exact-threshold eligibility, stale-true rejection, monotonic false reuse, exact small-log recency, live-attachment races, fallback direction, human-prompt recency, and fiber disposal. A keyless Web snapshot boots the shipped compressed JSONL composition, seeds a small cold blank artifact without a cache row, and verifies that the sidebar omits it. +The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins stale-true rejection, monotonic false reuse, cache-miss visibility, human-prompt recency, and fiber disposal. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md index 5dc4b62f7a..2c7f392fa8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md @@ -14,15 +14,15 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N `dsh-api-session-controller` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 -冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其观测大小不超过 `coldBlankProbeMaxBytes` 资格阈值(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`,从已存前缀折叠精确列表元数据。超过阈值的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。 +冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空,因而按 `blank: false` 提供,让 Session 保持可见。早先的物理大小探测——`locate()` 路径加上门控一次精确 `readFrom(id, 0)` 折叠的 `coldBlankProbeMaxBytes` 资格阈值——随该 seam 的路径查询一并移除([导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md));persistence 快照元数据(`stat()`/`list()` 上的 `eventCount`/`sizeBytes`)是重新引入精确冷验证的路径。 -`updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。符合资格的工件读取无需额外 I/O 即可提供精确 `lastPromptAt`;其他 cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。每次异步冷读取后,网关都会再次检查实时 store;若另一请求期间已恢复该 Session,则用已附加摘要替换冷结果。 +`updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。 ## Alternatives considered **信任缓存的 `blank: true`。** 拒绝,因为 projection cache 有意允许持久日志前进到 checkpoint 之后。首个 `turn/start` 之后若发生崩溃或 fail-soft 写入失败,真实对话就会被隐藏,客户端还可能把它复用为 New Session。 -**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小资格检查只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。该检查有意不为“让阈值与读取原子化”单独新增 persistence 操作:并发增长可能增加一次探测的读取成本,但新增事件只会保持可见,或把空白结果改为非空。 +**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长;未经核验的冷条目转而向保持可见降级。 **把空白状态与最近时间存入权威 persistence index。** 暂缓,因为交付的 JSONL provider 首行不可变,需要增加带有顺序写入要求的第二份持久工件。仓库外 provider 只有定义更新原子性、版本与恢复语义后才可使用自己的索引。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)负责。 @@ -30,8 +30,6 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N ## Consequences -既有的小型空白 JSONL 工件无需依赖 projection cache 是否存在即可被隐藏,陈旧 cache 也无法隐藏已存的 `turn/start`。对于 cache 尚不能证明非空,且观测物理大小在配置阈值内的每个 Session,冷列表可能读取其工件。对默认交付的 Zstandard JSONL 后端,该阈值比较压缩后的字节数。 +陈旧 cache 无法隐藏已存的 `turn/start`,且冷列表不做任何工件 I/O:冷行只从缓存投影提供。没有缓存非空投影的空白冷 Session 保持可见,缺失或延迟的最近时间 cache 条目回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。 -超过阈值的空白工件,以及来自不提供位置的后端的空白 Session 会保持可见。对于未被读取的工件,缺失或延迟的最近时间 cache 会回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。 - -网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了临界大小资格、拒绝陈旧 true、复用单调 false、小日志精确最近时间、实时附加竞态、回退方向、真人 prompt 最近时间和 fiber 销毁。无密钥 Web snapshot 会启动发行版的压缩 JSONL 组合,在没有 cache row 的情况下播种一份小型冷空白工件,并验证侧栏不展示它。 +网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了拒绝陈旧 true、复用单调 false、cache miss 保持可见、真人 prompt 最近时间和 fiber 销毁。 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml index 50b5c20bf4..9f4c455c9c 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md -2026-07-10-agent-session-identity-and-log-location.md: 1bd16fa4123aa8a44719aa0e8c40c4e662f7cb3b -2026-07-10-agent-session-identity-and-log-location.zh.md: 1b54949fb34a0593eaa255e8ff548c203c8023c8 +2026-07-10-agent-session-identity-and-log-location.md: c849ffb882a3334c380b6736a8c8fd8d07a9da40 +2026-07-10-agent-session-identity-and-log-location.zh.md: 07826993839257dd893ef2a56059170ef9d0db31 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index 1bd16fa412..c849ffb882 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -1,4 +1,4 @@ -# Agent Note: Expose agent session identity and JSONL location to tools and hooks +# Agent Note: Expose agent session identity to tools and hooks Status: implemented @@ -6,29 +6,12 @@ English | [中文](2026-07-10-agent-session-identity-and-log-location.zh.md) ## Problem -An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands. +An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call. Resume, forks, and concurrent parent/child agents make any ambient guess unreliable, while future plugins may need to expose other harness-owned environment facts to shell commands. The boundary must preserve two properties: the owner of a fact decides how to resolve it, and every child receives a per-execution snapshot rather than process-global mutable state. In particular, a nested harness must not leak its ambient `DSH_*` values into a child whose current agent, persistence backend, or configuration differs. ## Decision -Extend the [`SessionPersistence`](../architecture/2026-06-14-session-persistence.md) seam with a synchronous, side-effect-free location query: - -```ts -import type { SessionHeader } from '@deepseek-ai/dsh-session' - -interface SessionLocation { - readonly kind: string - readonly path: string -} - -interface SessionPersistence { - locate(meta: SessionHeader): SessionLocation | undefined -} -``` - -`path` is an absolute local path to the provider's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. An out-of-tree provider without an honest local per-Session artifact returns `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists. - The model-facing bash package owns a `ctx.shellEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment API enumerable for diagnostics and future prompt/UI consumers. The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`: @@ -36,15 +19,16 @@ The registry rebuilds a trusted overlay for every foreground and background bash - `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-home-paths`](../../../../packages/util/home-paths/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`. - `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness. - `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`. -- The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`. -Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. +A transcript-location fact is deliberately absent. An earlier form of this decision also extended the persistence seam with a `locate()` path query feeding a `DSH_SESSION_JSONL` variable and the hook bridges' `transcript_path`; the [persistence export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) note owns removing that half — the paths were only readable with compression disabled, and the seam no longer exposes artifact locations. + +Plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for ambient filtering. The seam carries the managed overlay separately as `ShellExecRequest.dshEnv` / `ShellExecSpec.dshEnv`: ordinary `env` remains the general in-process plugin surface used by hooks, while `dshEnv` is typed to managed keys. The local executor removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot, so an `env` entry can never displace a managed value. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required. -The [Claude Code and Codex hook bridges](2026-06-30-hook-bridges.md) resolve transcript location from the same persistence seam when constructing payloads. Codex uses `transcript_path: string | null`; Claude Code preserves its string field and falls back to `''`. Hook lookup neither materializes nor flushes a session. +The [Claude Code and Codex hook bridges](2026-06-30-hook-bridges.md) keep `transcript_path` in their wire payloads for protocol shape but always send `''` (Claude Code) / `null` (Codex); the [persistence export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) note owns that degradation. ## Peer product findings @@ -52,36 +36,30 @@ Peer products separate stable identity from physical storage. Codex injects stab ## Lifecycle and persistence semantics -A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID` and a JSONL target. The JSONL file may still be absent until the first successful turn-end checkpoint, and during an open turn it contains only the last flushed prefix. `DSH_SESSION_JSONL` is a location hint, not an authorization credential or freshness guarantee. - -Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe. +A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID`. Resume reuses the loaded header and therefore the same id. Fork and spawn create new session ids. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. The registry is effect-scoped and HMR-safe. `dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-home-paths` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix. ## Testing -Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL and no-artifact locator contract tests plus both hook bridge suites pin available and unavailable transcript dialects. +Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, ignored model `env`, and parent/child isolation. Both hook bridge suites pin the constant degraded transcript dialects. -A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice. +A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice. ## Alternatives considered **Only an id plus `find`.** Search cannot know a custom root or backend layout and races under multiple sessions. -**Only an absolute path.** A path can be unavailable, lazy, or representation-specific and is not stable session identity. - **Global `process.env`.** Concurrent agents would overwrite one another and nested harnesses would inherit stale current-session values. -**Put persistence instructions in the session prefix.** A session prefix is frozen while the active service can change across HMR or future backend switching; persistence-specific guidance would become stale. - **A typed waterfall event.** Listeners cannot declare ownership without running, and later listeners can silently overwrite keys. A registry detects key conflicts at registration and remains enumerable. -**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. `locate()` is also still required by hooks. +**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. **A model-facing `session_info` tool.** It adds schema and another call while bash already supplies the query API; the registry generalizes to future environment facts without one tool per fact. ## Consequences -Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them. +Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them. -The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization. +The namespace is discoverable but not secret. `DSH_HOME` can reveal a configured root, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts and rely on sandbox/filesystem policy rather than variable secrecy for authorization. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md index 1b54949fb3..0782699383 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 向工具与钩子公开 agent 会话标识和 JSONL 位置 +# Agent Note: 向工具与钩子公开 agent 会话标识 Status: implemented @@ -6,29 +6,12 @@ Status: implemented ## 问题 -agent(智能体)可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。 +agent(智能体)可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话。恢复、fork 以及并发运行的父子 agent 会让任何来自环境的猜测都不可靠,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。 这项边界必须维持两个属性:事实的所有者决定如何解析该事实;每个子进程接收每次执行的快照,而不是进程级可变全局状态。尤其是嵌套 harness 不能把环境中的 `DSH_*` 值泄漏给当前 agent、持久化后端或配置均可能不同的子进程。 ## 决策 -在 [`SessionPersistence`](../architecture/2026-06-14-session-persistence.zh.md) seam 上增加同步、无副作用的位置查询: - -```ts -import type { SessionHeader } from '@deepseek-ai/dsh-session' - -interface SessionLocation { - readonly kind: string - readonly path: string -} - -interface SessionPersistence { - locate(meta: SessionHeader): SessionLocation | undefined -} -``` - -`path` 是 provider 为 `meta` 保留的专用日志本地绝对路径;`kind` 标识其表示。JSONL 使用解析后的 root 与路径 helper 返回 `{ kind: 'jsonl', path }`。无法诚实提供逐 Session 本地产物的仓库外 provider 返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告按需创建的目标路径。 - 面向模型的 bash 包拥有一个 `ctx.shellEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境 API 可供诊断工具和未来的提示词/UI 消费方枚举。 注册表会为每次前台和后台 bash `ToolExecution` 重新构建受信任的覆盖层: @@ -36,15 +19,16 @@ interface SessionPersistence { - `DSH_HOME` 始终是配置的 Harness home 绝对路径。独立的 [`@deepseek-ai/dsh-home-paths`](../../../../packages/util/home-paths/README.zh.md) 工具库规定其优先级:显式 `dshHome`,其次是环境中的 `$DSH_HOME`,最后是 `~/.dsh`。 - `DSH_SHELL=1` 始终存在,用于标识由 DeepSeek Harness 管理、面向模型的 bash 子进程。 - 执行具有关联 agent 时,`DSH_SESSION_ID` 存在并等于 `agent.session.header.id`。 -- 内置的持久化转换层提供 `DSH_SESSION_JSONL` 的条件是 `ctx.sessionPersistence.locate(header)` 返回 `kind: 'jsonl'`。 -会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 +transcript(文本记录)位置事实被有意省略。本决策的早期形式还在持久化 seam 上增加了 `locate()` 路径查询,为 `DSH_SESSION_JSONL` 变量和钩子桥接层的 `transcript_path` 提供来源;[持久化导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md) Note 负责移除这一半——这些路径只有在禁用压缩时才可读,该 seam 也不再公开产物位置。 + +需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量过滤环境中已有的值。seam 通过 `ShellExecRequest.dshEnv`/`ShellExecSpec.dshEnv` 单独传递受管理的覆盖层:普通 `env` 仍是钩子所用的通用进程内插件接口,`dshEnv` 则以类型约束为受管理键。本地执行器移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照,因此 `env` 条目永远无法顶掉受管理的值。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。 -[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.zh.md)在构造 payload 时,从同一持久化 seam 解析 transcript 位置。Codex 使用 `transcript_path: string | null`;Claude Code 保留其字符串字段,并回退为 `''`。钩子查询不会物化或刷写会话。 +[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.zh.md)为保持协议格式,仍在线上 payload 中保留 `transcript_path` 字段,但始终发送 `''`(Claude Code)/`null`(Codex);这项降级由[持久化导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md) Note 负责。 ## 同类产品调研 @@ -52,36 +36,30 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管 ## 生命周期与持久化语义 -新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID` 和 JSONL 目标。JSONL 文件可能要等到第一次成功的轮次结束检查点后才存在,而且在一个轮次仍未结束时,它只包含上次刷写的前缀。`DSH_SESSION_JSONL` 是位置提示,不是授权凭据或新鲜度保证。 - -恢复操作复用已加载的 header,因此 id 和位置不变。fork 和 spawn 会创建新的会话 id 与位置。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。替换持久化服务会影响后续收集,因为转换层在执行时查询 `ctx.get('sessionPersistence')`;注册表本身受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。 +新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID`。恢复操作复用已加载的 header,因此 id 不变。fork 和 spawn 会创建新的会话 id。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。注册表受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。 `dshHome` 是与会话无关的部署上下文。agent-core 通过 `@deepseek-ai/dsh-home-paths` 解析出一个值,并将其同时传给 tool-bash 和本地 skill(技能)发现;独立消费方调用同一解析器。如果顶层 `dshHome` 与 `skills.local.dshHome` 均已提供但解析结果不同,组合会失败,而不会公开互相矛盾的 home。持久化可以独立变更,无需把其事实冻结到会话前缀中。 ## 测试 -单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL 与无产物定位器约定测试、两套钩子桥接测试均固定 transcript 可用和不可用两种方言。 +单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、忽略模型 `env`,以及父子隔离。两套钩子桥接测试均锁定恒定的降级 transcript 方言。 -一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照测试会固定录制请求 header 中的通用 bash 说明。该约定属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 +一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id 和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在,并最终检查持久化 header。快照测试会固定录制请求 header 中的通用 bash 说明。该约定属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 ## 考虑过的替代方案 **只提供 id,再用 `find`。** 搜索无法得知自定义根目录或后端布局,并且在多会话环境下存在竞态。 -**只提供绝对路径。** 路径可能不可用、延迟创建或取决于表示形式,不能作为稳定的会话标识。 - **使用全局 `process.env`。** 并发 agent 会互相覆盖,嵌套 harness 也会继承陈旧的当前会话值。 -**把持久化说明放入会话前缀。** 活动服务可以在 HMR 或未来的后端切换中改变,而会话前缀保持冻结;持久化专用指引会因此变得陈旧。 - **使用类型化 waterfall 事件。** 监听器不运行就无法声明所有权,而后续监听器可以无提示地覆盖键。注册表能在注册时检测键冲突,并且保持可枚举。 -**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。钩子仍然需要 `locate()`。 +**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。 **增加面向模型的 `session_info` 工具。** bash 已经提供查询 API,新增工具只会多出 schema 和一次调用;注册表可以扩展至未来的环境事实,无需为每项事实增加一个工具。 ## 影响 -每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。 +每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。 -该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 +该命名空间可被发现,但并非秘密。`DSH_HOME` 可能泄露配置的根目录,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml index 9fbc91279b..860f113f10 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md -2026-08-06-manager-owned-subagent-settlement-delivery.md: 7dfa05c247ee0efb71963e057731c7ff6a5a1989 -2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6bd8836d966c85ee0ef08ec19bbf96a4f72b0dd4 +2026-08-06-manager-owned-subagent-settlement-delivery.md: d06245eacd3b7453a031716b1921015a5e38a25c +2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: e5259e97f185203ed77ae9427e5523ac29d4162f diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md index 7dfa05c247..d06245eacd 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md @@ -32,6 +32,10 @@ An external `ctx.on('subagent/end')` listener looks more decoupled and is wrong. Both rules are pinned by tests that fail when the ordering is reversed or the accounting removed. +### Establishment holds ownership open + +The owned-child accounting also guards the creation side: `holdOwnership()` pre-registers the child id in a continuation-managed parent's owned set before the establishment or resume awaits (persistence stat, provider preparation, materialization), so an idle parent cannot be judged settled while a caller is still creating or resuming that child — an admitted delivery after settlement would find a stale parent identity. The returned releaser serves only the failure path: it removes just the hold this call added, and once a live Activation for the child exists the ownership edge belongs to that Activation and `finishDisposal`'s `releaseOwnership`. A parent with no Activation needs no hold (only this manager settles parents), and a parent whose own disposal transaction is already open rejects with `ACTIVATION_CLOSING` instead of establishing a child that could never be delivered to. + ### Scheduling An idle parent gets one ordinary later turn. A busy parent is steered into its nearest step boundary, because `Inbox.claim()` takes the whole next-step batch at one boundary: four children settling together then cost one step rather than four turns. Steering rather than injecting is deliberate — the wake is a no-op while the driver is running, and it closes the window where a driver retires between the status read and the send, which would strand the notice unclaimed until something unrelated woke the parent. This is a correctness rule, not a deployment preference, so it is not a `Config` field. diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md index 6bd8836d96..e5259e97f1 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md @@ -32,6 +32,10 @@ Status: implemented 两条规则都有测试固定:把顺序反转或去掉记账,测试就会失败。 +### 建立期间保持所有权敞开 + +被持有子代的记账同样守护创建侧:`holdOwnership()` 会在建立或恢复的各个 await(持久化 stat、提供方准备、实体化)之前,把子代 id 预先登记进由续接管理的父代持有集合,因此当调用方仍在创建或恢复某个子代时,空闲的父代不会被判定为已结算——若在结算之后才接纳投递,将会遇到过期的父代身份。返回的释放器只服务失败路径:它只移除本次调用添加的持有;一旦该子代存在活跃 Activation,所有权边就归属于该 Activation 与 `finishDisposal` 的 `releaseOwnership`。没有 Activation 的父代不需要持有(只有本管理器会结算父代),而自身 dispose 事务已经打开的父代会以 `ACTIVATION_CLOSING` 拒绝,而不是建立一个永远无法收到投递的子代。 + ### 调度 空闲父级得到一个普通的后续轮次。繁忙父级则被 steer 到其最近的 step 边界,因为 `Inbox.claim()` 会在一个边界上整批取走 next-step:四个 child 同时结算时因此只消耗一个 step,而不是四个轮次。采用 steer 而非 inject 是刻意的——驱动运行期间该唤醒是空操作,同时它关闭了「驱动在状态读取与发送之间退出」的那个窗口;否则通知会滞留无人认领,直到别的事件唤醒父级。这是正确性规则而非部署偏好,因此不做成 `Config` 字段。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 3f5971500e..5dfaad4c18 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: df80ad4d264d835b2c11973ca61cf143576f11f3 -2026-08-10-web-session-log-export.zh.md: 2af86f371f9e7ed5255bb4e57a8a43427c745fe0 +2026-08-10-web-session-log-export.md: 47686e70c2f961c2d7e5d9ebd54b71b25c3c80b7 +2026-08-10-web-session-log-export.zh.md: 2c12c5271a52e37a6ded95bb65d4eb343072ba41 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index df80ad4d26..47686e70c2 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -10,7 +10,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision -- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. +- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `export(id)` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. Connection applies the `/api` trust fence before dispatching the exact `GET`/`HEAD /api/session.export` route registered by `session-log-export`. - **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The current Header and `/export` consumers are defined by the [session-log export package contract](../../../../packages/session-query/session-log-export/README.md). @@ -25,6 +25,6 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Consequences - Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. -- `supportsRawArtifacts` explicitly separates backend capability from session absence: a backend without one raw artifact per Session reports `false` and the concrete `readRaw` default rejects, while the shipped JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response. +- The export needs no seam capability: each log is read through a persistence read handle and serialized here as canonical JSONL, so any mounted backend exports identically ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) records the removal of the earlier verbatim-artifact surface). Absence is decided by a `stat` preflight (absent session → 404). `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response. - Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button. - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 2af86f371f..2c12c5271a 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -10,8 +10,8 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 -- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 -- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。 +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务上的 `export(id)` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。 - **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 当前 Header 与 `/export` Consumer 由 [Session 日志导出包约定](../../../../packages/session-query/session-log-export/README.zh.md)定义。 @@ -25,6 +25,6 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 后果 - 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 -- `supportsRawArtifacts` 明确区分后端能力与会话缺失:没有每 Session 一份原始工件的后端报告 `false`,具体 `readRaw` 默认会拒绝;交付的 JSONL 覆写报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。 +- 导出不需要 seam 能力:每份日志经由持久化读句柄读取,并在此处序列化为规范 JSONL,因此任何挂载的后端导出完全一致(早先逐字工件表面的移除由[导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)记录)。缺失由一次 `stat` 预检判定(会话缺失 → 404)。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。 - fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml index 444893c272..d2f85360d8 100644 --- a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md -2026-08-22-standard-acp-automation-controls.md: 5523fd547dd850e2e52eb818ef418df5a325106e -2026-08-22-standard-acp-automation-controls.zh.md: 23140eb3e76b26716239d0f53fe3206efd87baa9 +2026-08-22-standard-acp-automation-controls.md: dd1e39b84f942417640f741f17a211276b67872c +2026-08-22-standard-acp-automation-controls.zh.md: be54850a933e4e728c672d76595fbf900bdda833 diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md index 5523fd547d..dd1e39b84f 100644 --- a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md @@ -32,7 +32,7 @@ Complete ACP lifecycle support requires session persistence. `session/list` read Persistence deliberately treats `create(meta)` as a live registration: the shipped JSONL provider creates no artifact until the first event append. That default removes abandoned empty sessions, but ACP cannot inherit it because `session/new` publishes a session identity before any prompt and the process may stop after the success response without receiving `session/close`. The bridge materializes only after Agent and MCP composition succeeds and before returning `session/new`; failed composition remains residue-free, while every returned id survives restart. -`ensureMaterialized(session)` accepts the exact live Session so the coordinator first flushes it, then serializes header-only materialization on the existing per-session write chain using the immutable registered header. JSONL writes one header frame; an out-of-tree provider must materialize equivalent header state atomically or reject the operation. Repeat calls are idempotent. Making `create` eager would change every frontend's abandoned-session behavior, appending a synthetic event would invent a sequence and replay fact solely to trigger storage, and waiting until close would make durability race process loss. +The bridge materializes through the ordinary durability barrier: `ctx.sessions.flush(session)` reaches the session's write handle, whose `flush` writes header-only materialization when nothing has been appended. JSONL writes one header frame; an out-of-tree provider must materialize equivalent header state atomically or reject the operation. Repeat calls are idempotent. Making `create` eager would change every frontend's abandoned-session behavior, appending a synthetic event would invent a sequence and replay fact solely to trigger storage, and waiting until close would make durability race process loss. ## Standard configuration options diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md index 23140eb3e7..be54850a93 100644 --- a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md @@ -32,7 +32,7 @@ 持久化有意把 `create(meta)` 视为 live registration:交付的 JSONL provider 在首次追加事件前不创建 artifact。该默认行为会移除被放弃的空会话,但 ACP 不能继承它,因为 `session/new` 会在任何提示词出现前公布会话身份,而进程可能在返回成功响应后、收到 `session/close` 前停止。桥接层只在 Agent 和 MCP 组合成功后、返回 `session/new` 前执行实体化;组合失败仍不留下残留物,每个已返回 id 则都能在重启后继续存在。 -`ensureMaterialized(session)` 接收确切 live Session,使 coordinator 先 flush 该会话,再通过现有 per-session 写入链,使用已注册的不可变 header 串行执行仅 header 实体化。JSONL 写入一个 header frame;仓库外 provider 必须原子实体化等价 header 状态,否则拒绝该操作。重复调用幂等。让 `create` 全面 eager 会改变所有前端放弃会话的行为;追加 synthetic event 会仅为触发存储而虚构 sequence 与 replay 事实;等到关闭时再写入则会让持久性与进程丢失竞争。 +bridge 经由普通的持久性屏障实体化:`ctx.sessions.flush(session)` 抵达该会话的写句柄,其 `flush` 在尚无任何追加时写入仅 header 实体化。JSONL 写入一个 header frame;仓库外 provider 必须原子实体化等价 header 状态,否则拒绝该操作。重复调用幂等。让 `create` 全面 eager 会改变所有前端放弃会话的行为;追加 synthetic event 会仅为触发存储而虚构 sequence 与 replay 事实;等到关闭时再写入则会让持久性与进程丢失竞争。 ## 标准配置选项 diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml index 81fd019718..9ffbc53991 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md -2026-08-27-web-at-mention-discovery-and-row-content.md: ae27f98b07d7a837e095f95129b355770fc8ac02 -2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 8569473d1dec28f371ae8cc12ed50081d2642ab4 +2026-08-27-web-at-mention-discovery-and-row-content.md: defe3eaffbfe111b383467354fb21f088c9475c0 +2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 236cefc55f96f7d918e754622ebc7ae3b7759792 diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md index ae27f98b07..defe3eaffb 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md @@ -34,7 +34,7 @@ The zh composer placeholder says `文件或对话`, matching the `对话` sectio **Fold the missing titles from their logs, memoized per cold log.** Implemented first, then removed in review. It made the first filtered query over a corpus the cache had not covered read those logs — on a 342-session store, roughly 190 of them — to rescue sessions that predate the cache. Correlating that store against the cache's arrival showed why the trade is bad: every session the product writes today gets a checkpoint at creation, `turn/end`, and disposal, and an old session acquires one the first time it is opened. The gap is legacy data that heals on contact, not a shape discovery has to pay for on every keystroke. -**Read a cold session's title through `sessionQuery.observeSession` or `persistence.readFrom`.** Rejected: neither removes the read on the shipped backend. `observeSession` borrows the whole `inspection.events`, and `readFrom` documents that sequential media — JSONL, both encodings — "still parse the whole artifact and skip forward"; the primitive bounds what is returned and refolded, not the physical read. +**Read a cold session's title through `sessionQuery.observeSession` or a persistence read handle.** Rejected: neither removes the read on the shipped backend. `observeSession` borrows the whole `inspection.events`, and `readFrom` documents that sequential media — JSONL, both encodings — "still parse the whole artifact and skip forward"; the primitive bounds what is returned and refolded, not the physical read. **Debounce the candidate fetch.** Rejected. The reducer already resets every group to pending on each hit, so a trailing debounce extends the skeleton state and reads as *slower* while typing. With the fold removed, the round trip no longer justifies the timer; keeping the previous rows visible under a new generation is a separate decision with pick-safety consequences, and is not taken here. diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md index 8569473d1d..236cefc55f 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md @@ -34,7 +34,7 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的 **从日志折叠缺失的标题,并按冷日志记忆化。** 先实现了,评审时移除。它会让缓存尚未覆盖的语料在首次过滤查询时读那些日志——在 342 会话的存储上约 190 份——只为救回早于缓存存在的会话。把该存储与缓存的上线时间对照后可以看出这笔买卖不划算:今天产品写出的每个会话都会在创建、`turn/end` 与销毁三处建立 checkpoint,而旧会话只要被打开一次就会补上。缺口是「一碰即愈」的存量数据,不是发现路径每次击键都该付的形状。 -**通过 `sessionQuery.observeSession` 或 `persistence.readFrom` 读冷会话标题。** 否决:在随附后端上两者都消不掉这次读。`observeSession` 借的是完整的 `inspection.events`;而 `readFrom` 的文档写明顺序介质(JSONL 的两种编码)「仍会解析整个产物再向前跳过」——该原语约束的是返回与重折叠的范围,不是物理读。 +**通过 `sessionQuery.observeSession` 或持久化读句柄读冷会话标题。** 否决:在随附后端上两者都消不掉这次读。`observeSession` 借的是完整的 `inspection.events`;而 `readFrom` 的文档写明顺序介质(JSONL 的两种编码)「仍会解析整个产物再向前跳过」——该原语约束的是返回与重折叠的范围,不是物理读。 **给候选拉取加防抖。** 否决。归约器在每次命中时已经把所有分组重置为 pending,因此尾部防抖会延长骨架状态,输入时读起来更慢。折叠成本移除后,往返时间不再值得一个定时器;在新 generation 下保留上一批行是另一个决定,带有误选后果,此处不做。 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml index 9d7829716d..fbde7aa853 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md -2026-06-19-drop-mutable-session-summary.md: 73fc99b4d948da90b78d66e801915a2fb38e4749 -2026-06-19-drop-mutable-session-summary.zh.md: 97b1b098489cccfbbfdbd48439d640b554a26c37 +2026-06-19-drop-mutable-session-summary.md: b93566914a490b5054b865e51a8fda21925be1b5 +2026-06-19-drop-mutable-session-summary.zh.md: 9a36ba6f20e38a571099372c47588024cfea4853 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md index 73fc99b4d9..b93566914a 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md @@ -22,7 +22,7 @@ Delete the mutable session summary entirely. `SessionSummary` and the `SessionMe Anything the summary was meant to provide is **derivable from the append-only log** when a consumer actually needs it (`firstPrompt` = first `user/message`; recency = the last event's `time` or the file mtime) or already lives in the immutable header (`createdAt`, `cwd`). The one thing *not* derivable — a user-*edited* title — had no implementation and is pure YAGNI; it can return as its own log event or header field if a real feature ever needs it. -The removal narrows the public service contract and JSONL on-disk format; the summary was a deliberate forward-looking design, not an accident; and `SessionHeader` stands where the original Agent Note described `SessionMeta`, which is why the summary vanished. It also simplifies the [shared persistence write coordinator](../architecture/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator needs no `updateSummary` hook, and an out-of-tree provider can reuse the same summary-free orchestration. +The removal narrows the public service contract and JSONL on-disk format; the summary was a deliberate forward-looking design, not an accident; and `SessionHeader` stands where the original Agent Note described `SessionMeta`, which is why the summary vanished. It also simplified the then-current [shared persistence write coordinator](../../archived/architecture/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, that orchestration needed no `updateSummary` hook. ## No migration diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md index 97b1b09848..9a36ba6f20 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -22,7 +22,7 @@ Status: implemented 摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一*不可*派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 -这次移除收窄公开服务约定与 JSONL 磁盘格式;摘要是有意为未来设计的结果,而非意外;原 Agent Note 描述 `SessionMeta` 之处由 `SessionHeader` 承担,这就是摘要消失的原因。它还简化了[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.zh.md):没有可变摘要后,协调器不需要 `updateSummary` 钩子,仓库外 provider 可复用相同的无摘要编排。 +这次移除收窄公开服务约定与 JSONL 磁盘格式;摘要是有意为未来设计的结果,而非意外;原 Agent Note 描述 `SessionMeta` 之处由 `SessionHeader` 承担,这就是摘要消失的原因。它也简化了当时的[共享持久化写入协调器](../../archived/architecture/2026-06-18-shared-persistence-write-coordinator.md):没有可变摘要后,那套编排不需要 `updateSummary` 钩子。 ## 无需迁移 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml similarity index 52% rename from .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml index a353fc98ef..f78c98f5a7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml @@ -1,6 +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 .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md -2026-07-28-load-pre-identity-session-messages.md: 6d022cb4b37345cd61cc9a89c6fc55c19ad402a5 -2026-07-28-load-pre-identity-session-messages.zh.md: 86439337b3c646a72b7584fbf4640799226fc9ca +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md +2026-08-27-persistence-export-and-pre-release-trims.md: ed01023023e9d7817b08e275492b7ec244b893a5 +2026-08-27-persistence-export-and-pre-release-trims.zh.md: 1e31a5836c2761c3d6fb5dbb68b30e8691c1fc69 diff --git a/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md new file mode 100644 index 0000000000..ed01023023 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md @@ -0,0 +1,46 @@ +# Agent Note: Persistence export() and pre-release read-path trims + +Status: implemented + +English | [中文](2026-08-27-persistence-export-and-pre-release-trims.zh.md) + +## Problem + +The session-persistence seam is moving to a handle-based API with cross-process ownership (open/read/append/flush/close per session). Before that swap, the old seam carried surfaces the new design drops or replaces, each with its own consumers and tests: a consumer-facing path query (`locate`), a capability-flagged verbatim read (`supportsRawArtifacts` + `readRaw`), ~300 lines of same-version legacy event-shape migration in the coordinator, and a `locate`-based size gate for the session list's cold blank probe. Removing them inside the seam swap would bloat an already large change; removing them first shrinks the core swap to the seam itself. + +## Decision + +**One verbatim export method.** `SessionPersistence.export(id, signal?)` returns the session's raw artifact (`SessionRawArtifact`: parsed header, logical filename, decoded verbatim text) or `undefined`. The base default resolves `undefined`; JSONL overrides it with the former `readRaw` behavior. `supportsRawArtifacts` and `readRaw` do not exist. The apiproxy ZIP download distinguishes an unsupported backend (session present in `list()` but `export()` undefined → 501) from an absent session (404) by list membership instead of a capability flag. Superseded by the [handle seam](../architecture/2026-08-27-handle-based-session-persistence.md): the WebUI download needs only the logical log, so `export()` was removed entirely and the ZIP route serializes JSONL from a read handle, ending the 501 path. + +**No consumer-facing path query.** `locate` is not a service method. `SessionLocation` survives only as refusal diagnostics: the JSONL backend derives the artifact path internally so `SessionFormatUnsupportedError` can point at the raw log a build refused. The three consumer features built on `locate` are removed or degraded, not ported: + +- `DSH_SESSION_JSONL` no longer exists; shell-env registers no persistence contributor. The variable was only honest with `compression: 'none'` — the default `.jsonl.zstd` artifact is unreadable from bash. +- The Claude Code / Codex hook bridges keep `transcript_path` in the wire payload for protocol shape but always send `''` / `null`. Hook scripts could not parse the compressed artifact either. +- The `locate`-based size gate for the session-controller cold blank probe is deleted. The probe itself runs on stat metadata: the [handle-based seam](../architecture/2026-08-27-handle-based-session-persistence.md)'s `stat()`/`list()` snapshots carry optional `eventCount`/`sizeBytes`, and session-controller bounds the probe with `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`; a cold session past both thresholds, or on a backend offering neither hint, reports `blank: false` (unknown). + +**Legacy event-shape migration is deleted.** Reads validate current v0 records only. Retired event types (`steering/message`, `mode/set`, `request/header-delta`) refuse through the read-side vocabulary gate as `SessionFormatUnsupportedError`. Pre-identity message payloads and the `request/header` `fallback` reason refuse through session validation — surfaced as `SessionPersistenceCorruptionError` on the load/inspect path and as the plain validation error on `readFrom`. Pre-react-loop turn envelopes have no validator: a stale `turn/start.trigger` field and the coarse `aborted`/`disposed` turn-end reasons load unprojected as extension-shaped data, the documented merge-extensible fall-through that the contract test "preserves extension turn/end reasons outside the closed reason set" pins. This consolidates and supersedes the pre-identity-message and pre-react-loop import notes; their record is preserved below. + +## Consolidated record of the deleted same-version imports + +Two shipped read-side imports existed because message identity (2026-07) and the react-loop refactor (2026-08) changed durable payloads without bumping `SESSION_FORMAT_VERSION`: the coordinator normalized four exact pre-identity message payloads (minting deterministic `legacy-message::` identities, with tool-result replacements inheriting their target's id) and projected pre-react-loop shapes (`steering/message` → identified `user/message`, `turn/start.trigger` removal, terminal-reason mapping including a persistence-only `{ kind: 'legacy' }` aborted cause). Both were read-only, exact-shape, and deliberately not a general v0 compatibility layer; their rejected alternatives were stranding first-party sessions, rewriting stored logs in place (violates append-only), and minting unstable identities. + +They no longer justify their surface: no tagged release exists, the covered logs are months-old development artifacts, and the mechanism cost ~300 coordinator lines, per-event normalization on every read, a `readFrom` whole-prefix fallback for suffix reads, and fixture suites in three backends. The capability given up: pre-identity logs refuse to load (loudly, with the raw-log path in the refusal) instead of resuming, and pre-react-loop logs either refuse (when they carry the retired `steering/message` type) or load with their stale turn-envelope fields passed through instead of projected to current shapes. Reintroduction condition: after the first tagged release, a durable format change bumps `SESSION_FORMAT_VERSION` and ships an explicit migration under the version gate — never another same-version exact-shape exception. Absence is verified by the vocabulary-refusal tests in the coordinator contract and backend specs. + +## Alternatives considered + +**Keep `locate` as (or move it to) a separate export-location service.** Rejected: all three path consumers are only functional with compression disabled, so the seam would preserve a half-broken feature; verbatim access needs are served by `export()`. + +**Keep the `supportsRawArtifacts` capability flag beside `export()`.** Rejected: `undefined` plus a list-membership check carries the same information with one seam member instead of three. + +**Keep the migrations until the first tagged release.** Rejected: the pre-release stance ("remove at the first tagged release") already refuses old on-disk formats everywhere else; the migrations' only beneficiaries are development-era logs. + +## Consequences + +The seam ahead of the handle refactor is smaller: one export method, no path query, no capability flag, and a coordinator without migration tables. The costs are recorded degradations: hook payload `transcript_path` is never populated (a durable consumer gap in both hook bridge READMEs), the session list marks never-opened cold sessions blank only within the snapshot-metadata probe thresholds, and development-era logs written before the react-loop refactor refuse to load. The 501/404 split for ZIP export costs one `list()` call on the undefined-export path only. + +## Related + +- [Retain ignorable external session events](../architecture/2026-08-30-retain-ignorable-external-session-events.md) — owns the read-side-only unknown-type gate this change leans on. +- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the seam these trims shrink. +- [Zstandard JSONL session logs](../architecture/2026-07-19-zstandard-jsonl-session-logs.md) — owns the frame container these reads and appends flow through. +- [Session identity and log location](../feature/2026-07-10-agent-session-identity-and-log-location.md) — partially superseded: its `DSH_SESSION_ID` and shell-env registry decisions stand; its `locate`/`DSH_SESSION_JSONL`/`transcript_path` decisions are removed here. diff --git a/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md new file mode 100644 index 0000000000..1e31a5836c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md @@ -0,0 +1,46 @@ +# Agent Note: 持久化 export() 与预发布读取路径精简 + +Status: implemented + +[English](2026-08-27-persistence-export-and-pre-release-trims.md) | 中文 + +## 问题 + +会话持久化 seam 正在迁移到基于句柄、具备跨进程所有权的 API(每个会话的 open/read/append/flush/close)。在这次替换之前,旧 seam 携带着新设计将放弃或替换的多个表面,每个都有自己的消费方与测试:面向消费方的路径查询(`locate`)、由能力标志控制的逐字读取(`supportsRawArtifacts` + `readRaw`)、协调器中约 300 行的同版本 legacy 事件形态迁移,以及会话列表冷空白探测使用的基于 `locate` 的大小门槛。若在 seam 替换中一并移除它们,会让本已庞大的变更进一步膨胀;先移除它们能把核心替换缩小到 seam 本身。 + +## 决策 + +**单一逐字导出方法。**`SessionPersistence.export(id, signal?)` 返回该会话的原始产物(`SessionRawArtifact`:解析后的 header、逻辑文件名、解码后的逐字文本)或 `undefined`。基类默认解析为 `undefined`;JSONL 用原先的 `readRaw` 行为覆盖它。`supportsRawArtifacts` 与 `readRaw` 不复存在。apiproxy 的 ZIP 下载通过 list 成员关系而非能力标志区分不受支持的后端(会话存在于 `list()` 中但 `export()` 为 undefined → 501)与不存在的会话(404)。已被[句柄 seam](../architecture/2026-08-27-handle-based-session-persistence.zh.md)取代:WebUI 下载只需要逻辑日志,因此 `export()` 被整体移除,ZIP 路由改为从读句柄序列化 JSONL,501 路径随之终结。 + +**不提供面向消费方的路径查询。**`locate` 不是服务方法。`SessionLocation` 仅作为拒绝诊断保留:JSONL 后端在内部推导工件路径,使 `SessionFormatUnsupportedError` 能指向构建所拒绝的原始日志。基于 `locate` 构建的三项消费方功能被移除或降级,而非移植: + +- `DSH_SESSION_JSONL` 不复存在;shell-env 不再注册持久化贡献方。该变量只有在 `compression: 'none'` 时才是诚实的——默认的 `.jsonl.zstd` 产物无法从 bash 读取。 +- Claude Code/Codex 钩子桥接层为保持协议格式,仍在线上 payload 中保留 `transcript_path`,但始终发送 `''`/`null`。钩子脚本同样无法解析压缩产物。 +- session-controller 冷空白探测中基于 `locate` 的大小门槛被删除。探测本身运行在 stat 元数据之上:[基于句柄的 seam](../architecture/2026-08-27-handle-based-session-persistence.zh.md) 的 `stat()`/`list()` 快照携带可选的 `eventCount`/`sizeBytes`,session-controller 以 `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes` 限定探测;超过两个阈值的冷会话,或位于两种提示都不提供的后端上的冷会话,报告 `blank: false`(未知)。 + +**删除 legacy 事件形态迁移。**读取只校验当前 v0 记录。已废弃的事件类型(`steering/message`、`mode/set`、`request/header-delta`)经由读取侧词汇门禁以 `SessionFormatUnsupportedError` 拒绝。消息标识机制之前的消息 payload 与 `request/header` 的 `fallback` 原因经由会话校验拒绝——在 load/inspect 路径上表现为 `SessionPersistenceCorruptionError`,在 `readFrom` 上表现为普通校验错误。react-loop 重构之前的轮次 envelope 没有校验器:过时的 `turn/start.trigger` 字段与粗粒度的 `aborted`/`disposed` 轮次结束原因会作为扩展形态数据不经投影地加载,这正是约定测试 "preserves extension turn/end reasons outside the closed reason set" 所钉住的、有文档记载的可合并扩展 fall-through。此举合并并取代了 pre-identity-message 与 pre-react-loop 两份导入 Note;其记录保存在下文。 + +## 已删除的同版本导入的合并记录 + +曾存在两个已上线的读取侧导入,因为消息标识机制(2026-07)与 react-loop 重构(2026-08)在未升级 `SESSION_FORMAT_VERSION` 的情况下改变了持久 payload:协调器会归一化四种精确的 pre-identity 消息 payload(铸造确定性的 `legacy-message::` 标识,工具结果替换项继承其目标的 id),并投影 pre-react-loop 形态(`steering/message` → 带标识的 `user/message`、移除 `turn/start.trigger`、终止原因映射——包括仅存在于持久化中的 `{ kind: 'legacy' }` aborted 原因)。二者都是只读、精确形态匹配,并且有意不构成通用的 v0 兼容层;当时被否决的替代方案是弃置第一方会话、就地改写已存储日志(违反仅追加)以及铸造不稳定的标识。 + +它们已不足以支撑自身的表面:尚无任何已打标签的发布,所覆盖的日志是数月前的开发期产物,而该机制的代价是协调器约 300 行代码、每次读取的逐事件归一化、后缀读取时 `readFrom` 回退到整个前缀,以及三个后端中的 fixture(测试前置数据)套件。放弃的能力是:pre-identity 日志会拒绝加载(明确报错,并在拒绝信息中给出原始日志路径),而不是继续恢复;pre-react-loop 日志则要么被拒绝(当其携带已废弃的 `steering/message` 类型时),要么在加载时把过时的轮次 envelope 字段原样透传,而不是投影为当前形态。重新引入条件:在第一个已打标签的发布之后,持久格式变更升级 `SESSION_FORMAT_VERSION` 并在版本门禁之下提供显式迁移——绝不再开一个同版本精确形态的例外。协调器约定与后端 spec 中的词汇拒绝测试验证该机制确实不存在。 + +## 考虑过的替代方案 + +**保留 `locate`(或将其移入独立的导出位置服务)。**不予采用:三个路径消费方都只有在禁用压缩时才可用,seam 会因此保留一个半失效的功能;逐字访问需求已由 `export()` 满足。 + +**在 `export()` 之外保留 `supportsRawArtifacts` 能力标志。**不予采用:`undefined` 加上 list 成员检查携带相同的信息,却只需一个 seam 成员而非三个。 + +**把迁移保留到第一个已打标签的发布。**不予采用:预发布立场(「在第一个已打标签的发布时移除」)已在其他所有地方拒绝旧的磁盘格式;这些迁移的唯一受益者是开发期日志。 + +## 后果 + +句柄重构之前的 seam 更小:一个导出方法、没有路径查询、没有能力标志,以及不含迁移表的协调器。代价是已记录在案的降级:钩子 payload 的 `transcript_path` 永远不会被填充(两个钩子桥接层 README 中记录的持久消费方缺口);会话列表只在快照元数据探测阈值之内才把从未打开过的冷会话标记为空白;react-loop 重构之前写入的开发期日志会拒绝加载。ZIP 导出的 501/404 区分只在 `export()` 为 undefined 的路径上多一次 `list()` 调用。 + +## 相关资料 + +- [保留可忽略的外部会话事件](../architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)——拥有本变更所依赖的仅读取侧未知类型门禁。 +- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.zh.md)——拥有本次精简所缩小的 seam。 +- [Zstandard JSONL 会话日志](../architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md)——拥有这些读取与追加流经的帧容器。 +- [会话标识与日志位置](../feature/2026-07-10-agent-session-identity-and-log-location.zh.md)——部分被取代:其 `DSH_SESSION_ID` 与 shell-env 注册表决策仍然有效;其 `locate`/`DSH_SESSION_JSONL`/`transcript_path` 决策在此移除。 diff --git a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.i18n.yaml index e697040ad9..d66d1e346d 100644 --- a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md -2026-08-30-jsonl-only-session-persistence.md: f21fb89e3747dffd043d42ace2c05bbe521f3069 -2026-08-30-jsonl-only-session-persistence.zh.md: 4100e576ccdcd46443e12a22cfec6dd3d3495317 +2026-08-30-jsonl-only-session-persistence.md: 6282544a6132ed66eab3b1a174edbf9b67e1c8ad +2026-08-30-jsonl-only-session-persistence.zh.md: 4785ff72d599404fa6d6b897fda520c761150b11 diff --git a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md index f21fb89e37..6282544a61 100644 --- a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md +++ b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md @@ -12,7 +12,7 @@ The SQLite full-text Session-query provider is not an alternative authoritative ## Decision -`@deepseek-ai/dsh-session-persistence-jsonl` is the sole first-party implementation of `ctx.sessionPersistence`. The abstract Service Definition and `PersistenceCoordinator` remain backend-neutral so an out-of-tree provider can implement the same service, but the repository owns and tests one authoritative physical Session format. +`@deepseek-ai/dsh-session-persistence-jsonl` is the sole first-party implementation of `ctx.sessionPersistence`. The abstract Service Definition remains backend-neutral so an out-of-tree provider can implement the same service, but the repository owns and tests one authoritative physical Session format. The `@deepseek-ai/dsh-session-persistence-sqlite` package, its schema resources, backend-specific tests, configuration surface, and Windows differential lane are absent. Cross-package persistence tests use the real JSONL provider or an owner-local fake. `@deepseek-ai/dsh-session-query-sqlite` remains the optional FTS5 query provider over a separate rebuildable database, and `@deepseek-ai/dsh-storage-sqlite` remains the generic domain-KV provider. diff --git a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md index 4100e576cc..4785ff72d5 100644 --- a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md @@ -12,7 +12,7 @@ SQLite 全文 Session-query provider 不是另一种权威 store。它通过 `ct ## Decision -`@deepseek-ai/dsh-session-persistence-jsonl` 是 `ctx.sessionPersistence` 唯一的 first-party 实现。抽象 Service Definition 与 `PersistenceCoordinator` 保持后端无关,使仓库外 provider 仍可实现同一服务,但仓库只拥有并测试一种权威 Session 物理格式。 +`@deepseek-ai/dsh-session-persistence-jsonl` 是 `ctx.sessionPersistence` 唯一的 first-party 实现。抽象 Service Definition 保持后端无关,使仓库外 provider 仍可实现同一服务,但仓库只拥有并测试一种权威 Session 物理格式。 仓库不再包含 `@deepseek-ai/dsh-session-persistence-sqlite` package、其 schema resource、后端专用测试、配置接口与 Windows differential lane。跨 package 持久化测试使用真实 JSONL provider 或 owner-local fake。`@deepseek-ai/dsh-session-query-sqlite` 继续作为可选 FTS5 query provider 使用独立、可重建的数据库,`@deepseek-ai/dsh-storage-sqlite` 继续作为通用 domain-KV provider。 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml index 1862475aae..e16b25b55b 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md -2026-07-29-durable-last-activity-index.md: e391b5cd0281c66221d9ca96920a294c98c7e16f -2026-07-29-durable-last-activity-index.zh.md: 453e8a772d2ad8fe4ab2affcfd7049d86da258c5 +2026-07-29-durable-last-activity-index.md: 8022bb50530dc565813ba4f4c07342b348125eed +2026-07-29-durable-last-activity-index.zh.md: be84ad73ba9f685bdff7cd80a4ed6fdb46006588 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md index e391b5cd02..8022bb5053 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md @@ -10,7 +10,7 @@ A cold (persisted, unattached) session has no authoritative stored answer to "wh The gateway previously used JSONL artifact mtime when available. mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including a truncate-repair of a torn tail, synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) appended during pickup. That approximation promoted a Session merely because it was opened. The [bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) removed mtime ordering and accepted the cache's conservative "too old" failure direction as an interim tradeoff. -An attached summary can fold the live event log and select the latest human-authored `user/message`, but the cold path deliberately does not read large logs. Reading every log to compute `updatedAt` would make `list()` scale with total conversation bytes rather than Session count. The 1 KiB cold read used for metadata verification makes eligible small-artifact recency exact, but it does not make large-log ordering exact. +An attached summary can fold the live event log and select the latest human-authored `user/message`, but the cold path deliberately reads no logs: cold summaries come from the projection cache alone, so cold recency is only as fresh as the cache. Making cold ordering exact remains a durable-format decision, which is why it is scoped here rather than in the gateway workaround. @@ -58,7 +58,7 @@ Three questions must be answered before implementation, and none of them is sett ## Related -- [Bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) — removes mtime ordering, defines the interim projection-cache fallback, and limits direct cold reads to small-artifact metadata verification. +- [Bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) — removes mtime ordering and defines the interim cache-only cold summary this proposal would make exact. - [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-prompt writes that made mtime unsuitable. - [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field. -- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into. +- [Handle-based session persistence](../../implemented/architecture/2026-08-27-handle-based-session-persistence.md) — the write-handle append path a stored field would hook into. diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md index 453e8a772d..be84ad73ba 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md @@ -10,7 +10,7 @@ Status: proposed 网关以前会在可用时采用 JSONL 产物的 mtime。mtime 回答的是另一件事:这份产物上次是什么时候被写入。每一次持久写入都会刷新它,包括对撕裂尾部的截断修复、平衡中断轮次的合成 closer,以及拾起时追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md)。这套近似会让 Session 仅仅因为被打开就提升排序。[有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)移除了 mtime 排序,并把 cache 保守的「过旧」错误方向作为现阶段取舍。 -已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志,会让 `list()` 的开销随对话总字节数而非 Session 数量增长。用于 metadata 验证的 1 KiB 冷读取可以让符合条件的小产物得到精确的最近时间,但不能让大日志的排序精确。 +已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取任何日志:冷摘要只来自 projection cache,因此冷最近时间的新旧只取决于 cache。 让冷排序变得精确仍是一项持久格式决策,因此其范围留在本文,而不是网关 workaround 中。 @@ -58,7 +58,7 @@ Status: proposed ## 相关 -- [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)——移除 mtime 排序,定义 projection cache 的过渡回退,并把直接冷读取限制为小产物 metadata 验证。 +- [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)——移除 mtime 排序,并定义了本提案将使之精确的、仅依赖 cache 的过渡冷摘要。 - [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md)——让 mtime 不适用的非 prompt 写入之一。 - [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.zh.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。 -- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)——一个已存储字段将挂入的那条追加路径。 +- [基于句柄的会话持久化](../../implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)——一个已存储字段将挂入的那条写句柄追加路径。 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml index 2b387384b3..eb78f7fe94 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md -2026-07-04-prune-dead-core-spine-api.md: 554244a67cdbe26d90aca1b4bd7a61e1ca0dc7ec -2026-07-04-prune-dead-core-spine-api.zh.md: a0a2520ecf31a4626b629779ca07b7debf8d062d +2026-07-04-prune-dead-core-spine-api.md: b26a39ef8d18306b9d42894eaab4925c69c37c8b +2026-07-04-prune-dead-core-spine-api.zh.md: b72e902504f66fc48947ea71c75a257ad724e643 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md index 554244a67c..b26a39ef8d 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md @@ -20,7 +20,6 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime | ACP `agentOptions` root export | The helper has only same-file and ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make `agentOptions` source-private and test it through bridge behavior. | | `providerWording` and `completedTurnPrefix` root exports | Each has one same-package production caller; only the balanced-prefix helper has a same-package white-box test. | Make them source-private and test provider behavior. | | `depthOf`, `SubagentDepthError`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. `SENSITIVE_ENV_PATTERN` is excluded because the SDK helper applies it to caller-supplied environments. | Keep depth and exit behavior but make the remaining helpers and error source-private; test through spawn and disposal. Keep the shared credential pattern public. | -| `PersistenceCoordinator.inits`, provider `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep the JSONL provider and `SessionHeader`. | | `LlmError.status` and replay status | Adapters/replay populate it, but production branches on stable error code/message and never reads raw status. | Remove the unread field and replay plumbing while preserving error classification. | | `BlockAssembler.push()` return value | Both production callers ignore the returned completed block. | Return `void`; keep the deliberately public `blocks()`/`message()` contract. | | `compactRegion`'s separate `session` argument | The fixed caller passes the same object already present as `agent.session`; the model-visible mount API can also call the method, but accepting two identities permits a mounted plugin to provide an incoherent pair. | Keep the manual-region API while deliberately narrowing it to `agent.session` as the one source of truth. | diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md index a0a2520ecf..b72e902504 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md @@ -20,7 +20,6 @@ Status: proposed | ACP 的 `agentOptions` 根导出 | 该辅助函数只有同文件和 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 `agentOptions` 改为源码私有,通过桥接层行为测试。 | | `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 | | `depthOf`、`SubagentDepthError`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制机制和测试内部实现。`SENSITIVE_ENV_PATTERN` 不在其中,因为 SDK helper 会将它应用于调用方传入的环境。 | 保留深度与退出行为,但将剩余辅助函数和 error 改为源码私有;通过 spawn 和 dispose 测试。保持共享凭据正则公开。 | -| `PersistenceCoordinator.inits`、provider `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留 JSONL provider 与 `SessionHeader`。 | | `LlmError.status` 与回放 status | 适配器/回放填充它,但生产分支基于稳定的错误码/消息判断,从不读取原始 status。 | 移除未读字段和回放管道,保留错误分类。 | | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 约定。 | | `compactRegion` 的独立 `session` 参数 | 固定调用方传入的对象就是 `agent.session` 中已有的对象;模型可见的 mount API 也可以调用该方法,但同时接受两个独立对象,会让挂载的插件传入不一致的组合。 | 保留手动 region API,同时有意将其收窄为以 `agent.session` 为唯一真源。 | diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml index 8c9988f86a..47d6125e0d 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md -2026-06-20-fold-session-persistence-interface.md: 3079e9495cdbf4dd698fccc65d3c2fdada29cfc1 -2026-06-20-fold-session-persistence-interface.zh.md: 3eff594a0d2377c52bbee54a1d7e3b26a015078e +2026-06-20-fold-session-persistence-interface.md: 58c6aa8f5b3b77f146e18d285ebf3fe1e36b0778 +2026-06-20-fold-session-persistence-interface.zh.md: 9fb53d16fced3d17ee0a76a05df04628ac9d8c95 diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md index 3079e9495c..58c6aa8f5b 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md @@ -22,7 +22,7 @@ The implementing PR should update the [capability seams](../../implemented/archi - `dsh-session` exports the persistence service type, coordinator, and contract helpers. - JSONL and SQLite backend packages depend on `dsh-session` directly. - `agent-loop` resume uses the session-owned service key. -- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../../packages/session/session-persistence/README.md) explain why backend implementations remain separate. +- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [handle-based session persistence](../../implemented/architecture/2026-08-27-handle-based-session-persistence.md), and [package docs](../../../../packages/session/session-persistence/README.md) explain why backend implementations remain separate. ## What we give up diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md index 3eff594a0d..9fb53d16fc 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md @@ -22,7 +22,7 @@ Status: rejected — 独立的持久化 Service Definition 包是持久化能力 - `dsh-session` 导出持久化服务类型、协调器和约定辅助工具。 - JSONL 和 SQLite 后端包直接依赖 `dsh-session`。 - `agent-loop` 的恢复功能使用会话包拥有的服务键。 -- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.zh.md)、[共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)与[包文档](../../../../packages/session/session-persistence/README.zh.md)说明后端实现为何仍保持独立。 +- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.zh.md)、[基于句柄的会话持久化](../../implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)与[包文档](../../../../packages/session/session-persistence/README.zh.md)说明后端实现为何仍保持独立。 ## 放弃了什么 diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 9416a511a3..777a1b08f6 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -11,7 +11,7 @@ This skill helps turn a broad "find things to simplify" request into evidence-ba - Read `AGENTS.md`, especially the pre-release stance and the conventions (including the tests-are-not-golden-truth and Agent Notes-are-not-golden-truth doctrines), plus [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) and [docs/testing.md](../../../docs/testing.md). - Skim [docs/architecture.md](../../../docs/architecture.md) before judging anything under `packages/`; simplifications that fight the service map or event taxonomy need extra evidence. -- Use the Agent Note tree and its [rules](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [JSONL-only first-party Session persistence](../../notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin-adapter Agent Notes. +- Use the Agent Note tree and its [rules](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [handle-based session persistence](../../notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md), [JSONL-only first-party Session persistence](../../notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin-adapter Agent Notes. - Treat dual LLM adapters as intentional by default. Session persistence is different: JSONL is the sole first-party provider, while the backend-neutral service remains available to out-of-tree providers. Do not propose deleting an LLM twin or the persistence seam as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design. ## What Counts As A Strong Candidate diff --git a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts index f6155102e3..312d712575 100644 --- a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts @@ -55,7 +55,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test expect(before.status).not.toBe(0) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const agent = await ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ content: [{ diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts index 7b22be2958..5dfc42f97d 100644 --- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts @@ -45,7 +45,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }, persistenceRoot: join(workdir, '.sessions'), }) - const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const agent = await ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ content: [{ diff --git a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts index cf55e35d28..01c8e65b99 100644 --- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts @@ -29,7 +29,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas it('runs a bash command on request and reports its output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-')) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const agent = await ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index fba29b93b3..8ee3affb8a 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -356,7 +356,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-e2e-')) ctx = await ptcModeHarness(workdir) - const agent = ctx.agentLoop.create(SessionId('e2e-ptc'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const agent = await ctx.agentLoop.create(SessionId('e2e-ptc'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ content: [{ diff --git a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts index 498a4e9499..587c4fda82 100644 --- a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts @@ -5,8 +5,9 @@ import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts' import { describe, expect, it } from 'vitest' const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'expected/semantic-checkpoint') @@ -22,14 +23,13 @@ const task = 'Continue safely from the interrupted operation.' async function seedInterruptedSession(root: string, cwd: string): Promise { const ctx = new Context() - await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) const meta: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, - cwd, isSeeded: false, + cwd, delegationDepth: 0, } const events: SessionEvent[] = [ @@ -70,11 +70,10 @@ async function seedInterruptedSession(root: string, cwd: string): Promise { const ctx = new Context() - await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) const meta: SessionHeader = { version, id: sessionId, createdAt: 1, cwd, isSeeded: false } try { - await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(sessionId, events) - const location = ctx.sessionPersistence.locate(meta) - if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') - return location.path + const handle = await ctx.sessionPersistence.create(meta) + await handle.append(events) + await handle.close() + return logPath(root, meta.cwd, meta.id, 'none') } finally { await ctx.fiber.dispose() } diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index e3c80f9219..13e1e75810 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -11,7 +11,7 @@ import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -33,14 +33,13 @@ const task = 'Call list_agents once and report what it shows.' */ async function seedDescriptorlessChild(root: string, cwd: string): Promise { const ctx = new Context() - await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) const parentMeta: SessionHeader = { version: SESSION_FORMAT_VERSION, id: parentId, createdAt: 1, - cwd, isSeeded: false, + cwd, delegationDepth: 0, } const parentEvents: SessionEvent[] = [ @@ -52,9 +51,9 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise version: SESSION_FORMAT_VERSION, id: childId, createdAt: 2, + isSeeded: false, cwd, parentSession: parentId, - isSeeded: false, origin: 'subagent', delegationDepth: 1, } @@ -63,10 +62,12 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise { type: 'turn/end', seq: SessionSeq(1), time: 21, data: { turn: 1, reason: { kind: 'interrupted' } } }, ] try { - await ctx.sessionPersistence.create(parentMeta) - await ctx.sessionPersistence.append(parentId, parentEvents) - await ctx.sessionPersistence.create(childMeta) - await ctx.sessionPersistence.append(childId, childEvents) + const parentHandle = await ctx.sessionPersistence.create(parentMeta) + await parentHandle.append(parentEvents) + await parentHandle.close() + const childHandle = await ctx.sessionPersistence.create(childMeta) + await childHandle.append(childEvents) + await childHandle.close() } finally { await ctx.fiber.dispose() } diff --git a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts index 9f9da8707d..5cfe4787c8 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts @@ -10,7 +10,7 @@ import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -29,14 +29,13 @@ const task = 'Delegate the write probe to a subagent.' /** Seed a completed parent turn with its read-only policy and current LLM selection. */ async function seedReadOnlyParent(root: string, cwd: string): Promise { const ctx = new Context() - await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) const meta: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, - cwd, isSeeded: false, + cwd, delegationDepth: 0, } const events: SessionEvent[] = [ @@ -61,8 +60,9 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise { { type: 'turn/end', seq: SessionSeq(4), time: 14, data: { turn: 1, reason: { kind: 'completed' } } }, ] try { - await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(sessionId, events) + const handle = await ctx.sessionPersistence.create(meta) + await handle.append(events) + await handle.close() } finally { await ctx.fiber.dispose() } @@ -110,7 +110,7 @@ describe('parent-only override inheritance snapshot', () => { ) expect(childRecords[1]).toMatchObject({ type: 'sandbox/mode', - seq: 0, + seq: SessionSeq(0), data: { mode: 'read-only', source: 'delegation' }, }) diff --git a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts index 5fa792b672..db17d267d4 100644 --- a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts @@ -27,7 +27,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a it('appends a todo/write event with the model-produced task list', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-')) ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const agent = await ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: diff --git a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts index 2fded92b5b..07fafe4438 100644 --- a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts @@ -11,7 +11,7 @@ import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { +import { SESSION_FORMAT_VERSION, SessionId, SessionSeq, @@ -19,6 +19,7 @@ import SessionStore, { type SessionHeader, } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts' import { renderWorkspaceContext } from '@deepseek-ai/dsh-agent-instructions' import { resolveConfig, workspaceBaselineIdentity } from '@deepseek-ai/dsh-agent-instructions/src/config.ts' import { describe, expect, it } from 'vitest' @@ -47,7 +48,6 @@ async function seedVisibleBaseline( options: SeedBaselineOptions = {}, ): Promise { const ctx = new Context() - await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) const meta: SessionHeader = { version: SESSION_FORMAT_VERSION, @@ -103,11 +103,10 @@ async function seedVisibleBaseline( { type: 'turn/end', seq: SessionSeq(3), time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, ] try { - await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(sessionId, events) - const location = ctx.sessionPersistence.locate(meta) - if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') - return location.path + const handle = await ctx.sessionPersistence.create(meta) + await handle.append(events) + await handle.close() + return logPath(root, meta.cwd, meta.id, 'none') } finally { await ctx.fiber.dispose() } diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 99437f7f8c..432c34a201 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -15,9 +15,8 @@ import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { MessageId } from '@deepseek-ai/dsh-llm' import { - SESSION_FORMAT_VERSION, SessionId as sessionId, SessionSeq, type SessionHeader, type SessionId, + SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId, } from '@deepseek-ai/dsh-session' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { @@ -94,7 +93,12 @@ function seedLog(): string { at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }), at(1, { type: 'user/message', - data: { content: [{ type: 'text', text: 'Seeded turn.' }], source: { kind: 'user', rpcId: 'seed' } }, + data: { + id: '00000000-0000-4000-9000-000000000001', + role: 'user', + content: [{ type: 'text', text: 'Seeded turn.' }], + source: { kind: 'user', rpcId: 'seed' }, + }, surfaceOp: 'append', }), at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }), @@ -114,29 +118,27 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: childId, + isSeeded: false, createdAt, cwd: scaffold.workspaceCwd, parentSession: parentId, - isSeeded: false, origin: 'subagent', delegationDepth: 1, agentPreset: 'minimal', } - await scaffold.ctx.sessionPersistence.create(header) - await scaffold.ctx.sessionPersistence.append(childId, [ + const handle = await scaffold.ctx.sessionPersistence.create(header) + await handle.append([ { type: 'turn/start', - seq: SessionSeq(0), + seq: 0, time: createdAt, - data: { turn: 1 }, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }, { type: 'user/message', - seq: SessionSeq(1), + seq: 1, time: createdAt + 1, data: { - id: MessageId(`legacy-message:${childId}:1`), - role: 'user', content: [{ type: 'text', text: 'Check the session-header action order.' }], source: { kind: 'user' }, }, @@ -144,7 +146,7 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise }, { type: 'subagent/descriptor', - seq: SessionSeq(2), + seq: 2, time: createdAt + 2, data: snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn', label: 'header order probe', @@ -152,11 +154,12 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise }, { type: 'turn/end', - seq: SessionSeq(3), + seq: 3, time: createdAt + 3, data: { turn: 1, reason: { kind: 'completed' } }, }, - ]) + ] as SessionEvent[]) + await handle.close() } /** diff --git a/apps/web/tests/cold-blank-session.e2e.ts b/apps/web/tests/cold-blank-session.e2e.ts deleted file mode 100644 index b9e64960d3..0000000000 --- a/apps/web/tests/cold-blank-session.e2e.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** Cold Session list visibility through the shipped compressed JSONL backend. */ - -import { mkdir, stat } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { - captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedBlankSession, - watchConsole, webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/cold-blank-session', import.meta.url)) -const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') -const MODE = webSnapshotMode() -const SESSION_ID = 'cold-blank-session-web-e2e' -const WORKSPACE_NAME = 'cold-blank-workspace' - -describe('web e2e: cold blank Session visibility', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - const cwd = join(scaffold.workspaceCwd, WORKSPACE_NAME) - await mkdir(cwd, { recursive: true }) - await seedBlankSession(scaffold, SESSION_ID, cwd) - const header = (await scaffold.ctx.sessionPersistence.list()) - .find(candidate => candidate.id === SESSION_ID) - if (header === undefined) throw new Error('blank Session fixture did not materialize') - const location = scaffold.ctx.sessionPersistence.locate(header) - if (location === undefined) throw new Error('JSONL fixture has no physical artifact') - expect((await stat(location.path)).size).toBeLessThanOrEqual(1024) - - browser = await chromium.launch() - page = await newEnglishPage(browser) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it('keeps the verified cold blank Session out of the sidebar', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-cold-blank-session')) - const tree = page.getByRole('tree', { name: 'Sessions' }) - await tree.waitFor({ timeout: 30_000 }) - expect(await tree.getByText(WORKSPACE_NAME, { exact: true }).count()).toBe(0) - const sidebar = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) - expect(tripwire.pageErrors).toEqual([]) - }) -}) diff --git a/apps/web/tests/expected/cold-blank-session/sidebar.expected.md b/apps/web/tests/expected/cold-blank-session/sidebar.expected.md deleted file mode 100644 index d4d370892e..0000000000 --- a/apps/web/tests/expected/cold-blank-session/sidebar.expected.md +++ /dev/null @@ -1 +0,0 @@ -- tree "Sessions": No sessions yet diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index de849baea3..c29d9cf977 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -39,12 +39,18 @@ function completedTailFixture(raw: string): string { const decoded = parseSeedFixture(raw) const kept = decoded.events.filter(event => event.seq < 101).map((event) => { if (event.type === 'assistant/message' && event.seq === 64) { - const data = event.data as unknown as { content?: unknown[] } - const content = data.content - if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content') + const data = event.data as unknown as { message?: { content?: unknown[] } } + const message = data.message + const content = message?.content + if (message === undefined || !Array.isArray(content)) { + throw new Error('borrowed step-one assistant message has no content') + } return { ...event, - data: { ...data, content: [...content.slice(0, 1), { type: 'text', text: MID_TURN_TEXT }, ...content.slice(1)] }, + data: { + ...data, + message: { ...message, content: [...content.slice(0, 1), { type: 'text', text: MID_TURN_TEXT }, ...content.slice(1)] }, + }, } } return event @@ -59,10 +65,10 @@ function completedTailFixture(raw: string): string { const tail = [ at({ type: 'step/end', data: { turn: 1, step: 2 } }), at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'aborted' } } }), - at({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } }), - at({ type: 'user/message', data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }), + at({ type: 'turn/start', data: { turn: 2 } }), + at({ type: 'user/message', data: { id: '00000000-0000-4000-9000-000000000201', role: 'user', content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }), at({ type: 'step/start', data: { turn: 2, step: 1 } }), - at({ type: 'assistant/message', data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' }), + at({ type: 'assistant/message', data: { turn: 2, step: 1, message: { id: '00000000-0000-4000-9000-000000000202', role: 'assistant', content: [{ type: 'text', text: 'DONE' }], source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' } } }, sourceEventSeqs: [], surfaceOp: 'append' }), at({ type: 'step/end', data: { turn: 2, step: 1 } }), at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }), ] diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 95a7118560..388c52a727 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -63,11 +63,10 @@ import type { } from '@deepseek-ai/dsh-llm' import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import SessionStore, { +import { packChunkRuns, SESSION_FORMAT_VERSION, SessionId, - SessionSeq, type Session, type SessionEvent, type SessionHeader, @@ -951,8 +950,9 @@ export function fixtureIdentity( /** * Seed a recorded session fixture into the scaffold's persistence root - * through the REAL backend API (throwaway Context + SessionStore + JSONL - * plugin — the semantic-checkpoint precedent), never raw file writes: no + * through the REAL backend API (throwaway Context + JSONL plugin, a + * create-handle/append/close write — the semantic-checkpoint precedent), + * never raw file writes: no * knowledge of bucket hashing, filename encoding, or compression, and * malformed session events fail loud at seed time. The fixture's tokenized identity * ({{sessionId}}/{{cwd}}) is realized for this world before parsing. Event @@ -1042,8 +1042,8 @@ export async function seedSession( version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: Date.now() - 60_000, - cwd: scaffold.workspaceCwd, isSeeded: false, + cwd: scaffold.workspaceCwd, delegationDepth: 0, ...agentPreset === undefined ? {} : { agentPreset }, } @@ -1057,29 +1057,6 @@ export async function seedSession( return meta.id } -/** Seed one materialized cold Session whose log has no turn/start event. */ -export async function seedBlankSession( - scaffold: WebScaffold, - id: string, - cwd: string, -): Promise { - const meta: SessionHeader = { - version: SESSION_FORMAT_VERSION, - id: SessionId(id), - createdAt: Date.now() - 60_000, - cwd, - isSeeded: false, - delegationDepth: 0, - } - await persistSeedSession(scaffold, meta, [{ - type: 'session/end-seed', - seq: SessionSeq(0), - time: meta.createdAt, - data: {}, - }]) - return meta.id -} - /** Materialize one detached Session fixture through the shipped JSONL provider. */ async function persistSeedSession( scaffold: WebScaffold, @@ -1088,17 +1065,35 @@ async function persistSeedSession( ): Promise { const seeder = new Context() try { - await seeder.plugin(SessionStore) // Same root as the booted tree with the plugin's own default compression, // so the host's directory-scan list() sees one consistent encoding. await seeder.plugin(JsonlSessionPersistence, { root: scaffold.persistenceRoot }) - await seeder.sessionPersistence.create(meta) - await seeder.sessionPersistence.append(meta.id, events) + const handle = await seeder.sessionPersistence.create(meta) + await handle.append(events) + await handle.close() } finally { await seeder.fiber.dispose() } } +/** + * Read one stored session's physical event log through a throwaway read + * handle. The physical log carries no synthetic closers: a resumed session + * shows the closers the loop appended durably, and a never-resumed + * interrupted log stays interrupted. + * @param scaffold - the booted scaffold whose persistence holds the session. + * @param id - the stored session to read. + * @returns the stored events. + */ +export async function readPersistedEvents(scaffold: WebScaffold, id: SessionId): Promise { + const handle = await scaffold.ctx.sessionPersistence.open(id, 'read') + try { + return await handle.read() + } finally { + await handle.close() + } +} + /** * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, * decode-throughput, and path-sensitive compaction estimates collapse to diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 28c3425941..2560b7a886 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -10,7 +10,7 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { ToolCallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { SessionId, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { ScheduleId, createEveryScheduleRecord, @@ -615,12 +615,13 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await workspace.attachSession(CATALOG_SESSION_ID) // Seed the zero-I/O list view before the Session is opened. - const catalog = await scaffold.ctx.sessionPersistence.readFrom(CATALOG_SESSION_ID, SessionLogOffset(0)) - scaffold.ctx.sessionProjectionCache.coldSnapshot( - catalog.meta, - catalog.inheritedEventCount, - catalog.events, - ) + const catalogReader = await scaffold.ctx.sessionPersistence.open(CATALOG_SESSION_ID, 'read') + try { + const catalogEvents = [...await catalogReader.read()] + scaffold.ctx.sessionProjectionCache.coldSnapshot(catalogReader.header, catalogReader.inheritedEventCount, catalogEvents) + } finally { + await catalogReader.close() + } browser = await chromium.launch() page = await browser.newPage({ diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index a55958353d..e20dde3149 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -149,6 +149,8 @@ function withCompaction(raw: string, meter: TokenMeter): string { at({ type: 'user/message', data: { + id: '00000000-0000-4000-8000-00000000c0de', + role: 'user', content: [{ type: 'text', text: 'Model-only compact checkpoint.', diff --git a/apps/web/tests/stats-paged-history.e2e.ts b/apps/web/tests/stats-paged-history.e2e.ts index 1e6682489d..6e6174232c 100644 --- a/apps/web/tests/stats-paged-history.e2e.ts +++ b/apps/web/tests/stats-paged-history.e2e.ts @@ -45,7 +45,12 @@ function buildSeed(turns: number): string { at({ type: 'turn/start', data: { turn } }) at({ type: 'user/message', - data: { content: [{ type: 'text', text: `m${turn}` }], source: { kind: 'user' } }, + data: { + id: `00000000-0000-4000-9000-${String(turn).padStart(12, '0')}`, + role: 'user', + content: [{ type: 'text', text: `m${turn}` }], + source: { kind: 'user' }, + }, surfaceOp: 'append', }) at({ type: 'step/start', data: { turn, step: 1 } }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 56b96adc4c..508dc711bc 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -5,16 +5,15 @@ import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { MessageId } from '@deepseek-ai/dsh-llm' -import { - SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, type SessionId, +import { SessionLogOffset, + SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId, } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, - launchWebScaffold, watchConsole, + launchWebScaffold, readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -67,10 +66,9 @@ async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promi async function waitForCacheRow( scaffold: WebScaffold, header: SessionHeader, - inheritedEventCount: SessionLogOffset, ): Promise { const deadline = Date.now() + 10_000 - while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header, inheritedEventCount) === undefined) { + while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0)) === undefined) { if (Date.now() >= deadline) throw new Error(`cache row for "${header.id}" did not land`) await new Promise(resolve => setTimeout(resolve, 10)) } @@ -136,26 +134,26 @@ describe('web e2e: persisted subagent conversation and human continuation', () = version: SESSION_FORMAT_VERSION, id: oneShotId, createdAt: oneShotAt, + isSeeded: false, cwd: scaffold.workspaceCwd, parentSession: parent.id, - isSeeded: false, origin: 'subagent', delegationDepth: 1, } - await scaffold.ctx.sessionPersistence.create(oneShotHeader) + const oneShotHandle = await scaffold.ctx.sessionPersistence.create(oneShotHeader) const oneShotEvents = [ { type: 'turn/start', - seq: SessionSeq(0), + seq: 0, time: oneShotAt, data: { turn: 1 }, }, { type: 'user/message', - seq: SessionSeq(1), + seq: 1, time: oneShotAt + 1, data: { - id: MessageId(`legacy-message:${oneShotId}:1`), + id: '00000000-0000-4000-9000-000000000101', role: 'user', content: [{ type: 'text', text: 'Review the event sourcing explanation.' }], source: { kind: 'user' }, @@ -164,7 +162,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, { type: 'subagent/descriptor', - seq: SessionSeq(2), + seq: 2, time: oneShotAt + 2, data: snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL, @@ -172,40 +170,41 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, { type: 'turn/end', - seq: SessionSeq(3), + seq: 3, time: oneShotAt + oneShotDurationMs, data: { turn: 1, reason: { kind: 'completed' } }, }, - ] satisfies SessionEvent[] - await scaffold.ctx.sessionPersistence.append(oneShotId, oneShotEvents) + ] as SessionEvent[] + await oneShotHandle.append(oneShotEvents) + await oneShotHandle.close() scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotHeader, SessionLogOffset(0), oneShotEvents) - await waitForCacheRow(scaffold, oneShotHeader, SessionLogOffset(0)) + await waitForCacheRow(scaffold, oneShotHeader) grandchildId = sessionId('recorded-grandchild') const authoredAt = Date.now() const grandchildHeader: SessionHeader = { version: SESSION_FORMAT_VERSION, id: grandchildId, createdAt: authoredAt, + isSeeded: false, cwd: scaffold.workspaceCwd, parentSession: childId, - isSeeded: false, origin: 'subagent', delegationDepth: 2, } - await scaffold.ctx.sessionPersistence.create(grandchildHeader) + const grandchildHandle = await scaffold.ctx.sessionPersistence.create(grandchildHeader) const grandchildEvents = [ { type: 'turn/start', - seq: SessionSeq(0), + seq: 0, time: authoredAt, data: { turn: 1 }, }, { type: 'user/message', - seq: SessionSeq(1), + seq: 1, time: authoredAt + 1, data: { - id: MessageId(`legacy-message:${grandchildId}:1`), + id: '00000000-0000-4000-9000-000000000102', role: 'user', content: [{ type: 'text', text: NESTED_PROMPT }], source: { kind: 'user' }, @@ -214,7 +213,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, { type: 'subagent/descriptor', - seq: SessionSeq(2), + seq: 2, time: authoredAt + 2, data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'spawn', label: NESTED_LABEL, @@ -222,14 +221,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, { type: 'turn/end', - seq: SessionSeq(3), + seq: 3, time: authoredAt + 3, data: { turn: 1, reason: { kind: 'completed' } }, }, - ] satisfies SessionEvent[] - await scaffold.ctx.sessionPersistence.append(grandchildId, grandchildEvents) + ] as SessionEvent[] + await grandchildHandle.append(grandchildEvents) + await grandchildHandle.close() scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildHeader, SessionLogOffset(0), grandchildEvents) - await waitForCacheRow(scaffold, grandchildHeader, SessionLogOffset(0)) + await waitForCacheRow(scaffold, grandchildHeader) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() @@ -575,10 +575,12 @@ describe('web e2e: persisted subagent conversation and human continuation', () = throw new Error(`post-fork follow-up rejected: ${JSON.stringify(promptReceipt.result.error)}`) } await expect.poll(async () => { - const loaded = await scaffold.ctx.sessionPersistence.load(childId) - const messageIndex = loaded.events.findIndex(event => event.type === 'user/message' + // The resumed loop appends the follow-up turn's closing events durably, + // so the physical log alone answers whether the turn settled. + const events = await readPersistedEvents(scaffold, childId) + const messageIndex = events.findIndex(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'text' && block.text === POST_FORK_FOLLOWUP)) - return messageIndex >= 0 && loaded.events.slice(messageIndex + 1).some(event => event.type === 'turn/end') + return messageIndex >= 0 && events.slice(messageIndex + 1).some(event => event.type === 'turn/end') }, { timeout: 30_000 }).toBe(true) expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined() await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined() diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index fb7e334719..b6a06857d6 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -23,7 +23,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { SubagentPromptRequestId } from '@deepseek-ai/dsh-subagent' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, - launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -305,15 +305,17 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1) await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() - const loaded = await scaffold.ctx.sessionPersistence.load(childId) - const userTexts = loaded.events.flatMap(event => event.type === 'user/message' + // The settled child's loop appended every turn's closing events durably, + // so the physical log carries the complete record asserted here. + const events = await readPersistedEvents(scaffold, childId) + const userTexts = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(userTexts[0]).toBe(INITIAL) expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /) expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, FOLLOWUP, WAKING]) - const turnEndKinds = loaded.events + const turnEndKinds = events .filter(event => event.type === 'turn/end') .map(event => event.data.reason.kind) expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed']) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 0243a8e170..0d674dee86 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -13,7 +13,7 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent' -import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { launchWebScaffold, readPersistedEvents, webSnapshotMode, type WebScaffold } from './scaffold.ts' const MODE = webSnapshotMode() const INITIAL = 'Explain event sourcing in one sentence.' @@ -176,17 +176,19 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th expect(waking).toMatchObject({ ok: true }) await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() - const loaded = await scaffold.ctx.sessionPersistence.load(childId) + // The settled child's loop appended every turn's closing events durably, + // so the physical log carries the complete record asserted here. + const events = await readPersistedEvents(scaffold, childId) // Human-origin messages only: the real composition also injects // runtime-context snapshots as non-user-source messages. - const userTexts = loaded.events.flatMap(event => event.type === 'user/message' + const userTexts = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(userTexts[0]).toBe(INITIAL) expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /) expect(userTexts.slice(2)).toEqual([FOLLOWUP, WAKING]) - const turnEndKinds = loaded.events + const turnEndKinds = events .filter(event => event.type === 'turn/end') .map(event => (event).data.reason.kind) expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed']) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a8345c45d3..8c885a395a 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -18,9 +18,10 @@ import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' +import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, readPersistedEvents, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' @@ -212,12 +213,12 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff if (workspace === undefined) throw new Error('GUI did not register the existing project directory') await workspace.attachSession(SessionId(SEED_ID)) const header = (await scaffold.ctx.sessionPersistence.list()) + .map(snapshot => snapshot.header) .find(candidate => candidate.id === SEED_ID) if (header === undefined) throw new Error('seeded Session log disappeared before deletion') - const logLocation = scaffold.ctx.sessionPersistence.locate(header) - if (logLocation === undefined) throw new Error('JSONL persistence did not expose the seeded log path') + const seededLogPath = logPath(scaffold.persistenceRoot, header.cwd, header.id, 'zstd') expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') - await stat(logLocation.path) + await stat(seededLogPath) // Open the seeded (first/accounted) Session so deletion must preserve the // current selection while it moves into Ungrouped. @@ -261,8 +262,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff { timeout: 10_000 }, ).toBe(1) expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') - await stat(logLocation.path) - expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + await stat(seededLogPath) + expect((await readPersistedEvents(scaffold, SessionId(SEED_ID))).length).toBeGreaterThan(0) // Re-registering the exact deleted path immediately, without a reload, is // a supported reversible flow. It creates a fresh Workspace id and does @@ -285,7 +286,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) .toBeGreaterThanOrEqual(1) expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') - await stat(logLocation.path) + await stat(seededLogPath) // Restore the deleted-registry state so reload still verifies deletion // persistence independently of the successful re-registration above. @@ -308,8 +309,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff ).toBe(1) expect(scaffold.ctx.workspaceRegistry.get(workspace.id)).toBeUndefined() expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') - await stat(logLocation.path) - expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + await stat(seededLogPath) + expect((await readPersistedEvents(scaffold, SessionId(SEED_ID))).length).toBeGreaterThan(0) expect(transientSlotErrors).toEqual([]) expect(slotConsoleErrors).toEqual([]) @@ -583,7 +584,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // Durable on the host: the registry-global set carries the id while the // session log itself stays in persistence untouched. expect([...scaffold.ctx.workspaceRegistry.archivedSessionIds]).toEqual([SessionId(SEED_ID)]) - expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID)) + expect((await scaffold.ctx.sessionPersistence.list()).map(snapshot => snapshot.header.id)).toContain(SessionId(SEED_ID)) // Reload: the hidden state is rebuilt from the workspace.list baseline. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 23760125d3..114c7ca082 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -54,7 +54,6 @@ "tests/hmr-live.e2e.ts", "tests/preview-boot.e2e.ts", "tests/seeded-history.e2e.ts", - "tests/cold-blank-session.e2e.ts", "tests/stats-paged-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", "tests/rail-search-expand.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 9516e43c8a..14c3c8ad07 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/architecture.md -architecture.md: ea1719b68e1109ca5446699a59ebb503f7f989e2 -architecture.zh.md: 3e7bbe7d51559d91858ba1062656c36bb390ec96 +architecture.md: 902fd7b53fe7493127da68f9a8f381a33b8edc18 +architecture.zh.md: 2890b32b5db1ac30f6eafef47b019a03c6acedd4 diff --git a/docs/architecture.md b/docs/architecture.md index ea1719b68e..902fd7b53f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -139,7 +139,8 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Generate session titles | register the sole `ctx.sessionTitle` provider | | Manage a same-session objective | use `ctx.goals`; continue through `agent/*` | -| Fork a live session | `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| Fork a session at a turn boundary | `ctx.agents.create({ sessionId, seed, meta: { parentSession, seedLength } })` — only agent-loop-published sessions persist | +| Store sessions in a new backend | implement `SessionPersistence` (`create`/`open`/`stat`/`list`/`export`) over the shared handle scaffolding | | Scope a registration to one agent | use that agent's `agent.ctx` | The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [settings cards](cookbook/adding-a-settings-card.md). The [Conversation subsystem](subsystems/conversation.md) owns Chat-node assembly. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 3e7bbe7d51..2890b32b5d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -143,7 +143,8 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 生成会话标题 | 注册唯一的 `ctx.sessionTitle` 提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `agent/*` 续跑 | -| fork 活跃会话 | `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| 在轮次边界 fork 会话 | `ctx.agents.create({ sessionId, seed, meta: { parentSession, seedLength } })`——只有经 agent-loop 发布的会话才会持久化 | +| 在新后端存储会话 | 基于共享的句柄脚手架实现 `SessionPersistence`(`create`/`open`/`stat`/`list`/`export`) | | 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx` | [扩展实操手册](cookbook/extension-cookbook.zh.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.zh.md)、[工具](cookbook/adding-a-tool.zh.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.zh.md)和[设置卡片](cookbook/adding-a-settings-card.zh.md)的分步指南。[Conversation 子系统](subsystems/conversation.zh.md)负责 Chat node 组装。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 19563b4b27..6ef6574c80 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: ec62912c76ad336a7bd7e966be7921c9938a211c -config-catalog.zh.md: e785cacb15652e4162066cbdae478870cf61bc4e +config-catalog.md: f41dcfc49f2dae3b529bd2d492aa56057cb8d031 +config-catalog.zh.md: 948f5edd492fd5ddd1eec2003de32bffc784c2f9 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ec62912c76..f41dcfc49f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -111,7 +111,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) -Source: [`packages/core/agent-loop/src/index.ts:311`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts) @@ -208,14 +208,16 @@ Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions ```ts config-catalog /** Session Controller deployment policy. */ export interface Config { - /** Maximum cold Session artifact size eligible for one full projection observation. */ + /** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */ + readonly coldBlankProbeMaxEvents?: number + /** Maximum stat-reported artifact byte size eligible for one full cold projection observation; `0` disables the byte-size gate. */ readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean } ``` -Source: [`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) @@ -817,7 +819,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude-code/src/index.ts:46`](../packages/hooks/hooks-claude-code/src/index.ts) +Source: [`packages/hooks/hooks-claude-code/src/index.ts:45`](../packages/hooks/hooks-claude-code/src/index.ts) @@ -844,7 +846,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) @@ -1819,14 +1821,12 @@ export interface Config { export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ``` -Source: [`packages/session-query/session-log-export/src/index.ts:42`](../packages/session-query/session-log-export/src/index.ts) +Source: [`packages/session-query/session-log-export/src/index.ts:45`](../packages/session-query/session-log-export/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` -Requires: `sessions` - ```ts config-catalog /** Plugin config: where the JSONL backend keeps its session logs, and the packed-row write switch. */ export interface Config { @@ -1848,17 +1848,13 @@ export interface Config { packChunks?: boolean /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression - /** Maximum cold Session preparations retained for history-to-resume reuse. */ - preparedSessionCacheSize?: number - /** Fixed live-event coalescing window; not a backend completion deadline. */ - writeBatchMaxDelayMs?: number } /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:70`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:73`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1915,8 +1911,10 @@ export interface Config extends SessionQueryConfig { maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number - /** Maximum concurrent persisted-log inspections in one inherited batch read. Defaults to 4. */ - persistedInspectConcurrency?: number + /** Maximum concurrent persisted-log reads in one inherited batch read. Defaults to 4. */ + persistedReadConcurrency?: number + /** Maximum cold prepared-Session observations the inherited reader retains for reuse. Defaults to 5. */ + preparedSessionCacheSize?: number } /** SQLite module/handle opening phase; `never` disables full-text search entirely. */ @@ -1928,7 +1926,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:96`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:92`](../packages/session-query/session-query-sqlite/src/index.ts) @@ -2078,7 +2076,7 @@ export interface Config { } ``` -Source: [`packages/shell/shell-env/src/index.ts:29`](../packages/shell/shell-env/src/index.ts) +Source: [`packages/shell/shell-env/src/index.ts:28`](../packages/shell/shell-env/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e785cacb15..948f5edd49 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -113,7 +113,7 @@ export interface Config { 依赖:[`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) -来源:[`packages/core/agent-loop/src/index.ts:311`](../packages/core/agent-loop/src/index.ts) +来源:[`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts) @@ -210,14 +210,16 @@ export interface Config { ```ts config-catalog /** Session Controller deployment policy. */ export interface Config { - /** Maximum cold Session artifact size eligible for one full projection observation. */ + /** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */ + readonly coldBlankProbeMaxEvents?: number + /** Maximum stat-reported artifact byte size eligible for one full cold projection observation; `0` disables the byte-size gate. */ readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean } ``` -来源:[`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) @@ -819,7 +821,7 @@ export interface Config { } ``` -来源:[`packages/hooks/hooks-claude-code/src/index.ts:46`](../packages/hooks/hooks-claude-code/src/index.ts) +来源:[`packages/hooks/hooks-claude-code/src/index.ts:45`](../packages/hooks/hooks-claude-code/src/index.ts) @@ -846,7 +848,7 @@ export interface Config { } ``` -来源:[`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) +来源:[`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) @@ -1821,14 +1823,12 @@ export interface Config { export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ``` -来源:[`packages/session-query/session-log-export/src/index.ts:42`](../packages/session-query/session-log-export/src/index.ts) +来源:[`packages/session-query/session-log-export/src/index.ts:45`](../packages/session-query/session-log-export/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` -需要:`sessions` · `sessionProjections` - ```ts config-catalog /** Plugin config: where the JSONL backend keeps its session logs, and the packed-row write switch. */ export interface Config { @@ -1850,17 +1850,13 @@ export interface Config { packChunks?: boolean /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression - /** Maximum cold Session preparations retained for history-to-resume reuse. */ - preparedSessionCacheSize?: number - /** Fixed live-event coalescing window; not a backend completion deadline. */ - writeBatchMaxDelayMs?: number } /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' ``` -来源:[`packages/session/session-persistence-jsonl/src/index.ts:70`](../packages/session/session-persistence-jsonl/src/index.ts) +来源:[`packages/session/session-persistence-jsonl/src/index.ts:73`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1917,8 +1913,10 @@ export interface Config extends SessionQueryConfig { maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number - /** Maximum concurrent persisted-log inspections in one inherited batch read. Defaults to 4. */ - persistedInspectConcurrency?: number + /** Maximum concurrent persisted-log reads in one inherited batch read. Defaults to 4. */ + persistedReadConcurrency?: number + /** Maximum cold prepared-Session observations the inherited reader retains for reuse. Defaults to 5. */ + preparedSessionCacheSize?: number } /** SQLite module/handle opening phase; `never` disables full-text search entirely. */ @@ -1930,7 +1928,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' 依赖:[`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -来源:[`packages/session-query/session-query-sqlite/src/index.ts:96`](../packages/session-query/session-query-sqlite/src/index.ts) +来源:[`packages/session-query/session-query-sqlite/src/index.ts:92`](../packages/session-query/session-query-sqlite/src/index.ts) @@ -2080,7 +2078,7 @@ export interface Config { } ``` -来源:[`packages/shell/shell-env/src/index.ts:29`](../packages/shell/shell-env/src/index.ts) +来源:[`packages/shell/shell-env/src/index.ts:28`](../packages/shell/shell-env/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 28dc25a1c8..571af289d6 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/event-producer-consumer.md -event-producer-consumer.md: bdfb3f23e817dadebb090df154af8e1ebf018ee8 -event-producer-consumer.zh.md: 3fe1fe737cb7e99bb8e3a8641dc7bbd7b11b74a4 +event-producer-consumer.md: d04a37ee71756619b36de192011a0053e5ceccb8 +event-producer-consumer.zh.md: 226c368517d989cba2eeac515ee2c544151e8005 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bdfb3f23e8..d04a37ee71 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,9 +7,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:239`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | @@ -45,10 +45,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3fe1fe737c..226c368517 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -9,9 +9,9 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:239`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | @@ -47,10 +47,10 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 3b1f274aee..7a1a91a45a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/module-graph.md -module-graph.md: a028d1efd12924f0592b1dba954071cebd9d3dbe -module-graph.zh.md: c4efd116e5c79aa9c0210d5ff3692d7a39cd1c46 +module-graph.md: 514d09c5883ec8196e0706fbd7a3ccf45b8aafb5 +module-graph.zh.md: 444100e17ce6794efa39ef2e0a10872a6e1c3484 diff --git a/docs/module-graph.md b/docs/module-graph.md index a028d1efd1..514d09c588 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -447,6 +447,7 @@ flowchart TD pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox pkg_spill_local --> pkg_spill + pkg_session_log_export --> pkg_session_persistence pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_llm pkg_message_feedback --> pkg_session @@ -749,7 +750,6 @@ flowchart TD pkg_hooks_codex --> pkg_hook_protocol pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session - pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_command_compact --> pkg_commands @@ -835,7 +835,6 @@ flowchart TD pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm pkg_shell_env --> pkg_home_paths - pkg_shell_env --> pkg_session_persistence pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools pkg_tool_bash_persistent --> pkg_agent @@ -1032,7 +1031,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_hook_protocol pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session - pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools @@ -1154,7 +1152,6 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1266,6 +1263,7 @@ flowchart TD | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session-persistence`](../packages/session/session-persistence) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1328,7 +1326,7 @@ flowchart TD | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1345,7 +1343,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | @@ -1375,7 +1373,7 @@ flowchart TD | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c4efd116e5..444100e17c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -449,6 +449,7 @@ flowchart TD pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox pkg_spill_local --> pkg_spill + pkg_session_log_export --> pkg_session_persistence pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_llm pkg_message_feedback --> pkg_session @@ -751,7 +752,6 @@ flowchart TD pkg_hooks_codex --> pkg_hook_protocol pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session - pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_command_compact --> pkg_commands @@ -837,7 +837,6 @@ flowchart TD pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm pkg_shell_env --> pkg_home_paths - pkg_shell_env --> pkg_session_persistence pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools pkg_tool_bash_persistent --> pkg_agent @@ -1034,7 +1033,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_hook_protocol pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session - pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools @@ -1156,7 +1154,6 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1268,6 +1265,7 @@ flowchart TD | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session-persistence`](../packages/session/session-persistence) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1330,7 +1328,7 @@ flowchart TD | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1347,7 +1345,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | @@ -1377,7 +1375,7 @@ flowchart TD | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 68394c63b7..013ca16f25 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/persistence-catalog.md -persistence-catalog.md: 2fb2b020841a42da6f7505928a87868fc43bc559 -persistence-catalog.zh.md: 4014fd2c10e20193ea68a5f6c761c99b4569b032 +persistence-catalog.md: 1439f5db933899cd145e302d9eefcebfad94097b +persistence-catalog.zh.md: 1c18a63f4382edafd23cf6c70dbef8922add34d3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 2fb2b02084..1439f5db93 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -90,7 +90,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:373`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts) ## Events @@ -215,7 +215,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) @@ -237,7 +237,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) ### `command/*` @@ -563,7 +563,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:339`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) @@ -582,7 +582,7 @@ Source: [`packages/core/session/src/types.ts:339`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -657,7 +657,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:364`](../packages/core/session/src/types.ts) @@ -717,7 +717,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:57`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) @@ -728,7 +728,7 @@ Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -859,7 +859,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) @@ -934,7 +934,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1014,7 +1014,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) @@ -1030,7 +1030,7 @@ Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) ### `user/*` @@ -1049,7 +1049,7 @@ Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4014fd2c10..1c18a63f43 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -92,7 +92,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:373`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts) ## 事件 @@ -217,7 +217,7 @@ export type SessionEvent = { 类型:[StreamChunk](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) @@ -239,7 +239,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) ### `command/*` @@ -565,7 +565,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:339`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) @@ -584,7 +584,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -659,7 +659,7 @@ export type SessionEvent = { 'session/end-seed': Record ``` -来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:364`](../packages/core/session/src/types.ts) @@ -719,7 +719,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) @@ -730,7 +730,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -861,7 +861,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) @@ -936,7 +936,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1016,7 +1016,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) @@ -1032,7 +1032,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) ### `user/*` @@ -1051,7 +1051,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index a60f0c783f..3dac641914 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/core.md -core.md: a59a692573c2e2756e60626268314a8ade6c546e -core.zh.md: acea4966a30ab03c24f1aea9bcd2d33a6271a28d +core.md: 170268351454643544d5ab20e3d4b6a8fd8c6c69 +core.zh.md: 048f7ed5f2136bb583c0308635e5cddde2b96785 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index a59a692573..1702683514 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -356,13 +356,14 @@ Concrete agent factory and driver service. /** * Create an agent and session under one caller-supplied identity, owned by * the accessing fiber. Constructor-driven config calls mint a fresh combined - * id before entering this boundary. + * id before entering this boundary. When a persistence backend is mounted, + * the session's durable identity and any seed are stored before publication. * @param id - shared agent/session identity. * @param options - concrete loop options. * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ -create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent +async create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Promise /** * Create an owned agent on a caller-supplied session id. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index acea4966a3..048f7ed5f2 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -366,13 +366,14 @@ Concrete agent factory and driver service. /** * Create an agent and session under one caller-supplied identity, owned by * the accessing fiber. Constructor-driven config calls mint a fresh combined - * id before entering this boundary. + * id before entering this boundary. When a persistence backend is mounted, + * the session's durable identity and any seed are stored before publication. * @param id - shared agent/session identity. * @param options - concrete loop options. * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ -create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent +async create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Promise /** * Create an owned agent on a caller-supplied session id. diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index 9e6b0d6424..c78fd14493 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/feedback.md -feedback.md: 046765b65773834b1804aa2a915625a3d6c9f099 -feedback.zh.md: 55b8e7d1b5b2c8ca5613d9888ef7f415b34ed5dc +feedback.md: 0bb01315d74cc41c1ab1008144dd8fca84efd76a +feedback.zh.md: 46f382edd270e5bb557f304a2f770b5fe7791dc5 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index 046765b657..0bb01315d7 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -191,13 +191,13 @@ One Session sidecar row contains its header identity `{createdAt, cwd}` and feed ## Target and lifecycle authority -`SessionPersistence.inspect()` supplies the target Session observation without publishing or resuming an Agent and without committing cold repair. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session propagates as infrastructure failure. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin, usage-only empty, and non-assistant records are not feedback targets. +A live owner's in-memory log supplies the target Session observation directly; a cold target is read through a `SessionPersistence.open(id, 'read')` handle without publishing or resuming an Agent and without writing recovery. A `stat(id)` preflight classifies definite absence; a read failure for a Session `stat` confirmed propagates as infrastructure failure. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin, usage-only empty, and non-assistant records are not feedback targets. The stored `{createdAt, cwd}` identity must match the inspected header. A mismatch is treated as absence: `list` returns no items, while `put` may replace the stale row with one bound to the current header identity. Forks use a new Session identity and receive no sidecar copy even when their seed contains the same messages. ## Persistence and Remote contract -The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `TypertRemoteService` and `@Remote`; the generated Cordis API below is the method-level authority. +The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through a fresh read handle, which observes at least the flushed prefix by the seam's freshness guarantee. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `TypertRemoteService` and `@Remote`; the generated Cordis API below is the method-level authority. Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain. diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index 55b8e7d1b5..46f382edd2 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -191,13 +191,13 @@ type MessageFeedbackDeleteResult = ## 目标与生命周期权威 -`SessionPersistence.inspect()` 提供目标 Session 的观测,且不会发布或恢复 Agent,也不会提交 cold repair。cold 路径先由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,会按基础设施故障原样传播。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin、仅承载 usage 的空记录和非 assistant 记录都不是反馈目标。 +live 持有者的内存日志直接提供目标 Session 的观测;cold 目标则通过 `SessionPersistence.open(id, 'read')` 句柄读取,既不会发布或恢复 Agent,也不会写入恢复内容。先由 `stat(id)` 预检明确不存在;`stat` 已确认存在的 Session 若读取失败,会按基础设施故障原样传播。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin、仅承载 usage 的空记录和非 assistant 记录都不是反馈目标。 存储的 `{createdAt, cwd}` 身份必须与检查所得 header 匹配。不匹配按不存在处理:`list` 返回空条目,`put` 则可用绑定当前 header 身份的新记录替换陈旧行。fork 使用新的 Session 身份,即使种子包含相同消息,也不获得伴随记录副本。 ## 持久化与 Remote 约定 -服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `TypertRemoteService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 约定;下方生成的 Cordis API 是方法级权威。 +服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过一个新开的读句柄从序列零做物理复读,依据该 seam 的新鲜度保证,它至少能观察到已 flush 的前缀。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `TypertRemoteService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 约定;下方生成的 Cordis API 是方法级权威。 Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index ec456511f9..9b08566a15 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/persistence.md -persistence.md: b61ddee8d12854b330b967a09a1a685d763f0041 -persistence.zh.md: 08cc35d987360138ec096b70f8c6b11b5cba4c55 +persistence.md: 4f6069b8a926f8fe24d2271650d45dc8377fa355 +persistence.zh.md: 6f8430868a001d0da4755579d2e667097d254ac6 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index b61ddee8d1..4f6069b8a9 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -4,29 +4,112 @@ English | [中文](persistence.zh.md) The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its provider model and shipped JSONL backend, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session/session-persistence), `ctx.sessionPersistence`) defines locate/create/append, reusable Session preparation, logical load/inspect, physical suffix reads, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type**. The repository ships [dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl) as its provider; out-of-tree providers may implement the same service contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session/session-persistence), `ctx.sessionPersistence`) exposing `create`/`open`/`stat`/`list` over the existing `SessionEvent` — **no parallel persisted event type** — where `create` and `open` return a per-session `SessionHandle` (`read`/`append`/`flush`/`close`) that carries all log access and single-writer ownership. The repository ships [dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl) as its provider; out-of-tree providers may implement the same service contract. See the [handle-based persistence Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) and the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). -## The flush checkpoint +## `SessionHandle` — one open channel onto a stored session -`session/event` is a *synchronous* notification; persistence plugins copy the event into a per-session controller without blocking the producer. The first pending event starts a fixed batching window, and later events join without resetting its deadline. Expiry starts one durable batch; events admitted during that write receive their own deadline and form a follow-up batch. `session/flush` cancels the wait and drains through quiescence, so the loop still uses it as the ordering and error-observation checkpoint before claiming the next ordinary turn. A rejected background write retains its events and pauses automatic retry; a new event starts a fresh window, while explicit flush retries immediately and reports failure through `agent/error` and the logger, never as a session event past the closed turn. Disposal performs the same final drain. The configured maximum bounds only intentional batching wait, not event-loop scheduling or backend durability latency ([decision](../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)). - -## Crash recovery preserves an interrupted turn - -A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the interrupted execution balanced without changing any standalone events before or after it. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). - -Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` waits until the authoritative in-memory snapshot is durable and returns it only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. HMR adopts a live prefix without closing its active turn. - -`SessionPersistence.inspect(id)` constructs an immutable logical Session without publishing it or writing recovery. Cold inspection balances an interrupted turn in memory while leaving torn physical tails untouched; inspection of an already-live Session borrows its current immutable snapshot and may therefore contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU, so repeated history reads and a later `prepare(id)` share one read, decompression, validation, freeze, and Session construction. `prepare(id)` reserves the Session, commits pending repair, and returns a disposable publication handle; `load(id)` uses the same machinery to commit repair without publication. The [Session preparation decision](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md) owns this lifecycle. - -## `SessionLocation` — optional per-session artifact target - -`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns the absolute transcript path inside its project/session directory; a backend without one independent artifact per session returns `undefined`. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. +Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door a future cross-process write lease will guard. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active. ```ts type-equiv /** - * 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. + * One open channel onto a stored session. A handle is single-owner state, not + * a shared service: `read` never backtracks below what this handle already + * observed, a `write` handle reads its own successful appends, and `close()` + * is the one teardown (idempotent, uncancellable; `Symbol.asyncDispose` + * delegates to it). Every operation on a closed handle rejects with + * `SessionHandleClosedError`. + * + * Freshness across handles: once an `append` or `flush` resolves on a write + * handle, every read STARTED afterwards on the same backend instance — on any + * handle, or through `stat`/`list` — observes at least that prefix. + * Reads concurrent with a mutation carry no ordering promise beyond the valid + * contiguous prefix. + */ +interface SessionHandle extends AsyncDisposable { + /** The stored session this handle addresses. */ + readonly id: SessionId + /** The immutable stored header, fixed at `create`/`open`. */ + readonly header: SessionHeader + /** + * Exact fork-inherited prefix length stored with the log; `0` when + * `header.isSeeded` is false. Storage metadata paired with the header for + * every body read, never part of the replayable event log. + */ + readonly inheritedEventCount: SessionLogOffset + /** Whether this handle may mutate the log. */ + readonly access: SessionAccess + + /** + * Read a slice of the valid contiguous logical log. The slice is a legal log + * prefix segment: a torn physical tail is never returned, and repeated reads + * on this handle never observe an older state than a prior read. + * @param offset - first logical event seq to include; defaults to `0`. + * @param length - maximum number of events to return; defaults to the rest + * of the log. An offset at or past the end returns an empty list. + * @param options - optional cancellation. + * @returns the events with `seq >= offset`, at most `length` of them. + */ + read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise + + /** + * Append a contiguous batch continuing the current logical end. The first + * event's `seq` MUST equal the stored next-seq; committed events are never + * rewritten. Persistence is best-effort: on resolution the batch is + * accepted, ordered, and visible to reads on this backend instance, but + * only a resolved {@link flush} promises it survives a crash — a backend + * may buffer or batch physical writes behind append. Rejects with + * `SessionReadOnlyError` on a read handle and `SessionOwnershipLostError` + * when write ownership is gone. + * @param events - the contiguous batch, in seq order. + * @param options - optional cancellation observed before the write starts. + */ + append(events: readonly SessionEvent[], options?: SessionHandleAppendOptions): Promise + + /** + * The durability barrier — the one operation that promises storage: on + * resolution every acknowledged append is durable and the session is + * materialized for other processes; an empty created session becomes + * durably listable here. Callers that must survive a crash flush; a backend + * whose `append` already persists on resolution treats this as + * materialize-if-needed. Rejects with `SessionReadOnlyError` on a read + * handle. + * @param options - optional cancellation observed before the barrier starts. + */ + flush(options?: SessionHandleFlushOptions): Promise + + /** + * Release the handle: a read handle frees local resources; a write handle + * completes pending durability and releases write ownership. Idempotent, + * asynchronous, and deliberately not cancellable. + */ + close(): Promise +} +``` + +A created session is observable in this process from the moment `create` resolves, while a backend may defer physical materialization (a pure optimization) until the first `append` or `flush`; other processes see only materialized sessions, and a session that never materialized before a crash never existed. + +## The flush checkpoint + +`session/event` is a *synchronous* notification; the mounted backend routes it by session id into the active write handle's bounded write-behind window without blocking the producer (the backend installs these listeners once, because persistence already enforces one active write handle per id). The first pending event starts a fixed internal batching window, and later events join without resetting its deadline. Expiry starts one durable `append` through the session's write handle; events admitted during that write receive their own deadline and form a follow-up batch. `session/flush` cancels the wait and drains through quiescence, so the loop still uses it as the ordering and error-observation checkpoint before claiming the next ordinary turn. A rejected background write retains its events in order, pauses the automatic path, and is reported through the logger; the next explicit flush retries and rejects loudly to its caller. `session/disposed` performs the same final drain and closes the handle, and `close()` itself drains the routed buffer through the still-open storage, so backend teardown's close sweep loses nothing. The window bounds only intentional batching wait, not event-loop scheduling or backend durability latency ([decision](../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)). + +## Crash recovery preserves an interrupted turn + +A log crashed mid-turn ends with an open `turn/start` and no `turn/end`. Persistence does **not** truncate or repair it — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. It returns the physically valid contiguous log; only the incomplete fragment of a torn physical tail, belonging to an append that never resolved, is discarded — complete records recovered from it (the JSONL backend partially decodes a torn Zstandard frame) are durably rewritten by the write path before the handle's first new append. Repair is the reader's job: resume (agent-loop) reads the stored log through its write handle, computes `interruptedTurnClosers` — missing tool errors, any open `step/end`, and a synthetic `turn/end { reason: { kind: 'interrupted' } }` — and appends them through the same handle as an ordinary batch before publishing the Session. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). + +Repair therefore writes only under write ownership: a live session's write handle is held by its lifecycle owner, so a concurrent `open(id, 'write')` rejects with `SessionAlreadyOwnedError` instead of racing repair against a live turn. Read-only observers (session-query) balance an interrupted cold log with the same closers in memory only, writing nothing back. + +Read-only observation is `open(id, 'read')`: the handle serves validated contiguous prefix slices, never a torn tail, and repeated reads on one handle never observe an older state than a prior read. There is no persistence-side prepared-Session cache: session-query owns its cold-read cache, keying one balanced cold Session per id on the `stat().revision` change token and re-reading only when the token changes. The [handle-based persistence Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) owns this lifecycle; the [Session preparation decision](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md) records the publication-boundary `SessionPreparation` that remains. + +## `SessionLocation` — refusal-diagnostics artifact target + +`SessionLocation` is not a consumer-facing query: log access goes through a session handle's `read`. It survives only as refusal diagnostics, letting a `SessionFormatUnsupportedError` name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. + +```ts type-equiv +/** + * A backend-resolved, per-session local artifact location. Carried only by + * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can + * find the raw log a build refused to interpret; it is not a consumer-facing + * query — log access goes through a session handle's `read`. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -91,7 +174,7 @@ interface SessionHeader { ## Format refusal — logs a build cannot faithfully read -A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating this format version's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt". An out-of-tree backend must enforce the equivalent direction-aware refusal at its own physical-format boundary. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. An event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating this format version's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt". An out-of-tree backend must enforce the equivalent direction-aware refusal at its own physical-format boundary. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). ## `CreateSessionOptions` — seeding and metadata @@ -127,39 +210,11 @@ interface CreateSessionOptions { } ``` -Plain replay is `ctx.sessions.create(id, { seed: seedEvents })`; a fork additionally supplies `inheritedEventCount` and `meta.isSeeded: true`. Resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. - -## `SessionStorageMetadata` — logical header and inherited cut - -Every persistence result that reads a Session body carries `SessionStorageMetadata`: the current logical header plus the separately validated inherited-event cut. Header-only listing intentionally returns only `SessionHeader`. - -```ts type-equiv -/** Logical Session header paired with its exact inherited cut for body-bearing storage operations. */ -interface SessionStorageMetadata { - /** Validated immutable Session header. */ - readonly meta: SessionHeader - /** Number of leading events inherited from the Session's fork parent. */ - readonly inheritedEventCount: SessionLogOffset -} -``` - -## `SessionRawArtifact` — verbatim stored artifact text - -A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives. Consumers first test `supportsRawArtifacts`: `false` means the backend does not provide this capability, while `readRaw(...) === undefined` means a supported backend has no materialized artifact for that session. - -```ts type-equiv -/** A backend's own raw artifact text for one session, verbatim. */ -interface SessionRawArtifact extends SessionStorageMetadata { - /** The artifact's base filename on disk, without any physical encoding suffix. */ - readonly filename: string - /** The artifact's full text content, decoded from the backend's physical encoding. */ - readonly content: string -} -``` +Replay/fork is therefore `ctx.agents.create({ sessionId, seed, meta })` — a fork additionally supplies `inheritedEventCount` with `meta.isSeeded: true`, and only agent-loop-published sessions persist, and the loop stores the seed through the new session's write handle before publication; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. ## Preparation and restoration ownership -`SessionStore.prepare()` accepts ordinary creation options or fresh persistence graphs transferred through `RestoredSessionOptions`. The restoration branch validates and freezes the transferred header and events in place, so callers must retain no mutable aliases. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. Persistence inspection exposes only `SessionInspection`, an immutable logical view borrowed from the same prepared Session. +`SessionStore.prepare()` accepts ordinary creation options or fresh persistence graphs transferred through `RestoredSessionOptions`. The restoration branch validates and freezes the transferred header and events in place, so callers must retain no mutable aliases. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. agent-loop's resume builds these graphs by reading the stored log through the session's write handle and appending any needed `interruptedTurnClosers` before preparation. ```ts type-equiv /** @@ -215,31 +270,9 @@ declare class SessionPreparation implements Disposable { } ``` -```ts type-equiv -/** Immutable logical session prepared from persistence or a live owner. */ -interface SessionInspection extends SessionStorageMetadata { - /** Validated contiguous logical event log. */ - readonly events: readonly SessionEvent[] -} -``` - -## Detached stored-log suffixes - -`readFrom` returns a detached `SessionEventSuffix` anchored by the requested `fromSeq`. Its event list may start above zero or be empty, so it is not a complete `SessionInspection` and must not be restored as a whole Session. - -```ts type-equiv -/** Detached logical suffix returned by one explicit stored-log offset read. */ -interface SessionEventSuffix extends SessionStorageMetadata { - /** First requested log offset; {@link events} contains only seqs at or after it. */ - readonly fromSeq: SessionLogOffset - /** Valid contiguous stored events at or after {@link fromSeq}; not a complete Session log when the offset is nonzero. */ - readonly events: readonly SessionEvent[] -} -``` - ## Lightweight source revisions -Consumers of derived state compare a cheap opaque revision before loading a full event log. The persistence backend owns its representation and changes it transactionally with append or mutating load repair; callers compare it only for equality. +Consumers of derived read models compare a cheap opaque revision before loading a full event log. The revision is a per-backend-instance change token from `stat`/`list`: equal revisions may be treated as an unchanged log; unequal revisions promise nothing, and write-ownership churn never changes one. session-query keys its cold-read cache on it; the token plays no part in open, read, or resume. ```ts type-equiv /** @@ -250,20 +283,29 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> ``` ```ts type-equiv -/** Lightweight immutable source identity returned without loading a full log. */ +/** + * Lightweight stored-session observation returned by {@link SessionPersistence.stat} + * and {@link SessionPersistence.list} without reading the full event log. + */ interface SessionPersistenceSnapshot { - /** Detached metadata for one materialized session. */ - header: SessionHeader - /** Opaque source-qualified token that changes whenever this stored log changes. */ - revision: SessionPersistenceRevision + /** Detached metadata for one stored session. */ + readonly header: SessionHeader + /** Opaque change token; see {@link SessionPersistence.stat}. */ + readonly revision: SessionPersistenceRevision + /** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */ + readonly eventCount?: number + /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ + readonly sizeBytes?: number } ``` +The optional `eventCount`/`sizeBytes` hints let the session list's cold blank probe bound its work from metadata alone (session-controller config `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`) without opening any log. + ## The backend -The shipped provider implements the abstract `SessionPersistence` contract (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and passes the shared `runPersistenceContract` suite: +The shipped provider implements the abstract `SessionPersistence` contract (`create`/`open`/`stat`/`list`, with per-session `SessionHandle`s carrying `read`/`append`/`flush`/`close` and optional cancellation throughout) and passes the shared persistence contract suite: -- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. +- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic materialization, per-batch `fsync` appends, and torn-tail truncation before the first new append. `stat`/`list` carry `sizeBytes` and a best-effort `fs.stat`-derived revision. @@ -277,162 +319,77 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) -Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. +Durable append-only session storage addressed through per-session handles. + +Storage semantics shared by every backend: events are contiguous from seq 0 and never rewritten; a torn physical tail is never returned to a reader and is truncated by the write path before its first append; reads validate current-format records only and refuse unknown vocabulary fail-closed. `append` persists best-effort; `flush` — per handle or service-wide — is the durability barrier. + +Visibility: a created session is observable through `stat`/`list`/`open` in this process from the moment `create` resolves, even while a backend defers physical materialization (a pure optimization); other processes see the session only once it materializes, and a session that never materialized before a crash never existed. `SessionHandle.flush` forces materialization. + +Freshness: once an `append` or `flush` resolves, reads started afterwards on this backend instance observe at least that prefix. ```ts cordis-catalog /** - * Resolve this backend's independent local artifact for a session without - * reading, creating, flushing, or otherwise materializing it. A backend - * that does not own one artifact per Session returns `undefined`. - * @param meta - the immutable session header whose artifact is requested. - * @returns the backend-specific absolute location, when one exists. + * Create a new stored session and take its write ownership. + * @param header - the immutable header (id, version, cwd, lineage) to store. + * @param options - optional cancellation. + * @returns a `write` handle owned by the caller; close it to release ownership. + * @throws {SessionAlreadyExistsError} when the id already exists. */ -abstract locate(meta: SessionHeader): SessionLocation | undefined +abstract create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise /** - * Read a session's backend-owned artifact text verbatim — the exact durable - * bytes the backend wrote (decoded from its physical encoding, e.g. a - * decompressed JSONL). The returned `content` is the raw text, not a - * reconstruction from parsed events, so it preserves backend-specific - * serialization (chunk packing, key order, line breaks). Callers first test - * {@link supportsRawArtifacts}; `undefined` then means only that the requested - * session has no materialized artifact. - * @param _id - the persisted session to read (unused by the default: no - * per-session artifact). - * @param signal - optional cancellation for backend read work. - * @returns the raw artifact plus its parsed header, or `undefined` when the - * session is absent. - * @throws when this backend does not expose per-session raw artifacts. - */ -readRaw(_id: SessionId, signal?: AbortSignal): Promise - -/** - * 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. - * @param inheritedEventCount - exact fork-inherited prefix length. Required - * for a seeded header and omitted only for an unseeded header. - */ -abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise - -/** - * Ensure a live session has a durable header even when it has no events. - * Ordinary sessions remain lazily materialized; lifecycle frontends call - * this only when an empty session itself is a durable resumable resource. - * @param _session - exact live session whose registered header is materialized. - */ -ensureMaterialized(_session: Session): Promise - -/** - * 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. - * A seeded session's first materializing batch must reach its complete - * inherited prefix. - * @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 - -/** - * 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 - -/** - * 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 - -/** - * 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 - -/** - * Borrow one exact inspection while retaining any reusable prepared source. - * A cold observation must pin the exact prepared Session that a later - * {@link prepare} reserves. Implementations must not degrade this operation - * to a detached {@link inspect} result. - * @param id - persisted session to observe. - * @param signal - optional cancellation for preparation work. - * @returns a disposable immutable observation. - */ -abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise - -/** - * 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). - * A backend whose medium can seek by seq may read only the suffix; - * sequential media such as JSONL 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 offset to include. - * @param signal - optional cancellation for queued and backend read work. - * @returns storage metadata, the requested offset, and stored events with `seq >= fromSeq`. - */ -abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise - -/** - * 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 - -/** - * List materialized sessions with cheap per-log change tokens. + * Open an existing stored session. * - * 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. + * `read` never takes ownership and works while another handle (or process) + * holds write ownership. `write` atomically claims single-writer ownership; + * an existing active owner rejects. + * @param id - the stored session to open. + * @param access - `read` or `write`. + * @param options - optional cancellation. + * @returns the open handle. + * @throws {SessionPersistenceNotFoundError} when the session does not exist. + * @throws {SessionAlreadyOwnedError} for `write` when ownership is taken. */ -abstract listSnapshots(signal?: AbortSignal): Promise +abstract open(id: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions): Promise + +/** + * Flush every active write handle owned by this service instance in one + * durability barrier: each handle's routed live events drain durably and + * its session materializes, exactly as that handle's own + * `SessionHandle.flush` would. Read handles buffer nothing and are + * untouched. A handle closed concurrently counts as flushed — close itself + * drains durably. + * @returns resolution once every write handle active at the call has flushed. + * @throws {AggregateError} naming each session whose flush failed; the + * remaining handles still flush. + */ +abstract flush(): Promise + +/** + * Observe one stored session without reading its event log or taking + * ownership. + * + * The snapshot's `revision` is an opaque change token comparable only + * against revisions from the same service instance and session id: equal + * revisions may be treated as an unchanged log; unequal revisions promise + * nothing. Write-ownership churn does not change a revision. It exists for + * derived read-model caches keyed off `stat`/`list`; it plays no part in + * open, read, or resume. + * @param id - the stored session to observe. + * @param options - optional cancellation. + * @returns the snapshot, or `undefined` when the session does not exist. + */ +abstract stat(id: SessionId, options?: SessionPersistenceStatOptions): Promise + +/** + * List every stored session visible to this process, in no promised order. + * @param options - optional cancellation. + * @returns one snapshot per stored session. + */ +abstract list(options?: SessionPersistenceListOptions): Promise ``` -Types: [Session](session.md) · [SessionEvent](session.md) · [SessionId](core.md) · [SessionLogOffset](session.md) +Types: [SessionId](core.md) Source: [`packages/session/session-persistence/src/index.ts`](../../packages/session/session-persistence/src/index.ts) diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 08cc35d987..6f8430868a 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -4,29 +4,112 @@ 事件日志的**持久性 seam**。[session.md](session.zh.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述如何使该日志持久化:抽象的 `SessionPersistence` 服务、它的提供方模型与随产品交付的 JSONL 后端、flush 检查点、崩溃恢复,以及随日志一同存储的元数据头。日志承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.zh.md)中逐项列举。 -该 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md):一个抽象服务([dsh-session-persistence](../../packages/session/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、可复用的 Session 准备流程、逻辑 load/inspect、物理后缀读取,以及轻量的 list/snapshot 观察——**没有平行的持久化事件类型**。仓库随产品交付 [dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl) 作为提供方;仓库外提供方可以实现同一服务约定。见 [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md)。 +该 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md):一个抽象服务([dsh-session-persistence](../../packages/session/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上暴露 `create`/`open`/`stat`/`list`——**没有平行的持久化事件类型**——其中 `create` 与 `open` 返回逐会话的 `SessionHandle`(`read`/`append`/`flush`/`close`),它承载全部日志访问与单写者所有权。仓库随产品交付 [dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl) 作为其 provider;仓库外 provider 可以实现同一服务约定。见[基于句柄的持久化 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)与 [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md)。 -## flush 检查点 +## `SessionHandle`——通向已存储会话的一条打开通道 -`session/event` 是一个*同步*通知;持久化插件会将事件复制到逐会话控制器,而不阻塞生产方。第一个待处理事件会开启固定批处理窗口,后续事件会加入但不会重置截止时间。窗口到期后会启动一个持久化批次;该次写入期间接纳的事件会获得自己的截止时间,并形成后续批次。`session/flush` 会取消等待并排空至完全停稳,因此循环仍将其用作在领取下一个普通轮次之前的顺序与错误观察检查点。后台写入被拒绝时会保留对应事件并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 会立即重试,并通过 `agent/error` 和 logger 报告失败,绝不会把失败记录成已关闭轮次之后的会话事件。dispose(资源释放)会执行同样的最终排空。配置的最大值只限制有意的批处理等待,不限制事件循环调度或后端完成持久化的延迟([决策](../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md))。 - -## 崩溃恢复保留被中断的轮次 - -后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,在不改变其前后任何独立事件的情况下配平被中断的执行。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.zh.md#why-a-turn-ended-turnendreasonmap))。 - -修复仅适用于冷会话。对于活跃 id,`SessionPersistence.load(id)` 会等待权威内存快照完成持久化,并且只在日志平衡时返回;若活跃轮次仍未闭合,则拒绝操作,而不是添加合成的中断边界。HMR(热模块替换)会接管活跃前缀,而不会关闭其中正在进行的轮次。 - -`SessionPersistence.inspect(id)` 会构造一个不可变的逻辑 Session,但不发布它,也不写入恢复内容。冷检查会在内存中配平中断的轮次,同时保持撕裂的物理尾部不变;检查已处于活跃状态的 Session 则借用其当前不可变快照,因此可能包含未闭合的轮次。使用协调器的实现会在有界 LRU 中保留这个精确的冷未发布 Session,因此重复历史读取与后续 `prepare(id)` 可复用同一次读取、解压、验证、冻结及 Session 构造。`prepare(id)` 会预留该 Session、提交待处理修复并返回可 dispose 的发布句柄;`load(id)` 使用相同机制提交修复,但不会发布 Session。该生命周期由 [Session 准备阶段决策](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md)定义。 - -## `SessionLocation`——可选的逐会话产物目标 - -`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立产物,而不会读取、创建或 flush 它。JSONL 返回其项目/会话目录内 transcript(文本记录)的绝对路径;不为每个会话各自拥有独立产物的后端返回 `undefined`。因此,返回的路径可能指向尚不存在的文件,或指向还不包含当前尚未 flush 轮次的文件;它是位置提示,不是授权或新鲜度保证。 +每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是未来跨进程写租约将要把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。 ```ts type-equiv /** - * 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. + * One open channel onto a stored session. A handle is single-owner state, not + * a shared service: `read` never backtracks below what this handle already + * observed, a `write` handle reads its own successful appends, and `close()` + * is the one teardown (idempotent, uncancellable; `Symbol.asyncDispose` + * delegates to it). Every operation on a closed handle rejects with + * `SessionHandleClosedError`. + * + * Freshness across handles: once an `append` or `flush` resolves on a write + * handle, every read STARTED afterwards on the same backend instance — on any + * handle, or through `stat`/`list` — observes at least that prefix. + * Reads concurrent with a mutation carry no ordering promise beyond the valid + * contiguous prefix. + */ +interface SessionHandle extends AsyncDisposable { + /** The stored session this handle addresses. */ + readonly id: SessionId + /** The immutable stored header, fixed at `create`/`open`. */ + readonly header: SessionHeader + /** + * Exact fork-inherited prefix length stored with the log; `0` when + * `header.isSeeded` is false. Storage metadata paired with the header for + * every body read, never part of the replayable event log. + */ + readonly inheritedEventCount: SessionLogOffset + /** Whether this handle may mutate the log. */ + readonly access: SessionAccess + + /** + * Read a slice of the valid contiguous logical log. The slice is a legal log + * prefix segment: a torn physical tail is never returned, and repeated reads + * on this handle never observe an older state than a prior read. + * @param offset - first logical event seq to include; defaults to `0`. + * @param length - maximum number of events to return; defaults to the rest + * of the log. An offset at or past the end returns an empty list. + * @param options - optional cancellation. + * @returns the events with `seq >= offset`, at most `length` of them. + */ + read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise + + /** + * Append a contiguous batch continuing the current logical end. The first + * event's `seq` MUST equal the stored next-seq; committed events are never + * rewritten. Persistence is best-effort: on resolution the batch is + * accepted, ordered, and visible to reads on this backend instance, but + * only a resolved {@link flush} promises it survives a crash — a backend + * may buffer or batch physical writes behind append. Rejects with + * `SessionReadOnlyError` on a read handle and `SessionOwnershipLostError` + * when write ownership is gone. + * @param events - the contiguous batch, in seq order. + * @param options - optional cancellation observed before the write starts. + */ + append(events: readonly SessionEvent[], options?: SessionHandleAppendOptions): Promise + + /** + * The durability barrier — the one operation that promises storage: on + * resolution every acknowledged append is durable and the session is + * materialized for other processes; an empty created session becomes + * durably listable here. Callers that must survive a crash flush; a backend + * whose `append` already persists on resolution treats this as + * materialize-if-needed. Rejects with `SessionReadOnlyError` on a read + * handle. + * @param options - optional cancellation observed before the barrier starts. + */ + flush(options?: SessionHandleFlushOptions): Promise + + /** + * Release the handle: a read handle frees local resources; a write handle + * completes pending durability and releases write ownership. Idempotent, + * asynchronous, and deliberately not cancellable. + */ + close(): Promise +} +``` + +已创建的会话自 `create` 完成之刻起即可在本进程内被观察到,而后端可以把物理实体化(纯粹的优化)推迟到第一次 `append` 或 `flush`;其他进程只能看到已实体化的会话,一个在崩溃前从未实体化的会话等于从未存在。 + +## flush 检查点 + +`session/event` 是一个*同步*通知;挂载的后端按会话 id 把它路由进活跃写句柄的有界 write-behind 窗口,而不阻塞生产方(后端一次性安装这些监听器,因为持久化已保证每个 id 只有一个活跃写句柄)。第一个待处理事件会开启固定的内部批处理窗口,后续事件会加入但不会重置其截止时间。窗口到期后会通过该会话的写句柄启动一次持久化 `append`;该次写入期间接纳的事件会获得自己的截止时间,并形成后续批次。`session/flush` 会取消等待并排空至完全停稳,因此循环仍将其用作在领取下一个普通轮次之前的顺序与错误观察检查点。后台写入被拒绝时会按序保留对应事件、暂停自动路径,并通过 logger 报告;下一次显式 flush 会重试,并向其调用方响亮地拒绝。`session/disposed` 会执行同样的最终排空并关闭句柄,而 `close()` 本身会经由仍然打开的存储排空已路由的缓冲,因此后端 teardown 的关闭清扫不丢任何数据。该窗口只限制有意的批处理等待,不限制事件循环调度或后端完成持久化的延迟([决策](../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md))。 + +## 崩溃恢复保留被中断的轮次 + +一个在轮次中途崩溃的日志以打开的 `turn/start` 而无 `turn/end` 结束。持久化**不会**截断或修复它:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。它返回物理上有效的连续日志;只有撕裂物理尾部——属于一次从未完成的 append——中不完整的碎片会被丢弃:从中恢复的完整记录(JSONL 后端会部分解码撕裂的 Zstandard 帧)由写路径在句柄的第一次新 append 之前持久重写。修复是读方的职责:resume(agent-loop)通过其写句柄读取已存储的日志,计算 `interruptedTurnClosers`——缺失的工具错误、任何未闭合的 `step/end`,以及一个合成的 `turn/end { reason: { kind: 'interrupted' } }`——并在发布 Session 之前把它们作为普通批次通过同一句柄追加。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.zh.md#why-a-turn-ended-turnendreasonmap))。 + +因此修复只在写所有权之下写入:活跃会话的写句柄由其生命周期所有者持有,故并发的 `open(id, 'write')` 会以 `SessionAlreadyOwnedError` 拒绝,而不是让修复与活跃轮次竞速。只读观察方(session-query)仅在内存中用同样的闭合事件配平被中断的冷日志,不回写任何内容。 + +只读观察即 `open(id, 'read')`:句柄提供经过验证的连续前缀切片,绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态。持久化侧不存在已准备 Session 缓存:session-query 拥有自己的冷读缓存,按 `stat().revision` 变更令牌为每个 id 缓存一个已配平的冷 Session,仅在令牌变化时重新读取。该生命周期由[基于句柄的持久化 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)定义;[Session 准备阶段决策](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md)记录仍然保留的发布边界 `SessionPreparation`。 + +## `SessionLocation`——拒绝诊断的产物目标 + +`SessionLocation` 不是面向消费者的查询:日志访问走会话句柄的 `read`。它仅作为拒绝诊断存在,使 `SessionFormatUnsupportedError` 能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 + +```ts type-equiv +/** + * A backend-resolved, per-session local artifact location. Carried only by + * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can + * find the raw log a build refused to interpret; it is not a consumer-facing + * query — log access goes through a session handle's `read`. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -91,7 +174,7 @@ interface SessionHeader { ## 格式拒绝:本构建无法可靠读取的日志 -后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于本格式版本的 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏"。仓库外后端必须在自己的物理格式入口执行等价的方向感知拒绝。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。 +后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于本格式版本的 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏"。仓库外后端必须在自己的物理格式入口执行等价的方向感知拒绝。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。 ## `CreateSessionOptions`:seed 与元数据 @@ -127,39 +210,11 @@ interface CreateSessionOptions { } ``` -因此,普通回放的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;fork 还会提供 `inheritedEventCount` 与 `meta.isSeeded: true`。将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 - -## `SessionStorageMetadata`:逻辑 header 与继承 cut - -每个读取 Session 正文的持久化结果都携带 `SessionStorageMetadata`:当前逻辑 header,以及单独校验的继承事件 cut。仅 header 的列表操作有意只返回 `SessionHeader`。 - -```ts type-equiv -/** Logical Session header paired with its exact inherited cut for body-bearing storage operations. */ -interface SessionStorageMetadata { - /** Validated immutable Session header. */ - readonly meta: SessionHeader - /** Number of leading events inherited from the Session's fork parent. */ - readonly inheritedEventCount: SessionLogOffset -} -``` - -## `SessionRawArtifact`——逐字存储工件文本 - -后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留。Consumer 须先检查 `supportsRawArtifacts`:`false` 表示后端不提供此能力,而 `readRaw(...) === undefined` 表示受支持的后端没有该会话的已实体化工件。 - -```ts type-equiv -/** A backend's own raw artifact text for one session, verbatim. */ -interface SessionRawArtifact extends SessionStorageMetadata { - /** The artifact's base filename on disk, without any physical encoding suffix. */ - readonly filename: string - /** The artifact's full text content, decoded from the backend's physical encoding. */ - readonly content: string -} -``` +因此,回放/fork 的调用方式为 `ctx.agents.create({ sessionId, seed, meta })`——fork 还会随 `meta.isSeeded: true` 提供 `inheritedEventCount`,且只有经 agent-loop 发布的会话才会持久化,且循环会在发布之前通过新会话的写句柄存储 seed;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 ## 准备与恢复所有权 -`SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的全新的持久化对象图。恢复分支会就地验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。持久化检查只暴露 `SessionInspection`,即从同一个已准备 Session 借用的不可变逻辑视图。 +`SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的全新的持久化对象图。恢复分支会就地验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。agent-loop 的 resume 通过该会话的写句柄读取已存储的日志,并在准备之前追加所需的 `interruptedTurnClosers`,以此构建这些对象图。 ```ts type-equiv /** @@ -215,31 +270,9 @@ declare class SessionPreparation implements Disposable { } ``` -```ts type-equiv -/** Immutable logical session prepared from persistence or a live owner. */ -interface SessionInspection extends SessionStorageMetadata { - /** Validated contiguous logical event log. */ - readonly events: readonly SessionEvent[] -} -``` - -## 分离的持久日志后缀 - -`readFrom` 返回以请求的 `fromSeq` 为锚点、与其他状态分离的 `SessionEventSuffix`。其事件列表可能从非零位置开始,也可能为空,因此它不是完整的 `SessionInspection`,不得作为完整 Session 恢复。 - -```ts type-equiv -/** Detached logical suffix returned by one explicit stored-log offset read. */ -interface SessionEventSuffix extends SessionStorageMetadata { - /** First requested log offset; {@link events} contains only seqs at or after it. */ - readonly fromSeq: SessionLogOffset - /** Valid contiguous stored events at or after {@link fromSeq}; not a complete Session log when the offset is nonzero. */ - readonly events: readonly SessionEvent[] -} -``` - ## 轻量源修订号 -派生状态的消费方会在加载完整事件日志之前比较一个低开销的不透明修订号。其表示由持久化后端拥有,并随 append 或会修改数据的 load 修复以事务方式改变;调用方仅比较修订号是否相等。 +派生读取模型的消费方会在加载完整事件日志之前比较一个低开销的不透明修订号。该修订号是来自 `stat`/`list` 的逐后端实例变更令牌:修订号相等可视为日志未变;不相等则不作任何承诺,且写所有权的变动绝不会改变修订号。session-query 以它为键管理冷读缓存;该令牌在 open、read 或 resume 中不起任何作用。 ```ts type-equiv /** @@ -250,20 +283,29 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> ``` ```ts type-equiv -/** Lightweight immutable source identity returned without loading a full log. */ +/** + * Lightweight stored-session observation returned by {@link SessionPersistence.stat} + * and {@link SessionPersistence.list} without reading the full event log. + */ interface SessionPersistenceSnapshot { - /** Detached metadata for one materialized session. */ - header: SessionHeader - /** Opaque source-qualified token that changes whenever this stored log changes. */ - revision: SessionPersistenceRevision + /** Detached metadata for one stored session. */ + readonly header: SessionHeader + /** Opaque change token; see {@link SessionPersistence.stat}. */ + readonly revision: SessionPersistenceRevision + /** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */ + readonly eventCount?: number + /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ + readonly sizeBytes?: number } ``` +可选的 `eventCount`/`sizeBytes` 提示让会话列表的冷空白探测(cold blank probe)仅凭元数据即可限定其工作量(session-controller 配置 `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`),而无需打开任何日志。 + ## 后端 -随产品交付的 provider 实现抽象 `SessionPersistence` 约定(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件: +随产品交付的 provider 实现抽象 `SessionPersistence` 约定(`create`/`open`/`stat`/`list`,逐会话 `SessionHandle` 承载 `read`/`append`/`flush`/`close`,全程可选支持取消),并通过共享的持久化契约套件: -- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 +- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;具备崩溃安全的原子实体化、逐批 `fsync` 的 append,以及在第一次新 append 之前截断撕裂尾部。`stat`/`list` 携带 `sizeBytes` 与尽力而为的、由 `fs.stat` 派生的修订号。 @@ -277,162 +319,77 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) -Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. +Durable append-only session storage addressed through per-session handles. + +Storage semantics shared by every backend: events are contiguous from seq 0 and never rewritten; a torn physical tail is never returned to a reader and is truncated by the write path before its first append; reads validate current-format records only and refuse unknown vocabulary fail-closed. `append` persists best-effort; `flush` — per handle or service-wide — is the durability barrier. + +Visibility: a created session is observable through `stat`/`list`/`open` in this process from the moment `create` resolves, even while a backend defers physical materialization (a pure optimization); other processes see the session only once it materializes, and a session that never materialized before a crash never existed. `SessionHandle.flush` forces materialization. + +Freshness: once an `append` or `flush` resolves, reads started afterwards on this backend instance observe at least that prefix. ```ts cordis-catalog /** - * Resolve this backend's independent local artifact for a session without - * reading, creating, flushing, or otherwise materializing it. A backend - * that does not own one artifact per Session returns `undefined`. - * @param meta - the immutable session header whose artifact is requested. - * @returns the backend-specific absolute location, when one exists. + * Create a new stored session and take its write ownership. + * @param header - the immutable header (id, version, cwd, lineage) to store. + * @param options - optional cancellation. + * @returns a `write` handle owned by the caller; close it to release ownership. + * @throws {SessionAlreadyExistsError} when the id already exists. */ -abstract locate(meta: SessionHeader): SessionLocation | undefined +abstract create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise /** - * Read a session's backend-owned artifact text verbatim — the exact durable - * bytes the backend wrote (decoded from its physical encoding, e.g. a - * decompressed JSONL). The returned `content` is the raw text, not a - * reconstruction from parsed events, so it preserves backend-specific - * serialization (chunk packing, key order, line breaks). Callers first test - * {@link supportsRawArtifacts}; `undefined` then means only that the requested - * session has no materialized artifact. - * @param _id - the persisted session to read (unused by the default: no - * per-session artifact). - * @param signal - optional cancellation for backend read work. - * @returns the raw artifact plus its parsed header, or `undefined` when the - * session is absent. - * @throws when this backend does not expose per-session raw artifacts. - */ -readRaw(_id: SessionId, signal?: AbortSignal): Promise - -/** - * 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. - * @param inheritedEventCount - exact fork-inherited prefix length. Required - * for a seeded header and omitted only for an unseeded header. - */ -abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise - -/** - * Ensure a live session has a durable header even when it has no events. - * Ordinary sessions remain lazily materialized; lifecycle frontends call - * this only when an empty session itself is a durable resumable resource. - * @param _session - exact live session whose registered header is materialized. - */ -ensureMaterialized(_session: Session): Promise - -/** - * 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. - * A seeded session's first materializing batch must reach its complete - * inherited prefix. - * @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 - -/** - * 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 - -/** - * 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 - -/** - * 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 - -/** - * Borrow one exact inspection while retaining any reusable prepared source. - * A cold observation must pin the exact prepared Session that a later - * {@link prepare} reserves. Implementations must not degrade this operation - * to a detached {@link inspect} result. - * @param id - persisted session to observe. - * @param signal - optional cancellation for preparation work. - * @returns a disposable immutable observation. - */ -abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise - -/** - * 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). - * A backend whose medium can seek by seq may read only the suffix; - * sequential media such as JSONL 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 offset to include. - * @param signal - optional cancellation for queued and backend read work. - * @returns storage metadata, the requested offset, and stored events with `seq >= fromSeq`. - */ -abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise - -/** - * 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 - -/** - * List materialized sessions with cheap per-log change tokens. + * Open an existing stored session. * - * 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. + * `read` never takes ownership and works while another handle (or process) + * holds write ownership. `write` atomically claims single-writer ownership; + * an existing active owner rejects. + * @param id - the stored session to open. + * @param access - `read` or `write`. + * @param options - optional cancellation. + * @returns the open handle. + * @throws {SessionPersistenceNotFoundError} when the session does not exist. + * @throws {SessionAlreadyOwnedError} for `write` when ownership is taken. */ -abstract listSnapshots(signal?: AbortSignal): Promise +abstract open(id: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions): Promise + +/** + * Flush every active write handle owned by this service instance in one + * durability barrier: each handle's routed live events drain durably and + * its session materializes, exactly as that handle's own + * `SessionHandle.flush` would. Read handles buffer nothing and are + * untouched. A handle closed concurrently counts as flushed — close itself + * drains durably. + * @returns resolution once every write handle active at the call has flushed. + * @throws {AggregateError} naming each session whose flush failed; the + * remaining handles still flush. + */ +abstract flush(): Promise + +/** + * Observe one stored session without reading its event log or taking + * ownership. + * + * The snapshot's `revision` is an opaque change token comparable only + * against revisions from the same service instance and session id: equal + * revisions may be treated as an unchanged log; unequal revisions promise + * nothing. Write-ownership churn does not change a revision. It exists for + * derived read-model caches keyed off `stat`/`list`; it plays no part in + * open, read, or resume. + * @param id - the stored session to observe. + * @param options - optional cancellation. + * @returns the snapshot, or `undefined` when the session does not exist. + */ +abstract stat(id: SessionId, options?: SessionPersistenceStatOptions): Promise + +/** + * List every stored session visible to this process, in no promised order. + * @param options - optional cancellation. + * @returns one snapshot per stored session. + */ +abstract list(options?: SessionPersistenceListOptions): Promise ``` -Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionId](core.zh.md) · [SessionLogOffset](session.zh.md) +Types: [SessionId](core.zh.md) Source: [`packages/session/session-persistence/src/index.ts`](../../packages/session/session-persistence/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index fd7e96441a..1de3205623 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/session-projection.md -session-projection.md: c8a5d4c1b05d830be3224e2e04db1762fe4b3c8f -session-projection.zh.md: d46c4f1589f57e560d70871d011245a3547c7825 +session-projection.md: e02ae8e36e79c4630a3799bf125ba8567db732cd +session-projection.zh.md: f95545ab3d5492c80400eea413ad0cfa4ff7fb90 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index c8a5d4c1b0..e02ae8e36e 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -271,9 +271,9 @@ checkpoint(session: Session): ProjectionCheckpoint * yields an end below every watermark and the restore rejects for a full * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). - * @returns the seq to hand the persistence `readFrom`, or `undefined` - * when no unit is registered (no read needed — {@link restore} would - * serve empty values regardless). + * @returns the offset for the stored-log suffix read (`SessionHandle.read`), + * or `undefined` when no unit is registered (no read needed — + * {@link restore} would serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined @@ -294,8 +294,8 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract => { - const persisted = (await persistence.list(signal)).find(header => header.id === sessionId) + const persisted = (await persistence.stat(sessionId, { signal }))?.header if (persisted === undefined || persisted.origin === 'subagent' || persisted.parentSession !== undefined) { throw invalidParams(`session is not resumable: ${sessionId}`) } @@ -299,8 +300,8 @@ export function apply(ctx: Context, config: AcpConfig): void { } catch (error: unknown) { throw invalidParams((error as Error).message) } - const listed = await persistence.list(signal) - const filtered = await Promise.all(listed.map(async (header) => { + const listed = await persistence.list({ signal }) + const filtered = await Promise.all(listed.map(async ({ header }) => { if ( sessions.has(header.id) || activating.has(header.id) diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 5dec9b3786..ec5036172b 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -7,10 +7,17 @@ import { fileURLToPath } from 'node:url' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' import { startHttpMcpFixture } from '../../../mcp/mcp-client/tests/http-fixture.ts' +/** Wrap a bare header as the snapshot shape `SessionPersistence.list` now returns. */ +function snapshotOf(header: SessionHeader): SessionPersistenceSnapshot { + return { header, revision: SessionPersistenceRevision(`test-${header.id}`) } +} + function oneToolCall(): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'tool-call' }, @@ -164,11 +171,16 @@ describe('automation-only ACP bridge', () => { await harness.client.closeSession({ sessionId: created.sessionId }) const updatesBeforeResume = harness.updates.length + const statSpy = vi.spyOn(harness.ctx.sessionPersistence, 'stat') + const listSpy = vi.spyOn(harness.ctx.sessionPersistence, 'list') const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers: [], }) + // Resume authorizes one known id through a point stat, never a corpus scan. + expect(statSpy).toHaveBeenCalledWith(SessionId(created.sessionId), expect.anything()) + expect(listSpy).not.toHaveBeenCalled() expect(Array.isArray(resumed.configOptions)).toBe(true) expect(harness.updates).toHaveLength(updatesBeforeResume) await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second prompt' }] }) @@ -249,13 +261,13 @@ describe('automation-only ACP bridge', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const sessionId = SessionId('other-frontend-live') harness.ctx.sessions.create(sessionId, { meta: { cwd: process.cwd() } }) - vi.spyOn(harness.ctx.sessionPersistence, 'list').mockResolvedValue([{ + vi.spyOn(harness.ctx.sessionPersistence, 'list').mockResolvedValue([snapshotOf({ version: 0, id: sessionId, createdAt: 1, - cwd: process.cwd(), isSeeded: false, - }]) + cwd: process.cwd(), + })]) const resume = vi.spyOn(harness.ctx.agents, 'resume') await expect(harness.client.listSessions({})).resolves.toEqual({ sessions: [] }) @@ -363,16 +375,20 @@ describe('automation-only ACP bridge', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const active = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const persistence = harness.ctx.get('sessionPersistence')! - vi.spyOn(persistence, 'list').mockResolvedValue([ - { version: 0, id: SessionId(active.sessionId), createdAt: 9, cwd: process.cwd(), isSeeded: false }, - { version: 0, id: SessionId('subagent'), createdAt: 8, cwd: '/missing/filter', isSeeded: false, origin: 'subagent' }, - { version: 0, id: SessionId('fork'), createdAt: 7, cwd: '/missing/filter', isSeeded: true, parentSession: SessionId('parent') }, + vi.spyOn(persistence, 'list').mockResolvedValue(([ + { version: 0, id: SessionId(active.sessionId), createdAt: 9, isSeeded: false, cwd: process.cwd() }, + { version: 0, id: SessionId('subagent'), createdAt: 8, isSeeded: false, cwd: '/missing/filter', origin: 'subagent' }, + { version: 0, id: SessionId('fork'), createdAt: 7, isSeeded: false, cwd: '/missing/filter', parentSession: SessionId('parent') }, { version: 0, id: SessionId('no-cwd'), createdAt: 6, isSeeded: false }, - { version: 0, id: SessionId('relative'), createdAt: 5, cwd: 'relative', isSeeded: false }, - { version: 0, id: SessionId('other'), createdAt: 4, cwd: '/missing/other', isSeeded: false }, - { version: 0, id: SessionId('valid-b'), createdAt: 3, cwd: '/missing/filter', isSeeded: false }, - { version: 0, id: SessionId('valid-a'), createdAt: 3, cwd: '/missing/filter', isSeeded: false }, - ]) + { version: 0, id: SessionId('relative'), createdAt: 5, isSeeded: false, cwd: 'relative' }, + { version: 0, id: SessionId('other'), createdAt: 4, isSeeded: false, cwd: '/missing/other' }, + { version: 0, id: SessionId('valid-b'), createdAt: 3, isSeeded: false, cwd: '/missing/filter' }, + { version: 0, id: SessionId('valid-a'), createdAt: 3, isSeeded: false, cwd: '/missing/filter' }, + ] satisfies SessionHeader[]).map(snapshotOf)) + // Resume authorizes through a point stat, not the list scan mocked above. + vi.spyOn(persistence, 'stat').mockImplementation(async id => (id === SessionId('no-cwd') + ? snapshotOf({ version: 0, id: SessionId('no-cwd'), createdAt: 6, isSeeded: false }) + : undefined)) await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow(/absolute path/) await expect(harness.client.listSessions({ cwd: '/missing/filter' })).resolves.toEqual({ @@ -423,7 +439,10 @@ describe('automation-only ACP bridge', () => { await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/Internal error/) expect(harness.ctx.agents.list()).toHaveLength(0) - await expect(harness.ctx.sessionPersistence.list()).resolves.toEqual([]) + // The loop seeds the log through its write handle before activation fails, + // so the rolled-back session's durable log remains; only the live agent and + // the bridge record are rolled back. + await expect(harness.ctx.sessionPersistence.list()).resolves.toHaveLength(1) const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 9e96e16e09..4ee411fe0e 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/api/session-controller/README.md -README.md: b3d22a340fff6a49270de520a043a1c8dfdbf096 -README.zh.md: 45590a53db17b32cc59f6107957c284b53ee8302 +README.md: cf78a247e27ec37ff51b10bad3f925348b9e01a2 +README.zh.md: 06d61851eec1ffdc99ed9348fac8b72cbc745b50 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index b3d22a340f..cf78a247e2 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -38,7 +38,8 @@ The Session object also carries local submission echoes: `session.beginSubmissio | Field | Default | Meaning | |---|---:|---| -| `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes | +| `coldBlankProbeMaxEvents` | `16` | Maximum stat-reported event count of a cold Session eligible for blankness verification; `0` disables the event-count gate | +| `coldBlankProbeMaxBytes` | `1,024` | Maximum stat-reported artifact byte size of a cold Session eligible for blankness verification when the backend offers no event count; `0` disables the byte-size gate | | `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 45590a53db..06d61851ee 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -38,7 +38,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 | 字段 | 默认值 | 含义 | |---|---:|---| -| `coldBlankProbeMaxBytes` | `1,024` | 可进行空白状态验证的冷 Session 工件最大物理大小;`0` 禁用探测 | +| `coldBlankProbeMaxEvents` | `16` | stat 报告的事件数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用事件数门槛 | +| `coldBlankProbeMaxBytes` | `1,024` | 后端不提供事件数时,stat 报告的工件字节数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用字节数门槛 | | `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 759c6b7f89..e982bf6f72 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -17,7 +17,11 @@ import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' -import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' +import { + ApiSessionList, + DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, + DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS, +} from './list.ts' import { buildModelCatalog } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' @@ -66,7 +70,9 @@ declare module '@deepseek-ai/cordis' { /** Session Controller deployment policy. */ export interface Config { - /** Maximum cold Session artifact size eligible for one full projection observation. */ + /** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */ + readonly coldBlankProbeMaxEvents?: number + /** Maximum stat-reported artifact byte size eligible for one full cold projection observation; `0` disables the byte-size gate. */ readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean @@ -95,6 +101,7 @@ export class SessionController extends TypertRemoteService { ] static Config: z = z.object({ + coldBlankProbeMaxEvents: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS), coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), nativeOpen: z.boolean(), }) @@ -110,7 +117,8 @@ export class SessionController extends TypertRemoteService { /** * @param ctx - Host context containing the Session capability assembly. - * @param config - cold-list observation policy. + * @param config - cold-list observation and native-opener deployment policy. + * @param internals - host integrations replaceable by direct unit tests. */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) @@ -124,10 +132,10 @@ export class SessionController extends TypertRemoteService { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) - this.listState = new ApiSessionList( - ctx, - config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, - ) + this.listState = new ApiSessionList(ctx, { + coldBlankProbeMaxEvents: config.coldBlankProbeMaxEvents ?? DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS, + coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, + }) this.openPath = internals.openPath ?? openNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 145c77c63a..3c9d78ecf9 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -1,6 +1,5 @@ /** Cold-safe Session list and search projection. */ -import { stat } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent-presets' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' @@ -20,9 +19,20 @@ import type { SessionSearchValue, SessionSummary, } from './types.ts' -/** Default maximum artifact size eligible for one cold projection observation. */ +/** Default maximum stat-reported event count eligible for one cold projection observation. */ +export const DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS = 16 + +/** Default maximum stat-reported artifact size eligible for one cold projection observation. */ export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 +/** Resolved cold-blank probe policy: each threshold gates its stat metric; `0` disables that gate. */ +export interface ColdBlankProbePolicy { + /** Maximum stat-reported `eventCount` eligible for a full observation. */ + readonly coldBlankProbeMaxEvents: number + /** Maximum stat-reported `sizeBytes` eligible for a full observation. */ + readonly coldBlankProbeMaxBytes: number +} + const COLD_SUMMARY_BATCH_SIZE = 16 const SEARCH_PROVIDER_CALL_LIMIT = 100 const SESSION_SEARCH_QUERY_MAX_CHARS = 500 @@ -82,11 +92,11 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin export class ApiSessionList { /** * @param ctx - Host context carrying Session, query, persistence, and projection services. - * @param coldBlankProbeMaxBytes - maximum physical artifact size eligible for a full observation. + * @param probe - stat-metadata thresholds gating a full cold observation. */ constructor( private readonly ctx: Context, - private readonly coldBlankProbeMaxBytes: number, + private readonly probe: ColdBlankProbePolicy, ) { ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ key: 'sessionListMetadata', @@ -176,7 +186,7 @@ export class ApiSessionList { sessionId: header.id, updatedAt: updatedAt(header, metadata), running: false, - // A large or inaccessible cache miss remains unknown and visible. + // A large, metadata-less, or inaccessible cache miss remains unknown and visible. blank: metadata?.blank ?? false, ...listFields(header), ...(projections === undefined ? {} : { projections }), @@ -187,15 +197,30 @@ export class ApiSessionList { header: SessionHeader, signal: AbortSignal | undefined, ): Promise { - if (this.coldBlankProbeMaxBytes === 0) return undefined + const { coldBlankProbeMaxEvents, coldBlankProbeMaxBytes } = this.probe + if (coldBlankProbeMaxEvents === 0 && coldBlankProbeMaxBytes === 0) return undefined const persistence = this.ctx.get('sessionPersistence') - const location = persistence?.locate(header) - if (location === undefined) return undefined + if (persistence === undefined) return undefined signal?.throwIfAborted() + let snapshot: Awaited> try { - if ((await stat(location.path)).size > this.coldBlankProbeMaxBytes) return undefined - } catch { + snapshot = await persistence.stat(header.id, signal === undefined ? {} : { signal }) + } catch (error: unknown) { + // An unreadable single session degrades to unknown state instead of + // failing the whole list request. signal?.throwIfAborted() + this.ctx.logger.warn( + `api-session.list: cold stat for "${header.id}" failed; serving it as visible: ${String(error)}`, + ) + return undefined + } + if (snapshot === undefined) return undefined + if (snapshot.eventCount !== undefined) { + if (coldBlankProbeMaxEvents === 0 || snapshot.eventCount > coldBlankProbeMaxEvents) return undefined + } else if (snapshot.sizeBytes !== undefined) { + if (coldBlankProbeMaxBytes === 0 || snapshot.sizeBytes > coldBlankProbeMaxBytes) return undefined + } else { + // The backend offers no cheap size hint, so a full observation is unbounded work. return undefined } try { diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 3d370bcf28..566ce048c8 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -5,10 +5,9 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' -import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' -import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -53,14 +52,6 @@ function header(id: string, cwd: string | null = '/workspace'): SessionHeader { } } -function unseededInspection( - meta: SessionHeader, - events: readonly SessionEvent[] = [], -): SessionInspection { - if (meta.isSeeded) throw new Error('seeded inspection fixtures require an explicit inherited cut') - return { meta, inheritedEventCount: SessionLogOffset(0), events } -} - function providePersistence(ctx: Context, persistence: Record): () => void { return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never) } @@ -96,18 +87,22 @@ describe('ApiSession identity failures', () => { .rejects.toBeInstanceOf(ApiSessionNotFound) const inspect = vi.fn(() => Promise.resolve(undefined)) + const stat = vi.fn(() => Promise.resolve(undefined)) const disposeMissing = providePersistence(ctx, { list: () => Promise.resolve([]), + stat, inspect, }) await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound) - expect(inspect).toHaveBeenCalledOnce() + // Absence is decided by the stat preflight; the log itself is never opened. + expect(stat).toHaveBeenCalledOnce() + expect(inspect).not.toHaveBeenCalled() disposeMissing() const listed = header('cwd-less-catalog', null) const disposeListed = providePersistence(ctx, { list: () => Promise.resolve([listed]), - inspect: () => Promise.resolve(unseededInspection(listed)), + inspect: () => Promise.resolve({ meta: listed, events: [] }), }) await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound) disposeListed() @@ -116,7 +111,7 @@ describe('ApiSession identity failures', () => { const inspected = header('cwd-less-inspect', null) providePersistence(ctx, { list: () => Promise.resolve([catalog]), - inspect: () => Promise.resolve(unseededInspection(inspected)), + inspect: () => Promise.resolve({ meta: inspected, events: [] }), }) await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound) }) @@ -127,11 +122,14 @@ describe('ApiSession identity failures', () => { await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) const meta = header('signalled-inspection') - const inspect = vi.fn(() => Promise.resolve(unseededInspection(meta))) - providePersistence(ctx, { inspect }) + const inspect = vi.fn(() => Promise.resolve({ meta, inheritedEventCount: SessionLogOffset(0), events: [] })) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect, + }) const signal = new AbortController().signal - await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual(unseededInspection(meta)) + await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, inheritedEventCount: SessionLogOffset(0), events: [] }) expect(inspect).toHaveBeenCalledWith(meta.id, signal) }) }) @@ -148,7 +146,6 @@ describe('ApiSession Agent lookup and recovery', () => { const observed = { source: 'prepared', header: meta, - inheritedEventCount: SessionLogOffset(0), events: [], cursor: -1, projections: { asOfSeq: -1, values: {} }, @@ -188,7 +185,7 @@ describe('ApiSession Agent lookup and recovery', () => { const ordinaryMeta = header('ordinary-race') providePersistence(ordinary.ctx, { list: () => Promise.resolve([ordinaryMeta]), - inspect: () => Promise.resolve(unseededInspection(ordinaryMeta)), + inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }), }) const winner = agent(ordinary.ctx, ordinaryMeta) vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => { @@ -201,7 +198,7 @@ describe('ApiSession Agent lookup and recovery', () => { const childMeta = header('child-race') providePersistence(child.ctx, { list: () => Promise.resolve([childMeta]), - inspect: () => Promise.resolve(unseededInspection(childMeta)), + inspect: () => Promise.resolve({ meta: childMeta, events: [] }), }) vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => { child.ctx.sessions.create(childMeta.id, { @@ -228,7 +225,7 @@ describe('ApiSession Agent lookup and recovery', () => { const meta = header('failed') providePersistence(failed.ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve(unseededInspection(meta)), + inspect: () => Promise.resolve({ meta, events: [] }), }) vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable')) await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({ @@ -242,7 +239,6 @@ describe('ApiSession Agent lookup and recovery', () => { const observed = { source: 'prepared', header: meta, - inheritedEventCount: SessionLogOffset(0), events: [], cursor: -1, retain: vi.fn(), @@ -375,27 +371,27 @@ describe('ApiSession create or adoption', () => { const meta = { ...header('stored'), agentPreset: 'minimal' } const events = [{ type: 'agent-preset/selected', - seq: SessionSeq(0), + seq: 0, time: 1, data: { agentPreset: 'minimal' }, }] as SessionEvent[] providePersistence(ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve(unseededInspection(meta, events)), + inspect: () => Promise.resolve({ meta, events }), }) ctx.provide('agentPresets', { resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), mount: () => Promise.resolve(), } as never) - const resumedSession = ctx.sessions.prepare(meta.id, { - seed: structuredClone(events), - meta: structuredClone(meta), - inheritedEventCount: SessionLogOffset(0), - seedSource: 'persistence', - }) const resumed = { id: meta.id, - session: resumedSession, + session: { + id: meta.id, + header: meta, + snapshotEvents: () => events, + eventAt: (seq: number) => events[seq], + seq: events.length, + }, status: 'idle', ctx, } as unknown as Agent @@ -413,7 +409,7 @@ describe('ApiSession create or adoption', () => { const childMeta = header('resume-child-race') providePersistence(child.ctx, { list: () => Promise.resolve([childMeta]), - inspect: () => Promise.resolve(unseededInspection(childMeta)), + inspect: () => Promise.resolve({ meta: childMeta, events: [] }), }) child.ctx.provide('agentPresets', { resolve: () => { @@ -432,7 +428,7 @@ describe('ApiSession create or adoption', () => { const stored = header('stored-cwd-conflict', '/stored') providePersistence(conflict.ctx, { list: () => Promise.resolve([stored]), - inspect: () => Promise.resolve(unseededInspection(stored)), + inspect: () => Promise.resolve({ meta: stored, events: [] }), }) await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) .rejects.toBeInstanceOf(ApiSessionCwdConflict) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index a6cc76c8b5..ce94fadd49 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -4,12 +4,10 @@ * isolation, and prompt failure mapping. */ +import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' @@ -20,10 +18,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { - PersistenceCoordinator, SessionPersistenceRevision, - type PersistenceBackend, - type StoredPrefix, + type SessionPersistenceSnapshot, } from '@deepseek-ai/dsh-session-persistence' import { ApiSessionList } from '../src/list.ts' import { @@ -49,79 +45,127 @@ function promptRequest( } function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { - return { version: 0, id: sid(id), createdAt, cwd: '/proj', isSeeded: false, ...extra } + return { version: 0, id: sid(id), createdAt, isSeeded: false, cwd: '/proj', ...extra } } function providePersistence(ctx: Context, persistence: Record): () => void { return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never) } +function statSnapshot( + meta: SessionHeader, + metrics: Partial> = {}, +): SessionPersistenceSnapshot { + return { header: meta, revision: SessionPersistenceRevision(`test:${meta.id}:stat`), ...metrics } +} + +/** A stored log whose only event is the seed boundary: still blank. */ +function blankEvents(): SessionEvent[] { + return [{ type: 'session/end-seed', seq: SessionSeq(0), time: 700, data: {} }] as SessionEvent[] +} + +/** A stored log with one human prompt at time 1200: proven non-blank. */ +function conversationEvents(): SessionEvent[] { + return [ + { type: 'turn/start', seq: SessionSeq(0), time: 800, data: { turn: 1 } }, + { + type: 'user/message', seq: SessionSeq(1), time: 1200, + data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + ] as SessionEvent[] +} + describe('sessions.list cold merge', () => { - it('fully observes only small possibly-blank artifacts and treats unavailable probes as visible', async () => { + it('serves cold rows from cached projections when stat offers no size metadata', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const metas = [ + header('cached-blank', 100), + header('cached-conversation', 200), + header('uncached', 300, { parentSession: sid('session-parent'), origin: 'subagent' }), + header('seeded-cold', 450, { isSeeded: true }), + { version: 0, id: sid('missing-cwd'), createdAt: 800, isSeeded: false }, + ] + const inspect = vi.fn() + providePersistence(ctx, { + list: () => Promise.resolve(metas), + inspect, + }) + const cacheCalls: string[] = [] + ctx.provide('sessionProjectionCache', { + cachedSnapshot: (meta: SessionHeader) => { + cacheCalls.push(String(meta.id)) + if (meta.id === sid('cached-blank')) { + return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } } + } + if (meta.id === sid('cached-conversation')) { + return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } + } + return undefined + }, + } as never) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const response = await remote.list(request({})) + expect(response.ok).toBe(true) + if (!response.ok) throw new Error('unreachable') + const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item])) + expect(byId['cached-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false }) + expect(byId['cached-conversation']).toMatchObject({ blank: false, updatedAt: 1000 }) + // A cache miss with a metadata-less stat leaves blankness unknown; the row stays visible. + expect(byId['uncached']).toMatchObject({ + blank: false, + updatedAt: 300, + parentSessionId: 'session-parent', + origin: 'subagent', + }) + expect(byId['missing-cwd']).toBeUndefined() + // A cold seeded header never consults the cache: its cut is not 0, so a + // cut-0 lookup would alias a different projection identity. + expect(byId['seeded-cold']).toMatchObject({ blank: false, updatedAt: 450 }) + expect(cacheCalls).not.toContain('seeded-cold') + expect(inspect).not.toHaveBeenCalled() + }) + + it('fully observes only small possibly-blank logs gated by stat eventCount', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const root = mkdtempSync(join(tmpdir(), 'dsh-cold-')) - const smallPath = join(root, 'small.log') - const largePath = join(root, 'large.log') - writeFileSync(smallPath, 'x'.repeat(1024)) - writeFileSync(largePath, 'x'.repeat(1025)) const metas = [ header('small-blank', 100), header('small-conversation', 200), header('large-unknown', 300), header('cached-nonblank', 400), - header('seeded-cold', 450, { isSeeded: true }), - header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }), header('vanished', 600), - header('read-failure', 700), { version: 0, id: sid('missing-cwd'), createdAt: 800, isSeeded: false }, ] const inspect = vi.fn(async (id: SessionId) => { - if (id === sid('small-blank')) { - return { - meta: metas[0]!, - events: [{ type: 'session/end-seed', seq: SessionSeq(0), time: 700, data: {} }] satisfies SessionEvent[], - } - } - if (id === sid('small-conversation')) { - return { - meta: metas[1]!, - events: [ - { type: 'turn/start', seq: SessionSeq(0), time: 800, data: { turn: 1 } }, - { - type: 'user/message', seq: SessionSeq(1), time: 1200, - data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }), - surfaceOp: 'append', - }, - ] satisfies SessionEvent[], - } - } - if (id === sid('read-failure')) throw new Error('simulated read failure') + if (id === sid('small-blank')) return { meta: metas[0]!, events: blankEvents() } + if (id === sid('small-conversation')) return { meta: metas[1]!, events: conversationEvents() } throw new Error(`unexpected cold read: ${id}`) }) + const stat = vi.fn(async (id: SessionId) => { + if (id === sid('small-blank')) return statSnapshot(metas[0]!, { eventCount: 1 }) + if (id === sid('small-conversation')) return statSnapshot(metas[1]!, { eventCount: 2 }) + if (id === sid('large-unknown')) return statSnapshot(metas[2]!, { eventCount: 17 }) + if (id === sid('vanished')) return undefined + throw new Error(`unexpected stat: ${id}`) + }) providePersistence(ctx, { list: () => Promise.resolve(metas), - locate: (meta: SessionHeader) => { - if (meta.id === sid('large-unknown') || meta.id === sid('seeded-cold')) { - return { kind: 'jsonl', path: largePath } - } - if (meta.id === sid('locationless')) return undefined - if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') } - return { kind: 'jsonl', path: smallPath } - }, + stat, inspect, }) ctx.provide('sessionProjectionCache', { cachedSnapshot: (meta: SessionHeader) => { - if (meta.id === sid('seeded-cold')) throw new Error('seeded cold listing must not guess a body cut') if (meta.id === sid('small-blank')) { - return { asOfSeq: SessionSeq(0), values: { sessionListMetadata: { blank: true, lastPromptAt: null } } } + return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } } } if (meta.id === sid('small-conversation')) { - return { asOfSeq: SessionSeq(0), values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } } + return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } } } if (meta.id === sid('cached-nonblank')) { - return { asOfSeq: SessionSeq(1), values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } + return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } } return undefined }, @@ -138,37 +182,80 @@ describe('sessions.list cold merge', () => { expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 }) expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 }) expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 }) - expect(byId['seeded-cold']).toMatchObject({ blank: false, updatedAt: 450 }) - expect(byId['locationless']).toMatchObject({ - blank: false, - updatedAt: 500, - parentSessionId: 'session-parent', - origin: 'subagent', - }) expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 }) - expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 }) expect(byId['missing-cwd']).toBeUndefined() - expect(inspect).toHaveBeenCalledTimes(3) + // A cache row proving blank:false is never re-probed. + expect(stat.mock.calls.map(([id]) => id)).not.toContain(sid('cached-nonblank')) + expect(inspect).toHaveBeenCalledTimes(2) expect(inspect.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([ sid('small-blank'), sid('small-conversation'), - sid('read-failure'), ])) }) - it('can disable bounded cold observations without hiding cold Sessions', async () => { + it('falls back to the stat sizeBytes gate when no eventCount is offered', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const metas = [header('small-jsonl', 100), header('large-jsonl', 200)] + const inspect = vi.fn(async (id: SessionId) => { + if (id === sid('small-jsonl')) return { meta: metas[0]!, events: conversationEvents() } + throw new Error(`unexpected cold read: ${id}`) + }) + providePersistence(ctx, { + list: () => Promise.resolve(metas), + stat: (id: SessionId) => Promise.resolve(id === sid('small-jsonl') + ? statSnapshot(metas[0]!, { sizeBytes: 1024 }) + : statSnapshot(metas[1]!, { sizeBytes: 1025 })), + inspect, + }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item])) + expect(byId['small-jsonl']).toMatchObject({ blank: false, updatedAt: 1200 }) + expect(byId['large-jsonl']).toMatchObject({ blank: false, updatedAt: 200 }) + expect(inspect).toHaveBeenCalledTimes(1) + expect(inspect).toHaveBeenCalledWith(sid('small-jsonl'), expect.anything()) + }) + + it('skips the observation when stat offers neither eventCount nor sizeBytes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('no-metrics', 100) + const inspect = vi.fn() + const stat = vi.fn(async () => statSnapshot(meta)) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + stat, + inspect, + }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }), + ]) + expect(stat).toHaveBeenCalledOnce() + expect(inspect).not.toHaveBeenCalled() + }) + + it('can disable both probe gates without hiding cold Sessions or calling stat', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const meta = header('probe-disabled', 100) const inspect = vi.fn() + const stat = vi.fn() providePersistence(ctx, { list: () => Promise.resolve([meta]), - locate: () => ({ kind: 'jsonl', path: '/not-read' }), + stat, inspect, }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + coldBlankProbeMaxEvents: 0, coldBlankProbeMaxBytes: 0, }) @@ -177,9 +264,179 @@ describe('sessions.list cold merge', () => { expect(response.value.items).toEqual([ expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }), ]) + expect(stat).not.toHaveBeenCalled() expect(inspect).not.toHaveBeenCalled() }) + it('treats zero as disabling one gate without falling back to the other metric', async () => { + const bench = async ( + metrics: Partial>, + thresholds: { coldBlankProbeMaxEvents?: number; coldBlankProbeMaxBytes?: number }, + ) => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('gate-off', 100) + const inspect = vi.fn() + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + stat: () => Promise.resolve(statSnapshot(meta, metrics)), + inspect, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + ...thresholds, + }) + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ sessionId: meta.id, blank: false }), + ]) + expect(inspect).not.toHaveBeenCalled() + } + + // An offered eventCount never falls through to the byte gate, even disabled. + await bench({ eventCount: 1, sizeBytes: 10 }, { coldBlankProbeMaxEvents: 0 }) + await bench({ sizeBytes: 10 }, { coldBlankProbeMaxBytes: 0 }) + }) + + it('prefers a Session that attaches during its bounded cold observation', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const meta = header('attached-during-probe', 100) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + stat: () => { + const session = ctx.sessions.create(meta.id, { + meta, + seed: [{ type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } }], + }) + ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent) + return Promise.resolve(statSnapshot(meta, { eventCount: 1 })) + }, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + }) + + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ sessionId: meta.id, running: true, blank: false }), + ]) + await ctx.fiber.dispose() + }) + + it('serves a session whose cold stat fails as visible instead of failing the list', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('broken-stat', 100) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + stat: () => Promise.reject(new Error('stat failed')), + }) + const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + }) + + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ sessionId: meta.id, running: false }), + ]) + expect(warned.mock.calls.join('\n')).toContain('cold stat for "broken-stat" failed') + warned.mockRestore() + await ctx.fiber.dispose() + }) + + it('a stat rejection after cancellation propagates instead of degrading', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('stat-abort', 100) + const controller = new AbortController() + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + stat: () => { + controller.abort(new Error('caller left')) + return Promise.reject(new Error('stat failed')) + }, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + }) + + await expect(remote.list(request({}), controller.signal)).resolves.toMatchObject({ + ok: false, + error: { code: 'gateway/cancelled' }, + }) + await ctx.fiber.dispose() + }) + + it('serves a small cold Session as visible when its observation fails', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('read-failure', 700) + const inspect = vi.fn(async () => { throw new Error('simulated read failure') }) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + stat: () => Promise.resolve(statSnapshot(meta, { eventCount: 1 })), + inspect, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + }) + + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: 700 }), + ]) + expect(inspect).toHaveBeenCalledOnce() + }) + + it('supports an unsignalled probe whose observation has no projection block', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + const meta = header('unprojected-small', 100) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + stat: () => Promise.resolve(statSnapshot(meta, { eventCount: 0 })), + } as never) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{ + header: meta, live: false, persisted: true, + }]) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({ + source: 'prepared', header: meta, inheritedEventCount: SessionLogOffset(0), events: [], cursor: -1, + retain: vi.fn(), [Symbol.dispose]: vi.fn(), + }) + const list = new ApiSessionList(ctx, { coldBlankProbeMaxEvents: 16, coldBlankProbeMaxBytes: 1024 }) + + await expect(list.list()).resolves.toEqual([ + expect.objectContaining({ sessionId: meta.id, blank: false }), + ]) + await ctx.fiber.dispose() + }) + + it('serves a cold row visible when no persistence service can stat it', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const meta = header('service-less', 100) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{ + header: meta, live: false, persisted: true, + }]) + const list = new ApiSessionList(ctx, { coldBlankProbeMaxEvents: 16, coldBlankProbeMaxBytes: 1024 }) + + await expect(list.list()).resolves.toEqual([ + expect.objectContaining({ sessionId: meta.id, blank: false }), + ]) + await ctx.fiber.dispose() + }) + it('prefers a live row attached during the query without folding its seed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -227,92 +484,6 @@ describe('sessions.list cold merge', () => { ]) }) - it('prefers a Session that attaches during its bounded cold observation', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-')) - const path = join(root, 'small.log') - writeFileSync(path, 'small') - const meta = header('attached-during-probe', 100) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - locate: () => ({ kind: 'jsonl', path }), - inspect: () => { - const session = ctx.sessions.create(meta.id, { - meta, - seed: [{ type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } }], - }) - ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent) - return Promise.resolve({ - meta, - inheritedEventCount: SessionLogOffset(0), - events: [], - }) - }, - }) - const remote = createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', - }) - - const response = await remote.list(request({})) - if (!response.ok) throw new Error('list failed') - expect(response.value.items).toEqual([ - expect.objectContaining({ sessionId: meta.id, running: true, blank: false }), - ]) - await ctx.fiber.dispose() - }) - - it('propagates a cold location failure instead of returning a partial list', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('broken-cache', 100) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - locate: () => { throw new Error('location failed') }, - }) - const remote = createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', - }) - - await expect(remote.list(request({}))).resolves.toMatchObject({ - ok: false, - error: { message: expect.stringContaining('location failed') as string }, - }) - await ctx.fiber.dispose() - }) - - it('supports an unsignalled probe whose observation has no projection registry', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - installSessionReadTestServices(ctx) - const root = mkdtempSync(join(tmpdir(), 'dsh-cold-unprojected-')) - const path = join(root, 'small.log') - writeFileSync(path, 'small') - const meta = header('unprojected-small', 100) - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - locate: () => ({ kind: 'jsonl', path }), - } as never) - vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{ - header: meta, live: false, persisted: true, - }]) - vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({ - source: 'prepared', - header: meta, - inheritedEventCount: SessionLogOffset(0), - events: [], - cursor: -1, - retain: vi.fn(), [Symbol.dispose]: vi.fn(), - }) - const list = new ApiSessionList(ctx, 1024) - - await expect(list.list()).resolves.toEqual([ - expect.objectContaining({ sessionId: meta.id, blank: false }), - ]) - await ctx.fiber.dispose() - }) }) describe('attached updatedAt tracks human prompts', () => { @@ -366,40 +537,25 @@ describe('attached updatedAt tracks human prompts', () => { }) describe('cold history recovery view', () => { - it('shows in-memory interruption repair without activating the session', async () => { + it('serves the stored interrupted prefix verbatim without activating the session', async () => { + // Semantic crash repair is the resuming agent loop's job (it appends the + // closers durably through its write handle); a cold history read shows the + // stored prefix exactly as persisted. const ctx = new Context() await ctx.plugin(SessionStore) const sessionId = sid('session-interrupted') const meta = header(sessionId, 1000) - const stored: StoredPrefix = { - meta, - inheritedEventCount: SessionLogOffset(0), - events: [{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }], - revision: SessionPersistenceRevision('history-recovery-test:1'), - } - const backend: PersistenceBackend = { - name: 'history-recovery-test', - loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined), - readStoredRevision: id => Promise.resolve( - id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined, - ), - appendBatch: () => Promise.resolve(), - commitRepair: () => Promise.resolve(), - list: () => Promise.resolve([structuredClone(meta)]), - } - const coordinator = new PersistenceCoordinator(ctx, backend) + const events = [{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }] as SessionEvent[] providePersistence(ctx, { - list: (signal?: AbortSignal) => backend.list(signal), - inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), - borrowSession: (id: SessionId, signal?: AbortSignal) => coordinator.borrowSession(id, signal), - locate: () => undefined, + list: () => Promise.resolve([structuredClone(meta)]), + inspect: () => Promise.resolve({ meta: structuredClone(meta), events: structuredClone(events) }), }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const history = await remote.page({ address: { kind: 'session', sessionId }, - throughSeq: 1, - beforeSeq: 2, + throughSeq: 0, + beforeSeq: 1, maxMessages: 10, }) if (!history.ok) throw new Error('history failed') @@ -413,17 +569,6 @@ describe('cold history recovery view', () => { "time": 1, "type": "turn/start", }, - { - "data": { - "reason": { - "kind": "interrupted", - }, - "turn": 1, - }, - "seq": 1, - "time": 1, - "type": "turn/end", - }, ] `) expect(ctx.sessions.get(sessionId)).toBeUndefined() @@ -439,15 +584,10 @@ describe('Remote Agent and Session lookup policy', () => { await ctx.plugin(AgentRegistry) const sessionId = sid('session-remote-cold') const meta = header(sessionId, 1000) - const inspect = vi.fn(() => Promise.resolve({ - meta, - inheritedEventCount: SessionLogOffset(0), - events: [] as SessionEvent[], - })) + const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] })) providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect, - locate: () => undefined, }) const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent @@ -487,15 +627,10 @@ describe('Remote Agent and Session lookup policy', () => { parentSession: sid('session-parent'), origin: 'subagent', }) - const inspect = vi.fn(() => Promise.resolve({ - meta: coldMeta, - inheritedEventCount: SessionLogOffset(0), - events: [] as SessionEvent[], - })) + const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] })) providePersistence(ctx, { list: () => Promise.resolve([coldMeta]), inspect, - locate: () => undefined, }) const liveSession = ctx.sessions.create(sid('session-remote-live-child'), { meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' }, @@ -535,19 +670,10 @@ describe('subagent ownership fence', () => { const sessionId = sid('session-child') const meta = header('session-child', 1000, { parentSession: sid('session-parent'), - isSeeded: true, origin: 'subagent', }) const events = [ - { - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - } as SessionEvent<'turn/start'>['data'], - }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'user/message', seq: SessionSeq(1), @@ -566,16 +692,11 @@ describe('subagent ownership fence', () => { }), }, { type: 'turn/end', seq: SessionSeq(3), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, - ] satisfies SessionEvent[] - const inspect = vi.fn(() => Promise.resolve({ - meta, - inheritedEventCount: SessionLogOffset(0), - events, - })) + ] as SessionEvent[] + const inspect = vi.fn(() => Promise.resolve({ meta, events })) providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect, - locate: () => undefined, }) const resume = vi.spyOn(ctx.agents, 'resume') const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -615,7 +736,9 @@ describe('subagent ownership fence', () => { if (!create.ok) expect(create.error.code).toBe('session/agent-busy') expect(resume).not.toHaveBeenCalled() expect(ctx.agents.get(sessionId)).toBeUndefined() - expect(inspect).toHaveBeenCalledTimes(3) + // One log open serves all three cold reads: the observation cache reuses + // the prepared Session while the stat revision is unchanged. + expect(inspect).toHaveBeenCalledTimes(1) }) it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => { @@ -625,7 +748,6 @@ describe('subagent ownership fence', () => { const sessionId = sid('session-legacy-child') const meta = header('session-legacy-child', 1000, { parentSession: sid('session-parent'), - isSeeded: true, }) const events = [ { @@ -634,16 +756,10 @@ describe('subagent ownership fence', () => { time: 1, data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, }, - ] satisfies SessionEvent[] + ] as SessionEvent[] providePersistence(ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ - meta, - inheritedEventCount: SessionLogOffset(0), - events, - }), - locate: () => undefined, - }) + inspect: () => Promise.resolve({ meta, events }) }) // Stores whose headers predate `origin` classify a child only through the // descriptor event; the pre-release decision stops recognizing them, so // the ownership fence lets generic resume reach the registry instead of @@ -732,8 +848,8 @@ describe('subagent ownership fence', () => { time: 1, data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' }, }], - inheritedEventCount: SessionLogOffset(1), meta: { cwd: '/proj', parentSession: sid('session-source'), isSeeded: true }, + inheritedEventCount: SessionLogOffset(1), }) const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent @@ -844,8 +960,10 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) const inspect = vi.fn() + const stat = vi.fn(() => Promise.resolve(undefined)) providePersistence(ctx, { list: () => Promise.resolve([]), + stat, inspect, }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -856,7 +974,9 @@ describe('degenerate composition (no persistence, no factory)', () => { }) expect(response.ok).toBe(false) if (!response.ok) expect(response.error.code).toBe('session/not-found') - expect(inspect).toHaveBeenCalledOnce() + // Absence is decided by the stat preflight; the log itself is never opened. + expect(stat).toHaveBeenCalledOnce() + expect(inspect).not.toHaveBeenCalled() }) }) @@ -901,13 +1021,7 @@ describe('sessions.prompt synchronous rejection', () => { const meta: SessionHeader = header('race-resume', 1000) providePersistence(ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ - meta, - inheritedEventCount: SessionLogOffset(0), - events: [] as SessionEvent[], - }), - locate: () => undefined, - }) + inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }) }) // The raced winner: a live parent-owned subagent publishes the identity // while the generic cold resume is in flight, so the resume collides. const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } }) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 3f57e1afe2..6b97a08195 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -27,7 +27,7 @@ import Storage from '@deepseek-ai/dsh-storage' import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import * as StorageJson from '@deepseek-ai/dsh-storage-json' import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' -import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' +import { createSessionTestRemote, testSessionPersistence, type TestSessionRemote } from './test-remote.ts' declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { @@ -434,13 +434,11 @@ describe('session.list projections column', () => { const { ctx } = await harness(true) const coldId = SessionId('session-cold-listing') const load = () => { throw new Error('list must not load event logs') } - ctx.provide('sessionPersistence', { - list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], - locate: () => undefined, - load, + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: async () => [{ version: 0, id: coldId, createdAt: 5, isSeeded: false, cwd: '/tmp' }], inspect: load, - readFrom: load, - } as never) + open: load, + }) as never) ctx.provide('sessionProjectionCache', { // The carrier hands the listed header through as the identity witness. cachedSnapshot: (meta: { id: unknown; createdAt: number }) => @@ -507,8 +505,7 @@ describe('session.list projections column', () => { await owner.dispose() expect(ctx.sessions.get(id)).toBeUndefined() ctx.provide('sessionPersistence', { - list: async () => [header], - locate: () => undefined, + list: async () => [{ header, revision: 'test:cold-host-state:1' }], } as never) const response = await gateway.list(request({})) @@ -526,10 +523,9 @@ describe('session.list projections column', () => { it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { const { ctx } = await harness(true) const coldId = SessionId('session-cold-uncached') - ctx.provide('sessionPersistence', { - list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], - locate: () => undefined, - } as never) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: async () => [{ version: 0, id: coldId, createdAt: 5, isSeeded: false, cwd: '/tmp' }], + }) as never) const response = await remote(ctx).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === coldId) diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index 89d1a2f424..1d88f9d47a 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -17,7 +17,7 @@ import { type SessionSearchHit, type SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' -import { createSessionTestRemote } from './test-remote.ts' +import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' import { ApiSessionList } from '../src/list.ts' const sid = (value: string): SessionId => value as SessionId @@ -96,7 +96,7 @@ function installSearchQuery( describe('session.search', () => { it('rejects search when the query service is absent', async () => { const ctx = await baseContext() - const list = new ApiSessionList(ctx, 0) + const list = new ApiSessionList(ctx, { coldBlankProbeMaxEvents: 16, coldBlankProbeMaxBytes: 1024 }) await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/internal', @@ -113,10 +113,9 @@ describe('session.search', () => { }), { surfaceOp: 'append' }) const cold = header('cold', '/cold') const legacy = header('legacy', null) - ctx.provide('sessionPersistence', { + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([cold, legacy]), - locate: () => undefined, - } as never) + }) as never) const searchSessions = vi.fn(( _request: SessionSearchRequest, @@ -770,10 +769,9 @@ describe('session.search', () => { { length: 32_751 }, (_, index) => header(`cold-${index}`, `/cold-${index}`), ) - ctx.provide('sessionPersistence', { + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve(cold), - locate: () => undefined, - } as never) + }) as never) const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({ items: [hit('cold-32750')], })) @@ -804,14 +802,14 @@ describe('session.search', () => { controller.abort() return Promise.resolve(cold) }) - let locateCalls = 0 - ctx.provide('sessionPersistence', { + let statCalls = 0 + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list, - locate: () => { - locateCalls++ - return undefined + stat: () => { + statCalls++ + return Promise.resolve(undefined) }, - } as never) + }) as never) const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) @@ -825,18 +823,20 @@ describe('session.search', () => { error: { code: 'gateway/cancelled' }, }) expect(list).toHaveBeenCalledOnce() - expect(locateCalls).toBe(0) + expect(statCalls).toBe(0) expect(searchSessions).not.toHaveBeenCalled() }) - it('does not stat or locate cold artifacts while collecting search visibility', async () => { + it('does not stat or open cold artifacts while collecting search visibility', async () => { const ctx = await baseContext() const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`)) - const locate = vi.fn((meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` })) - ctx.provide('sessionPersistence', { + const stat = vi.fn() + const inspect = vi.fn() + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve(cold), - locate, - } as never) + stat, + inspect, + }) as never) const searchSessions = vi.fn(() => Promise.resolve({ items: [] })) installSearchQuery(ctx, searchSessions) @@ -848,7 +848,8 @@ describe('session.search', () => { ok: true, value: { items: [], hasMore: false }, }) - expect(locate).not.toHaveBeenCalled() + expect(stat).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() expect(searchSessions).toHaveBeenCalledOnce() }) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 2794e52ffe..1b5859b509 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -1,15 +1,20 @@ /** Test-only direct Remote face over the Session Controller's internal controllers. */ +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { - SessionPersistenceCorruptionError, SessionPersistenceNotFoundError, SessionPersistenceRevision, - type BorrowedSessionSource, - type SessionInspection, + SessionReadOnlyError, + type SessionAccess, + type SessionHandle, + type SessionHandleReadOptions, + type SessionPersistenceListOptions, + type SessionPersistenceOpenOptions, + type SessionPersistenceSnapshot, + type SessionPersistenceStatOptions, } from '@deepseek-ai/dsh-session-persistence' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' @@ -78,6 +83,7 @@ export interface TestSessionRemote { export interface TestSessionRemoteDefaults { readonly defaultModelSelection: () => AgentModelSelection readonly cwd: string + readonly coldBlankProbeMaxEvents?: number readonly coldBlankProbeMaxBytes?: number readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise @@ -87,63 +93,102 @@ export interface TestSessionRemoteDefaults { const installed = new WeakMap() +/** Compact header-and-events point read a persistence double declares per session. */ +interface TestSessionInspection { + readonly meta: SessionHeader + readonly events: readonly SessionEvent[] +} + type LegacyTestPersistence = Record & { + readonly list?: (signal?: AbortSignal) => Promise readonly inspect?: ( sessionId: SessionId, signal?: AbortSignal, - ) => Promise - readonly borrowSession?: ( + ) => Promise + readonly stat?: ( sessionId: SessionId, - signal?: AbortSignal, - ) => Promise + options?: SessionPersistenceStatOptions, + ) => Promise + readonly open?: ( + sessionId: SessionId, + access: SessionAccess, + options?: SessionPersistenceOpenOptions, + ) => Promise } -/** Add the preparation-backed point-read contract to compact persistence doubles. */ -export function testSessionPersistence( - ctx: Context, - persistence: LegacyTestPersistence, -): LegacyTestPersistence { - if (persistence.borrowSession !== undefined) return persistence +/** One immutable read handle over a double's inspected header and events. */ +function testReadHandle( + sessionId: SessionId, + inspection: TestSessionInspection, +): SessionHandle { + const events = Object.freeze([...inspection.events]) return { - ...persistence, - borrowSession: async (sessionId, signal) => { - signal?.throwIfAborted() - const inspection = await persistence.inspect?.(sessionId, signal) - signal?.throwIfAborted() - if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId) - try { - const inheritedEventCount = (inspection as Partial).inheritedEventCount - if (inspection.meta.isSeeded && inheritedEventCount === undefined) { - throw new Error('seeded test persistence must provide inheritedEventCount') - } - const cut = SessionLogOffset(inheritedEventCount ?? 0) - const preparedSession = ctx.sessions.prepare(inspection.meta.id, { - seed: [...inspection.events], - meta: inspection.meta, - inheritedEventCount: cut, - seedSource: 'persistence', - }) - return { - source: 'prepared', - inspection: { - meta: preparedSession.header, - inheritedEventCount: preparedSession.inheritedEventCount, - events: Object.freeze([...inspection.events]), - }, - revision: SessionPersistenceRevision(`test:${sessionId}:${String(preparedSession.seq)}`), - preparedSession, - [Symbol.dispose]: () => {}, - } - } catch (error: unknown) { - throw new SessionPersistenceCorruptionError( - `test session "${sessionId}" failed validation: ${String(error)}`, - { cause: error }, - ) - } + id: sessionId, + header: inspection.meta, + inheritedEventCount: SessionLogOffset(0), + access: 'read', + read: (offset = 0, length?: number, options?: SessionHandleReadOptions) => { + options?.signal?.throwIfAborted() + return Promise.resolve(events.slice(offset, length === undefined ? undefined : offset + length)) }, + append: () => Promise.reject(new SessionReadOnlyError(sessionId, 'append')), + flush: () => Promise.reject(new SessionReadOnlyError(sessionId, 'flush')), + close: () => Promise.resolve(), + [Symbol.asyncDispose]: () => Promise.resolve(), } } +/** + * Adapt a compact header/inspect persistence double onto the handle-based + * abstract the production readers consume: `list` snapshots wrap the double's + * headers, `stat` derives a metadata-less snapshot from the listing (so the + * cold-blank probe skips unless the double declares its own `stat`), and + * `open` serves immutable read handles over the double's `inspect` result. + */ +export function testSessionPersistence( + _ctx: Context, + persistence: LegacyTestPersistence, +): Record { + const listHeaders = async (signal?: AbortSignal): Promise => + await persistence.list?.(signal) ?? [] + const adapted: Record = { + ...persistence, + list: async (options?: SessionPersistenceListOptions) => + (await listHeaders(options?.signal)).map(header => ({ + header, + revision: SessionPersistenceRevision(`test:${header.id}:list`), + })), + } + if (persistence.stat === undefined) { + adapted.stat = async ( + sessionId: SessionId, + options?: SessionPersistenceStatOptions, + ): Promise => { + options?.signal?.throwIfAborted() + const header = (await listHeaders(options?.signal)).find(listed => listed.id === sessionId) + return header === undefined + ? undefined + : { header, revision: SessionPersistenceRevision(`test:${sessionId}:stat`) } + } + } + if (persistence.open === undefined) { + adapted.open = async ( + sessionId: SessionId, + access: SessionAccess, + options?: SessionPersistenceOpenOptions, + ): Promise => { + options?.signal?.throwIfAborted() + if (access !== 'read') { + throw new Error(`test persistence double only serves read handles (requested "${access}")`) + } + const inspection = await persistence.inspect?.(sessionId, options?.signal) + if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId) + return testReadHandle(sessionId, inspection) + } + } + return adapted +} + /** Concrete point-read query used by Session Controller tests that do not exercise search. */ class TestSessionQuery extends SessionQueryEngine { override searchSessions(): Promise { @@ -198,6 +243,9 @@ function installControllers( controller = new SessionController( ctx, { + ...defaults.coldBlankProbeMaxEvents === undefined + ? {} + : { coldBlankProbeMaxEvents: defaults.coldBlankProbeMaxEvents }, ...defaults.coldBlankProbeMaxBytes === undefined ? {} : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index b6eeda6b41..d88f79e2d9 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -183,6 +183,7 @@ describe('SessionHistoryController', () => { events: readonly SessionEvent[] }>() ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), inspect: () => inspected.promise, }) as never) const abort = new AbortController() @@ -468,7 +469,7 @@ describe('SessionHistoryController', () => { const { ctx, transport } = await setup() const sessionId = SessionId('corrupt-cold') const failure = new Error('cold log is corrupt') - const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const header = { version: 0, id: sessionId, createdAt: 1, isSeeded: false, cwd: '/workspace' } ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), inspect: () => Promise.reject(failure), @@ -525,8 +526,10 @@ describe('SessionHistoryController', () => { .rejects.toMatchObject({ code: 'session/not-found' }) const inspect = vi.fn(() => Promise.resolve(undefined)) + const stat = vi.fn(() => Promise.resolve(undefined)) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([]), + stat, inspect, }) as never) await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) @@ -540,7 +543,9 @@ describe('SessionHistoryController', () => { }, throughSeq: -1, }, signal())).rejects.toMatchObject({ code: 'subagent/not-found' }) - expect(inspect).toHaveBeenCalledTimes(2) + // Absence is decided by the stat preflight; no log open is attempted. + expect(stat).toHaveBeenCalledTimes(2) + expect(inspect).not.toHaveBeenCalled() }) it('rejects incomplete cold metadata before serving a source', async () => { diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index f7b44e1940..c695031a0d 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -225,7 +225,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () ...await next(), provider: 'mock', model: 'mock', })) try { - const agent = ctx.agentLoop.create(SessionId('routed-pressure'), { + const agent = await ctx.agentLoop.create(SessionId('routed-pressure'), { provider: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback', }) @@ -246,7 +246,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () it('runs automatic pressure between the completed tool step and the next step', async () => { const { ctx } = await harness(8) try { - const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -278,7 +278,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () it('the head checkpoint the loop lands is a balanced cut on both sides', async () => { const { ctx } = await harness(8) try { - const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index 4aa8d27cde..0bc8c8e6ae 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -110,7 +110,7 @@ async function loopHarness(): Promise { const adapter = new TextAdapter() ctx.llm.registerAdapter([MODEL], adapter) const compact = new GatedCompactionEngine(ctx, { auto: false }) - const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL }) + const agent = await ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL }) const log: string[] = [] ctx.on('session/event', (_session, event) => { if (event.type === 'turn/start') log.push('turn/start') diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index af270437b1..28669e88d0 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -2548,7 +2548,7 @@ describe('dynamic nested workspace context injection', () => { await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) + const agent = await ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) ctx.tools.register(defineContentToolFixture({ name: 'abort_step', description: 'Abort the current test step.', diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index ec8976fead..63d1bcb2b0 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -453,7 +453,7 @@ describe('real agent-loop request history', () => { subject.cancel({ kind: 'user' }) return next() }) - const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })) await agent.whenIdle() @@ -476,7 +476,7 @@ describe('real agent-loop request history', () => { return [{ type: 'text' as const, text: 'advanced' }] }, })) - const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })) await agent.whenIdle() diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 5f61e791ac..7541d9a310 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/core/agent-loop/README.md -README.md: 60d1e75a5038500a42244a45fb077e1eb71da092 -README.zh.md: 166ba6ff366f9a8c8c687b4521bb04b591814851 +README.md: 5e4ab0a741f0b7dae821e52f1a9b0a1faa90ceed +README.zh.md: ef47f897978c8254242075a559f56faa2dceca19 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 60d1e75a50..5e4ab0a741 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -103,7 +103,11 @@ After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields an ### Creation and teardown -Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; only then start the driver. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, unwind the scope, detach the agent, then detach the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement. +Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; only then start the driver. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, closes the session's write path, unwinds the scope, detaches the agent, then detaches the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement. + +### Persistence integration + +The loop is the production acquisition point for session write handles. When `ctx.sessionPersistence` is mounted, `create`/`createAgent` call `persistence.create(header)` — storing the durable identity and taking write ownership before publication — and append the constructor seed through the handle; `resume` calls `persistence.open(id, 'write')` first (excluding a concurrent resume of the same id), reads the physically valid log through the handle, and appends `interruptedTurnClosers` for a log crashed mid-turn as an ordinary batch — semantic crash repair is the agent layer's job, not a storage entry point. Immediately before publication, `appendUnstoredSuffix` stores any events appended during the setup window (seed markers, delegation policy records), which never re-emit through `session/event`. Once published, the mounted backend routes the session's `session/event` batches, `session/flush` barriers, and `session/disposed` retirement into the active write handle by session id; the loop touches storage only through the handle it owns. The memoized teardown closes the handle — close drains any routed buffer — after the loop commits the session's closing events, provably releasing write ownership. Without a backend, sessions are memory-only and nothing else changes. ### Turn and step flow diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 166ba6ff36..ef47f89797 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -103,7 +103,11 @@ const handle = await ctx.agents.create({ ### 创建与拆除 -创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 +创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、关闭会话的写路径、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 + +### 持久化集成 + +循环是会话写句柄在生产环境中的获取点。挂载 `ctx.sessionPersistence` 后,`create`/`createAgent` 调用 `persistence.create(header)`——在发布之前存储持久身份并取得写所有权——并通过句柄追加构造 seed;`resume` 先调用 `persistence.open(id, 'write')`(排除同 id 的并发恢复),通过句柄读取物理上有效的日志,并为在轮次中途崩溃的日志把 `interruptedTurnClosers` 作为普通批次追加——语义崩溃修复是 agent 层的职责,而非存储入口。发布前的最后一刻,`appendUnstoredSuffix` 存储 setup 窗口期间追加的事件(seed 标记、委派策略记录),它们绝不会经由 `session/event` 重新发出。发布之后,挂载的后端按会话 id 把该会话的 `session/event` 批次、`session/flush` 屏障与 `session/disposed` 退役路由进活跃写句柄;循环只通过它拥有的句柄触碰存储。记忆化的 teardown 在循环提交会话的收尾事件之后关闭句柄——close 会排空任何已路由的缓冲——可证明地释放写所有权。没有后端时,会话只存在于内存中,其余一切不变。 ### 轮次与步骤流程 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 572f2bcc7b..743f386bfc 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -24,13 +24,14 @@ import type { } from '@deepseek-ai/dsh-agent' import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-settings' -import { SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session' +import { interruptedTurnClosers, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' +import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { ReactLoopAgent } from './agent.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' @@ -202,6 +203,12 @@ function assertAgentOptions(options: AgentOptions): void { } } +/** One session's owned write handle plus the count of events already stored through it. */ +interface StoredSession { + readonly handle: SessionHandle + storedCount: number +} + /** Prepared-but-unpublished agent resources sharing one memoized teardown. */ interface PreparedAgent { agent: ReactLoopAgent @@ -421,7 +428,10 @@ export class AgentLoop extends Service implements AgentFactory { const configuredId = sessionId ?? brandString(`${id}-session-${randomUUID()}`) const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence') if (persistence === undefined) { - this.create(configuredId, options, meta) + const startup = this.create(configuredId, options, meta).then(() => undefined, (error: unknown) => { + this.reportConfiguredStartupFailure(id, 'restore', configuredId, error) + }) + this.ownership.trackStartup(startup) } else { const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => { this.reportConfiguredStartupFailure(id, 'restore', configuredId, error) @@ -481,13 +491,11 @@ export class AgentLoop extends Service implements AgentFactory { return } catch (error: unknown) { if (!this.ownership.isActive()) return - // A load is the per-id serialization barrier for eager write-behind and - // lifecycle retirement. Only a genuinely absent artifact falls back to - // first creation; corruption and backend failures stay loud. - const exists = (await persistence.list()).some(header => header.id === sessionId) - if (exists) throw error + // Only a genuinely absent stored session falls back to first creation; + // corruption, ownership conflicts, and backend failures stay loud. + if (!(error instanceof SessionPersistenceNotFoundError)) throw error } - this.create(sessionId, agentOptions, meta) + await this.create(sessionId, agentOptions, meta) } /** Wait for a draining same-id lifecycle to finish registry teardown. */ @@ -519,7 +527,14 @@ export class AgentLoop extends Service implements AgentFactory { * BEFORE publication, so a mid-setup unload rolls everything back; `signal` * fuses caller cancellation with lifecycle teardown for setup awaits. */ - private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent { + private prepare( + ownerCtx: Context, + id: SessionId, + options: AgentOptions, + session: Session, + callerSignal?: AbortSignal, + handle?: SessionHandle, + ): PreparedAgent { assertAgentOptions(options) ownerCtx.fiber.assertActive() // Every caller reaches prepare() synchronously from a service method @@ -555,12 +570,16 @@ export class AgentLoop extends Service implements AgentFactory { let disposing: Promise | undefined const machineReady = Promise.withResolvers() // Reverse teardown, memoized so every racing owner awaits one quiescence: - // stop the machine, leave the registries, unwind the scope, release - // bookkeeping. + // stop the machine, drain and close the session's write path, leave the + // registries, unwind the scope, release bookkeeping. const dispose = (ownerTriggered = false): Promise => (disposing ??= (async () => { abort.abort(new Error(`agent "${id}" lifecycle disposed`)) callerSignal?.removeEventListener('abort', onCallerAbort) this.ownership.signal.removeEventListener('abort', onFactoryTeardown) + // Teardown failures are collected, never swallowed: registry, scope, + // and ownership cleanup always run to quiescence, then the memoized + // disposal rejects with what failed so every racing owner observes it. + const failures: unknown[] = [] try { // Disposal IS a disposed-cause cancel followed by quiescence. New work // sent after this point is the sender's bug — the registries are about @@ -571,14 +590,28 @@ export class AgentLoop extends Service implements AgentFactory { await machine.whenIdle() await machine.scope.dispose() } + } catch (error: unknown) { + failures.push(error) + } + // The loop above committed its closing events synchronously into the + // session; handle close drains them durably before releasing the write + // path. The close drain can be the first operation that surfaces a + // durability failure, so its error is retained, not logged away. + try { + await handle?.close() + } catch (error: unknown) { + failures.push(error) + } + try { + detachAgent?.() + detachSession?.() } finally { - try { - detachAgent?.() - detachSession?.() - } finally { - untrack() - if (!ownerTriggered) await unfollowOwner() - } + untrack() + if (!ownerTriggered) await unfollowOwner() + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) { + throw new AggregateError(failures, `agent "${id}" disposal failed`) } })()) const untrack = this.ownership.track(dispose) @@ -619,6 +652,8 @@ export class AgentLoop extends Service implements AgentFactory { publish: (source) => { assertLive() detachSession = agent.ctx.sessions.enter(session) + // The mounted backend routes announced live events into the active + // write handle by session id; the loop only owns the handle itself. detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent) agent.ctx.sessions.announce(session) assertLive() @@ -635,7 +670,8 @@ export class AgentLoop extends Service implements AgentFactory { } } catch (error: unknown) { machineReady.resolve() - void dispose() + // Rollback swallows a disposal rejection: the setup failure is primary. + void dispose().catch(() => {}) throw error } } @@ -643,21 +679,70 @@ export class AgentLoop extends Service implements AgentFactory { /** * Create an agent and session under one caller-supplied identity, owned by * the accessing fiber. Constructor-driven config calls mint a fresh combined - * id before entering this boundary. + * id before entering this boundary. When a persistence backend is mounted, + * the session's durable identity and any seed are stored before publication. * @param id - shared agent/session identity. * @param options - concrete loop options. * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ - create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { + async create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Promise { using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta })) - const prepared = this.prepare(this.ctx, id, options, preparation.session) + const stored = await this.createStoredSession(preparation.session) + let prepared: PreparedAgent try { - return prepared.publish('startup').agent + prepared = this.prepare(this.ctx, id, options, preparation.session, undefined, stored?.handle) } catch (error: unknown) { - void prepared.dispose() + await stored?.handle.close().catch(() => {}) throw error } + try { + await this.appendUnstoredSuffix(stored, preparation.session) + return prepared.publish('startup').agent + } catch (error: unknown) { + // Rollback swallows a disposal rejection: the setup failure is primary. + void prepared.dispose().catch(() => {}) + throw error + } + } + + /** + * Take a fresh session's write ownership when persistence is mounted. + * Nothing is appended here: the constructor seed (which never re-emits + * through `session/event`) is stored by `appendUnstoredSuffix` at the + * publication commit point, so a failed or cancelled validation or setup + * closes an unmaterialized handle and leaves no stored residue — the same + * id can be created again. + * @param session - the unpublished session to store. + * @param signal - optional cancellation forwarded to the backend create. + * @returns the owned handle and stored cursor, or `undefined` without a backend. + */ + private async createStoredSession(session: Session, signal?: AbortSignal): Promise { + const persistence = this.runtime.ctx.get('sessionPersistence') + if (persistence === undefined) return undefined + const handle = await persistence.create(session.header, { + inheritedEventCount: session.inheritedEventCount, + ...signal === undefined ? {} : { signal }, + }) + return { handle, storedCount: 0 } + } + + /** + * Durably store the session events appended since the last stored cursor. + * Pre-publication appends (constructor seed markers, setup-window events + * such as delegation policy records) never re-emit through `session/event`, + * so publication must flush them through the handle before live events + * start routing into it. + * @param stored - the session's owned handle and stored cursor, if any. + * @param session - the unpublished session whose suffix is stored. + */ + private async appendUnstoredSuffix(stored: StoredSession | undefined, session: Session): Promise { + if (stored === undefined) return + const suffix = session.snapshotEvents(SessionLogOffset(stored.storedCount)) + if (suffix.length > 0) await stored.handle.append(suffix) + // Advance by what was stored, not to `session.seq`: an event appended + // during the await must stay unstored for the next flush. + stored.storedCount += suffix.length } /** @@ -672,15 +757,34 @@ export class AgentLoop extends Service implements AgentFactory { ...options.meta === undefined ? {} : { meta: options.meta }, ...options.inheritedEventCount === undefined ? {} : { inheritedEventCount: options.inheritedEventCount }, })) - const published = this.setupAndPublish( - ownerCtx, - options.sessionId, - preparation, - options.agentOptions ?? {}, - options.setup, - options.signal, - 'startup', - ) + const published = (async () => { + let stored: StoredSession | undefined + try { + // raceAbortCall normalizes a pre-aborted or mid-create abort and + // closes a handle that finishes creating after abandonment. + stored = options.signal === undefined + ? await this.createStoredSession(preparation.session) + : await raceAbortCall( + () => this.createStoredSession(preparation.session, options.signal), + options.signal, + options.sessionId, + (abandoned) => { void abandoned?.handle.close().catch(() => {}) }, + ) + } catch (error: unknown) { + preparation[Symbol.dispose]() + throw error + } + return this.setupAndPublish( + ownerCtx, + options.sessionId, + preparation, + options.agentOptions ?? {}, + options.setup, + options.signal, + 'startup', + stored, + ) + })() this.ownership.trackWrapper(published) return published } @@ -694,16 +798,26 @@ export class AgentLoop extends Service implements AgentFactory { setup: AgentSetup | undefined, signal: AbortSignal | undefined, source: SessionStartSource, + stored?: StoredSession, ): Promise { using ownedPreparation = preparation const session = ownedPreparation.session - const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal) + let prepared: PreparedAgent + try { + prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle) + } catch (error: unknown) { + await stored?.handle.close().catch(() => {}) + throw error + } try { const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id) setupCommit?.commit() + await this.appendUnstoredSuffix(stored, session) return prepared.publish(source) } catch (error: unknown) { - await prepared.dispose() + // Rollback swallows a disposal rejection (a failing final handle close): + // the setup failure is the primary error the caller must see. + await prepared.dispose().catch(() => {}) throw error } } @@ -730,9 +844,9 @@ export class AgentLoop extends Service implements AgentFactory { ): Promise { const id = options.resumeSessionId const published = (async () => { - // The load may outlive its owner: race it against caller cancellation, - // owner-fiber unload, and factory teardown so a never-settling backend - // cannot pin the identity. + // The open and read may outlive their owner: race them against caller + // cancellation, owner-fiber unload, and factory teardown so a + // never-settling backend cannot pin the identity. const ownerAbort = new AbortController() const unfollowOwner = ownerCtx.effect(() => () => { ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) @@ -742,20 +856,42 @@ export class AgentLoop extends Service implements AgentFactory { ownerAbort.signal, this.ownership.signal, ]) + let handle: SessionHandle | undefined + let stored: StoredSession | undefined let preparation: SessionPreparation | undefined try { try { - preparation = await raceAbortCall( - () => persistence.prepare(id, fused), + // Taking write ownership FIRST excludes a concurrent resume of the + // same id (in this process, a live agent's handle holds the claim). + handle = await raceAbortCall( + () => persistence.open(id, 'write', { signal: fused }), fused, id, - (abandoned) => { abandoned[Symbol.dispose]() }, + (abandoned) => { void abandoned.close() }, ) + // Semantic crash repair is the agent layer's job: persistence hands + // back the physically valid log; an interrupted final turn receives + // synthetic closers (missing tool errors, step/end, turn/end) that + // are appended through the same handle as an ordinary batch. + const persisted = await handle.read(0, undefined, { signal: fused }) + fused.throwIfAborted() + const closers = interruptedTurnClosers(persisted) + if (closers.length > 0) await handle.append(closers) + preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { + seed: [...persisted, ...closers], + meta: structuredClone(handle.header), + inheritedEventCount: handle.inheritedEventCount, + seedSource: 'persistence', + })) + stored = { handle, storedCount: persisted.length + closers.length } + await this.appendUnstoredSuffix(stored, preparation.session) } finally { await unfollowOwner() } ownerCtx.fiber.assertActive() if (!this.ownership.isActive()) throw new Error('agent loop is not active') + const owned = stored + handle = undefined // ownership passes to setupAndPublish/prepare return await this.setupAndPublish( ownerCtx, id, @@ -764,9 +900,11 @@ export class AgentLoop extends Service implements AgentFactory { options.setup, options.signal, 'resume', + owned, ) } finally { preparation?.[Symbol.dispose]() + await handle?.close().catch(() => {}) } })() this.ownership.trackWrapper(published) diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index bdee0222c0..5f41c9c004 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -130,8 +130,8 @@ describe('AgentLoop initiator scope', () => { await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) - const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) + const a = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const b = await ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const idleA = waitForIdle(ctx, a) const idleB = waitForIdle(ctx, b) send(a, 'a') @@ -154,7 +154,7 @@ describe('AgentLoop initiator scope', () => { textResponse('second done'), ]) const { ctx } = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' }) let signals: AbortSignal[] = [] let preStepSignals: AbortSignal[] = [] const capture = (signal: AbortSignal | undefined): void => { diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 9507844521..2ec5bcf01c 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -31,7 +31,7 @@ describe('Agent', () => { it('idle inject() durably stages context without opening a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })) @@ -43,7 +43,7 @@ describe('Agent', () => { it('inject() preserves an explicitly empty plugin source', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })) @@ -54,7 +54,7 @@ describe('Agent', () => { it('emits exact inserted, claimed, and discarded inbox messages', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) - const agent = ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' }) const inserted: unknown[] = [] const claimed: unknown[] = [] const discarded: unknown[] = [] @@ -92,7 +92,7 @@ describe('Agent', () => { it('idle inject() rejects invalid input before enqueue', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) expect(() => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })) @@ -103,7 +103,7 @@ describe('Agent', () => { it('steer() while idle becomes a woken prompt turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })) await agent.whenIdle() @@ -114,7 +114,7 @@ describe('Agent', () => { it('emits one running and idle transition for one completed turn', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const statuses: string[] = [] ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) @@ -128,7 +128,7 @@ describe('Agent', () => { it('whenIdle() resolves immediately without active work', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await agent.whenIdle() @@ -137,7 +137,7 @@ describe('Agent', () => { it('whenIdle() waits for active work until explicit cancellation', async () => { const ctx = await harness(new MockAdapter(['hang'])) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'queued') let settled = false @@ -153,7 +153,7 @@ describe('Agent', () => { it('contains a throwing status listener on both transitions', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/status', ({ status }) => { throw new Error(`bad ${status} listener`) }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 4c148d663d..6a8e085acc 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -60,7 +60,7 @@ describe('Agent.cancel()', () => { it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // The loop is parked at the idle wait with nothing queued. A cancel here must // NOT arm the marker — otherwise the next legitimate prompt would be dropped. @@ -77,7 +77,7 @@ describe('Agent.cancel()', () => { it('cancel({ keepInbox: true }) does not restore work already claimed by a waking send', async () => { const adapter = new MockAdapter([textResponse('wake reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'preserved' }], @@ -109,7 +109,7 @@ describe('Agent.cancel()', () => { textResponse('wake reply'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('keep-after-abort'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('keep-after-abort'), { provider: 'mock', model: 'mock' }) send(agent, 'active') await new Promise(resolve => setTimeout(resolve, 30)) @@ -131,7 +131,7 @@ describe('Agent.cancel()', () => { it('cancel({ keepInbox: true }) latches a waking send landing in the abort-to-idle window', async () => { const adapter = new MockAdapter(['hang', textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('latch-window'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('latch-window'), { provider: 'mock', model: 'mock' }) send(agent, 'active') await new Promise(resolve => setTimeout(resolve, 30)) @@ -156,7 +156,7 @@ describe('Agent.cancel()', () => { it('cancel() without keepInbox clears a latched wake alongside the inbox', async () => { const adapter = new MockAdapter(['hang', textResponse('C reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('latch-cleared'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('latch-cleared'), { provider: 'mock', model: 'mock' }) send(agent, 'active') await new Promise(resolve => setTimeout(resolve, 30)) @@ -178,7 +178,7 @@ describe('Agent.cancel()', () => { it('removing the latched wake before convergence suppresses the replay', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('removed-latched-wake'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('removed-latched-wake'), { provider: 'mock', model: 'mock' }) send(agent, 'active') await new Promise(resolve => setTimeout(resolve, 30)) @@ -205,7 +205,7 @@ describe('Agent.cancel()', () => { // be latched across the whole window, not just the same-tick case. const adapter = new MockAdapter(['hang-slow', textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('slow-convergence'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('slow-convergence'), { provider: 'mock', model: 'mock' }) send(agent, 'A') await new Promise(resolve => setTimeout(resolve, 30)) @@ -246,7 +246,7 @@ describe('Agent.cancel()', () => { it('cancel after waking send closes its synchronously opened turn without a step', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'drop me first') send(agent, 'drop me second') @@ -294,7 +294,7 @@ describe('Agent.cancel()', () => { it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // This waiter cannot rely on a running→idle transition because cancellation // drops the turn before it runs; the skip path must settle it directly. @@ -313,7 +313,7 @@ describe('Agent.cancel()', () => { it('idle-listener cancellation settles its waiter without cancelling later work', async () => { const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' }) const replacementRegistered = Promise.withResolvers() let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined @@ -352,7 +352,7 @@ describe('Agent.cancel()', () => { textResponse('wake reply'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' }) const replacementRegistered = Promise.withResolvers() let replacementIdle: Promise | undefined @@ -386,7 +386,7 @@ describe('Agent.cancel()', () => { it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -420,7 +420,7 @@ describe('Agent.cancel()', () => { return [{ type: 'text', text: 'ran' }] }, })) - const agent = ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' }) const dispose = ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'assistant/message') { agent.cancel({ kind: 'user' }) @@ -462,7 +462,7 @@ describe('Agent.cancel()', () => { it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { const adapter = new MockAdapter(['hang', textResponse('second reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // First turn hangs; cancel it mid-step. send(agent, 'first') @@ -484,7 +484,7 @@ describe('Agent.cancel()', () => { it('cancel mid-stream finalizes the streamed prefix onto the surface', async () => { const adapter = new MockAdapter(['hang', textResponse('after')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('partial-finalize'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('partial-finalize'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -523,7 +523,7 @@ describe('Agent.cancel()', () => { ], }]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('reasoning-finalize'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('reasoning-finalize'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -549,7 +549,7 @@ describe('Agent.cancel()', () => { ], }]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('tool-call-drop'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('tool-call-drop'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -570,7 +570,7 @@ describe('Agent.cancel()', () => { { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } }, ]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('recovery-cancel'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('recovery-cancel'), { provider: 'mock', model: 'mock' }) // Cancellation lands while agent/request-error is in flight — the window // dsh-llm-retry opens when its backoff waits after appending llm/retry. ctx.on('agent/request-error', async ({ agent: subject }) => { @@ -597,7 +597,7 @@ describe('Agent.cancel()', () => { textResponse('recovered'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('retry-discards-content'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('retry-discards-content'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request-error', async () => ({ kind: 'retry' as const })) send(agent, 'go') @@ -626,7 +626,7 @@ describe('Agent.cancel()', () => { ], }]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('nothing-to-finalize'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('nothing-to-finalize'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -639,7 +639,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A step/start session-event listener fires AFTER step/start is appended // (and after the pre-step extension point), so cancelling there lands in the SECOND @@ -705,7 +705,7 @@ describe('Agent.cancel()', () => { it('cancel during the stopping window ends the turn aborted and runs no further step', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 const reasons: TurnEndReason[] = [] @@ -734,7 +734,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // `agent/status` is synchronous, so cancellation can land before the // durable turn-start commit and must drop the reserved work. @@ -757,7 +757,7 @@ describe('Agent.cancel()', () => { it('a running-listener cancellation replays replacement work at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let replaced = false const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { @@ -788,7 +788,7 @@ describe('Agent.cancel()', () => { it('a prompt queued during pre-step cancellation replays at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'A') const idle = agent.whenIdle() @@ -811,7 +811,7 @@ describe('Agent.cancel()', () => { it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -843,7 +843,7 @@ describe('Agent.cancel()', () => { textResponse('wake reply'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' }) send(agent, 'original') await expect.poll(() => adapter.requests.length).toBe(1) @@ -886,7 +886,7 @@ describe('Agent.cancel()', () => { it('keeps the first typed cause for an active turn', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' }) const supplied: { kind: 'parent' | 'user' } = { kind: 'parent' } send(agent, 'go') @@ -934,7 +934,7 @@ describe('Agent.cancel()', () => { ? [toolCallResponse('blocked-tool', 'blocked', {})] : [textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId(`cooperative-${stage}`), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId(`cooperative-${stage}`), { provider: 'mock', model: 'mock' }) const started = Promise.withResolvers() const blockUntilAbort = async (signal: AbortSignal): Promise => { started.resolve(undefined) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index a19b5da3f5..038c6e97d3 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -5,10 +5,12 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' @@ -37,6 +39,16 @@ async function makeCoreContext(): Promise { return ctx } +/** Read one stored session's physical validated log through a read handle. */ +async function readStoredEvents(ctx: Context, sessionId: SessionId): Promise { + const handle = await ctx.sessionPersistence.open(sessionId, 'read') + try { + return await handle.read() + } finally { + await handle.close() + } +} + describe('config-driven session id', () => { it('applies launcher identities by configured id without changing unmatched entries', async () => { const ctx = await makeCoreContext() @@ -51,9 +63,11 @@ describe('config-driven session id', () => { { id: 'unchanged', sessionId: SessionId('config-unchanged'), model: 'mock' }, ], }) + await expect.poll(() => ctx.agents.get(SessionId('launcher-fresh'))).toBeDefined() expect(ctx.agents.get(SessionId('launcher-fresh'))?.session.id).toBe('launcher-fresh') expect(ctx.agents.get(SessionId('launcher-resumed'))).toBeUndefined() expect(ctx.agents.get(SessionId('config-resumed'))).toBeUndefined() + await expect.poll(() => ctx.agents.get(SessionId('config-unchanged'))).toBeDefined() expect(ctx.agents.get(SessionId('config-unchanged'))?.session.id).toBe('config-unchanged') await ctx.fiber.dispose() }) @@ -72,6 +86,7 @@ describe('config-driven session id', () => { await exact.plugin(AgentLoop, { agents: [{ id: 'main', sessionId: SessionId('config-exact'), model: 'mock' }], }) + await expect.poll(() => exact.agents.get(SessionId('config-exact'))).toBeDefined() expect(exact.agents.get(SessionId('config-exact'))?.session.id).toBe('config-exact') await exact.fiber.dispose() @@ -128,8 +143,8 @@ describe('config-driven session id', () => { second.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) await waitForIdle(ctx, second) await ctx.sessions.flush(second.session) - const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) - expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + const stored = await readStoredEvents(ctx, SessionId('config-exact-reload')) + expect(stored.filter(event => event.type === 'turn/start')).toHaveLength(2) await secondLoop.dispose() await ctx.fiber.dispose() @@ -157,7 +172,7 @@ describe('config-driven session id', () => { first.followup(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'user' } })) await idle await ctx.sessions.flush(first.session) - expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)) + expect(JSON.stringify(await readStoredEvents(ctx, sessionId))) .toContain('persist before replacement') const firstDisposal = firstLoop.dispose() @@ -201,7 +216,7 @@ describe('config-driven session id', () => { }) first.inject(createUserMessage({ content: [{ type: 'text', text: 'persist before cancellation' }], source: { kind: 'plugin', plugin: 'test' } })) await ctx.sessions.flush(first.session) - expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)) + expect(JSON.stringify(await readStoredEvents(ctx, sessionId))) .toContain('persist before cancellation') const firstDisposal = firstLoop.dispose() @@ -217,7 +232,28 @@ describe('config-driven session id', () => { await ctx.fiber.dispose() }) - it('contains an exact-id persistence lookup failure', async () => { + it('contains a configured fresh-create failure without persistence', async () => { + const ctx = await makeCoreContext() + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + // A relative cwd fails session preparation inside the plain create path + // (no backend mounted): the failure is reported, not crashed on. + await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', model: 'mock', cwd: 'relative' }], + }) + + await expect.poll(() => failures.length).toBe(1) + expect(failures[0]).toBeInstanceOf(Error) + expect((failures[0] as Error).message).toMatch(/absolute path/) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven restore')) + expect(ctx.agents.list()).toEqual([]) + warn.mockRestore() + await ctx.fiber.dispose() + }) + + it('contains an exact-id persistence open failure', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-')) dirs.push(root) const ctx = await makeCoreContext() @@ -231,7 +267,7 @@ describe('config-driven session id', () => { ctx.on('agent-loop/config-start-failed', ({ sessionId, error }) => { failures.push({ sessionId, error }) }) - vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) + vi.spyOn(ctx.sessionPersistence, 'open').mockRejectedValue(failure) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await ctx.plugin(AgentLoop, { @@ -269,7 +305,7 @@ describe('config-driven session id', () => { // oxlint-disable-next-line typescript/prefer-promise-reject-errors ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) - vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) + vi.spyOn(ctx.sessionPersistence, 'open').mockRejectedValue(unrenderable) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await ctx.plugin(AgentLoop, { @@ -290,15 +326,15 @@ describe('config-driven session id', () => { }) it.each(['resolve', 'reject'] as const)( - 'abandons an exact-id preparation that later %s when AgentLoop disposal starts', + 'abandons an exact-id open that later %ss when AgentLoop disposal starts', async (outcome) => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) dirs.push(root) const ctx = await makeCoreContext() await ctx.plugin(JsonlSessionPersistence, { root }) - const preparing = Promise.withResolvers() - vi.spyOn(ctx.sessionPersistence, 'prepare').mockReturnValue(preparing.promise) - const released = vi.fn() + const opening = Promise.withResolvers() + vi.spyOn(ctx.sessionPersistence, 'open').mockReturnValue(opening.promise) + const closed = vi.fn(async () => {}) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const failures: unknown[] = [] ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) @@ -308,15 +344,13 @@ describe('config-driven session id', () => { }) await loop.dispose() if (outcome === 'resolve') { - preparing.resolve(SessionPreparation.create( - ctx.sessions.prepare(SessionId('config-exact-dispose')), - { release: released }, - )) + opening.resolve({ close: closed } as unknown as SessionHandle) } else { - preparing.reject(new Error('startup cancelled by teardown')) + opening.reject(new Error('startup cancelled by teardown')) } await Promise.resolve() - if (outcome === 'resolve') await expect.poll(() => released).toHaveBeenCalledOnce() + // The abandoned handle is closed; the rejected open is silently released. + if (outcome === 'resolve') await expect.poll(() => closed).toHaveBeenCalledOnce() expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined() expect(failures).toEqual([]) expect(warn).not.toHaveBeenCalled() @@ -359,19 +393,22 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) await ctx1.plugin(JsonlSessionPersistence, { root }) + await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) + await expect.poll(() => ctx1.agents.list().length).toBe(1) const a1 = ctx1.agents.list()[0] as Agent expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) + await ctx1.sessions.flush(a1.session) + expect(JSON.stringify(await readStoredEvents(ctx1, a1.session.id))).toContain('cfg') await ctx1.fiber.dispose() // Run 2 over the SAME root: a fresh id means no on-disk collision (a fixed - // ${id}-session would crash here with "already has a persisted log"). + // ${id}-session would crash here with "already exists"). const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) @@ -379,9 +416,10 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) await ctx2.plugin(JsonlSessionPersistence, { root }) + await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) + await expect.poll(() => ctx2.agents.list().length).toBe(1) const a2 = ctx2.agents.list()[0] as Agent expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) @@ -404,12 +442,13 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(JsonlSessionPersistence, { root }) + await ctx1.plugin(AgentLoop, { agents: [] }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent - a1.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) - await waitForIdle(ctx1, a1) + const h1 = await ctx1.agents.create({ sessionId: SessionId('sticky-1') }) + h1.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) + await waitForIdle(ctx1, h1.agent) + await h1.dispose() await ctx1.fiber.dispose() // Resume waits for the injected persistence service, so poll until the @@ -455,9 +494,10 @@ describe('config-driven session id', () => { // The deferred resume fails (no such session on disk). It must be contained: // a warning is logged, no agent is registered, and the app stays up. - await new Promise(r => setTimeout(r, 200)) + await expect.poll(() => warn.mock.calls.some(call => + typeof call[0] === 'string' && call[0].includes('config-driven resume of "does-not-exist" failed'), + )).toBe(true) expect(ctx.agents.list()).toEqual([]) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed')) warn.mockRestore() await ctx.fiber.dispose() }) @@ -471,12 +511,12 @@ describe('startup reporting after factory teardown', () => { await ctx.plugin(JsonlSessionPersistence, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')])) - // A restore lookup that hangs until after the loop is gone: the eventual + // A restore open that hangs until after the loop is gone: the eventual // failure lands with ownership inactive and must be silently dropped. - const gate = Promise.withResolvers() - // The teardown path may drop the pending lookup without awaiting it. + const gate = Promise.withResolvers() + // The teardown path may drop the pending open without awaiting it. gate.promise.catch(() => undefined) - vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise) + vi.spyOn(ctx.sessionPersistence, 'open').mockReturnValue(gate.promise) const failures: unknown[] = [] ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ab338d3b01..950133c56b 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -68,7 +68,7 @@ describe('assistant replay provider and model fields', () => { response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState } const adapter = new MockAdapter([response]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('replay-state'), { provider: 'mock', model: 'next-model' }) + const agent = await ctx.agentLoop.create(SessionId('replay-state'), { provider: 'mock', model: 'next-model' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -90,7 +90,7 @@ describe('abort during tool execution ends the turn', () => { textResponse('after wake'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', @@ -143,7 +143,7 @@ describe('abort during tool execution ends the turn', () => { { type: 'finish', reason: { kind: 'tool-calls' } }, ] satisfies StreamChunk[]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ name: 'first', description: '', @@ -192,7 +192,7 @@ describe('abort during tool execution ends the turn', () => { it('closes an empty admitted batch as a turn without a step', async () => { const adapter = new MockAdapter([textResponse('must not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject !== agent) return next() return Promise.resolve({ kind: 'enter', messages: [] }) @@ -213,8 +213,8 @@ describe('abort during tool execution ends the turn', () => { const ctx = await harness(adapter) const started = Promise.withResolvers() let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.tools.register(defineContentToolFixture({ name: 'waiter', @@ -269,7 +269,7 @@ describe('abort during tool execution ends the turn', () => { textResponse('later turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', @@ -323,7 +323,7 @@ describe('steering from late extension points is never stranded', () => { textResponse('continued because of steering'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-stopping', () => { @@ -347,7 +347,7 @@ describe('plugin exceptions are contained', () => { it('a throwing agent/turn-stopping listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/turn-stopping', async () => { @@ -378,8 +378,8 @@ describe('disposal leaves the two-state status contract balanced', () => { const ctx = await harness(adapter) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const statuses: string[] = [] @@ -409,8 +409,8 @@ describe('disposal leaves the two-state status contract balanced', () => { const ctx = await harness(adapter) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.on('agent/status', ({ status }) => { @@ -441,7 +441,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => { const adapter = new MockAdapter([textResponse('never')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model + const agent = await ctx.agentLoop.create(SessionId('a1'), {}) // no model send(agent, 'go') await waitForIdle(ctx, agent) @@ -457,7 +457,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('the agent/request waterfall can supply the model for a model-less agent', async () => { const adapter = new MockAdapter([textResponse('routed')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides + const agent = await ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides ctx.on('agent/request', async (_payload, next) => { return { ...await next(), provider: 'mock', model: 'mock' } @@ -472,7 +472,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('durable inbox splices carry exact messages and the claimed steer preserves its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ name: 'noop', description: '', @@ -523,7 +523,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { createUserMessage({ content: [{ type: 'text', text: 'first steer' }], source: { kind: 'user' } }), createUserMessage({ content: [{ type: 'text', text: 'second steer' }], source: { kind: 'user' } }), ] - const agent = ctx.agentLoop.create(SessionId('claim-order'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('claim-order'), { provider: 'mock', model: 'mock' }) let execution = 0 ctx.tools.register(defineContentToolFixture({ name: 'steer_next', @@ -567,7 +567,7 @@ describe('turn numbering continues across seeded sessions', () => { it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -634,7 +634,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] const errors: unknown[] = [] @@ -665,7 +665,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([abortedStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-finish-aborted'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -683,7 +683,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -699,7 +699,7 @@ describe('step boundary publication order', () => { it('the step/start event is in session.snapshotEvents() when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] ctx.on('session/event', (subject, event) => { @@ -754,7 +754,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/start observer cannot change a successful turn', async () => { const adapter = new MockAdapter([textResponse('request completed')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-stepstart'), { provider: 'mock', model: 'mock' }) // Session owns post-commit containment. The loop sees a successful append, // runs the request, and balances the ordinary step and turn boundaries. @@ -785,7 +785,7 @@ describe('turn and step boundary recovery', () => { it('a pre-commit turn/start rejection leaves no durable turn state', async () => { const adapter = new MockAdapter([]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -813,7 +813,7 @@ describe('turn and step boundary recovery', () => { it('a pre-commit step/start validation failure does not invent a step boundary', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-stepstart-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -842,7 +842,7 @@ describe('turn and step boundary recovery', () => { it('a step/end validation failure surfaces the resulting open-step invariant', async () => { const adapter = new MockAdapter([textResponse('completed before close validation')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -879,7 +879,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } }) @@ -915,8 +915,8 @@ describe('turn and step boundary recovery', () => { const adapter = new MockAdapter(['hang']) const ctx = await balancedHarness(adapter) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -941,8 +941,8 @@ describe('turn and step boundary recovery', () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) let threw = false @@ -972,7 +972,7 @@ describe('turn and step boundary recovery', () => { it('a throwing turn/start observer cannot starve the loop or later turns', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-preturn'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-preturn'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_session, event) => { @@ -1005,7 +1005,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/end observer cannot rewrite the turn outcome', async () => { const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-stepend-throw'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1046,7 +1046,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1080,7 +1080,7 @@ describe('turn and step boundary recovery', () => { // boundary stays authoritative and the loop continues normally. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-turnendappend'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1126,7 +1126,7 @@ describe('tool result call identity', () => { return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) }, { prepend: true }) - const agent = ctx.agentLoop.create(SessionId('a-callid'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-callid'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -1176,8 +1176,8 @@ describe('disposal and cancellation during pre-step assembly', () => { }) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1226,8 +1226,8 @@ describe('disposal and cancellation during pre-step assembly', () => { }) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1277,8 +1277,8 @@ describe('disposal and cancellation during pre-step assembly', () => { }) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1324,8 +1324,8 @@ describe('disposal and cancellation during pre-step assembly', () => { }) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1373,8 +1373,8 @@ describe('disposal and cancellation during pre-step assembly', () => { }) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 6c9ecc2020..f8ea231143 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -63,7 +63,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -96,7 +96,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: 'ran with empty args' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -109,7 +109,7 @@ describe('thrown-value propagation', () => { it('preserves non-Error throws from pre-commit dispatch validation', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('internal/dispatch', (_mode, name, args) => { @@ -142,7 +142,7 @@ describe('thrown-value propagation', () => { it('preserves non-Error throws from the agent/request waterfall', async () => { const adapter = new MockAdapter([textResponse('irrelevant')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_payload, next) => { @@ -166,7 +166,7 @@ describe('durable error rendering', () => { it('renders a coded error thrown from a plugin', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_payload, next) => { @@ -196,8 +196,8 @@ describe('disposed vs aborted branching', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -223,7 +223,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note, textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ name: 'boom', description: 'always fails', @@ -251,7 +251,7 @@ describe('request-error action edges', () => { textResponse('never used'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request-error', async ({ agent: subject }) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } @@ -272,7 +272,7 @@ describe('request-error action edges', () => { () => { throw new LlmError('busy', 'RATE_LIMIT') }, ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request-error', async ({ agent: subject, signal }, next) => { await next() subject.cancel({ kind: 'user' }) @@ -293,7 +293,7 @@ describe('stream failure edges', () => { it('rethrows a mid-stream throw that carries no adapter failure facts', async () => { const adapter = new MockAdapter([textResponse('will be vetoed')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('stream-no-facts'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('stream-no-facts'), { provider: 'mock', model: 'mock' }) let recoveries = 0 ctx.on('agent/request-error', async () => { recoveries += 1 }) // A pre-commit chunk veto throws INSIDE the stream-consumption try, but it @@ -322,7 +322,7 @@ describe('post-turn continuation edges', () => { it('whenIdle resolves for a waiter whose awaited run fails', async () => { const adapter = new MockAdapter([textResponse('unused')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('whenidle-reject'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('whenidle-reject'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -343,7 +343,7 @@ describe('persistent step-close rejection', () => { it('still publishes the terminal status when both step-close attempts are vetoed', async () => { const adapter = new MockAdapter([textResponse('will not close')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('stepend-double-veto'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('stepend-double-veto'), { provider: 'mock', model: 'mock' }) // Persistently reject step/end: the catch's own close attempt fails too, // and the contained failure must not strand status at running. ctx.on('internal/dispatch', (_mode, name, args) => { @@ -370,7 +370,7 @@ describe('tool result meta persistence', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('tool-meta'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('tool-meta'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'meta-tool', description: 'carries presentation meta', @@ -397,7 +397,7 @@ describe('turn close failure containment', () => { it('a rejected turn/end append is contained: warn + agent/error, no retry', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('turnend-veto'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('turnend-veto'), { provider: 'mock', model: 'mock' }) let vetoed = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -427,7 +427,7 @@ describe('recovery without a retry action', () => { () => { throw new LlmError('down', 'SERVICE_UNAVAILABLE') }, ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('recovery-no-retry'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('recovery-no-retry'), { provider: 'mock', model: 'mock' }) let recoveries = 0 ctx.on('agent/request-error', async () => { recoveries += 1 }) @@ -456,7 +456,7 @@ describe('unrenderable failure settlement', () => { }, ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('unrenderable'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('unrenderable'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await agent.whenIdle() @@ -474,7 +474,7 @@ describe('unrenderable failure settlement', () => { describe('driver bookkeeping edges', () => { it('rejects a direct turn invocation without a driver reservation', async () => { const ctx = await harness(new MockAdapter([])) - const agent = ctx.agentLoop.create(SessionId('turn-without-reservation'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('turn-without-reservation'), { provider: 'mock', model: 'mock' }) await expect((agent as unknown as { turn(): Promise }).turn()) .rejects.toThrow('turn without driver reservation') @@ -484,7 +484,7 @@ describe('driver bookkeeping edges', () => { it('closes an entered turn as blocked when its next step is rejected', async () => { const adapter = new MockAdapter([textResponse('first step')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' }) let proposals = 0 ctx.on('agent/pre-step', async (_payload, next) => { proposals += 1 @@ -518,7 +518,7 @@ describe('driver bookkeeping edges', () => { ] satisfies StreamChunk[], ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('finish-after-close'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('finish-after-close'), { provider: 'mock', model: 'mock' }) void LlmError send(agent, 'go') diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index d836d6e8f9..8ef5929522 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -64,7 +64,7 @@ describe('agent/pre-step', () => { it('enter (default via next) records the user/message unchanged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] ctx.on('agent/pre-step', async ({ messages }, next) => { @@ -92,7 +92,7 @@ describe('agent/pre-step', () => { parameters: { text: { type: 'string', required: true } }, execute: async ({ text }) => [{ type: 'text', text }], })) - const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) const seen: Array<{ turn: number; step: number; messages: number }> = [] ctx.on('agent/pre-step', async ({ messages, turn, step }, next) => { seen.push({ turn, step, messages: messages.length }) @@ -111,7 +111,7 @@ describe('agent/pre-step', () => { it('publishes frozen input without replacing its identity', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() const observed: UserMessage[] = [] @@ -161,7 +161,7 @@ describe('agent/pre-step', () => { it('enter with content rewrites the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async ({ messages }): Promise => ({ @@ -182,7 +182,7 @@ describe('agent/pre-step', () => { it('enter with additional messages records separately sourced context in the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async ({ messages }): Promise => ({ @@ -209,7 +209,7 @@ describe('agent/pre-step', () => { it('does not open another step when a completed turn rewrites pending input to empty', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), { + const agent = await ctx.agentLoop.create(SessionId('empty-completed-continuation'), { provider: 'mock', model: 'mock', }) @@ -236,7 +236,7 @@ describe('agent/pre-step', () => { it('reject closes the claimed prompt turn without a step or model call', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async (): Promise => ({ kind: 'reject' })) @@ -259,7 +259,7 @@ describe('agent/pre-step', () => { it('stages inject and steer during pre-step for the entered turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() let claimed: UserMessage[] = [] @@ -320,7 +320,7 @@ describe('agent/pre-step', () => { it('preserves input staged after the blocked batch was claimed', async () => { const adapter = new MockAdapter([textResponse('retried')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() const disposeBlock = ctx.on('agent/pre-step', async () => { @@ -370,7 +370,7 @@ describe('agent/pre-step', () => { textResponse('wake reply'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-order'), { + const agent = await ctx.agentLoop.create(SessionId('rejected-pre-step-order'), { provider: 'mock', model: 'mock', }) @@ -425,7 +425,7 @@ describe('agent/pre-step', () => { it('preserves context-only injection staged after pre-step began', async () => { const adapter = new MockAdapter([textResponse('continued')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() const disposeBlock = ctx.on('agent/pre-step', async () => { @@ -460,7 +460,7 @@ describe('agent/pre-step', () => { it('leaves inbox state unchanged when its durable append fails', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), { + const agent = await ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), { provider: 'mock', model: 'mock', }) @@ -482,7 +482,7 @@ describe('agent/pre-step', () => { textResponse('wake reply'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) @@ -518,7 +518,7 @@ describe('agent/pre-step', () => { it('a throwing pre-step listener reports the driver error and retains adjacent work', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('agent/pre-step', async ({ messages }) => { @@ -563,7 +563,7 @@ describe('agent/session-start', () => { const sources: SessionStartSource[] = [] ctx.on('agent/session-start', ({ source }) => void sources.push(source)) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires synchronously at create, before any turn expect(sources).toEqual(['startup']) expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) @@ -582,7 +582,7 @@ describe('agent/session-start', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })) }) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -600,7 +600,7 @@ describe('agent/session-start', () => { ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') }) // create must not throw — the listener error is contained/logged - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) expect(agent.id).toBe(SessionId('a1')) // and the agent still runs @@ -627,7 +627,7 @@ describe('tool additionalContexts buffering across a step', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Each call attaches one context naming itself. ctx.on('tools/post-execute', async (exec, _result): Promise => @@ -674,7 +674,7 @@ describe('tool additionalContexts buffering across a step', () => { return [{ type: 'text', text: 'outer result' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -700,7 +700,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' } @@ -764,7 +764,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'please echo hi') await waitForIdle(ctx, agent) @@ -787,7 +787,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) - const agent = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -806,7 +806,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se await fiber.dispose() // After disposal, a destructive prompt is NOT blocked (the listener is gone). - const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' }) send(agent, 'run rm -rf /') await waitForIdle(ctx, agent) // the prompt ran (not rejected) — proving the pre-step listener was disposed diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index d3235a3970..b8e155e446 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -56,10 +56,10 @@ describe('agent loop', () => { 'rejects invalid AgentOptions.maxTokens %s before publication', async (maxTokens) => { const ctx = await harness(new MockAdapter([])) - expect(() => ctx.agentLoop.create( + await expect(ctx.agentLoop.create( SessionId('invalid-max-tokens'), { provider: 'mock', model: 'mock', maxTokens }, - )).toThrow('agent maxTokens must be a positive safe integer') + )).rejects.toThrow('agent maxTokens must be a positive safe integer') expect(ctx.agents.list()).toEqual([]) expect(ctx.sessions.list()).toEqual([]) }, @@ -68,7 +68,7 @@ describe('agent loop', () => { it('seeds a valid AgentOptions.maxTokens into the first model request', async () => { const adapter = new MockAdapter([textResponse('bounded')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create( + const agent = await ctx.agentLoop.create( SessionId('valid-max-tokens'), { provider: 'mock', model: 'mock', maxTokens: 256 }, ) @@ -86,7 +86,7 @@ describe('agent loop', () => { defaultEffort: effort, }) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create( + const agent = await ctx.agentLoop.create( SessionId('configured-reasoning-effort'), { provider: 'mock', model: 'mock', reasoningEffort: effort }, ) @@ -110,7 +110,7 @@ describe('agent loop', () => { it('cancels queued wakeup work together with an active maintenance task', async () => { const adapter = new MockAdapter([textResponse('park reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), { + const agent = await ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), { provider: 'mock', model: 'mock', }) @@ -138,10 +138,31 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) }) + it('rejects a second maintenance task while one is active', async () => { + const ctx = await harness(new MockAdapter([])) + const agent = await ctx.agentLoop.create(SessionId('maintenance-busy'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + expect(() => agent.runMaintenance(async () => {})).toThrow(/already has active work/) + + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + }) + it('replays a wake latched behind maintenance at convergence', async () => { const adapter = new MockAdapter([textResponse('wake reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('maintenance-wake-replay'), { + const agent = await ctx.agentLoop.create(SessionId('maintenance-wake-replay'), { provider: 'mock', model: 'mock', }) @@ -165,7 +186,7 @@ describe('agent loop', () => { it('suppresses the replay when a latched maintenance wake is removed', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('maintenance-wake-removed'), { + const agent = await ctx.agentLoop.create(SessionId('maintenance-wake-removed'), { provider: 'mock', model: 'mock', }) @@ -192,7 +213,7 @@ describe('agent loop', () => { it('runs a simple turn: queued message → model → idle, with ordered events', async () => { const adapter = new MockAdapter([textResponse('hello there')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // All boundaries — turn and step — are durable session events on the // session/event feed (no agent/* mirror). Record them in fire order to @@ -239,7 +260,7 @@ describe('agent loop', () => { return [{ type: 'text', text: `echo: ${args.text}` }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -276,7 +297,7 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -310,7 +331,7 @@ describe('agent loop', () => { ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -358,7 +379,7 @@ describe('agent loop', () => { const config = await next() return { ...config, provider: 'mock', model: 'mock' } }) - const agent = ctx.agentLoop.create(SessionId('a-late-model'), {}) + const agent = await ctx.agentLoop.create(SessionId('a-late-model'), {}) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -375,7 +396,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.on('system-prompt/assemble', async () => ({ sections: [], contexts: [], tools: [], variables: {} })) - const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -395,7 +416,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) let mode = 'read-only' const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` }) - const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' }) const contextEvents = () => agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -445,7 +466,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' }) - const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-runtime-context-compacted'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -480,7 +501,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' }) - const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted-clear'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-runtime-context-compacted-clear'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -512,7 +533,7 @@ describe('agent loop', () => { it('does not clear runtime context after an unrelated replacement', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-runtime-context-unrelated-compaction'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-runtime-context-unrelated-compaction'), { provider: 'mock', model: 'mock' }) const original = agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'old context' }], source: { kind: 'plugin', plugin: 'test-context' }, @@ -536,7 +557,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' }) - const agent = ctx.agentLoop.create(SessionId('a-runtime-context-malformed'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a-runtime-context-malformed'), { provider: 'mock', model: 'mock' }) agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'broken' }, { type: 'text', text: 'snapshot' }], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, @@ -560,7 +581,7 @@ describe('agent loop', () => { it('records raw chunks for replay as assistant/chunk session events', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -584,7 +605,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ name: 'slow', description: '', @@ -618,7 +639,7 @@ describe('agent loop', () => { it('starts idle steering synchronously and enters later steering at the next step', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(ctx, agent) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } })) @@ -643,7 +664,7 @@ describe('agent loop', () => { it('stops after a throwing pre-step listener and retains later steering until a wakeup', async () => { const adapter = new MockAdapter([textResponse('recovered')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) let fail = true ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject !== agent || !fail) return next() @@ -671,7 +692,7 @@ describe('agent loop', () => { it('inject() while idle durably stages context without opening a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } })) expect(agent.status).toBe('idle') @@ -700,7 +721,7 @@ describe('agent loop', () => { it('inject() persists structured context content verbatim with durable source', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' }) const text = 'Additional instructions from: pkg/AGENTS.md' agent.inject(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'agent-instructions' } })) send(agent, 'go') @@ -720,7 +741,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let visibleDuringTool = false ctx.tools.register(defineContentToolFixture({ name: 'noticer', @@ -775,7 +796,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ name: 'invalid-injector', description: 'attempts an invalid context injection', @@ -801,7 +822,7 @@ describe('agent loop', () => { textResponse('step 3'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -829,7 +850,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -845,7 +866,7 @@ describe('agent loop', () => { textResponse('next turn reply'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineContentToolFixture({ name: 'finalize', description: '', @@ -875,7 +896,7 @@ describe('agent loop', () => { it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request', async (_payload, next) => { const config = await next() @@ -905,7 +926,7 @@ describe('agent loop', () => { name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const fires: { turn: number; step: number; signal: AbortSignal }[] = [] ctx.on('agent/pre-step', ({ agent: subject, turn, step, signal }, next) => { @@ -926,7 +947,7 @@ describe('agent loop', () => { it('agent/pre-step fires before its step boundary opens and before the request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let boundaryOpen = true ctx.on('agent/pre-step', ({ agent: subject }, next) => { @@ -944,7 +965,7 @@ describe('agent loop', () => { it('a throwing agent/pre-step listener fails the proposal, not the loop', async () => { const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let throwOnce = true ctx.on('agent/pre-step', (_payload, next) => { @@ -976,7 +997,7 @@ describe('agent loop', () => { it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -996,7 +1017,7 @@ describe('agent loop', () => { // turn stops by default and ends max-tokens, not completed. const adapter = new MockAdapter([maxTokensResponse('truncat')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -1019,7 +1040,7 @@ describe('agent loop', () => { textResponse('second half'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -1069,7 +1090,7 @@ describe('agent loop', () => { // stop. The per-turn reason must be independent — turn 2 ends completed. const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -1102,7 +1123,7 @@ describe('agent loop', () => { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -1152,7 +1173,7 @@ describe('agent loop', () => { parameters: { text: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -1186,7 +1207,7 @@ describe('agent loop', () => { // a durable successful-call boundary for replay consumers. const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -1230,7 +1251,7 @@ describe('agent loop', () => { }, ], textResponse('continued')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -1293,7 +1314,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false // Post-commit session observers cannot control the loop. The tool call still // drives the second model request, and the turn completes normally. @@ -1312,7 +1333,7 @@ describe('agent loop', () => { it('contains a reentrant send attempted during durable inbox publication', async () => { const adapter = new MockAdapter([textResponse('first')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let nested = false ctx.on('session/event', (session, event) => { @@ -1337,7 +1358,7 @@ describe('agent loop', () => { it('preserves independent turn sources across an adjacent microtask send', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(ctx, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'user message' }], source: { kind: 'user' } })) @@ -1359,7 +1380,7 @@ describe('agent loop', () => { it('keeps a session-listener send after dequeue in the following turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const turns: number[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) @@ -1395,7 +1416,7 @@ describe('agent loop', () => { textResponse('second'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agentRef.current = agent const idle = waitForIdle(ctx, agent) @@ -1415,7 +1436,7 @@ describe('agent loop', () => { it('records normalized model errors on the turn boundary', async () => { const adapter = new MockAdapter([]) // script exhausted → throws const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const errors: unknown[] = [] const reasons: TurnEndReason[] = [] @@ -1444,8 +1465,8 @@ describe('agent loop', () => { const ctx = await harness(adapter) let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + agent = await inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) expect(ctx.agents.get(SessionId('scoped'))).toBe(agent) @@ -1522,7 +1543,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'run') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 1dc37ffc14..68e170f27e 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -113,7 +113,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) const { seen: trace } = recordStatus(ctx, agent) const idle = nextIdle(ctx, agent) // Send all in one synchronous tick: they queue before the loop wakes. @@ -141,7 +141,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) @@ -166,7 +166,7 @@ describe('agent loop scheduling properties', () => { async (steps) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) // Capture before each send; the last waiter covers the final turn, and // awaiting an already-settled earlier waiter is harmless. let lastIdle: Promise | undefined diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index e8f0bab180..960dccf608 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -73,7 +73,7 @@ function waitForIdle(context: Context, agent: Agent): Promise { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => { it('every request after the first hits the provider prefix cache', async () => { ctx = await loopHarness() - const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const agent = await ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) // Turn 1: forces a tool call → at least two steps (two model requests). agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }], source: { kind: 'user' } })) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 17af91a06e..616a817082 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -33,7 +33,7 @@ describe('agent/request-error', () => { it('does not offer middleware failures to request recovery', async () => { const adapter = new MockAdapter([textResponse('unused')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('request-error-narrow'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('request-error-narrow'), { provider: 'mock', model: 'mock' }) let recoveries = 0 ctx.on('agent/request', () => { throw new LlmError('middleware failed', 'MIDDLEWARE') @@ -56,7 +56,7 @@ describe('agent/request-error', () => { textResponse('ok'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('request-error-retry'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('request-error-retry'), { provider: 'mock', model: 'mock' }) const seen: { turn: number step: number @@ -105,7 +105,7 @@ describe('agent/request-error', () => { it('lets cancellation win over a retry action', async () => { const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request-error', async ({ agent: subject }) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } @@ -125,7 +125,7 @@ describe('agent/request-error', () => { it('does not retry when the recovery listener fails before returning its action', async () => { const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('request-error-recovery-failed'), { + const agent = await ctx.agentLoop.create(SessionId('request-error-recovery-failed'), { provider: 'mock', model: 'mock', }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index d6332965ec..93459584a7 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -81,7 +81,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -102,7 +102,7 @@ describe('request stability across the loop', () => { it('a later turn append-extends the previous turn (one conversation, one log)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -118,7 +118,7 @@ describe('request stability across the loop', () => { it('starts a new request series only when the admitted step explicitly asks for one', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async ({ turn }, next) => { const decision = await next() return decision.kind === 'enter' && turn === 2 @@ -139,7 +139,7 @@ describe('request stability across the loop', () => { it('retains the explicit series boundary when that request also changes its header', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async ({ turn }, next) => { const decision = await next() return decision.kind === 'enter' && turn === 2 @@ -168,7 +168,7 @@ describe('request stability across the loop', () => { it('keeps the series declaration when an outer listener rebuilds the enter decision', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Context-appending wrapper in the tool-cordis / session-reference shape: // it rebuilds the downstream decision, so it must spread it to keep fields // it does not own — a bare `{ kind: 'enter', messages }` drops the series. @@ -208,7 +208,7 @@ describe('request stability across the loop', () => { } const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config @@ -259,7 +259,7 @@ describe('request stability across the loop', () => { it('logs an adapter-owned maxTokens default before dispatch', async () => { const adapter = new MockAdapter([textResponse('bounded')], undefined, 256_000) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('adapter-max-tokens'), { + const agent = await ctx.agentLoop.create(SessionId('adapter-max-tokens'), { provider: 'mock', model: 'mock', }) @@ -281,7 +281,7 @@ describe('request stability across the loop', () => { ['deepseek', deepseek], ['other', other], ]) - const agent = ctx.agentLoop.create(SessionId('adapter-max-tokens-switch'), { + const agent = await ctx.agentLoop.create(SessionId('adapter-max-tokens-switch'), { provider: 'deepseek', model: 'deepseek-model', }) @@ -314,7 +314,7 @@ describe('request stability across the loop', () => { ['deepseek', deepseek], ['other', other], ]) - const agent = ctx.agentLoop.create(SessionId('explicit-max-tokens-switch'), { + const agent = await ctx.agentLoop.create(SessionId('explicit-max-tokens-switch'), { provider: 'deepseek', model: 'deepseek-model', maxTokens: 4_096, @@ -369,7 +369,7 @@ describe('request stability across the loop', () => { defaultEffort: ReasoningEffortId('max'), }) const disposeFirst = ctx.llm.registerAdapter(['mock'], first) - const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await started.promise @@ -438,7 +438,7 @@ describe('request stability across the loop', () => { } }([]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), { + const agent = await ctx.agentLoop.create(SessionId(`reasoning-${kind}`), { provider: 'mock', model: 'mock', }) @@ -473,7 +473,7 @@ describe('request stability across the loop', () => { yield* textResponse('owned') })() }) - const agent = ctx.agentLoop.create(SessionId('listener-owned'), { + const agent = await ctx.agentLoop.create(SessionId('listener-owned'), { provider: 'listener', model: 'virtual', }) @@ -495,7 +495,7 @@ describe('request stability across the loop', () => { it('a compaction replace rewrites the resend, and the log explains it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, maxTokens: 1_024 } : config @@ -533,7 +533,7 @@ describe('request stability across the loop', () => { textResponse('recovered'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('same-step-compaction'), { + const agent = await ctx.agentLoop.create(SessionId('same-step-compaction'), { provider: 'mock', model: 'mock', }) @@ -564,7 +564,7 @@ describe('request stability across the loop', () => { it('a real system-prompt change is a full changed-header snapshot; a stable new turn reuses it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -588,7 +588,7 @@ describe('request stability across the loop', () => { it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let injected = false ctx.on('agent/request', async (_payload, next) => { @@ -616,7 +616,7 @@ describe('request stability across the loop', () => { it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('llm/stream', (options, next) => { // The historical failure mode this design kills: a listener rewriting @@ -640,7 +640,7 @@ describe('request stability across the loop', () => { it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('gen1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('gen1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -668,7 +668,7 @@ describe('request stability across the loop', () => { it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request', async (_payload, next) => { const config = await next() @@ -703,7 +703,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -764,7 +764,7 @@ describe('request/context capacity records', () => { it('records capacity once and skips it while the route is unchanged', async () => { const adapter = capacityAdapter({ mock: 128_000 }, [textResponse('a'), textResponse('b')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('capacity-dedup'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('capacity-dedup'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -786,7 +786,7 @@ describe('request/context capacity records', () => { [textResponse('a'), textResponse('b')], ) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('capacity-switch'), { provider: 'mock', model: 'small' }) + const agent = await ctx.agentLoop.create(SessionId('capacity-switch'), { provider: 'mock', model: 'small' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -803,7 +803,7 @@ describe('request/context capacity records', () => { it('records and deduplicates a route whose adapter advertises no capacity', async () => { const ctx = await harness(new MockAdapter([textResponse('a'), textResponse('b')])) - const agent = ctx.agentLoop.create(SessionId('capacity-absent'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('capacity-absent'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) send(agent, 'second') @@ -816,7 +816,7 @@ describe('request/context capacity records', () => { it('clears a previous capacity when the next route advertises none', async () => { const adapter = capacityAdapter({ known: 64_000 }, [textResponse('a'), textResponse('b')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) + const agent = await ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) let model = 'known' ctx.on('agent/request', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model }) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 457de1a306..229229b5b1 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,15 +1,16 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { ToolCallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { mkdtemp, rm } from 'node:fs/promises' +import { appendFile, mkdtemp, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -25,7 +26,7 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; return { ctx: await mountPersistentHarness(root, adapter), root } } -async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise { +async function mountPersistentHarness(root: string, adapter: MockAdapter, compression?: 'none'): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) @@ -33,14 +34,34 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + // The backend mounts BEFORE the loop so root teardown unwinds the loop + // first: live agents drain their writers into still-open handles. + await ctx.plugin(JsonlSessionPersistence, { root, ...compression === undefined ? {} : { compression } }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(JsonlSessionPersistence, { root }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } +/** Seed one stored session through the persistence seam (header minted by the store). */ +async function seedStoredSession(ctx: Context, sessionId: SessionId, events: readonly SessionEvent[]): Promise { + const detached = ctx.sessions.prepare(sessionId) + const handle = await ctx.sessionPersistence.create(detached.header) + await handle.append(events) + await handle.close() +} + +/** Read one stored session's physical validated log through a read handle. */ +async function readStoredEvents(ctx: Context, sessionId: SessionId): Promise { + const handle = await ctx.sessionPersistence.open(sessionId, 'read') + try { + return await handle.read() + } finally { + await handle.close() + } +} + async function persistSession(sessionId: SessionId): Promise { - const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')])) + const { ctx, root } = await persistentHarness(new MockAdapter([])) // Persistence deliberately has no artifact for a truly empty session. A // balanced completed turn is the smallest resumable log and avoids running // the model merely to construct this lifecycle fixture. @@ -48,27 +69,15 @@ async function persistSession(sessionId: SessionId): Promise { { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const session = ctx.sessions.create(sessionId, { seed }) - await ctx.sessions.flush(session) + await seedStoredSession(ctx, sessionId, seed) await ctx.fiber.dispose() return root } -/** Build a detached preparation for lifecycle-race test doubles. */ -function preparationFromSnapshot( - ctx: Context, - snapshot: { - meta: SessionHeader - inheritedEventCount: SessionLogOffsetType - events: readonly SessionEvent[] - }, -): SessionPreparation { - return SessionPreparation.create(ctx.sessions.prepare(snapshot.meta.id, { - seed: structuredClone(snapshot.events) as SessionEvent[], - meta: structuredClone(snapshot.meta), - inheritedEventCount: snapshot.inheritedEventCount, - seedSource: 'persistence', - })) +/** A handle stand-in for abandoned-open races; only `close()` is ever reachable. */ +function abandonedHandleStub(): { handle: SessionHandle; close: ReturnType } { + const close = vi.fn(async () => {}) + return { handle: { close } as unknown as SessionHandle, close } } function waitForIdle(ctx: Context, agent: Agent): Promise { @@ -96,84 +105,7 @@ function throwUnknown(value: unknown): never { } describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => { - it('resumes a pre-react-loop session including pre-identity message events', async () => { - const sessionId = SessionId('pre-identity-resume') - const first = await persistentHarness(new MockAdapter([])) - await first.ctx.sessionPersistence.create({ - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: 1, - isSeeded: false, - }) - await first.ctx.sessionPersistence.append(sessionId, [ - { - type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, - }, - { - type: 'user/message', - seq: 1, - time: 2, - data: { content: [{ type: 'text', text: 'old question' }], 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, - content: [{ type: 'text', text: 'old answer' }], - provenance: { provider: 'mock', model: 'mock' }, - }, - surfaceOp: 'append', - }, - { - type: 'steering/message', - seq: 4, - time: 5, - data: { - turn: 1, - content: [{ type: 'text', text: 'old steering' }], - source: { kind: 'user' }, - }, - surfaceOp: 'append', - }, - { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, - ] as unknown as SessionEvent[]) - await first.ctx.fiber.dispose() - - const ctx = await mountPersistentHarness(first.root, new MockAdapter([textResponse('new answer')])) - const handle = await ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - expect(handle.agent.session.deriveMessages()).toMatchObject([ - { id: `legacy-message:${sessionId}:1`, role: 'user' }, - { id: `legacy-message:${sessionId}:3`, role: 'assistant' }, - { id: `legacy-message:${sessionId}:4`, role: 'user' }, - ]) - expect(handle.agent.inbox.nextTurn).toEqual([]) - expect(handle.agent.inbox.nextStep).toEqual([]) - - handle.agent.followup(createUserMessage({ - content: [{ type: 'text', text: 'new question' }], - source: { kind: 'user' }, - })) - await waitForIdle(ctx, handle.agent) - expect(handle.agent.session.deriveMessages()).toHaveLength(5) - expect(handle.agent.session.snapshotEvents().at(-1)).toMatchObject({ - type: 'turn/end', - data: { reason: { kind: 'completed' } }, - }) - await handle.dispose() - await ctx.fiber.dispose() - }) - - it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => { + it('normalizes a non-Error resume publication failure for rollback and releases the write handle', async () => { const sessionId = SessionId('unknown-resume-failure-s') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) @@ -186,6 +118,9 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() + // Rollback closed the write handle: write ownership is claimable again. + const reopened = await ctx.sessionPersistence.open(sessionId, 'write') + await reopened.close() await ctx.fiber.dispose() }) @@ -208,6 +143,302 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('a created agent stores its seed and live turn durably through its handle', async () => { + const seed: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const { ctx } = await persistentHarness(new MockAdapter([textResponse('stored')])) + const sessionId = SessionId('durable-create') + const handle = await ctx.agents.create({ + sessionId, + seed, + meta: { cwd: '/w' }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) + await waitForIdle(ctx, handle.agent) + await handle.dispose() + + const stored = await readStoredEvents(ctx, sessionId) + const seqs = stored.map(event => event.seq) + expect(seqs).toEqual(seqs.map((_, index) => index)) + // The constructor seed (turn 1 + its end-seed marker) precedes the live turn 2. + expect(stored.slice(0, 2).map(event => event.type)).toEqual(['turn/start', 'turn/end']) + expect(stored[2]?.type).toBe('session/end-seed') + const turnStarts = stored.filter(event => event.type === 'turn/start') + expect(turnStarts.map(event => event.type === 'turn/start' && event.data.turn)).toEqual([1, 2]) + expect(stored.some(event => event.type === 'turn/end' && event.data.turn === 2)).toBe(true) + expect(JSON.stringify(stored)).toContain('stored') + await ctx.fiber.dispose() + }) + + it('a rejecting final writer close releases the registries, then rejects disposal', async () => { + const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')])) + const sessionId = SessionId('drain-close-fails') + const handle = await ctx.agents.create({ sessionId }) + const persisted = await ctx.sessionPersistence.open(sessionId, 'read') + await persisted.close() + const stored = [...(ctx.sessionPersistence as unknown as { + tracker: { openHandles: Set } + }).tracker.openHandles].find(open => open.id === sessionId && open.access === 'write') + if (stored === undefined) throw new Error('missing owned write handle') + // The real close still runs (releasing write ownership); the injected + // failure models a drain that reports a durability error at close. + const realClose = stored.close.bind(stored) + vi.spyOn(stored, 'close').mockImplementation(async () => { + await realClose() + throw new Error('close exploded') + }) + + await expect(handle.dispose()).rejects.toThrow('close exploded') + // Teardown reached quiescence before the rejection: the agent and session + // are unregistered, and write ownership is released — the never-flushed + // session reports absence, not an ownership conflict. + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + await expect(ctx.sessionPersistence.open(sessionId, 'write')).rejects.toThrow('not found') + await ctx.fiber.dispose() + }) + + it('combines a machine-teardown failure with a close failure into one rejection', async () => { + const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')])) + const sessionId = SessionId('drain-both-fail') + const handle = await ctx.agents.create({ sessionId }) + const stored = [...(ctx.sessionPersistence as unknown as { + tracker: { openHandles: Set } + }).tracker.openHandles].find(open => open.id === sessionId && open.access === 'write') + if (stored === undefined) throw new Error('missing owned write handle') + const machine = handle.agent as Agent & { scope: { dispose: () => Promise } } + vi.spyOn(machine.scope, 'dispose').mockRejectedValue(new Error('scope exploded')) + vi.spyOn(stored, 'close').mockRejectedValue(new Error('close exploded')) + + const failure = await handle.dispose().then(() => undefined, (error: unknown) => error) + if (!(failure instanceof AggregateError)) throw new Error('expected an AggregateError rejection') + expect(failure.message).toContain(`agent "${sessionId}" disposal failed`) + expect(failure.errors.map(error => (error as Error).message)).toEqual(['scope exploded', 'close exploded']) + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('agent dispose releases write ownership of its stored session', async () => { + const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')])) + const sessionId = SessionId('ownership-release') + const handle = await ctx.agents.create({ sessionId }) + + await expect(ctx.sessionPersistence.open(sessionId, 'write')) + .rejects.toThrow(/already owned by an active write handle/) + + // Materialize the log so the session outlives its creator handle. + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) + await waitForIdle(ctx, handle.agent) + await handle.dispose() + const reopened = await ctx.sessionPersistence.open(sessionId, 'write') + expect(reopened.access).toBe('write') + await reopened.close() + await ctx.fiber.dispose() + }) + + it('a stored-session create failure rolls the fresh identity back', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const sessionId = SessionId('create-backend-fail') + vi.spyOn(ctx.sessionPersistence, 'create').mockRejectedValueOnce(new Error('backend create failed')) + + await expect(ctx.agents.create({ sessionId })).rejects.toThrow('backend create failed') + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + + // The identity was fully released: the same id creates cleanly afterwards. + const retry = await ctx.agents.create({ sessionId }) + await retry.dispose() + await ctx.fiber.dispose() + }) + + it('a seed append failure closes the fresh write handle and rethrows', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const sessionId = SessionId('seed-append-fail') + const seed: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence) + let closeSpy: MockInstance<() => Promise> | undefined + ctx.sessionPersistence.create = async (header, options) => { + const handle = await originalCreate(header, options) + vi.spyOn(handle, 'append').mockRejectedValue(new Error('seed append failed')) + closeSpy = vi.spyOn(handle, 'close') + return handle + } + + await expect(ctx.agents.create({ sessionId, seed })).rejects.toThrow('seed append failed') + expect(closeSpy).toHaveBeenCalled() + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + // The closed never-materialized creation left no stored session behind. + await expect(ctx.sessionPersistence.open(sessionId, 'write')) + .rejects.toThrow(/not found/) + await ctx.fiber.dispose() + }) + + + it('a setup failure before publication leaves no stored residue; the id creates again', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const sessionId = SessionId('setup-fail-no-residue') + const seed: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + await expect(ctx.agents.create({ + sessionId, + seed, + setup: () => { throw new Error('setup refused') }, + })).rejects.toThrow('setup refused') + + // The seed is stored only at the publication commit point, so the failed + // attempt materialized nothing and released the identity completely. + await expect(ctx.sessionPersistence.stat(sessionId)).resolves.toBeUndefined() + const retry = await ctx.agents.create({ sessionId, seed }) + await expect(ctx.sessionPersistence.stat(sessionId)).resolves.toBeDefined() + await retry.dispose() + await ctx.fiber.dispose() + }) + + it('rollback swallows a rejecting handle close after a prepare failure (create and createAgent)', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence) + const closeSpies: Array<{ mockRestore: () => void }> = [] + ctx.sessionPersistence.create = async (header, options) => { + const handle = await originalCreate(header, options) + closeSpies.push(vi.spyOn(handle, 'close').mockRejectedValue(new Error('close failed'))) + return handle + } + + // Config create path: prepare's option validation throws after the handle exists. + await expect(ctx.agentLoop.create(SessionId('close-reject-config'), { maxTokens: -1 })) + .rejects.toThrow('agent maxTokens must be a positive safe integer') + expect(ctx.agents.get(SessionId('close-reject-config'))).toBeUndefined() + + // Owned createAgent path: the same validation failure after the handle exists. + await expect(ctx.agents.create({ + sessionId: SessionId('close-reject-owned'), + agentOptions: { maxTokens: -1 }, + })).rejects.toThrow('agent maxTokens must be a positive safe integer') + expect(ctx.agents.get(SessionId('close-reject-owned'))).toBeUndefined() + + for (const spy of closeSpies.splice(0)) spy.mockRestore() + await ctx.fiber.dispose() + }) + + it('a reentrant abort during preparation swallows a rejecting handle close', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const sessionId = SessionId('prepare-abort-close-reject') + const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence) + const spies: Array<{ mockRestore: () => void }> = [] + let closed: Promise | undefined + ctx.sessionPersistence.create = async (header, options) => { + const handle = await originalCreate(header, options) + const realClose = handle.close.bind(handle) + spies.push(vi.spyOn(handle, 'close').mockImplementation(async () => { + closed = realClose() + await closed + throw new Error('close failed') + })) + return handle + } + const reason = new Error('cancelled while preparing') + const controller = new AbortController() + let aborted = false + ctx.on('internal/plugin', (fiber) => { + if (aborted || fiber.name !== 'scope') return + aborted = true + controller.abort(reason) + }) + + await expect(ctx.agents.create({ sessionId, signal: controller.signal })).rejects.toBe(reason) + // The rejecting close stays swallowed by the rollback; wait for the + // rollback's real close so factory teardown finds a quiescent lifecycle. + await vi.waitFor(() => { if (closed === undefined) throw new Error('close not reached') }) + await closed + expect(ctx.agents.get(sessionId)).toBeUndefined() + for (const spy of spies.splice(0)) spy.mockRestore() + await ctx.fiber.dispose() + }) + + it('config create rollback swallows a rejecting close after a publish failure', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const sessionId = SessionId('config-publish-close-reject') + const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence) + const spies: Array<{ mockRestore: () => void }> = [] + let closed: Promise | undefined + ctx.sessionPersistence.create = async (header, options) => { + const handle = await originalCreate(header, options) + const realClose = handle.close.bind(handle) + spies.push(vi.spyOn(handle, 'close').mockImplementation(async () => { + closed = realClose() + await closed + throw new Error('close failed') + })) + return handle + } + const announce = vi.spyOn(ctx.agents, 'announce').mockImplementation(() => { + throw new Error('announce failed') + }) + + await expect(ctx.agentLoop.create(sessionId)).rejects.toThrow('announce failed') + announce.mockRestore() + await vi.waitFor(() => { if (closed === undefined) throw new Error('close not reached') }) + await closed + expect(ctx.agents.get(sessionId)).toBeUndefined() + for (const spy of spies.splice(0)) spy.mockRestore() + await ctx.fiber.dispose() + }) + + it('a seed append failure swallows a rejecting handle close', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const sessionId = SessionId('seed-append-close-reject') + const seed: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence) + const spies: Array<{ mockRestore: () => void }> = [] + ctx.sessionPersistence.create = async (header, options) => { + const handle = await originalCreate(header, options) + spies.push(vi.spyOn(handle, 'append').mockRejectedValue(new Error('seed append failed'))) + spies.push(vi.spyOn(handle, 'close').mockRejectedValue(new Error('close failed'))) + return handle + } + + await expect(ctx.agents.create({ sessionId, seed })).rejects.toThrow('seed append failed') + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + + for (const spy of spies.splice(0)) spy.mockRestore() + await ctx.fiber.dispose() + }) + + it('a resume read failure swallows a rejecting handle close during rollback', async () => { + const sessionId = SessionId('resume-read-close-reject') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([])) + const originalOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + const spies: Array<{ mockRestore: () => void }> = [] + ctx.sessionPersistence.open = async (id, access, options) => { + const handle = await originalOpen(id, access, options) + spies.push(vi.spyOn(handle, 'read').mockRejectedValue(new Error('stored read failed'))) + spies.push(vi.spyOn(handle, 'close').mockRejectedValue(new Error('close failed'))) + return handle + } + + await expect(ctx.agents.resume({ resumeSessionId: sessionId })) + .rejects.toThrow('stored read failed') + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + + for (const spy of spies.splice(0)) spy.mockRestore() + await ctx.fiber.dispose() + }) + it('resume cannot crash-repair a turn owned by a live agent', async () => { const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')])) const sessionId = SessionId('live-resume-race') @@ -216,19 +447,111 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.sessions.flush(first.session) await expect(ctx.agents.resume({ resumeSessionId: sessionId })) - .rejects.toThrow(/while it is live/) + .rejects.toThrow(/already owned by an active write handle/) first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(first.session) - const loaded = await ctx.sessionPersistence.load(sessionId) - expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) - expect(loaded.events.at(-1)).toMatchObject({ + const stored = await readStoredEvents(ctx, sessionId) + expect(stored.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + expect(stored.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) await ctx.fiber.dispose() }) + it('resume appends interrupted-turn closers durably through the handle', async () => { + // Lifecycle 1: store an interrupted log — an open turn with no turn/end. + const sessionId = SessionId('interrupted-resume') + const { ctx: ctx1, root } = await persistentHarness(new MockAdapter([])) + await seedStoredSession(ctx1, sessionId, [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + ]) + // A read returns the PHYSICAL validated log: no synthetic closers. + const raw = await readStoredEvents(ctx1, sessionId) + expect(raw.map(event => event.type)).toEqual(['turn/start']) + await ctx1.fiber.dispose() + + // Lifecycle 2: resume repairs the tail and stores the repair durably. + const ctx2 = await mountPersistentHarness(root, new MockAdapter([])) + const handle = await ctx2.agents.resume({ resumeSessionId: sessionId }) + expect(handle.agent.session.snapshotEvents().map(event => event.type)) + .toEqual(['turn/start', 'turn/end', 'session/end-seed']) + await handle.dispose() + + const stored = await readStoredEvents(ctx2, sessionId) + expect(stored.map(event => event.type)).toEqual(['turn/start', 'turn/end', 'session/end-seed']) + expect(stored[1]).toMatchObject({ data: { reason: { kind: 'interrupted' } } }) + await ctx2.fiber.dispose() + }) + + it('resume closes an interrupted tool call durably: tool/result, step/end, turn/end', async () => { + const sessionId = SessionId('interrupted-tool-resume') + const { ctx: ctx1, root } = await persistentHarness(new MockAdapter([])) + await seedStoredSession(ctx1, sessionId, [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: SessionSeq(1), time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: SessionSeq(2), time: 2, surfaceOp: 'append', data: { + turn: 1, step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{}' }], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + } }, + { type: 'tool/call', seq: SessionSeq(3), time: 2, data: { turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{}' } }, + ] as SessionEvent[]) + await ctx1.fiber.dispose() + + const ctx2 = await mountPersistentHarness(root, new MockAdapter([])) + const handle = await ctx2.agents.resume({ resumeSessionId: sessionId }) + await handle.dispose() + + // The multi-closer set lands durably in one contiguous batch, and the + // synthetic tool/result cites the recorded tool/call seq. + const stored = await readStoredEvents(ctx2, sessionId) + expect(stored.map(event => event.type)).toEqual([ + 'turn/start', 'step/start', 'assistant/message', 'tool/call', + 'tool/result', 'step/end', 'turn/end', 'session/end-seed', + ]) + expect(stored.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect(stored[4]).toMatchObject({ + sourceEventSeqs: [3], + data: { error: { code: TOOL_OUTCOME_UNKNOWN } }, + }) + expect(stored[6]).toMatchObject({ data: { reason: { kind: 'interrupted' } } }) + await ctx2.fiber.dispose() + }) + + it('resume over a torn physical tail continues from the committed prefix', async () => { + const sessionId = SessionId('torn-tail-resume') + const root = await mkdtemp(join(tmpdir(), 'dsh-resume-torn-')) + dirs.push(root) + const ctx1 = await mountPersistentHarness(root, new MockAdapter([]), 'none') + await seedStoredSession(ctx1, sessionId, [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + ]) + await ctx1.fiber.dispose() + + // Crash artifact: a half-written record with no trailing newline. + const logs = (await readdir(root, { recursive: true })).filter(name => name.endsWith('.jsonl')) + expect(logs).toHaveLength(1) + await appendFile(join(root, logs[0] as string), '{"type":"assistant/chunk","seq":1,"ti') + + // Resume truncates the torn tail under its write open, then appends the + // closers immediately after the committed prefix — no gap, no fragment. + const ctx2 = await mountPersistentHarness(root, new MockAdapter([]), 'none') + const handle = await ctx2.agents.resume({ resumeSessionId: sessionId }) + expect(handle.agent.session.snapshotEvents().map(event => event.type)) + .toEqual(['turn/start', 'turn/end', 'session/end-seed']) + await handle.dispose() + + const stored = await readStoredEvents(ctx2, sessionId) + expect(stored.map(event => `${event.type}@${event.seq}`)) + .toEqual(['turn/start@0', 'turn/end@1', 'session/end-seed@2']) + await ctx2.fiber.dispose() + }) + it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) @@ -242,23 +565,15 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent - a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) - await waitForIdle(ctx1, a1) + const h1 = await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') }) + h1.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) + await waitForIdle(ctx1, h1.agent) + // Agent disposal drains the writer through the still-open handle. + await h1.dispose() await ctx1.fiber.dispose() // Lifecycle 2: resume it; the header cwd stays undefined (no-cwd branch). - const adapter2 = new MockAdapter([textResponse('b')]) - const ctx2 = new Context() - await ctx2.plugin(LlmRuntime) - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(SystemPrompt) - await ctx2.plugin(ToolRuntime) - await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [] }) - await ctx2.plugin(JsonlSessionPersistence, { root }) - ctx2.llm.registerAdapter(['mock'], adapter2) + const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('b')])) const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() @@ -270,24 +585,15 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] ctx1.on('agent/session-start', ({ source }) => void sources1.push(source)) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent + const h1 = await ctx1.agents.create({ sessionId: SessionId('start-sess') }) expect(sources1).toEqual(['startup']) - a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) - await waitForIdle(ctx1, a1) + h1.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) + await waitForIdle(ctx1, h1.agent) + await h1.dispose() await ctx1.fiber.dispose() // Lifecycle 2: resuming the persisted session emits session-start 'resume'. - const adapter2 = new MockAdapter([textResponse('b')]) - const ctx2 = new Context() - await ctx2.plugin(LlmRuntime) - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(SystemPrompt) - await ctx2.plugin(ToolRuntime) - await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [] }) - await ctx2.plugin(JsonlSessionPersistence, { root }) - ctx2.llm.registerAdapter(['mock'], adapter2) + const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('b')])) const sources2: string[] = [] ctx2.on('agent/session-start', ({ source }) => void sources2.push(source)) await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) @@ -361,6 +667,28 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('a resumed session stores its repair suffix durably before publication', async () => { + // The stored fixture (two events, no end-seed) gains the end-seed marker + // through the resume handle: after one resume lifecycle the STORED log + // carries it, so the next resume reads it back without re-marking. + const sessionId = SessionId('resume-suffix-durable') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([])) + const first = await ctx.agents.resume({ resumeSessionId: sessionId }) + await first.dispose() + + const stored = await readStoredEvents(ctx, sessionId) + expect(stored.map(event => event.type)).toEqual(['turn/start', 'turn/end', 'session/end-seed']) + + const second = await ctx.agents.resume({ resumeSessionId: sessionId }) + expect(second.agent.session.snapshotEvents().map(event => event.type)) + .toEqual(['turn/start', 'turn/end', 'session/end-seed']) + await second.dispose() + const restored = await readStoredEvents(ctx, sessionId) + expect(restored.map(event => event.type)).toEqual(['turn/start', 'turn/end', 'session/end-seed']) + await ctx.fiber.dispose() + }) + it('successful resume disposal retires its caller-owned transaction effects', async () => { const sessionId = SessionId('resume-retired-effects-s') const root = await persistSession(sessionId) @@ -468,24 +796,22 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) - it('owner unload aborts a never-settling persistence preparation, releases the identity, and blocks late publication', async () => { + it('owner unload aborts a never-settling persistence open, releases the identity, and blocks late publication', async () => { const sessionId = SessionId('resume-load-owner-unload') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) - const snapshot = await ctx.sessionPersistence.load(sessionId) - const abandoned = preparationFromSnapshot(ctx, snapshot) - const latePreparation = Promise.withResolvers() - const preparationStarted = Promise.withResolvers() - const originalPrepare = ctx.sessionPersistence.prepare.bind(ctx.sessionPersistence) - let preparations = 0 - ctx.sessionPersistence.prepare = (id, signal) => { + const lateOpen = Promise.withResolvers() + const openStarted = Promise.withResolvers() + const originalOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + let opens = 0 + ctx.sessionPersistence.open = (id, access, options) => { expect(id).toBe(sessionId) - preparations += 1 - if (preparations === 1) { - preparationStarted.resolve(undefined) - return latePreparation.promise + opens += 1 + if (opens === 1) { + openStarted.resolve(undefined) + return lateOpen.promise } - return originalPrepare(id, signal) + return originalOpen(id, access, options) } const published: string[] = [] @@ -497,7 +823,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const owner = await ctx.plugin(Object.assign((inner: Context) => { resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) - await preparationStarted.promise + await openStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) await promptly(owner.dispose()) @@ -509,24 +835,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // can be reused before awaiting the public rejection. const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })) await rejection - expect(preparations).toBe(2) + expect(opens).toBe(2) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) - // Settlement of the abandoned backend promise cannot resume the old - // transaction or emit a second publication after the retry owns the ids. - latePreparation.resolve(abandoned) - await Promise.resolve() - await Promise.resolve() + // Settlement of the abandoned backend open cannot resume the old + // transaction: the late handle is closed, and no second publication lands + // after the retry owns the ids. + const abandoned = abandonedHandleStub() + lateOpen.resolve(abandoned.handle) + await expect.poll(() => abandoned.close.mock.calls.length).toBe(1) expect(ctx.agents.get(sessionId)).toBe(retry.agent) expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) - abandoned[Symbol.dispose]() await retry.dispose() await ctx.fiber.dispose() }) - it('AgentLoop unload aborts persistence preparation and awaits wrapper settlement', async () => { + it('AgentLoop unload aborts a never-settling persistence open and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') const root = await persistSession(sessionId) const ctx = new Context() @@ -540,21 +866,19 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.plugin(JsonlSessionPersistence, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')])) - const snapshot = await ctx.sessionPersistence.load(sessionId) - const abandoned = preparationFromSnapshot(ctx, snapshot) - const latePreparation = Promise.withResolvers() - const preparationStarted = Promise.withResolvers() - ctx.sessionPersistence.prepare = (id) => { + const lateOpen = Promise.withResolvers() + const openStarted = Promise.withResolvers() + ctx.sessionPersistence.open = (id) => { expect(id).toBe(sessionId) - preparationStarted.resolve(undefined) - return latePreparation.promise + openStarted.resolve(undefined) + return lateOpen.promise } const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) - await preparationStarted.promise + await openStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/) await promptly(loopFiber.dispose()) await rejection @@ -562,46 +886,38 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(published).toEqual([]) expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - latePreparation.resolve(abandoned) - await Promise.resolve() - await Promise.resolve() + // The abandoned handle is closed once the hung open finally settles. + const abandoned = abandonedHandleStub() + lateOpen.resolve(abandoned.handle) + await expect.poll(() => abandoned.close.mock.calls.length).toBe(1) expect(published).toEqual([]) - abandoned[Symbol.dispose]() await ctx.fiber.dispose() }) it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => { - // Lifecycle 1: persist a FORKED session (carries parentSession + isSeeded - // in its header and an exact Session-owned cut) with a complete-turn seed — the write path - // materializes the fork (header + seed) on disk. + // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength + // in its header) through createAgent with a complete-turn seed — the + // factory stores the header and seed through its write handle. const seed: SessionEvent[] = [ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const adapter1 = new MockAdapter([textResponse('a')]) - const { ctx: ctx1, root } = await persistentHarness(adapter1) - const forked = ctx1.sessions.create(SessionId('forked-sess'), { + const { ctx: ctx1, root } = await persistentHarness(new MockAdapter([])) + const forked = await ctx1.agents.create({ + sessionId: SessionId('forked-sess'), seed, - inheritedEventCount: SessionLogOffset(seed.length), meta: { cwd: '/w', parentSession: SessionId('parent-sess'), isSeeded: true, delegationDepth: 1 }, + inheritedEventCount: SessionLogOffset(seed.length), }) - await ctx1.sessions.flush(forked) + await forked.dispose() await ctx1.fiber.dispose() - // Lifecycle 2: resume it; parentSession, isSeeded, and the exact cut survive - // the round-trip. The inherited count must come from persisted storage, - // not the resume seed length (the whole stored log). - const adapter2 = new MockAdapter([textResponse('b')]) - const ctx2 = new Context() - await ctx2.plugin(LlmRuntime) - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(SystemPrompt) - await ctx2.plugin(ToolRuntime) - await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [] }) - await ctx2.plugin(JsonlSessionPersistence, { root }) - ctx2.llm.registerAdapter(['mock'], adapter2) + // Lifecycle 2: resume it; the parentSession + seedLength header survives the + // round-trip (exercises resume's parentSession- and seedLength-present + // branches). seedLength must come from the PERSISTED header, not from the + // resume seed length (which is the whole stored log, not the original + // boundary). + const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('b')])) const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') @@ -625,20 +941,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // Lifecycle 2: resume; the injected context is still pending and becomes // model-visible when the next turn admits it. - const adapter2 = new MockAdapter([textResponse('next')]) - const ctx2 = new Context() - await ctx2.plugin(LlmRuntime) - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(SystemPrompt) - await ctx2.plugin(ToolRuntime) - await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [] }) - await ctx2.plugin(JsonlSessionPersistence, { root }) - ctx2.llm.registerAdapter(['mock'], adapter2) - const loaded = await ctx2.sessionPersistence.load(SessionId('inject-sess')) - expect(loaded.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true) - expect(JSON.stringify(loaded.events)).toContain('background job 42 finished') + const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const stored = await readStoredEvents(ctx2, SessionId('inject-sess')) + expect(stored.some(event => event.type === 'agent/inbox/spliced')).toBe(true) + expect(JSON.stringify(stored)).toContain('background job 42 finished') const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent expect(JSON.stringify(a2.inbox.nextStep)).toContain('background job 42 finished') a2.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) @@ -653,26 +959,18 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent + const h1 = await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }) + const a1 = h1.agent a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) const events1 = a1.session.snapshotEvents() const seqs1 = events1.map(e => e.seq) expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous + await h1.dispose() await ctx1.fiber.dispose() // Lifecycle 2: a brand-new context over the SAME root; resume the session. - const adapter2 = new MockAdapter([textResponse('second answer')]) - const ctx2 = new Context() - await ctx2.plugin(LlmRuntime) - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(SystemPrompt) - await ctx2.plugin(ToolRuntime) - await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [] }) - await ctx2.plugin(JsonlSessionPersistence, { root }) - ctx2.llm.registerAdapter(['mock'], adapter2) + const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('second answer')])) const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent // The resumed session carries the prior history… @@ -805,8 +1103,74 @@ describe('creation and resume cancellation edges', () => { await ctx.fiber.dispose() }) - it('releases a restored preparation if the loop becomes inactive before setup', async () => { - const sessionId = SessionId('resume-loop-inactive-after-prepare') + it('an abort landing between crash repair and publication refuses resume, normalized', async () => { + // Cover the publication-time abort backstop: the signal fires AFTER the + // raced open settled (during the closer append), so only the final + // pre-publication check can refuse. + const run = async (suffix: string, reason: unknown): Promise => { + const sessionId = SessionId(`late-abort-${suffix}`) + const { ctx: seedCtx, root } = await persistentHarness(new MockAdapter([])) + await seedStoredSession(seedCtx, sessionId, [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + ]) + await seedCtx.fiber.dispose() + + const ctx = await mountPersistentHarness(root, new MockAdapter([])) + const controller = new AbortController() + const originalOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + ctx.sessionPersistence.open = async (id, access, options) => { + const handle = await originalOpen(id, access, options) + const realAppend = handle.append.bind(handle) + Object.defineProperty(handle, 'append', { + value: (events: readonly SessionEvent[]) => { + controller.abort(reason) + return realAppend(events) + }, + }) + return handle + } + const rejection = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + signal: controller.signal, + }).then(() => undefined, (error: unknown) => error) + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + return rejection + } + + expect(await run('error', new Error('late abort error'))).toMatchObject({ message: 'late abort error' }) + expect(await run('string', 'late abort string')).toMatchObject({ message: expect.stringMatching(/creation aborted/) as unknown }) + }) + + it('an aborted create closes the write handle its abandoned backend create later resolves', async () => { + const sessionId = SessionId('create-abandoned-handle') + const { ctx } = await persistentHarness(new MockAdapter([])) + const lateCreate = Promise.withResolvers() + const createStarted = Promise.withResolvers() + ctx.sessionPersistence.create = () => { + createStarted.resolve(undefined) + return lateCreate.promise + } + + const controller = new AbortController() + const creating = ctx.agents.create({ sessionId, signal: controller.signal }) + await createStarted.promise + controller.abort(new Error('caller aborted create')) + await expect(creating).rejects.toThrow('caller aborted create') + expect(ctx.sessions.get(sessionId)).toBeUndefined() + + // The abandoned backend create still resolves a real write handle later; + // the loop closes it so ownership is not leaked, and a rejecting close is + // swallowed — there is no owner left to observe it. + const close = vi.fn(async () => { throw new Error('abandoned close failed') }) + lateCreate.resolve({ append: async () => {}, close } as unknown as SessionHandle) + await expect.poll(() => close.mock.calls.length).toBe(1) + await ctx.fiber.dispose() + }) + + it('closes the resume write handle when the loop becomes inactive before setup', async () => { + const sessionId = SessionId('resume-loop-inactive-after-open') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([])) const loop = ctx.agentLoop as unknown as { @@ -819,19 +1183,25 @@ describe('creation and resume cancellation edges', () => { agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('agent loop is not active') expect(ctx.agents.get(sessionId)).toBeUndefined() + + // The already-open handle was closed on the inactive path: a retry can + // claim write ownership again. + const retry = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await retry.dispose() await ctx.fiber.dispose() }) - it('factory teardown during a hung resume preparation rejects with loop-inactive', async () => { + it('factory teardown during a hung resume open rejects promptly and closes the late handle', async () => { const sessionId = SessionId('resume-loop-teardown') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([])) - const snapshot = await ctx.sessionPersistence.load(sessionId) - const abandoned = preparationFromSnapshot(ctx, snapshot) - const gate = Promise.withResolvers() - const preparationStarted = Promise.withResolvers() - ctx.sessionPersistence.prepare = () => { - preparationStarted.resolve(undefined) + const gate = Promise.withResolvers() + const openStarted = Promise.withResolvers() + ctx.sessionPersistence.open = () => { + openStarted.resolve(undefined) return gate.promise } @@ -839,28 +1209,29 @@ describe('creation and resume cancellation edges', () => { resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) - await preparationStarted.promise - // Resolve the preparation only after teardown began: the post-prepare ownership - // check, not the abort race, must reject the wrapper. + await openStarted.promise + // Resolve the open only after teardown began: the abandoned handle must + // still be released even though the wrapper already rejected. const rejection = expect(promptly(resuming)).rejects.toThrow() const disposal = ctx.fiber.dispose() - gate.resolve(abandoned) + const abandoned = abandonedHandleStub() + gate.resolve(abandoned.handle) await rejection await disposal - abandoned[Symbol.dispose]() + await expect.poll(() => abandoned.close.mock.calls.length).toBe(1) }) }) describe('configured-start failure edges', () => { - it('a non-Error mid-prepare abort reason is wrapped for the resume caller', async () => { + it('a non-Error mid-open abort reason is wrapped for the resume caller', async () => { const sessionId = SessionId('resume-string-mid-abort') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([])) const gate = Promise.withResolvers() gate.promise.catch(() => undefined) - const preparationStarted = Promise.withResolvers() - ctx.sessionPersistence.prepare = () => { - preparationStarted.resolve(undefined) + const openStarted = Promise.withResolvers() + ctx.sessionPersistence.open = () => { + openStarted.resolve(undefined) return gate.promise } const controller = new AbortController() @@ -870,7 +1241,7 @@ describe('configured-start failure edges', () => { agentOptions: { provider: 'mock', model: 'mock' }, signal: controller.signal, }) - await preparationStarted.promise + await openStarted.promise controller.abort('operator string reason') await expect(promptly(resuming)).rejects.toThrow(/creation aborted/) @@ -881,12 +1252,6 @@ describe('configured-start failure edges', () => { it('a failing exact-id restore over an existing artifact stays loud', async () => { const sessionId = SessionId('config-existing-corrupt') const root = await persistSession(sessionId) - const ctx = await mountPersistentHarness(root, new MockAdapter([])) - // The artifact exists (list reports it) but its load fails: this is - // corruption, not first creation — the failure must be reported, and no - // fresh same-id session may shadow the broken one. - ctx.sessionPersistence.prepare = () => Promise.reject(new Error('artifact corrupt')) - const configured = new Context() await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) @@ -896,43 +1261,31 @@ describe('configured-start failure edges', () => { await configured.plugin(AgentRegistry) await configured.plugin(JsonlSessionPersistence, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) - configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) + // The artifact exists but its open fails with a NON-NotFound error: this is + // corruption, not first creation — the failure must be reported, and no + // fresh same-id session may shadow the broken one. + configured.sessionPersistence.open = () => Promise.reject(new Error('artifact corrupt')) const configFailures: unknown[] = [] configured.on('agent-loop/config-start-failed', ({ error }) => { configFailures.push(error) }) - const configWarnings: string[] = [] - const configWarn = configured.logger.warn.bind(configured.logger) - configured.logger.warn = ((...args: unknown[]) => { - if (typeof args[0] === 'string') configWarnings.push(args[0]) - return (configWarn as (...a: unknown[]) => unknown)(...args) - }) as typeof configured.logger.warn + const warn = vi.spyOn(configured.logger, 'warn').mockImplementation(() => undefined) + const loop = await configured.plugin(AgentLoop, { agents: [{ id: 'main', sessionId, provider: 'mock', model: 'mock' }], }) await expect.poll(() => configFailures.length).toBe(1) expect(configFailures[0]).toBeInstanceOf(Error) expect((configFailures[0] as Error).message).toBe('artifact corrupt') - expect(configWarnings.some(w => w.includes('config-driven restore'))).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven restore')) expect(configured.agents.get(sessionId)).toBeUndefined() + warn.mockRestore() await loop.dispose() await configured.fiber.dispose() - await ctx.fiber.dispose() }) it('suppresses a configured-resume failure that lands after teardown', async () => { const sessionId = SessionId('config-late-resume-failure') const root = await persistSession(sessionId) - const ctx = await mountPersistentHarness(root, new MockAdapter([])) - const gate = Promise.withResolvers() - gate.promise.catch(() => undefined) - const preparationStarted = Promise.withResolvers() - ctx.sessionPersistence.prepare = () => { - preparationStarted.resolve(undefined) - return gate.promise - } - const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) - const configured = new Context() await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) @@ -942,12 +1295,19 @@ describe('configured-start failure edges', () => { await configured.plugin(AgentRegistry) await configured.plugin(JsonlSessionPersistence, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) - configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) + const gate = Promise.withResolvers() + gate.promise.catch(() => undefined) + const openStarted = Promise.withResolvers() + configured.sessionPersistence.open = () => { + openStarted.resolve(undefined) + return gate.promise + } + const failures: unknown[] = [] configured.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const loop = await configured.plugin(AgentLoop, { agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }], }) - await preparationStarted.promise + await openStarted.promise const disposal = loop.dispose() gate.reject(new Error('late backend failure')) await disposal @@ -956,6 +1316,5 @@ describe('configured-start failure edges', () => { // Ownership deactivated before the failure landed: the report is dropped. expect(failures).toEqual([]) await configured.fiber.dispose() - await ctx.fiber.dispose() }) }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 6d9e7b33e9..d01049c46f 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -125,7 +125,7 @@ describe('agent scope lifecycle', () => { thrown = createFailure let createCaught: unknown try { - ctx.agentLoop.create(SessionId('unknown-create')) + await ctx.agentLoop.create(SessionId('unknown-create')) } catch (error: unknown) { createCaught = error } @@ -144,7 +144,7 @@ describe('agent scope lifecycle', () => { it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) expect(scopeOf(agent.ctx)).toBe(agent) expect(agent.ctx.agent).toBe(agent) // The root accessor default: a plain context answers undefined, not a throw. @@ -197,8 +197,8 @@ describe('agent scope lifecycle', () => { it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')])) - const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) - const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) + const a = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const b = await ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] a.ctx.on('agent/status', ({ agent: subject, status }) => void heard.push(`a-sees:${subject.id}:${status}`)) @@ -516,7 +516,7 @@ describe('agent scope lifecycle', () => { await ctx.fiber.dispose() }) - it('synchronous create rechecks provider liveness before its first publication edge', async () => { + it('create rechecks provider liveness before its first publication edge', async () => { const { ctx, loopFiber } = await harnessWithLoop() const sessionsBefore = ctx.sessions.list().length let unloaded = false @@ -527,20 +527,23 @@ describe('agent scope lifecycle', () => { unloading = loopFiber.dispose() }) - ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' }) + // The mid-setup unload races publication: create either rejects or its + // published agent is torn straight back down — both leave no state. + await ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' }) + .then(() => undefined, () => undefined) await unloading expect(ctx.agents.get(SessionId('config-scope-race')) === undefined).toBe(true) expect(ctx.sessions.list().length).toBe(sessionsBefore) await ctx.fiber.dispose() }) - it('synchronous create leaves no lifecycle state when session preparation fails', async () => { + it('create leaves no lifecycle state when session preparation fails', async () => { const ctx = await harness() const id = SessionId('config-prepare-failure') - expect(() => ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' })) - .toThrow(/absolute path/) - const replacement = ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: '/recovered' }) + await expect(ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' })) + .rejects.toThrow(/absolute path/) + const replacement = await ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: '/recovered' }) expect(ctx.agents.get(id)).toBe(replacement) await replacement.whenIdle() await ctx.fiber.dispose() @@ -884,7 +887,7 @@ describe('agent scope lifecycle', () => { expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined() }) - it('the synchronous config helper rolls back when publication throws', async () => { + it('the config create helper rolls back when publication throws', async () => { const ctx = await harness() const sessionsBefore = ctx.sessions.list().length let boom = true @@ -895,8 +898,8 @@ describe('agent scope lifecycle', () => { } }) - expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' })) - .toThrow('config publish failed') + await expect(ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' })) + .rejects.toThrow('config publish failed') await expect.poll(() => ctx.agents.get(SessionId('config-bad')) === undefined).toBe(true) await expect.poll(() => ctx.sessions.list().length).toBe(sessionsBefore) }) @@ -910,8 +913,8 @@ describe('agent scope lifecycle', () => { it('agentEvents fuses carrier and subject for custom drivers', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const other = await ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] agent.ctx.on('agent/error', ({ agent: subject, turn }) => void heard.push(`${subject.id}:${turn}`)) diff --git a/packages/core/agent-loop/tests/shutdown-drain.spec.ts b/packages/core/agent-loop/tests/shutdown-drain.spec.ts new file mode 100644 index 0000000000..5ab7dc46c2 --- /dev/null +++ b/packages/core/agent-loop/tests/shutdown-drain.spec.ts @@ -0,0 +1,69 @@ +/** Root-fiber shutdown drains buffered session events durably (both mount orders). */ + +import { describe, expect, it, afterEach } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +const dirs: string[] = [] +afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +async function mount(order: 'backend-first' | 'loop-first'): Promise<{ ctx: Context; root: string }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-shutdown-drain-')) + dirs.push(root) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + if (order === 'backend-first') { + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + } else { + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(JsonlSessionPersistence, { root }) + } + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('done')])) + return { ctx, root } +} + +describe.each(['backend-first', 'loop-first'] as const)('root shutdown drain (%s)', (order) => { + it('persists buffered turn events without an explicit flush before dispose', async () => { + const { ctx, root } = await mount(order) + const sessionId = SessionId('shutdown-drain') + const handle = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) + await waitForIdle(ctx, handle.agent) + // No explicit flush and no agent dispose: root teardown must drain. + await ctx.fiber.dispose() + + const verify = new Context() + await verify.plugin(JsonlSessionPersistence, { root }) + const reader = await verify.sessionPersistence.open(sessionId, 'read') + const events = await reader.read() + await reader.close() + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + await verify.fiber.dispose() + }) +}) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index cc079ca291..0697224e70 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -107,7 +107,7 @@ describe('tool-call scheduler: grouping and barriers', () => { const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 3) @@ -136,7 +136,7 @@ describe('tool-call scheduler: grouping and barriers', () => { name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } }, async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -171,7 +171,7 @@ describe('tool-call scheduler: grouping and barriers', () => { return [{ type: 'text', text: 'replaced' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => replacement.started.length === 1) @@ -202,7 +202,7 @@ describe('tool-call scheduler: grouping and barriers', () => { disposeInitial() ctx.tools.register(replacement.tool) }) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => initial.started.length === 2) @@ -228,7 +228,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 2) @@ -251,7 +251,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 2) gated.release('2'); gated.release('1') @@ -300,7 +300,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const ctx = await harness(adapter, 2) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 2) @@ -332,7 +332,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const ctx = await harness(adapter, 1) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) @@ -359,7 +359,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => ctx.llm.registerAdapter(['mock'], adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) @@ -385,7 +385,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = const post: string[] = [] ctx.on('tools/pre-execute', async (exec, next): Promise => { pre.push(String(exec.callId)); return next() }) ctx.on('tools/post-execute', async (exec, _result, next): Promise => { post.push(String(exec.callId)); return next() }) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 3) @@ -408,7 +408,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = ({ kind: 'accept', additionalContexts: [createUserMessage({ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' }, })] })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 2) @@ -446,7 +446,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = post.push(String(exec.callId)) return next() }) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 1) @@ -471,7 +471,7 @@ describe('tool-call scheduler: abort handling', () => { const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'assistant/message') { agent.cancel({ kind: 'user' }) @@ -502,7 +502,7 @@ describe('tool-call scheduler: abort handling', () => { const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.callId === ToolCallId('c1')) { agent.cancel({ kind: 'user' }) @@ -540,7 +540,7 @@ describe('tool-call scheduler: abort handling', () => { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' }, })], })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 2) @@ -613,7 +613,7 @@ describe('tool-call scheduler: abort handling', () => { parameters: { id: { type: 'string', required: true } }, async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] }, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await until(() => gated.started.length === 2) @@ -669,7 +669,7 @@ describe('tool-call scheduler: failure quiescence', () => { scheduler.dispatch = exec => exec.callId === ToolCallId('c1') ? new Promise((_resolve, reject) => { rejectFirst = reject }) : dispatch(exec).then(() => { throw drainedError }) - const agent = ctx.agentLoop.create(SessionId('scheduler-failure'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('scheduler-failure'), { provider: 'mock', model: 'mock' }) let idle = false const idlePromise = waitForIdle(ctx, agent).then(() => { idle = true }) @@ -748,7 +748,7 @@ describe('PTC mode native-tool denial through the agent loop', () => { const ctx = await ptcModeHarness(adapter) ctx.tools.register(tool) - const agent = ctx.agentLoop.create(SessionId('code-native'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('code-native'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'write a file' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index e7ffe77a5c..15e1a8574d 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -60,7 +60,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter, toolOrder) for (const name of registrationOrder) registerNamed(ctx, name) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) return { ctx, agent, adapter } @@ -99,7 +99,7 @@ describe('loop-level canonical tool order', () => { const adapter = new MockAdapter([textResponse('never sent')]) const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST]) registerNamed(ctx, 'alpha') - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 6e3db1edb0..7df60db27c 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -194,11 +194,12 @@ export interface AgentFactory { */ createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** - * Prepare a persisted session and resume an agent on it. Async because it awaits - * both `ctx.sessionPersistence.prepare` and the optional unpublished setup - * transaction; must be called after that service exists (consumers inject - * `sessionPersistence`). Publication follows the same setup-commit and - * ordered boundary as {@link createAgent}. + * Resume an agent on a persisted session. Async because it opens the + * persisted session for write, reads and repairs the log, publishes it, and + * awaits the optional unpublished setup transaction; must be called after + * `ctx.sessionPersistence` exists (consumers inject `sessionPersistence`). + * Publication follows the same setup-commit and ordered boundary as + * {@link createAgent}. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index c55cc5fb6a..59e198db44 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -850,8 +850,9 @@ export class SessionForkError extends Error { /** * In-memory session store (`ctx.sessions`). * - * Persistence is intentionally not implemented here — persistence plugins - * subscribe to `session/event` and flush on `session/flush` / dispose. + * Persistence is intentionally not implemented here — the agent lifecycle + * attaches a session-log writer to each published session's write handle; + * a session published outside that lifecycle persists nothing. */ export class SessionStore extends Service { private store = new Map() diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 461c31ad36..c27cd4d98e 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -205,8 +205,10 @@ export interface TurnEndReasonMap { /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * A persistence backend closed a crash-orphaned turn on reload. The loop never - * emits this marker, and the events recorded before the crash remain intact. + * A crash-orphaned turn was closed after the fact: agent-loop resume appends + * this closer for a stored log whose last turn never ended, and session-query + * synthesizes it on cold reads. The loop never emits this marker live, and + * the events recorded before the crash remain intact. */ interrupted: { kind: 'interrupted' } } diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index 0f71a4da37..0bb7a5a75a 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -12,6 +12,7 @@ import { queueHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal' import { errorMessage, TeamError } from './error.ts' import type { TeamJournal } from './journal.ts' import type { TeamRuntimeLifecycle } from './lifecycle.ts' +import { readPersistedSession } from './persisted.ts' import type { TeamRoster } from './roster.ts' import { resolveActiveMember } from './roster.ts' import { messageAccepted } from './session-message.ts' @@ -308,7 +309,8 @@ export class TeamMailbox { /** Whether a target Session already contains the durable message identity. */ private targetRecorded(session: Session, messageId: TeamMessageId): boolean { - return messageAccepted(session.ownEvents(), message => message.source.kind === 'team-message' + const suffix = session.snapshotEvents(session.inheritedEventCount) + return messageAccepted(suffix, message => message.source.kind === 'team-message' && message.source.messageId === messageId) } @@ -320,19 +322,19 @@ export class TeamMailbox { ] } - /** Inspect an inactive target before cold resume; uncertainty keeps the mailbox queued. */ + /** Read an inactive target's durable log before cold resume; uncertainty keeps the mailbox queued. */ private async persistedTargetRecorded( targetId: SessionId, messageId: TeamMessageId, signal: AbortSignal, ): Promise { try { - const stored = await this.ctx.sessionPersistence.inspect(targetId, signal) + const stored = await readPersistedSession(this.ctx.sessionPersistence, targetId, signal) const suffix = stored.events.slice(stored.inheritedEventCount) return messageAccepted(suffix, message => message.source.kind === 'team-message' && message.source.messageId === messageId) } catch (error: unknown) { - this.ctx.logger.warn(`cannot inspect Team message target "${targetId}": ${errorMessage(error)}`) + this.ctx.logger.warn(`cannot read Team message target "${targetId}": ${errorMessage(error)}`) return undefined } } diff --git a/packages/experimental/agent-team/src/persisted.ts b/packages/experimental/agent-team/src/persisted.ts new file mode 100644 index 0000000000..4d4da6d9a3 --- /dev/null +++ b/packages/experimental/agent-team/src/persisted.ts @@ -0,0 +1,33 @@ +/** Short-lived read-handle access to persisted Team member Sessions. */ + +import type { SessionEvent, SessionHeader, SessionId , SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' + +/** One persisted Session's detached header and complete committed event log. */ +export interface PersistedSessionView { + /** Exact fork-inherited event count paired with `header`. */ + readonly inheritedEventCount: SessionLogOffset + readonly header: SessionHeader + readonly events: readonly SessionEvent[] +} + +/** + * Read one stored session's header and complete event log through a + * short-lived read handle, closing the handle before returning. + * @param persistence - the durable session store. + * @param id - the stored session to read. + * @param signal - cancellation observed by open and read. + * @returns the stored header and every committed event. + */ +export async function readPersistedSession( + persistence: SessionPersistence, + id: SessionId, + signal: AbortSignal, +): Promise { + const handle = await persistence.open(id, 'read', { signal }) + try { + return { header: handle.header, inheritedEventCount: handle.inheritedEventCount, events: await handle.read(0, undefined, { signal }) } + } finally { + await handle.close() + } +} diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index 5035d2209a..7ff934b150 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -11,6 +11,7 @@ import type { ContinuableStart } from '@deepseek-ai/dsh-subagent' import { errorMessage, TeamError } from './error.ts' import type { TeamJournal } from './journal.ts' import type { TeamRuntimeLifecycle } from './lifecycle.ts' +import { readPersistedSession } from './persisted.ts' import type { TeamState } from './projection.ts' import { messageAccepted } from './session-message.ts' import { TeamId } from './types.ts' @@ -345,7 +346,7 @@ export class TeamRoster { signal.throwIfAborted() const session = this.ctx.sessions.get(childId) if (session === undefined) { - const stored = await this.ctx.sessionPersistence.inspect(childId, signal) + const stored = await readPersistedSession(this.ctx.sessionPersistence, childId, signal) const suffix = stored.events.slice(stored.inheritedEventCount) if (messageAccepted(suffix, message => message.id === messageId)) return throw new TeamError( @@ -374,7 +375,7 @@ export class TeamRoster { try { signal.throwIfAborted() await this.ctx.sessions.flush(session) - const suffix = session.ownEvents() + const suffix = session.snapshotEvents(session.inheritedEventCount) if (messageAccepted(suffix, message => message.id === messageId)) return if (this.ctx.sessions.get(childId) !== session) continue await progress.promise @@ -397,11 +398,11 @@ export class TeamRoster { let phase: 'active' | 'failed' = 'failed' let failure = 'provisioning did not leave a resumable child Session' try { - const loaded = await this.ctx.sessionPersistence.inspect(member.id, signal) + const loaded = await readPersistedSession(this.ctx.sessionPersistence, member.id, signal) const suffix = loaded.events.slice(loaded.inheritedEventCount) const descriptor = foldSubagentDescriptor(suffix) const acceptedInitialPrompt = messageAccepted(suffix, message => message.source.kind === 'user') - if (loaded.meta.parentSession === root.id + if (loaded.header.parentSession === root.id && descriptor?.mode === 'continuable' && descriptor.provider === member.provider && acceptedInitialPrompt) { @@ -481,6 +482,6 @@ export class TeamRoster { /** Whether a Session's own suffix identifies a provider-owned subagent child. */ private subagentDescriptor(agent: Agent): boolean { - return foldSubagentDescriptor(agent.session.ownEvents()) !== undefined + return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.inheritedEventCount)) !== undefined } } diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index fb115719aa..715d83c440 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -9,6 +9,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService, { seedDescriptorTurn, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' @@ -41,6 +42,16 @@ function durable(agent: Agent): { } } +/** Read one stored session's full event log through a short-lived read handle. */ +async function storedEvents(ctx: Context, id: SessionId): Promise { + const handle = await ctx.sessionPersistence.open(id, 'read') + try { + return await handle.read() + } finally { + await handle.close() + } +} + async function disposeContext(ctx: Context): Promise { try { await ctx.fiber.dispose() @@ -118,7 +129,7 @@ function provisioning(childId: SessionId, name: string): TeamMemberSnapshot { } } -function persistedChild( +async function persistedChild( ctx: Context, rootId: SessionId, childId: SessionId, @@ -140,6 +151,11 @@ function persistedChild( start: 0, inserted: [message], }) + // Live sessions persist only through an attached agent-loop writer; this + // bare fixture session seeds its durable log directly for the cold restart. + const handle = await ctx.sessionPersistence.create(child.header) + await handle.append(child.snapshotEvents()) + await handle.close() return child } @@ -154,8 +170,8 @@ for (const backend of backends) { const activeRootId = SessionId(`${backend.name.toLowerCase()}-active-root`) const failedRootId = SessionId(`${backend.name.toLowerCase()}-failed-root`) const childId = SessionId(`${backend.name.toLowerCase()}-child`) - const activeRoot = first.ctx.agentLoop.create(activeRootId, { provider: 'mock', model: 'mock' }) - const failedRoot = first.ctx.agentLoop.create(failedRootId, { provider: 'mock', model: 'mock' }) + const activeRoot = await first.ctx.agentLoop.create(activeRootId, { provider: 'mock', model: 'mock' }) + const failedRoot = await first.ctx.agentLoop.create(failedRootId, { provider: 'mock', model: 'mock' }) // Let each root's startup recovery observe the empty initial log before // simulating the crash-only provisioning prefix. await Promise.resolve() @@ -186,7 +202,7 @@ for (const backend of backends) { signal: SIGNAL, }) await vi.waitFor(() => { expect(first.ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) - expect((await first.ctx.sessionPersistence.inspect(childId)).events + expect((await storedEvents(first.ctx, childId)) .some(event => event.type === 'user/message')).toBe(true) await first.dispose() @@ -229,7 +245,7 @@ for (const backend of backends) { const rootId = SessionId(`${backend.name.toLowerCase()}-pending-root`) const childId = SessionId(`${backend.name.toLowerCase()}-pending-child`) const first = await stack(backend, storageRoot, []) - const root = first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) + const root = await first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) await Promise.resolve() await Promise.resolve() root.session.append('team/member', { @@ -241,11 +257,8 @@ for (const backend of backends) { content: [{ type: 'text', text: 'durably pending initial task' }], source: { kind: 'user' }, }) - const child = persistedChild(first.ctx, rootId, childId, initial) - await Promise.all([ - first.ctx.sessions.flush(root.session), - first.ctx.sessions.flush(child), - ]) + await persistedChild(first.ctx, rootId, childId, initial) + await first.ctx.sessions.flush(root.session) await first.dispose() const second = await stack(backend, storageRoot, []) @@ -257,8 +270,8 @@ for (const backend of backends) { expect(durable(rootHandle.agent).members[0]?.phase).toBe('active') }) expect(second.adapter.requests).toEqual([]) - const stored = await second.ctx.sessionPersistence.inspect(childId) - expect(stored.events.some(event => event.type === 'agent/inbox/spliced' + const stored = await storedEvents(second.ctx, childId) + expect(stored.some(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.id === initial.id))).toBe(true) await rootHandle.dispose() @@ -273,7 +286,7 @@ for (const backend of backends) { const rootId = SessionId(`${backend.name.toLowerCase()}-mail-root`) const first = await stack(backend, storageRoot, [textResponse('initial teammate answer')]) - const firstLead = first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) + const firstLead = await first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) const started = await first.ctx.agentTeams.spawnTeammate(firstLead, { name: 'mail-worker', description: 'mail recovery worker', @@ -314,8 +327,8 @@ for (const backend of backends) { await vi.waitFor(() => { expect(second.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) await vi.waitFor(() => { expect(durable(rootHandle.agent).pendingMessages).toEqual([]) }) - const child = await second.ctx.sessionPersistence.inspect(started.member.id) - const peerIds = child.events.flatMap(event => event.type === 'user/message' + const child = await storedEvents(second.ctx, started.member.id) + const peerIds = child.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'team-message' ? [event.data.source.messageId] : []) @@ -334,7 +347,7 @@ for (const backend of backends) { const messageId = TeamMessageId(`${backend.name.toLowerCase()}-recorded-message`) const first = await stack(backend, storageRoot, [textResponse('initial teammate answer')]) - const firstLead = first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) + const firstLead = await first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) const started = await first.ctx.agentTeams.spawnTeammate(firstLead, { name: 'dedup-worker', description: 'mail deduplication worker', @@ -394,8 +407,8 @@ for (const backend of backends) { expect(second.ctx.agents.get(started.member.id)).toBeUndefined() expect(second.adapter.requests).toEqual([]) - const child = await second.ctx.sessionPersistence.inspect(started.member.id) - const occurrences = child.events.filter(event => event.type === 'user/message' + const child = await storedEvents(second.ctx, started.member.id) + const occurrences = child.filter(event => event.type === 'user/message' && event.data.source.kind === 'team-message' && event.data.source.messageId === messageId) expect(occurrences).toHaveLength(1) @@ -413,7 +426,7 @@ for (const backend of backends) { const childId = SessionId(`${backend.name.toLowerCase()}-inbox-child`) const messageId = TeamMessageId(`${backend.name.toLowerCase()}-pending-team-message`) const first = await stack(backend, storageRoot, []) - const root = first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) + const root = await first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) await Promise.resolve() await Promise.resolve() const provisioned = provisioning(childId, 'pending-mail-worker') @@ -454,11 +467,8 @@ for (const backend of backends) { senderName: 'lead', }, }) - const child = persistedChild(first.ctx, rootId, childId, pending) - await Promise.all([ - first.ctx.sessions.flush(root.session), - first.ctx.sessions.flush(child), - ]) + await persistedChild(first.ctx, rootId, childId, pending) + await first.ctx.sessions.flush(root.session) await first.dispose() const second = await stack(backend, storageRoot, []) @@ -471,8 +481,8 @@ for (const backend of backends) { }) expect(second.adapter.requests).toEqual([]) expect(second.ctx.agents.get(childId)).toBeUndefined() - const stored = await second.ctx.sessionPersistence.inspect(childId) - const pendingCopies = stored.events.flatMap(event => event.type === 'agent/inbox/spliced' + const stored = await storedEvents(second.ctx, childId) + const pendingCopies = stored.flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted.filter(message => message.source.kind === 'team-message' && message.source.messageId === messageId) : []) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 013d6f4cce..33a0426772 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -7,7 +7,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId, type Session } from '@deepseek-ai/dsh-session' +import { SessionLogOffset, SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -46,6 +46,16 @@ function durable(agent: Agent): { } } +/** Read one stored session's full event log through a short-lived read handle. */ +async function storedEvents(ctx: Context, id: SessionId): Promise { + const handle = await ctx.sessionPersistence.open(id, 'read') + try { + return await handle.read() + } finally { + await handle.close() + } +} + async function setup( script: ConstructorParameters[0], config: ConstructorParameters[1] = {}, @@ -64,7 +74,7 @@ async function setup( const teamFiber = await ctx.plugin(TeamService, config) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) - const lead = ctx.agentLoop.create(SessionId('lead'), { provider: 'mock', model: 'mock' }) + const lead = await ctx.agentLoop.create(SessionId('lead'), { provider: 'mock', model: 'mock' }) return { ctx, lead, adapter, storageRoot, teamFiber } } @@ -166,7 +176,7 @@ describe('Team identity and provisioning', () => { await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - const lead = ctx.agentLoop.create(SessionId('preexisting-lead'), {}) + const lead = await ctx.agentLoop.create(SessionId('preexisting-lead'), {}) const service = new TeamService(ctx) expect(service.listMembers(lead)).toEqual([expect.objectContaining({ @@ -210,12 +220,8 @@ describe('Team identity and provisioning', () => { const fresh = await spawn(ctx, lead, 'fresh-worker') await waitNoAgent(ctx, fresh.member.id) - const forkedInspection = await ctx.sessionPersistence.inspect(forked.member.id) - const freshInspection = await ctx.sessionPersistence.inspect(fresh.member.id) - expect(forkedInspection.meta.isSeeded).toBe(true) - expect(forkedInspection.inheritedEventCount).toBeGreaterThan(0) - expect(freshInspection.meta.isSeeded).toBe(false) - expect(freshInspection.inheritedEventCount).toBe(0) + expect((await ctx.sessionPersistence.stat(forked.member.id))?.header.isSeeded).toBe(true) + expect((await ctx.sessionPersistence.stat(fresh.member.id))?.header.isSeeded).toBe(false) expect(ctx.agentTeams.listMembers(lead).map(row => [row.name, row.context, row.status])).toEqual([ ['lead', undefined, 'idle'], ['fork-worker', 'fork', 'inactive'], @@ -264,6 +270,11 @@ describe('Team identity and provisioning', () => { target: 'next-turn', start: 0, inserted: [initial], }) await checkpoint + // Live sessions persist only through an attached agent-loop writer; this + // bare fixture session seeds its durable log directly for the cold reread. + const persisted = await ctx.sessionPersistence.create(liveSession.header) + await persisted.append(liveSession.snapshotEvents()) + await persisted.close() await liveFiber.dispose() await expect(internal.checkpointInitialPrompt(liveSession.id, initial.id, SIGNAL)).resolves.toBeUndefined() @@ -462,8 +473,8 @@ describe('Team identity and provisioning', () => { const handle = await ctx.agents.create({ sessionId: SessionId('ordinary-fork'), seed: lead.session.snapshotEvents(), - inheritedEventCount: lead.session.seq, meta: { parentSession: lead.id, isSeeded: true }, + inheritedEventCount: SessionLogOffset(lead.session.seq), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -967,8 +978,8 @@ describe('Team mailbox and waiting', () => { expect(durable(lead).pendingMessages).toEqual([]) const messageIds = new Set([first.messageId, second.messageId]) - const persisted = await ctx.sessionPersistence.inspect(lead.id) - const receiptOrder = persisted.events.flatMap((event) => { + const persisted = await storedEvents(ctx, lead.id) + const receiptOrder = persisted.flatMap((event) => { if (event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'team-message' && messageIds.has(message.source.messageId))) { return ['agent/inbox/spliced'] @@ -1248,12 +1259,12 @@ describe('Team mailbox and waiting', () => { const inactiveStarted = await spawn(ctx, lead, 'inactive-target') await waitNoAgent(ctx, inactiveStarted.member.id) - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect').mockRejectedValueOnce(new Error('inspect unavailable')) + const openRead = vi.spyOn(ctx.sessionPersistence, 'open').mockRejectedValueOnce(new Error('read unavailable')) const uncertain = await ctx.agentTeams.sendMessage(lead, { target: 'inactive-target', content: content('inspection failure'), delivery: 'wakeup', signal: SIGNAL, }) expect(uncertain.status).toBe('queued') - inspect.mockRestore() + openRead.mockRestore() vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt) .mockRejectedValueOnce(new Error('delivery unavailable')) @@ -1261,7 +1272,7 @@ describe('Team mailbox and waiting', () => { target: 'inactive-target', content: content('delivery failure'), delivery: 'wakeup', signal: SIGNAL, }) expect(failed.status).toBe('queued') - expect(warnings.some(warning => warning.includes('inspect unavailable'))).toBe(true) + expect(warnings.some(warning => warning.includes('read unavailable'))).toBe(true) expect(warnings.some(warning => warning.includes('delivery unavailable'))).toBe(true) ctx.agentTeams.interrupt(lead, 'live-target') @@ -1287,8 +1298,8 @@ describe('Team mailbox and waiting', () => { await waitNoAgent(ctx, betaStarted.member.id) await vi.waitFor(() => { expect(durable(lead).pendingMessages).toEqual([]) }) - const stored = await ctx.sessionPersistence.inspect(betaStarted.member.id) - const peerMessages = stored.events.filter(event => event.type === 'user/message' + const stored = await storedEvents(ctx, betaStarted.member.id) + const peerMessages = stored.filter(event => event.type === 'user/message' && event.data.source.kind === 'team-message') expect(peerMessages.map((event) => { if (event.type !== 'user/message') return undefined @@ -1369,7 +1380,7 @@ describe('Team mailbox and waiting', () => { await ctx.plugin(SubagentService) const fiber = await ctx.plugin(TeamService) const service = ctx.agentTeams - const lead = ctx.agentLoop.create(SessionId('wait-lead'), {}) + const lead = await ctx.agentLoop.create(SessionId('wait-lead'), {}) await expect(service.waitForChange(lead, 9_999, SIGNAL)) .rejects.toMatchObject({ code: 'TEAM_INVALID_TIMEOUT' }) @@ -1787,7 +1798,7 @@ describe('Team mailbox and waiting', () => { }) const entered = Promise.withResolvers() const release = Promise.withResolvers() - vi.spyOn(second.ctx.sessionPersistence, 'inspect').mockImplementationOnce(async () => { + vi.spyOn(second.ctx.sessionPersistence, 'open').mockImplementationOnce(async () => { entered.resolve(undefined) await release.promise throw new Error('late inspection failure') diff --git a/packages/experimental/agent-team/tests/test-session-query.ts b/packages/experimental/agent-team/tests/test-session-query.ts index 903626c082..a2315452cd 100644 --- a/packages/experimental/agent-team/tests/test-session-query.ts +++ b/packages/experimental/agent-team/tests/test-session-query.ts @@ -1,9 +1,55 @@ /** Minimal concrete Session query for Agent Team continuation tests. */ +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' +import type { SessionObservation, SessionObservationOptions } from '@deepseek-ai/dsh-session-query' + +/** Undisposable immutable cut over one session's header and events. */ +function cut( + source: 'live' | 'prepared', + header: SessionHeader, + events: readonly SessionEvent[], +): SessionObservation { + const lease = (): SessionObservation => ({ + source, + header, + inheritedEventCount: SessionLogOffset(0), + events, + cursor: events.at(-1)?.seq ?? -1, + retain: lease, + [Symbol.dispose]: () => {}, + }) + return lease() +} /** Session query implementation whose search faces are outside these tests. */ export class TestSessionQuery extends SessionQueryEngine { + static override inject = ['sessions', 'sessionPersistence'] + + /** Live-preferred observation backed directly by a short-lived persistence read handle. */ + override async observeSession( + sessionId: SessionId, + options: SessionObservationOptions = {}, + ): Promise { + const live = this.ctx.sessions.get(sessionId) + if (live !== undefined) return cut('live', live.header, live.snapshotEvents()) + const handle = await this.ctx.sessionPersistence.open( + sessionId, + 'read', + options.signal === undefined ? {} : { signal: options.signal }, + ) + try { + return cut( + 'prepared', + handle.header, + await handle.read(0, undefined, options.signal === undefined ? {} : { signal: options.signal }), + ) + } finally { + await handle.close() + } + } + override searchSessions(): Promise { return Promise.reject(new Error('session search is not configured in this test')) } diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index 353586a37f..4fa659efcc 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -71,7 +71,7 @@ async function setup(script: ConstructorParameters[0], legac const fiber = await ctx.plugin(toolTeam) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) - const lead = ctx.agentLoop.create(SessionId('tool-team-lead'), { provider: 'mock', model: 'mock' }) + const lead = await ctx.agentLoop.create(SessionId('tool-team-lead'), { provider: 'mock', model: 'mock' }) return { ctx, lead, fiber } } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 55761bf933..22f7b5be65 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -111,8 +111,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent', - description: 'Create an agent and session under one caller-supplied identity, owned by the accessing fiber. Constructor-driven config calls mint a fresh combined id before entering this boundary.', + signature: 'async create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Promise', + description: 'Create an agent and session under one caller-supplied identity, owned by the accessing fiber. Constructor-driven config calls mint a fresh combined id before entering this boundary. When a persistence backend is mounted, the session\'s durable identity and any seed are stored before publication.', parameters: [{ name: 'id', description: 'shared agent/session identity.' }, { name: 'options', description: 'concrete loop options.' }, { name: 'meta', description: 'optional fresh-session workspace metadata.' }], returns: 'the published running agent.', }, @@ -1460,83 +1460,41 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sessionPersistence', - summary: 'Durable append-only session storage.', - description: 'Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events.', + summary: 'Durable append-only session storage addressed through per-session handles.', + description: 'Durable append-only session storage addressed through per-session handles.\n\nStorage semantics shared by every backend: events are contiguous from seq 0 and never rewritten; a torn physical tail is never returned to a reader and is truncated by the write path before its first append; reads validate current-format records only and refuse unknown vocabulary fail-closed. `append` persists best-effort; `flush` — per handle or service-wide — is the durability barrier.\n\nVisibility: a created session is observable through `stat`/`list`/`open` in this process from the moment `create` resolves, even while a backend defers physical materialization (a pure optimization); other processes see the session only once it materializes, and a session that never materialized before a crash never existed. `SessionHandle.flush` forces materialization.\n\nFreshness: once an `append` or `flush` resolves, reads started afterwards on this backend instance observe at least that prefix.', methods: [ { - signature: 'abstract locate(meta: SessionHeader): SessionLocation | undefined', - description: 'Resolve this backend\'s independent local artifact for a session without reading, creating, flushing, or otherwise materializing it. A backend that does not own one artifact per Session returns `undefined`.', - parameters: [{ name: 'meta', description: 'the immutable session header whose artifact is requested.' }], - returns: 'the backend-specific absolute location, when one exists.', + signature: 'abstract create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise', + description: 'Create a new stored session and take its write ownership.', + parameters: [{ name: 'header', description: 'the immutable header (id, version, cwd, lineage) to store.' }, { name: 'options', description: 'optional cancellation.' }], + returns: 'a `write` handle owned by the caller; close it to release ownership.', + throws: ['{SessionAlreadyExistsError} when the id already exists.'], }, { - signature: 'abstract readonly supportsRawArtifacts: boolean', - description: 'Whether this backend exposes one verbatim raw artifact per session. A backend that declares `true` must override readRaw.', + signature: 'abstract open(id: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions): Promise', + description: 'Open an existing stored session.\n\n`read` never takes ownership and works while another handle (or process) holds write ownership. `write` atomically claims single-writer ownership; an existing active owner rejects.', + parameters: [{ name: 'id', description: 'the stored session to open.' }, { name: 'access', description: '`read` or `write`.' }, { name: 'options', description: 'optional cancellation.' }], + returns: 'the open handle.', + throws: ['{SessionPersistenceNotFoundError} when the session does not exist.', '{SessionAlreadyOwnedError} for `write` when ownership is taken.'], + }, + { + signature: 'abstract flush(): Promise', + description: 'Flush every active write handle owned by this service instance in one durability barrier: each handle\'s routed live events drain durably and its session materializes, exactly as that handle\'s own `SessionHandle.flush` would. Read handles buffer nothing and are untouched. A handle closed concurrently counts as flushed — close itself drains durably.', parameters: [], + returns: 'resolution once every write handle active at the call has flushed.', + throws: ['{AggregateError} naming each session whose flush failed; the remaining handles still flush.'], }, { - signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', - description: 'Read a session\'s backend-owned artifact text verbatim — the exact durable bytes the backend wrote (decoded from its physical encoding, e.g. a decompressed JSONL). The returned `content` is the raw text, not a reconstruction from parsed events, so it preserves backend-specific serialization (chunk packing, key order, line breaks). Callers first test supportsRawArtifacts; `undefined` then means only that the requested session has no materialized artifact.', - parameters: [{ name: '_id', description: 'the persisted session to read (unused by the default: no per-session artifact).' }, { name: 'signal', description: 'optional cancellation for backend read work.' }], - returns: 'the raw artifact plus its parsed header, or `undefined` when the session is absent.', - throws: ['when this backend does not expose per-session raw artifacts.'], + signature: 'abstract stat(id: SessionId, options?: SessionPersistenceStatOptions): Promise', + description: 'Observe one stored session without reading its event log or taking ownership.\n\nThe snapshot\'s `revision` is an opaque change token comparable only against revisions from the same service instance and session id: equal revisions may be treated as an unchanged log; unequal revisions promise nothing. Write-ownership churn does not change a revision. It exists for derived read-model caches keyed off `stat`/`list`; it plays no part in open, read, or resume.', + parameters: [{ name: 'id', description: 'the stored session to observe.' }, { name: 'options', description: 'optional cancellation.' }], + returns: 'the snapshot, or `undefined` when the session does not exist.', }, { - signature: 'abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise', - description: 'Register a new session\'s metadata. A backend MAY defer the physical write until the first append (lazy materialization), in which case a created-but-never-appended session is absent from list — abandoned sessions leave nothing behind.', - parameters: [{ name: 'meta', description: 'the immutable header (id, version, cwd, lineage) to record.' }, { name: 'inheritedEventCount', description: 'exact fork-inherited prefix length. Required for a seeded header and omitted only for an unseeded header.' }], - }, - { - signature: 'ensureMaterialized(_session: Session): Promise', - description: 'Ensure a live session has a durable header even when it has no events. Ordinary sessions remain lazily materialized; lifecycle frontends call this only when an empty session itself is a durable resumable resource.', - parameters: [{ name: '_session', description: 'exact live session whose registered header is materialized.' }], - }, - { - signature: 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', - description: '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. A seeded session\'s first materializing batch must reach its complete inherited prefix.', - parameters: [{ name: 'id', description: 'the session the batch belongs to.' }, { name: 'events', description: 'the contiguous batch to persist, in seq order.' }], - }, - { - signature: 'async prepare(id: SessionId, signal?: AbortSignal): Promise', - description: 'Prepare the exact unpublished Session used by resume. Implementations may reuse object graphs retained by an earlier 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.', - parameters: [{ name: 'id', description: 'persisted session to prepare.' }, { name: 'signal', description: 'optional cancellation for preparation work.' }], - returns: 'one owned unpublished Session preparation.', - }, - { - signature: 'abstract load(id: SessionId): Promise', - description: '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.', - parameters: [{ name: 'id', description: 'the persisted session to reload.' }], - returns: 'the header and a log ending on a balanced `turn/end`.', - }, - { - signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise', - description: '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 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.', - parameters: [{ name: 'id', description: 'the persisted session to inspect.' }, { name: 'signal', description: 'optional cancellation for queued and backend read work.' }], - returns: 'the validated header and current logical event log.', - }, - { - signature: 'abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise', - description: 'Borrow one exact inspection while retaining any reusable prepared source. A cold observation must pin the exact prepared Session that a later prepare reserves. Implementations must not degrade this operation to a detached inspect result.', - parameters: [{ name: 'id', description: 'persisted session to observe.' }, { name: 'signal', description: 'optional cancellation for preparation work.' }], - returns: 'a disposable immutable observation.', - }, - { - signature: 'abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise', - description: '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 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). A backend whose medium can seek by seq may read only the suffix; sequential media such as JSONL still parse the whole artifact and skip forward. The primitive bounds what is returned and refolded, not every backend\'s physical read.', - parameters: [{ name: 'id', description: 'the persisted session to read.' }, { name: 'fromSeq', description: 'first event offset to include.' }, { name: 'signal', description: 'optional cancellation for queued and backend read work.' }], - returns: 'storage metadata, the requested offset, and stored events with `seq >= fromSeq`.', - }, - { - signature: 'abstract list(signal?: AbortSignal): Promise', - description: 'Lightweight listing from metadata, without a full-log parse.', - parameters: [{ name: 'signal', description: 'optional cancellation for backend listing work.' }], - returns: 'one header per materialized session.', - }, - { - signature: 'abstract listSnapshots(signal?: AbortSignal): Promise', - description: 'List materialized sessions with cheap per-log change tokens.\n\nRepeated observations of an unchanged log return the same revision. A successful mutating load repair changes the next listed revision. Revisions also distinguish independently backed stores so backend-local counters cannot compare equal across different persistence sources.', - parameters: [{ name: 'signal', description: 'optional cancellation for backend snapshot-listing work.' }], - returns: 'one header and opaque revision per materialized session without loading full logs.', + signature: 'abstract list(options?: SessionPersistenceListOptions): Promise', + description: 'List every stored session visible to this process, in no promised order.', + parameters: [{ name: 'options', description: 'optional cancellation.' }], + returns: 'one snapshot per stored session.', }, ], }, @@ -1622,7 +1580,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined', description: 'The stored seq a restore tail read over `checkpoint` must start at: one event BELOW the lowest usable watermark (a row is usable when its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row pulls the floor to `0` — that key must refold the full log). The one-below anchor is load-bearing: the tail then proves how far the stored log still extends, so restore can detect a log that shrank below a row\'s watermark (crash-repair truncation) instead of serving the stale row as current — an empty tail read from the anchor yields an end below every watermark and the restore rejects for a full re-read.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }], - returns: 'the seq to hand the persistence `readFrom`, or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).', + returns: 'the offset for the stored-log suffix read (`SessionHandle.read`), or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).', }, { signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial', @@ -1632,7 +1590,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', - description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the events returned by a persistence `readFrom(id, restoreFloor(checkpoint))` and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).', + description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the stored events at or past `restoreFloor(checkpoint)` (a `SessionHandle.read` slice) and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'header', description: 'immutable metadata for the Session being restored.' }, { name: 'inheritedEventCount', description: 'exact fork-inherited prefix length supplied to unit initialization.' }], returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.', }, @@ -1773,7 +1731,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', - description: 'In-memory session store (`ctx.sessions`).\n\nPersistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose.', + description: 'In-memory session store (`ctx.sessions`).\n\nPersistence is intentionally not implemented here — the agent lifecycle attaches a session-log writer to each published session\'s write handle; a session published outside that lifecycle persists nothing.', methods: [ { signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session', @@ -3606,10 +3564,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BashEnvVariableInfo', declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: DshEnvironmentKey;\n}', }, - { - name: 'BorrowedSessionSource', - declaration: 'export type BorrowedSessionSource = Disposable & ({\n readonly source: \'prepared\';\n readonly inspection: SessionInspection;\n readonly revision: SessionPersistenceRevision;\n readonly preparedSession: Session;\n} | {\n readonly source: \'live\';\n readonly inspection: SessionInspection;\n});', - }, { name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', @@ -4798,6 +4752,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Session', declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, + { + name: 'SessionAccess', + declaration: 'export type SessionAccess = \'read\' | \'write\';', + }, { name: 'SessionAddress', declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\';\n};', @@ -4886,10 +4844,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSearchRequest', declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, - { - name: 'SessionEventSuffix', - declaration: 'export interface SessionEventSuffix extends SessionStorageMetadata {\n readonly fromSeq: SessionLogOffset;\n readonly events: readonly SessionEvent[];\n}', - }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', @@ -4934,6 +4888,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionForkValue', declaration: 'export interface SessionForkValue {\n readonly sessionId: SessionId;\n}', }, + { + name: 'SessionHandle', + declaration: 'export interface SessionHandle extends AsyncDisposable {\n readonly id: SessionId;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n readonly access: SessionAccess;\n read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise;\n append(events: readonly SessionEvent[], options?: SessionHandleAppendOptions): Promise;\n flush(options?: SessionHandleFlushOptions): Promise;\n close(): Promise;\n}', + }, + { + name: 'SessionHandleAppendOptions', + declaration: 'export interface SessionHandleAppendOptions {\n readonly signal?: AbortSignal;\n}', + }, + { + name: 'SessionHandleFlushOptions', + declaration: 'export interface SessionHandleFlushOptions {\n readonly signal?: AbortSignal;\n}', + }, + { + name: 'SessionHandleReadOptions', + declaration: 'export interface SessionHandleReadOptions {\n readonly signal?: AbortSignal;\n}', + }, { name: 'SessionHeader', declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly isSeeded: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', @@ -4970,10 +4940,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionListValue', declaration: 'export interface SessionListValue {\n readonly items: readonly SessionSummary[];\n}', }, - { - name: 'SessionLocation', - declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', - }, { name: 'SessionLogOffset', declaration: 'export type SessionLogOffset = BrandedNumber<\'SessionLogOffset\'>;', @@ -4984,7 +4950,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionObservation', - declaration: 'export interface SessionObservation extends Disposable {\n readonly source: \'live\' | \'prepared\';\n readonly header: SessionHeader;\n readonly events: readonly SessionEvent[];\n readonly inheritedEventCount: SessionLogOffsetType;\n readonly cursor: SessionSeqCursor;\n readonly revision?: SessionPersistenceRevision;\n readonly projections?: ProjectionSnapshot;\n retain(): SessionObservation;\n}', + declaration: 'export interface SessionObservation extends Disposable {\n readonly source: \'live\' | \'prepared\';\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffsetType;\n readonly events: readonly SessionEvent[];\n readonly cursor: SessionSeqCursor;\n readonly revision?: SessionPersistenceRevision;\n readonly projections?: ProjectionSnapshot;\n retain(): SessionObservation;\n}', }, { name: 'SessionObservationOptions', @@ -5006,21 +4972,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionPageRequest', declaration: 'export interface SessionPageRequest {\n readonly address: SessionAddress;\n readonly throughSeq: number;\n readonly beforeSeq?: number;\n readonly maxMessages?: number;\n}', }, + { + name: 'SessionPersistenceCreateOptions', + declaration: 'export interface SessionPersistenceCreateOptions {\n readonly signal?: AbortSignal;\n readonly inheritedEventCount?: SessionLogOffset;\n}', + }, + { + name: 'SessionPersistenceListOptions', + declaration: 'export interface SessionPersistenceListOptions {\n readonly signal?: AbortSignal;\n}', + }, + { + name: 'SessionPersistenceOpenOptions', + declaration: 'export interface SessionPersistenceOpenOptions {\n readonly signal?: AbortSignal;\n}', + }, { name: 'SessionPersistenceRevision', declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;', }, { name: 'SessionPersistenceSnapshot', - declaration: 'export interface SessionPersistenceSnapshot {\n header: SessionHeader;\n revision: SessionPersistenceRevision;\n}', + declaration: 'export interface SessionPersistenceSnapshot {\n readonly header: SessionHeader;\n readonly revision: SessionPersistenceRevision;\n readonly eventCount?: number;\n readonly sizeBytes?: number;\n}', }, { - name: 'SessionPreparation', - declaration: 'export class SessionPreparation implements Disposable {\n readonly session: Session;\n static create(session: Session, options?: SessionPreparationOptions): SessionPreparation;\n [Symbol.dispose](): void;\n}', - }, - { - name: 'SessionPreparationOptions', - declaration: 'export interface SessionPreparationOptions {\n readonly release?: () => void;\n}', + name: 'SessionPersistenceStatOptions', + declaration: 'export interface SessionPersistenceStatOptions {\n readonly signal?: AbortSignal;\n}', }, { name: 'SessionProjectionBaseline', @@ -5062,10 +5036,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionQueuedItem', declaration: 'export interface SessionQueuedItem {\n readonly id: MessageId;\n readonly placement: \'queued\' | \'steering\' | \'context\';\n readonly rpcId?: SessionRequestId;\n readonly message: {\n readonly id: MessageId;\n readonly content: readonly JsonValue[];\n };\n}', }, - { - name: 'SessionRawArtifact', - declaration: 'export interface SessionRawArtifact extends SessionStorageMetadata {\n readonly filename: string;\n readonly content: string;\n}', - }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml index 836a82fcf3..5bcf038fd8 100644 --- a/packages/feedback/message-feedback/README.i18n.yaml +++ b/packages/feedback/message-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/feedback/message-feedback/README.md -README.md: d8d2746293f516a5fca1729d574646dbd59698b7 -README.zh.md: f6396fe7a20355e17281e47515ebb92a8f880e06 +README.md: f323ee44e3196a519ab05b8195950b18d939d9ce +README.zh.md: 48944a5834e65d48bc5b3f1162981ae500e31917 diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md index d8d2746293..f323ee44e3 100644 --- a/packages/feedback/message-feedback/README.md +++ b/packages/feedback/message-feedback/README.md @@ -84,7 +84,7 @@ Mutations are optimistic and per message: a caller sends the version it last obs ### Durability and target validation -A write is staged, verified, then committed: the target message is flushed through the canonical checkpoint, the physical log prefix is re-read, and only then is the sidecar row written — feedback can never reference a message that is not durable. Cold sessions are inspected without resuming an agent, absence is decided from the persistence catalog rather than guessed, and only a real, sent assistant message is a valid target. The flush and inspect path lives in [`src/index.ts`](src/index.ts). +A write is staged, verified, then committed: the target message is flushed through the canonical checkpoint, the physical log prefix is re-read, and only then is the sidecar row written — feedback can never reference a message that is not durable. Cold sessions are read without resuming an agent, absence is decided by the persistence store's `stat` rather than guessed, and only a real, sent assistant message is a valid target. The flush and read path lives in [`src/index.ts`](src/index.ts). ### Failure modes @@ -110,7 +110,7 @@ Read these pages when the package-level contract is not enough. They move from t - [Feedback subsystem](../../../docs/subsystems/feedback.md) — the public types, Remote contract, and Web consumer details. - [Message-feedback sidecar decision](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md) — the design boundary that keeps this sidecar out of Session-log content. -- [Session persistence subsystem](../../../docs/subsystems/persistence.md) — `inspect`, `readFrom`, and `flush` semantics behind the durability barrier. +- [Session persistence subsystem](../../../docs/subsystems/persistence.md) — the handle `read`, `stat`, and `flush` semantics behind the durability barrier. - [dsh-client-ui-message-feedback](../../client/ui-message-feedback/README.md) — the browser consumer that drives the Host Remote contract. - [Feedback package map](../README.md) — where per-message feedback sits next to the log-only capture command. @@ -142,10 +142,9 @@ These limits define when the service is a poor fit or needs special operational - **Compare-and-set is single-process** — the per-Session queue serializes one service instance only; storage-domain has no cross-process conditional write, so multiple Host processes writing one storage root can still lose updates. - **No durable Session deletion cascade** — Session persistence has no deletion API, and `session/disposed`/`api-session/removed` mean detach rather than durable deletion. The service therefore retains empty rows and may leave orphan rows after out-of-band log removal instead of deleting valid feedback on detach. -- **Detach/catalog retirement window** — a request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. - **Header identity is not a content fingerprint** — `{createdAt, cwd}` detects reuse only when those fields differ; a cloned log retaining the same header identity is indistinguishable. - **Trusted caller boundary** — `list`/`put`/`delete` carry no authenticated actor or audit identity. A deployment must expose the Host gateway only through its trusted or separately authenticated boundary until authorization and attribution are added. -- **Catalog and row bounds** — a cold request scans the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. `maxNoteBytes` bounds one note, but the item count and aggregate retained bytes of one Session row are not capped; an indexed metadata read and deployment-owned row bound remain deferred until a concrete consumer defines their policy. +- **Row bounds** — `maxNoteBytes` bounds one note, but the item count and aggregate retained bytes of one Session row are not capped; a deployment-owned row bound remains deferred until a concrete consumer defines its policy. ### Dev Note diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md index f6396fe7a2..48944a5834 100644 --- a/packages/feedback/message-feedback/README.zh.md +++ b/packages/feedback/message-feedback/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" ### 持久性与目标校验 -写入按「暂存—校验—提交」进行:目标消息先通过权威 checkpoint flush,再物理重读日志前缀,之后才写入伴随记录行——反馈绝不会引用尚未持久的消息。冷会话在不恢复 agent 的情况下被检查,缺失依据持久化目录判定而非猜测,只有真实发送过的 assistant 消息才是有效目标。flush 与检查路径见 [`src/index.ts`](src/index.ts)。 +写入按「暂存—校验—提交」进行:目标消息先通过权威 checkpoint flush,再物理重读日志前缀,之后才写入伴随记录行——反馈绝不会引用尚未持久的消息。冷会话在不恢复 agent 的情况下被读取,缺失由持久化存储的 `stat` 判定而非猜测,只有真实发送过的 assistant 消息才是有效目标。flush 与读取路径见 [`src/index.ts`](src/index.ts)。 ### 故障模式 @@ -110,7 +110,7 @@ kind: "package-reference" - [反馈子系统](../../../docs/subsystems/feedback.zh.md)——公开类型、Remote 契约与 Web 消费方细节。 - [消息反馈伴随记录决策](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md)——让此伴随记录不进入会话日志内容的设计边界。 -- [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——持久性屏障背后的 `inspect`、`readFrom` 与 `flush` 语义。 +- [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——持久性屏障背后的 handle `read`、`stat` 与 `flush` 语义。 - [dsh-client-ui-message-feedback](../../client/ui-message-feedback/README.zh.md)——驱动 Host Remote 契约的浏览器消费方。 - [反馈包映射](../README.zh.md)——逐消息反馈与仅写入日志的采集命令并存的组。 @@ -142,10 +142,9 @@ kind: "package-reference" - **Compare-and-set 仅限单进程**——按 Session 划分的队列只串行化一个服务实例;storage-domain 不提供跨进程条件写,因此多个 Host 进程写入同一存储根目录时仍可能丢失更新。 - **没有持久 Session 删除级联**——Session persistence 没有删除接口,且 `session/disposed`/`api-session/removed` 表示 detach 而非持久删除。因此服务会保留空行,并可能在带外移除日志后留下遗留行,而不会在 detach 时删除仍有效的反馈。 -- **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 - **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。 - **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。 -- **目录与行边界**——由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。 +- **行边界**——`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;由部署决定的行边界,延后到具体消费方明确策略时处理。 ### 开发备注 diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts index e07a8b809c..8808b199bb 100644 --- a/packages/feedback/message-feedback/src/index.ts +++ b/packages/feedback/message-feedback/src/index.ts @@ -7,10 +7,10 @@ import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' import { Context, Service } from '@deepseek-ai/cordis' import s from '@deepseek-ai/schemastery' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { KvTable } from '@deepseek-ai/dsh-storage-domain' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { messageFeedbackDomainSpec } from './spec.ts' @@ -134,9 +134,15 @@ function nextVersion(): MessageFeedbackVersion { return randomUUID() as MessageFeedbackVersion } -/** Session inspection result that keeps absence inside the business union. */ +/** Observed session view: header identity plus the logged events. */ +interface SessionObservation { + readonly meta: SessionHeader + readonly events: readonly SessionEvent[] +} + +/** Session observation result that keeps absence inside the business union. */ type KnownSession = - | MessageFeedbackSuccess + | MessageFeedbackSuccess | MessageFeedbackRejected /** Validated note or one explicit request failure. */ @@ -296,25 +302,41 @@ export class MessageFeedbackService extends TypertRemoteService { } /** - * Resolve a live owner directly; otherwise use the storage catalog as the - * existence authority before inspecting the log. Inspection failures for a - * catalogued Session remain infrastructure failures rather than being - * guessed into the business `session-not-found` branch. + * Resolve a live owner directly; otherwise use `stat` as the existence + * authority before reading the log. Read failures for a Session that `stat` + * confirmed remain infrastructure failures rather than being guessed into + * the business `session-not-found` branch. */ private async inspectSession(sessionId: SessionId): Promise { if (this.ctx.sessions.get(sessionId) === undefined) { - const snapshots = await this.ctx.sessionPersistence.listSnapshots() - if (!snapshots.some(snapshot => snapshot.header.id === sessionId) + if (await this.ctx.sessionPersistence.stat(sessionId) === undefined && this.ctx.sessions.get(sessionId) === undefined) { return rejected({ code: 'session-not-found', sessionId }) } } - return success(await this.ctx.sessionPersistence.inspect(sessionId)) + return success(await this.observeSession(sessionId)) + } + + /** Observe a live owner's in-memory log when one exists, else the durable log. */ + private async observeSession(sessionId: SessionId): Promise { + const live = this.ctx.sessions.get(sessionId) + if (live !== undefined) return { meta: live.header, events: live.snapshotEvents() } + return await this.readDurable(sessionId) + } + + /** Read the complete durable log prefix through a fresh read handle. */ + private async readDurable(sessionId: SessionId): Promise { + const handle = await this.ctx.sessionPersistence.open(sessionId, 'read') + try { + return { meta: handle.header, events: await handle.read() } + } finally { + await handle.close() + } } /** Require the exact finalized append-origin assistant message projection. */ - private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean { - return inspection.events.some((event) => { + private hasFeedbackTarget(observation: SessionObservation, messageId: MessageFeedbackItem['messageId']): boolean { + return observation.events.some((event) => { if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false const message = deriveEventMessage(event) return message?.role === 'assistant' && message.id === messageId @@ -323,26 +345,20 @@ export class MessageFeedbackService extends TypertRemoteService { /** * Put the target log prefix behind a durability barrier before its sidecar. - * A live owner flushes through the SessionStore's canonical checkpoint; a - * cold owner is re-read from the physical durable prefix. + * A live owner flushes through the SessionStore's canonical checkpoint; the + * physical durable prefix is then re-read, which observes at least the + * flushed prefix by the `SessionPersistence` freshness guarantee. */ - private async ensureTargetDurable(inspection: SessionInspection): Promise { - const live = this.ctx.sessions.get(inspection.meta.id) - if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) { + private async ensureTargetDurable(observation: SessionObservation): Promise { + const live = this.ctx.sessions.get(observation.meta.id) + if (live !== undefined && sameHeaderIdentity(live.header, observation.meta)) { if (!(await this.ctx.sessions.flush(live))) { throw new Error( - `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`, + `message-feedback: no durability listener participated for live session '${observation.meta.id}'`, ) } - return await this.ctx.sessionPersistence.readFrom( - inspection.meta.id, - SessionLogOffset(0), - ) } - return await this.ctx.sessionPersistence.readFrom( - inspection.meta.id, - SessionLogOffset(0), - ) + return await this.readDurable(observation.meta.id) } /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */ diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index 3ae1363506..caf81258f3 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -4,20 +4,21 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import SessionStore, { +import SessionStore, { SessionLogOffset, SESSION_FORMAT_VERSION, Session, SessionId, - SessionLogOffset, type SessionEvent, type SessionHeader, - type SessionLogOffset as SessionLogOffsetType, } from '@deepseek-ai/dsh-session' import SessionPersistence, { + SessionAlreadyExistsError, + SessionHandleClosedError, + SessionPersistenceNotFoundError, SessionPersistenceRevision, - type SessionEventSuffix, - type SessionInspection, - type SessionLocation, + SessionReadOnlyError, + type SessionAccess, + type SessionHandle, type SessionPersistenceSnapshot, } from '@deepseek-ai/dsh-session-persistence' import Storage from '@deepseek-ai/dsh-storage' @@ -111,90 +112,88 @@ export function messageFixture( return { session, ...appendMessageFixture(session) } } +/** One stored session in the in-memory test backend. */ +interface StoredSession { + readonly meta: SessionHeader + events: readonly SessionEvent[] +} + /** Minimal controllable persistence provider for service-level tests. */ class TestPersistence extends SessionPersistence { - override readonly supportsRawArtifacts = false + readonly durable = new Map() + readFailure: Error | undefined + statCalls = 0 + readCalls = 0 + onRead: (() => void | Promise) | undefined + onStat: (() => void | Promise) | undefined - static inject = ['sessions'] - - readonly durable = new Map() - readonly logical = new Map() - inspectFailure: Error | undefined - inspectCalls = 0 - readFromCalls = 0 - onReadFrom: (() => void | Promise) | undefined - onListSnapshots: (() => void | Promise) | undefined - - locate(_meta: SessionHeader): SessionLocation | undefined { return undefined } - create(_meta: SessionHeader): Promise { return Promise.resolve() } - append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } - - load(id: SessionId): Promise { - return this.readFrom(id, SessionLogOffset(0)) + async create(header: SessionHeader): Promise { + if (this.durable.has(header.id)) throw new SessionAlreadyExistsError(header.id) + const stored: StoredSession = { meta: header, events: [] } + this.durable.set(header.id, stored) + return this.handle(stored, 'write') } - inspect(id: SessionId): Promise { - this.inspectCalls += 1 - if (this.inspectFailure !== undefined) return Promise.reject(this.inspectFailure) - const explicit = this.logical.get(id) - if (explicit !== undefined) return Promise.resolve(explicit) - const live = this.ctx.sessions.get(id) - if (live !== undefined) { - return Promise.resolve({ - meta: live.header, - inheritedEventCount: live.inheritedEventCount, - events: live.snapshotEvents(), - }) - } + // Appends are durable on resolution here; nothing buffers, so the service-wide flush is a no-op. + async flush(): Promise {} + + async open(id: SessionId, access: SessionAccess): Promise { const stored = this.durable.get(id) - return stored === undefined - ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) - : Promise.resolve(stored) + if (stored === undefined) throw new SessionPersistenceNotFoundError(id) + return this.handle(stored, access) } - borrowSession(_id: SessionId, _signal?: AbortSignal): ReturnType { - return Promise.reject(new Error('not used')) - } - - async readFrom( - id: SessionId, - fromSeq: SessionLogOffsetType, - ): Promise { - this.readFromCalls += 1 - await this.onReadFrom?.() + async stat(id: SessionId): Promise { + this.statCalls += 1 + await this.onStat?.() const stored = this.durable.get(id) - return stored === undefined - ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) - : { - meta: stored.meta, - inheritedEventCount: stored.inheritedEventCount, - fromSeq, - events: stored.events.filter(event => event.seq >= fromSeq), - } + if (stored === undefined) return undefined + return { header: stored.meta, revision: SessionPersistenceRevision(`test:${id}:${stored.events.length}`) } } - list(): Promise { - return Promise.resolve([...this.durable.values()].map(value => value.meta)) - } - - async listSnapshots(): Promise { - await this.onListSnapshots?.() - return [...this.durable.values()].map((value, index) => ({ - header: value.meta, - revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`), + async list(): Promise { + return [...this.durable.entries()].map(([id, stored]) => ({ + header: stored.meta, + revision: SessionPersistenceRevision(`test:${id}:${stored.events.length}`), })) } - persist(session: Session): void { - this.durable.set(session.id, { - meta: session.header, - inheritedEventCount: session.inheritedEventCount, - events: session.snapshotEvents(), - }) + private handle(stored: StoredSession, access: SessionAccess): SessionHandle { + let closed = false + const handle: SessionHandle = { + id: stored.meta.id, + header: stored.meta, + inheritedEventCount: SessionLogOffset(0), + access, + read: async (offset = 0, length?: number) => { + if (closed) throw new SessionHandleClosedError(stored.meta.id, 'read') + this.readCalls += 1 + if (this.readFailure !== undefined) throw this.readFailure + await this.onRead?.() + const events = stored.events.filter(event => event.seq >= offset) + return length === undefined ? events : events.slice(0, length) + }, + append: async (events) => { + if (closed) throw new SessionHandleClosedError(stored.meta.id, 'append') + if (access !== 'write') throw new SessionReadOnlyError(stored.meta.id, 'append') + stored.events = [...stored.events, ...events] + }, + flush: async () => { + if (closed) throw new SessionHandleClosedError(stored.meta.id, 'flush') + if (access !== 'write') throw new SessionReadOnlyError(stored.meta.id, 'flush') + }, + close: async () => { closed = true }, + [Symbol.asyncDispose]() { return handle.close() }, + } + return handle } - setDurable(inspection: SessionInspection): void { - this.durable.set(inspection.meta.id, inspection) + persist(session: Session): void { + this.durable.set(session.id, { meta: session.header, events: [...session.snapshotEvents()] }) + } + + setDurable(stored: StoredSession): void { + this.durable.set(stored.meta.id, stored) } } diff --git a/packages/feedback/message-feedback/tests/loader-composition.spec.ts b/packages/feedback/message-feedback/tests/loader-composition.spec.ts index a682dfd3d9..5fc528360b 100644 --- a/packages/feedback/message-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/message-feedback/tests/loader-composition.spec.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Include from '@deepseek-ai/cordis-plugin-include' import Loader from '@deepseek-ai/cordis-plugin-loader' -import SessionStore, { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import Storage from '@deepseek-ai/dsh-storage' import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' @@ -66,7 +66,6 @@ describe('message feedback through a real Loader composition', () => { ' config:', ` root: ${JSON.stringify(join(root, 'sessions'))}`, ' compression: none', - ' writeBatchMaxDelayMs: 1', "- name: '@deepseek-ai/dsh-storage'", "- name: '@deepseek-ai/dsh-storage-json'", ' config:', @@ -88,6 +87,9 @@ describe('message feedback through a real Loader composition', () => { const session = first.sessions.create(SessionId('loader-feedback'), { meta: { cwd: root }, }) + // The mounted backend routes this published session's `session/event` + // batches and `session/flush` barriers into its active write handle. + const writeHandle = await first.sessionPersistence.create(session.header) const fixture = appendMessageFixture(session) const put = await first.messageFeedback.put({ sessionId: session.id, @@ -97,11 +99,14 @@ describe('message feedback through a real Loader composition', () => { ifVersion: null, }) if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`) - const durable = await first.sessionPersistence.readFrom(session.id, SessionLogOffset(0)) - expect(durable.events.some(event => + const readHandle = await first.sessionPersistence.open(session.id, 'read') + const durableEvents = await readHandle.read() + await readHandle.close() + expect(durableEvents.some(event => event.type === 'assistant/message' && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true) + await writeHandle.close() await first.fiber.dispose() contexts.splice(contexts.indexOf(first), 1) diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index 92b65ce2d4..82eb07e818 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -62,13 +62,9 @@ describe('MessageFeedbackService public contract', () => { }) const fixture = messageFixture('corrupt-session') - persistence.setDurable({ - meta: fixture.session.header, - inheritedEventCount: fixture.session.inheritedEventCount, - events: fixture.session.snapshotEvents(), - }) + persistence.setDurable({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }) const corruption = new Error('stored log checksum mismatch') - persistence.inspectFailure = corruption + persistence.readFailure = corruption await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) }) @@ -77,7 +73,7 @@ describe('MessageFeedbackService public contract', () => { const sessionId = SessionId('catalog-live-race') const listed = Promise.withResolvers() const release = Promise.withResolvers() - persistence.onListSnapshots = async () => { + persistence.onStat = async () => { listed.resolve(undefined) await release.promise } @@ -88,7 +84,8 @@ describe('MessageFeedbackService public contract', () => { release.resolve(undefined) await expect(pending).resolves.toEqual({ ok: true, value: { items: [] } }) - expect(persistence.inspectCalls).toBe(1) + expect(persistence.statCalls).toBe(1) + expect(persistence.readCalls).toBe(0) }) it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => { @@ -189,7 +186,7 @@ describe('MessageFeedbackService public contract', () => { const fixture = messageFixture('note-limits') persistence.persist(fixture.session) const messageId = fixture.assistantMessageIds[0] - const before = persistence.inspectCalls + const before = persistence.statCalls + persistence.readCalls await expect(ctx.messageFeedback.put({ sessionId: fixture.session.id, @@ -208,7 +205,7 @@ describe('MessageFeedbackService public contract', () => { ok: false, error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 }, }) - expect(persistence.inspectCalls).toBe(before) + expect(persistence.statCalls + persistence.readCalls).toBe(before) expectItem(await ctx.messageFeedback.put({ sessionId: fixture.session.id, @@ -261,8 +258,12 @@ describe('MessageFeedbackService public contract', () => { const rawCtx = new Context() rawCtx.provide('sessions', { get: () => undefined } as never) rawCtx.provide('sessionPersistence', { - listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]), - inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }), + stat: () => Promise.resolve({ header: fixture.session.header, revision: 'test' }), + open: () => Promise.resolve({ + header: fixture.session.header, + read: () => Promise.resolve(fixture.session.snapshotEvents()), + close: () => Promise.resolve(), + }), } as never) const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 }) await expect(raw.list({ sessionId: fixture.session.id })) @@ -475,7 +476,7 @@ describe('MessageFeedbackService item concurrency', () => { const release = Promise.withResolvers() let physicalReads = 0 let committed = 0 - persistence.onReadFrom = async () => { + persistence.onRead = async () => { physicalReads += 1 if (physicalReads !== 1) return started.resolve(undefined) @@ -511,28 +512,24 @@ describe('MessageFeedbackService item concurrency', () => { expectItem(await first) expectItem(await second) await disposal - expect(physicalReads).toBe(2) + expect(physicalReads).toBe(4) expect(committed).toBe(2) }) }) describe('MessageFeedbackService durability ordering', () => { - it('rejects a logical target missing from the cold physical durable prefix', async () => { + it('rejects a live target missing from the re-read physical durable prefix', async () => { const { ctx, persistence } = await harness() - const fixture = messageFixture('cold-prefix') - persistence.logical.set(fixture.session.id, { - meta: fixture.session.header, - inheritedEventCount: fixture.session.inheritedEventCount, - events: fixture.session.snapshotEvents(), + const session = ctx.sessions.create(SessionId('live-prefix'), { + meta: { createdAt: 50, cwd: '/prefix' }, }) - persistence.setDurable({ - meta: fixture.session.header, - inheritedEventCount: fixture.session.inheritedEventCount, - events: [], + const fixture = appendMessageFixture(session) + ctx.on('session/flush', () => { + persistence.setDurable({ meta: session.header, events: [] }) }) await expect(ctx.messageFeedback.put({ - sessionId: fixture.session.id, + sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null, @@ -540,12 +537,12 @@ describe('MessageFeedbackService durability ordering', () => { ok: false, error: { code: 'target-not-found', - sessionId: fixture.session.id, + sessionId: session.id, messageId: fixture.assistantMessageIds[0], }, }) - expect(persistence.readFromCalls).toBe(1) - await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).resolves.toEqual({ + expect(persistence.readCalls).toBe(1) + await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({ ok: true, value: { items: [] }, }) @@ -565,7 +562,7 @@ describe('MessageFeedbackService durability ordering', () => { ctx.on('domain/changed', (change) => { if (change.domain === 'message_feedback') order.push('sidecar:durable') }) - persistence.onReadFrom = () => { order.push('session:verified') } + persistence.onRead = () => { order.push('session:verified') } expectItem(await ctx.messageFeedback.put({ sessionId: session.id, @@ -574,7 +571,7 @@ describe('MessageFeedbackService durability ordering', () => { ifVersion: null, })) expect(order).toEqual(['session:durable', 'session:verified', 'sidecar:durable']) - expect(persistence.readFromCalls).toBe(1) + expect(persistence.readCalls).toBe(1) expect(persistence.durable.get(session.id)?.events).toContainEqual( expect.objectContaining({ type: 'assistant/message' }), ) @@ -655,7 +652,7 @@ describe('MessageFeedbackService durability ordering', () => { expect(ctx.sessions.get(session.id)).toBeUndefined() release.resolve(undefined) expectItem(await pending) - expect(persistence.readFromCalls).toBe(1) + expect(persistence.readCalls).toBe(1) await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({ ok: true, value: { items: [{ messageId: fixture.assistantMessageIds[0] }] }, diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 774d04674c..9e15589025 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => ctx = await fsHarness(workdir, SYSTEM) // agentLoop.create prepares a session with no cwd, so the provider default // (config.cwd = workdir) is the workspace. - const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const agent = await ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index d2749378bf..9e5ea7e87e 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -96,7 +96,7 @@ async function harness(script: ScriptEntry[]): Promise { await ctx.plugin(AgentLoop, { agents: [] }) const adapter = new ScriptedAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId(`goal-session-${Math.random()}`), { + const agent = await ctx.agentLoop.create(SessionId(`goal-session-${Math.random()}`), { provider: 'mock', model: 'mock', }) @@ -222,7 +222,7 @@ describe('same-session goal driving', () => { await ctx.plugin(AgentLoop, { agents: [] }) const adapter = new ScriptedAdapter([textResponse('after resume')]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('goal-session-hot-load'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('goal-session-hot-load'), { provider: 'mock', model: 'mock' }) const created = ctx.goals.create(agent, { objective: 'wait for a human', maxGoalRounds: 1 }) await ctx.plugin(goalSession) diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index 0ac97a7a0b..b8a32f2370 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -65,7 +65,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -86,7 +86,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -108,7 +108,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -132,7 +132,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -150,7 +150,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -171,7 +171,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -187,7 +187,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -203,7 +203,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -223,8 +223,8 @@ describe('chain semantics', () => { toolCallResponse('b3', 'probe', { q: 1 }), textResponse('done'), ])) - const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' }) - const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' }) + const agentA = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' }) + const agentB = await ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' }) agentA.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) agentB.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) @@ -243,7 +243,7 @@ describe('chain semantics', () => { textResponse('turn two done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } })) @@ -263,15 +263,15 @@ describe('chain semantics', () => { // Loop agents are torn down by disposing the scope that created them // (the loop.spec pattern): a child plugin fiber owns `first`. let first!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + first = await inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) first.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, first) await fiber.dispose() await first.whenIdle() - const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) + const second = await ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) second.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, second) @@ -287,7 +287,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -303,7 +303,7 @@ describe('chain semantics', () => { toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2 textResponse('done'), ])) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -327,7 +327,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -357,7 +357,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude-code/README.i18n.yaml b/packages/hooks/hooks-claude-code/README.i18n.yaml index 28a6b22cd9..ac4f5023a5 100644 --- a/packages/hooks/hooks-claude-code/README.i18n.yaml +++ b/packages/hooks/hooks-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/hooks/hooks-claude-code/README.md -README.md: b90e33a840370215aeb9a0d0f60bd0a0def9adbc -README.zh.md: a9c183b293b388129e0663bac6c1031312dcb327 +README.md: 6d6d789ec0adc765825adaf49969d281b46b73d9 +README.zh.md: 40ae81924b836dbd4bc28cf61465829cfd25ddde diff --git a/packages/hooks/hooks-claude-code/README.md b/packages/hooks/hooks-claude-code/README.md index b90e33a840..6d6d789ec0 100644 --- a/packages/hooks/hooks-claude-code/README.md +++ b/packages/hooks/hooks-claude-code/README.md @@ -88,7 +88,7 @@ Each supported event programs against one harness extension point: `SessionStart ### Payloads and environment -The bridge builds each event's stdin payload from a base of `session_id`, string-shaped `transcript_path`, `cwd`, and `hook_event_name` plus per-event fields. `transcript_path` resolves through `ctx.sessionPersistence.locate(session.header)` when available and otherwise is `''`; lookup never creates or flushes the artifact. `CLAUDE_PROJECT_DIR` defaults per-run to the session workspace when `projectDir` is omitted, matching the directory the hook runs in; `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PROJECT_DIR}` substitution happens at config parse time. +The bridge builds each event's stdin payload from a base of `session_id`, string-shaped `transcript_path`, `cwd`, and `hook_event_name` plus per-event fields. `transcript_path` stays in the payload for compatibility but is always `''`: the persistence seam exposes no artifact paths, and the default-zstd session log is not readable by hook scripts. `CLAUDE_PROJECT_DIR` defaults per-run to the session workspace when `projectDir` is omitted, matching the directory the hook runs in; `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PROJECT_DIR}` substitution happens at config parse time. ### Matcher subjects and serial execution @@ -176,9 +176,9 @@ These limits describe what your Claude Code hooks cannot do through this bridge - **`UserPromptSubmit` is partial** — blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout. - **`PreToolUse` is partial** — `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). - **`PostToolUse` is partial** — blocking feedback and JSON `additionalContext` work, but `updatedToolOutput` and `updatedMCPToolOutput` are unsupported and `tool_response` is flattened to text. -- **`SubagentStart` and `SubagentStop` are partial** — both report a constant `agent_type` of `general-purpose` and use the child session id where Claude Code reports the parent session. Start context is best-effort and can only reach a live in-process child; stop is observe-only and cannot block the subagent or feed it context. Start omits `transcript_path`; stop also omits `agent_transcript_path`, `last_assistant_message`, `background_tasks`, and `session_crons` and always reports `stop_hook_active: false`. +- **`SubagentStart` and `SubagentStop` are partial** — both report a constant `agent_type` of `general-purpose` and use the child session id where Claude Code reports the parent session. Start context is best-effort and can only reach a live in-process child; stop is observe-only and cannot block the subagent or feed it context. Stop omits `agent_transcript_path`, `last_assistant_message`, `background_tasks`, and `session_crons` and always reports `stop_hook_active: false`. - **`Stop` is partial** — blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message`, `background_tasks`, and `session_crons` are omitted, and the consecutive-block cap is not implemented. An unconditionally blocking hook therefore force-continues every step unless it self-limits. -- **Common payload and output fields are partial** — mapped event payloads omit `prompt_id`, `transcript_path`, `permission_mode`, and `effort` where Claude Code would provide them. `systemMessage` is logged + warned but not surfaced; `{"continue": false}` is recorded but does not halt the run; `suppressOutput`, `stopReason`, and `terminalSequence` are not applied. +- **Common payload and output fields are partial** — mapped event payloads omit `prompt_id`, `permission_mode`, and `effort` where Claude Code would provide them, and `transcript_path` is never populated: it is always the empty string, because the persistence seam exposes no artifact paths and the default-zstd session log is not readable by hook scripts. `systemMessage` is logged + warned but not surfaced; `{"continue": false}` is recorded but does not halt the run; `suppressOutput`, `stopReason`, and `terminalSequence` are not applied. - **Handler and config support is partial** — only shell-form command handlers run. `http`, `mcp_tool`, `prompt`, and `agent` handlers are skipped; command-handler options such as `args`, `async`, `asyncRewake`, `shell`, `if`, `once`, and `statusMessage` are not honored. Matching handlers run serially and are not deduplicated, whereas Claude Code runs them in parallel and deduplicates identical handlers. One process-level `configPath` is parsed once at load; Claude Code's layered project, user, plugin, and policy discovery and live reload are not implemented. diff --git a/packages/hooks/hooks-claude-code/README.zh.md b/packages/hooks/hooks-claude-code/README.zh.md index a9c183b293..40ae81924b 100644 --- a/packages/hooks/hooks-claude-code/README.zh.md +++ b/packages/hooks/hooks-claude-code/README.zh.md @@ -88,7 +88,7 @@ kind: "package-reference" ### 载荷与环境 -桥接从 `session_id`、字符串形态的 `transcript_path`、`cwd` 与 `hook_event_name` 的基础字段加逐事件字段构建每个事件的 stdin payload。可用时 `transcript_path` 通过 `ctx.sessionPersistence.locate(session.header)` 解析,否则为 `''`;查找从不创建或 flush 产物。省略 `projectDir` 时,`CLAUDE_PROJECT_DIR` 按次默认到会话工作区,与钩子运行的目录一致;`${CLAUDE_PLUGIN_ROOT}` 与 `${CLAUDE_PROJECT_DIR}` 替换在配置解析时进行。 +桥接从 `session_id`、字符串形态的 `transcript_path`、`cwd` 与 `hook_event_name` 的基础字段加逐事件字段构建每个事件的 stdin payload。`transcript_path` 出于兼容性保留在 payload 中,但始终为 `''`:持久化 seam 不暴露产物路径,且默认 zstd 压缩的会话日志无法被 hook 脚本读取。省略 `projectDir` 时,`CLAUDE_PROJECT_DIR` 按次默认到会话工作区,与钩子运行的目录一致;`${CLAUDE_PLUGIN_ROOT}` 与 `${CLAUDE_PROJECT_DIR}` 替换在配置解析时进行。 ### Matcher subject 与串行执行 @@ -176,9 +176,9 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记 - **`UserPromptSubmit` 只支持部分功能**——支持阻塞与 JSON `additionalContext`,但不支持纯 stdout 上下文、`sessionTitle` 与 `suppressOriginalPrompt`。除非被覆盖,否则桥接还会使用自身 600 秒默认值,而非 Claude Code 的事件特定 30 秒 command 超时。 - **`PreToolUse` 只支持部分功能**——`deny` 与 `ask` 决策可用;`allow` 不会预审批,`defer` 不受支持,`additionalContext` 会被忽略,`updatedInput` 会被记录 + 警告但不应用(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md))。 - **`PostToolUse` 只支持部分功能**——支持阻塞反馈与 JSON `additionalContext`,但不支持 `updatedToolOutput` 与 `updatedMCPToolOutput`,`tool_response` 会展平为文本。 -- **`SubagentStart` 与 `SubagentStop` 只支持部分功能**——两者均报告常量 `agent_type` `general-purpose`,并在 Claude Code 报告父会话的位置使用 child 会话 id。Start 上下文是尽力而为,且只能到达仍在运行的同进程 child;stop 只观测,无法阻塞 subagent 或向其提供上下文。Start 省略 `transcript_path`;stop 还省略 `agent_transcript_path`、`last_assistant_message`、`background_tasks` 与 `session_crons`,并始终报告 `stop_hook_active: false`。 +- **`SubagentStart` 与 `SubagentStop` 只支持部分功能**——两者均报告常量 `agent_type` `general-purpose`,并在 Claude Code 报告父会话的位置使用 child 会话 id。Start 上下文是尽力而为,且只能到达仍在运行的同进程 child;stop 只观测,无法阻塞 subagent 或向其提供上下文。Stop 省略 `agent_transcript_path`、`last_assistant_message`、`background_tasks` 与 `session_crons`,并始终报告 `stop_hook_active: false`。 - **`Stop` 只支持部分功能**——阻塞会强制另一个模型轮次,但 `stop_hook_active` 始终为 `false`,会省略 `last_assistant_message`、`background_tasks` 与 `session_crons`,且未实现连续阻塞上限。因此,无条件阻塞 hook 会在每个步骤中强制 continuation,除非它自我限制。 -- **通用 payload 与输出字段只支持部分功能**——已映射事件会省略 Claude Code 原本会提供的 `prompt_id`、`transcript_path`、`permission_mode` 与 `effort`。`systemMessage` 会被记录 + 警告但不呈现;`{"continue": false}` 会被记录但不会停止运行;`suppressOutput`、`stopReason` 与 `terminalSequence` 不会被应用。 +- **通用 payload 与输出字段只支持部分功能**——已映射事件会省略 Claude Code 原本会提供的 `prompt_id`、`permission_mode` 与 `effort`,且 `transcript_path` 永不填充:它始终为空字符串,因为持久化 seam 不暴露产物路径,且默认 zstd 压缩的会话日志无法被 hook 脚本读取。`systemMessage` 会被记录 + 警告但不呈现;`{"continue": false}` 会被记录但不会停止运行;`suppressOutput`、`stopReason` 与 `terminalSequence` 不会被应用。 - **Handler 与配置只支持部分功能**——只运行 shell 形态 command handler。会跳过 `http`、`mcp_tool`、`prompt` 与 `agent` handler;`args`、`async`、`asyncRewake`、`shell`、`if`、`once` 与 `statusMessage` 等 command handler 选项不会被遵循。匹配 handler 串行运行且不去重,而 Claude Code 会并行运行并对相同 handler 去重。一个进程级 `configPath` 会在加载时解析一次;尚未实现 Claude Code 的分层项目、用户、插件与策略发现以及实时重新加载。 diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index fe735edd9a..28234e6b38 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -34,7 +34,6 @@ "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^", @@ -50,7 +49,6 @@ "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/hooks/hooks-claude-code/src/index.ts b/packages/hooks/hooks-claude-code/src/index.ts index e82b728a8e..688d1a065b 100644 --- a/packages/hooks/hooks-claude-code/src/index.ts +++ b/packages/hooks/hooks-claude-code/src/index.ts @@ -17,7 +17,6 @@ import type {} from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-persistence' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, @@ -205,7 +204,7 @@ export function apply(ctx: Context, config: Config): void { // may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', ({ agent, source }) => { - detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal }) + detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context) agent.inject(context) @@ -220,7 +219,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise => { if (messages.length === 0) return next() const content = messages.flatMap(message => message.content) - const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal }) + const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn, signal }) if (merged.decision === 'deny') { return { kind: 'reject' } } @@ -238,7 +237,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(ctx, exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } return next() @@ -247,7 +246,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(ctx, exec.agent) - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -269,7 +268,7 @@ export function apply(ctx: Context, config: Config): void { // machine observe pending input and run another step. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { - const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal }) + const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. const text = merged.reason ?? 'continue: blocked by Stop hook' @@ -282,7 +281,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('subagent/start', (info) => { const child = ctx.get('agents')?.get(info.id) if (child !== undefined) subagentChildren.set(info.runId, child) - detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) + detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context && child) child.inject(context) @@ -292,7 +291,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('subagent/end', (info) => { const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id) subagentChildren.delete(info.runId) - detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) + detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) }) } @@ -319,31 +318,31 @@ function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') } -function base(ctx: Context, agent: Agent | undefined, event: string): Record { +function base(agent: Agent | undefined, event: string): Record { return { session_id: agent?.session.header.id ?? '', - transcript_path: agent === undefined - ? '' - : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? '', + // The persistence seam exposes no artifact path; the field stays empty + // (a durable consumer gap recorded in this package's README). + transcript_path: '', cwd: agent?.session.header.cwd ?? process.cwd(), hook_event_name: event, } } -function sessionStartPayload(ctx: Context, agent: Agent, source: string): Record { - return { ...base(ctx, agent, 'SessionStart'), source } +function sessionStartPayload(agent: Agent, source: string): Record { + return { ...base(agent, 'SessionStart'), source } } -function promptPayload(ctx: Context, agent: Agent, content: ContentBlock[]): Record { - return { ...base(ctx, agent, 'UserPromptSubmit'), prompt: blocksToText(content) } +function promptPayload(agent: Agent, content: ContentBlock[]): Record { + return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) } } -function preToolPayload(ctx: Context, exec: ToolExecution): Record { - return { ...base(ctx, exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId } +function preToolPayload(exec: ToolExecution): Record { + return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId } } -function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): Record { - return { ...base(ctx, exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } +function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record { + return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } } -function stopPayload(ctx: Context, agent: Agent): Record { - return { ...base(ctx, agent, 'Stop'), stop_hook_active: false } +function stopPayload(agent: Agent): Record { + return { ...base(agent, 'Stop'), stop_hook_active: false } } /** * Build a SubagentStart/SubagentStop payload from the CC base (the child's @@ -351,9 +350,9 @@ function stopPayload(ctx: Context, agent: Agent): Record { * fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active` * is present on SubagentStop only (the loop-guard flag, always false). */ -function subagentPayload(ctx: Context, event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record { +function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record { return { - ...base(ctx, child, event), + ...base(child, event), agent_id: info.id, agent_type: SUBAGENT_TYPE, ...event === 'SubagentStop' ? { stop_hook_active: false } : {}, diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index fb24106d0e..7f5facfa65 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -103,7 +103,7 @@ describe('hooks-claude-code bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -124,7 +124,7 @@ describe('hooks-claude-code bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -149,7 +149,7 @@ describe('hooks-claude-code bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'use danger' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -172,7 +172,7 @@ describe('hooks-claude-code bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'use safe' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -194,7 +194,7 @@ describe('hooks-claude-code bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -215,7 +215,7 @@ describe('hooks-claude-code bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -239,7 +239,7 @@ describe('hooks-claude-code bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -263,7 +263,7 @@ describe('hooks-claude-code bridge — SessionStart', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // session-start fires async (detached .then → agent.inject); injection now // enters the next-step inbox directly and becomes a user/message only after // step entry, so synchronize on the pending inbox item before sending. @@ -362,7 +362,7 @@ describe('hooks-claude-code bridge — load resilience', () => { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) // The turn ran normally — no hooks, no crash. @@ -377,7 +377,7 @@ describe('hooks-claude-code bridge — load resilience', () => { const adapter = new MockAdapter([textResponse('fine')]) const warn = vi.fn() const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) - const agent = ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -396,7 +396,7 @@ describe('hooks-claude-code bridge — load resilience', () => { const adapter = new MockAdapter([textResponse('should not run')]) const warn = vi.fn() const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) - const agent = ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -424,7 +424,7 @@ describe('hooks-claude-code bridge — load resilience', () => { const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index af39599dd2..0f90281364 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -70,27 +70,20 @@ export type CoverageGroup = 'config' | 'stop' | 'context' | 'edge-paths' /** Register independently schedulable slices of the hooks-claude-code coverage matrix. */ export function defineCoverageCases(group: CoverageGroup): void { if (group === 'config') describe('hooks-claude-code coverage — config option arms + substitution + skip warning', () => { - it('uses the persistence locator for transcript_path and an empty string without one', async () => { - async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string }; expected: string | undefined }> { - const d = dir() - const cap = join(d, 'payload') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) - ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - return { - payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }, - expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path, - } - } - - const located = await capture(dir()) - expect(located.payload.transcript_path).toBe(located.expected) - expect((await capture()).payload.transcript_path).toBe('') - }, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom. + it('degrades transcript_path to the empty string even with persistence mounted', async () => { + const d = dir() + const cap = join(d, 'payload') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { sessionRoot: dir() }) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = await ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + // The persistence seam exposes no artifact paths, so the field stays '' + // even with a persistence backend mounted. + expect((JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }).transcript_path).toBe('') + }, 15_000) // The real agent/hook subprocess loop needs process startup and teardown headroom. it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => { const d = dir() @@ -108,7 +101,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) ctx.logger.warn = warn as never ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran @@ -124,7 +117,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.logger.warn = warn as never let sawArgs: unknown ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) // updatedInput is NOT honored — the tool ran with the ORIGINAL args. @@ -140,7 +133,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ran')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) // The prompt proceeded unchanged; no injected context. @@ -168,7 +161,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -193,7 +186,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -209,7 +202,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -225,7 +218,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) // A second model request ran → the empty-reason block forced continuation. @@ -278,7 +271,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -292,7 +285,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -321,7 +314,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked' @@ -337,7 +330,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) // ask (no reason) → degrades to deny with the registry's generic message. @@ -352,7 +345,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -379,7 +372,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // the protocol lib's reference default, not a config knob). HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) @@ -394,7 +387,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -409,7 +402,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -428,7 +421,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -445,7 +438,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -465,7 +458,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran @@ -503,7 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.on('agent/pre-step', async () => ({ kind: 'reject' as const, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) // the downstream block won: the model was never called, no user/message was @@ -533,7 +526,7 @@ export function defineCoverageCases(group: CoverageGroup): void { source: { kind: 'plugin' as const, plugin: 'policy' }, })], })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) @@ -560,7 +553,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -582,7 +575,7 @@ export function defineCoverageCases(group: CoverageGroup): void { source: { kind: 'plugin' as const, plugin: 'policy' }, })], })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -604,7 +597,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -628,7 +621,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const bash = ctx.shell bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -644,7 +637,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Make inject throw, forcing the SessionStart .catch path. const original = agent.inject.bind(agent) let threw = false @@ -737,7 +730,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) @@ -755,7 +748,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Send immediately — do NOT wait for the session-start inject. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude-code/tsconfig.json b/packages/hooks/hooks-claude-code/tsconfig.json index 23a293d40e..64e0fa1ad4 100644 --- a/packages/hooks/hooks-claude-code/tsconfig.json +++ b/packages/hooks/hooks-claude-code/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../core/session" }, - { - "path": "../../session/session-persistence" - }, { "path": "../../subagent/subagent" }, diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 1c241537fb..b03504ee30 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/hooks/hooks-codex/README.md -README.md: 9d2a2d5aff7d97521178725e5ef7ec44fce20330 -README.zh.md: 4e672e37caa42a2dcabbefe4c71549820cee9bc2 +README.md: 8ab22336f5558ab3ab7ce0fe83621994fa7b8092 +README.zh.md: ad7c2790cba3d87262f2c3b2949eb0c74d823c72 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 9d2a2d5aff..8ab22336f5 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -84,7 +84,7 @@ Each supported event programs against one harness extension point: `SessionStart ### Payloads and environment -Payloads are Codex-shaped: snake_case with `turn_id` on turn-scoped events, `model` and `permission_mode: "default"` on every event, and stdin written without a trailing newline. A tool call's payload carries the real `tool_name` and the `tool_input: { command }` shape (the `command` argument when present, else `''`), so non-shell tool arguments are not faithfully exposed. The base payload carries `session_id` and `transcript_path`; the latter resolves through `ctx.sessionPersistence.locate(session.header)` when available and otherwise is `null`, preserving the Codex `string | null` shape — lookup never creates or flushes the artifact. Codex performs no command substitution and injects no plugin environment. +Payloads are Codex-shaped: snake_case with `turn_id` on turn-scoped events, `model` and `permission_mode: "default"` on every event, and stdin written without a trailing newline. A tool call's payload carries the real `tool_name` and the `tool_input: { command }` shape (the `command` argument when present, else `''`), so non-shell tool arguments are not faithfully exposed. The base payload carries `session_id` and `transcript_path`; the latter keeps the Codex `string | null` shape but is always `null` — the persistence seam exposes no artifact paths, and the default-zstd session log is not readable by hook scripts. Codex performs no command substitution and injects no plugin environment. ### Matcher subjects and serial execution @@ -173,7 +173,7 @@ These limits describe what your Codex hooks cannot do through this bridge yet, a - **`PreToolUse` is partial** — blocking works, but `additionalContext`, `permissionDecision: "allow"`, and `updatedInput` are ignored. Every tool is represented as `tool_input: { command }`, so non-shell tool arguments are not faithfully exposed to the hook. - **`PostToolUse` is partial** — blocking feedback and JSON `additionalContext` work, but `{"continue": false}` is not enforced, non-shell tool arguments are reduced to `{ command }`, and structured tool output is flattened to text in `tool_response`. - **`Stop` is partial** — blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message` is always `null`, and `{"continue": false}` is not enforced. An unconditionally blocking hook therefore force-continues every step unless it self-limits. -- **Common payload and output fields are partial** — every mapped event reports the statically configured `model` and `permission_mode: "default"` instead of current Codex runtime values. `systemMessage` is logged + warned but not surfaced, and `{"continue": false}` is recorded but does not apply Codex's event-specific stop behavior. +- **Common payload and output fields are partial** — every mapped event reports the statically configured `model` and `permission_mode: "default"` instead of current Codex runtime values, and `transcript_path` is never populated: it is always `null`, because the persistence seam exposes no artifact paths and the default-zstd session log is not readable by hook scripts. `systemMessage` is logged + warned but not surfaced, and `{"continue": false}` is recorded but does not apply Codex's event-specific stop behavior. - **Config loading and execution are partial** — one process-level `configPath` is parsed at load; Codex's active user, project, session, system/managed, and plugin layers, trust controls, and inline `config.toml` hook form are not implemented. Only synchronous `command` handlers run, current metadata such as `statusMessage` and `commandWindows` is ignored, and matching handlers run serially rather than with Codex's concurrent launch semantics. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 4e672e37ca..ad7c2790cb 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" ### 载荷与环境 -payload 采用 Codex 形状:snake_case,轮次事件带 `turn_id`,每个事件都带 `model` 与 `permission_mode: "default"`,stdin 写入时不带尾随换行符。工具调用的 payload 携带真实 `tool_name` 与 `tool_input: { command }` 形状(存在 `command` 参数时使用该值,否则使用 `''`),因此非 shell 工具参数不会被如实公开。基础 payload 携带 `session_id` 与 `transcript_path`;可用时后者通过 `ctx.sessionPersistence.locate(session.header)` 解析,否则为 `null`,保留 Codex `string | null` 形状——查找从不创建或 flush 产物。Codex 不进行命令替换,也不注入插件环境。 +payload 采用 Codex 形状:snake_case,轮次事件带 `turn_id`,每个事件都带 `model` 与 `permission_mode: "default"`,stdin 写入时不带尾随换行符。工具调用的 payload 携带真实 `tool_name` 与 `tool_input: { command }` 形状(存在 `command` 参数时使用该值,否则使用 `''`),因此非 shell 工具参数不会被如实公开。基础 payload 携带 `session_id` 与 `transcript_path`;后者保留 Codex `string | null` 形状但始终为 `null`——持久化 seam 不暴露产物路径,且默认 zstd 压缩的会话日志无法被 hook 脚本读取。Codex 不进行命令替换,也不注入插件环境。 ### Matcher subject 与串行执行 @@ -173,7 +173,7 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记 - **`PreToolUse` 只支持部分功能**——支持阻塞,但会忽略 `additionalContext`、`permissionDecision: "allow"` 与 `updatedInput`。每个工具都表示为 `tool_input: { command }`,因此非 shell 工具参数不会被如实公开给 hook。 - **`PostToolUse` 只支持部分功能**——支持阻塞反馈与 JSON `additionalContext`,但不会强制执行 `{"continue": false}`,非 shell 工具参数会缩减为 `{ command }`,结构化工具输出会在 `tool_response` 中展平为文本。 - **`Stop` 只支持部分功能**——阻塞会强制另一个模型轮次,但 `stop_hook_active` 始终为 `false`,`last_assistant_message` 始终为 `null`,且不会强制执行 `{"continue": false}`。因此,无条件阻塞 hook 会在每个步骤中强制 continuation,除非它自我限制。 -- **通用 payload 与输出字段只支持部分功能**——每个已映射事件都报告静态配置的 `model` 与 `permission_mode: "default"`,而非当前 Codex 运行时值。`systemMessage` 会被记录 + 警告但不呈现,`{"continue": false}` 会被记录但不会应用 Codex 的事件特定停止行为。 +- **通用 payload 与输出字段只支持部分功能**——每个已映射事件都报告静态配置的 `model` 与 `permission_mode: "default"`,而非当前 Codex 运行时值,且 `transcript_path` 永不填充:它始终为 `null`,因为持久化 seam 不暴露产物路径,且默认 zstd 压缩的会话日志无法被 hook 脚本读取。`systemMessage` 会被记录 + 警告但不呈现,`{"continue": false}` 会被记录但不会应用 Codex 的事件特定停止行为。 - **配置加载与执行只支持部分功能**——一个进程级 `configPath` 会在加载时解析;尚未实现 Codex 的活动用户层、项目层、会话层、系统/托管层与插件层、信任控制以及内联 `config.toml` hook 形态。只运行同步 `command` handler,`statusMessage` 与 `commandWindows` 等当前元数据会被忽略,匹配 handler 串行运行,而非使用 Codex 的并发启动语义。 diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index f0c96eeb14..e5fa4d787b 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -34,7 +34,6 @@ "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^" @@ -49,7 +48,6 @@ "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index b8f8027c88..c3c408fb7d 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -20,7 +20,6 @@ import type {} from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-persistence' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, @@ -187,7 +186,7 @@ export function apply(ctx: Context, config: Config): void { // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', ({ agent, source }) => { - detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) + detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context) agent.inject(context) @@ -200,7 +199,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise => { if (messages.length === 0) return next() const payload = { - ...base(ctx, agent, 'UserPromptSubmit', model), + ...base(agent, 'UserPromptSubmit', model), turn_id: String(turn), prompt: blocksToText(messages.flatMap(message => message.content)), } @@ -289,12 +288,12 @@ function blocksToText(content: ContentBlock[]): string { /* jscpd:ignore-end */ /** Base fields on every Codex payload (no turn_id). */ -function base(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { +function base(agent: Agent | undefined, event: string, model: string): Record { return { session_id: agent?.session.header.id ?? '', - transcript_path: agent === undefined - ? null - : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? null, + // The persistence seam exposes no artifact path; the field stays null + // (a durable consumer gap recorded in this package's README). + transcript_path: null, cwd: agent?.session.header.cwd ?? process.cwd(), hook_event_name: event, model, @@ -304,7 +303,7 @@ function base(ctx: Context, agent: Agent | undefined, event: string, model: stri /** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */ function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { - return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(ctx, agent)) } + return { ...base(agent, event, model), turn_id: String(lastTurn(ctx, agent)) } } /** Extract a `command` string from a tool call's parsed arguments, else ''. */ diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index bdf476e0fe..511ec5e12a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -78,7 +78,7 @@ describe('hooks-codex bridge', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run ls' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -99,7 +99,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -116,7 +116,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('must not run')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'cancel the hook' }], source: { kind: 'user' } })) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) @@ -139,7 +139,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -149,7 +149,7 @@ describe('hooks-codex bridge', () => { const dir = configDir() // no hooks.json written const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -164,7 +164,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('ok')]) const warn = vi.fn() const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) - const agent = ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -191,7 +191,7 @@ describe('hooks-codex bridge', () => { const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone @@ -216,7 +216,7 @@ describe('hooks-codex bridge', () => { ctx.llm.registerAdapter(['mock'], new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start + await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await fiber.dispose() diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 04217b1ee3..85ab765f7e 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -61,34 +61,27 @@ export type CoverageGroup = 'prompt' | 'post-tool' | 'result-shape' | 'edge-path export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGroup[]): void { const selected = new Set(typeof groups === 'string' ? [groups] : groups) if (selected.has('prompt')) describe('hooks-codex coverage — prompt decision mapping', () => { - it('uses the persistence locator for transcript_path and null without one', async () => { - async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string | null }; expected: string | undefined }> { - const d = dir() - const cap = join(d, 'payload') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) - ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - return { - payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }, - expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path, - } - } - - const located = await capture(dir()) - expect(located.payload.transcript_path).toBe(located.expected) - expect((await capture()).payload.transcript_path).toBeNull() - }, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom. + it('degrades transcript_path to null even with persistence mounted', async () => { + const d = dir() + const cap = join(d, 'payload') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { sessionRoot: dir() }) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = await ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + // The persistence seam exposes no artifact paths, so the field stays null + // even with a persistence backend mounted. + expect((JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }).transcript_path).toBeNull() + }, 15_000) // The real agent/hook subprocess loop needs process startup and teardown headroom. it('UserPromptSubmit block (exit 2) closes a blocked turn without a step', async () => { const d = dir() hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked' @@ -101,7 +94,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') }) @@ -116,7 +109,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.on('agent/pre-step', async () => ({ kind: 'reject' as const, })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) @@ -140,7 +133,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro source: { kind: 'plugin' as const, plugin: 'policy' }, })], })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') @@ -162,7 +155,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) @@ -182,7 +175,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro source: { kind: 'plugin' as const, plugin: 'policy' }, })], })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') @@ -199,7 +192,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true) @@ -212,7 +205,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => agent.inbox.nextStep.some(message => message.content.some(block => block.type === 'text' && block.text.includes('start-ctx')))) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) @@ -225,7 +218,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.message.content[0].isError).toBe(true) @@ -238,7 +231,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) @@ -252,7 +245,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' }) @@ -263,7 +256,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) @@ -276,7 +269,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) @@ -299,7 +292,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') @@ -324,7 +317,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) @@ -337,7 +330,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -351,7 +344,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) @@ -363,7 +356,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject = (() => { throw new Error('inject boom') }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) @@ -378,7 +371,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -391,7 +384,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) @@ -407,7 +400,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded @@ -420,7 +413,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) @@ -432,7 +425,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.message.content[0].isError).toBe(true) @@ -449,7 +442,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } expect(payload.tool_input.command).toBe('') @@ -485,7 +478,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.shell.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) @@ -501,7 +494,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') @@ -514,7 +507,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') }) @@ -528,7 +521,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the exit-2 hook has finished expect(events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) @@ -542,7 +535,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') @@ -553,7 +546,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => agent.inbox.nextStep.some(message => message.content.some(block => block.type === 'text' && block.text.includes('session preamble')))) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) @@ -567,7 +560,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') }) @@ -582,7 +575,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } expect(payload.tool_name).toBe('shell') @@ -598,7 +591,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) @@ -610,7 +603,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') diff --git a/packages/hooks/hooks-codex/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json index 7b87f4d871..8d2002823d 100644 --- a/packages/hooks/hooks-codex/tsconfig.json +++ b/packages/hooks/hooks-codex/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../core/session" }, - { - "path": "../../session/session-persistence" - }, { "path": "../../llm/llm" }, diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 1ce91a8b29..5c5a177040 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -107,7 +107,7 @@ describe('real Loader composition', () => { const adapter = new TransientOnceAdapter() loaded.llm.registerAdapter(['mock'], adapter) - const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) + const agent = await loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } })) await agent.whenIdle() diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 61c3d2bab5..adabf7df9b 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -28,6 +28,7 @@ describe('JSONL retry-event persistence', () => { const ctx = await backend() try { const session = ctx.sessions.create(SessionId('retry-jsonl')) + const handle = await ctx.sessionPersistence.create(session.header) session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { @@ -52,9 +53,14 @@ describe('JSONL retry-event persistence', () => { expect(session.deriveMessages()).toEqual([]) await ctx.sessions.flush(session) - const loaded = await ctx.sessionPersistence.load(session.id) - - expect(loaded.events.find(item => item.type === 'llm/retry')).toEqual(event) + await handle.close() + const reader = await ctx.sessionPersistence.open(session.id, 'read') + try { + const loaded = await reader.read() + expect(loaded.find(item => item.type === 'llm/retry')).toEqual(event) + } finally { + await reader.close() + } } finally { await ctx.fiber.dispose() } diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 1516ff20a4..3ea3ee5134 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -184,7 +184,7 @@ describe('provider-routed retry policy', () => { ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ retryableCodes: ['SERVER', 'RATE_LIMIT'] }), }, undefined, { random: () => 0.5 })) - const agent = context.agentLoop.create(SessionId('retry-success'), { + const agent = await context.agentLoop.create(SessionId('retry-success'), { provider: 'mock', model: 'mock', }) @@ -235,7 +235,7 @@ describe('provider-routed retry policy', () => { // adapters' empty-completion classification end to end (finish-chunk error // delivery, not a thrown stream error). ;({ ctx: context } = await harness(adapter)) - const agent = context.agentLoop.create(SessionId('retry-empty-response'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-empty-response'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) @@ -277,7 +277,7 @@ describe('provider-routed retry policy', () => { return [{ type: 'text', text: 'unexpected' }] }, })) - const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) @@ -323,7 +323,7 @@ describe('provider-routed retry policy', () => { }) }, undefined, { random: () => samples.shift() ?? 0.5, })) - const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) const first = waitForRetry(context, agent, 1) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) @@ -354,7 +354,7 @@ describe('provider-routed retry policy', () => { ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 1 }, }) }, undefined, { random: () => 0 })) - const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) @@ -375,7 +375,7 @@ describe('provider-routed retry policy', () => { ;({ ctx: context } = await harness(accepted, { mock: normalConfig({ backoff: { jitterRatio: 1 }, }) })) - const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) + const acceptedAgent = await context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, acceptedAgent, 1) acceptedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) expect((await scheduled).data.delayMs).toBe(2_000) @@ -389,7 +389,7 @@ describe('provider-routed retry policy', () => { new LlmError('wait too long', 'RATE_LIMIT', { providerRetryAfterMs: 10_001 }), ]) ;({ ctx: context } = await harness(rejected)) - const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) + const rejectedAgent = await context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) const rejectedIdle = waitForIdle(context, rejectedAgent) rejectedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await rejectedIdle @@ -408,7 +408,7 @@ describe('provider-routed retry policy', () => { maxDelayMs: 4, jitterRatio: 0.5, }) }, undefined, { random: () => 1 })) - const agent = context.agentLoop.create(SessionId('retry-always-over-cap'), { + const agent = await context.agentLoop.create(SessionId('retry-always-over-cap'), { provider: 'mock', model: 'mock', }) @@ -427,7 +427,7 @@ describe('provider-routed retry policy', () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) ;({ ctx: context } = await harness(adapter)) - const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(context, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await idle @@ -441,7 +441,7 @@ describe('provider-routed retry policy', () => { const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx mounted.disposeAdapter() - const agent = context.agentLoop.create(SessionId('retry-no-serving-policy'), { + const agent = await context.agentLoop.create(SessionId('retry-no-serving-policy'), { provider: 'mock', model: 'mock', }) @@ -473,7 +473,7 @@ describe('provider-routed retry policy', () => { other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), })) - const normalAgent = context.agentLoop.create(SessionId('retry-provider-normal'), { + const normalAgent = await context.agentLoop.create(SessionId('retry-provider-normal'), { provider: 'mock', model: 'mock', }) @@ -482,7 +482,7 @@ describe('provider-routed retry policy', () => { await normalIdle expect(normalAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) - const alwaysAgent = context.agentLoop.create(SessionId('retry-provider-always'), { + const alwaysAgent = await context.agentLoop.create(SessionId('retry-provider-always'), { provider: 'other', model: 'mock', }) @@ -515,7 +515,7 @@ describe('provider-routed retry policy', () => { provider: 'other', })) })) - const agent = context.agentLoop.create(SessionId('retry-provider-rerouted'), { + const agent = await context.agentLoop.create(SessionId('retry-provider-rerouted'), { provider: 'mock', model: 'mock', }) @@ -552,7 +552,7 @@ describe('provider-routed retry policy', () => { provider: adapter.requests.length === 0 ? 'mock' : 'other', })) })) - const agent = context.agentLoop.create(SessionId('retry-provider-budgets'), { + const agent = await context.agentLoop.create(SessionId('retry-provider-budgets'), { provider: 'mock', model: 'mock', }) @@ -600,7 +600,7 @@ describe('provider-routed retry policy', () => { maxDelayMs: 1, }) }) context = mounted.ctx - const agent = context.agentLoop.create(SessionId('retry-serving-registration'), { + const agent = await context.agentLoop.create(SessionId('retry-serving-registration'), { provider: 'mock', model: 'mock', }) @@ -667,7 +667,7 @@ describe('provider-routed retry policy', () => { maxDelayMs: 4, jitterRatio: 0.1, }) }, undefined, { random: () => 1 })) - const agent = context.agentLoop.create(SessionId('retry-always-unbounded'), { + const agent = await context.agentLoop.create(SessionId('retry-always-unbounded'), { provider: 'mock', model: 'mock', }) @@ -704,7 +704,7 @@ describe('provider-routed retry policy', () => { initialDelayMs: 1, maxDelayMs: 1, }) })) - const agent = context.agentLoop.create(SessionId('retry-always-context-isolation'), { + const agent = await context.agentLoop.create(SessionId('retry-always-context-isolation'), { provider: 'mock', model: 'mock', }) @@ -733,7 +733,7 @@ describe('provider-routed retry policy', () => { ]) ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() })) context.on('agent/request-error', async () => ({ kind: 'retry' })) - const agent = context.agentLoop.create(SessionId('retry-always-composition'), { + const agent = await context.agentLoop.create(SessionId('retry-always-composition'), { provider: 'mock', model: 'mock', }) @@ -760,7 +760,7 @@ describe('provider-routed retry policy', () => { maxDelayMs: 1, }) })) context.on('agent/request-error', failDownstream) - const agent = context.agentLoop.create(SessionId('retry-always-downstream-error'), { + const agent = await context.agentLoop.create(SessionId('retry-always-downstream-error'), { provider: 'mock', model: 'mock', }) @@ -783,7 +783,7 @@ describe('provider-routed retry policy', () => { ]) const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx - const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await scheduled @@ -811,7 +811,7 @@ describe('provider-routed retry policy', () => { order.push('downstream') return { kind: 'retry' } }) - const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), { + const agent = await context.agentLoop.create(SessionId('retry-delegated-disposal'), { provider: 'mock', model: 'mock', }) @@ -851,7 +851,7 @@ describe('provider-routed retry policy', () => { order.push('downstream') return decision }) - const agent = context.agentLoop.create(SessionId('retry-delegated-cancel'), { + const agent = await context.agentLoop.create(SessionId('retry-delegated-cancel'), { provider: 'mock', model: 'mock', }) @@ -890,7 +890,7 @@ describe('provider-routed retry policy', () => { entered.resolve(undefined) return downstream.promise }) - const agent = context.agentLoop.create(SessionId('retry-delegated-sync-cancel'), { + const agent = await context.agentLoop.create(SessionId('retry-delegated-sync-cancel'), { provider: 'mock', model: 'mock', }) @@ -934,7 +934,7 @@ describe('provider-routed retry policy', () => { downstreamCalls += 1 return next() }) - const agent = context.agentLoop.create(SessionId('retry-captured-disposal'), { + const agent = await context.agentLoop.create(SessionId('retry-captured-disposal'), { provider: 'mock', model: 'mock', }) @@ -958,7 +958,7 @@ describe('provider-routed retry policy', () => { textResponse('must not run'), ]) ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() })) - const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await scheduled @@ -989,7 +989,7 @@ describe('provider-routed retry policy', () => { return next() }) })) - const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(context, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) @@ -1010,7 +1010,7 @@ describe('provider-routed retry policy', () => { textResponse('must not run'), ]) ;({ ctx: context } = await harness(adapter)) - const agent = context.agentLoop.create(SessionId('retry-event-cancel'), { provider: 'mock', model: 'mock' }) + const agent = await context.agentLoop.create(SessionId('retry-event-cancel'), { provider: 'mock', model: 'mock' }) context.on('session/event', (session, event) => { if (session === agent.session && event.type === 'llm/retry') agent.cancel({ kind: 'user' }) }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index c9b8c7eaca..0d62e3a22c 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -88,7 +88,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { it('recovers from a true refused connection after the endpoint starts during backoff', async () => { const port = await unusedPort() context = await harness(`http://127.0.0.1:${port}`, { initialDelayMs: 100 }) - const agent = context.agentLoop.create(SessionId('wire-refused'), { + const agent = await context.agentLoop.create(SessionId('wire-refused'), { provider: 'deepseek-official', model: 'mock-model', }) @@ -123,7 +123,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { successText: 'recovered response', }) context = await harness(server.baseURL) - const agent = context.agentLoop.create(SessionId(`wire-${behavior}`), { + const agent = await context.agentLoop.create(SessionId(`wire-${behavior}`), { provider: 'deepseek-official', model: 'mock-model', }) @@ -152,7 +152,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { successText: 'recovered from empty', }) context = await harness(server.baseURL) - const agent = context.agentLoop.create(SessionId('wire-empty'), { + const agent = await context.agentLoop.create(SessionId('wire-empty'), { provider: 'deepseek-official', model: 'mock-model', }) @@ -180,7 +180,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { chunkSize: 100, }) context = await harness(server.baseURL) - const agent = context.agentLoop.create(SessionId('wire-partial-eof'), { + const agent = await context.agentLoop.create(SessionId('wire-partial-eof'), { provider: 'deepseek-official', model: 'mock-model', }) @@ -207,7 +207,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { // This crosses the real HTTP idle timer, so leave scheduler slack between // the stalled attempt and the mock server's immediate successful response. context = await harness(server.baseURL, { streamIdleTimeoutMs: 1_000 }) - const agent = context.agentLoop.create(SessionId('wire-stall'), { + const agent = await context.agentLoop.create(SessionId('wire-stall'), { provider: 'deepseek-official', model: 'mock-model', }) @@ -225,7 +225,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { apiKey: 'mock-key', }) context = await harness(server.baseURL) - const agent = context.agentLoop.create(SessionId('wire-exhausted'), { + const agent = await context.agentLoop.create(SessionId('wire-exhausted'), { provider: 'deepseek-official', model: 'mock-model', }) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 0cbb6649d7..5e4240e05b 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -79,7 +79,7 @@ describe('plan mode through the agent loop', () => { textResponse('Noted in the plan.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' }) // Selected while idle: the mode commits immediately, before the first assembly. ctx.planMode.set(agent, true) @@ -109,7 +109,7 @@ describe('plan mode through the agent loop', () => { textResponse('Second turn, plan mode.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -147,7 +147,7 @@ describe('plan mode through the agent loop', () => { textResponse('Entered plan mode on the next step.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request-error', async ({ agent: subject }, next) => { if (subject !== agent) return next() ctx.planMode.set(agent, true) diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts index ea67d764c1..742ffd2c35 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -82,6 +82,16 @@ async function settleCurrentTasks(): Promise { await new Promise(resolve => setImmediate(resolve)) } +/** Read one stored session's header and full event log through a read handle. */ +async function readStored(ctx: Context, id: SessionId) { + const handle = await ctx.sessionPersistence.open(id, 'read') + try { + return { header: handle.header, inheritedEventCount: handle.inheritedEventCount, events: await handle.read() } + } finally { + await handle.close() + } +} + describe('Schedule production JSONL restart', () => { it('resumes one overdue reminder exactly once across fresh runtime mounts', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-schedule-jsonl-')) @@ -94,7 +104,9 @@ describe('Schedule production JSONL restart', () => { ScheduleId('schedule-1'), 'restart reminder', 1, Date.now() - 60_000, ) pending.append('schedule/change', { version: 1, operation: 'create', schedule: pendingRecord }) - await expect(first.sessions.flush(pending)).resolves.toBe(true) + const seed = await first.sessionPersistence.create(pending.header) + await seed.append(pending.snapshotEvents()) + await seed.close() await disposeContext(first) const dispatchingAdapter = new RecordingAdapter() @@ -107,7 +119,7 @@ describe('Schedule production JSONL restart', () => { await dispatched await handle.agent.whenIdle() await expect(restarted.sessions.flush(handle.agent.session)).resolves.toBe(true) - const dispatchedStored = await restarted.sessionPersistence.inspect(sessionId) + const dispatchedStored = await readStored(restarted, sessionId) expect(foldScheduleEvents(dispatchedStored.events, dispatchedStored.inheritedEventCount).active) .toEqual([]) const dispatches = dispatchedStored.events.filter(event => @@ -131,7 +143,7 @@ describe('Schedule production JSONL restart', () => { expect(replayAdapter.requests).toEqual([]) expect(replayHandle.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) - const replayedStored = await replayed.sessionPersistence.inspect(sessionId) + const replayedStored = await readStored(replayed, sessionId) expect(replayedStored.events.filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) await replayHandle.dispose() diff --git a/packages/schedule/schedule/tests/plugin.spec.ts b/packages/schedule/schedule/tests/plugin.spec.ts index 09231bd792..5a8f5712e5 100644 --- a/packages/schedule/schedule/tests/plugin.spec.ts +++ b/packages/schedule/schedule/tests/plugin.spec.ts @@ -1,17 +1,75 @@ import { describe, expect, it } from 'vitest' -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { agentEvents } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { ToolCallId } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { + SessionPersistence, + SessionPersistenceNotFoundError, + SessionPersistenceRevision, +} from '@deepseek-ai/dsh-session-persistence' +import type { SessionAccess, SessionHandle, SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import * as toolSchedule from '../src/index.ts' -class PersistenceProbe extends Service { - constructor(ctx: Context) { - super(ctx, 'sessionPersistence') +interface StoredProbeSession { + readonly header: SessionHeader + readonly events: SessionEvent[] +} + +/** In-memory handle-based persistence, just enough for agent-loop's write path. */ +class PersistenceProbe extends SessionPersistence { + private readonly stored = new Map() + + override async create(header: SessionHeader): Promise { + const entry: StoredProbeSession = { header, events: [] } + this.stored.set(header.id, entry) + return this.handle(entry, 'write') + } + + // Appends are durable on resolution here; nothing buffers, so the service-wide flush is a no-op. + override async flush(): Promise {} + + override async open(id: SessionId, access: SessionAccess): Promise { + const entry = this.stored.get(id) + if (entry === undefined) throw new SessionPersistenceNotFoundError(id) + return this.handle(entry, access) + } + + override async stat(id: SessionId): Promise { + const entry = this.stored.get(id) + return entry === undefined ? undefined : this.snapshot(entry) + } + + override async list(): Promise { + return [...this.stored.values()].map(entry => this.snapshot(entry)) + } + + private snapshot(entry: StoredProbeSession): SessionPersistenceSnapshot { + return { + header: entry.header, + revision: SessionPersistenceRevision(`probe-${entry.header.id}-${entry.events.length}`), + eventCount: entry.events.length, + } + } + + private handle(entry: StoredProbeSession, access: SessionAccess): SessionHandle { + return { + id: entry.header.id, + header: entry.header, + inheritedEventCount: SessionLogOffset(0), + access, + read: async (offset = 0, length = Number.MAX_SAFE_INTEGER) => + entry.events.slice(offset, offset + length), + append: async (events) => { entry.events.push(...events) }, + flush: async () => {}, + close: async () => {}, + [Symbol.asyncDispose]: async () => {}, + } } } diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 2fcd2c63d0..e3c17174b7 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -2,5 +2,5 @@ # 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-query/session-log-export/README.md -README.md: a096cea71b2e0bed4882a6e8d23c8f35dc3d0fb7 -README.zh.md: 0c2da35ae0be9420cfd6ff076dd038c3a8edf2cb +README.md: f7cfa5c755c0f3e4c68bd20de712ed00bdf97ff4 +README.zh.md: 244df44b339a75d4a6d1e4a2fd4c41a692936f66 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index a096cea71b..f7cfa5c755 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -29,7 +29,7 @@ Use this package when the Web bundle should let users export a session log. It r ### When to choose it -Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write, and it requires the shipped JSONL provider's per-Session raw artifact in plaintext or zstd form. +Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. ### Composition @@ -79,7 +79,7 @@ The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) regis Both entry paths issue a `HEAD` preflight to `GET /api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. -The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, raw artifact and attachment reads, ZIP generation, and HTTP status semantics. +The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics. @@ -121,7 +121,6 @@ None. The log-only command lifecycle and browser download do not change the deri These limits define when this package is a poor fit or needs special operational care. They are current package constraints, not a task backlog. -- **Requires a per-Session raw artifact** — the download endpoint reads the shipped JSONL provider's plaintext or zstd artifact; an out-of-tree provider without a raw artifact cannot serve this route. - **Browser download, not a Host-path writer** — the browser chooses the local destination; no Host path or native folder action is returned. - **Preflight reports only pre-stream failures** — a descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 0c2da35ae0..244df44b33 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -为需要带可见下载弹窗的用户级会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入,并且它要求随产品交付的 JSONL provider 提供逐 Session 的明文或 zstd 原始产物。 +为需要带可见下载弹窗的用户级会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 ### 组合 @@ -79,7 +79,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 两条入口都会对 `GET /api/session.export?...` 发出 `HEAD` 预检,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 -Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、原始产物与附件读取、ZIP 生成和 HTTP 状态语义。 +Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。 @@ -121,7 +121,6 @@ Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/ 这些限制说明本包何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。 -- **要求逐 Session 原始产物**——下载端点读取随产品交付的 JSONL provider 所提供的明文或 zstd 产物;没有原始产物的仓库外 provider 无法服务该 route。 - **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。 - **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 468e59ffb8..2d9e7b59af 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -41,7 +41,8 @@ "@deepseek-ai/dsh-brand": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -53,16 +54,16 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0", - "@deepseek-ai/dsh-session": "workspace:^" + "react": "^18.2.0" }, "dsh": { "client": { diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts index 2f7d1fbd11..b13a774cee 100644 --- a/packages/session-query/session-log-export/src/archive.ts +++ b/packages/session-query/session-log-export/src/archive.ts @@ -1,16 +1,18 @@ /** * Host-side session-log download: streams one ZIP archive whose files are the - * sessions' stored artifact text verbatim plus every referenced media object. - * The root artifact sits under its original base name (`session.jsonl`); each - * subagent descendant under `subagents//`; each image referenced - * by any included log under `media/.` (content-addressed, - * so one archive never duplicates a shared image). No manifest is written — - * every file is byte-identical to the backend's durable artifact or attachment - * store and self-describing through its own header line or media type. Before - * each live session's artifact read, the SessionStore flush barrier makes the - * current in-memory log durable; cold sessions need no barrier. Request abort - * and response-consumer cancellation share one producer signal and terminate - * the active compressor. + * sessions' logical session logs plus every referenced media object. Each log + * is read through a persistence read handle and serialized here as canonical + * JSONL — one header line, then one line per validated event — so every + * backend (JSONL, SQLite, future) exports identically. The root log sits at + * `session.jsonl`; each subagent descendant under + * `subagents//session.jsonl`; each image referenced by any included log + * under `media/.` (content-addressed, so one archive never + * duplicates a shared image). No manifest is written — every file is + * self-describing through its own header line or media type. Before each live + * session's log read, the SessionStore flush barrier makes the current + * in-memory log durable; cold sessions need no barrier. Request abort and + * response-consumer cancellation share one producer signal and terminate the + * active compressor. * Compression runs on the host with fflate's streaming Zip API, so the archive * bytes are produced incrementally and the host never holds the whole archive * in one buffer; production waits for consumer pull whenever the response queue @@ -23,8 +25,9 @@ import { Zip, ZipDeflate } from 'fflate' import type { Context } from '@deepseek-ai/cordis' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' -import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session' -import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import type { SessionEvent, SessionHeader, SessionId, SessionStore } from '@deepseek-ai/dsh-session' +import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' /** Valid fflate DEFLATE levels accepted by session-log export. */ export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 @@ -84,11 +87,86 @@ export async function flushLiveSessionLog( signal?.throwIfAborted() } -/** One exported file: a stored artifact text or one referenced media object. */ +/** One exported file: a serialized session log or one referenced media object. */ export type SessionLogZipEntry = | { readonly path: string; readonly content: string } | { readonly path: string; readonly data: Uint8Array } +/** The zip base filename for every exported session log. */ +export const SESSION_LOG_FILENAME = 'session.jsonl' + +/** + * Serialize one session's logical log as canonical JSONL text: the header + * line, then one line per event, with a trailing newline. + * @param header - the session's immutable header. + * @param inheritedEventCount - the exact fork-inherited prefix length stored + * beside the header (`0` when `header.isSeeded` is false). + * @param events - the validated committed events in seq order. + * @returns the JSONL text. + */ +export function serializeSessionLog( + header: SessionHeader, + inheritedEventCount: number, + events: readonly SessionEvent[], +): string { + // Match the JSONL backend's canonical header line: lineage is the physical + // `seedLength`, and `delegationDepth` is required-on-read there, so an + // omitted top-level depth serializes as 0 and the exported log parses as a + // valid log. + /* jscpd:ignore-start -- deliberately mirrors the JSONL backend's + `toHeaderLine`: the exported text is the canonical v0 physical header + line, and this backend-agnostic package must not depend on one backend + implementation. */ + const lines = [JSON.stringify({ + type: 'session', + version: header.version, + id: header.id, + createdAt: header.createdAt, + ...header.cwd !== undefined ? { cwd: header.cwd } : {}, + ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, + ...header.isSeeded ? { seedLength: inheritedEventCount } : {}, + ...header.origin !== undefined ? { origin: header.origin } : {}, + delegationDepth: header.delegationDepth ?? 0, + ...header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {}, + })] + /* jscpd:ignore-end */ + for (const event of events) lines.push(JSON.stringify(event)) + return `${lines.join('\n')}\n` +} + +/** + * Read one session's complete logical log through a read handle and serialize + * it. The read observes the committed log only — persistence never returns a + * torn tail — and a handle read after a resolved flush observes at least the + * flushed prefix. + * @param persistence - the mounted persistence backend. + * @param id - the session to read. + * @param signal - optional cancellation forwarded to the open and read. + * @returns the serialized JSONL text, or `undefined` when the session does not exist. + */ +export async function readSessionLogText( + persistence: SessionPersistence, + id: SessionId, + signal?: AbortSignal, +): Promise { + const options = signal === undefined ? {} : { signal } + let handle: SessionHandle + try { + handle = await persistence.open(id, 'read', options) + } catch (error) { + // Absence is `open`'s decision; every other failure (corruption, + // unsupported format, I/O, cancellation) stays fail-loud. + if (error instanceof SessionPersistenceNotFoundError) return undefined + throw error + } + try { + const events = await handle.read(0, undefined, options) + return serializeSessionLog(handle.header, Number(handle.inheritedEventCount), events) + } finally { + await handle.close() + } +} + /** Zip extension for each accepted raster media type. */ const MEDIA_TYPE_EXTENSIONS: Record = { 'image/png': 'png', @@ -201,16 +279,16 @@ export function sessionLogZipFilename(sessionId: string): string { } /** - * Yield the export entries in zip order: the preloaded root artifact first, - * then every subagent descendant in lineage order (each flushed when live, - * read from the persistence backend right before it is yielded, and dropped + * Yield the export entries in zip order: the preloaded root log first, then + * every subagent descendant in lineage order (each flushed when live, read + * through a persistence read handle right before it is yielded, and dropped * after the consumer moves on), then every distinct media object referenced by any of * the included logs (read and verified from the attachment store, one archive - * entry per attachment id). The host holds at most one descendant's artifact - * text and one media object at a time beyond the root. + * entry per attachment id). The host holds at most one descendant's log text + * and one media object at a time beyond the root. * @param deps - the mounted export services (the caller answered 500 before this runs). - * @param root - the already-read root artifact (read by the caller so the - * missing-session path can answer cleanly before streaming starts). + * @param rootContent - the already-serialized root log (read by the caller so + * the missing-session path can answer cleanly before streaming starts). * @param sessionId - the root session id. * @param includeDescendants - whether to include every subagent descendant. * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads. @@ -218,7 +296,7 @@ export function sessionLogZipFilename(sessionId: string): string { */ export async function* sessionLogZipEntries( deps: SessionLogExportReady, - root: SessionRawArtifact, + rootContent: string, sessionId: SessionId, includeDescendants: boolean, signal?: AbortSignal, @@ -227,8 +305,8 @@ export async function* sessionLogZipEntries( const rememberMedia = (content: string): void => { for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) } - rememberMedia(root.content) - yield { path: root.filename, content: root.content } + rememberMedia(rootContent) + yield { path: SESSION_LOG_FILENAME, content: rootContent } if (includeDescendants) { const seen = new Set([sessionId]) const collect = async function* ( @@ -240,15 +318,15 @@ export async function* sessionLogZipEntries( if (seen.has(id)) continue seen.add(id) await flushLiveSessionLog(deps, id, signal) - const raw = await deps.sessionPersistence.readRaw(id, signal) + const content = await readSessionLogText(deps.sessionPersistence, id, signal) signal?.throwIfAborted() - if (raw === undefined) { - throw new Error(`subagent "${id}" has no stored log artifact`) + if (content === undefined) { + throw new Error(`subagent "${id}" has no stored log`) } - rememberMedia(raw.content) + rememberMedia(content) yield { - path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, - content: raw.content, + path: `subagents/${safeSessionIdSegment(id)}/${SESSION_LOG_FILENAME}`, + content, } yield* collect(node.descendants) } @@ -371,14 +449,14 @@ async function pushArtifactChunks( } /** - * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is - * read and validated by the caller before this is called (missing root or + * Stream one session-log ZIP as a WHATWG ReadableStream. The root log is read + * and serialized by the caller before this is called (a missing root or * missing services answer cleanly before any byte is produced); each entry is * then encoded and deflated in bounded chunks as it is produced, so the * archive bytes arrive incrementally. A descendant that fails to read errors * the stream (fail-loud, never silent under-export). * @param deps - the mounted export services (the caller answered 500 before this runs). - * @param root - the already-read root artifact (first zip entry). + * @param rootContent - the already-serialized root log (first zip entry). * @param sessionId - the root session id. * @param includeDescendants - whether to include every subagent descendant. * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry. @@ -387,7 +465,7 @@ async function pushArtifactChunks( */ export function streamSessionLogZip( deps: SessionLogExportReady, - root: SessionRawArtifact, + rootContent: string, sessionId: SessionId, includeDescendants: boolean, compressionLevel: SessionLogCompressionLevel, @@ -422,7 +500,7 @@ export function streamSessionLogZip( zip = archive void (async () => { try { - for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) { + for await (const entry of sessionLogZipEntries(deps, rootContent, sessionId, includeDescendants, producerSignal)) { const deflate = new ZipDeflate(entry.path, { level: compressionLevel }) archive.add(deflate) if ('content' in entry) { diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 88c4cc6ccb..0ec33bc2e5 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -6,10 +6,10 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-attachment' import type { CommandResult } from '@deepseek-ai/dsh-commands' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, + readSessionLogText, sessionLogExportDeps, sessionLogZipFilename, streamSessionLogZip, @@ -20,6 +20,9 @@ import { export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, + readSessionLogText, + serializeSessionLog, + SESSION_LOG_FILENAME, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, @@ -121,32 +124,31 @@ async function sessionLogExportResponse( { status: 500 }, ) } - if (!deps.sessionPersistence.supportsRawArtifacts) { - return new Response( - 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts', - { status: 501 }, - ) - } const ready: SessionLogExportReady = { sessionQuery: deps.sessionQuery, sessionPersistence: deps.sessionPersistence, attachments: deps.attachments, sessions: deps.sessions, } - let root: SessionRawArtifact | undefined + let rootContent: string | undefined try { await flushLiveSessionLog(deps, sessionId, request.signal) - root = await deps.sessionPersistence.readRaw(sessionId, request.signal) + rootContent = await readSessionLogText(deps.sessionPersistence, sessionId, request.signal) request.signal.throwIfAborted() } catch { request.signal.throwIfAborted() - return new Response('session log export failed to prepare the stored artifact', { status: 500 }) + // Root preparation failure (flush, open, or read): answer 500 without + // echoing the error, which may carry absolute host paths into the + // browser error bar. + return new Response('session log export failed to read the stored log', { status: 500 }) + } + if (rootContent === undefined) { + return new Response('session not found', { status: 404 }) } - if (root === undefined) return new Response('session not found', { status: 404 }) const response = new Response( streamSessionLogZip( ready, - root, + rootContent, sessionId, descendantsValue === 'true', compressionLevel, diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index 66ebe197c0..6ab02812e2 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -1,19 +1,21 @@ /** * session.export host path: the GET download endpoint streams a ZIP whose - * files are the stored artifacts verbatim (root + optional descendants), and - * the degenerate compositions fail loudly (missing services → 500, missing - * root → 404, missing descendant → errored stream). + * files are the sessions' logical logs serialized as canonical JSONL (root + + * optional descendants) read through persistence read handles, and the + * degenerate compositions fail loudly (missing services → 500, missing root → + * 404, missing descendant → errored stream). */ +import { SessionSeq } from '@deepseek-ai/dsh-session' import { randomBytes } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { unzipSync, strFromU8 } from 'fflate' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' -import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' +import type { SessionAccess, SessionHandle } from '@deepseek-ai/dsh-session-persistence' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' import * as SessionLogExport from '../src/index.ts' @@ -25,20 +27,28 @@ function header(id: string, parentSession?: SessionId): SessionHeader { version: 0, id: sid(id), createdAt: 1000, - cwd: '/proj', isSeeded: false, + cwd: '/proj', ...parentSession === undefined ? {} : { parentSession }, delegationDepth: parentSession === undefined ? 0 : 1, } } -function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact { - return { - meta: header(id, parentSession), - inheritedEventCount: SessionLogOffset(0), - filename: 'session.jsonl', - content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, - } +/** One stored logical session log served by the fake persistence backend. */ +interface StoredLog { + readonly header: SessionHeader + readonly events: readonly SessionEvent[] +} + +const turnStart: SessionEvent = { type: 'turn/start', seq: SessionSeq(0), time: 2000, data: { turn: 1 } } + +function log(id: string, parentSession?: SessionId, events: readonly SessionEvent[] = [turnStart]): StoredLog { + return { header: header(id, parentSession), events } +} + +/** The expected zip text for one stored log: the canonical JSONL serialization. */ +function logText(stored: StoredLog): string { + return SessionLogExport.serializeSessionLog(stored.header, 0, stored.events) } function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { @@ -53,23 +63,38 @@ function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'i } } -/** A user/message event line carrying one image reference. */ -function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string { - return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}` +/** A user/message event carrying one image reference. */ +function imageEvent(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): SessionEvent { + return { + type: 'user/message', seq: SessionSeq(1), time: 1000, + data: { content: [{ type: 'image', attachment: { attachmentId: id, mediaType, bytes: 4, width: 2, height: 2 } }] }, + } as unknown as SessionEvent +} + +/** A read handle over one stored log; only what readSessionLogText touches. */ +function readHandle(stored: StoredLog): SessionHandle { + return { + id: stored.header.id, + header: stored.header, + access: 'read', + inheritedEventCount: 0, + read: async () => stored.events, + close: async () => {}, + } as unknown as SessionHandle } async function buildApi( - artifacts: Record, + logs: Record, descendants: SessionLineageNode[] = [], services: { query?: boolean - persistence?: boolean | 'throw' | 'unsupported' + persistence?: boolean | 'throw' attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) sessions?: { get(id: SessionId): { readonly id: SessionId } | undefined flush(session: { readonly id: SessionId }): Promise } - readRaw?: (id: SessionId, signal?: AbortSignal) => Promise + open?: (id: SessionId, access: SessionAccess, options?: { signal?: AbortSignal }) => Promise traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{ target: { header: SessionHeader; live: boolean; persisted: boolean } ancestors: readonly SessionLineageNode[] @@ -97,10 +122,17 @@ async function buildApi( } if (persistence) { ctx.provide('sessionPersistence', { - supportsRawArtifacts: persistence !== 'unsupported', - readRaw: services.readRaw ?? (async (id: SessionId) => { + stat: async (id: SessionId) => { + // A custom `open` owns the scenario: absence must reach it, not stop here. + if (services.open !== undefined || persistence === 'throw') return { header: header(String(id)) } + const stored = logs[id] + return stored === undefined ? undefined : { header: stored.header } + }, + open: services.open ?? (async (id: SessionId) => { if (persistence === 'throw') throw new Error('/host/private/session.jsonl') - return artifacts[id] + const stored = logs[id] + if (stored === undefined) throw new SessionPersistenceNotFoundError(id) + return readHandle(stored) }), } as never) } @@ -148,6 +180,24 @@ async function responseBytes(response: Response): Promise { return new Uint8Array(await response.arrayBuffer()) } +/** Minimal ready services for the direct streamSessionLogZip chunking tests. */ +function directReady(): SessionLogExport.SessionLogExportReady { + return { + sessionQuery: { traceSession: async () => { throw new Error('unused') } } as never, + sessionPersistence: { open: async () => { throw new Error('unused') } } as never, + attachments: { readImage: async () => { throw new Error('no media') } } as never, + sessions: undefined, + } +} + +/** Consume one directly built zip stream into its unpacked files. */ +async function directZipFiles(rootContent: string): Promise> { + const stream = SessionLogExport.streamSessionLogZip( + directReady(), rootContent, sid('session-root'), false, 6, new AbortController().signal, + ) + return unzipSync(new Uint8Array(await new Response(stream).arrayBuffer())) +} + describe('session export compression config', () => { it('defaults to level 6 and rejects values outside the integer 0-9 range', () => { expect(SessionLogExport.Config({})).toEqual({ @@ -163,9 +213,68 @@ describe('session export compression config', () => { }) }) +describe('serializeSessionLog', () => { + it('writes the physical header line, one line per event, and a trailing newline', () => { + const stored = log('session-root') + expect(logText(stored)).toBe( + `${JSON.stringify({ + type: 'session', version: 0, id: sid('session-root'), createdAt: 1000, cwd: '/proj', delegationDepth: 0, + })}\n${JSON.stringify(turnStart)}\n`, + ) + }) + + it('serializes lineage as the physical seedLength and an omitted delegationDepth as 0', () => { + const seeded: SessionHeader = { + version: 0, + id: sid('seeded'), + createdAt: 1000, + isSeeded: true, + parentSession: sid('parent'), + origin: 'subagent', + agentPreset: 'minimal', + } + expect(SessionLogExport.serializeSessionLog(seeded, 3, [])).toBe(`${JSON.stringify({ + type: 'session', + version: 0, + id: sid('seeded'), + createdAt: 1000, + parentSession: sid('parent'), + seedLength: 3, + origin: 'subagent', + delegationDepth: 0, + agentPreset: 'minimal', + })}\n`) + }) +}) + +describe('readSessionLogText', () => { + it('reads without a signal and maps only open not-found to undefined', async () => { + const stored = log('session-root') + const persistence = { + open: async (id: SessionId) => { + if (id !== stored.header.id) throw new SessionPersistenceNotFoundError(id) + return readHandle(stored) + }, + } as never + await expect(SessionLogExport.readSessionLogText(persistence, sid('session-root'))) + .resolves.toBe(logText(stored)) + await expect(SessionLogExport.readSessionLogText(persistence, sid('absent'))) + .resolves.toBeUndefined() + }) + + it('propagates a non-not-found open failure', async () => { + const persistence = { + open: async () => { throw new Error('EACCES: permission denied') }, + } as never + await expect(SessionLogExport.readSessionLogText(persistence, sid('session-root'))) + .rejects.toThrow('EACCES: permission denied') + }) +}) + describe('session.export download endpoint', () => { - it('streams a ZIP with the root artifact verbatim under its original filename', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) + it('streams a ZIP with the root log serialized as canonical JSONL', async () => { + const stored = log('session-root') + const api = await buildApi({ 'session-root': stored }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) @@ -174,12 +283,12 @@ describe('session.export download endpoint', () => { expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') const files = unzipSync(await responseBytes(response)) expect(Object.keys(files)).toEqual(['session.jsonl']) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(logText(stored)) }) it('preflights root preparation through HEAD without streaming a body', async () => { - const readRaw = vi.fn(async () => artifact('session-root')) - const api = await buildApi({}, [], { readRaw }) + const open = vi.fn(async () => readHandle(log('session-root'))) + const api = await buildApi({}, [], { open }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), ) @@ -188,7 +297,7 @@ describe('session.export download endpoint', () => { expect(response.headers.get('content-type')).toBe('application/zip') expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') expect(response.body).toBeNull() - expect(readRaw).toHaveBeenCalledOnce() + expect(open).toHaveBeenCalledOnce() }) it('returns a bodyless preparation error from HEAD', async () => { @@ -202,10 +311,14 @@ describe('session.export download endpoint', () => { }) it('uses the resolved compression level for ZIP entries', async () => { - const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024)) - const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 }) - const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 }) - const stored = await storedApi.downloads.sessionLog( + const filler = { + type: 'user/message', seq: SessionSeq(1), time: 1000, + data: { content: [{ type: 'text', text: 'compressible '.repeat(32 * 1024) }] }, + } as unknown as SessionEvent + const stored = log('session-root', undefined, [turnStart, filler]) + const storedApi = await buildApi({ 'session-root': stored }, [], { compressionLevel: 0 }) + const compressedApi = await buildApi({ 'session-root': stored }, [], { compressionLevel: 9 }) + const uncompressed = await storedApi.downloads.sessionLog( { sessionId: sid('session-root'), includeDescendants: false }, new AbortController().signal, ) @@ -213,17 +326,18 @@ describe('session.export download endpoint', () => { { sessionId: sid('session-root'), includeDescendants: false }, new AbortController().signal, ) - const storedBytes = await responseBytes(stored) + const storedBytes = await responseBytes(uncompressed) const compressedBytes = await responseBytes(compressed) expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength) - expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content) + expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(logText(stored)) }) - it('includes descendant artifacts under subagents// when requested', async () => { + it('includes descendant logs under subagents// when requested', async () => { + const child = log('child-a', sid('session-root')) const api = await buildApi({ - 'session-root': artifact('session-root'), - 'child-a': artifact('child-a', sid('session-root')), - 'grandchild-a': artifact('grandchild-a', sid('child-a')), + 'session-root': log('session-root'), + 'child-a': child, + 'grandchild-a': log('grandchild-a', sid('child-a')), }, [ node('child-a', node('grandchild-a')), ]) @@ -238,27 +352,29 @@ describe('session.export download endpoint', () => { 'subagents/grandchild-a/session.jsonl', ]) expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) - .toBe(artifact('child-a').content) + .toBe(logText(child)) }) - it('flushes each live root and descendant immediately before reading its artifact', async () => { - const stored: Record = { - 'session-root': artifact('session-root', undefined, 'stale root'), - 'child-a': artifact('child-a', sid('session-root'), 'stale child'), + it('flushes each live root and descendant immediately before reading its log', async () => { + const staleMarker = { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } } as SessionEvent + const durableMarker = { type: 'turn/start', seq: SessionSeq(0), time: 2, data: { turn: 1 } } as SessionEvent + const stored: Record = { + 'session-root': log('session-root', undefined, [staleMarker]), + 'child-a': log('child-a', sid('session-root'), [staleMarker]), } - const durable: Record = { - 'session-root': artifact('session-root', undefined, 'durable root'), - 'child-a': artifact('child-a', sid('session-root'), 'durable child'), + const durable: Record = { + 'session-root': log('session-root', undefined, [durableMarker]), + 'child-a': log('child-a', sid('session-root'), [durableMarker]), } const flushed: SessionId[] = [] const api = await buildApi(stored, [node('child-a')], { sessions: { get: id => durable[id] === undefined ? undefined : { id }, flush: async (session) => { - const artifactAfterFlush = durable[session.id] - if (artifactAfterFlush === undefined) throw new Error('unexpected session') + const logAfterFlush = durable[session.id] + if (logAfterFlush === undefined) throw new Error('unexpected session') flushed.push(session.id) - stored[session.id] = artifactAfterFlush + stored[session.id] = logAfterFlush return true }, }, @@ -268,14 +384,14 @@ describe('session.export download endpoint', () => { ) const files = unzipSync(await responseBytes(response)) expect(flushed).toEqual([sid('session-root'), sid('child-a')]) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root') - expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child') + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(logText(durable['session-root'] as StoredLog)) + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe(logText(durable['child-a'] as StoredLog)) }) - it('reads a cold artifact without asking the live-session store to flush', async () => { + it('reads a cold log without asking the live-session store to flush', async () => { const flush = vi.fn(async () => true) - const root = artifact('session-root') - const api = await buildApi({ 'session-root': root }, [], { + const stored = log('session-root') + const api = await buildApi({ 'session-root': stored }, [], { sessions: { get: () => undefined, flush, @@ -287,28 +403,20 @@ describe('session.export download endpoint', () => { ) const files = unzipSync(await responseBytes(response)) expect(flush).not.toHaveBeenCalled() - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(logText(stored)) }) - it('answers 404 for a missing root session', async () => { + it('answers 404 for a session the backend does not store', async () => { const api = await buildApi({}) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) expect(response.status).toBe(404) - }) - - it('answers 501 when the persistence backend has no per-session raw artifacts', async () => { - const api = await buildApi({}, [], { persistence: 'unsupported' }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(501) - expect(await response.text()).toContain('does not expose per-session raw artifacts') + expect(await response.text()).toBe('session not found') }) it('answers 400 when the sessionId query parameter is absent', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) + const api = await buildApi({ 'session-root': log('session-root') }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?includeDescendants=true'), ) @@ -316,7 +424,7 @@ describe('session.export download endpoint', () => { }) it('answers 400 for an includeDescendants value other than true or false', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) + const api = await buildApi({ 'session-root': log('session-root') }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), ) @@ -332,9 +440,9 @@ describe('session.export download endpoint', () => { expect(await response.text()).toContain('session-query') }) - it('fails the whole export when a descendant has no stored artifact', async () => { + it('fails the whole export when a descendant has no stored log', async () => { const api = await buildApi({ - 'session-root': artifact('session-root'), + 'session-root': log('session-root'), }, [node('child-missing')]) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), @@ -348,35 +456,34 @@ describe('session.export download endpoint', () => { it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { // The push loop slices by 2^16 code units and must back off one unit when // the boundary lands inside a surrogate pair; otherwise the pair re-encodes - // as U+FFFD and the exported artifact is silently corrupted. - const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + // as U+FFFD and the exported log is silently corrupted. + const content = `${'a'.repeat((1 << 16) - 1)}😀tail` + const files = await directZipFiles(content) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(content) }) - it('splits a long artifact on a plain code-unit boundary without backoff', async () => { + it('splits a long log on a plain code-unit boundary without backoff', async () => { // A boundary that lands on a BMP character needs no surrogate backoff; the // round trip must still be byte-identical across the multi-chunk push. - const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + const content = 'z'.repeat((1 << 16) + 4096) + const files = await directZipFiles(content) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(content) + }) + + it('streams an empty root text as an empty zip entry', async () => { + const files = await directZipFiles('') + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') }) it('waits for response pull capacity before reading the next archive entry', async () => { - const root = artifact('session-root', undefined, [ - imageEventLine('after-root'), - randomBytes(512 * 1024).toString('base64'), - ].join('\n')) + const filler = { + type: 'user/message', seq: SessionSeq(2), time: 1000, + data: { content: [{ type: 'text', text: randomBytes(512 * 1024).toString('base64') }] }, + } as unknown as SessionEvent + const stored = log('session-root', undefined, [imageEvent('after-root'), filler]) let imageReads = 0 - const api = await buildApi({ 'session-root': root }, [], { + const api = await buildApi({ 'session-root': stored }, [], { attachments: async (ref) => { imageReads += 1 return storedImage(String(ref.attachmentId), ref.mediaType) @@ -401,23 +508,12 @@ describe('session.export download endpoint', () => { expect(files['media/after-root.png']).toEqual(storedImage('after-root').data) }) - it('exports an empty artifact as an empty zip entry', async () => { - const root = { ...artifact('session-root'), content: '' } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files)).toEqual(['session.jsonl']) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') - }) - it('exports a shared lineage node once (seen-set dedup)', async () => { const api = await buildApi({ - 'session-root': artifact('session-root'), - 'child-a': artifact('child-a', sid('session-root')), - 'child-b': artifact('child-b', sid('session-root')), - shared: artifact('shared', sid('child-a')), + 'session-root': log('session-root'), + 'child-a': log('child-a', sid('session-root')), + 'child-b': log('child-b', sid('session-root')), + shared: log('shared', sid('child-a')), }, [ node('child-a', node('shared')), node('child-b', node('shared')), @@ -434,19 +530,19 @@ describe('session.export download endpoint', () => { ]) }) - it('answers 500 without leaking the backend error when the root artifact read fails', async () => { + it('answers 500 without leaking the backend error when the root read fails', async () => { const api = await buildApi({}, [], { query: true, persistence: 'throw' }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) expect(response.status).toBe(500) const body = await response.text() - expect(body).toBe('session log export failed to prepare the stored artifact') + expect(body).toBe('session log export failed to read the stored log') expect(body).not.toContain('/host/private/') }) it('answers the private-error-safe 500 when the live root flush fails', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }, [], { + const api = await buildApi({ 'session-root': log('session-root') }, [], { sessions: { get: id => ({ id }), flush: async () => { throw new Error('/host/private/flush-state') }, @@ -457,7 +553,7 @@ describe('session.export download endpoint', () => { ) expect(response.status).toBe(500) const body = await response.text() - expect(body).toBe('session log export failed to prepare the stored artifact') + expect(body).toBe('session log export failed to read the stored log') expect(body).not.toContain('/host/private/') }) @@ -465,11 +561,11 @@ describe('session.export download endpoint', () => { const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = [] const traces: AbortSignal[] = [] const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - reads.push({ id, signal }) - return id === sid('session-root') - ? artifact('session-root') - : artifact('child-a', sid('session-root')) + open: async (id, _access, options) => { + reads.push({ id, signal: options?.signal }) + return readHandle(id === sid('session-root') + ? log('session-root') + : log('child-a', sid('session-root'))) }, traceSession: async (_id, signal) => { if (signal !== undefined) traces.push(signal) @@ -503,7 +599,7 @@ describe('session.export download endpoint', () => { }) it('preserves request cancellation instead of translating it to HTTP 500', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) + const api = await buildApi({ 'session-root': log('session-root') }) const controller = new AbortController() const cancellation = new Error('request cancelled') controller.abort(cancellation) @@ -519,8 +615,9 @@ describe('session.export download endpoint', () => { reportDescendantStarted = resolve }) const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - if (id === sid('session-root')) return artifact('session-root') + open: async (id, _access, options) => { + if (id === sid('session-root')) return readHandle(log('session-root')) + const signal = options?.signal if (signal === undefined) throw new Error('missing descendant signal') reportDescendantStarted(signal) return new Promise((_, reject) => { @@ -548,11 +645,8 @@ describe('session.export download endpoint', () => { const attachmentStarted = new Promise((resolve) => { reportAttachmentStarted = resolve }) - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('slow-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }, [], { + const stored = log('session-root', undefined, [imageEvent('slow-img')]) + const api = await buildApi({ 'session-root': stored }, [], { attachments: async (_ref, signal) => { if (signal === undefined) throw new Error('missing attachment signal') reportAttachmentStarted(signal) @@ -582,8 +676,9 @@ describe('session.export download endpoint', () => { reportDescendantStarted = resolve }) const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - if (id === sid('session-root')) return artifact('session-root') + open: async (id, _access, options) => { + if (id === sid('session-root')) return readHandle(log('session-root')) + const signal = options?.signal if (signal === undefined) throw new Error('missing descendant signal') reportDescendantStarted(signal) return new Promise((_, reject) => { @@ -606,8 +701,8 @@ describe('session.export download endpoint', () => { it('normalizes a non-Error descendant failure before erroring the stream', async () => { const api = await buildApi({}, [node('child-a')], { - readRaw: async (id) => { - if (id === sid('session-root')) return artifact('session-root') + open: async (id) => { + if (id === sid('session-root')) return readHandle(log('session-root')) throw 'descendant read failed' }, }) @@ -619,11 +714,8 @@ describe('session.export download endpoint', () => { }) it('includes media objects referenced by the root log under media/.', async () => { - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('img-1'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) + const stored = log('session-root', undefined, [imageEvent('img-1')]) + const api = await buildApi({ 'session-root': stored }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) @@ -634,12 +726,11 @@ describe('session.export download endpoint', () => { }) it('collects media referenced from nested tool results', async () => { - const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}' - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - nested, - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) + const nested = { + type: 'assistant/message', seq: SessionSeq(2), time: 2000, + data: { content: [{ type: 'tool-result', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] }] }, + } as unknown as SessionEvent + const api = await buildApi({ 'session-root': log('session-root', undefined, [nested]) }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) @@ -648,18 +739,21 @@ describe('session.export download endpoint', () => { }) it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => { - const block = (id: string, mediaType: string) => - `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}` - const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}` - const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}` - const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}` - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - wrapped, - inserted, - chunk, - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) + const block = (id: string, mediaType: string): unknown => + ({ type: 'image', attachment: { attachmentId: id, mediaType, bytes: 4, width: 2, height: 2 } }) + const wrapped = { + type: 'assistant/message', seq: SessionSeq(2), time: 2000, + data: { message: { role: 'assistant', content: ['noise', block('wrapped-1', 'image/jpeg')] } }, + } as unknown as SessionEvent + const inserted = { + type: 'context/inserted', seq: SessionSeq(3), time: 3000, + data: { inserted: [{ content: [block('inserted-1', 'image/gif')] }] }, + } as unknown as SessionEvent + const chunk = { + type: 'assistant/chunk', seq: SessionSeq(4), time: 4000, + data: { chunk: { type: 'block-end', block: block('chunk-1', 'image/png') } }, + } as unknown as SessionEvent + const api = await buildApi({ 'session-root': log('session-root', undefined, [wrapped, inserted, chunk]) }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) @@ -673,15 +767,8 @@ describe('session.export download endpoint', () => { }) it('deduplicates one media object referenced by several included logs', async () => { - const line = imageEventLine('shared-img') - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - line, - ].join('\n') + '\n') - const child = artifact('child-a', sid('session-root'), [ - '{"type":"session","version":0,"id":"child-a","createdAt":1000}', - line, - ].join('\n') + '\n') + const root = log('session-root', undefined, [imageEvent('shared-img')]) + const child = log('child-a', sid('session-root'), [imageEvent('shared-img')]) const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')]) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), @@ -692,11 +779,8 @@ describe('session.export download endpoint', () => { }) it('includes descendant media only when descendants are requested', async () => { - const child = artifact('child-a', sid('session-root'), [ - '{"type":"session","version":0,"id":"child-a","createdAt":1000}', - imageEventLine('child-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')]) + const child = log('child-a', sid('session-root'), [imageEvent('child-img')]) + const api = await buildApi({ 'session-root': log('session-root'), 'child-a': child }, [node('child-a')]) const without = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) @@ -712,11 +796,8 @@ describe('session.export download endpoint', () => { }) it('fails the whole export when a referenced image cannot be read', async () => { - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('gone-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }, [], { + const stored = log('session-root', undefined, [imageEvent('gone-img')]) + const api = await buildApi({ 'session-root': stored }, [], { attachments: async () => { throw new Error('attachment bytes missing') }, }) const response = await toFetchHandler(api).fetch( @@ -727,7 +808,7 @@ describe('session.export download endpoint', () => { }) it('answers 500 when the deployment mounts no attachments service', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false }) + const api = await buildApi({ 'session-root': log('session-root') }, [], { attachments: false }) const response = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts index 19d07bb9de..fcfe1df75d 100644 --- a/packages/session-query/session-log-export/tests/route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -1,9 +1,8 @@ import { Context } from '@deepseek-ai/cordis' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence' import { strFromU8, unzipSync } from 'fflate' import { describe, expect, it } from 'vitest' import { @@ -15,28 +14,22 @@ import { const sid = (value: string): SessionId => value as SessionId -function artifact(id: string): SessionRawArtifact { +function readHandle(id: string): SessionHandle { const header: SessionHeader = { version: 0, id: sid(id), createdAt: 1, - cwd: '/workspace', isSeeded: false, + cwd: '/workspace', delegationDepth: 0, } return { - meta: header, - inheritedEventCount: SessionLogOffset(0), - filename: 'session.jsonl', - content: `${JSON.stringify({ - type: 'session', - version: header.version, - id: header.id, - createdAt: header.createdAt, - cwd: header.cwd, - delegationDepth: header.delegationDepth, - })}\n`, - } + id: header.id, + header, + access: 'read', + read: async () => [], + close: async () => {}, + } as unknown as SessionHandle } async function mounted(withServices: boolean): Promise<{ @@ -50,8 +43,8 @@ async function mounted(withServices: boolean): Promise<{ traceSession: async () => ({ descendants: [] }), } as never) ctx.provide('sessionPersistence', { - supportsRawArtifacts: true, - readRaw: async (id: SessionId) => artifact(String(id)), + stat: async (id: SessionId) => ({ header: readHandle(String(id)).header }), + open: async (id: SessionId) => readHandle(String(id)), } as never) ctx.provide('attachments', { readImage: async () => { throw new Error('fixture has no images') }, @@ -74,9 +67,7 @@ describe('Session log export Fetch route', () => { expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('application/zip') const files = unzipSync(new Uint8Array(await response.arrayBuffer())) - const exported = strFromU8(files['session.jsonl'] as Uint8Array) - expect(exported).toContain('"id":"session-1"') - expect(exported).not.toContain('isSeeded') + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toContain('"id":"session-1"') const head = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' }, diff --git a/packages/session-query/session-query-sqlite/README.i18n.yaml b/packages/session-query/session-query-sqlite/README.i18n.yaml index 23e7b38fac..70ff2fc46a 100644 --- a/packages/session-query/session-query-sqlite/README.i18n.yaml +++ b/packages/session-query/session-query-sqlite/README.i18n.yaml @@ -2,5 +2,5 @@ # 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-query/session-query-sqlite/README.md -README.md: 1db3999186beebc10a6a3c6874122fa65f2787a3 -README.zh.md: f6ef419293ab0df3a3edfd171cc60bc62ee4bcb0 +README.md: 9d0f4965aceb552ff6fd8cc3e0fc2cadf59125b3 +README.zh.md: 6b0e5641e791205fdfb066ea62eb69f6f990f5f7 diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 1db3999186..9d0f4965ac 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -49,7 +49,8 @@ Choose it when you want full-text recall over prior sessions with ranking and pa | `maxLimit` | `100` | Largest accepted request page size | | `snippetChars` | `240` | Maximum snippet length in Unicode code points | | `readWindowMax` | `50` | Maximum `before`/`after` raw events for the inherited `readEvent()` | -| `persistedInspectConcurrency` | `4` | Concurrent persisted-log inspections for inherited batch reads | +| `persistedReadConcurrency` | `4` | Concurrent persisted-log reads for inherited batch reads | +| `preparedSessionCacheSize` | `5` | Cold prepared-Session observations the inherited `observeSession` reader retains for reuse | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-query-sqlite) is the exhaustive source for every accepted field and its JSDoc. @@ -84,7 +85,7 @@ This section explains the design decisions behind the backend and points at the The backend is built on one separation and three commitments: - **Derived index, never the source store.** The FTS rows live in a dedicated disposable database; the session-persistence database is never opened here. -- **Live-preferred observation.** One serialized state machine compares persistence snapshot revisions, inspects only new or changed logs, and reconciles in one transaction, so a search reflects the newest stable state. +- **Live-preferred observation.** One serialized state machine compares persistence snapshot revisions, reads only new or changed logs through short-lived read handles, and reconciles in one transaction, so a search reflects the newest stable state. - **Generation-bound cursors.** Every corpus change bumps a generation; cursors carry the generation they were created under and fail stale rather than returning a shifted page. - **Literal phrases as data.** Caller query text is quoted into one FTS5 phrase so query syntax stays inert, and reserved highlight markers are stripped from documents before indexing. @@ -101,7 +102,7 @@ The design history lives in the [SQLite FTS5 session search note](../../../.agen ### Index lifecycle -Persisted FTS rows live in a dedicated derived database and survive restarts; live sessions use connection-local TEMP tables that shadow the durable base for the same session and reveal it again when the live owner detaches. Both tables retain the exact inherited cut in numeric `seed_length`; reconstructed headers expose only `isSeeded`, while the cut participates in live fingerprints and persisted source revisions. Each search runs one serialized observation: list persistence snapshots, compare per-session revisions with the indexed rows, inspect only new or changed logs, extract semantic documents, and commit the reconciliation in one transaction before running the query. Repeated queries and unchanged reopens inspect nothing; switching stores or observing new, changed, deleted, or externally repaired sources reconciles on the next stable observation. Source or transaction failure commits nothing and the next search retries. +Persisted FTS rows live in a dedicated derived database and survive restarts; live sessions use connection-local TEMP tables that shadow the durable base for the same session and reveal it again when the live owner detaches. Both tables retain the exact inherited cut in numeric `seed_length`; reconstructed headers expose only `isSeeded`, while the cut participates in live fingerprints and persisted source revisions. Each search runs one serialized observation: list persistence snapshots, compare per-session revisions with the indexed rows, read only new or changed logs through a read handle (balancing an interrupted final turn in memory, never writing back), extract semantic documents, and commit the reconciliation in one transaction before running the query. Repeated queries and unchanged reopens read nothing; switching stores or observing new, changed, deleted, or externally repaired sources reconciles on the next stable observation. Source or transaction failure commits nothing and the next search retries. ### Schema ownership diff --git a/packages/session-query/session-query-sqlite/README.zh.md b/packages/session-query/session-query-sqlite/README.zh.md index f6ef419293..6b0e5641e7 100644 --- a/packages/session-query/session-query-sqlite/README.zh.md +++ b/packages/session-query/session-query-sqlite/README.zh.md @@ -49,7 +49,8 @@ kind: "package-reference" | `maxLimit` | `100` | 接受的最大请求分页大小 | | `snippetChars` | `240` | 按 Unicode 码点计算的最大 snippet 长度 | | `readWindowMax` | `50` | 继承的 `readEvent()` 的 `before`/`after` 原始事件数上限 | -| `persistedInspectConcurrency` | `4` | 继承批量读取的并发持久化日志检查数 | +| `persistedReadConcurrency` | `4` | 继承批量读取的并发持久化日志读取数 | +| `preparedSessionCacheSize` | `5` | 继承的 `observeSession` 读取器为复用保留的冷 prepared-Session 观察数 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-query-sqlite)是每个受支持字段及其 JSDoc 的穷尽式真源。 @@ -84,7 +85,7 @@ kind: "package-reference" 本后端建立在一个分离与三项承诺之上: - **派生索引,绝不动源存储。** FTS 行存放在专用可丢弃数据库中;这里的代码从不打开 session-persistence 数据库。 -- **实时优先的观察。** 一个串行化状态机比较持久化快照修订,只检查新增或已更改日志,并在一个事务中对账,因此搜索反映最新的稳定状态。 +- **实时优先的观察。** 一个串行化状态机比较持久化快照修订,只通过短生命周期读取 handle 读取新增或已更改日志,并在一个事务中对账,因此搜索反映最新的稳定状态。 - **世代绑定的游标。** 每次语料库变化都会递增世代;游标携带其创建时的世代,宁可陈旧失败也不返回偏移后的页面。 - **字面短语即数据。** 调用方查询文本被引成一个 FTS5 短语,查询语法保持惰性;保留高亮标记在索引前从文档中剥离。 @@ -101,7 +102,7 @@ kind: "package-reference" ### 索引生命周期 -持久化 FTS 行存放在专用派生数据库中并跨重启保留;实时会话使用连接本地 TEMP 表,遮蔽同一会话的持久化基库,并在实时所有者脱离后再次显示基库。两类表都在数字 `seed_length` 中保留精确继承切点;重建的 header 只公开 `isSeeded`,而切点参与实时 fingerprint 与持久来源修订。每次搜索执行一次串行化观察:列出持久化快照、把逐会话修订与已索引行比较、只检查新增或已更改日志、提取语义文档,并在运行查询前于一个事务中提交对账。重复查询与不变的重新打开不会检查任何内容;切换存储或观察到新增、已更改、已删除或经外部修复的来源时,会在下次稳定观察时对账。来源或事务失败不提交任何内容,下一次搜索重试。 +持久化 FTS 行存放在专用派生数据库中并跨重启保留;实时会话使用连接本地 TEMP 表,遮蔽同一会话的持久化基库,并在实时所有者脱离后再次显示基库。两类表都在数字 `seed_length` 中保留精确继承切点;重建的 header 只公开 `isSeeded`,而切点参与实时 fingerprint 与持久来源修订。每次搜索执行一次串行化观察:列出持久化快照、把逐会话修订与已索引行比较、只通过读取 handle 读取新增或已更改日志(在内存中补齐被中断的末尾轮次,从不写回)、提取语义文档,并在运行查询前于一个事务中提交对账。重复查询与不变的重新打开不读取任何内容;切换存储或观察到新增、已更改、已删除或经外部修复的来源时,会在下次稳定观察时对账。来源或事务失败不提交任何内容,下一次搜索重试。 ### Schema 归属 diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4722557a65..18f1fa572d 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -5,17 +5,11 @@ */ import { createHash, randomUUID } from 'node:crypto' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type { DatabaseSync } from 'node:sqlite' import { Context, Service, type Fiber } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SessionSeq } from '@deepseek-ai/dsh-session' -import type { - Session, - SessionEvent, - SessionHeader, - SessionId, - SessionLogOffset, -} from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId , SessionLogOffset } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionPersistenceRevision, @@ -23,11 +17,13 @@ import type { } from '@deepseek-ai/dsh-session-persistence' import SessionQueryEngine, { SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, + SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE, SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, SessionSearchCursor, assertSessionHeadersCompatible, buildSessionEventSearchDocuments, + readColdSessionLog, } from '@deepseek-ai/dsh-session-query' import type { Config as SessionQueryConfig, @@ -116,8 +112,10 @@ export interface Config extends SessionQueryConfig { maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number - /** Maximum concurrent persisted-log inspections in one inherited batch read. Defaults to 4. */ - persistedInspectConcurrency?: number + /** Maximum concurrent persisted-log reads in one inherited batch read. Defaults to 4. */ + persistedReadConcurrency?: number + /** Maximum cold prepared-Session observations the inherited reader retains for reuse. Defaults to 5. */ + preparedSessionCacheSize?: number } interface ResolvedConfig { @@ -128,7 +126,8 @@ interface ResolvedConfig { maxLimit: number snippetChars: number readWindowMax: number - persistedInspectConcurrency: number + persistedReadConcurrency: number + preparedSessionCacheSize: number } interface ObservedSession { @@ -212,11 +211,16 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT), snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS), readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX), - persistedInspectConcurrency: z.number() + persistedReadConcurrency: z.number() .step(1) .min(1) .max(Number.MAX_SAFE_INTEGER) .default(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY), + preparedSessionCacheSize: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE), }) /** Validated and defaulted backend configuration. */ @@ -502,28 +506,26 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { try { const canReuseIndexed = this._lastPersistenceIdentity === undefined || this._lastPersistenceIdentity === persistenceBinding.identity - const before = await persistence.listSnapshots(signal) + const listOptions = signal === undefined ? undefined : { signal } + const before = await persistence.list(listOptions) assertNotAborted(signal) persisted = materializePersistenceSnapshots(before) for (const entry of persisted.values()) { if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue - // Skip work already shadowed by a live owner. `inspect()` is - // non-mutating, so an owner attaching after this check cannot cause - // crash-repair side effects; the live-membership retry below makes - // the returned observation live-preferred. + // Skip work already shadowed by a live owner. The cold read is + // non-mutating (interrupted turns are balanced in memory only), so + // an owner attaching after this check cannot cause side effects; + // the live-membership retry below makes the returned observation + // live-preferred. if (initiallyLive.has(entry.header.id) || this.ctx.sessions.get(entry.header.id) !== undefined) continue assertNotAborted(signal) - const loaded = await persistence.inspect(entry.header.id, signal) + const loaded = await readColdSessionLog(persistence, entry.header.id, signal) assertNotAborted(signal) - assertSessionHeadersCompatible(entry.header, loaded.meta) - entry.loaded = observeSession( - loaded.meta, - loaded.inheritedEventCount, - loaded.events, - ) + assertSessionHeadersCompatible(entry.header, loaded.header) + entry.loaded = observeSession(loaded.header, loaded.inheritedEventCount, loaded.events) } assertNotAborted(signal) - const afterSnapshots = await persistence.listSnapshots(signal) + const afterSnapshots = await persistence.list(listOptions) assertNotAborted(signal) const after = materializePersistenceSnapshots(afterSnapshots) if (!samePersistenceSnapshots(persisted, after)) continue @@ -1026,8 +1028,10 @@ function resolveConfig(config: Config): ResolvedConfig { maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT, snippetChars: config.snippetChars ?? SESSION_QUERY_SQLITE_SNIPPET_CHARS, readWindowMax: config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX, - persistedInspectConcurrency: config.persistedInspectConcurrency + persistedReadConcurrency: config.persistedReadConcurrency ?? SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, + preparedSessionCacheSize: config.preparedSessionCacheSize + ?? SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE, } if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { throw invalidConfig('path must not be blank') @@ -1041,10 +1045,16 @@ function resolveConfig(config: Config): ResolvedConfig { throw invalidConfig('readWindowMax must be a non-negative integer') } if ( - !Number.isSafeInteger(resolved.persistedInspectConcurrency) - || resolved.persistedInspectConcurrency < 1 + !Number.isSafeInteger(resolved.persistedReadConcurrency) + || resolved.persistedReadConcurrency < 1 ) { - throw invalidConfig('persistedInspectConcurrency must be a positive safe integer') + throw invalidConfig('persistedReadConcurrency must be a positive safe integer') + } + if ( + !Number.isSafeInteger(resolved.preparedSessionCacheSize) + || resolved.preparedSessionCacheSize < 1 + ) { + throw invalidConfig('preparedSessionCacheSize must be a positive safe integer') } if (resolved.defaultLimit > resolved.maxLimit) { throw invalidConfig('defaultLimit must be less than or equal to maxLimit') diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts index 29d37915fb..2c5bae0cf0 100644 --- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionSeq, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine, * as queryModule from '@deepseek-ai/dsh-session-query-sqlite' @@ -48,13 +48,8 @@ describe('dsh-session-query-sqlite real Loader path', () => { const query = await ctx.plugin(unwrapped, { path: searchPath }) const id = SessionId('loader-path') - await ctx.sessionPersistence.create({ - version: SESSION_FORMAT_VERSION, - id, - createdAt: 10, - isSeeded: false, - }) - await ctx.sessionPersistence.append(id, [{ + const writer = await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id, createdAt: 10, isSeeded: false }) + await writer.append([{ type: 'user/message', seq: SessionSeq(0), time: 10, @@ -63,6 +58,7 @@ describe('dsh-session-query-sqlite real Loader path', () => { }), surfaceOp: 'append', }]) + await writer.close() await expect(ctx.sessionQuery.searchSessions({ query: 'Loader needle' })) .resolves.toMatchObject({ items: [{ header: { id }, persisted: true, live: false }] }) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index e09d74ba12..c9d944bfc5 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -5,16 +5,21 @@ import { DatabaseSync } from 'node:sqlite' import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import SessionStore, { - SESSION_FORMAT_VERSION, - SessionId, - SessionLogOffset, - SessionSeq, -} from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset, SessionSeq, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEventSuffix, SessionInspection, SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { + SessionPersistenceNotFoundError, + SessionPersistenceRevision, + SessionReadOnlyError, +} from '@deepseek-ai/dsh-session-persistence' +import type { + SessionAccess, + SessionHandle, + SessionHandleReadOptions, + SessionPersistenceListOptions, + SessionPersistenceSnapshot, +} from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine, { SESSION_QUERY_SQLITE_SCHEMA_VERSION, @@ -46,7 +51,7 @@ function header(id: string, createdAt = 1, extra: Partial = {}): return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, isSeeded: false, ...extra } } -function messageEvents(text: string, time = 1): SessionEvent<'user/message'>[] { +function messageEvents(text: string, time = 1): SessionEvent[] { return [{ type: 'user/message', seq: SessionSeq(0), @@ -72,145 +77,120 @@ function replaceCursorOffset( return SessionSearchCursor(Buffer.from(JSON.stringify({ ...payload, offset }), 'utf8').toString('base64url')) } -class TestPersistence extends SessionPersistence { - override readonly supportsRawArtifacts = false +class TestHandle implements SessionHandle { + readonly inheritedEventCount = SessionLogOffset(0) - static entries = new Map() + constructor( + readonly id: SessionIdType, + readonly header: SessionHeader, + readonly access: SessionAccess, + ) {} + + async read(_offset = 0, _length?: number, options?: SessionHandleReadOptions): Promise { + TestPersistence.reads.set(this.id, (TestPersistence.reads.get(this.id) ?? 0) + 1) + TestPersistence.readSignals.push(options?.signal) + if (TestPersistence.failure !== undefined) throw TestPersistence.failure + const entry = TestPersistence.entries.get(this.id) + if (entry === undefined) throw new SessionPersistenceNotFoundError(this.id) + await TestPersistence.readEffect?.(entry, options?.signal) + TestPersistence.readEffect = undefined + return structuredClone(entry.events) + } + + append(events: readonly SessionEvent[]): Promise { + if (this.access === 'read') return Promise.reject(new SessionReadOnlyError(this.id, 'append')) + const entry = TestPersistence.entries.get(this.id) + if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(this.id)) + entry.events.push(...structuredClone(events)) + TestPersistence.revisions.set(this.id, ++TestPersistence.nextRevision) + return Promise.resolve() + } + + flush(): Promise { + if (this.access === 'read') return Promise.reject(new SessionReadOnlyError(this.id, 'flush')) + return Promise.resolve() + } + + close(): Promise { + return Promise.resolve() + } + + [Symbol.asyncDispose](): Promise { + return this.close() + } +} + +class TestPersistence extends SessionPersistence { + static entries = new Map() static revisions = new Map() static nextRevision = 0 - static loads = new Map() - static inspections = new Map() - static inspectSignals: Array = [] - static snapshotSignals: Array = [] - static loadEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void) | undefined - static inspectEffect: (( + static reads = new Map() + static readSignals: Array = [] + static listSignals: Array = [] + static readEffect: (( entry: { meta: SessionHeader; events: SessionEvent[] }, signal?: AbortSignal, ) => void | Promise) | undefined static listGate: Promise | undefined static listStarted: (() => void) | undefined - static snapshotEffect: ((signal?: AbortSignal) => void | Promise) | undefined - static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined + static listEffect: ((signal?: AbortSignal) => void | Promise) | undefined + static listOverride: (() => SessionPersistenceSnapshot[]) | undefined static failure: unknown - locate(_meta: SessionHeader): undefined { - return undefined - } - - borrowSession(_id: SessionIdType, _signal?: AbortSignal): ReturnType { - return Promise.reject(new Error('not used')) - } - - static reset(entries: readonly { - meta: SessionHeader - inheritedEventCount?: SessionLogOffset - events: SessionEvent[] - }[] = []): void { + static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map() this.revisions = new Map() - this.loads = new Map() - this.inspections = new Map() - this.inspectSignals = [] - this.snapshotSignals = [] - this.loadEffect = undefined - this.inspectEffect = undefined + this.reads = new Map() + this.readSignals = [] + this.listSignals = [] + this.readEffect = undefined for (const entry of entries) this.set(entry) this.listGate = undefined this.listStarted = undefined - this.snapshotEffect = undefined - this.snapshotOverride = undefined + this.listEffect = undefined + this.listOverride = undefined this.failure = undefined } - static set(entry: { - meta: SessionHeader - inheritedEventCount?: SessionLogOffset - events: SessionEvent[] - }): void { + static set(entry: { meta: SessionHeader; events: SessionEvent[] }): void { this.entries.set(entry.meta.id, structuredClone(entry)) this.revisions.set(entry.meta.id, ++this.nextRevision) } - create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise { - TestPersistence.set({ - meta, - ...inheritedEventCount === undefined ? {} : { inheritedEventCount }, - events: [], + create(header: SessionHeader): Promise { + TestPersistence.set({ meta: header, events: [] }) + return Promise.resolve(new TestHandle(header.id, structuredClone(header), 'write')) + } + + // Appends are durable on resolution here; nothing buffers, so the service-wide flush is a no-op. + async flush(): Promise {} + + open(id: SessionIdType, access: SessionAccess): Promise { + const entry = TestPersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(id)) + return Promise.resolve(new TestHandle(id, structuredClone(entry.meta), access)) + } + + stat(id: SessionIdType): Promise { + const entry = TestPersistence.entries.get(id) + if (entry === undefined) return Promise.resolve(undefined) + return Promise.resolve({ + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`test:${TestPersistence.revisions.get(id)}`), }) - return Promise.resolve() } - append(id: SessionIdType, events: readonly SessionEvent[]): Promise { - const entry = TestPersistence.entries.get(id) - if (entry === undefined) return Promise.reject(new Error('missing test session')) - entry.events.push(...structuredClone(events)) - TestPersistence.revisions.set(id, ++TestPersistence.nextRevision) - return Promise.resolve() - } - - async load(id: SessionIdType): Promise { - TestPersistence.loads.set(id, (TestPersistence.loads.get(id) ?? 0) + 1) - if (TestPersistence.failure !== undefined) throw TestPersistence.failure - const entry = TestPersistence.entries.get(id) - if (entry === undefined) throw new Error('missing test session') - if (TestPersistence.loadEffect !== undefined) { - const effect = TestPersistence.loadEffect - TestPersistence.loadEffect = undefined - effect(entry) - TestPersistence.revisions.set(id, ++TestPersistence.nextRevision) - } - return { - ...structuredClone(entry), - inheritedEventCount: entry.inheritedEventCount ?? SessionLogOffset(0), - } - } - - async inspect(id: SessionIdType, signal?: AbortSignal): Promise { - TestPersistence.inspections.set(id, (TestPersistence.inspections.get(id) ?? 0) + 1) - TestPersistence.inspectSignals.push(signal) - if (TestPersistence.failure !== undefined) throw TestPersistence.failure - const entry = TestPersistence.entries.get(id) - if (entry === undefined) throw new Error('missing test session') - await TestPersistence.inspectEffect?.(entry, signal) - TestPersistence.inspectEffect = undefined - return { - ...structuredClone(entry), - inheritedEventCount: entry.inheritedEventCount ?? SessionLogOffset(0), - } - } - - async readFrom( - id: SessionIdType, - fromSeq: SessionLogOffset, - signal?: AbortSignal, - ): Promise { - const whole = await this.inspect(id, signal) - return { ...whole, fromSeq, events: whole.events.filter(event => event.seq >= fromSeq) } - } - - async list(): Promise { + async list(options?: SessionPersistenceListOptions): Promise { + TestPersistence.listSignals.push(options?.signal) TestPersistence.listStarted?.() await TestPersistence.listGate if (TestPersistence.failure !== undefined) throw TestPersistence.failure - return [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) - } - - - async listSnapshots(signal?: AbortSignal): Promise { - TestPersistence.snapshotSignals.push(signal) - TestPersistence.listStarted?.() - await TestPersistence.listGate - if (TestPersistence.failure !== undefined) throw TestPersistence.failure - const snapshots = TestPersistence.snapshotOverride?.() + const snapshots = TestPersistence.listOverride?.() ?? [...TestPersistence.entries.values()].map(entry => ({ header: structuredClone(entry.meta), revision: SessionPersistenceRevision(`test:${TestPersistence.revisions.get(entry.meta.id)}`), })) - await TestPersistence.snapshotEffect?.(signal) + await TestPersistence.listEffect?.(options?.signal) return snapshots } } @@ -227,25 +207,25 @@ describe('SQLite session search', () => { it('defaults and validates opening policy and persisted inspection concurrency through its Cordis config', async () => { const defaultCtx = await liveContext() expect((defaultCtx.sessionQuery as SqliteSessionQueryEngine).config.openAt).toBe('startup') - expect((defaultCtx.sessionQuery as SqliteSessionQueryEngine).config.persistedInspectConcurrency) + expect((defaultCtx.sessionQuery as SqliteSessionQueryEngine).config.persistedReadConcurrency) .toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY) const configuredValue = 2 const configured = new SqliteSessionQueryEngine.Config({ path: ':memory:', openAt: 'first-search', - persistedInspectConcurrency: configuredValue, + persistedReadConcurrency: configuredValue, }) expect(configured.openAt).toBe('first-search') - expect(configured.persistedInspectConcurrency).toBe(configuredValue) + expect(configured.persistedReadConcurrency).toBe(configuredValue) const configuredCtx = await liveContext(configured) - expect((configuredCtx.sessionQuery as SqliteSessionQueryEngine).config.persistedInspectConcurrency) + expect((configuredCtx.sessionQuery as SqliteSessionQueryEngine).config.persistedReadConcurrency) .toBe(configuredValue) - for (const persistedInspectConcurrency of [0, Number.MAX_SAFE_INTEGER + 1]) { + for (const persistedReadConcurrency of [0, Number.MAX_SAFE_INTEGER + 1]) { expect(() => new SqliteSessionQueryEngine.Config({ path: ':memory:', - persistedInspectConcurrency, + persistedReadConcurrency, })).toThrow() } expect(() => new SqliteSessionQueryEngine.Config({ @@ -371,43 +351,10 @@ describe('SQLite session search', () => { await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'AI' })) .resolves.toMatchObject({ session: session.header, - items: [{ sessionId: session.id, seq: 2, snippet: 'An AI helper' }], + items: [{ sessionId: session.id, seq: SessionSeq(2), snippet: 'An AI helper' }], }) await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })) .resolves.toMatchObject({ items: [{ header: session.header, live: true, persisted: false }] }) - const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db - expect(db.prepare('SELECT seed_length FROM temp.live_sessions WHERE id = ?').get(session.id)) - .toEqual({ seed_length: 1 }) - }) - - it('retains a persisted inherited cut and reindexes when that source identity changes', async () => { - const meta = header('persisted-seed-cut', 10, { isSeeded: true }) - const events: SessionEvent[] = [ - ...messageEvents('persisted cut needle'), - { ...messageEvents('second inherited event')[0]!, seq: SessionSeq(1) }, - ] - TestPersistence.reset([{ - meta, - inheritedEventCount: SessionLogOffset(1), - events, - }]) - const ctx = await liveContext() - await ctx.plugin(TestPersistence) - const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db - - await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) - .resolves.toMatchObject({ items: [{ header: { ...meta, isSeeded: true } }] }) - expect(db.prepare('SELECT seed_length FROM persisted_sessions WHERE id = ?').get(meta.id)) - .toEqual({ seed_length: 1 }) - - TestPersistence.set({ - meta, - inheritedEventCount: SessionLogOffset(2), - events, - }) - await ctx.sessionQuery.searchSessions({ query: 'needle' }) - expect(db.prepare('SELECT seed_length FROM persisted_sessions WHERE id = ?').get(meta.id)) - .toEqual({ seed_length: 2 }) }) it('excludes assistant reasoning while indexing visible answer text', async () => { @@ -467,7 +414,7 @@ describe('SQLite session search', () => { { kind: 'type', values: ['user/message'] }, { kind: 'surface', values: ['current'] }, ], - })).resolves.toMatchObject({ items: [{ seq: 2, surface: 'current' }] }) + })).resolves.toMatchObject({ items: [{ seq: SessionSeq(2), surface: 'current' }] }) const grouped = await ctx.sessionQuery.searchSessions({ query: 'needle', @@ -485,7 +432,7 @@ describe('SQLite session search', () => { header: { id: SessionId('a'), cwd: '/a', parentSession: parent }, live: true, persisted: false, - bestMatch: { seq: 0, surface: 'shadowed' }, + bestMatch: { seq: SessionSeq(0), surface: 'shadowed' }, }) }) @@ -510,7 +457,7 @@ describe('SQLite session search', () => { sessionId: session.id, query: 'needle', filters: eventFilters, - })).resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0 }] }) + })).resolves.toMatchObject({ items: [{ sessionId: session.id, seq: SessionSeq(0) }] }) }) it('rejects unsupported FTS5 outer-predicate counts with typed errors', async () => { @@ -745,8 +692,10 @@ describe('SQLite session search', () => { { path: ':memory:', maxLimit: 1e100 }, { path: ':memory:', snippetChars: 0 }, { path: ':memory:', readWindowMax: -1 }, - { path: ':memory:', persistedInspectConcurrency: 0 }, - { path: ':memory:', persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 }, + { path: ':memory:', persistedReadConcurrency: 0 }, + { path: ':memory:', persistedReadConcurrency: Number.MAX_SAFE_INTEGER + 1 }, + { path: ':memory:', preparedSessionCacheSize: 0 }, + { path: ':memory:', preparedSessionCacheSize: Number.MAX_SAFE_INTEGER + 1 }, { path: ':memory:', defaultLimit: 3, maxLimit: 2 }, { path: ':memory:', openAt: 'later' }, { path: ':memory:', journalMode: 'memory' }, @@ -884,14 +833,12 @@ describe('SQLite reconciliation and source lifecycle', () => { })).resolves.toMatchObject({ items: [{ header: shared, live: true, persisted: true }], }) - expect(TestPersistence.loads.get(shared.id)).toBeUndefined() - expect(TestPersistence.inspections.get(shared.id)).toBeUndefined() + expect(TestPersistence.reads.get(shared.id)).toBeUndefined() detach() await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' })) .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) - expect(TestPersistence.loads.get(shared.id)).toBeUndefined() - expect(TestPersistence.inspections.get(shared.id)).toBe(1) + expect(TestPersistence.reads.get(shared.id)).toBe(1) await persistence.dispose() }) @@ -899,8 +846,8 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.reset() const ctx = await liveContext() await ctx.plugin(TestPersistence) - TestPersistence.snapshotEffect = () => { - TestPersistence.snapshotEffect = undefined + TestPersistence.listEffect = () => { + TestPersistence.listEffect = undefined ctx.sessions.create(SessionId('attached'), { seed: messageEvents('attached needle') }) } @@ -908,16 +855,13 @@ describe('SQLite reconciliation and source lifecycle', () => { .resolves.toMatchObject({ items: [{ header: { id: SessionId('attached') } }] }) }) - it('cannot crash-repair a log when live ownership begins during persisted inspection', async () => { - const shared = header('attach-during-inspect', 10) + it('prefers a live owner that attaches during a persisted read and never mutates the store', async () => { + const shared = header('attach-during-read', 10) const persistedEvents = messageEvents('persisted needle') TestPersistence.reset([{ meta: shared, events: persistedEvents }]) const ctx = await liveContext() await ctx.plugin(TestPersistence) - TestPersistence.loadEffect = (entry) => { - entry.events = messageEvents('incorrect repair') - } - TestPersistence.inspectEffect = () => { + TestPersistence.readEffect = () => { ctx.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: shared.createdAt }, @@ -926,7 +870,7 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(ctx.sessionQuery.searchSessions({ query: 'live' })) .resolves.toMatchObject({ items: [{ header: shared, live: true, persisted: true }] }) - expect(TestPersistence.loads.get(shared.id)).toBeUndefined() + // The cold read is observation-only: the stored log is unchanged. expect(TestPersistence.entries.get(shared.id)?.events).toEqual(persistedEvents) }) @@ -937,8 +881,8 @@ describe('SQLite reconciliation and source lifecycle', () => { const detachFirst = ctx.sessions.enter(first) ctx.sessions.announce(first) await ctx.plugin(TestPersistence) - TestPersistence.snapshotEffect = () => { - TestPersistence.snapshotEffect = undefined + TestPersistence.listEffect = () => { + TestPersistence.listEffect = undefined detachFirst() ctx.sessions.create(SessionId('second'), { seed: messageEvents('second needle') }) } @@ -1040,10 +984,10 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.revisions.set(durable.id, revision) const replacement = await ctx.plugin(TestPersistence) const page = await ctx.sessionQuery.searchSessions({ query: 'new needle' }) - expect(TestPersistence.inspections.get(durable.id)).toBe(2) + expect(TestPersistence.reads.get(durable.id)).toBe(2) expect(page).toMatchObject({ items: [{ header: durable }] }) await expect(ctx.sessionQuery.searchSessions({ query: 'old' })).resolves.toEqual({ items: [] }) - expect(TestPersistence.inspections.get(durable.id)).toBe(2) + expect(TestPersistence.reads.get(durable.id)).toBe(2) await replacement.dispose() }) @@ -1053,7 +997,7 @@ describe('SQLite reconciliation and source lifecycle', () => { const ctx = await liveContext() const persistence = await ctx.plugin(TestPersistence) let lists = 0 - TestPersistence.snapshotEffect = async () => { + TestPersistence.listEffect = async () => { lists += 1 if (lists === 2) await persistence.dispose() } @@ -1068,15 +1012,15 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.reset([{ meta: first, events: messageEvents('first needle') }]) const ctx = await liveContext() await ctx.plugin(TestPersistence) - TestPersistence.snapshotEffect = () => { - TestPersistence.snapshotEffect = undefined + TestPersistence.listEffect = () => { + TestPersistence.listEffect = undefined TestPersistence.set({ meta: added, events: messageEvents('added needle') }) } const page = await ctx.sessionQuery.searchSessions({ query: 'needle' }) expect(page.items.map(item => item.header.id).sort()).toEqual([added.id, first.id].sort()) - expect(TestPersistence.inspections.get(first.id)).toBe(2) - expect(TestPersistence.inspections.get(added.id)).toBe(1) + expect(TestPersistence.reads.get(first.id)).toBe(2) + expect(TestPersistence.reads.get(added.id)).toBe(1) }) it('fails after one retry when persistence snapshots keep changing', async () => { @@ -1085,7 +1029,7 @@ describe('SQLite reconciliation and source lifecycle', () => { const ctx = await liveContext() await ctx.plugin(TestPersistence) let lists = 0 - TestPersistence.snapshotEffect = () => { + TestPersistence.listEffect = () => { lists += 1 TestPersistence.set({ meta: durable, events: messageEvents(`durable needle ${lists}`) }) } @@ -1118,7 +1062,7 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .resolves.toMatchObject({ items: [{ header: durable }] }) - expect(TestPersistence.inspections.get(durable.id)).toBe(2) + expect(TestPersistence.reads.get(durable.id)).toBe(2) list.mockRestore() }) @@ -1128,20 +1072,20 @@ describe('SQLite reconciliation and source lifecycle', () => { const ctx = await liveContext() await ctx.plugin(TestPersistence) - TestPersistence.snapshotOverride = () => 'not-an-array' as never + TestPersistence.listOverride = () => 'not-an-array' as never await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.snapshotOverride = () => [{ header: durable, revision: 1 as never }] + TestPersistence.listOverride = () => [{ header: durable, revision: 1 as never }] await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.snapshotOverride = () => [ + TestPersistence.listOverride = () => [ { header: durable, revision: SessionPersistenceRevision('duplicate:1') }, { header: durable, revision: SessionPersistenceRevision('duplicate:2') }, ] await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.snapshotOverride = undefined + TestPersistence.listOverride = undefined const typed = new SessionQueryError('typed persistence failure', 'SESSION_QUERY_PERSISTENCE_FAILED') TestPersistence.failure = typed await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).rejects.toBe(typed) @@ -1177,9 +1121,9 @@ describe('SQLite reconciliation and source lifecycle', () => { const firstPersistence = await first.plugin(TestPersistence) const firstSearch = await first.plugin(SqliteSessionQueryEngine, { path }) await first.sessionQuery.searchSessions({ query: 'needle' }) - expect(Object.fromEntries(TestPersistence.inspections)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) + expect(Object.fromEntries(TestPersistence.reads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) await first.sessionQuery.searchSessions({ query: 'needle' }) - expect(Object.fromEntries(TestPersistence.inspections)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) + expect(Object.fromEntries(TestPersistence.reads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) await firstSearch.dispose() await firstPersistence.dispose() @@ -1199,7 +1143,7 @@ describe('SQLite reconciliation and source lifecycle', () => { const secondSearch = await second.plugin(SqliteSessionQueryEngine, { path }) const result = await second.sessionQuery.searchSessions({ query: 'needle' }) expect(result.items.map(item => item.header.id).sort()).toEqual([added.id, changed.id, unchanged.id].sort()) - expect(Object.fromEntries(TestPersistence.inspections)).toEqual({ + expect(Object.fromEntries(TestPersistence.reads)).toEqual({ unchanged: 1, changed: 2, deleted: 1, @@ -1240,29 +1184,27 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(second.sessionQuery.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) await expect(second.sessionQuery.searchSessions({ query: 'persisted' })) .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) - expect(TestPersistence.inspections.get(shared.id)).toBe(1) + expect(TestPersistence.reads.get(shared.id)).toBe(1) await searchAgain.dispose() await persistenceAgain.dispose() }) - it('refreshes after an external mutating load repair without loading from the query path', async () => { + it('refreshes after an external writer replaces a stored log, then reuses the new revision', async () => { const durable = header('repair') TestPersistence.reset([{ meta: durable, events: messageEvents('before repair') }]) const ctx = await liveContext() const persistence = await ctx.plugin(TestPersistence) await expect(ctx.sessionQuery.searchSessions({ query: 'before' })) .resolves.toMatchObject({ items: [{ header: durable }] }) - TestPersistence.loadEffect = (entry) => { - entry.events = messageEvents('repaired needle') - } - await ctx.sessionPersistence.load(durable.id) + // An external writer (resume-time torn-tail repair, or another append) + // replaces the stored log and moves its revision. + TestPersistence.set({ meta: durable, events: messageEvents('repaired needle') }) await expect(ctx.sessionQuery.searchSessions({ query: 'repaired' })) .resolves.toMatchObject({ items: [{ header: durable }] }) - expect(TestPersistence.inspections.get(durable.id)).toBe(2) + expect(TestPersistence.reads.get(durable.id)).toBe(2) await ctx.sessionQuery.searchSessions({ query: 'repaired' }) - expect(TestPersistence.inspections.get(durable.id)).toBe(2) - expect(TestPersistence.loads.get(durable.id)).toBe(1) + expect(TestPersistence.reads.get(durable.id)).toBe(2) await persistence.dispose() }) @@ -1294,7 +1236,7 @@ describe('SQLite reconciliation and source lifecycle', () => { db.exec('PRAGMA query_only = OFF') // seq 2: one-event seed, end-seed, then the live message. await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' })) - .resolves.toMatchObject({ items: [{ seq: 2 }] }) + .resolves.toMatchObject({ items: [{ seq: SessionSeq(2) }] }) }) }) @@ -1524,8 +1466,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => ) expect(result.items).toHaveLength(1) - expect(TestPersistence.snapshotSignals).toEqual([controller.signal, controller.signal]) - expect(TestPersistence.inspectSignals).toEqual([controller.signal]) + expect(TestPersistence.listSignals).toEqual([controller.signal, controller.signal]) + expect(TestPersistence.readSignals).toEqual([controller.signal]) }, ) @@ -1547,8 +1489,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => ) await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - expect(TestPersistence.snapshotSignals).toEqual([]) - expect(TestPersistence.inspectSignals).toEqual([]) + expect(TestPersistence.listSignals).toEqual([]) + expect(TestPersistence.readSignals).toEqual([]) }, ) @@ -1560,8 +1502,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const started = Promise.withResolvers() const abortObserved = Promise.withResolvers() const cleanup = Promise.withResolvers() - TestPersistence.snapshotEffect = async (signal) => { - TestPersistence.snapshotEffect = undefined + TestPersistence.listEffect = async (signal) => { + TestPersistence.listEffect = undefined if (signal === undefined) throw new Error('expected reconciliation signal') started.resolve(signal) await new Promise((resolve) => { @@ -1583,8 +1525,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => controller.abort(new Error('cooperative list cancellation')) await abortObserved.promise expect(settled).toBe(false) - expect(TestPersistence.snapshotSignals).toEqual([controller.signal]) - expect(TestPersistence.inspectSignals).toEqual([]) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.readSignals).toEqual([]) cleanup.resolve(undefined) await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) @@ -1621,8 +1563,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => expect(firstSettled).toBe(false) expect(secondSettled).toBe(false) - expect(TestPersistence.snapshotSignals).toEqual([controller.signal]) - expect(TestPersistence.inspectSignals).toEqual([]) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.readSignals).toEqual([]) cleanup.resolve(undefined) await expect(first).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) @@ -1640,8 +1582,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await ctx.plugin(TestPersistence) const started = Promise.withResolvers() const cleanup = Promise.withResolvers() - TestPersistence.inspectEffect = async (_entry, signal) => { - TestPersistence.inspectEffect = undefined + TestPersistence.readEffect = async (_entry, signal) => { + TestPersistence.readEffect = undefined if (signal === undefined) throw new Error('expected reconciliation signal') started.resolve(signal) await cleanup.promise @@ -1658,14 +1600,14 @@ describe('SQLite schema, cancellation, and real persistence integration', () => controller.abort(new Error('ignored inspect cancellation')) await Promise.resolve() expect(settled).toBe(false) - expect(TestPersistence.snapshotSignals).toEqual([controller.signal]) - expect(TestPersistence.inspections.get(first.id)).toBe(1) - expect(TestPersistence.inspections.get(second.id)).toBeUndefined() + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.reads.get(first.id)).toBe(1) + expect(TestPersistence.reads.get(second.id)).toBeUndefined() cleanup.resolve(undefined) await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - expect(TestPersistence.snapshotSignals).toEqual([controller.signal]) - expect(TestPersistence.inspections.get(second.id)).toBeUndefined() + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.reads.get(second.id)).toBeUndefined() }) it('cancels both queued and in-flight source waits without committing them', async () => { @@ -1840,85 +1782,80 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await persistence.dispose() }) - it('combines the real JSONL persistence backend with the real SQLite search service keylessly', async () => { - const persistenceRoot = await temporaryPath('canonical') + it('combines the real JSONL persistence backend with the real search service keylessly', async () => { + const persistenceRoot = await temporaryPath('sessions') const searchPath = await temporaryPath('derived.db') const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - const persistence = await ctx.plugin(JsonlSessionPersistence, { - root: persistenceRoot, - compression: 'none', - }) + const persistence = await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) const search = await ctx.plugin(SqliteSessionQueryEngine, { path: searchPath }) const meta = header('real', 10, { cwd: '/work' }) - await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(meta.id, messageEvents('real search needle')) + const writer = await ctx.sessionPersistence.create(meta) + await writer.append(messageEvents('real JSONL needle')) + await writer.close() - await expect(ctx.sessionQuery.searchSessions({ query: 'search needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'JSONL needle' })) .resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] }) - await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'search needle' })) - .resolves.toMatchObject({ session: meta, items: [{ sessionId: meta.id, seq: 0 }] }) + await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'JSONL needle' })) + .resolves.toMatchObject({ session: meta, items: [{ sessionId: meta.id, seq: SessionSeq(0) }] }) await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) await search.dispose() - await expect(ctx.sessionPersistence.load(meta.id)).resolves.toMatchObject({ meta, events: [{ seq: 0 }] }) + const reader = await ctx.sessionPersistence.open(meta.id, 'read') + expect(reader.header).toMatchObject(meta) + await expect(reader.read()).resolves.toMatchObject([{ seq: SessionSeq(0) }]) + await reader.close() await persistence.dispose() }) - it('reconciles colliding local revisions when a derived index reopens against another JSONL store', async () => { - const persistenceRootA = await temporaryPath('canonical-a') - const persistenceRootB = await temporaryPath('canonical-b') + it('reconciles a reopened derived index: unchanged revisions skip reads, another store reloads', async () => { + const persistenceRootA = await temporaryPath('sessions-a') + const persistenceRootB = await temporaryPath('sessions-b') const searchPath = await temporaryPath('derived-collision.db') const shared = header('same-id', 10) + const storeSession = async (ctx: Context, events: SessionEvent[]): Promise => { + const writer = await ctx.sessionPersistence.create(shared) + await writer.append(events) + await writer.close() + } const first = new Context() await first.plugin(SessionStore) await first.plugin(SessionProjectionRegistry) - const persistenceA = await first.plugin(JsonlSessionPersistence, { - root: persistenceRootA, - compression: 'none', - }) - await first.sessionPersistence.create(shared) - await first.sessionPersistence.append(shared.id, messageEvents('alpha source')) - const inspectA = vi.spyOn(first.sessionPersistence, 'inspect') + const persistenceA = await first.plugin(JsonlSessionPersistence, { root: persistenceRootA, compression: 'none' }) + await storeSession(first, messageEvents('alpha source')) + const openA = vi.spyOn(first.sessionPersistence, 'open') const searchA = await first.plugin(SqliteSessionQueryEngine, { path: searchPath }) await expect(first.sessionQuery.searchSessions({ query: 'alpha' })) .resolves.toMatchObject({ items: [{ header: shared }] }) - expect(inspectA).toHaveBeenCalledTimes(1) + expect(openA).toHaveBeenCalledTimes(1) await searchA.dispose() await persistenceA.dispose() const reopened = new Context() await reopened.plugin(SessionStore) await reopened.plugin(SessionProjectionRegistry) - const persistenceAAgain = await reopened.plugin(JsonlSessionPersistence, { - root: persistenceRootA, - compression: 'none', - }) - const reopenedInspect = vi.spyOn(reopened.sessionPersistence, 'inspect') + const persistenceAAgain = await reopened.plugin(JsonlSessionPersistence, { root: persistenceRootA, compression: 'none' }) + const reopenedOpen = vi.spyOn(reopened.sessionPersistence, 'open') const searchAAgain = await reopened.plugin(SqliteSessionQueryEngine, { path: searchPath }) await expect(reopened.sessionQuery.searchSessions({ query: 'alpha' })) .resolves.toMatchObject({ items: [{ header: shared }] }) - expect(reopenedInspect).not.toHaveBeenCalled() + expect(reopenedOpen).not.toHaveBeenCalled() await searchAAgain.dispose() await persistenceAAgain.dispose() const second = new Context() await second.plugin(SessionStore) await second.plugin(SessionProjectionRegistry) - const persistenceB = await second.plugin(JsonlSessionPersistence, { - root: persistenceRootB, - compression: 'none', - }) - await second.sessionPersistence.create(shared) - await second.sessionPersistence.append(shared.id, messageEvents('bravo source')) - const inspectB = vi.spyOn(second.sessionPersistence, 'inspect') + const persistenceB = await second.plugin(JsonlSessionPersistence, { root: persistenceRootB, compression: 'none' }) + await storeSession(second, messageEvents('bravo source')) + const openB = vi.spyOn(second.sessionPersistence, 'open') const searchB = await second.plugin(SqliteSessionQueryEngine, { path: searchPath }) await expect(second.sessionQuery.searchSessions({ query: 'bravo' })) .resolves.toMatchObject({ items: [{ header: shared }] }) await expect(second.sessionQuery.searchSessions({ query: 'alpha' })).resolves.toEqual({ items: [] }) - expect(inspectB).toHaveBeenCalledTimes(1) + expect(openB).toHaveBeenCalledTimes(1) await searchB.dispose() await persistenceB.dispose() }) diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index c6092ee220..7ad7fd3980 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # 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-query/session-query/README.md -README.md: 549ec7344d53a09e82568be4d1a21778367c38cc -README.zh.md: d2161b9528306ead31d709c4d4f6fab20feec8fd +README.md: bd078a69357d8b2f77d70411925ff37bf85ac642 +README.zh.md: d848899789e6c843575c09829f7e119d0ff03bdf diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 549ec7344d..bd078a6935 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -52,12 +52,13 @@ The text clause is a literal, case-insensitive, whitespace-flexible scan of extr ### Configuration -The two inherited knobs are set through the mounted backend's config: +The inherited knobs are set through the mounted backend's config: | Field | Default | Meaning | |---|---|---| | `readWindowMax` | `50` | Maximum `before`/`after` raw events accepted by `readEvent` | -| `persistedInspectConcurrency` | `4` | Concurrent persisted-log inspections in one batch title read | +| `persistedReadConcurrency` | `4` | Concurrent persisted-log reads in one batch title read | +| `preparedSessionCacheSize` | `5` | Cold prepared-Session observations retained for reuse across `observeSession` reads | ### Failures and recovery @@ -90,6 +91,8 @@ The decision history lives in the [unified service decision](../../../.agents/no |---|---| | [`src/index.ts`](src/index.ts) | Service definition: the abstract `SessionQueryEngine`, concrete reads, config validation | | [`src/corpus.ts`](src/corpus.ts) | Live-preferred corpus resolution, optional persistence binding, batch projections | +| [`src/observation.ts`](src/observation.ts) | Live-preferred point observations with a bounded revision-keyed prepared-Session cache | +| [`src/cold-read.ts`](src/cold-read.ts) | Handle-based cold log read with in-memory interrupted-turn closers | | [`src/types.ts`](src/types.ts) | Public records, filters, requests, and page types | | [`src/config.ts`](src/config.ts) | Inherited config and the closed `SessionQueryError` taxonomy | | [`src/filters.ts`](src/filters.ts) | Provider-independent predicates and the literal text scan | @@ -101,7 +104,11 @@ The decision history lives in the [unified service decision](../../../.agents/no ### Corpus resolution -`SessionCorpus` binds optional `ctx.sessionPersistence` through a fiber and resolves each read live-first: a known live target is snapshotted without consulting persistence; otherwise the session is listed, inspected non-mutatingly, and re-checked for a live attachment before cloning. Header compatibility is asserted between listed and loaded observations. Batch title reads run one metadata listing and bounded-concurrency inspections, isolating per-session failures while cancellation rejects the whole batch. +`SessionCorpus` binds optional `ctx.sessionPersistence` through a fiber and resolves each read live-first: a known live target is snapshotted without consulting persistence; otherwise the session is listed, read completely through a short-lived read handle, and re-checked for a live attachment before cloning. A cold log whose writer crashed mid-turn is balanced in memory with `interruptedTurnClosers` — persistence is never mutated by a read. Header compatibility is asserted between listed and loaded observations. Batch title reads run one metadata listing and bounded-concurrency reads, isolating per-session failures while cancellation rejects the whole batch. + +### Observation cache + +`observeSession` builds point observations without a listing preflight. The cold path stats the stored session first and consults an own bounded cache keyed by the persistence instance and the `stat` revision: an unchanged revision reuses the restored unpublished Session without re-reading the log; a changed revision, or a replaced persistence instance, reloads through the handle seam and replaces the entry. The cache holds `preparedSessionCacheSize` entries with least-recently-used eviction, entries pinned by active observation leases are never evicted, and a session that goes live mid-read retries the live path. ### Reads and traces diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index d2161b9528..d848899789 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -52,12 +52,13 @@ kind: "package-reference" ### 配置 -两个继承的旋钮通过挂载后端的配置设置: +继承的旋钮通过挂载后端的配置设置: | 字段 | 默认值 | 含义 | |---|---|---| | `readWindowMax` | `50` | `readEvent` 接受的 `before`/`after` 原始事件数上限 | -| `persistedInspectConcurrency` | `4` | 一次批量标题读取中的并发持久化日志检查数 | +| `persistedReadConcurrency` | `4` | 一次批量标题读取中的并发持久化日志读取数 | +| `preparedSessionCacheSize` | `5` | 为跨 `observeSession` 读取复用而保留的冷 prepared-Session 观察数 | ### 失败与恢复 @@ -90,6 +91,8 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 服务定义:抽象 `SessionQueryEngine`、具体读取、配置校验 | | [`src/corpus.ts`](src/corpus.ts) | 实时优先的语料库解析、可选持久化绑定、批量投影 | +| [`src/observation.ts`](src/observation.ts) | 实时优先的定点观察,带按修订键控的有界 prepared-Session 缓存 | +| [`src/cold-read.ts`](src/cold-read.ts) | 基于 handle 的冷日志读取,附内存中的中断轮次闭合事件 | | [`src/types.ts`](src/types.ts) | 公共记录、过滤器、请求与分页类型 | | [`src/config.ts`](src/config.ts) | 继承配置与封闭的 `SessionQueryError` 分类体系 | | [`src/filters.ts`](src/filters.ts) | 提供方无关谓词与字面文本扫描 | @@ -101,7 +104,11 @@ kind: "package-reference" ### 语料库解析 -`SessionCorpus` 通过 fiber 绑定可选的 `ctx.sessionPersistence`,并实时优先解析每次读取:已知实时目标直接快照,不查询持久化;否则先列出会话,再以不修改日志的方式检查,并在克隆前重新检查是否出现实时挂载。列表与加载观察之间会断言 header 兼容性。批量标题读取执行一次元数据列表与有界并发检查,把逐会话失败隔离,而取消会拒绝整个批次。 +`SessionCorpus` 通过 fiber 绑定可选的 `ctx.sessionPersistence`,并实时优先解析每次读取:已知实时目标直接快照,不查询持久化;否则先列出会话,再通过短生命周期的读取 handle 完整读出日志,并在克隆前重新检查是否出现实时挂载。写入者在轮次中途崩溃的冷日志用 `interruptedTurnClosers` 在内存中补齐 —— 读取从不修改持久化。列表与加载观察之间会断言 header 兼容性。批量标题读取执行一次元数据列表与有界并发读取,把逐会话失败隔离,而取消会拒绝整个批次。 + +### 观察缓存 + +`observeSession` 不经过列表预检直接构建定点观察。冷路径先对存储会话执行 `stat`,再查询自有的有界缓存,缓存键为持久化实例加 `stat` 修订:修订未变则复用已恢复的未发布 Session,不再重读日志;修订变化或持久化实例被替换则经 handle 缝重新加载并替换条目。缓存保留 `preparedSessionCacheSize` 个条目并按最久未用淘汰,被活跃观察租约钉住的条目从不被淘汰;读取中途转为实时的会话会重试实时路径。 ### 读取与追踪 diff --git a/packages/session-query/session-query/src/cold-read.ts b/packages/session-query/session-query/src/cold-read.ts new file mode 100644 index 0000000000..1732091d9b --- /dev/null +++ b/packages/session-query/session-query/src/cold-read.ts @@ -0,0 +1,48 @@ +/** One-shot cold session read through the handle-based persistence seam. */ + +import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' + +/** A stored session log balanced for read-only viewing. */ +export interface ColdSessionLog { + /** The stored header, fixed when the read handle opened. */ + readonly header: SessionHeader + /** Exact fork-inherited event count paired with {@link header}. */ + readonly inheritedEventCount: SessionLogOffset + /** Stored events plus deterministic in-memory closers for an interrupted final turn; nothing is written back. */ + readonly events: SessionEvent[] +} + +/** + * Read one complete stored session log without taking ownership or mutating + * storage: open a read handle, read the validated contiguous log, close the + * handle, and append `interruptedTurnClosers` so a log whose writer crashed + * mid-turn folds as a balanced transcript. Backend failures propagate + * unmapped — each caller owns its error taxonomy. + * @param persistence - the mounted persistence service. + * @param sessionId - the stored session to read. + * @param signal - optional cancellation for the open and read work. + * @returns the stored header and the balanced event log. + */ +export async function readColdSessionLog( + persistence: SessionPersistence, + sessionId: SessionId, + signal?: AbortSignal, +): Promise { + const options = signal === undefined ? undefined : { signal } + const handle = await persistence.open(sessionId, 'read', options) + let events: readonly SessionEvent[] + try { + events = await handle.read(0, undefined, options) + } catch (error: unknown) { + try { + await handle.close() + } catch { + // The read failure is the actionable cause; a close failure on the same broken handle adds nothing. + } + throw error + } + await handle.close() + return { header: handle.header, inheritedEventCount: handle.inheritedEventCount, events: [...events, ...interruptedTurnClosers(events)] } +} diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 99039bcbfa..e5475e3df6 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -5,15 +5,24 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** Default maximum `before`/`after` raw-event window. */ export const SESSION_QUERY_READ_WINDOW_MAX = 50 -/** Default maximum number of concurrent persisted-log inspections in one batch read. */ +/** Default maximum number of concurrent persisted-log reads in one batch read. */ export const SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY = 4 +/** Default maximum number of cold prepared-Session observations retained for reuse. */ +export const SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5 + /** Backend-independent configuration inherited by every session-query implementation. */ export interface Config { /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number - /** Maximum concurrent persisted-log inspections in one batch read. Defaults to 4. */ - persistedInspectConcurrency?: number + /** Maximum concurrent persisted-log reads in one batch read. Defaults to 4. */ + persistedReadConcurrency?: number + /** + * Maximum cold prepared-Session observations retained for reuse, keyed by + * durable revision. Entries pinned by active observation leases do not count + * against this bound until released. Defaults to 5. + */ + preparedSessionCacheSize?: number } /** Stable machine-routable failure taxonomy for session reads, traces, and search. */ diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index a9746b1793..a6e44c85ad 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -1,16 +1,11 @@ /** Live/persisted logical-corpus resolution for session-query. */ import type { Context, Fiber } from '@deepseek-ai/cordis' -import type { - Session, - SessionEvent, - SessionHeader, - SessionId, - SessionLogOffset, -} from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId , SessionLogOffset } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' import { SessionQueryError } from './config.ts' +import { readColdSessionLog, type ColdSessionLog } from './cold-read.ts' import { assertSessionHeadersCompatible } from './sources.ts' /** Detached source selected for one exact read. */ @@ -27,8 +22,6 @@ export interface LogicalSession { export interface LogicalSessionSource { /** Header selected with `events`; callers must clone retained output. */ readonly header: SessionHeader - /** Exact fork-inherited event count paired with {@link header}. */ - readonly inheritedEventCount: SessionLogOffset /** Raw events selected with `header`; valid only for the projection call. */ readonly events: readonly SessionEvent[] } @@ -45,7 +38,7 @@ export class SessionCorpus { constructor( private readonly _ctx: Context, - private readonly _persistedInspectConcurrency: number, + private readonly _persistedReadConcurrency: number, ) { this._optionalPersistenceFiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence @@ -116,9 +109,9 @@ export class SessionCorpus { signal?.throwIfAborted() return snapshot } - assertSessionHeadersCompatible(loaded.meta, listed) + assertSessionHeadersCompatible(loaded.header, listed) const snapshot = { - header: structuredClone(loaded.meta), + header: structuredClone(loaded.header), inheritedEventCount: loaded.inheritedEventCount, events: loaded.events.map(event => structuredClone(event)), } @@ -193,10 +186,9 @@ export class SessionCorpus { resolved.set(sessionId, projectSource(sessionId, sourceLive(attached), project, signal)) return } - assertSessionHeadersCompatible(loaded.meta, listed) + assertSessionHeadersCompatible(loaded.header, listed) resolved.set(sessionId, projectSource(sessionId, { - header: loaded.meta, - inheritedEventCount: loaded.inheritedEventCount, + header: loaded.header, events: loaded.events, }, project, signal)) } catch (error: unknown) { @@ -214,7 +206,7 @@ export class SessionCorpus { await resolvePersisted(unresolved[index] as SessionId) } } - const workerCount = Math.min(this._persistedInspectConcurrency, unresolved.length) + const workerCount = Math.min(this._persistedReadConcurrency, unresolved.length) const settlements = await Promise.allSettled( Array.from({ length: workerCount }, () => worker()), ) @@ -251,11 +243,7 @@ function projectSource( } function sourceLive(session: Session): LogicalSessionSource { - return { - header: session.header, - inheritedEventCount: session.inheritedEventCount, - events: session.snapshotEvents(), - } + return { header: session.header, events: session.snapshotEvents() } } function orderedResults( @@ -270,7 +258,8 @@ async function listPersisted( signal?: AbortSignal, ): Promise { try { - return await persistence.list(signal) + const snapshots = await persistence.list(signal === undefined ? undefined : { signal }) + return snapshots.map(snapshot => snapshot.header) } catch (error: unknown) { if (signal?.aborted) signal.throwIfAborted() throw new SessionQueryError( @@ -285,9 +274,9 @@ async function inspectPersisted( persistence: SessionPersistence, sessionId: SessionId, signal?: AbortSignal, -): Promise>> { +): Promise { try { - return await persistence.inspect(sessionId, signal) + return await readColdSessionLog(persistence, sessionId, signal) } catch (error: unknown) { if (signal?.aborted) signal.throwIfAborted() if (error instanceof Error && error.name === 'SessionPersistenceCorruptionError') { @@ -298,7 +287,7 @@ async function inspectPersisted( ) } throw new SessionQueryError( - `failed to inspect session "${sessionId}": ${errorMessage(error)}`, + `failed to read stored session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error }, ) diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 8d533f66c9..44360042e9 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -38,6 +38,7 @@ import type { } from './types.ts' import { SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, + SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE, SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, type Config, @@ -62,9 +63,12 @@ export { SessionSearchCursor } from './cursor.ts' export type { Config, SessionQueryErrorCode } from './config.ts' export { SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, + SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE, SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, } from './config.ts' +export { readColdSessionLog } from './cold-read.ts' +export type { ColdSessionLog } from './cold-read.ts' export { extractSessionEventText } from './extraction.ts' export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' export { @@ -106,16 +110,24 @@ export abstract class SessionQueryEngine extends Service { 'SESSION_QUERY_INVALID_CONFIG', ) } - const persistedInspectConcurrency = config.persistedInspectConcurrency + const persistedReadConcurrency = config.persistedReadConcurrency ?? SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY - if (!Number.isSafeInteger(persistedInspectConcurrency) || persistedInspectConcurrency < 1) { + if (!Number.isSafeInteger(persistedReadConcurrency) || persistedReadConcurrency < 1) { throw new SessionQueryError( - 'session-query: persistedInspectConcurrency must be a positive safe integer', + 'session-query: persistedReadConcurrency must be a positive safe integer', 'SESSION_QUERY_INVALID_CONFIG', ) } - this._corpus = new SessionCorpus(ctx, persistedInspectConcurrency) - this._observations = new SessionObservationReader(ctx) + const preparedSessionCacheSize = config.preparedSessionCacheSize + ?? SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE + if (!Number.isSafeInteger(preparedSessionCacheSize) || preparedSessionCacheSize < 1) { + throw new SessionQueryError( + 'session-query: preparedSessionCacheSize must be a positive safe integer', + 'SESSION_QUERY_INVALID_CONFIG', + ) + } + this._corpus = new SessionCorpus(ctx, persistedReadConcurrency) + this._observations = new SessionObservationReader(ctx, preparedSessionCacheSize) } /** diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts index f2aebc9503..6ec40d3785 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -2,21 +2,16 @@ import type { Context } from '@deepseek-ai/cordis' import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId , SessionLogOffset as SessionLogOffsetType , SessionSeqCursor } from '@deepseek-ai/dsh-session' +import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { - Session, - SessionEvent, - SessionHeader, - SessionId, - SessionLogOffset as SessionLogOffsetType, - SessionSeqCursor, -} from '@deepseek-ai/dsh-session' -import type { - BorrowedSessionSource, SessionPersistenceRevision, + SessionPersistenceSnapshot, } from '@deepseek-ai/dsh-session-persistence' import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' -import { SessionQueryError } from './config.ts' +import { SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionQueryError } from './config.ts' +import { readColdSessionLog, type ColdSessionLog } from './cold-read.ts' /** One exact immutable Session cut retained for the caller's read lifetime. */ export interface SessionObservation extends Disposable { @@ -24,10 +19,10 @@ export interface SessionObservation extends Disposable { readonly source: 'live' | 'prepared' /** Immutable Session identity metadata. */ readonly header: SessionHeader + /** Exact fork-inherited event count paired with {@link header}. */ + readonly inheritedEventCount: SessionLogOffsetType /** Immutable contiguous events at {@link cursor}. */ readonly events: readonly SessionEvent[] - /** Exact number of fork-inherited events in this Session lifecycle. */ - readonly inheritedEventCount: SessionLogOffsetType /** Last observed event seq, or -1 for an empty log. */ readonly cursor: SessionSeqCursor /** Durable source revision for a cold prepared observation. */ @@ -49,10 +44,45 @@ export interface SessionObservationOptions { readonly projectionMode?: 'all' | 'none' } -/** Builds point observations without a corpus listing preflight. */ +/** + * One reusable cold observation: an unpublished restored Session plus the + * exact balanced log it represents, valid while the producing persistence + * instance still reports the same revision. + */ +interface PreparedEntry { + /** The persistence instance whose `stat` produced {@link revision}; revisions from another instance are incomparable. */ + readonly persistence: SessionPersistence + /** Durable revision observed by `stat` immediately before the log read. */ + readonly revision: SessionPersistenceRevision + /** Unpublished Session restored from the balanced log; never entered into the store. */ + readonly session: Session + /** Immutable balanced log (stored events plus in-memory interrupted-turn closers). */ + readonly events: readonly SessionEvent[] + /** Active observation leases; a pinned entry (`refs > 0`) is never evicted. */ + refs: number +} + +/** + * Builds point observations without a corpus listing preflight. + * + * Cold reads are cached per session id, keyed by the persistence instance and + * the `stat` revision observed before the log read: an unchanged revision + * reuses the restored Session without re-reading the log. The cache is bounded + * (least-recently-used unpinned entries are evicted past the capacity), and + * entries pinned by active leases survive eviction and replacement — a lease's + * cut stays valid for the lease lifetime even after a newer revision lands. + */ export class SessionObservationReader { - /** @param ctx - context carrying Session and optional persistence/projection services. */ - constructor(private readonly ctx: Context) {} + private readonly cache = new Map() + + /** + * @param ctx - context carrying Session and optional persistence/projection services. + * @param cacheCapacity - maximum unpinned cold observations retained for reuse. + */ + constructor( + private readonly ctx: Context, + private readonly cacheCapacity: number = SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE, + ) {} /** * Observe one live-preferred Session and retain a cold preparation until disposal. @@ -72,90 +102,169 @@ export class SessionObservationReader { const persistence = this.ctx.get('sessionPersistence') if (persistence === undefined) throw notFound(sessionId) - let borrowed: BorrowedSessionSource - try { - borrowed = await persistence.borrowSession(sessionId, signal) - } catch (error: unknown) { + const snapshot = await this.statSource(persistence, sessionId, signal) + const attachedDuringStat = this.ctx.sessions.get(sessionId) + if (attachedDuringStat !== undefined) return this.live(attachedDuringStat, projectionMode) + let entry = this.cachedEntry(persistence, sessionId, snapshot.revision) + if (entry === undefined) { + const loaded = await this.loadSource(persistence, sessionId, signal) throwIfObservationAborted(signal) - if (hasErrorName(error, 'SessionPersistenceNotFoundError')) throw notFound(sessionId, error) - if (hasErrorName(error, 'SessionPersistenceCorruptionError')) { + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined) return this.live(attached, projectionMode) + // Ownership transfer into `prepare` freezes the seed in place, so the + // entry keeps its own detached copies of the just-read events. + const seed = loaded.events.map(event => structuredClone(event)) + let session: Session + try { + session = this.ctx.sessions.prepare(sessionId, { + seed, + meta: structuredClone(loaded.header), + inheritedEventCount: loaded.inheritedEventCount, + seedSource: 'persistence', + }) + } catch (error: unknown) { + // The store rejects an id with a live owner: that owner is the + // fresher source, so retry the live path. Any other rejection means + // the stored log failed restore validation. + if (this.ctx.sessions.get(sessionId) !== undefined) continue throw new SessionQueryError( - `stored session "${sessionId}" is corrupt: ${error.message}`, + `stored session "${sessionId}" is corrupt: ${errorMessage(error)}`, 'SESSION_QUERY_CORRUPT_SESSION', { cause: error }, ) } + entry = { + persistence, + revision: snapshot.revision, + session, + events: Object.freeze(seed), + refs: 0, + } + this.store(sessionId, entry) + } + + let projections: ProjectionSnapshot | undefined + try { + projections = projectionMode === 'none' ? undefined : this.preparedProjections(entry) + } catch (error: unknown) { throw new SessionQueryError( - `failed to observe session "${sessionId}": ${errorMessage(error)}`, - 'SESSION_QUERY_PERSISTENCE_FAILED', + `failed to project session "${sessionId}": ${errorMessage(error)}`, + 'SESSION_QUERY_CORRUPT_SESSION', { cause: error }, ) } + return this.preparedLease(sessionId, entry, projections) + } + } - try { - throwIfObservationAborted(signal) - if (borrowed.inspection.meta.id !== sessionId) { - throw new SessionQueryError( - `session persistence returned "${borrowed.inspection.meta.id}" for "${sessionId}"`, - 'SESSION_QUERY_SOURCE_CONFLICT', - ) - } - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined) { - const liveObservation = this.live(attached, projectionMode) - borrowed[Symbol.dispose]() - return liveObservation - } - if (borrowed.source === 'live') { - // The live Session disappeared between persistence's race check and - // this read. Retry against its now-cold durable identity. - borrowed[Symbol.dispose]() - continue - } - const prepared = borrowed - const events = prepared.inspection.events - let projections: ProjectionSnapshot | undefined - try { - projections = projectionMode === 'none' - ? undefined - : this.preparedProjections(prepared, events) - } catch (error: unknown) { - throw new SessionQueryError( - `failed to project session "${sessionId}": ${errorMessage(error)}`, - 'SESSION_QUERY_CORRUPT_SESSION', - { cause: error }, - ) - } - let references = 1 - const lease = (): SessionObservation => { - let disposed = false - return { - source: 'prepared', - header: prepared.inspection.meta, - events, - inheritedEventCount: prepared.inspection.inheritedEventCount, - cursor: events.at(-1)?.seq ?? -1, - revision: prepared.revision, - ...projections === undefined ? {} : { projections }, - retain: () => { - if (disposed || references === 0) throw new Error(`session observation "${sessionId}" is disposed`) - references += 1 - return lease() - }, - [Symbol.dispose]: () => { - if (disposed) return - disposed = true - references -= 1 - if (references === 0) prepared[Symbol.dispose]() - }, - } - } - return lease() - } catch (error: unknown) { - borrowed[Symbol.dispose]() - throw error + /** Observe the stored snapshot, mapping absence and backend failures to the query taxonomy. */ + private async statSource( + persistence: SessionPersistence, + sessionId: SessionId, + signal: AbortSignal | undefined, + ): Promise { + let snapshot: SessionPersistenceSnapshot | undefined + try { + snapshot = await persistence.stat(sessionId, signal === undefined ? undefined : { signal }) + } catch (error: unknown) { + throwIfObservationAborted(signal) + throw mapPersistenceFailure(sessionId, error) + } + throwIfObservationAborted(signal) + if (snapshot === undefined) throw notFound(sessionId) + if (snapshot.header.id !== sessionId) { + throw new SessionQueryError( + `session persistence returned "${snapshot.header.id}" for "${sessionId}"`, + 'SESSION_QUERY_SOURCE_CONFLICT', + ) + } + return snapshot + } + + /** Read the complete balanced cold log, mapping backend failures to the query taxonomy. */ + private async loadSource( + persistence: SessionPersistence, + sessionId: SessionId, + signal: AbortSignal | undefined, + ): Promise { + try { + return await readColdSessionLog(persistence, sessionId, signal) + } catch (error: unknown) { + throwIfObservationAborted(signal) + throw mapPersistenceFailure(sessionId, error) + } + } + + /** Return a still-valid cached entry and mark it most recently used. */ + private cachedEntry( + persistence: SessionPersistence, + sessionId: SessionId, + revision: SessionPersistenceRevision, + ): PreparedEntry | undefined { + const cached = this.cache.get(sessionId) + if (cached === undefined || cached.persistence !== persistence || cached.revision !== revision) { + return undefined + } + this.cache.delete(sessionId) + this.cache.set(sessionId, cached) + return cached + } + + /** Insert or replace the entry for one id, then evict past the capacity. */ + private store(sessionId: SessionId, entry: PreparedEntry): void { + // Replacing a stale revision only drops the map's reference; live leases + // keep the old entry alive through their own references. + this.cache.delete(sessionId) + this.cache.set(sessionId, entry) + this.evictPastCapacity(entry) + } + + /** + * Evict oldest unpinned entries until the cache fits its capacity again. + * Runs on store and whenever a lease release unpins an entry, so leases + * that pinned every candidate cannot leave the cache over budget for good. + * @param keep - the entry being stored, about to be leased; never evicted. + */ + private evictPastCapacity(keep?: PreparedEntry): void { + if (this.cache.size <= this.cacheCapacity) return + for (const [id, candidate] of this.cache) { + if (candidate === keep || candidate.refs > 0) continue + this.cache.delete(id) + if (this.cache.size <= this.cacheCapacity) return + } + } + + /** Build one disposable lease over a cached entry, pinning it until every lease releases. */ + private preparedLease( + sessionId: SessionId, + entry: PreparedEntry, + projections: ProjectionSnapshot | undefined, + ): SessionObservation { + entry.refs += 1 + const lease = (): SessionObservation => { + let disposed = false + return { + source: 'prepared', + header: entry.session.header, + inheritedEventCount: entry.session.inheritedEventCount, + events: entry.events, + cursor: entry.events.at(-1)?.seq ?? -1, + revision: entry.revision, + ...projections === undefined ? {} : { projections }, + retain: () => { + if (disposed) throw new Error(`session observation "${sessionId}" is disposed`) + entry.refs += 1 + return lease() + }, + [Symbol.dispose]: () => { + if (disposed) return + disposed = true + entry.refs -= 1 + if (entry.refs === 0) this.evictPastCapacity() + }, } } + return lease() } private live( @@ -171,8 +280,8 @@ export class SessionObservationReader { return { source: 'live', header: session.header, - events, inheritedEventCount: session.inheritedEventCount, + events, cursor: events.at(-1)?.seq ?? -1, ...projections === undefined ? {} : { projections }, retain: () => { @@ -185,17 +294,13 @@ export class SessionObservationReader { return lease() } - private preparedProjections( - observation: Extract, - events: readonly SessionEvent[], - ): ProjectionSnapshot | undefined { + private preparedProjections(entry: PreparedEntry): ProjectionSnapshot | undefined { const registry = this.ctx.get('sessionProjections') if (registry === undefined) return undefined - const prepared = observation.preparedSession const cache = this.ctx.get('sessionProjectionCache') return cache === undefined - ? registry.hydrate(prepared, {}, events, SessionLogOffset(0)) - : cache.hydratePrepared(prepared, events) + ? registry.hydrate(entry.session, {}, entry.events, SessionLogOffset(0)) + : cache.hydratePrepared(entry.session, entry.events) } } @@ -208,6 +313,22 @@ function throwIfObservationAborted(signal: AbortSignal | undefined): void { ) } +function mapPersistenceFailure(sessionId: SessionId, error: unknown): SessionQueryError { + if (hasErrorName(error, 'SessionPersistenceNotFoundError')) return notFound(sessionId, error) + if (hasErrorName(error, 'SessionPersistenceCorruptionError')) { + return new SessionQueryError( + `stored session "${sessionId}" is corrupt: ${error.message}`, + 'SESSION_QUERY_CORRUPT_SESSION', + { cause: error }, + ) + } + return new SessionQueryError( + `failed to observe session "${sessionId}": ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) +} + function notFound(sessionId: SessionId, cause?: unknown): SessionQueryError { return new SessionQueryError( `session "${sessionId}" not found`, diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 01c196aa1f..04b7295b26 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -29,7 +29,7 @@ export interface SessionRecord { header: SessionHeader /** Whether the id currently exists in `ctx.sessions`. */ live: boolean - /** Whether the active persistence backend currently materializes the id. */ + /** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */ persisted: boolean } @@ -51,7 +51,7 @@ export interface SessionLogSnapshot { session: SessionHeader /** Exact number of fork-inherited events in the observed log. */ inheritedEventCount: SessionLogOffset - /** Cloned contiguous raw events after persistence repair and replay validation. */ + /** Cloned contiguous raw events after in-memory interrupted-turn balancing and replay validation. */ events: SessionEvent[] } diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts index ef2460e7a2..aa2e6c31b6 100644 --- a/packages/session-query/session-query/tests/observation.spec.ts +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -1,138 +1,138 @@ import { Context } from '@deepseek-ai/cordis' -import SessionStore, { Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' -import type { SessionHeader } from '@deepseek-ai/dsh-session' -import { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' -import type { BorrowedSessionSource } from '@deepseek-ai/dsh-session-persistence' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionLogOffset, SessionSeq, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' +import SessionPersistence, { + SessionPersistenceCorruptionError, + SessionPersistenceNotFoundError, + SessionPersistenceRevision, + SessionReadOnlyError, +} from '@deepseek-ai/dsh-session-persistence' +import type { + SessionAccess, + SessionHandle, + SessionHandleReadOptions, + SessionPersistenceSnapshot, + SessionPersistenceStatOptions, +} from '@deepseek-ai/dsh-session-persistence' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it, vi } from 'vitest' import { SessionObservationReader } from '../src/observation.ts' function header(id: string): SessionHeader { - return { version: 0, id: SessionId(id), createdAt: 1, cwd: '/workspace', isSeeded: false } + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: 1, isSeeded: false, cwd: '/workspace' } } -function preparedSource( - meta: SessionHeader, - dispose = vi.fn(), -): BorrowedSessionSource { - const preparedSession = Session.create(meta.id, [], meta, SessionLogOffset(0)) +function messageEvent(seq: number, text: string): SessionEvent { return { - source: 'prepared', - inspection: { - meta: preparedSession.header, - inheritedEventCount: preparedSession.inheritedEventCount, - events: preparedSession.snapshotEvents(), - }, - revision: SessionPersistenceRevision(`fixture:${meta.id}`), - preparedSession, - [Symbol.dispose]: dispose, + type: 'user/message', + seq: SessionSeq(seq), + time: seq + 1, + data: createUserMessage({ + content: [{ type: 'text', text }], source: { kind: 'user' }, + }), + surfaceOp: 'append', } } -describe('SessionObservationReader', () => { - it('prefers a live Session that attaches while a prepared source is borrowed', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('attached-during-borrow') - const dispose = vi.fn() - const prepared = preparedSource(meta, dispose) - ctx.provide('sessionPersistence', { - borrowSession: () => { - ctx.sessions.create(meta.id, { meta }) - return Promise.resolve(prepared) - }, - } as never) +/** A log whose writer crashed mid-turn: read-only viewing must balance it in memory. */ +function interruptedLog(text: string): SessionEvent[] { + return [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + messageEvent(1, text), + ] +} - using observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) +interface StoredEntry { + header: SessionHeader + events: SessionEvent[] + revision: string +} - expect(observed.source).toBe('live') - expect(dispose).toHaveBeenCalledOnce() - await ctx.fiber.dispose() - }) +interface StubCounters { + stat: number + open: number + read: number +} - it('releases a borrowed source once when the winning live projection fails', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - const meta = header('attached-projection-failure') - const dispose = vi.fn() - const prepared = preparedSource(meta, dispose) - ctx.provide('sessionPersistence', { - borrowSession: () => { - ctx.sessions.create(meta.id, { meta }) - return Promise.resolve(prepared) - }, - } as never) - vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementation(() => { - throw new Error('projection failed') +interface StubHooks { + /** Runs inside `stat` before it resolves. */ + onStat?: () => void + /** Runs inside `read` before it resolves. */ + onRead?: () => void + /** Replaces the read result for every open handle. */ + readFailure?: unknown + /** Replaces the stat result. */ + statFailure?: unknown +} + +/** Object-stub persistence: only the members the observation reader touches. */ +function stubPersistence( + store: Map, + counters: StubCounters, + hooks: StubHooks = {}, +): SessionPersistence { + const stat = ( + id: SessionIdType, + options?: SessionPersistenceStatOptions, + ): Promise => { + counters.stat += 1 + void options + const entry = store.get(id) + hooks.onStat?.() + if (hooks.statFailure !== undefined) { + // Exercise containment of a backend violating the Error rejection convention. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + return Promise.reject(hooks.statFailure) + } + if (entry === undefined) return Promise.resolve(undefined) + return Promise.resolve({ + header: structuredClone(entry.header), + revision: SessionPersistenceRevision(entry.revision), }) + } + const open = (id: SessionIdType, access: SessionAccess): Promise => { + counters.open += 1 + const entry = store.get(id) + if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(id)) + const handle: SessionHandle = { + id, + header: structuredClone(entry.header), + inheritedEventCount: SessionLogOffset(0), + access, + read: ( + _offset?: number, + _length?: number, + options?: SessionHandleReadOptions, + ): Promise => { + counters.read += 1 + void options + hooks.onRead?.() + if (hooks.readFailure !== undefined) { + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + return Promise.reject(hooks.readFailure) + } + return Promise.resolve(structuredClone(entry.events)) + }, + append: () => Promise.reject(new SessionReadOnlyError(id, 'append')), + flush: () => Promise.reject(new SessionReadOnlyError(id, 'flush')), + close: () => Promise.resolve(), + [Symbol.asyncDispose]: () => Promise.resolve(), + } + return Promise.resolve(handle) + } + return { stat, open } as never +} - await expect(new SessionObservationReader(ctx).read(meta.id)).rejects.toThrow('projection failed') - expect(dispose).toHaveBeenCalledOnce() - await ctx.fiber.dispose() - }) - - it('retries when persistence reports a live source that has already detached', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('detached-live-source') - const disposeLive = vi.fn() - const prepared = preparedSource(meta) - const borrowSession = vi.fn() - .mockResolvedValueOnce({ - source: 'live', - inspection: { meta, inheritedEventCount: SessionLogOffset(0), events: [] }, - [Symbol.dispose]: disposeLive, - } satisfies BorrowedSessionSource) - .mockResolvedValueOnce(prepared) - ctx.provide('sessionPersistence', { borrowSession } as never) - - using observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) - - expect(observed.source).toBe('prepared') - expect(borrowSession).toHaveBeenCalledTimes(2) - expect(disposeLive).toHaveBeenCalledOnce() - await ctx.fiber.dispose() - }) - - it('returns a prepared observation without projections when no registry is mounted', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('prepared-without-projections') - ctx.provide('sessionPersistence', { - borrowSession: () => Promise.resolve(preparedSource(meta)), - } as never) - - using observed = await new SessionObservationReader(ctx).read(meta.id) - - expect(observed.source).toBe('prepared') - expect(observed.projections).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('reference-counts prepared leases and rejects retention after disposal', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('prepared-leases') - const dispose = vi.fn() - ctx.provide('sessionPersistence', { - borrowSession: () => Promise.resolve(preparedSource(meta, dispose)), - } as never) - const observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) - const retained = observed.retain() - - observed[Symbol.dispose]() - observed[Symbol.dispose]() - expect(dispose).not.toHaveBeenCalled() - expect(() => observed.retain()).toThrow('is disposed') - retained[Symbol.dispose]() - expect(dispose).toHaveBeenCalledOnce() - await ctx.fiber.dispose() - }) +async function readerContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + return ctx +} +describe('SessionObservationReader live path', () => { it('creates independent live leases and rejects retention after disposal', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) + const ctx = await readerContext() const session = ctx.sessions.create(SessionId('live-leases'), { meta: { cwd: '/workspace' } }) const reader = new SessionObservationReader(ctx) const observed = await reader.read(session.id, { projectionMode: 'none' }) @@ -145,37 +145,599 @@ describe('SessionObservationReader', () => { await ctx.fiber.dispose() }) - it('carries the exact inherited cut on a live observation', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const inherited = [ - { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, - { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, - ] as const - const session = ctx.sessions.create(SessionId('seeded-observation'), { - seed: inherited, - inheritedEventCount: SessionLogOffset(inherited.length), - meta: { cwd: '/workspace', isSeeded: true }, + it('computes live projections when the registry is mounted and surfaces its failure raw', async () => { + const ctx = await readerContext() + await ctx.plugin(SessionProjectionRegistry) + const session = ctx.sessions.create(SessionId('live-projections')) + const reader = new SessionObservationReader(ctx) + + using observed = await reader.read(session.id) + expect(observed.projections).toBeDefined() + + vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementation(() => { + throw new Error('projection failed') }) - - using observed = await new SessionObservationReader(ctx).read(session.id, { projectionMode: 'none' }) - - expect(observed.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) + await expect(reader.read(session.id)).rejects.toThrow('projection failed') await ctx.fiber.dispose() }) - it('contains a non-Error persistence rejection', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - ctx.provide('sessionPersistence', { - // Exercise containment of a backend that violates the Error rejection convention. - borrowSession: () => Promise.reject('offline'), // oxlint-disable-line typescript/prefer-promise-reject-errors - } as never) + it('reports a missing session when no persistence service is mounted', async () => { + const ctx = await readerContext() + await expect(new SessionObservationReader(ctx).read(SessionId('absent'))).rejects.toMatchObject({ + code: 'SESSION_QUERY_SESSION_NOT_FOUND', + }) + await ctx.fiber.dispose() + }) +}) - await expect(new SessionObservationReader(ctx).read(SessionId('failed'))).rejects.toMatchObject({ +describe('SessionObservationReader cold path', () => { + it('balances an interrupted stored turn in memory and exposes the durable revision', async () => { + const ctx = await readerContext() + const meta = header('interrupted-cold') + const store = new Map([[meta.id, { header: meta, events: interruptedLog('crashed'), revision: 'r1' }]]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx) + + using observed = await reader.read(meta.id) + + expect(observed.source).toBe('prepared') + expect(observed.header).toMatchObject({ id: meta.id, cwd: '/workspace' }) + expect(observed.revision).toBe(SessionPersistenceRevision('r1')) + expect(observed.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end']) + expect(observed.cursor).toBe(2) + // No projection registry is mounted, so the observation carries none. + expect(observed.projections).toBeUndefined() + // Balancing is in-memory only: nothing was written back to the store. + expect(store.get(meta.id)?.events).toHaveLength(2) + await ctx.fiber.dispose() + }) + + it('observes an empty stored log with a -1 cursor', async () => { + const ctx = await readerContext() + const meta = header('empty-cold') + const store = new Map([[meta.id, { header: meta, events: [], revision: 'r1' }]]) + ctx.provide('sessionPersistence', stubPersistence(store, { stat: 0, open: 0, read: 0 })) + + using observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) + + expect(observed.events).toEqual([]) + expect(observed.cursor).toBe(-1) + await ctx.fiber.dispose() + }) + + it('reference-counts prepared leases and rejects retention after disposal', async () => { + const ctx = await readerContext() + const meta = header('prepared-leases') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'kept')], revision: 'r1' }]]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx) + const observed = await reader.read(meta.id, { projectionMode: 'none' }) + const retained = observed.retain() + + observed[Symbol.dispose]() + observed[Symbol.dispose]() + expect(() => observed.retain()).toThrow('is disposed') + retained[Symbol.dispose]() + await ctx.fiber.dispose() + }) + + it('serves an unchanged revision from its cache without re-reading the log', async () => { + const ctx = await readerContext() + const meta = header('cache-hit') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'stable')], revision: 'r1' }]]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx) + + using first = await reader.read(meta.id, { projectionMode: 'none' }) + using second = await reader.read(meta.id, { projectionMode: 'none' }) + + expect(counters).toEqual({ stat: 2, open: 1, read: 1 }) + expect(second.events).toBe(first.events) + await ctx.fiber.dispose() + }) + + it('reloads when the durable revision changes', async () => { + const ctx = await readerContext() + const meta = header('cache-stale') + const entry = { header: meta, events: [messageEvent(0, 'old')], revision: 'r1' } + const store = new Map([[meta.id, entry]]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx) + + using first = await reader.read(meta.id, { projectionMode: 'none' }) + entry.events = [messageEvent(0, 'old'), messageEvent(1, 'new')] + entry.revision = 'r2' + using second = await reader.read(meta.id, { projectionMode: 'none' }) + + expect(counters).toEqual({ stat: 2, open: 2, read: 2 }) + expect(first.cursor).toBe(0) + expect(second.cursor).toBe(1) + expect(second.revision).toBe(SessionPersistenceRevision('r2')) + await ctx.fiber.dispose() + }) + + it('keeps pinned entries cached past the capacity and never evicts them', async () => { + const ctx = await readerContext() + const a = header('pinned-a') + const b = header('pinned-b') + const store = new Map([ + [a.id, { header: a, events: [messageEvent(0, 'a')], revision: 'ra' }], + [b.id, { header: b, events: [messageEvent(0, 'b')], revision: 'rb' }], + ]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx, 1) + + const pinnedA = await reader.read(a.id, { projectionMode: 'none' }) + using pinnedB = await reader.read(b.id, { projectionMode: 'none' }) + // Both entries are pinned by live leases, so both stay cached over capacity. + using hitA = await reader.read(a.id, { projectionMode: 'none' }) + using hitB = await reader.read(b.id, { projectionMode: 'none' }) + + expect(counters.read).toBe(2) + expect(hitA.events).toBe(pinnedA.events) + expect(hitB.events).toBe(pinnedB.events) + pinnedA[Symbol.dispose]() + await ctx.fiber.dispose() + }) + + it('continues evicting past pinned entries until the cache reaches its bound', async () => { + const ctx = await readerContext() + const a = header('sweep-a') + const b = header('sweep-b') + const c = header('sweep-c') + const store = new Map([ + [a.id, { header: a, events: [messageEvent(0, 'a')], revision: 'ra' }], + [b.id, { header: b, events: [messageEvent(0, 'b')], revision: 'rb' }], + [c.id, { header: c, events: [messageEvent(0, 'c')], revision: 'rc' }], + ]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx, 1) + + using pinnedA = await reader.read(a.id, { projectionMode: 'none' }) + void pinnedA + { + using observedB = await reader.read(b.id, { projectionMode: 'none' }) + void observedB + } + { + // Storing C sweeps past pinned A and evicts unpinned B, and the cache + // stays over its bound because the remaining entries are protected. + using observedC = await reader.read(c.id, { projectionMode: 'none' }) + void observedC + } + expect(counters.read).toBe(3) + using hitA = await reader.read(a.id, { projectionMode: 'none' }) + void hitA + expect(counters.read).toBe(3) + using rereadB = await reader.read(b.id, { projectionMode: 'none' }) + void rereadB + expect(counters.read).toBe(4) + await ctx.fiber.dispose() + }) + + it('evicts on lease release when pins forced the cache over its bound', async () => { + const ctx = await readerContext() + const a = header('release-a') + const b = header('release-b') + const store = new Map([ + [a.id, { header: a, events: [messageEvent(0, 'a')], revision: 'ra' }], + [b.id, { header: b, events: [messageEvent(0, 'b')], revision: 'rb' }], + ]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx, 1) + + const pinnedA = await reader.read(a.id, { projectionMode: 'none' }) + { + using observedB = await reader.read(b.id, { projectionMode: 'none' }) + void observedB + } + // B's release found the cache over its bound (A stayed pinned) and evicted + // the just-unpinned B instead of leaving it resident for good. + expect(counters.read).toBe(2) + { + using rereadB = await reader.read(b.id, { projectionMode: 'none' }) + void rereadB + } + expect(counters.read).toBe(3) + pinnedA[Symbol.dispose]() + await ctx.fiber.dispose() + }) + + it('a release sweep keeps evicting while the cache stays over its bound', async () => { + const ctx = await readerContext() + const a = header('sweep-release-a') + const b = header('sweep-release-b') + const c = header('sweep-release-c') + const store = new Map([ + [a.id, { header: a, events: [messageEvent(0, 'a')], revision: 'ra' }], + [b.id, { header: b, events: [messageEvent(0, 'b')], revision: 'rb' }], + [c.id, { header: c, events: [messageEvent(0, 'c')], revision: 'rc' }], + ]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx, 1) + + const pinnedA = await reader.read(a.id, { projectionMode: 'none' }) + const pinnedB = await reader.read(b.id, { projectionMode: 'none' }) + const pinnedC = await reader.read(c.id, { projectionMode: 'none' }) + // B's release evicts B but the cache is still over its bound past the + // remaining pins, so the sweep continues (and finds only pinned entries). + pinnedB[Symbol.dispose]() + pinnedC[Symbol.dispose]() + expect(counters.read).toBe(3) + { + using rereadB = await reader.read(b.id, { projectionMode: 'none' }) + void rereadB + } + expect(counters.read).toBe(4) + pinnedA[Symbol.dispose]() + await ctx.fiber.dispose() + }) + + it('evicts the least recently used unpinned entry at the capacity bound', async () => { + const ctx = await readerContext() + const a = header('lru-a') + const b = header('lru-b') + const c = header('lru-c') + const store = new Map([ + [a.id, { header: a, events: [messageEvent(0, 'a')], revision: 'ra' }], + [b.id, { header: b, events: [messageEvent(0, 'b')], revision: 'rb' }], + [c.id, { header: c, events: [messageEvent(0, 'c')], revision: 'rc' }], + ]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + const reader = new SessionObservationReader(ctx, 2) + const readOnce = async (id: SessionIdType): Promise => { + using observed = await reader.read(id, { projectionMode: 'none' }) + void observed + } + + await readOnce(a.id) + await readOnce(b.id) + await readOnce(a.id) // cache hit; A becomes most recently used + expect(counters.read).toBe(2) + await readOnce(c.id) // evicts B (least recently used), keeps A + expect(counters.read).toBe(3) + await readOnce(a.id) + expect(counters.read).toBe(3) + await readOnce(b.id) + expect(counters.read).toBe(4) + await ctx.fiber.dispose() + }) + + it('discards cached revisions produced by a replaced persistence instance', async () => { + const meta = header('swapped-instance') + + class SwapHandle implements SessionHandle { + readonly inheritedEventCount = SessionLogOffset(0) + constructor(readonly id: SessionIdType, readonly header: SessionHeader, readonly access: SessionAccess) {} + read(): Promise { + SwapPersistence.readCalls += 1 + return Promise.resolve([messageEvent(0, 'swap')]) + } + + append(): Promise { + return Promise.reject(new SessionReadOnlyError(this.id, 'append')) + } + + flush(): Promise { + return Promise.reject(new SessionReadOnlyError(this.id, 'flush')) + } + + close(): Promise { + return Promise.resolve() + } + + [Symbol.asyncDispose](): Promise { + return this.close() + } + } + + class SwapPersistence extends SessionPersistence { + static readCalls = 0 + + create(): Promise { + return Promise.reject(new Error('not used')) + } + + // Appends are durable on resolution here; nothing buffers, so the service-wide flush is a no-op. + async flush(): Promise {} + + open(id: SessionIdType, access: SessionAccess): Promise { + return Promise.resolve(new SwapHandle(id, structuredClone(meta), access)) + } + + stat(): Promise { + return Promise.resolve({ + header: structuredClone(meta), + revision: SessionPersistenceRevision('constant'), + }) + } + + list(): Promise { + return Promise.resolve([]) + } + } + + const ctx = await readerContext() + const reader = new SessionObservationReader(ctx) + const first = await ctx.plugin(SwapPersistence) + { + using observed = await reader.read(meta.id, { projectionMode: 'none' }) + void observed + } + expect(SwapPersistence.readCalls).toBe(1) + await first.dispose() + const second = await ctx.plugin(SwapPersistence) + { + using observed = await reader.read(meta.id, { projectionMode: 'none' }) + void observed + } + // The revision string matches, but revisions from different instances are + // incomparable, so the cached entry must not be reused. + expect(SwapPersistence.readCalls).toBe(2) + await second.dispose() + await ctx.fiber.dispose() + }) + + it('prefers a live Session that attaches while the cold log is read', async () => { + const ctx = await readerContext() + const meta = header('attached-during-read') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'stale')], revision: 'r1' }]]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters, { + onRead: () => { + ctx.sessions.create(meta.id, { + meta: { createdAt: meta.createdAt, ...meta.cwd === undefined ? {} : { cwd: meta.cwd } }, + }) + }, + })) + + using observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) + + expect(observed.source).toBe('live') + await ctx.fiber.dispose() + }) + + it('prefers a live Session that attaches while the snapshot is stated', async () => { + const ctx = await readerContext() + const meta = header('attached-during-stat') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'stale')], revision: 'r1' }]]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters, { + onStat: () => { + ctx.sessions.create(meta.id, { + meta: { createdAt: meta.createdAt, ...meta.cwd === undefined ? {} : { cwd: meta.cwd } }, + }) + }, + })) + + using observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) + + expect(observed.source).toBe('live') + expect(counters.open).toBe(0) + await ctx.fiber.dispose() + }) + + it('retries the live path when the store rejects preparation for a live owner', async () => { + const ctx = await readerContext() + const meta = header('prepare-collision') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'stale')], revision: 'r1' }]]) + const counters = { stat: 0, open: 0, read: 0 } + ctx.provide('sessionPersistence', stubPersistence(store, counters)) + vi.spyOn(ctx.sessions, 'prepare').mockImplementationOnce(() => { + // A racing owner claims the id between the reader's live check and its + // preparation; the nested create uses the store's real prepare. + ctx.sessions.create(meta.id, { meta: { createdAt: meta.createdAt, ...meta.cwd === undefined ? {} : { cwd: meta.cwd } } }) + throw new Error(`session "${meta.id}" already exists`) + }) + + using observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) + + expect(observed.source).toBe('live') + await ctx.fiber.dispose() + }) + + it('maps a stored log the restore validation refuses to a corrupt-session failure', async () => { + const ctx = await readerContext() + const meta = header('bad-restore') + const store = new Map([[meta.id, { + header: meta, + events: [{ ...messageEvent(0, 'gap'), seq: SessionSeq(5) }], + revision: 'r1', + }]]) + ctx.provide('sessionPersistence', stubPersistence(store, { stat: 0, open: 0, read: 0 })) + + await expect(new SessionObservationReader(ctx).read(meta.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_CORRUPT_SESSION', + message: expect.stringContaining('is corrupt') as string, + }) + await ctx.fiber.dispose() + }) + + it('maps stat absence, open not-found, corruption, and non-Error rejections', async () => { + const ctx = await readerContext() + const meta = header('failure-taxonomy') + const counters = { stat: 0, open: 0, read: 0 } + const store = new Map() + const hooks: StubHooks = {} + ctx.provide('sessionPersistence', stubPersistence(store, counters, hooks)) + const reader = new SessionObservationReader(ctx) + + await expect(reader.read(meta.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_SESSION_NOT_FOUND', + }) + + store.set(meta.id, { header: meta, events: [messageEvent(0, 'gone')], revision: 'r1' }) + hooks.onStat = () => { + delete hooks.onStat + // Deleted between stat and open: open reports not-found. + store.delete(meta.id) + } + await expect(reader.read(meta.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_SESSION_NOT_FOUND', + cause: expect.any(SessionPersistenceNotFoundError) as Error, + }) + + store.set(meta.id, { header: meta, events: [messageEvent(0, 'torn')], revision: 'r2' }) + hooks.readFailure = new SessionPersistenceCorruptionError('stored prefix failed validation', { + cause: new Error('torn record'), + }) + await expect(reader.read(meta.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_CORRUPT_SESSION', + message: `stored session "${meta.id}" is corrupt: stored prefix failed validation`, + }) + hooks.readFailure = undefined + + hooks.statFailure = 'offline' + await expect(reader.read(meta.id)).rejects.toMatchObject({ code: 'SESSION_QUERY_PERSISTENCE_FAILED', message: expect.stringContaining('unknown error') as string, }) await ctx.fiber.dispose() }) + + it('reports a header whose id does not match the requested session as a source conflict', async () => { + const ctx = await readerContext() + const meta = header('expected-id') + const store = new Map([[meta.id, { + header: { ...meta, id: SessionId('other-id') }, + events: [messageEvent(0, 'other')], + revision: 'r1', + }]]) + ctx.provide('sessionPersistence', stubPersistence(store, { stat: 0, open: 0, read: 0 })) + + await expect(new SessionObservationReader(ctx).read(meta.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_SOURCE_CONFLICT', + }) + await ctx.fiber.dispose() + }) + + it('maps pre-abort, stat-time, read-time, and post-read cancellation to aborted reads', async () => { + const ctx = await readerContext() + const meta = header('aborted-cold') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'slow')], revision: 'r1' }]]) + const counters = { stat: 0, open: 0, read: 0 } + const hooks: StubHooks = {} + ctx.provide('sessionPersistence', stubPersistence(store, counters, hooks)) + const reader = new SessionObservationReader(ctx) + + const preAborted = new AbortController() + preAborted.abort(new Error('before start')) + await expect(reader.read(meta.id, { signal: preAborted.signal })).rejects.toMatchObject({ + code: 'SESSION_QUERY_ABORTED', + }) + expect(counters.stat).toBe(0) + + const statAbort = new AbortController() + hooks.onStat = () => { + delete hooks.onStat + statAbort.abort(new Error('stat deadline')) + throw new Error('stat interrupted') + } + await expect(reader.read(meta.id, { signal: statAbort.signal })).rejects.toMatchObject({ + code: 'SESSION_QUERY_ABORTED', + }) + + const statResolvedAbort = new AbortController() + hooks.onStat = () => { + delete hooks.onStat + statResolvedAbort.abort(new Error('after stat')) + } + await expect(reader.read(meta.id, { signal: statResolvedAbort.signal })).rejects.toMatchObject({ + code: 'SESSION_QUERY_ABORTED', + }) + + const readAbort = new AbortController() + hooks.onRead = () => { + delete hooks.onRead + readAbort.abort(new Error('read deadline')) + throw new Error('read interrupted') + } + await expect(reader.read(meta.id, { signal: readAbort.signal })).rejects.toMatchObject({ + code: 'SESSION_QUERY_ABORTED', + }) + + const readResolvedAbort = new AbortController() + hooks.onRead = () => { + delete hooks.onRead + readResolvedAbort.abort(new Error('after read')) + } + await expect(reader.read(meta.id, { signal: readResolvedAbort.signal })).rejects.toMatchObject({ + code: 'SESSION_QUERY_ABORTED', + }) + await ctx.fiber.dispose() + }) + + it('surfaces a read failure that is neither corruption nor absence as a persistence failure', async () => { + const ctx = await readerContext() + const meta = header('read-failed') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'x')], revision: 'r1' }]]) + ctx.provide('sessionPersistence', stubPersistence(store, { stat: 0, open: 0, read: 0 }, { + readFailure: new Error('disk detached'), + })) + + await expect(new SessionObservationReader(ctx).read(meta.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + message: expect.stringContaining('disk detached') as string, + }) + await ctx.fiber.dispose() + }) +}) + +describe('SessionObservationReader cold projections', () => { + it('hydrates prepared projections through the registry when no projection cache is mounted', async () => { + const ctx = await readerContext() + await ctx.plugin(SessionProjectionRegistry) + const meta = header('cold-registry') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'projected')], revision: 'r1' }]]) + ctx.provide('sessionPersistence', stubPersistence(store, { stat: 0, open: 0, read: 0 })) + + using observed = await new SessionObservationReader(ctx).read(meta.id) + + expect(observed.source).toBe('prepared') + expect(observed.projections).toBeDefined() + await ctx.fiber.dispose() + }) + + it('hydrates prepared projections through a mounted projection cache', async () => { + const ctx = await readerContext() + await ctx.plugin(SessionProjectionRegistry) + const meta = header('cold-cache') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'cached')], revision: 'r1' }]]) + ctx.provide('sessionPersistence', stubPersistence(store, { stat: 0, open: 0, read: 0 })) + const snapshot = { asOfSeq: 0, values: {} } + const hydratePrepared = vi.fn().mockReturnValue(snapshot) + ctx.provide('sessionProjectionCache', { hydratePrepared } as never) + + using observed = await new SessionObservationReader(ctx).read(meta.id) + + expect(observed.projections).toBe(snapshot) + expect(hydratePrepared).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + + it('wraps a prepared projection failure as a corrupt-session failure', async () => { + const ctx = await readerContext() + await ctx.plugin(SessionProjectionRegistry) + const meta = header('cold-projection-failure') + const store = new Map([[meta.id, { header: meta, events: [messageEvent(0, 'broken')], revision: 'r1' }]]) + ctx.provide('sessionPersistence', stubPersistence(store, { stat: 0, open: 0, read: 0 })) + vi.spyOn(ctx.sessionProjections, 'hydrate').mockImplementation(() => { + throw new Error('hydration failed') + }) + + await expect(new SessionObservationReader(ctx).read(meta.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_CORRUPT_SESSION', + message: expect.stringContaining('failed to project') as string, + }) + await ctx.fiber.dispose() + }) }) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index f011df923d..10e30ec2ba 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,16 +1,21 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' -import SessionStore, { - SESSION_FORMAT_VERSION, - SessionId, - SessionLogOffset, - SessionSeq, -} from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset, SessionSeq, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEventSuffix, SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { + SessionPersistenceCorruptionError, + SessionPersistenceNotFoundError, + SessionPersistenceRevision, + SessionReadOnlyError, +} from '@deepseek-ai/dsh-session-persistence' +import type { + SessionAccess, + SessionHandle, + SessionHandleReadOptions, + SessionPersistenceSnapshot, +} from '@deepseek-ai/dsh-session-persistence' import SessionQueryEngine, { SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, type SessionEventSurface, @@ -25,7 +30,7 @@ function header(id: string, createdAt = 1, extra: Partial = {}): return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, isSeeded: false, ...extra } } -function eventLog(text = 'hello'): SessionEvent<'user/message'>[] { +function eventLog(text = 'hello'): SessionEvent[] { return [{ type: 'user/message', seq: SessionSeq(0), @@ -37,108 +42,121 @@ function eventLog(text = 'hello'): SessionEvent<'user/message'>[] { }] } -class TestPersistence extends SessionPersistence { - override readonly supportsRawArtifacts = false +class TestHandle implements SessionHandle { + readonly inheritedEventCount = SessionLogOffset(0) + constructor( + readonly id: SessionIdType, + readonly header: SessionHeader, + readonly access: SessionAccess, + ) {} + + read(offset = 0, length?: number, options?: SessionHandleReadOptions): Promise { + TestPersistence.readCalls.push(this.id) + TestPersistence.readSignals.push(options?.signal) + const slice = (events: SessionEvent[]): SessionEvent[] => { + const from = events.filter(event => event.seq >= offset) + return length === undefined ? from : from.slice(0, length) + } + if (TestPersistence.readOverride !== undefined) { + return TestPersistence.readOverride(this.id, options?.signal).then(loaded => slice(loaded.events)) + } + if (TestPersistence.readFailure !== undefined) return rejectUnknown(TestPersistence.readFailure) + const entry = TestPersistence.entries.get(this.id) + if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(this.id)) + const result = structuredClone(entry.events) + TestPersistence.readEffect?.() + TestPersistence.readEffect = undefined + return Promise.resolve(slice(result)) + } + + append(events: readonly SessionEvent[]): Promise { + if (this.access === 'read') return Promise.reject(new SessionReadOnlyError(this.id, 'append')) + const entry = TestPersistence.entries.get(this.id) + if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(this.id)) + entry.events.push(...structuredClone(events)) + return Promise.resolve() + } + + flush(): Promise { + if (this.access === 'read') return Promise.reject(new SessionReadOnlyError(this.id, 'flush')) + return Promise.resolve() + } + + close(): Promise { + return Promise.resolve() + } + + [Symbol.asyncDispose](): Promise { + return this.close() + } +} + +function entryRevision(entry: { events: SessionEvent[] }): SessionPersistenceRevision { + return SessionPersistenceRevision(`events:${entry.events.length}`) +} + +class TestPersistence extends SessionPersistence { static entries = new Map() static listFailure: unknown - static listOverride: ((signal?: AbortSignal) => Promise) | undefined - static inspectFailure: unknown - static inspectEffect: (() => void) | undefined - static inspectOverride: (( + static listOverride: ((signal?: AbortSignal) => Promise) | undefined + static readFailure: unknown + static readEffect: (() => void) | undefined + static readOverride: (( id: SessionIdType, signal?: AbortSignal, - ) => Promise) | undefined + ) => Promise<{ meta: SessionHeader; events: SessionEvent[] }>) | undefined static afterList: (() => void) | undefined static listCalls = 0 - static inspectCalls: SessionIdType[] = [] + static readCalls: SessionIdType[] = [] static listSignals: Array = [] - static inspectSignals: Array = [] + static readSignals: Array = [] static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listFailure = undefined this.listOverride = undefined - this.inspectFailure = undefined - this.inspectEffect = undefined - this.inspectOverride = undefined + this.readFailure = undefined + this.readEffect = undefined + this.readOverride = undefined this.afterList = undefined this.listCalls = 0 - this.inspectCalls = [] + this.readCalls = [] this.listSignals = [] - this.inspectSignals = [] + this.readSignals = [] } - locate(_meta: SessionHeader): undefined { - return undefined + create(header: SessionHeader): Promise { + TestPersistence.entries.set(header.id, { meta: structuredClone(header), events: [] }) + return Promise.resolve(new TestHandle(header.id, structuredClone(header), 'write')) } - borrowSession(_id: SessionIdType, _signal?: AbortSignal): ReturnType { - return Promise.reject(new Error('not used')) - } + // Appends are durable on resolution here; nothing buffers, so the service-wide flush is a no-op. + async flush(): Promise {} - create(meta: SessionHeader): Promise { - TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) - return Promise.resolve() - } - - append(id: SessionIdType, events: readonly SessionEvent[]): Promise { + open(id: SessionIdType, access: SessionAccess): Promise { const entry = TestPersistence.entries.get(id) - if (entry === undefined) return Promise.reject(new Error('missing test session')) - entry.events.push(...structuredClone(events)) - return Promise.resolve() + if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(id)) + return Promise.resolve(new TestHandle(id, structuredClone(entry.meta), access)) } - load(id: SessionIdType): Promise { - return this.inspect(id) - } - - inspect( - id: SessionIdType, - signal?: AbortSignal, - ): Promise { - TestPersistence.inspectCalls.push(id) - TestPersistence.inspectSignals.push(signal) - if (TestPersistence.inspectOverride !== undefined) { - return TestPersistence.inspectOverride(id, signal) - } - if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure) + stat(id: SessionIdType): Promise { const entry = TestPersistence.entries.get(id) - if (entry === undefined) return Promise.reject(new Error('missing test session')) - const result: SessionInspection = { - ...structuredClone(entry), - inheritedEventCount: SessionLogOffset(0), - } - TestPersistence.inspectEffect?.() - TestPersistence.inspectEffect = undefined - return Promise.resolve(result) + if (entry === undefined) return Promise.resolve(undefined) + return Promise.resolve({ header: structuredClone(entry.meta), revision: entryRevision(entry) }) } - async readFrom( - id: SessionIdType, - fromSeq: SessionLogOffset, - signal?: AbortSignal, - ): Promise { - const whole = await this.inspect(id, signal) - return { ...whole, fromSeq, events: whole.events.filter(event => event.seq >= fromSeq) } - } - - list(signal?: AbortSignal): Promise { + list(options?: { signal?: AbortSignal }): Promise { TestPersistence.listCalls += 1 - TestPersistence.listSignals.push(signal) - if (TestPersistence.listOverride !== undefined) return TestPersistence.listOverride(signal) + TestPersistence.listSignals.push(options?.signal) + if (TestPersistence.listOverride !== undefined) return TestPersistence.listOverride(options?.signal) if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure) - const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) - TestPersistence.afterList?.() - return Promise.resolve(headers) - } - - - async listSnapshots() { - return [...TestPersistence.entries.values()].map(entry => ({ + const snapshots = [...TestPersistence.entries.values()].map(entry => ({ header: structuredClone(entry.meta), - revision: SessionPersistenceRevision(`events:${entry.events.length}`), + revision: entryRevision(entry), })) + TestPersistence.afterList?.() + return Promise.resolve(snapshots) } } @@ -263,7 +281,7 @@ describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => { const controller = new AbortController() const reason = new Error('session listing cancelled before persistence returned') const started = Promise.withResolvers() - const listing = Promise.withResolvers() + const listing = Promise.withResolvers() TestPersistence.listOverride = (_signal) => { started.resolve(undefined) return listing.promise @@ -291,7 +309,7 @@ describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) = await expect(run(ctx, persisted.id, controller.signal)).rejects.toBe(reason) expect(TestPersistence.listCalls).toBe(0) - expect(TestPersistence.inspectCalls).toEqual([]) + expect(TestPersistence.readCalls).toEqual([]) }) it('forwards in-flight list cancellation and waits for cleanup before rejecting', async () => { @@ -333,7 +351,7 @@ describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) = expect(settled).toBe(false) expect(active).toBe(true) expect(TestPersistence.listSignals).toEqual([controller.signal]) - expect(TestPersistence.inspectCalls).toEqual([]) + expect(TestPersistence.readCalls).toEqual([]) cleanup.resolve(undefined) await expect(pending).rejects.toBe(reason) @@ -352,15 +370,12 @@ describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) = const release = Promise.withResolvers() let active = false if (inspects) { - TestPersistence.inspectOverride = async () => { + TestPersistence.readOverride = async () => { active = true started.resolve(undefined) await release.promise active = false - return { - ...structuredClone(entry), - inheritedEventCount: SessionLogOffset(0), - } + return structuredClone(entry) } } else { TestPersistence.listOverride = async () => { @@ -368,7 +383,7 @@ describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) = started.resolve(undefined) await release.promise active = false - return [structuredClone(persisted)] + return [{ header: structuredClone(persisted), revision: SessionPersistenceRevision('override:0') }] } } @@ -384,7 +399,7 @@ describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) = expect(settled).toBe(false) expect(active).toBe(true) expect(TestPersistence.listSignals).toEqual([controller.signal]) - expect(TestPersistence.inspectSignals).toEqual(inspects ? [controller.signal] : []) + expect(TestPersistence.readSignals).toEqual(inspects ? [controller.signal] : []) release.resolve(undefined) await expect(pending).rejects.toBe(reason) @@ -406,7 +421,7 @@ describe.each(cancellableExactReads.filter(read => read.inspects))( const abortObserved = Promise.withResolvers() const cleanup = Promise.withResolvers() let active = false - TestPersistence.inspectOverride = async (_sessionId, signal) => { + TestPersistence.readOverride = async (_sessionId, signal) => { if (signal === undefined) throw new Error('expected exact-read inspection signal') active = true const aborted = new Promise((resolve) => { @@ -434,7 +449,7 @@ describe.each(cancellableExactReads.filter(read => read.inspects))( expect(settled).toBe(false) expect(active).toBe(true) expect(TestPersistence.listSignals).toEqual([controller.signal]) - expect(TestPersistence.inspectSignals).toEqual([controller.signal]) + expect(TestPersistence.readSignals).toEqual([controller.signal]) cleanup.resolve(undefined) await expect(pending).rejects.toBe(reason) @@ -472,7 +487,7 @@ describe('session-query exact reads', () => { TestPersistence.reset([{ meta: shared, events: eventLog('persisted') }]) const ctx = await liveContext() await ctx.plugin(TestPersistence) - TestPersistence.inspectEffect = () => { + TestPersistence.readEffect = () => { ctx.sessions.create(shared.id, { seed: eventLog('live'), meta: { createdAt: shared.createdAt }, @@ -572,9 +587,9 @@ describe('session-query exact reads', () => { expect(results[0]).toMatchObject({ value: { session: second, title: { title: 'Second title' } } }) expect(results[1]).toMatchObject({ value: { session: first, title: { title: 'First title' } } }) expect(TestPersistence.listCalls).toBe(1) - expect(TestPersistence.inspectCalls).toEqual([second.id, first.id]) + expect(TestPersistence.readCalls).toEqual([second.id, first.id]) expect(TestPersistence.listSignals).toEqual([signal]) - expect(TestPersistence.inspectSignals).toEqual([signal, signal]) + expect(TestPersistence.readSignals).toEqual([signal, signal]) }) it('bounds persisted title inspection concurrency while preserving ordered results', async () => { @@ -587,24 +602,21 @@ describe('session-query exact reads', () => { await ctx.plugin(TestPersistence) let active = 0 let maximum = 0 - TestPersistence.inspectOverride = async (id) => { + TestPersistence.readOverride = async (id) => { active += 1 maximum = Math.max(maximum, active) await new Promise(resolve => setImmediate(resolve)) active -= 1 const entry = TestPersistence.entries.get(id) if (entry === undefined) throw new Error('missing bounded test session') - return { - ...structuredClone(entry), - inheritedEventCount: SessionLogOffset(0), - } + return structuredClone(entry) } const results = await ctx.sessionQuery.readTitleSnapshots(entries.map(entry => entry.meta.id)) expect(maximum).toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY) expect(TestPersistence.listCalls).toBe(1) - expect(TestPersistence.inspectCalls).toEqual(entries.map(entry => entry.meta.id)) + expect(TestPersistence.readCalls).toEqual(entries.map(entry => entry.meta.id)) expect(results.map(result => result.sessionId)).toEqual(entries.map(entry => entry.meta.id)) expect(results.every(result => result.status === 'fulfilled')).toBe(true) }) @@ -619,7 +631,7 @@ describe('session-query exact reads', () => { await ctx.plugin(TestPersistence) const timeline: string[] = [] const releases = new Map void>() - TestPersistence.inspectOverride = id => new Promise((resolve) => { + TestPersistence.readOverride = id => new Promise((resolve) => { timeline.push(`inspect:${id}`) releases.set(id, () => { const marker = `full-log-marker:${id}` @@ -638,7 +650,6 @@ describe('session-query exact reads', () => { } as unknown as SessionEvent resolve({ meta: entries.find(entry => entry.meta.id === id)!.meta, - inheritedEventCount: SessionLogOffset(0), events: [...eventLog(marker), titleEvent], }) }) @@ -651,9 +662,9 @@ describe('session-query exact reads', () => { const ids = entries.map(entry => entry.meta.id) const pending = ctx.sessionQuery.readTitleSnapshots(ids) - await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) }) + await vi.waitFor(() => { expect(TestPersistence.readCalls).toHaveLength(4) }) release(ids[0]!) - await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(5) }) + await vi.waitFor(() => { expect(TestPersistence.readCalls).toHaveLength(5) }) // Heap-retention assertions would depend on nondeterministic GC. This ordering // is the deterministic guard: a retain-all implementation cannot touch the @@ -677,7 +688,7 @@ describe('session-query exact reads', () => { const reason = new Error('title deadline') let started!: () => void const inspectStarted = new Promise((resolve) => { started = resolve }) - TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => { + TestPersistence.readOverride = (_id, signal) => new Promise((_resolve, reject) => { started() signal?.addEventListener('abort', () => { reject(reason) }, { once: true }) }) @@ -688,7 +699,7 @@ describe('session-query exact reads', () => { await expect(pending).rejects.toBe(reason) expect(TestPersistence.listSignals).toEqual([controller.signal]) - expect(TestPersistence.inspectSignals).toEqual([controller.signal]) + expect(TestPersistence.readSignals).toEqual([controller.signal]) }) it('drains started title inspections after cancellation without starting queued ids', async () => { @@ -697,15 +708,15 @@ describe('session-query exact reads', () => { events: eventLog(`queued-${index}`), })) TestPersistence.reset(entries) - const persistedInspectConcurrency = 2 - const ctx = await liveContext({ persistedInspectConcurrency }) + const persistedReadConcurrency = 2 + const ctx = await liveContext({ persistedReadConcurrency }) await ctx.plugin(TestPersistence) const controller = new AbortController() const reason = new Error('cancel queued title batch') const releases: Array<() => void> = [] let abortsObserved = 0 let inspectionsSettled = 0 - TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => { + TestPersistence.readOverride = (_id, signal) => new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => { abortsObserved += 1 }, { once: true }) releases.push(() => { inspectionsSettled += 1 @@ -723,20 +734,20 @@ describe('session-query exact reads', () => { () => { batchSettled = true }, ) await vi.waitFor(() => { - expect(TestPersistence.inspectCalls).toHaveLength(persistedInspectConcurrency) + expect(TestPersistence.readCalls).toHaveLength(persistedReadConcurrency) }) controller.abort(reason) - await vi.waitFor(() => { expect(abortsObserved).toBe(persistedInspectConcurrency) }) + await vi.waitFor(() => { expect(abortsObserved).toBe(persistedReadConcurrency) }) expect(batchSettled).toBe(false) - expect(TestPersistence.inspectCalls) - .toEqual(entries.slice(0, persistedInspectConcurrency).map(entry => entry.meta.id)) + expect(TestPersistence.readCalls) + .toEqual(entries.slice(0, persistedReadConcurrency).map(entry => entry.meta.id)) for (const release of releases) release() await expect(pending).rejects.toBe(reason) - expect(inspectionsSettled).toBe(persistedInspectConcurrency) - expect(TestPersistence.inspectCalls) - .toEqual(entries.slice(0, persistedInspectConcurrency).map(entry => entry.meta.id)) + expect(inspectionsSettled).toBe(persistedReadConcurrency) + expect(TestPersistence.readCalls) + .toEqual(entries.slice(0, persistedReadConcurrency).map(entry => entry.meta.id)) }) it('passes cancellation into a stalled persisted title listing and rejects with its reason', async () => { @@ -759,7 +770,7 @@ describe('session-query exact reads', () => { await expect(pending).rejects.toBe(reason) expect(TestPersistence.listSignals).toEqual([controller.signal]) - expect(TestPersistence.inspectCalls).toEqual([]) + expect(TestPersistence.readCalls).toEqual([]) }) it('isolates title read and fold failures while preferring a live owner attached during inspection', async () => { @@ -784,7 +795,7 @@ describe('session-query exact reads', () => { const ctx = await liveContext() await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG) await ctx.plugin(TestPersistence) - TestPersistence.inspectOverride = (id) => { + TestPersistence.readOverride = (id) => { if (id === failed.id) return Promise.reject(inspectFailure) const entry = TestPersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) @@ -796,10 +807,7 @@ describe('session-query exact reads', () => { source: { kind: 'fallback' }, }) } - return Promise.resolve({ - ...structuredClone(entry), - inheritedEventCount: SessionLogOffset(0), - }) + return Promise.resolve(structuredClone(entry)) } const results = await ctx.sessionQuery.readTitleSnapshots([ @@ -998,10 +1006,7 @@ describe('session-query exact reads', () => { createUserMessage({ content: [{ type: 'text', text: 'latest checkpoint' }], source: { kind: 'plugin', plugin: 'compact' }, }), - { - surfaceOp: { op: 'replace', start: SessionSeq(2), end: retained.seq }, - sourceEventSeqs: [SessionSeq(2), retained.seq], - }, + { surfaceOp: { op: 'replace', start: SessionSeq(2), end: retained.seq }, sourceEventSeqs: [SessionSeq(2), retained.seq] }, ) session.append( 'assistant/message', @@ -1060,12 +1065,7 @@ describe('session-query exact reads', () => { ) } - const result = await ctx.sessionQuery.readEvent({ - sessionId: session.id, - seq: SessionSeq(2), - before: 1, - after: 1, - }) + const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: SessionSeq(2), before: 1, after: 1 }) expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([1, 3, 2]) expect(result.session).toEqual(session.header) Object.assign(result.session, { createdAt: -1 }) @@ -1147,16 +1147,16 @@ describe('session-query exact reads', () => { ) await ctx.plugin(TestPersistence) TestPersistence.listFailure = new Error('list unavailable') - TestPersistence.inspectFailure = new Error('inspect unavailable') + TestPersistence.readFailure = new Error('inspect unavailable') const signal = new AbortController().signal await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2) await expect(ctx.sessionQuery.traceEvent({ sessionId: live.id, seq: SessionSeq(1) }, signal)) - .resolves.toMatchObject({ session: { id: live.id }, target: { seq: 1 } }) + .resolves.toMatchObject({ session: { id: live.id }, target: { seq: SessionSeq(1) } }) await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: SessionSeq(1) }, signal)) - .resolves.toMatchObject({ target: { seq: 1 } }) + .resolves.toMatchObject({ target: { seq: SessionSeq(1) } }) expect(TestPersistence.listSignals).toEqual([]) - expect(TestPersistence.inspectSignals).toEqual([]) + expect(TestPersistence.readSignals).toEqual([]) await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) @@ -1170,7 +1170,7 @@ describe('session-query exact reads', () => { 'stored prefix failed validation', { cause: new Error('torn final record') }, ) - TestPersistence.inspectFailure = corruption + TestPersistence.readFailure = corruption await expect(ctx.sessionQuery.readSession(durable.id)).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION', @@ -1189,10 +1189,10 @@ describe('session-query exact reads', () => { await expect(ctx.sessionQuery.listEvents(SessionId('absent'))) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) - TestPersistence.inspectFailure = 'raw failure' + TestPersistence.readFailure = 'raw failure' await expect(ctx.sessionQuery.listEvents(durable.id)) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.inspectFailure = undefined + TestPersistence.readFailure = undefined const durableEntry = TestPersistence.entries.get(durable.id)! durableEntry.meta = { ...durableEntry.meta, cwd: '/changed-after-list' } TestPersistence.afterList = () => { @@ -1227,8 +1227,10 @@ describe('session-query exact reads', () => { expect(new TestSessionQueryEngine(direct)).toBeInstanceOf(SessionQueryEngine) for (const config of [ { readWindowMax: -1 }, - { persistedInspectConcurrency: 0 }, - { persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 }, + { persistedReadConcurrency: 0 }, + { persistedReadConcurrency: Number.MAX_SAFE_INTEGER + 1 }, + { preparedSessionCacheSize: 0 }, + { preparedSessionCacheSize: Number.MAX_SAFE_INTEGER + 1 }, ]) { const invalid = new Context() await invalid.plugin(SessionStore) @@ -1237,6 +1239,16 @@ describe('session-query exact reads', () => { } }) + it('exposes caller-owned observation leases through observeSession', async () => { + const ctx = await liveContext() + const live = ctx.sessions.create(SessionId('observe-live')) + + using observed = await ctx.sessionQuery.observeSession(live.id) + + expect(observed.source).toBe('live') + expect(observed.header.id).toBe(live.id) + }) + it('leaves the optional persistence dependency optional', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index e40af94f68..fbc29998b3 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -1,16 +1,19 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { - SESSION_FORMAT_VERSION, - SessionId, - SessionLogOffset, - SessionSeq, -} from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset, SessionSeq, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence from '@deepseek-ai/dsh-session-persistence' -import type { SessionEventSuffix, SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { + SessionPersistenceNotFoundError, + SessionPersistenceRevision, + SessionReadOnlyError, +} from '@deepseek-ai/dsh-session-persistence' +import type { + SessionAccess, + SessionHandle, + SessionPersistenceSnapshot, +} from '@deepseek-ai/dsh-session-persistence' import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' import { TestSessionQueryEngine } from './test-service.ts' @@ -25,89 +28,102 @@ function header(id: string, createdAt = 1, extra: Partial = {}): return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, isSeeded: false, ...extra } } -function appendEvent(seq: SessionSeq, sources?: number[]): SessionEvent { +function appendEvent(seq: number, sources?: readonly number[]): SessionEvent { return { type: 'user/message', - seq, + seq: SessionSeq(seq), time: seq + 1, data: createUserMessage({ content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' }, }), surfaceOp: 'append', - ...sources === undefined ? {} : { sourceEventSeqs: sources.map(SessionSeq) }, + ...sources === undefined ? {} : { sourceEventSeqs: sources as unknown as SessionSeq[] }, + } +} + +class TraceHandle implements SessionHandle { + readonly inheritedEventCount = SessionLogOffset(0) + constructor( + readonly id: SessionIdType, + readonly header: SessionHeader, + readonly access: SessionAccess, + ) {} + + read(): Promise { + TracePersistence.readCalls += 1 + if (TracePersistence.readFailure !== undefined) return Promise.reject(TracePersistence.readFailure) + const entry = TracePersistence.entries.get(this.id) + if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(this.id)) + return Promise.resolve(structuredClone(entry.events)) + } + + append(): Promise { + return Promise.reject(new SessionReadOnlyError(this.id, 'append')) + } + + flush(): Promise { + return Promise.reject(new SessionReadOnlyError(this.id, 'flush')) + } + + close(): Promise { + return Promise.resolve() + } + + [Symbol.asyncDispose](): Promise { + return this.close() } } class TracePersistence extends SessionPersistence { - override readonly supportsRawArtifacts = false - static entries = new Map() static listCalls = 0 - static inspectCalls = 0 + static readCalls = 0 static listFailure: Error | undefined - static inspectFailure: Error | undefined + static readFailure: Error | undefined static afterList: (() => void) | undefined static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listCalls = 0 - this.inspectCalls = 0 + this.readCalls = 0 this.listFailure = undefined - this.inspectFailure = undefined + this.readFailure = undefined this.afterList = undefined } - locate(_meta: SessionHeader): undefined { - return undefined + create(header: SessionHeader): Promise { + TracePersistence.entries.set(header.id, { meta: structuredClone(header), events: [] }) + return Promise.resolve(new TraceHandle(header.id, structuredClone(header), 'write')) } - borrowSession(_id: SessionIdType, _signal?: AbortSignal): ReturnType { - return Promise.reject(new Error('not used')) - } + // Appends are durable on resolution here; nothing buffers, so the service-wide flush is a no-op. + async flush(): Promise {} - create(meta: SessionHeader): Promise { - TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) - return Promise.resolve() - } - - append(id: SessionIdType, events: readonly SessionEvent[]): Promise { + open(id: SessionIdType, access: SessionAccess): Promise { const entry = TracePersistence.entries.get(id) - if (entry === undefined) return Promise.reject(new Error('missing test session')) - entry.events.push(...structuredClone(events)) - return Promise.resolve() + if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(id)) + return Promise.resolve(new TraceHandle(id, structuredClone(entry.meta), access)) } - load(id: SessionIdType): Promise { - return this.inspect(id) - } - - inspect(id: SessionIdType): Promise { - TracePersistence.inspectCalls += 1 - if (TracePersistence.inspectFailure !== undefined) return Promise.reject(TracePersistence.inspectFailure) + stat(id: SessionIdType): Promise { const entry = TracePersistence.entries.get(id) - if (entry === undefined) return Promise.reject(new Error('missing test session')) + if (entry === undefined) return Promise.resolve(undefined) return Promise.resolve({ - ...structuredClone(entry), - inheritedEventCount: SessionLogOffset(0), + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`events:${entry.events.length}`), }) } - async readFrom(id: SessionIdType, fromSeq: SessionLogOffset): Promise { - const whole = await this.inspect(id) - return { ...whole, fromSeq, events: whole.events.filter(event => event.seq >= fromSeq) } - } - - list(): Promise { + list(): Promise { TracePersistence.listCalls += 1 if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure) - const result = [...TracePersistence.entries.values()].map(entry => structuredClone(entry.meta)) + const result = [...TracePersistence.entries.values()].map(entry => ({ + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`events:${entry.events.length}`), + })) TracePersistence.afterList?.() return Promise.resolve(result) } - - listSnapshots(): Promise { - return Promise.resolve([]) - } } async function queryContext(): Promise { @@ -150,10 +166,7 @@ function appendTraceEvents(session: Session): void { }, }), }, - { - surfaceOp: { op: 'replace', start: SessionSeq(3), end: SessionSeq(3) }, - sourceEventSeqs: [SessionSeq(3), SessionSeq(2)], - }, + { surfaceOp: { op: 'replace', start: SessionSeq(3), end: SessionSeq(3) }, sourceEventSeqs: [SessionSeq(3), SessionSeq(2)] }, ) session.append( 'user/message', @@ -177,10 +190,7 @@ function appendTraceEvents(session: Session): void { }, }), }, - { - surfaceOp: { op: 'replace', start: SessionSeq(4), end: SessionSeq(4) }, - sourceEventSeqs: [SessionSeq(2), SessionSeq(4)], - }, + { surfaceOp: { op: 'replace', start: SessionSeq(4), end: SessionSeq(4) }, sourceEventSeqs: [SessionSeq(2), SessionSeq(4)] }, ) } @@ -259,7 +269,7 @@ describe('session lineage tracing', () => { it('uses one cross-corpus observation and preserves persistence failure semantics', async () => { const durable = header('durable') - TracePersistence.reset([{ meta: durable, events: [appendEvent(SessionSeq(0))] }]) + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) const ctx = await queryContext() await ctx.plugin(TracePersistence) @@ -268,7 +278,7 @@ describe('session lineage tracing', () => { complete: true, }) expect(TracePersistence.listCalls).toBe(1) - expect(TracePersistence.inspectCalls).toBe(0) + expect(TracePersistence.readCalls).toBe(0) TracePersistence.listFailure = new Error('unavailable') await expect(ctx.sessionQuery.traceSession(durable.id)) @@ -306,7 +316,7 @@ describe('session event tracing', () => { const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: SessionSeq(3) }) expect(original.target).toMatchObject({ sessionId: session.id, - seq: 3, + seq: SessionSeq(3), type: 'user/message', surface: 'shadowed', }) @@ -362,13 +372,13 @@ describe('session event tracing', () => { it('inspects persisted logs once, prefers live logs, and preserves failures and conflicts', async () => { const durable = header('shared', 1, { cwd: '/same' }) - TracePersistence.reset([{ meta: durable, events: [appendEvent(SessionSeq(0))] }]) + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) const ctx = await queryContext() await ctx.plugin(TracePersistence) await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } }) - expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) + expect([TracePersistence.listCalls, TracePersistence.readCalls]).toEqual([1, 1]) const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) live.append('turn/start', { turn: 1 }) @@ -380,22 +390,22 @@ describe('session event tracing', () => { { surfaceOp: 'append' }, ) TracePersistence.listFailure = new Error('list unavailable') - TracePersistence.inspectFailure = new Error('inspect unavailable') + TracePersistence.readFailure = new Error('inspect unavailable') await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(1) })) .resolves.toMatchObject({ target: { type: 'user/message' } }) - expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) + expect([TracePersistence.listCalls, TracePersistence.readCalls]).toEqual([1, 1]) - TracePersistence.reset([{ meta: durable, events: [appendEvent(SessionSeq(0))] }]) + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) const failedCtx = await queryContext() await failedCtx.plugin(TracePersistence) TracePersistence.listFailure = new Error('list unavailable') await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TracePersistence.listFailure = undefined - TracePersistence.inspectFailure = new Error('inspect unavailable') + TracePersistence.readFailure = new Error('inspect unavailable') await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TracePersistence.inspectFailure = undefined + TracePersistence.readFailure = undefined TracePersistence.afterList = () => { mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed' } @@ -405,7 +415,7 @@ describe('session event tracing', () => { it('checks target existence before surface or source-event analysis', async () => { const bad = header('bad-target') - const malformed: SessionEvent[] = [appendEvent(SessionSeq(0)), { + const malformed: SessionEvent[] = [appendEvent(0), { type: 'assistant/message', seq: SessionSeq(1), time: 2, @@ -435,37 +445,37 @@ describe('session event tracing', () => { it.each([ ['non-surface sources', [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 }, sourceEventSeqs: [0] }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 }, sourceEventSeqs: [0] }, ]], ['invalid source array', [ - { ...appendEvent(SessionSeq(0)), sourceEventSeqs: 'invalid' }, + { ...appendEvent(0), sourceEventSeqs: 'invalid' }, ]], ['empty sources', [ - appendEvent(SessionSeq(0), []), + appendEvent(0, []), ]], ['sparse sources', [ - appendEvent(SessionSeq(0), Array(1)), + appendEvent(0, Array(1)), ]], ['duplicate sources', [ - appendEvent(SessionSeq(0)), - appendEvent(SessionSeq(1), [0, 0]), + appendEvent(0), + appendEvent(1, [0, 0]), ]], ['missing earlier source', [ - appendEvent(SessionSeq(0)), - { ...appendEvent(SessionSeq(1)), sourceEventSeqs: [-1] } as unknown as SessionEvent, + appendEvent(0), + appendEvent(1, [-1]), ]], ['future source', [ - appendEvent(SessionSeq(0), [1]), - appendEvent(SessionSeq(1)), + appendEvent(0, [1]), + appendEvent(1), ]], ['replacement without sources', [ - appendEvent(SessionSeq(0)), - { ...appendEvent(SessionSeq(1)), surfaceOp: { op: 'replace', start: 0, end: 0 } }, + appendEvent(0), + { ...appendEvent(1), surfaceOp: { op: 'replace', start: 0, end: 0 } }, ]], ['replacement missing a shadowed source', [ - { type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } }, - appendEvent(SessionSeq(1)), - { ...appendEvent(SessionSeq(2), [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } }, + { type: 'assistant/chunk', seq: SessionSeq(0), time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } }, + appendEvent(1), + { ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } }, ]], ] as const)('rejects an invalid surface log: %s', async (_name, rawEvents) => { const durable = header('invalid-provenance') @@ -482,7 +492,7 @@ describe('session event tracing', () => { const durable = header('invalid-non-surface-op') const events = [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, surfaceOp: 'append', @@ -497,7 +507,7 @@ describe('session event tracing', () => { it('applies the same surface contract to listEvents', async () => { const durable = header('list-regression') - TracePersistence.reset([{ meta: durable, events: [appendEvent(SessionSeq(0)), appendEvent(SessionSeq(1), [0, 0])] }]) + TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }]) const ctx = await queryContext() await ctx.plugin(TracePersistence) diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index 5e60ac5118..ed6402865f 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -53,14 +53,14 @@ describe('tool-session-query with the real SQLite provider', () => { await ctx.plugin(ToolSessionQuery) const persisted = SessionId('persisted') - await ctx.sessionPersistence.create({ + const writer = await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id: persisted, createdAt: 1, cwd: '/work', isSeeded: false, }) - await ctx.sessionPersistence.append(persisted, [{ + await writer.append([{ type: 'user/message', seq: SessionSeq(0), time: 2, @@ -70,6 +70,7 @@ describe('tool-session-query with the real SQLite provider', () => { }), surfaceOp: 'append', }]) + await writer.close() const caller = ctx.sessions.create(SessionId('caller'), { meta: { createdAt: 10, cwd: '/work' }, @@ -126,14 +127,14 @@ describe('tool-session-query with the real SQLite provider', () => { const base = Date.parse('2026-07-24T00:00:00.000Z') const persisted = SessionId('fractional-persisted') - await ctx.sessionPersistence.create({ + const writer = await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id: persisted, createdAt: base, cwd: '/work', isSeeded: false, }) - await ctx.sessionPersistence.append(persisted, [ + await writer.append([ { type: 'user/message', seq: SessionSeq(0), @@ -175,6 +176,7 @@ describe('tool-session-query with the real SQLite provider', () => { surfaceOp: 'append', }, ]) + await writer.close() const caller = ctx.sessions.create(SessionId('fractional-caller'), { meta: { createdAt: base + 1_000, cwd: '/work' }, diff --git a/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts index fb09802203..cf5552ec54 100644 --- a/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -6,7 +6,7 @@ import { execa } from 'execa' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import SessionStore, { - SessionId, TOOL_OUTCOME_UNKNOWN, + SessionId, TOOL_OUTCOME_UNKNOWN, interruptedTurnClosers, type SessionEvent, } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -64,12 +64,21 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker } } +// Read the crashed durable log and balance it the way a resuming reader does: +// the stored events stay untouched; `interruptedTurnClosers` supplies the +// in-memory closers for the interrupted tail turn. async function load(root: string): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) try { - return [...(await ctx.sessionPersistence.load(sessionId)).events] + const handle = await ctx.sessionPersistence.open(sessionId, 'read') + try { + const events = await handle.read() + return [...events, ...interruptedTurnClosers(events)] + } finally { + await handle.close() + } } finally { await ctx.fiber.dispose() } diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 670d410ee3..654b1ab18f 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -4,34 +4,21 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import LlmRuntime, { ToolCallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, SessionLogOffset } from '@deepseek-ai/dsh-session' -import SessionPersistence, { type SessionEventSuffix, type SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { type SessionHandle, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import * as checkpointPolicy from '../src/index.ts' const contexts: Context[] = [] +// The policy only requires the service's presence; it flushes through +// `ctx.sessions`, so no handle is ever opened in these tests. class TestPersistence extends SessionPersistence { - override readonly supportsRawArtifacts = false - - locate(_meta: SessionHeader): undefined { return undefined } - create(_meta: SessionHeader): Promise { return Promise.resolve() } - append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } - load(_id: SessionId): Promise { - return Promise.reject(new Error('not used')) - } - inspect(_id: SessionId): Promise { - return Promise.reject(new Error('not used')) - } - borrowSession(_id: SessionId, _signal?: AbortSignal): ReturnType { - return Promise.reject(new Error('not used')) - } - readFrom(_id: SessionId, _fromSeq: SessionLogOffset): Promise { - return Promise.reject(new Error('not used')) - } - list(): Promise { return Promise.resolve([]) } - listSnapshots(): Promise { return Promise.resolve([]) } + create(): Promise { return Promise.reject(new Error('not used')) } + open(): Promise { return Promise.reject(new Error('not used')) } + flush(): Promise { return Promise.resolve() } + stat(): Promise { return Promise.resolve(undefined) } + list(): Promise { return Promise.resolve([]) } } class RecordingAdapter extends LlmAdapter { diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 0a44d29f0c..a443c9871e 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/session-persistence-jsonl/README.md -README.md: d3dfaf8affb142979ede69d86bbf0b1587e8c39c -README.zh.md: 15fc815523355674a1f325b604dd2655f56f7f58 +README.md: 9d2175df1536943d56ac150135fcf4018f645256 +README.zh.md: 9de71107687837b4833f15edb9ba033be50d7a81 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index d3dfaf8aff..9d2175df15 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-persistence-jsonl` stores each session in its own append-only JSONL log — checksummed Zstandard frames by default, raw newline-delimited lines when compression is disabled. It serves the same logical `SessionEvent` stream as any persistence backend, so choosing it changes nothing for the agent loop, the model, or replay; compression, packing, and crash recovery are storage-internal details. Choose it when consumers need a per-session artifact on disk: `locate(meta)` returns the transcript path, and the logs are readable as plain lines when `compression: 'none'` is selected. A root directory is the one required configuration; durability, lazy materialization, and interrupted-turn recovery come with the backend. +`dsh-session-persistence-jsonl` stores each session in its own append-only JSONL log — checksummed Zstandard frames by default, raw newline-delimited lines when compression is disabled. It serves the same logical `SessionEvent` stream as any persistence backend, so choosing it changes nothing for the agent loop, the model, or replay; compression, packing, and crash recovery are storage-internal details. Choose it when consumers need a per-session file on disk; the logs are readable as plain lines when `compression: 'none'` is selected. A root directory is the one required configuration; durability, lazy materialization, and torn-tail crash recovery come with the backend. ## Table of Contents @@ -47,8 +47,8 @@ Choose this backend when consumers benefit from one artifact per session — nav | `root` | required | Root directory for all session files | | `packChunks` | `true` | Write eligible `assistant/chunk` runs as packed rows; `false` keeps one event per line for diagnostics | | `compression` | `'zstd'` | Physical encoding: `'zstd'` checksummed frames, or `'none'` newline-delimited UTF-8 text | -| `preparedSessionCacheSize` | `5` | Cold session preparations retained for resume reuse | -| `writeBatchMaxDelayMs` | `200` | Fixed live-event coalescing window, in milliseconds | + +Live-event write batching is not configuration: the batching window is the seam's internal scheduling policy inside each write handle. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-persistence-jsonl) is the exhaustive source for every accepted field and its JSDoc. @@ -64,15 +64,15 @@ Each session gets a session-owned directory under a readable project directory; session.jsonl # only with compression: 'none' ``` -Session ids are injectively escaped to one safe path segment before use (no traversal, no collision). The normalized cwd keeps the project directory readable for navigation; cwd strings that normalize alike share a project directory while session ids still select distinct session directories. `locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved directories, performing no filesystem I/O. +Session ids are injectively escaped to one safe path segment before use (no traversal, no collision). The normalized cwd keeps the project directory readable for navigation; cwd strings that normalize alike share a project directory while session ids still select distinct session directories. Format-refusal diagnostics name the absolute path of the fixed transcript inside the resolved directories, so an operator can find the raw log a build refused to interpret. ### Durability and crash semantics -A session is materialized lazily: `create(meta)` writes nothing, and the first `append` writes and `fsync`s the encoded header and first batch through a no-overwrite publish — so a created-but-never-appended session leaves nothing on disk unless a lifecycle consumer calls `ensureMaterialized`, which publishes one header frame without an event. Flushed events are never rewritten; each subsequent batch appends lines or one compressed frame, and a caught write or sync failure rolls the file back to its prior length. After a crash, `load` preserves an interrupted final turn: it keeps the complete decoded records of an incomplete last frame, truncates from that frame's start, and re-encodes the records with the synthetic tool, step, and turn closers required by the shared persistence contract. Only a never-fully-written torn tail is discarded; checksum, decompression, or structural failure in the committed prefix rejects as corruption. +A session is materialized lazily: `create(header)` writes nothing and returns the owned write handle, and the handle's first `append` writes and `fsync`s the encoded header and first batch through a no-overwrite publish — so a created-but-never-appended session leaves nothing on disk unless its owner calls `handle.flush()`, which publishes one header frame without an event. Each subsequent batch appends lines or one compressed frame and `fsync`s before the append resolves; a caught write or sync failure rolls the file back to its prior length. Committed events are never rewritten. After a crash, the stored log keeps its interrupted final turn — every record in the committed prefix survives, and the resuming reader appends synthetic closers through its write handle. A torn tail — an incomplete final line, or a torn final frame — is never returned to a reader and is discarded whole, truncated durably before the write handle's first new append, because its own append never resolved and nothing in it was acknowledged durable; checksum, decompression, or structural failure in the committed prefix rejects as corruption. ### Reading the logs -`inspect(id)` returns an immutable balanced view with its exact inherited cut without committing recovery. `readFrom(id, fromOffset)` accepts a `SessionLogOffset`, returns stored events at or past that offset, and retains the same cut beside the suffix; sequential media like JSONL parse the whole artifact and skip forward. Header-only listing exposes `isSeeded` without reading event bodies. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. +`open(id, 'read')` returns a handle whose `read(offset?, length?)` serves validated contiguous slices; the artifact is re-scanned on demand under a bounded stable read, so a slice never contains a torn tail. A torn final Zstandard frame is partially decoded: complete JSONL records already flushed into it are recovered into the logical log, and the write handle's first mutation truncates the torn bytes and durably rewrites the recovered records ahead of its own batch. `open(id, 'write')` primes the handle with the validated stored prefix, so resume's whole-log read costs no second parse before the first append. A bounded memo keyed by session id and the stat-derived revision lets an immediately following open reuse the parsed log — the cold observe-then-resume handoff parses once — and every local mutation invalidates its id. `stat(id)` and `list()` read only the header line and one `fs.stat`, carrying `sizeBytes` and a best-effort stat-derived revision (device, inode, size, nanosecond timestamps) without parsing the log. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. ----- @@ -86,7 +86,7 @@ This section explains the physical encoding and write path; the observable contr ### Design concept -The backend is a thin storage layer over the shared [PersistenceCoordinator](../session-persistence/README.md#understand-the-implementation): it loads stored records, appends batches, commits repairs, and delegates lifecycle orchestration to the coordinator. Its physical identity is a file revision: device, inode, size, and nanosecond timestamps identify one log and change after append or repair, which is what `listSnapshots` and retained-preparation validation use. +The backend owns its complete storage runtime (`src/storage.ts`): `JsonlSessionHandle` carries the per-handle mutation chain, the routed live-event buffer with its fixed batching window and single-flight drain, monotonic reads, and idempotent close; a tracker holds the in-process single-writer claims, the open-handle set teardown sweeps, and the created-but-unmaterialized pending sessions the backend's own session listeners route into. The package deliberately exposes only its default plugin export plus configuration types — the concrete class is not a named export, so consumers couple to `ctx.sessionPersistence`, and the shared seam suites (`runPersistenceContract`/`runLiveWritePathContract`) pin its observable behavior. Its change token is a best-effort file revision: device, inode, size, and nanosecond timestamps identify one log for `stat`/`list` and for the stable-read loop that retries a read torn by a concurrent append. ### Physical encoding @@ -96,7 +96,8 @@ The default artifact is a standard concatenation of independent [Zstandard frame | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, backend class, coordinator wiring | +| [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, the backend service class, and file storage primitives | +| [`src/storage.ts`](src/storage.ts) | The JSONL handle, routed live-event buffer, in-process writer bookkeeping, listeners, teardown | | [`src/format.ts`](src/format.ts) | Log path derivation, header encoding, record scanning, packed-row layout | | [`src/zstd.ts`](src/zstd.ts) | Zstandard frame compression, decoding, and frame scanning | | [`src/win32.ts`](src/win32.ts) | Windows write-through publish and directory creation | @@ -146,7 +147,7 @@ These limits define when this backend is a poor fit or needs special operational - **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally; the seam has no deletion API. -- **One live writer per session** — append and repair are coordinated only inside the owning backend instance; another instance or process must not write the same session until that owner reaches quiescent disposal. +- **One live writer per session, in-process only** — the write-handle claim excludes a second writer inside the owning backend instance; another instance or process must not write the same session until that handle closes (the durable cross-process lease is the seam's planned next layer). - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 15fc815523..9de7110768 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-persistence-jsonl` 把每个会话存为一份仅追加 JSONL 日志——默认以带校验和的 Zstandard 帧存储,禁用压缩时以换行分隔的原始文本行存储。它提供与任何持久化后端相同的逻辑 `SessionEvent` 流,因此选择它不会改变 agent loop、模型或回放的任何行为;压缩、打包与崩溃恢复都是存储内部细节。当消费方需要按会话的磁盘产物时选择它:`locate(meta)` 返回 transcript 路径,选择 `compression: 'none'` 后日志可作为纯文本按行读取。根目录是唯一必填配置;持久性、延迟实体化与中断轮次恢复都随后端提供。 +`dsh-session-persistence-jsonl` 把每个会话存为一份仅追加 JSONL 日志——默认以带校验和的 Zstandard 帧存储,禁用压缩时以换行分隔的原始文本行存储。它提供与任何持久化后端相同的逻辑 `SessionEvent` 流,因此选择它不会改变 agent loop、模型或回放的任何行为;压缩、打包与崩溃恢复都是存储内部细节。当消费方需要按会话的磁盘文件时选择它;选择 `compression: 'none'` 后日志可作为纯文本按行读取。根目录是唯一必填配置;持久性、延迟实体化与撕裂尾部崩溃恢复都随后端提供。 ## 目录 @@ -47,8 +47,8 @@ kind: "package-reference" | `root` | 必填 | 所有会话文件的根目录 | | `packChunks` | `true` | 把符合条件的 `assistant/chunk` 连续段写为打包行;`false` 为诊断保留每事件一行 | | `compression` | `'zstd'` | 物理编码:`'zstd'` 带校验和帧,或 `'none'` 换行分隔 UTF-8 文本 | -| `preparedSessionCacheSize` | `5` | 为恢复复用而保留的冷会话准备结果数量 | -| `writeBatchMaxDelayMs` | `200` | 实时事件的固定聚合窗口,单位为毫秒 | + +实时事件的写入批处理不是配置:批处理窗口是该 seam 在每个写句柄内部的调度策略。 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-persistence-jsonl)是每个受支持字段及其 JSDoc 的穷尽式真源。 @@ -64,15 +64,15 @@ kind: "package-reference" session.jsonl # only with compression: 'none' ``` -会话 id 在使用前被单射转义为一个安全路径段(无遍历、无冲突)。规范化 cwd 让项目目录保持可读、便于导航;规范化相同的 cwd 字符串共享项目目录,而会话 id 仍选择不同会话目录。`locate(meta)` 返回已解析目录内固定 transcript 的 `{ kind: 'jsonl', path }`,不执行任何文件系统 I/O。 +会话 id 在使用前被单射转义为一个安全路径段(无遍历、无冲突)。规范化 cwd 让项目目录保持可读、便于导航;规范化相同的 cwd 字符串共享项目目录,而会话 id 仍选择不同会话目录。格式拒绝诊断会点名已解析目录内固定 transcript 的绝对路径,让操作者能找到构建拒绝解读的原始日志。 ### 持久性与崩溃语义 -会话延迟实体化:`create(meta)` 不写入任何内容,第一次 `append` 通过无覆盖发布写入并 `fsync` 编码后的 header 与第一批——因此已创建但从未 append 的会话不留下任何磁盘内容,除非生命周期消费方调用 `ensureMaterialized`,以无事件的单个 header 帧发布它。已 flush 事件绝不重写;后续每个批次追加行或一个压缩帧,捕获到写入或同步失败时把文件回滚到之前的字节长度。崩溃后,`load` 保留被中断的最终轮次:保留不完整最后帧中完整解码的记录,从该帧开头截断,并按共享持久化约定的要求,用合成工具、步骤与轮次 closer 重新编码这些记录。只有从未完整写入的撕裂尾部被丢弃;已提交前缀中的校验和、解压或结构失败以损坏拒绝。 +会话延迟实体化:`create(header)` 不写入任何内容并返回持有的写句柄,句柄的第一次 `append` 通过无覆盖发布写入并 `fsync` 编码后的 header 与第一批——因此已创建但从未 append 的会话不留下任何磁盘内容,除非其所有者调用 `handle.flush()`,以无事件的单个 header 帧发布它。后续每个批次追加行或一个压缩帧,并在 append 完成前 `fsync`;捕获到写入或同步失败时把文件回滚到之前的字节长度。已提交事件绝不重写。崩溃后,已存储日志保留被中断的最终轮次——已提交前缀中的每条记录都保留下来,由执行恢复的读方通过其写句柄追加合成 closer。撕裂尾部——不完整的最后一行,或撕裂的最后一帧——绝不返回给读取方并被整体丢弃,在写句柄的第一次新 append 之前被持久截断,因为其自身的 append 从未成功返回,其中没有任何内容被确认为已持久;已提交前缀中的校验和、解压或结构失败以损坏拒绝。 ### 读取日志 -`inspect(id)` 返回带精确继承切点的不可变平衡视图,不提交恢复。`readFrom(id, fromOffset)` 接受 `SessionLogOffset`,返回该偏移及之后的已存储事件,并在后缀旁保留同一切点;JSONL 这类顺序介质解析整个产物并向前跳过。仅 header 的列表读取不读事件正文即可公开 `isSeeded`。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 +`open(id, 'read')` 返回一个句柄,其 `read(offset?, length?)` 提供经过验证的连续切片;产物在有界稳定读取下按需重新扫描,因此切片绝不包含撕裂尾部。撕裂的最终 Zstandard 帧会被部分解码:其中已刷入的完整 JSONL 记录被恢复进逻辑日志,写句柄的第一次修改会截掉撕裂字节并在自己的批次之前持久重写这些恢复的记录。`open(id, 'write')` 会用已验证的存储前缀预热句柄,因此恢复的全日志读取无需在第一次 append 之前再解析一遍。一个按会话 id 与 stat 派生修订号作键的有界 memo 让紧随其后的 open 复用已解析日志——冷的观察后恢复交接只解析一次——任何本地修改都会使该 id 失效。`stat(id)` 与 `list()` 只读取 header 行并执行一次 `fs.stat`,携带 `sizeBytes` 与尽力而为的、由 stat 派生的修订号(device、inode、size、纳秒时间戳),而不解析日志。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 ----- @@ -86,7 +86,7 @@ kind: "package-reference" ### 设计理念 -该后端是共享 [PersistenceCoordinator](../session-persistence/README.zh.md#understand-the-implementation) 之上的一层薄存储:它加载已存储记录、追加批次、提交修复,并把生命周期编排委托给协调器。其物理身份是文件修订值:device、inode、size 与纳秒时间戳标识一份日志,并在追加或修复后改变,这正是 `listSnapshots` 与保留准备结果校验所使用的身份。 +该后端拥有自己完整的存储运行时(`src/storage.ts`):`JsonlSessionHandle` 承载逐句柄修改链、带固定批处理窗口与 single-flight 排空的已路由实时事件缓冲、单调读取与幂等 close;一个 tracker 持有进程内单写者认领、teardown 清扫所遍历的打开句柄集合,以及后端自己的会话监听器所路由进的已创建但未实体化待定会话。本包有意只暴露默认插件导出与配置类型——具体类不是具名导出,因此消费方只耦合 `ctx.sessionPersistence`,其可观察行为由共享 seam 测试套件(`runPersistenceContract`/`runLiveWritePathContract`)钉住。其变更令牌是尽力而为的文件修订值:device、inode、size 与纳秒时间戳标识一份日志,供 `stat`/`list` 以及在并发 append 撕裂读取时重试的稳定读取循环使用。 ### 物理编码 @@ -96,7 +96,8 @@ kind: "package-reference" | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、后端类、协调器接线 | +| [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、后端服务类与文件存储原语 | +| [`src/storage.ts`](src/storage.ts) | JSONL 句柄、已路由实时事件缓冲、进程内写入者记账、监听器、teardown | | [`src/format.ts`](src/format.ts) | 日志路径派生、header 编码、记录扫描、打包行布局 | | [`src/zstd.ts`](src/zstd.ts) | Zstandard 帧压缩、解码与帧扫描 | | [`src/win32.ts`](src/win32.ts) | Windows write-through 发布与目录创建 | @@ -146,7 +147,7 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope - **平铺文件存储布局不加载**——加载前使用独立根,或将预发布产物移入项目/会话目录布局。 - **压缩文件不能直接按行读取**——使用后端加载;或在写入新根前选择 `compression: 'none'`,供外部行读取方使用。 - **不删除会话文件**——日志在 `root` 下累积,直到外部移除;seam 无删除接口。 -- **每会话一个活动写入方**——append 与修复只在所属后端实例内协调;在该所有者达到完全停稳的 dispose 前,另一实例或进程不得写入同一会话。 +- **每会话一个活动写入方,仅限进程内**——写句柄认领只在所属后端实例内排除第二个写入方;在该句柄关闭前,另一实例或进程不得写入同一会话(持久的跨进程租约是该 seam 计划中的下一层)。 - **POSIX 实体化需要硬链接支持**——第一次 append 使用 `link()`,使同 id 竞态失败而不覆盖已提交日志;Windows 使用无替换 write-through rename。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 8ccebf0be0..32b6b01295 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -1,8 +1,8 @@ /** * JSONL durable session-persistence backend. It stores a header and contiguous - * events in one append-only file per session, and delegates orchestration to - * {@link PersistenceCoordinator}. Its side-effect-free locator returns the - * absolute per-session log target before materialization. + * events in one append-only file per session and serves the handle-based + * `SessionPersistence` API: `create`/`open` return per-session handles, and + * every read validates the same fail-closed storage contract. * @module @deepseek-ai/dsh-session-persistence-jsonl */ @@ -15,23 +15,19 @@ import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' import { randomBytes } from 'node:crypto' import { - DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, - type BorrowedSessionSource, - type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionEventSuffix, type SessionInspection, - type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, - type SessionStorageMetadata, - type StoredPrefix, + SessionPersistence, SessionPersistenceRevision, SessionFormatUnsupportedError, + SessionPersistenceCorruptionError, + SessionAlreadyExistsError, SessionPersistenceNotFoundError, + assertStoredId, assertVersion, materializeCreateHeader, validateStoredEvents, + type SessionAccess, type SessionHandle, + type SessionLocation, type SessionPersistenceCreateOptions, + type SessionPersistenceListOptions, type SessionPersistenceOpenOptions, + type SessionPersistenceSnapshot, type SessionPersistenceStatOptions, + type SessionPersistenceRevision as PersistenceRevision, } from '@deepseek-ai/dsh-session-persistence' -import type { - Session, - SessionEvent, - SessionId, - SessionHeader, - SessionLogOffset, - SessionPreparation, -} from '@deepseek-ai/dsh-session' +import { JsonlBackendTracker, JsonlSessionHandle } from './storage.ts' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import { encodeSegment, eventLines, logPath, logSuffix, parseHeader, parseHeaderMeta, projectDir, scanLog, sessionDir, SessionLogScanner, toHeaderLine, @@ -44,6 +40,13 @@ import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts' export type { JsonlCompression } from './format.ts' +/** + * Internal handoff-reuse policy, not deployment configuration: a cold + * observation and the resume that immediately follows it reuse one parsed + * log, so the memo only needs the sessions in flight between those steps. + */ +const COLD_LOG_MEMO_MAX_ENTRIES = 2 + const DEFAULT_PACK_CHUNKS = true const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' /** @@ -86,16 +89,19 @@ export interface Config { packChunks?: boolean /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression - /** Maximum cold Session preparations retained for history-to-resume reuse. */ - preparedSessionCacheSize?: number - /** Fixed live-event coalescing window; not a backend completion deadline. */ - writeBatchMaxDelayMs?: number } -/** Opaque coordinator token for replacing bytes recovered from a torn frame. */ -interface JsonlTornMarker { - truncateTo: number - recoveredEvents: SessionEvent[] +/** A parsed, validated stored log: header, logical events, and any torn-tail repair state. */ +interface StoredLog { + readonly meta: SessionHeader + /** The logical log, including any events recovered from a torn final frame. */ + readonly events: SessionEvent[] + readonly tornTruncateTo: number | undefined + /** Complete events recovered from the torn final frame; the write path rewrites them durably. */ + readonly recoveredTail: SessionEvent[] + /** Exact fork-inherited prefix length stored in the header line. */ + readonly inheritedEventCount: SessionLogOffsetType + readonly revision: PersistenceRevision } interface FileRevisionIdentity { @@ -106,7 +112,7 @@ interface FileRevisionIdentity { readonly ctimeNs: bigint } -/** Build the source-qualified revision shared by full and lightweight reads. */ +/** Build the stat-derived best-effort change token shared by full and lightweight reads. */ function fileRevision(identity: FileRevisionIdentity): PersistenceRevision { return SessionPersistenceRevision([ identity.dev, @@ -124,250 +130,438 @@ function isENOENT(error: unknown): boolean { /** * The JSONL persistence backend. Load as a plugin; it registers as - * `ctx.sessionPersistence` and (via the coordinator) installs the write-path - * listeners. Its torn-tail marker carries the byte offset and any events - * recovered from an incomplete final Zstandard frame. + * `ctx.sessionPersistence`. Sessions materialize lazily: a created session is + * visible to this process immediately, reaches disk on its first append or + * flush, and never existed if the process crashes before that. */ -export class JsonlSessionPersistence extends SessionPersistence implements PersistenceBackend { - override readonly supportsRawArtifacts = true - - static inject = ['sessions'] - +class JsonlSessionPersistence extends SessionPersistence { static Config: z = z.object({ root: z.string().required(), packChunks: z.boolean().default(DEFAULT_PACK_CHUNKS), compression: JsonlCompressionSchema, - preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE), - writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS) - .default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS), }) - /** - * Backend label for coordinator diagnostics and effects. It shadows - * `Service.name` without changing the service key captured by the base - * constructor. - */ + /** Backend label for diagnostics and effects; shadows `Service.name` without changing the service key. */ override readonly name = 'session-persistence-jsonl' private root: string private packChunks: boolean private compression: JsonlCompression - private coordinator: PersistenceCoordinator private rootEncodingCheck: Promise | undefined + private readonly tracker = new JsonlBackendTracker(this.name) + /** + * Bounded LRU of parsed, validated stored logs keyed by session id and + * guarded by the stat-derived revision, so an immediate cold-read handoff + * (observation then resume) parses the artifact once. Every local mutation + * for an id invalidates its entry; a foreign write misses through the + * revision guard. + */ + private readonly coldLogMemo = new Map() constructor(ctx: Context, public config: Config) { super(ctx) // Resolve once so later process.cwd() changes cannot split one backend across roots. this.root = resolve(config.root) - // Programmatic wrappers may construct the backend without Schemastery normalization. - const preparedSessionCacheSize = config.preparedSessionCacheSize - ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE - const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs - ?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS this.packChunks = config.packChunks ?? DEFAULT_PACK_CHUNKS this.compression = config.compression ?? DEFAULT_COMPRESSION this.assertUsableRoot() - this.coordinator = new PersistenceCoordinator(this.ctx, this, { - preparedSessionCacheSize, - writeBatchMaxDelayMs, - }) - } - - // Each backend keeps the typed service API beside its storage hooks; - // extracting these trivial forwards would add an inheritance layer. - /* jscpd:ignore-start */ - // --- SessionPersistence service API (delegated to the coordinator) --- - - /** Resolve the absolute target path without touching the filesystem. */ - locate(meta: SessionHeader): SessionLocation { - return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id, this.compression) } - } - - create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise { - return this.coordinator.create(meta, inheritedEventCount) - } - - override ensureMaterialized(session: Session): Promise { - return this.coordinator.ensureMaterialized(session) - } - - append(id: SessionId, events: readonly SessionEvent[]): Promise { - return this.coordinator.append(id, events) - } - - override prepare(id: SessionId, signal?: AbortSignal): Promise { - return this.coordinator.prepare(id, signal) - } - - load(id: SessionId): Promise { - return this.coordinator.load(id) - } - - inspect(id: SessionId, signal?: AbortSignal): Promise { - return this.coordinator.inspect(id, signal) - } - - override borrowSession(id: SessionId, signal?: AbortSignal): Promise { - return this.coordinator.borrowSession(id, signal) - } - - // JSONL is sequential media: no loadStoredFrom hook, so the coordinator - // parses the stored prefix (both encodings) and skips forward to fromSeq. - readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise { - return this.coordinator.readFrom(id, fromSeq, signal) - } - - // One method serves both public `list` and the backend hook; delegating it to - // the coordinator would call this hook recursively. - - /* jscpd:ignore-end */ - // --- PersistenceBackend hooks (the file-bytes storage primitives) --- - - /** Read a stored prefix by id across all project directories when cwd is unknown. */ - async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { - signal?.throwIfAborted() - await this.ensureRootEncoding() - signal?.throwIfAborted() - const path = await this.findLog(id, signal) - if (path === undefined) return undefined - return this.readPrefix(path, id, signal) + this.tracker.install(ctx) } /** - * Read one log's stat-derived revision without loading its event bytes. - * Resolving an id with unknown cwd still scans the project directories. + * Refusal-diagnostics hook: the absolute target path, without touching the filesystem. + * @param meta - the stored header naming the session and its cwd. + * @returns the artifact kind and absolute path. */ - async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise { - signal?.throwIfAborted() + private locate(meta: SessionHeader): SessionLocation { + return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id, this.compression) } + } + + // --- SessionPersistence service API --- + + /** + * Create a new stored session and take its write ownership. The session is + * visible to this process immediately; the physical artifact appears on the + * first append or flush. + * @param header - the immutable header to store; must be losslessly + * JSON-serializable with a non-negative safe-integer `createdAt`. + * @param options - optional cancellation. + * @returns the owned write handle. + */ + async create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise { + options?.signal?.throwIfAborted() + const snapshot = materializeCreateHeader(header) + // Fail fast on a seeded/cut mismatch with the exact refusal the header + // line encoder enforces at materialization. + toHeaderLine(snapshot, options?.inheritedEventCount) + const inheritedEventCount = SessionLogOffset(options?.inheritedEventCount ?? 0) await this.ensureRootEncoding() - signal?.throwIfAborted() - const path = await this.findLog(id, signal) + options?.signal?.throwIfAborted() + if (this.tracker.hasPending(snapshot.id) || await this.findLog(snapshot.id, options?.signal) !== undefined) { + throw new SessionAlreadyExistsError(snapshot.id) + } + options?.signal?.throwIfAborted() + this.tracker.registerCreated(snapshot, inheritedEventCount) + return this.tracker.adopt(new JsonlSessionHandle(this, snapshot.id, snapshot, 'write', { cursor: 0, materialized: false, inheritedEventCount })) + } + + /** + * Open an existing stored session for `read` or single-writer `write`. + * @param id - the stored session to open. + * @param access - `read` (no ownership) or `write` (atomic in-process claim). + * @param options - optional cancellation. + * @returns the open handle. + */ + async open(id: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions): Promise { + options?.signal?.throwIfAborted() + await this.ensureRootEncoding() + options?.signal?.throwIfAborted() + const pending = this.tracker.pendingOf(id) + if (access === 'read') { + if (pending !== undefined) { + return this.tracker.adopt(new JsonlSessionHandle(this, id, pending.header, 'read', { cursor: 0, materialized: false, inheritedEventCount: pending.inheritedEventCount })) + } + // Header-only existence and identity check: reads scan the log on demand. + const snapshot = await this.stat(id, options) + if (snapshot === undefined) { + // stat() reports an artifact whose header it cannot parse as absent so + // listing can skip foreign junk, but a physically present log must + // refuse loudly here — a newer-format log's user must see "upgrade the + // harness", never "not found". + const stored = await this.requireStoredLog(id, options?.signal) + // The full read succeeded after the header-only read failed (a writer + // completed the header in between): serve the session. + return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'read', { cursor: 0, materialized: true, inheritedEventCount: stored.inheritedEventCount })) + } + // A stored foreign format version is refused at open, matching the + // refusal every read of this handle would produce. + assertVersion(snapshot.header, this.locate(snapshot.header)) + return this.tracker.adopt(new JsonlSessionHandle(this, id, snapshot.header, 'read', { cursor: 0, materialized: true, inheritedEventCount: snapshot.inheritedEventCount })) + } + // A pending entry always belongs to an ACTIVE creator handle (close erases + // it), so the claim below rejects that case as already owned. + this.tracker.claimWrite(id) + try { + const stored = await this.requireStoredLog(id, options?.signal) + return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'write', { + cursor: stored.events.length, + materialized: true, + tornTruncateTo: stored.tornTruncateTo, + recoveredTail: stored.recoveredTail, + inheritedEventCount: stored.inheritedEventCount, + primed: stored.events, + })) + } catch (error) { + this.tracker.releaseClaim(id) + throw error + } + } + + /** + * Flush every active write handle in one durability barrier; see the seam + * contract. + * @returns resolution once every write handle active at the call has flushed. + */ + flush(): Promise { + return this.tracker.flushAll() + } + + /** + * Observe one stored session without reading its event log. + * @param id - the stored session to observe. + * @param options - optional cancellation. + * @returns the snapshot (`sizeBytes` carries the physical artifact size), or + * `undefined` when the session does not exist. + */ + async stat( + id: SessionId, + options?: SessionPersistenceStatOptions, + ): Promise<(SessionPersistenceSnapshot & { inheritedEventCount: SessionLogOffsetType }) | undefined> { + options?.signal?.throwIfAborted() + await this.ensureRootEncoding() + options?.signal?.throwIfAborted() + const pending = this.tracker.pendingOf(id) + if (pending !== undefined) { + return { header: pending.header, revision: pending.revision, inheritedEventCount: pending.inheritedEventCount } + } + const path = await this.findLog(id, options?.signal) if (path === undefined) return undefined + let first: string | undefined + try { + first = this.compression === 'zstd' + ? await this.readFirstZstdLine(path, options?.signal) + : await this.readFirstLine(path, options?.signal) + } catch (error: unknown) { + options?.signal?.throwIfAborted() + // The artifact vanished between discovery and the header read: absent. + if (isENOENT(error)) return undefined + throw error + } + options?.signal?.throwIfAborted() + if (first === undefined) return undefined // empty/half-written file + let stored + try { + stored = parseHeader(first) + } catch (error: unknown) { + // A foreign-version refusal from the raw header line names the artifact + // it refused, matching the refusal a full read would produce. + if (error instanceof SessionFormatUnsupportedError) { + throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path }) + } + throw error + } + if (stored === undefined) return undefined + const meta = stored.meta + await this.assertStoredIdentity(path, meta, id, options?.signal) try { const identity = await stat(path, { bigint: true }) - signal?.throwIfAborted() - return fileRevision(identity) + options?.signal?.throwIfAborted() + return { + header: meta, + revision: fileRevision(identity), + sizeBytes: Number(identity.size), + inheritedEventCount: stored.inheritedEventCount, + } } catch (error: unknown) { - signal?.throwIfAborted() + options?.signal?.throwIfAborted() if (isENOENT(error)) return undefined throw error } } /** - * Read a session's stored artifact text verbatim: the durable file bytes - * decoded from this backend's physical encoding (complete zstd frames - * concatenated, or UTF-8 plaintext). The content is the exact JSONL text the - * backend wrote — never a reconstruction from parsed events — so packed- - * chunk rows, key order, and line breaks survive byte-for-byte. A torn - * final frame is omitted, matching the committed-prefix semantics of every - * other read. - * @param id - the persisted session to read. - * @param signal - optional cancellation for the stat/read/decode work. - * @returns the raw artifact text plus the header parsed from its own first - * line, or `undefined` when the session has no stored artifact. + * List every stored session visible to this process: materialized artifacts + * plus this process's created-but-unmaterialized sessions. + * @param options - optional cancellation. + * @returns one snapshot per session, in no promised order. */ - override async readRaw(id: SessionId, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - await this.ensureRootEncoding() - signal?.throwIfAborted() - const path = await this.findLog(id, signal) - if (path === undefined) return undefined - const { buffer } = await this.readStableFile(path, signal) - let content: string - if (this.compression === 'zstd') { - const { frames } = scanZstdFrames(buffer) - if (frames.length === 0) throw new Error('empty or header-less Zstandard session log') - const decoder = createZstdFrameDecoder() - const plaintexts: Buffer[] = [] - // The decoder yields views into a reused buffer; copy each frame's - // plaintext immediately so a later concat cannot read overwritten memory. - for (const plaintext of decoder.decode(buffer, frames)) { + async list(options?: SessionPersistenceListOptions): Promise { + const signal = options?.signal + const snapshots: SessionPersistenceSnapshot[] = [] + const listed = new Set() + // Snapshot pending entries BEFORE scanning storage: a session whose first + // append lands mid-scan is then still in this snapshot (its artifact may + // predate the scan), so create-to-list visibility never has a hole. + const pending = [...this.tracker.pendingEntries()] + for (const artifact of await this.listArtifacts(signal)) { + signal?.throwIfAborted() + try { + const identity = await stat(artifact.path, { bigint: true }) signal?.throwIfAborted() - plaintexts.push(Buffer.from(plaintext)) + listed.add(artifact.header.id) + snapshots.push({ + header: artifact.header, + revision: fileRevision(identity), + sizeBytes: Number(identity.size), + }) + } catch (error: unknown) { + signal?.throwIfAborted() + if (!isENOENT(error)) throw error } - content = Buffer.concat(plaintexts).toString('utf8') - } else { - content = buffer.toString('utf8') } - const storage = parseHeader(content.split('\n', 1)[0] as string) - if (storage === undefined || storage.meta.id !== id) { - throw new Error(`corrupt session log: invalid header line in "${path}"`) + for (const [id, entry] of pending) { + if (!listed.has(id)) snapshots.push({ header: entry.header, revision: entry.revision }) } - // The logical artifact name is `session.jsonl` regardless of the physical - // encoding suffix (`.jsonl.zstd` marks compression only). - return { ...storage, filename: 'session.jsonl', content } + signal?.throwIfAborted() + return snapshots + } + + // --- handle-facing storage internals (package-private via the handle class below) --- + + /** Resolve and read one stored log, refusing loudly when the artifact is absent. */ + private async requireStoredLog(id: SessionId, signal?: AbortSignal): Promise { + const path = await this.findLog(id, signal) + if (path === undefined) throw new SessionPersistenceNotFoundError(id) + return this.readStoredLog(path, id, signal) } /** - * Read a file's bytes under a revision-stable loop: a writer appending - * between stat and readFile would yield a torn physical file, so retry - * while the stat revision changes. + * Read, parse, and validate one stored log as the current logical prefix. + * @param path - the artifact file to read. + * @param expectedId - the session identity the artifact must carry. + * @param signal - optional cancellation for the stat/read/decode work. + * @returns the validated stored log with any torn-tail truncation point. + */ + async readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const probe = fileRevision(await stat(path, { bigint: true })) + const memoized = this.coldLogMemo.get(expectedId) + if (memoized !== undefined && memoized.revision === probe) { + this.coldLogMemo.delete(expectedId) + this.coldLogMemo.set(expectedId, memoized) + return memoized + } + const { buffer, revision } = await this.readStableFile(path, signal) + let parsed: { + meta: SessionHeader + inheritedEventCount: SessionLogOffsetType + events: SessionEvent[] + tornTruncateTo: number | undefined + recoveredTail: SessionEvent[] + } + try { + if (this.compression === 'zstd') { + parsed = await this.readZstdPrefix(buffer, signal) + } else { + signal?.throwIfAborted() + const { meta, inheritedEventCount, events, committedBytes } = scanLog(buffer) + signal?.throwIfAborted() + parsed = { + meta, + inheritedEventCount, + events, + tornTruncateTo: committedBytes < buffer.byteLength ? committedBytes : undefined, + // A torn raw tail is one incomplete JSONL line; it holds no complete + // record to recover. + recoveredTail: [], + } + } + } catch (error: unknown) { + signal?.throwIfAborted() + // A parse-time format refusal predates any SessionHeader, so attach the + // artifact this read actually refused; every other parse failure is + // committed bytes the decoder cannot interpret — damage, classified for + // the seam's stable error vocabulary. + if (error instanceof SessionFormatUnsupportedError) { + throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path }) + } + throw new SessionPersistenceCorruptionError(`session "${expectedId}": stored log is corrupt: ${String(error)} (raw log: ${path})`, { cause: error }) + } + signal?.throwIfAborted() + await this.assertStoredIdentity(path, parsed.meta, expectedId, signal) + signal?.throwIfAborted() + assertStoredId(expectedId, parsed.meta) + const location = this.locate(parsed.meta) + assertVersion(parsed.meta, location) + validateStoredEvents(parsed.meta, parsed.events, location) + const stored: StoredLog = { ...parsed, revision } + this.coldLogMemo.delete(expectedId) + this.coldLogMemo.set(expectedId, stored) + for (const oldest of this.coldLogMemo.keys()) { + if (this.coldLogMemo.size <= COLD_LOG_MEMO_MAX_ENTRIES) break + this.coldLogMemo.delete(oldest) + } + return stored + } + + /** + * Resolve a session's unique log path. + * @param id - the stored session to locate. + * @param signal - optional cancellation for the directory scans. + * @returns the artifact path, or `undefined` when absent. + */ + async resolveLog(id: SessionId, signal?: AbortSignal): Promise { + await this.ensureRootEncoding() + signal?.throwIfAborted() + return this.findLog(id, signal) + } + + /** + * Durably append one validated batch; lazily materializes on the first write. + * @param header - the session's stored header. + * @param events - the validated contiguous batch, in seq order. + * @param isMaterialized - whether the session already has a durable artifact. + * @param inheritedEventCount - the exact fork-inherited prefix length written into a materializing header line. + */ + async persistBatch( + header: SessionHeader, + events: readonly SessionEvent[], + isMaterialized: boolean, + inheritedEventCount: SessionLogOffsetType, + ): Promise { + this.coldLogMemo.delete(header.id) + await this.ensureRootEncoding() + if (isMaterialized) { + await this.appendLines(header, events) + } else { + await this.materialize(header, inheritedEventCount, events) + this.tracker.materialized(header.id) + } + } + + /** + * Materialize a header-only artifact for an explicitly durable empty session. + * @param header - the session's stored header. + * @param inheritedEventCount - the exact fork-inherited prefix length written into the header line. + */ + async persistHeader(header: SessionHeader, inheritedEventCount: SessionLogOffsetType): Promise { + this.coldLogMemo.delete(header.id) + await this.ensureRootEncoding() + await this.materialize(header, inheritedEventCount, []) + this.tracker.materialized(header.id) + } + + /** + * Truncate a torn physical tail durably before this session's first new append. + * @param header - the session's stored header. + * @param truncateTo - the byte offset the artifact is truncated to. + */ + async truncateTornTail(header: SessionHeader, truncateTo: number): Promise { + this.coldLogMemo.delete(header.id) + await this.repair(header, truncateTo) + this.ctx.logger.warn(`${this.name}: session "${header.id}" recovered from a torn tail; incomplete tail bytes were discarded`) + } + + /** + * Whether this process still tracks a created-but-unmaterialized session. + * @param id - the session to test. + * @returns true while the pending entry exists. + */ + hasPendingSession(id: SessionId): boolean { + return this.tracker.hasPending(id) + } + + /** + * Release one handle's backend bookkeeping on close. + * @param handle - the closing handle. + * @param materialized - whether the session reached durable storage. + */ + releaseHandle(handle: JsonlSessionHandle, materialized: boolean): void { + this.tracker.release(handle, materialized) + } + + /** + * Read a file's bytes with one bounded stability retry: a writer appending + * between stat and readFile yields a torn read, so a changed revision + * triggers exactly one re-read. A second change does not loop — the log is + * append-only, so the bytes at the retry's own pre-read stat size are a + * committed prefix, and the decoders treat anything past a torn cut as + * unwritten. A continuous writer therefore delays a read by at most one + * extra whole-file read instead of starving it. * @param path - the artifact file to read. * @param signal - optional cancellation for the stat/read work. - * @returns the stable bytes and the revision that matched both stats. + * @returns the stable bytes (or the committed prefix) and their revision. */ private async readStableFile( path: string, signal?: AbortSignal, ): Promise<{ buffer: Buffer; revision: PersistenceRevision }> { - for (;;) { - signal?.throwIfAborted() - const before = fileRevision(await stat(path, { bigint: true })) + signal?.throwIfAborted() + let identity = await stat(path, { bigint: true }) + for (let attempt = 0; ; attempt += 1) { + const before = fileRevision(identity) const buffer = await readFile(path, { signal }) signal?.throwIfAborted() - const after = fileRevision(await stat(path, { bigint: true })) - if (before === after) return { buffer, revision: after } - } - } - - /** - * Read a stored prefix and convert torn-tail state to the opaque marker the - * coordinator can round-trip without knowing the physical encoding. - */ - private async readPrefix( - path: string, - expectedId?: SessionId, - signal?: AbortSignal, - ): Promise> { - const { buffer, revision } = await this.readStableFile(path, signal) - let prefix: Omit, 'revision'> - try { - if (this.compression === 'zstd') { - prefix = await this.readZstdPrefix(buffer, signal) - } else { - signal?.throwIfAborted() - const { meta, inheritedEventCount, events, committedBytes } = scanLog(buffer) - signal?.throwIfAborted() - prefix = { - meta, - inheritedEventCount, - events, - ...committedBytes < buffer.byteLength - ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } - : {}, - } + const after = await stat(path, { bigint: true }) + if (before === fileRevision(after)) return { buffer, revision: before } + if (attempt === 1) { + return { buffer: buffer.subarray(0, Number(identity.size)), revision: before } } - } catch (error: unknown) { - // A parse-time format refusal predates any SessionHeader, so the - // coordinator's locate-based enrichment cannot run; attach the artifact - // this read actually refused. - if (error instanceof SessionFormatUnsupportedError && error.location === undefined) { - throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path }) - } - throw error + identity = after } - signal?.throwIfAborted() - await this.assertStoredIdentity(path, prefix.meta, expectedId, signal) - signal?.throwIfAborted() - return { ...prefix, revision } } /** Decode complete frames and retain complete JSONL records from a torn final frame. */ private async readZstdPrefix( buffer: Buffer, signal?: AbortSignal, - ): Promise, 'revision'>> { + ): Promise<{ + meta: SessionHeader + inheritedEventCount: SessionLogOffsetType + events: SessionEvent[] + tornTruncateTo: number | undefined + recoveredTail: SessionEvent[] + }> { signal?.throwIfAborted() const { frames, tornStart } = scanZstdFrames(buffer) signal?.throwIfAborted() @@ -407,9 +601,13 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi meta: prefix.meta, inheritedEventCount: prefix.inheritedEventCount, events: prefix.events, + tornTruncateTo: undefined, + recoveredTail: [], } } - + // A torn final frame's append never resolved, but complete JSONL records + // already flushed into it are real emitted events: recover them, and let + // the write path truncate the torn bytes and rewrite them durably. let recoveredPlaintext: Buffer = Buffer.alloc(0) try { signal?.throwIfAborted() @@ -417,21 +615,18 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } catch { /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ if (signal?.aborted) signal.throwIfAborted() - // A structurally incomplete final frame may end before Node's decoder can - // emit any plaintext; the complete prior frames remain recoverable. + // A structurally incomplete final frame may end before Node's decoder + // can emit any plaintext; the complete prior frames remain recoverable. } signal?.throwIfAborted() scanner.write(recoveredPlaintext) - const recoveredPrefix = scanner.finish() - signal?.throwIfAborted() + const prefix = scanner.finish() return { - meta: recoveredPrefix.meta, - inheritedEventCount: recoveredPrefix.inheritedEventCount, - events: recoveredPrefix.events, - tornMarker: { - truncateTo: tornStart, - recoveredEvents: recoveredPrefix.events.slice(complete.eventCount), - }, + meta: prefix.meta, + inheritedEventCount: prefix.inheritedEventCount, + events: prefix.events, + tornTruncateTo: tornStart, + recoveredTail: prefix.events.slice(complete.eventCount), } } catch (error) { /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ @@ -442,68 +637,6 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } } - /** Durably append a batch, lazily materializing the file when not yet present. */ - async appendBatch( - storage: SessionStorageMetadata, - events: readonly SessionEvent[], - isMaterialized: boolean, - ): Promise { - await this.ensureRootEncoding() - if (isMaterialized) { - await this.appendLines(storage.meta, events) - } else { - await this.materialize(storage, events) - } - } - - /** Materialize a header-only JSONL artifact for an explicitly durable empty session. */ - async materializeHeader(storage: SessionStorageMetadata): Promise { - await this.materialize(storage, []) - } - - /** - * Make a crash repair durable: truncate a torn tail, restore complete events - * decoded from it, then append synthetic closers. Two fsync'd steps — the seam - * does not require this to be atomic. - */ - async commitRepair( - storage: SessionStorageMetadata, - tornMarker: JsonlTornMarker | undefined, - closers: readonly SessionEvent[], - ): Promise { - const { meta } = storage - if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) - const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] - if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) - if (tornMarker !== undefined) this.ctx.logger.warn(`${this.name}: session "${meta.id}" recovered from a torn tail; incomplete tail bytes were discarded`) - } - - /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ - async list(signal?: AbortSignal): Promise { - return (await this.listArtifacts(signal)).map(artifact => artifact.header) - } - - /** List metadata plus a stat-derived identity for each append-only log. */ - async listSnapshots(signal?: AbortSignal): Promise { - const snapshots: SessionPersistenceSnapshot[] = [] - for (const artifact of await this.listArtifacts(signal)) { - signal?.throwIfAborted() - try { - const identity = await stat(artifact.path, { bigint: true }) - signal?.throwIfAborted() - snapshots.push({ - header: artifact.header, - revision: fileRevision(identity), - }) - } catch (error: unknown) { - signal?.throwIfAborted() - if (!isENOENT(error)) throw error - } - } - signal?.throwIfAborted() - return snapshots - } - private async listArtifacts(signal?: AbortSignal): Promise> { signal?.throwIfAborted() await this.ensureRootEncoding() @@ -528,7 +661,15 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi : await this.readFirstLine(path, signal) signal?.throwIfAborted() if (first === undefined) continue // empty/half-written file - const meta = parseHeaderMeta(first) + let meta: SessionHeader | undefined + try { + meta = parseHeaderMeta(first) + } catch (error: unknown) { + // Listing skips an unreadable (foreign-version) header instead of + // failing the whole list; opening that id still refuses loudly. + if (error instanceof SessionFormatUnsupportedError) continue + throw error + } if (meta === undefined) continue // not a session header await this.assertStoredIdentity(path, meta, undefined, signal) signal?.throwIfAborted() @@ -546,13 +687,16 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi // --- materialization / append / repair (file mechanics) --- /** Atomically write the header line + first batch (temp-write, fsync, publish). */ - private async materialize(storage: SessionStorageMetadata, events: readonly SessionEvent[]): Promise { - const { meta } = storage + private async materialize( + meta: SessionHeader, + inheritedEventCount: SessionLogOffsetType, + events: readonly SessionEvent[], + ): Promise { const project = projectDir(this.root, meta.cwd) const dir = sessionDir(this.root, meta.cwd, meta.id) const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression) await this.rejectOppositeArtifact(meta.cwd, meta.id) - const content = await this.encodeMaterialization(storage, events) + const content = await this.encodeMaterialization(meta, inheritedEventCount, events) /* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */ if (process.platform === 'win32') { await this.materializeWin32(project, dir, finalPath, meta.id, content) @@ -630,12 +774,12 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi private async rejectExistingLog(finalPath: string, id: SessionId): Promise { // Never publish over an existing committed log: materialize is the first // write of a session the backend believes is new. A file here means a - // different session shares this id on disk — reject loudly. (createCore - // already guards the create path, so this is unreachable-in-practice TOCTOU + // different session shares this id on disk — reject loudly. (create already + // guards the create path, so this is unreachable-in-practice TOCTOU // defense.) - /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */ + /* v8 ignore next 3 -- create guards collisions before materialize; this is a TOCTOU backstop */ if (await this.exists(finalPath)) { - throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`) + throw new Error(`refusing to materialize "${id}": a log already exists on disk (open it instead)`) } } @@ -653,10 +797,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi /** Encode the header and first batch without combining their frame boundaries. */ private async encodeMaterialization( - storage: SessionStorageMetadata, + meta: SessionHeader, + inheritedEventCount: SessionLogOffsetType, events: readonly SessionEvent[], ): Promise { - const header = JSON.stringify(toHeaderLine(storage.meta, storage.inheritedEventCount)) + '\n' + const header = JSON.stringify(toHeaderLine(meta, meta.isSeeded ? inheritedEventCount : undefined)) + '\n' if (events.length === 0) { return this.compression === 'none' ? header : compressZstdFrame(header) } @@ -1009,4 +1154,10 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi /* v8 ignore stop */ } +/** + * One open channel onto a JSONL-stored session: the shared storage-handle + * scaffolding over this backend's file primitives. Reads re-scan the artifact + * under the stable-read loop. + */ + export default JsonlSessionPersistence diff --git a/packages/session/session-persistence-jsonl/src/storage.ts b/packages/session/session-persistence-jsonl/src/storage.ts new file mode 100644 index 0000000000..936355260f --- /dev/null +++ b/packages/session/session-persistence-jsonl/src/storage.ts @@ -0,0 +1,497 @@ +/** + * The JSONL provider's session storage runtime: its concrete write/read + * handle with a per-handle mutation chain and a routed live write-behind + * buffer, the in-process bookkeeping that enforces one active writer per + * session id, and the backend's live event routing and teardown. Deliberately + * provider-local: the persistence seam exposes only the service and handle + * contracts, and the shared contract suites pin equivalent observable + * behavior across providers. + * @module + */ + +import type { Context } from '@deepseek-ai/cordis' +import { errorChain } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import { + assertContiguous, + materializeAppendBatch, + SessionAlreadyExistsError, + SessionAlreadyOwnedError, + SessionHandleClosedError, + SessionPersistenceNotFoundError, + SessionPersistenceRevision, + SessionReadOnlyError, +} from '@deepseek-ai/dsh-session-persistence' +import type { + SessionAccess, + SessionHandle, + SessionHandleAppendOptions, + SessionHandleFlushOptions, + SessionHandleReadOptions, +} from '@deepseek-ai/dsh-session-persistence' + +/** Maximum intentional wait before a routed live session batch starts writing. */ +export const LIVE_WRITE_BATCH_MAX_DELAY_MS = 200 + +/** The file-storage primitives the handle drives on its owning service. */ +export interface JsonlHandleStorage { + /** Append encoded lines; `isMaterialized` selects create-vs-extend publication. */ + persistBatch( + header: SessionHeader, + events: readonly SessionEvent[], + isMaterialized: boolean, + inheritedEventCount: SessionLogOffset, + ): Promise + /** Materialize the header-only artifact for an explicitly flushed empty session. */ + persistHeader(header: SessionHeader, inheritedEventCount: SessionLogOffset): Promise + /** Truncate a torn physical tail before the first new append lands. */ + truncateTornTail(header: SessionHeader, truncateTo: number): Promise + /** Resolve the session's artifact path, or `undefined` before materialization. */ + resolveLog(id: SessionId, signal?: AbortSignal): Promise + /** Read and validate the stored log at `path`. */ + readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<{ events: SessionEvent[] }> + /** Whether the id is still a created-but-unmaterialized session here. */ + hasPendingSession(id: SessionId): boolean + /** Drop the handle's bookkeeping on close. */ + releaseHandle(handle: JsonlSessionHandle, materialized: boolean): void +} + +/** Mutable per-handle log state; a write handle is its session's single mutator. */ +export interface StorageHandleState { + /** The stored next-seq (the logical end this handle knows). */ + cursor: number + /** Whether the session has a durable artifact yet. */ + materialized: boolean + /** Torn-tail truncation point, consumed by the first new append. */ + tornTruncateTo?: number | undefined + /** Complete events recovered from the torn final frame; the first mutation rewrites them durably. */ + recoveredTail?: SessionEvent[] | undefined + /** Exact fork-inherited prefix length stored with the log; `0` when unseeded. */ + inheritedEventCount: SessionLogOffset + /** The validated stored prefix from a write open, served to reads until the first append. */ + primed?: SessionEvent[] | undefined +} + +/** + * The JSONL session handle. Mutations serialize on a per-handle promise + * chain; reads re-scan the artifact on demand and never observe a shorter log + * than a prior read on this handle. Routed live events buffer in a bounded + * window and drain through the same chain as explicit appends. + */ +export class JsonlSessionHandle implements SessionHandle { + private chain: Promise = Promise.resolve() + private closing: Promise | undefined + private observedLength = 0 + /** Routed live events awaiting their batching deadline (persistence-owned copies). */ + private buffered: SessionEvent[] = [] + private batchTimer: ReturnType | undefined + /** Set when a drain failed; the automatic timer stays quiet until the next drain. */ + private drainPaused = false + private draining: Promise | undefined + + constructor( + private readonly storage: JsonlHandleStorage, + readonly id: SessionId, + readonly header: SessionHeader, + readonly access: SessionAccess, + private readonly state: StorageHandleState, + ) {} + + /** Exact fork-inherited prefix length stored with this session's log. */ + get inheritedEventCount(): SessionLogOffset { + return this.state.inheritedEventCount + } + + /** + * Read a slice of the valid contiguous logical log; see the seam contract. + * @param offset - first logical seq to include (default 0). + * @param length - maximum events returned (default: the rest). + * @param options - optional cancellation. + * @returns the requested slice. + */ + async read(offset = 0, length = Number.MAX_SAFE_INTEGER, options?: SessionHandleReadOptions): Promise { + // Closed-handle refusal precedes argument validation: a closed handle + // rejects SessionHandleClosedError regardless of the arguments. + this.assertOpen('read') + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new TypeError(`read offset must be a non-negative safe integer, got ${String(offset)}`) + } + if (!Number.isSafeInteger(length) || length < 0) { + throw new TypeError(`read length must be a non-negative safe integer, got ${String(length)}`) + } + options?.signal?.throwIfAborted() + if (this.state.primed !== undefined) { + this.observedLength = Math.max(this.observedLength, this.state.primed.length) + return this.state.primed.slice(offset, offset + length) + } + // A write handle knows its own materialization; a read handle asks the + // backend so a writer's later materialization becomes visible here. + if (this.access === 'write' && !this.state.materialized) return [] + const path = await this.storage.resolveLog(this.id, options?.signal) + if (path === undefined) { + if (this.storage.hasPendingSession(this.id)) return [] + throw new SessionPersistenceNotFoundError(this.id) + } + const { events } = await this.storage.readStoredLog(path, this.id, options?.signal) + if (events.length < this.observedLength) { + throw new Error(`session "${this.id}": stored log shrank below a previously observed prefix (${events.length} < ${this.observedLength})`) + } + this.observedLength = events.length + return events.slice(offset, offset + length) + } + + /** + * Durably append a contiguous batch; see the seam contract. + * @param events - the contiguous batch in seq order. + * @param options - optional cancellation observed before the write starts. + */ + async append(events: readonly SessionEvent[], options?: SessionHandleAppendOptions): Promise { + this.assertOpen('append') + // Validate and deep-snapshot the batch HERE, before queueing behind the + // chain, so the checked value is exactly the value persisted. + const batch = materializeAppendBatch(events) + return this.run('append', async () => { + options?.signal?.throwIfAborted() + await this.persistContiguous(batch) + }) + } + + /** + * Durability barrier; materializes the artifact when nothing has been + * appended yet, so an explicitly flushed empty session survives this process. + * @param options - optional cancellation observed before the barrier starts. + */ + flush(options?: SessionHandleFlushOptions): Promise { + return this.run('flush', async () => { + options?.signal?.throwIfAborted() + if (this.access !== 'write') throw new SessionReadOnlyError(this.id, 'flush') + if (this.state.materialized) return // appends are durable on resolution + await this.storage.persistHeader(this.header, this.state.inheritedEventCount) + this.state.materialized = true + }) + } + + /** + * Release the handle; see the seam contract. Idempotent and uncancellable. + * A write handle first drains its routed live buffer through the still-open + * storage, so backend teardown loses nothing regardless of which fiber + * unwinds first; a drain failure still releases ownership, then rejects. + * @returns settlement of the release. + */ + close(): Promise { + return this.closing ??= (async () => { + let drainFailure: unknown + // Producers on other fibers may still publish while close waits for + // in-flight mutations (root disposal is concurrent), so drain again + // until a full pass leaves the routed buffer empty. The chain never + // rejects because run() swallows each operation's rejection after its + // caller observed it. + for (;;) { + try { + await this.drainLive() + } catch (error: unknown) { + drainFailure = error + break + } + await this.chain + if (this.buffered.length === 0) break + } + // After a drain failure the chain may still hold in-flight mutations. + await this.chain + this.storage.releaseHandle(this, this.state.materialized) + if (drainFailure !== undefined) { + throw drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure)) + } + })() + } + + /** `await using` support: delegates to {@link close}. */ + [Symbol.asyncDispose](): Promise { + return this.close() + } + + /** + * Buffer one published live session event and arm the bounded batching + * window when it is idle. The routing installer is the only caller. + * @param event - the live event, retained as a persistence-owned copy. + * @param reportBackgroundFailure - observes a deadline-driven drain failure + * (the events stay buffered; the next {@link drainLive} retries loudly). + */ + enqueueLive(event: SessionEvent, reportBackgroundFailure: (error: unknown) => void): void { + this.buffered.push(structuredClone(event)) + if (this.batchTimer !== undefined || this.drainPaused) return + this.batchTimer = setTimeout(() => { + this.batchTimer = undefined + this.drainLive().catch(reportBackgroundFailure) + }, LIVE_WRITE_BATCH_MAX_DELAY_MS) + } + + /** + * Durably drain the routed live buffer through the mutation chain; + * concurrent callers join one drain, and a failure retains the batch in + * order so `session/flush` can retry and reject loudly. + */ + drainLive(): Promise { + return this.draining ??= this.drainBuffered().finally(() => { + this.draining = undefined + }) + } + + private async drainBuffered(): Promise { + if (this.batchTimer !== undefined) { + clearTimeout(this.batchTimer) + this.batchTimer = undefined + } + this.drainPaused = false + while (this.buffered.length > 0) { + // Capture inside the chained turn so events landing while an earlier + // batch writes coalesce into the next one, in order. + await this.enqueueChain(async () => { + // Only this single-flight drain splices the buffer, so the batch the + // while-guard saw is still here when the chained turn runs. + const batch = this.buffered.splice(0) + try { + await this.persistContiguous(materializeAppendBatch(batch)) + } catch (error: unknown) { + this.buffered = batch.concat(this.buffered) + this.drainPaused = true + throw error + } + }) + } + } + + /** The shared durable-append body: contiguity, torn-tail repair, storage write, state advance. */ + private async persistContiguous(batch: readonly SessionEvent[]): Promise { + if (this.access !== 'write') throw new SessionReadOnlyError(this.id, 'append') + if (batch.length === 0) return + assertContiguous(this.id, batch, this.state.cursor) + // Commit any pending torn-tail repair first, clearing each step's state + // only once it lands so a failed step retries on the next mutation: + // truncate the torn bytes, then durably rewrite the complete events + // recovered from them (already counted in the primed cursor). + if (this.state.tornTruncateTo !== undefined) { + await this.storage.truncateTornTail(this.header, this.state.tornTruncateTo) + this.state.tornTruncateTo = undefined + } + if (this.state.recoveredTail !== undefined) { + if (this.state.recoveredTail.length > 0) { + await this.storage.persistBatch(this.header, this.state.recoveredTail, this.state.materialized, this.state.inheritedEventCount) + } + this.state.recoveredTail = undefined + } + await this.storage.persistBatch(this.header, batch, this.state.materialized, this.state.inheritedEventCount) + this.state.materialized = true + this.state.cursor += batch.length + this.state.primed = undefined + this.observedLength = this.state.cursor + } + + /** Serialize one operation onto the chain without the closed-handle refusal (drain-from-close). */ + private enqueueChain(op: () => Promise): Promise { + const next = this.chain.then(op) + this.chain = next.catch(() => {}) + return next + } + + /** Serialize one public mutating operation onto this handle's chain. */ + private async run(operation: string, op: () => Promise): Promise { + this.assertOpen(operation) + return this.enqueueChain(async () => { + this.assertOpen(operation) + return op() + }) + } + + private assertOpen(operation: string): void { + if (this.closing !== undefined) throw new SessionHandleClosedError(this.id, operation) + } +} + +/** One created-but-unmaterialized session tracked in this process only. */ +export interface PendingSession { + readonly header: SessionHeader + readonly revision: SessionPersistenceRevision + /** Exact fork-inherited prefix length supplied at create. */ + readonly inheritedEventCount: SessionLogOffset +} + +/** + * The JSONL backend's in-process bookkeeping: the single active writer per + * session id (doubling as the live event router), the open-handle set the + * teardown sweep closes, and the created-but-unmaterialized sessions this + * process can already observe. + */ +export class JsonlBackendTracker { + /** Every open handle; teardown closes what remains. */ + readonly openHandles = new Set() + /** `null` marks a claim whose handle is still being constructed. */ + private readonly writers = new Map() + private readonly pending = new Map() + private counter = 0 + + /** @param name - backend label used in in-memory revision tokens and teardown errors. */ + constructor(private readonly name: string) {} + + /** + * Claim write ownership and record the created session as pending, making + * it observable to this process before it materializes. + * @param header - the validated detached header. + * @param inheritedEventCount - the exact fork-inherited prefix length. + * @throws {SessionAlreadyExistsError} when a concurrent create or an open + * write handle holds the id — for create, the duplicate is the fact. + */ + registerCreated(header: SessionHeader, inheritedEventCount: SessionLogOffset): void { + if (this.writers.has(header.id)) throw new SessionAlreadyExistsError(header.id) + this.writers.set(header.id, null) + this.pending.set(header.id, { + header, + revision: SessionPersistenceRevision(`memory:${this.name}:${++this.counter}`), + inheritedEventCount, + }) + } + + /** + * Claim write ownership for an existing session. + * @param id - the session to claim. + * @throws {SessionAlreadyOwnedError} when an active write handle exists. + */ + claimWrite(id: SessionId): void { + if (this.writers.has(id)) throw new SessionAlreadyOwnedError(id) + this.writers.set(id, null) + } + + /** + * Roll a failed write open back. + * @param id - the session whose claim is dropped. + */ + releaseClaim(id: SessionId): void { + this.writers.delete(id) + } + + /** + * The pending entry for a created-but-unmaterialized session, if any. + * @param id - the session to look up. + * @returns the pending header and in-memory revision. + */ + pendingOf(id: SessionId): PendingSession | undefined { + return this.pending.get(id) + } + + /** + * Whether this process still tracks a created-but-unmaterialized session. + * @param id - the session to test. + * @returns true while the pending entry exists. + */ + hasPending(id: SessionId): boolean { + return this.pending.has(id) + } + + /** + * Iterate the pending sessions for listing. + * @returns the pending entries, keyed by session id. + */ + pendingEntries(): IterableIterator<[SessionId, PendingSession]> { + return this.pending.entries() + } + + /** + * Drop a pending entry once the session materialized durably. + * @param id - the session that reached durable storage. + */ + materialized(id: SessionId): void { + this.pending.delete(id) + } + + /** + * Track one open handle for teardown and, for a write handle, bind it as + * the session's live event route. + * @param handle - the just-constructed handle. + * @returns the same handle, for construction-site chaining. + */ + adopt(handle: JsonlSessionHandle): JsonlSessionHandle { + this.openHandles.add(handle) + if (handle.access === 'write') this.writers.set(handle.id, handle) + return handle + } + + /** + * Release one handle's bookkeeping on close. A write handle drops its + * ownership claim; a creator that never materialized leaves nothing behind — + * the session never existed. + * @param handle - the closing handle. + * @param materialized - whether the session reached durable storage. + */ + release(handle: JsonlSessionHandle, materialized: boolean): void { + this.openHandles.delete(handle) + if (handle.access !== 'write') return + this.writers.delete(handle.id) + if (!materialized) this.pending.delete(handle.id) + } + + /** + * Drain and flush every active write handle — the service-wide durability + * barrier behind `SessionPersistence.flush`. + * @throws {AggregateError} naming each session whose flush failed; the + * remaining handles still flush. + */ + async flushAll(): Promise { + const errors: unknown[] = [] + for (const writer of [...this.writers.values()]) { + if (writer === null) continue // a claim mid-construction routes nothing yet + try { + await writer.drainLive() + await writer.flush() + } catch (error: unknown) { + // A handle closed during the sweep counts as flushed: close itself + // drained the routed buffer durably before refusing this flush. + if (error instanceof SessionHandleClosedError) continue + errors.push(error) + } + } + if (errors.length > 0) throw new AggregateError(errors, `${this.name} flush failed`) + } + + /** + * Install the backend's live session routing and teardown. Persistence + * enforces one active write handle per id, so the listeners route published + * sessions' events by id; the teardown effect closes every open handle — + * close drains the routed buffer — and aggregates failures. This provider + * owns no separate storage connection, so closing handles is the complete + * teardown. Registrations are effects of the current fiber. + * @param ctx - the backend's context. + */ + install(ctx: Context): void { + ctx.on('session/event', (session: Session, event) => { + this.writers.get(session.id)?.enqueueLive(event, (error) => { + ctx.logger.warn(`session-persistence: background write for session "${session.id}" failed (buffered events retained): ${String(error)}`) + }) + }) + ctx.on('session/flush', (session: Session) => { + const writer = this.writers.get(session.id) + if (writer === null || writer === undefined) return undefined + return (async () => { + await writer.drainLive() + await writer.flush() + })() + }) + ctx.on('session/disposed', (session: Session) => { + const writer = this.writers.get(session.id) + if (writer === null || writer === undefined) return + writer.close().catch((error: unknown) => { + ctx.logger.warn(`session-persistence: final drain for session "${session.id}" failed: ${String(error)}`) + }) + }) + ctx.effect(() => async () => { + const errors: unknown[] = [] + for (const handle of [...this.openHandles]) { + try { + await handle.close() + } catch (error: unknown) { + errors.push(error) + } + } + if (errors.length > 0) throw new AggregateError(errors, `${this.name} dispose failed`) + }, `${this.name} open handles`) + } +} diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 363ec28212..e90b6474db 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,22 +1,37 @@ -import { MessageId, createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' +import { MessageId, createMessage } from '@deepseek-ai/dsh-llm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, isAbsolute, join, relative, resolve } from 'node:path' -import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { dirname, join, relative, resolve } from 'node:path' +import { SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { - encodeSegment, eventLines, logPath, parseHeader, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, - toHeaderLine, + encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, toHeaderLine, } from '../src/format.ts' -import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' -import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' +import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' +import { runLiveWritePathContract } from '../../session-persistence/tests/live-write-contract.ts' +import { LIVE_WRITE_BATCH_MAX_DELAY_MS, type JsonlSessionHandle } from '../src/storage.ts' +import SessionStore from '@deepseek-ai/dsh-session' const statRace = vi.hoisted(() => ({ path: undefined as string | undefined, reads: 0, + /** 'settle': the revision changes once and then holds; 'churn': every stat differs. */ + mode: 'settle' as 'settle' | 'churn', +})) + +const statFailure = vi.hoisted(() => ({ + path: undefined as string | undefined, + error: undefined as Error | undefined, +})) + +const readTally = vi.hoisted(() => ({ + /** Physical whole-file reads per path suffix; keyed by session id segment. */ + bySuffix: new Map(), + enabled: false, })) vi.mock('node:fs/promises', async (importOriginal) => { @@ -24,12 +39,20 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { ...actual, stat: (async (...args: Parameters) => { + if (String(args[0]) === statFailure.path && statFailure.error !== undefined) throw statFailure.error const identity = await actual.stat(...args) if (String(args[0]) !== statRace.path || !('mtimeNs' in identity)) return identity statRace.reads += 1 - if (statRace.reads !== 2) return identity + if (statRace.mode === 'churn') return { ...identity, mtimeNs: identity.mtimeNs + BigInt(statRace.reads) } + if (statRace.reads < 3) return identity return { ...identity, mtimeNs: identity.mtimeNs + 1n } }) as typeof actual.stat, + readFile: (async (...args: Parameters) => { + if (readTally.enabled && typeof args[0] === 'string') { + readTally.bySuffix.set(args[0], (readTally.bySuffix.get(args[0]) ?? 0) + 1) + } + return actual.readFile(...args) + }) as typeof actual.readFile, } }) @@ -38,7 +61,7 @@ const dirs: string[] = [] type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] } -/** Test-only mutable view used to verify that backends detach returned/caller metadata. */ +/** Test-only mutable view used to verify that backends detach caller metadata. */ function mutableHeader(header: SessionHeader): MutableSessionHeader { return header } @@ -52,18 +75,7 @@ async function rewriteHeader(path: string, update: (header: Record, message: RegExp): Promise { - try { - await promise - } catch (error) { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toMatch(message) - return - } - throw new Error('expected flush to reject') -} - -async function expectFlushCode(promise: Promise, codes: readonly string[]): Promise { +async function expectCode(promise: Promise, codes: readonly string[]): Promise { try { await promise } catch (error) { @@ -71,7 +83,7 @@ async function expectFlushCode(promise: Promise, codes: readonly string expect(codes).toContain((error as NodeJS.ErrnoException).code) return } - throw new Error('expected flush to reject') + throw new Error('expected the operation to reject') } async function freshRoot(): Promise { @@ -84,56 +96,130 @@ function rawLogPath(root: string, cwd: string | undefined, id: SessionId): strin return logPath(root, cwd, id, 'none') } +/** Create + append + close: persist one whole log through the write handle. */ +async function writeLog(persistence: SessionPersistence, m: SessionHeader, events: readonly SessionEvent[]): Promise { + const handle = await persistence.create(m) + try { + await handle.append(events) + } finally { + await handle.close() + } +} + +/** Open a read handle, read the whole log, and close. */ +async function readAll(persistence: SessionPersistence, id: SessionId): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> { + const handle = await persistence.open(id, 'read') + try { + return { meta: handle.header, events: await handle.read() } + } finally { + await handle.close() + } +} + +/** Append one contiguous batch through a temporary write handle. */ +async function appendBatch(persistence: SessionPersistence, id: SessionId, events: readonly SessionEvent[]): Promise { + const handle = await persistence.open(id, 'write') + try { + await handle.append(events) + } finally { + await handle.close() + } +} + afterEach(async () => { statRace.path = undefined statRace.reads = 0 + statRace.mode = 'settle' + readTally.bySuffix.clear() + readTally.enabled = false + statFailure.path = undefined + statFailure.error = undefined vi.restoreAllMocks() for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) -function appendClosedTurn(session: Session): void { - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'hello' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) -} - runPersistenceContract('jsonl-none', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-')) - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(JsonlSessionPersistence, { root: dir, compression: 'none' }) + const instance = async (): Promise<{ persistence: SessionPersistence; dispose: () => Promise }> => { + const ctx = new Context() + const fiber = await ctx.plugin(JsonlSessionPersistence, { root: dir, compression: 'none' }) + return { + persistence: ctx.sessionPersistence, + dispose: async () => { await fiber.dispose() }, + } + } + const primary = await instance() return { - persistence: ctx.sessionPersistence, + persistence: primary.persistence, dispose: async () => { - await fiber.dispose() + await primary.dispose() await rm(dir, { recursive: true, force: true }) }, + reopen: instance, + // A half-written record with no trailing newline: scanLog treats it as an + // uncommitted crash fragment, so the write path sees a torn tail to truncate. + corruptTail: async (id, cwd) => { + await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti') + }, } }) -// Two mounts share this temp root to exercise reload. `corruptTail` appends a partial, -// newline-less fragment past the committed region so coordinator repair runs on real file bytes. -runCoordinatorContract('jsonl-none', async (): Promise => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-')) - return { - mount: async (ctx) => { - const fiber = await ctx.plugin(JsonlSessionPersistence, { root: dir, compression: 'none' }) - return fiber - }, - corruptTail: async (id, cwd) => { - // A half-written record with no trailing newline: scanLog treats it as an - // uncommitted crash fragment and reports committedBytes < byteLength, so - // the coordinator sees a tornMarker to truncate. - await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti') - }, - cleanup: async () => { await rm(dir, { recursive: true, force: true }) }, +runLiveWritePathContract('jsonl', LIVE_WRITE_BATCH_MAX_DELAY_MS, async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-live-')) + dirs.push(dir) + const mount = async (): Promise => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(JsonlSessionPersistence, { root: dir, compression: 'none' }) + return ctx } + return { ctx: await mount(), remount: mount } }) describe('JsonlSessionPersistence: format helpers', () => { + it('encodeSegment neutralizes traversal, separators, and absolute paths', () => { + expect(encodeSegment('..')).toBe('~002E~002E') + expect(encodeSegment('.')).toBe('~002E') + expect(encodeSegment('a/b')).toBe('a~002Fb') + expect(encodeSegment('/etc/passwd')).toBe('~002Fetc~002Fpasswd') + expect(encodeSegment('a\u0000b')).toBe('a~0000b') + expect(encodeSegment('plain-ID_1.2')).toBe('plain-ID_1.2') // safe chars pass through + expect(encodeSegment('a~b')).toBe('a~007Eb') // ~ itself is escaped + }) + + it('encodeSegment is injective over UTF-16, incl. lone surrogates', () => { + // Distinct lone surrogates must NOT collide (Buffer.from would normalize + // both to U+FFFD; code-unit escaping keeps them distinct). + const hi = encodeSegment(String.fromCharCode(0xD800)) + const lo = encodeSegment(String.fromCharCode(0xDC00)) + expect(hi).toBe('~D800') + expect(lo).toBe('~DC00') + expect(hi).not.toBe(lo) + // A literal "~002F" input cannot collide with the encoding of "/". + expect(encodeSegment('~002F')).not.toBe(encodeSegment('/')) + }) + + it('encodeSegment rejects an empty id', () => { + expect(() => encodeSegment('')).toThrow(/empty/) + }) + + it('round-trips every optional header field through the header line', () => { + const full: SessionHeader = { + version: 0, + id: SessionId('full-header'), + createdAt: 5, + cwd: '/w', + parentSession: SessionId('parent'), + isSeeded: true, + origin: 'subagent', + delegationDepth: 2, + agentPreset: 'minimal', + } + const scan = scanLog(Buffer.from(`${JSON.stringify(toHeaderLine(full, SessionLogOffset(3)))}\n`)) + expect(scan.meta).toEqual(full) + expect(scan.inheritedEventCount).toBe(3) + }) + it.each([ ['absent', undefined, false, 0], ['zero', 0, true, 0], @@ -169,39 +255,6 @@ describe('JsonlSessionPersistence: format helpers', () => { .toThrow('unseeded session header inherited event count must be 0') }) - it('round-trips the subagent origin and rejects other physical values', () => { - const line = toHeaderLine({ ...meta('subagent-origin'), origin: 'subagent' }) - expect(scanLog(Buffer.from(`${JSON.stringify(line)}\n`)).meta.origin).toBe('subagent') - expect(() => scanLog(Buffer.from(`${JSON.stringify({ ...line, origin: 'worker' })}\n`))) - .toThrow(/session header/) - }) - - it('encodeSegment neutralizes traversal, separators, and absolute paths', () => { - expect(encodeSegment('..')).toBe('~002E~002E') - expect(encodeSegment('.')).toBe('~002E') - expect(encodeSegment('a/b')).toBe('a~002Fb') - expect(encodeSegment('/etc/passwd')).toBe('~002Fetc~002Fpasswd') - expect(encodeSegment('a\u0000b')).toBe('a~0000b') - expect(encodeSegment('plain-ID_1.2')).toBe('plain-ID_1.2') // safe chars pass through - expect(encodeSegment('a~b')).toBe('a~007Eb') // ~ itself is escaped - }) - - it('encodeSegment is injective over UTF-16, incl. lone surrogates', () => { - // Distinct lone surrogates must NOT collide (Buffer.from would normalize - // both to U+FFFD; code-unit escaping keeps them distinct). - const hi = encodeSegment(String.fromCharCode(0xD800)) - const lo = encodeSegment(String.fromCharCode(0xDC00)) - expect(hi).toBe('~D800') - expect(lo).toBe('~DC00') - expect(hi).not.toBe(lo) - // A literal "~002F" input cannot collide with the encoding of "/". - expect(encodeSegment('~002F')).not.toBe(encodeSegment('/')) - }) - - it('encodeSegment rejects an empty id', () => { - expect(() => encodeSegment('')).toThrow(/empty/) - }) - it('projectKey normalizes project paths into bounded readable names', () => { expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--') expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c')) @@ -212,96 +265,158 @@ describe('JsonlSessionPersistence: format helpers', () => { expect(() => projectKey('')).toThrow(/empty project path/) }) - it('resolves a relative custom root before locating a session', async () => { + it('resolves a relative custom root before storing a session', async () => { const absoluteRoot = await freshRoot() const ctx = new Context() - await ctx.plugin(SessionStore) const fiber = await ctx.plugin(JsonlSessionPersistence, { root: relative(process.cwd(), absoluteRoot), compression: 'none', - writeBatchMaxDelayMs: 1, }) const m = meta('relative-location', '/work') - expect(ctx.sessionPersistence.locate(m)).toEqual({ - kind: 'jsonl', - path: rawLogPath(resolve(absoluteRoot), '/work', m.id), - }) + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + expect((await stat(rawLogPath(resolve(absoluteRoot), '/work', m.id))).isFile()).toBe(true) await fiber.dispose() }) +}) - it('refuses a structurally foreign future header as unsupported, not corrupt', async () => { - const absoluteRoot = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(JsonlSessionPersistence, { root: absoluteRoot, compression: 'none' }) +describe('JsonlSessionPersistence: stored-format refusals', () => { + let ctx: Context + beforeEach(async () => { + root = await freshRoot() + ctx = new Context() + await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + }) + afterEach(async () => { await ctx.fiber.dispose() }) + + it('propagates a non-format header failure from stat and list unchanged', async () => { + // Only foreign-version refusals are enriched (stat) or skipped (list); + // any other header failure stays fail-loud on both paths. + const id = SessionId('retired-policy-header') + const path = rawLogPath(root, '/work', id) + await mkdir(dirname(path), { recursive: true }) + const line = { type: 'session', version: 0, id, createdAt: 1, delegationDepth: 0, sandboxMode: 'strict' } + await writeFile(path, `${JSON.stringify(line)}\n`) + await expect(ctx.sessionPersistence.stat(id)).rejects.toThrow('retired policy baseline fields') + await expect(ctx.sessionPersistence.list()).rejects.toThrow('retired policy baseline fields') + }) + + it('refuses a structurally foreign future header as unsupported, not corrupt or absent', async () => { // A future format need not satisfy this build's header shape at all (no // createdAt, unknown fields): the version must be refused before shape - // validation, so the user sees the upgrade direction. + // validation, so the user sees the upgrade direction — never "not found". const id = SessionId('future-shape') - const path = rawLogPath(resolve(absoluteRoot), '/work', id) + const path = rawLogPath(root, '/work', id) await mkdir(dirname(path), { recursive: true }) await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, futureOnly: true })}\n{"future":"row"}\n`) - const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + for (const access of ['read', 'write'] as const) { + const failure = await ctx.sessionPersistence.open(id, access).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + expect(failure?.message).toContain(`(raw log: ${path})`) + } + // Listing skips the unreadable header instead of failing the whole root. + expect(await ctx.sessionPersistence.list()).toEqual([]) + }) + + it('refuses a well-shaped newer-version header at read open with the upgrade direction', async () => { + // A header that satisfies the current shape but carries a future version: + // stat can parse it, and the open still refuses before handing out a + // handle whose every read would fail. + const id = SessionId('future-version') + const path = rawLogPath(root, '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, createdAt: 1, cwd: '/work', delegationDepth: 0 })}\n`) + const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error) expect(failure?.name).toBe('SessionFormatUnsupportedError') - expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + expect(failure?.message).toMatch(/upgrade the harness/) expect(failure?.message).toContain(`(raw log: ${path})`) - await fiber.dispose() }) it('keeps a non-object header line a corruption, not a format refusal', async () => { - const absoluteRoot = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(JsonlSessionPersistence, { root: absoluteRoot, compression: 'none' }) // Valid JSON that is no object carries no version to compare, so the // version guard must pass it through to the corruption diagnostics. const id = SessionId('scalar-header') - const path = rawLogPath(resolve(absoluteRoot), '/work', id) + const path = rawLogPath(root, '/work', id) await mkdir(dirname(path), { recursive: true }) await writeFile(path, '42\n') - const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error) expect(failure?.name).not.toBe('SessionFormatUnsupportedError') expect(failure?.message).toContain('first line is not a session header') - await fiber.dispose() }) it('names a foreign-version header by its stringified non-string id', async () => { - const absoluteRoot = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(JsonlSessionPersistence, { root: absoluteRoot, compression: 'none' }) // A future header's id field is as untrusted as the rest of its shape: // the refusal must still name the session it read, not crash on the type. const id = SessionId('numeric-id') - const path = rawLogPath(resolve(absoluteRoot), '/work', id) + const path = rawLogPath(root, '/work', id) await mkdir(dirname(path), { recursive: true }) await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`) - const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error) expect(failure?.name).toBe('SessionFormatUnsupportedError') expect(failure?.message).toContain('session "123" uses log format v42') - await fiber.dispose() }) - it('refuses a foreign version on the header-only read path', () => { - expect(() => parseHeader(JSON.stringify({ version: 42, id: 'future', futureOnly: true }))) - .toThrow(expect.objectContaining({ name: 'SessionFormatUnsupportedError' })) - }) - - it('points a format refusal at the raw log path', async () => { - const absoluteRoot = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(JsonlSessionPersistence, { root: absoluteRoot, compression: 'none' }) + it('points a format refusal for a self-written foreign version at the raw log path', async () => { + // create() stores the caller's header verbatim, so a foreign version can + // reach disk through this build; reads then refuse it with the location. const m = { ...meta('newer-format', '/work'), version: 7 } - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, [ + await writeLog(ctx.sessionPersistence, m, [ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ]) - const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + const failure = await ctx.sessionPersistence.open(m.id, 'read').then(() => undefined, (error: unknown) => error as Error) expect(failure?.name).toBe('SessionFormatUnsupportedError') - expect(failure?.message).toContain(`(raw log: ${rawLogPath(resolve(absoluteRoot), '/work', m.id)})`) - await fiber.dispose() + expect(failure?.message).toContain(`(raw log: ${rawLogPath(root, '/work', m.id)})`) + }) + + it('serves a read open through the full log read when the header-only read races a writer', async () => { + const m = meta('stat-race-open', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + // Simulate the race: the header-only stat sees nothing although the full + // log is present and readable. + vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValue(undefined) + const handle = await ctx.sessionPersistence.open(m.id, 'read') + try { + expect(handle.header).toMatchObject({ id: m.id, cwd: '/work' }) + expect(await handle.read()).toEqual(oneTurnLog()) + } finally { + await handle.close() + } + }) + + it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { + const m = meta('legacy-header-delta', '/legacy') + const path = rawLogPath(root, m.cwd, m.id) + await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) + await writeFile(path, [ + JSON.stringify(toHeaderLine(m)), + JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }), + JSON.stringify({ type: 'request/header-delta', seq: SessionSeq(1), time: 2, data: { config: { model: 'legacy' } } }), + JSON.stringify({ type: 'turn/end', seq: SessionSeq(2), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), + '', + ].join('\n')) + + await expect(readAll(ctx.sessionPersistence, m.id)) + .rejects.toThrow(/contains event type "request\/header-delta" \(seq 1\) unknown to this harness/) + }) + + it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { + const m = meta('legacy-header-fallback', '/legacy') + const path = rawLogPath(root, m.cwd, m.id) + await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) + await writeFile(path, [ + JSON.stringify(toHeaderLine(m)), + JSON.stringify({ + type: 'request/header', + seq: SessionSeq(0), + time: 1, + data: { header: { config: { provider: 'mock', model: 'legacy' } }, reason: 'fallback' }, + }), + '', + ].join('\n')) + + await expect(readAll(ctx.sessionPersistence, m.id)) + .rejects.toThrow(/unsupported legacy reason "fallback"/) }) }) @@ -310,122 +425,244 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { beforeEach(async () => { root = await freshRoot() ctx = new Context() - await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) }) afterEach(async () => { await ctx.fiber.dispose() }) it('lazy materialization: create() writes no file until the first append', async () => { const m = meta('lazy', '/work') - const location = ctx.sessionPersistence.locate(m) - expect(location).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', m.id) }) - expect(isAbsolute(location!.path)).toBe(true) - - await ctx.sessionPersistence.create(m) - // locate() is a pure target-path calculation: neither it nor create() - // materializes a file before the first append. + const handle = await ctx.sessionPersistence.create(m) + // create() materializes no file before the first append — while the + // created session is already visible to this process. const dir = sessionDir(root, '/work', m.id) await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow() - expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) + expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id) + expect((await ctx.sessionPersistence.stat(m.id))?.sizeBytes).toBeUndefined() - await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await handle.append(oneTurnLog()) expect((await stat(dir)).isDirectory()).toBe(true) expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true) - expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) + expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id) + await handle.close() }) - it('lists a seeded header without reading an event body', async () => { - const id = SessionId('header-only-seeded') - const path = rawLogPath(root, '/work', id) - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, `${JSON.stringify({ - type: 'session', - version: 0, - id, - createdAt: 1000, - cwd: '/work', - seedLength: 0, - delegationDepth: 0, - })}\n{not-valid-json`) + it('flush materializes an explicitly durable empty session without an event row', async () => { + const m = meta('durable-empty', '/work') + const handle = await ctx.sessionPersistence.create(m) + await handle.flush() + await handle.close() - await expect(ctx.sessionPersistence.list()).resolves.toEqual([ - expect.objectContaining({ id, isSeeded: true }), - ]) + expect(await readFile(rawLogPath(root, '/work', m.id), 'utf8')).toBe(`${JSON.stringify(toHeaderLine(m))}\n`) + await expect(readAll(ctx.sessionPersistence, m.id)).resolves.toMatchObject({ events: [] }) }) - it('materializes an explicitly durable empty live session without an event row', async () => { - const id = SessionId('durable-empty') - const session = ctx.sessions.create(id, { meta: { cwd: '/work' } }) - - await ctx.sessionPersistence.ensureMaterialized(session) - - expect(await readFile(rawLogPath(root, '/work', id), 'utf8')).toBe(`${JSON.stringify(toHeaderLine(session.header))}\n`) - await expect(ctx.sessionPersistence.load(id)).resolves.toEqual({ - meta: session.header, - inheritedEventCount: SessionLogOffset(0), - events: [], + it('close drains a routed event that arrives while it waits for an in-flight append', async () => { + const m = meta('late-closer', '/work') + const handle = await ctx.sessionPersistence.create(m) as JsonlSessionHandle + const service = ctx.sessionPersistence as unknown as { + persistBatch: (...args: [SessionHeader, readonly SessionEvent[], boolean]) => Promise + } + const original = service.persistBatch.bind(service) + const gate = Promise.withResolvers() + const entered = Promise.withResolvers() + vi.spyOn(service, 'persistBatch').mockImplementationOnce(async (...args) => { + entered.resolve(undefined) + await gate.promise + return original(...args) }) + + const [start, ...rest] = oneTurnLog() + const inflight = handle.append([start!]) + // The append is in flight (inside the gated storage write) before close + // starts, so close waits on the chain rather than refusing the append. + await entered.promise + const closing = handle.close() + // A concurrently unwinding producer routes more events while close waits + // on the blocked chain; the close loop must still drain them. + for (const event of rest) handle.enqueueLive(event, () => {}) + gate.resolve(undefined) + await inflight + await closing + + const reopened = new Context() + await reopened.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + await expect(readAll(reopened.sessionPersistence, m.id)) + .resolves.toMatchObject({ events: oneTurnLog() }) + await reopened.fiber.dispose() }) - it('delegates direct preparation through the JSONL provider', async () => { - const m = meta('direct-prepare', '/work') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) + it('service flush skips a write claim whose handle is still opening', async () => { + const m = meta('opening-claim', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const service = ctx.sessionPersistence as unknown as { + readStoredLog: (...args: [string, SessionId]) => Promise + } + const original = service.readStoredLog.bind(service) + const gate = Promise.withResolvers() + const entered = Promise.withResolvers() + vi.spyOn(service, 'readStoredLog').mockImplementationOnce(async (...args) => { + entered.resolve(undefined) + await gate.promise + return original(...args) + }) - const preparation = await ctx.sessionPersistence.prepare(m.id) - - expect(preparation.session.header).toMatchObject(m) - preparation[Symbol.dispose]() + const opening = ctx.sessionPersistence.open(m.id, 'write') + await entered.promise + // The claim exists but its handle is still constructing: nothing routes + // to it yet, so the barrier has nothing to flush there. + await ctx.sessionPersistence.flush() + gate.resolve(undefined) + await (await opening).close() }) - it('readRaw returns the stored artifact text verbatim with its original filename', async () => { - const m = meta('raw-read', '/work') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const raw = await ctx.sessionPersistence.readRaw(m.id) - expect(raw).toBeDefined() - expect(raw!.filename).toBe('session.jsonl') - expect(raw!.meta.id).toBe(m.id) - // Byte-identical to the physical file — never a reconstruction. - expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8')) - expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m))) - const scanned = scanLog(Buffer.from(raw!.content)) - expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + it('stat and list carry the physical artifact size once materialized', async () => { + const m = meta('sized', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const size = (await stat(rawLogPath(root, '/work', m.id))).size + expect((await ctx.sessionPersistence.stat(m.id))?.sizeBytes).toBe(size) + expect((await ctx.sessionPersistence.list()).find(s => s.header.id === m.id)?.sizeBytes).toBe(size) }) - it('readRaw is undefined for an absent session', async () => { - const m = meta('raw-missing', '/work') - expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() + it('binds revisions to the physical artifact identity', async () => { + const m = meta('revision-source') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const revision = (await ctx.sessionPersistence.stat(m.id))?.revision + + // A fresh backend over the SAME root reports the same revision… + const reopenedCtx = new Context() + await reopenedCtx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + expect((await reopenedCtx.sessionPersistence.stat(m.id))?.revision).toBe(revision) + + // …while an identical log in a DIFFERENT root is a different source. + const otherRoot = await freshRoot() + const otherCtx = new Context() + await otherCtx.plugin(JsonlSessionPersistence, { root: otherRoot, compression: 'none' }) + await writeLog(otherCtx.sessionPersistence, m, oneTurnLog()) + expect((await otherCtx.sessionPersistence.stat(m.id))?.revision).not.toBe(revision) + + await reopenedCtx.fiber.dispose() + await otherCtx.fiber.dispose() }) - it('readRaw rejects a corrupt header line instead of exporting it', async () => { - const m = meta('raw-corrupt', '/work') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await writeFile(rawLogPath(root, '/work', m.id), 'not a header line\n{"type":"turn/start","seq":0}\n') - await expect(ctx.sessionPersistence.readRaw(m.id)).rejects.toThrow(/corrupt session log/) + + + + + it('an unchanged cold log parses once across an observe-then-resume handoff', async () => { + const m = meta('memo-handoff', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const path = rawLogPath(root, '/work', m.id) + readTally.enabled = true + + // The observation's read parses the artifact... + expect((await readAll(ctx.sessionPersistence, m.id)).events).toEqual(oneTurnLog()) + expect(readTally.bySuffix.get(path)).toBe(1) + // ...and the immediate write-open (resume) reuses the parsed log through + // the revision guard instead of re-reading the file. + const writer = await ctx.sessionPersistence.open(m.id, 'write') + expect((await writer.read()).length).toBe(oneTurnLog().length) + expect(readTally.bySuffix.get(path)).toBe(1) + + // A local append invalidates the memo: the next cold read re-parses and + // observes the appended suffix. + await writer.append([ + { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[]) + await writer.close() + expect((await readAll(ctx.sessionPersistence, m.id)).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect(readTally.bySuffix.get(path)).toBe(2) }) - it('readRaw retries when the file revision changes during the read', async () => { - const m = meta('raw-revision-race', '/work') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - statRace.path = rawLogPath(root, '/work', m.id) + it('a foreign write misses the memo through the revision guard', async () => { + const m = meta('memo-foreign', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const path = rawLogPath(root, '/work', m.id) + await readAll(ctx.sessionPersistence, m.id) - const raw = await ctx.sessionPersistence.readRaw(m.id) - expect(raw).toBeDefined() - // Two stat calls per iteration; the mocked revision change forces a retry. - expect(statRace.reads).toBe(4) + // Another backend instance over the same root appends behind this one's memo. + const foreign = new Context() + await foreign.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + const writer = await foreign.sessionPersistence.open(m.id, 'write') + await writer.append([ + { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[]) + await writer.close() + await foreign.fiber.dispose() + + readTally.enabled = true + expect((await readAll(ctx.sessionPersistence, m.id)).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect(readTally.bySuffix.get(path)).toBe(1) }) - it('keeps the same location on resume and gives a fork its own location', async () => { + it('the cold-log memo keeps only the handoff window and evicts the oldest id', async () => { + const first = meta('memo-evict-a', '/work') + const second = meta('memo-evict-b', '/work') + const third = meta('memo-evict-c', '/work') + for (const m of [first, second, third]) await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + readTally.enabled = true + + await readAll(ctx.sessionPersistence, first.id) + await readAll(ctx.sessionPersistence, second.id) + await readAll(ctx.sessionPersistence, third.id) // evicts the first id + await readAll(ctx.sessionPersistence, third.id) // still memoized + await readAll(ctx.sessionPersistence, first.id) // re-parses after eviction + expect(readTally.bySuffix.get(rawLogPath(root, '/work', first.id))).toBe(2) + expect(readTally.bySuffix.get(rawLogPath(root, '/work', third.id))).toBe(1) + }) + + it('a handle read retries once when the file revision changes during the read', async () => { + const m = meta('read-revision-race', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const handle = await ctx.sessionPersistence.open(m.id, 'read') + try { + statRace.path = rawLogPath(root, '/work', m.id) + expect(await handle.read()).toEqual(oneTurnLog()) + // The memo probe, the initial identity, the mismatching post-read stat + // (reused as the retry's pre-read identity), and the retry's matching + // post-read stat. + expect(statRace.reads).toBe(4) + } finally { + statRace.path = undefined + await handle.close() + } + }) + + it('a continuously churning revision yields the committed prefix instead of looping', async () => { + const m = meta('read-revision-churn', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const handle = await ctx.sessionPersistence.open(m.id, 'read') + try { + statRace.mode = 'churn' + statRace.path = rawLogPath(root, '/work', m.id) + // Every stat disagrees, so the bounded read stops after one retry and + // serves the retry's pre-read committed prefix — here the whole log. + // Four stats: the memo probe, the initial identity, and one mismatching + // post-read stat per bounded attempt. + expect(await handle.read()).toEqual(oneTurnLog()) + expect(statRace.reads).toBe(4) + } finally { + statRace.path = undefined + await handle.close() + } + }) + + it('appends on reopen extend the same artifact and a fork materializes its own', async () => { const parent = meta('location-parent', '/work') - const parentLocation = ctx.sessionPersistence.locate(parent) - await ctx.sessionPersistence.create(parent) - await ctx.sessionPersistence.append(parent.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, parent, oneTurnLog()) + const parentPath = rawLogPath(root, '/work', parent.id) + const sizeBefore = (await stat(parentPath)).size - const loaded = await ctx.sessionPersistence.load(parent.id) - expect(ctx.sessionPersistence.locate(loaded.meta)).toEqual(parentLocation) + const loaded = await readAll(ctx.sessionPersistence, parent.id) + const writer = await ctx.sessionPersistence.open(parent.id, 'write') + await writer.append([ + { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[]) + await writer.close() + // The resume-side append extended the same physical artifact. + expect((await stat(parentPath)).size).toBeGreaterThan(sizeBefore) const child = { ...loaded.meta, @@ -433,9 +670,10 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { parentSession: parent.id, seedLength: loaded.events.length, } - const childLocation = ctx.sessionPersistence.locate(child) - expect(childLocation?.path).not.toBe(parentLocation?.path) - expect(childLocation).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', child.id) }) + await writeLog(ctx.sessionPersistence, child, oneTurnLog()) + const childPath = rawLogPath(root, '/work', child.id) + expect(childPath).not.toBe(parentPath) + expect((await stat(childPath)).isFile()).toBe(true) }) it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => { @@ -459,61 +697,205 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { { type: 'step/end', seq: SessionSeq(5), time: 6, data: { turn: 1, step: 1 } }, { type: 'turn/end', seq: SessionSeq(6), time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, ] - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, log) - const loaded = await ctx.sessionPersistence.load(m.id) + await writeLog(ctx.sessionPersistence, m, log) + const loaded = await readAll(ctx.sessionPersistence, m.id) expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs }) - it('source-qualifies revisions across roots while preserving same-log reopen identity', async () => { - const m = meta('revision-source') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const revision = (await ctx.sessionPersistence.listSnapshots())[0]?.revision + it('a torn crash tail is served as the committed prefix and repaired only by the write path', async () => { + const m = meta('crash', '/proj') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) // seqs 0..5 + const path = rawLogPath(root, '/proj', m.id) + const committed = await readFile(path) + // A crash mid-second-turn: two complete uncommitted lines plus a torn + // fragment with no newline. + const tail = [ + JSON.stringify({ type: 'turn/start', seq: SessionSeq(6), time: 8, data: { turn: 2 } }), + JSON.stringify({ type: 'step/start', seq: SessionSeq(7), time: 9, data: { turn: 2, step: 1 } }), + '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline) + ].join('\n') + await writeFile(path, tail, { flag: 'a' }) - const reopenedCtx = new Context() - await reopenedCtx.plugin(SessionStore) - await reopenedCtx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - expect((await reopenedCtx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revision) + // A reader serves the valid contiguous prefix — the complete tail lines + // ARE committed reads, the torn fragment never is — without repairing. + const loaded = await readAll(ctx.sessionPersistence, m.id) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect(await readFile(path)).toEqual(Buffer.concat([committed, Buffer.from(tail)])) - const otherRoot = await freshRoot() - const otherCtx = new Context() - await otherCtx.plugin(SessionStore) - await otherCtx.plugin(JsonlSessionPersistence, { root: otherRoot, compression: 'none' }) - await otherCtx.sessionPersistence.create(m) - await otherCtx.sessionPersistence.append(m.id, oneTurnLog()) - expect((await otherCtx.sessionPersistence.listSnapshots())[0]?.revision).not.toBe(revision) - - await reopenedCtx.fiber.dispose() - await otherCtx.fiber.dispose() + // The write path truncates the torn fragment durably before its first + // append, preserving every committed byte before it. + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await appendBatch(ctx.sessionPersistence, m.id, [ + { type: 'step/end', seq: SessionSeq(8), time: 10, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(9), time: 11, data: { turn: 2, reason: { kind: 'interrupted' } } }, + ]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('recovered from a torn tail')) + const repaired = await readFile(path, 'utf8') + expect(repaired.startsWith(committed.toString('utf8'))).toBe(true) + expect(repaired).not.toContain('assistant/chunk') + const reloaded = await readAll(ctx.sessionPersistence, m.id) + expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) }) - it('binds a full stored prefix to the same revision as a lightweight read', async () => { - const m = meta('stored-prefix-revision') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const persistence = ctx.sessionPersistence as JsonlSessionPersistence - - const stored = await persistence.loadStored(m.id) - expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) - expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() + it('a stored open turn is served as stored, with no synthetic closers', async () => { + // Logical repair (closing an interrupted turn) is a resume concern; the + // storage seam returns exactly the committed events. + const m = meta('open-turn', '/h') + await writeLog(ctx.sessionPersistence, m, [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + ]) + const { events } = await readAll(ctx.sessionPersistence, m.id) + expect(events.map(e => e.type)).toEqual(['turn/start']) }) - it('retries a full-prefix read when the file revision changes during the read', async () => { - const m = meta('stored-prefix-revision-race') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const persistence = ctx.sessionPersistence as JsonlSessionPersistence - statRace.path = rawLogPath(root, m.cwd, m.id) + it('a failed appendLines truncates partial bytes so a retry has no seq gap', async () => { + const m = meta('truncate-retry') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) // materialized, seqs 0..5 + const sizeBefore = (await stat(rawLogPath(root, undefined, m.id))).size - await expect(persistence.loadStored(m.id)).resolves.toMatchObject({ events: oneTurnLog() }) - expect(statRace.reads).toBe(4) + const handle = await ctx.sessionPersistence.open(m.id, 'write') + try { + // Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile + // has already put bytes on disk — simulating an ENOSPC/fsync error + // mid-append. The recovery truncate() also fsyncs, so allow that one. + const probe = await (await import('node:fs/promises')).open(rawLogPath(root, undefined, m.id), 'r') + const proto = Object.getPrototypeOf(probe) as { sync: () => Promise } + await probe.close() + const realSync = proto.sync + let failed = false + const spy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) { + if (!failed) { failed = true; throw new Error('simulated fsync ENOSPC') } + return realSync.call(this) + }) + + const turn2: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + // The append rejects, but the partial bytes are truncated back: the file + // is its pre-append size and the handle cursor is unchanged. + await expect(handle.append(turn2)).rejects.toThrow(/ENOSPC/) + expect((await stat(rawLogPath(root, undefined, m.id))).size).toBe(sizeBefore) + spy.mockRestore() + + // The retry now succeeds with NO seq gap — the log is contiguous 0..7. + await handle.append(turn2) + expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + } finally { + await handle.close() + } }) - it('handles revision-stat races and errors after log discovery', async () => { + it('reports both the append failure and a failed rollback', async () => { + const m = meta('rollback-failure') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + + const path = rawLogPath(root, undefined, m.id) + const probe = await (await import('node:fs/promises')).open(path, 'r') + const proto = Object.getPrototypeOf(probe) as { sync: () => Promise } + await probe.close() + const realSync = proto.sync + let failed = false + const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) { + if (!failed) { failed = true; throw new Error('simulated append fsync failure') } + return realSync.call(this) + }) + const backend = ctx.sessionPersistence as unknown as { + rollbackAppend: (path: string, size: number) => Promise + } + const realRollback = backend.rollbackAppend.bind(backend) + backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure')) + + try { + await appendBatch(ctx.sessionPersistence, m.id, [ + { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } }, + ]) + throw new Error('expected append to reject') + } catch (error) { + expect(error).toBeInstanceOf(AggregateError) + const aggregate = error as AggregateError + expect(aggregate.message).toContain(`failed to roll back append to "${path}"`) + expect(aggregate.errors).toHaveLength(2) + expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' }) + expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' }) + } finally { + backend.rollbackAppend = realRollback + syncSpy.mockRestore() + } + }) + + it('rejects a mismatched header id before serving either session log', async () => { + const a = meta('identity-a', '/same') + const b = meta('identity-b', '/same') + await writeLog(ctx.sessionPersistence, a, [{ + type: 'turn/start', + seq: SessionSeq(0), + time: 1, + data: { turn: 1 }, + }]) + await writeLog(ctx.sessionPersistence, b, oneTurnLog()) + + const aPath = rawLogPath(root, a.cwd, a.id) + const bPath = rawLogPath(root, b.cwd, b.id) + await rewriteHeader(aPath, (header) => { header.id = b.id }) + const beforeA = await readFile(aPath) + const beforeB = await readFile(bPath) + + await expect(readAll(ctx.sessionPersistence, a.id)) + .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/) + expect(await readFile(aPath)).toEqual(beforeA) + expect(await readFile(bPath)).toEqual(beforeB) + }) + + it('path-traversal session ids are neutralized (no escape from root)', async () => { + const evil = SessionId('../../etc/pwn') + const m = { version: 0, id: evil, createdAt: 1, isSeeded: false } + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + // The file lives UNDER root, not at ../../etc. + const all: string[] = [] + async function walk(dir: string): Promise { + for (const e of await readdir(dir, { withFileTypes: true })) { + const p = join(dir, e.name) + if (e.isDirectory()) await walk(p) + else all.push(p) + } + } + await walk(root) + expect(all.length).toBeGreaterThan(0) + expect(all.every(p => p.startsWith(root))).toBe(true) + }) + + it('rejects pre-aborted operations with the exact cancellation reason', async () => { + const m = meta('pre-aborted') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const reason = new Error('persistence operation cancelled') + const controller = new AbortController() + controller.abort(reason) + const signal = controller.signal + + await expect(ctx.sessionPersistence.create(meta('aborted-create'), { signal })).rejects.toBe(reason) + await expect(ctx.sessionPersistence.open(m.id, 'read', { signal })).rejects.toBe(reason) + await expect(ctx.sessionPersistence.open(m.id, 'write', { signal })).rejects.toBe(reason) + await expect(ctx.sessionPersistence.stat(m.id, { signal })).rejects.toBe(reason) + await expect(ctx.sessionPersistence.list({ signal })).rejects.toBe(reason) + + const handle = await ctx.sessionPersistence.open(m.id, 'write') + try { + await expect(handle.read(0, undefined, { signal })).rejects.toBe(reason) + await expect(handle.append([ + { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } }, + ], { signal })).rejects.toBe(reason) + await expect(handle.flush({ signal })).rejects.toBe(reason) + // The aborted mutations left the log untouched. + expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) + } finally { + await handle.close() + } + }) + + it('stat handles artifact removal and non-ENOENT failures after log discovery', async () => { const m = meta('stored-revision-race') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) const persistence = ctx.sessionPersistence as JsonlSessionPersistence const internals = persistence as unknown as { findLog(id: SessionId, signal?: AbortSignal): Promise @@ -522,27 +904,111 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { const findLog = vi.spyOn(internals, 'findLog').mockResolvedValue(path) await rm(path) - expect(await persistence.readStoredRevision(m.id)).toBeUndefined() + expect(await persistence.stat(m.id)).toBeUndefined() const invalidPath = `${path}\0` findLog.mockResolvedValue(invalidPath) - await expect(persistence.readStoredRevision(m.id)).rejects.toMatchObject({ + await expect(persistence.stat(m.id)).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE', }) - const reason = new Error('revision read cancelled after discovery') + const reason = new Error('stat cancelled after discovery') const controller = new AbortController() findLog.mockImplementation(async () => { controller.abort(reason) return invalidPath }) - await expect(persistence.readStoredRevision(m.id, controller.signal)).rejects.toBe(reason) + await expect(persistence.stat(m.id, { signal: controller.signal })).rejects.toBe(reason) }) - it('omits a snapshot artifact removed after discovery', async () => { + it('stat reports absence for an artifact vanishing before its identity stat and surfaces other faults', async () => { + const m = meta('stat-fault', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + // The header read succeeds; the identity stat then loses the file (a + // concurrent removal) or hits a storage fault. + statFailure.path = rawLogPath(root, '/work', m.id) + statFailure.error = Object.assign(new Error('ENOENT: vanished'), { code: 'ENOENT' }) + expect(await ctx.sessionPersistence.stat(m.id)).toBeUndefined() + statFailure.error = Object.assign(new Error('EACCES: denied'), { code: 'EACCES' }) + await expect(ctx.sessionPersistence.stat(m.id)).rejects.toThrow(/EACCES/) + }) + + it('an empty append batch is a no-op that does not materialize', async () => { + const m = meta('empty-batch', '/work') + const handle = await ctx.sessionPersistence.create(m) + await handle.append([]) + await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow() + await handle.append(oneTurnLog()) + expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) + await handle.close() + }) + + it('lists a pending id once when an artifact for the same id appears beneath it', async () => { + const m = meta('shadowed', '/w') + const creator = await ctx.sessionPersistence.create(m) + // An artifact materialized outside this instance's create/append path. + await mkdir(sessionDir(root, '/w', m.id), { recursive: true }) + await writeFile(rawLogPath(root, '/w', m.id), [ + JSON.stringify(toHeaderLine(m)), + ...oneTurnLog().map(e => JSON.stringify(e)), + ].join('\n') + '\n') + + const entries = (await ctx.sessionPersistence.list()).filter(s => s.header.id === m.id) + expect(entries).toHaveLength(1) + // The artifact entry wins over the pending one. + expect(entries[0]!.sizeBytes).toBeDefined() + await creator.close() + }) + + it('a read handle over an erased pending session fails loudly', async () => { + const m = meta('erased-pending') + const creator = await ctx.sessionPersistence.create(m) + const reader = await ctx.sessionPersistence.open(m.id, 'read') + expect(await reader.read()).toEqual([]) + // The creator closes without ever appending: the session never existed. + await creator.close() + await expect(reader.read()).rejects.toThrow(/not found/) + await reader.close() + }) + + it('a read handle rejects a stored log that shrank below an observed prefix', async () => { + const m = meta('shrunk', '/work') + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) + const reader = await ctx.sessionPersistence.open(m.id, 'read') + try { + expect(await reader.read()).toHaveLength(6) + // Committed events are never rewritten; a shorter file is damage, not a + // legal state, and a handle must not silently backtrack. + await writeFile(rawLogPath(root, '/work', m.id), [ + JSON.stringify(toHeaderLine(m)), + JSON.stringify(oneTurnLog()[0]), + ].join('\n') + '\n') + await expect(reader.read()).rejects.toThrow(/shrank below a previously observed prefix/) + } finally { + await reader.close() + } + }) + + it('backend dispose aggregates open-handle close failures into one reported error', async () => { + const ctx2 = new Context() + const fiber = await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + const reported = vi.spyOn(ctx2.logger, 'error').mockImplementation(() => undefined) + const handle = await ctx2.sessionPersistence.create(meta('dispose-fail')) + const failure = new Error('close exploded') + vi.spyOn(handle, 'close').mockRejectedValue(failure) + await fiber.dispose() + // Cordis contains effect-disposal failures and reports them; the backend's + // teardown aggregate names every handle that refused to close. + const aggregate = reported.mock.calls + .map((call): unknown => call[0]) + .find((value): value is AggregateError => value instanceof AggregateError) + expect(aggregate?.message).toContain('session-persistence-jsonl dispose failed') + expect(aggregate?.errors).toEqual([failure]) + }) + + it('omits a listed artifact removed after discovery', async () => { const m = meta('vanishing-snapshot') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) const persistence = ctx.sessionPersistence as unknown as { listArtifacts(): Promise> } @@ -553,11 +1019,11 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { return artifacts }) - await expect(ctx.sessionPersistence.listSnapshots()).resolves.toEqual([]) + await expect(ctx.sessionPersistence.list()).resolves.toEqual([]) discovery.mockRestore() }) - it('surfaces non-ENOENT snapshot stat failures after discovery', async () => { + it('surfaces non-ENOENT stat failures during listing', async () => { const persistence = ctx.sessionPersistence as unknown as { listArtifacts(): Promise> } @@ -566,25 +1032,25 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { path: `${root}\0snapshot-stat-failure`, }]) - await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/null bytes/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/null bytes/) discovery.mockRestore() }) - it('forwards snapshot-list cancellation and awaits in-flight discovery cleanup', async () => { + it('forwards list cancellation and awaits in-flight discovery cleanup', async () => { const persistence = ctx.sessionPersistence as unknown as { listArtifacts(signal?: AbortSignal): Promise> } const started = Promise.withResolvers() const cleanup = Promise.withResolvers() vi.spyOn(persistence, 'listArtifacts').mockImplementation(async (signal) => { - if (signal === undefined) throw new Error('expected snapshot-list signal') + if (signal === undefined) throw new Error('expected list signal') started.resolve(signal) await cleanup.promise return [] }) - const reason = new Error('JSONL snapshot discovery cancelled') + const reason = new Error('JSONL list discovery cancelled') const controller = new AbortController() - const pending = ctx.sessionPersistence.listSnapshots(controller.signal) + const pending = ctx.sessionPersistence.list({ signal: controller.signal }) expect(await started.promise).toBe(controller.signal) let settled = false void pending.then( @@ -600,10 +1066,9 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { await expect(pending).rejects.toBe(reason) }) - it('checks cancellation after an uncancellable snapshot stat settles', async () => { + it('checks cancellation after an uncancellable list stat settles', async () => { const m = meta('snapshot-stat-cancellation') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) const persistence = ctx.sessionPersistence as unknown as { listArtifacts(signal?: AbortSignal): Promise> } @@ -611,305 +1076,16 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { header: m, path: rawLogPath(root, m.cwd, m.id), }]) - const reason = new Error('JSONL snapshot stat cancelled') + const reason = new Error('JSONL list stat cancelled') const controller = new AbortController() - const pending = ctx.sessionPersistence.listSnapshots(controller.signal) + const pending = ctx.sessionPersistence.list({ signal: controller.signal }) queueMicrotask(() => { controller.abort(reason) }) await expect(pending).rejects.toBe(reason) expect(discovery).toHaveBeenCalledWith(controller.signal) }) - - it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { - const m = meta('legacy-header-delta', '/legacy') - const path = rawLogPath(root, m.cwd, m.id) - await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) - await writeFile(path, [ - JSON.stringify(toHeaderLine(m)), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), - JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }), - JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), - '', - ].join('\n')) - - await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/) - }) - - it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { - const m = meta('legacy-header-fallback', '/legacy') - const path = rawLogPath(root, m.cwd, m.id) - await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) - await writeFile(path, [ - JSON.stringify(toHeaderLine(m)), - JSON.stringify({ - type: 'request/header', - seq: 0, - time: 1, - data: { header: { config: { model: 'legacy' } }, reason: 'fallback' }, - }), - '', - ].join('\n')) - - await expect(ctx.sessionPersistence.load(m.id)) - .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/) - }) - - it('persists a forked child seed through the existing session write path', async () => { - const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) - appendClosedTurn(source) - - const child = ctx.sessions.fork(source, undefined, SessionId('persist-child')) - await ctx.sessions.flush(child) - const loaded = await ctx.sessionPersistence.load(child.id) - - // The constructor seed reaches disk verbatim, then the child's end-seed. - expect(loaded.events.slice(0, source.snapshotEvents().length)).toEqual(source.snapshotEvents()) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.snapshotEvents().length }) - expect(loaded.meta).toMatchObject({ - id: SessionId('persist-child'), - cwd: '/workspace', - parentSession: SessionId('persist-parent'), - isSeeded: true, - }) - expect(loaded.inheritedEventCount).toBe(source.seq) - await expect(ctx.sessionPersistence.readRaw(child.id)).resolves.toMatchObject({ - meta: { isSeeded: true }, - inheritedEventCount: source.seq, - }) - }) - - it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => { - const m = meta('crash', '/proj') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5, turn/end at 5 - - // Simulate a crash mid-second-turn: append raw lines that are NOT closed by - // a turn/end (turn/start + step/start are fully written), plus a final - // partial line with no newline (a torn fragment never fully flushed). - const path = rawLogPath(root, '/proj', m.id) - await writeFile(path, [ - JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }), - JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }), - '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline) - ].join('\n'), { flag: 'a' }) - - // load PRESERVES the interrupted turn's real events (turn/start 6, step/start - // 7) — a turn can be huge, so they must not be truncated — and durably closes - // the orphaned turn with synthetic step/end (8) + turn/end {interrupted} (9). - const loaded = await ctx.sessionPersistence.load(m.id) - 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' }) - const stepEnd = loaded.events[8]! - expect(stepEnd.type).toBe('step/end') - // the torn seq-8 chunk fragment did not survive - expect(loaded.events.some(e => e.type === 'assistant/chunk' && e.seq === 8)).toBe(false) - - // The next append continues at seq 10 (the balanced length). - const turn3 = [ - { type: 'turn/start', seq: 10, time: 11, data: { turn: 3 } }, - { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } }, - ] as SessionEvent[] - await ctx.sessionPersistence.append(m.id, turn3) - const reloaded = await ctx.sessionPersistence.load(m.id) - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) - }) - - it('committed events are never rewritten: only the crash tail is repaired', async () => { - const m = meta('append-only') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const before = await readFile(rawLogPath(root, undefined, m.id), 'utf8') - const committedPrefix = before // the whole committed log - - // A crash tail then a repair-append. - await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' }) - await ctx.sessionPersistence.load(m.id) - await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[]) - const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8') - // the committed prefix is byte-for-byte intact at the head of the file - expect(after.startsWith(committedPrefix)).toBe(true) - }) - - it('a failed appendLines truncates partial bytes so a retry has no seq gap', async () => { - const m = meta('truncate-retry') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) // materialized, seqs 0..5 - const sizeBefore = (await stat(rawLogPath(root, undefined, m.id))).size - - // Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile - // has already put bytes on disk — simulating an ENOSPC/fsync error - // mid-append. The recovery truncate() also fsyncs, so allow that one. - const handle = await (await import('node:fs/promises')).open(rawLogPath(root, undefined, m.id), 'r') - const proto = Object.getPrototypeOf(handle) as { sync: () => Promise } - await handle.close() - const realSync = proto.sync - let failed = false - const spy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) { - if (!failed) { failed = true; throw new Error('simulated fsync ENOSPC') } - return realSync.call(this) - }) - - const turn2 = [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[] - // The append rejects, but the partial bytes are truncated back: the file is - // its pre-append size and the cursor is unchanged. - await expect(ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/ENOSPC/) - expect((await stat(rawLogPath(root, undefined, m.id))).size).toBe(sizeBefore) - spy.mockRestore() - - // The retry now succeeds with NO seq gap — the log is contiguous 0..7. - await ctx.sessionPersistence.append(m.id, turn2) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - }) - - it('reports both the append failure and a failed rollback', async () => { - const m = meta('rollback-failure') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - - const path = rawLogPath(root, undefined, m.id) - const handle = await (await import('node:fs/promises')).open(path, 'r') - const proto = Object.getPrototypeOf(handle) as { sync: () => Promise } - await handle.close() - const realSync = proto.sync - let failed = false - const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) { - if (!failed) { failed = true; throw new Error('simulated append fsync failure') } - return realSync.call(this) - }) - const backend = ctx.sessionPersistence as unknown as { - rollbackAppend: (path: string, size: number) => Promise - } - const realRollback = backend.rollbackAppend.bind(backend) - backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure')) - - try { - await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, - ] as SessionEvent[]) - throw new Error('expected append to reject') - } catch (error) { - expect(error).toBeInstanceOf(AggregateError) - const aggregate = error as AggregateError - expect(aggregate.message).toContain(`failed to roll back append to "${path}"`) - expect(aggregate.errors).toHaveLength(2) - expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' }) - expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' }) - } finally { - backend.rollbackAppend = realRollback - syncSpy.mockRestore() - } - }) - - it('load returns immutable meta without exposing backend pathing', async () => { - const m = meta('meta-copy', '/proj') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(() => { mutableHeader(loaded.meta).cwd = '/evil' }).toThrow() - await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[]) - // The append landed in the ORIGINAL /proj log, not beside an /evil path. - const reloaded = await ctx.sessionPersistence.load(m.id) - expect(reloaded.meta.cwd).toBe('/proj') - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - }) - - it('rejects a mismatched header before repairing either session log', async () => { - const a = meta('identity-a', '/same') - const b = meta('identity-b', '/same') - await ctx.sessionPersistence.create(a) - await ctx.sessionPersistence.append(a.id, [{ - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { turn: 1 }, - }]) - await ctx.sessionPersistence.create(b) - await ctx.sessionPersistence.append(b.id, oneTurnLog()) - - const aPath = rawLogPath(root, a.cwd, a.id) - const bPath = rawLogPath(root, b.cwd, b.id) - await rewriteHeader(aPath, (header) => { header.id = b.id }) - const beforeA = await readFile(aPath) - const beforeB = await readFile(bPath) - - await expect(ctx.sessionPersistence.load(a.id)) - .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/) - expect(await readFile(aPath)).toEqual(beforeA) - expect(await readFile(bPath)).toEqual(beforeB) - }) - - it('rejects a re-append of an already-stored seq', async () => { - const m = meta('reappend') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow(/seq mismatch/) - }) - - it('path-traversal session ids are neutralized (no escape from root)', async () => { - const evil = SessionId('../../etc/pwn') - const m = { version: 0, id: evil, createdAt: 1, isSeeded: false } - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(evil, oneTurnLog()) - // The file lives UNDER root, not at ../../etc. - const all: string[] = [] - async function walk(dir: string): Promise { - for (const e of await readdir(dir, { withFileTypes: true })) { - const p = join(dir, e.name) - if (e.isDirectory()) await walk(p) - else all.push(p) - } - } - await walk(root) - expect(all.length).toBeGreaterThan(0) - expect(all.every(p => p.startsWith(root))).toBe(true) - }) }) -describe('JsonlSessionPersistence: write path (session/event → flush)', () => { - it('concurrent sessions do not cross buffers', async () => { - root = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - - const a = ctx.sessions.create(SessionId('sa')) - const b = ctx.sessions.create(SessionId('sb')) - a.append('turn/start', { turn: 1 }) - b.append('turn/start', { turn: 1 }) - a.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'A' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - b.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'B' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - b.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(a) - await ctx.sessions.flush(b) - - const la = await ctx.sessionPersistence.load(SessionId('sa')) - const lb = await ctx.sessionPersistence.load(SessionId('sb')) - expect(JSON.stringify(la.events)).toContain('"A"') - expect(JSON.stringify(la.events)).not.toContain('"B"') - expect(JSON.stringify(lb.events)).toContain('"B"') - expect(JSON.stringify(lb.events)).not.toContain('"A"') - await ctx.fiber.dispose() - }) - -}) - - describe('JsonlSessionPersistence: scanLog unit', () => { it('requires exactly one newline-terminated header record', () => { const header = JSON.stringify(toHeaderLine(meta('scanner-header'))) @@ -939,14 +1115,14 @@ describe('JsonlSessionPersistence: scanLog unit', () => { scanner.write(Buffer.from([ JSON.stringify(oneTurnLog()[0]), '{not json', - JSON.stringify({ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }), + JSON.stringify({ type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } }), '', ].join('\n'))) expect(scanner.finish().events).toEqual([oneTurnLog()[0]]) const committed = new SessionLogScanner(header) expect(() => { committed.write(Buffer.from([ - JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), + JSON.stringify({ type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), '', ].join('\n'))) }).toThrow(/seq gap in committed region/) }) @@ -1027,8 +1203,8 @@ describe('JsonlSessionPersistence: scanLog unit', () => { const line = toHeaderLine({ version: 0, id: SessionId('composed'), - createdAt: 1, isSeeded: false, + createdAt: 1, delegationDepth: 0, agentPreset: 'minimal', }) @@ -1048,21 +1224,21 @@ describe('JsonlSessionPersistence: scanLog unit', () => { it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), - JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 + JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }), + JSON.stringify({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 ].join('\n') + '\n' - // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the - // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and - // stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn. + // No committed turn/end, so the gap is a tolerated crash boundary: scanLog + // PRESERVES the contiguous prefix (turn/start seq 0) — real interrupted-turn + // work, not discarded — and stops at the gap. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0]) }) it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), - JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 - JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), + JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }), + JSON.stringify({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 + JSON.stringify({ type: 'turn/end', seq: SessionSeq(3), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' // A turn/end exists, so the prefix up to it is committed — but it has a hole. // Truncating it would silently drop committed data → unloadable. @@ -1079,7 +1255,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }), record, - JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), + JSON.stringify({ type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/) } @@ -1096,7 +1272,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { it('a corrupt line after the last turn/end bounds the preserved tail', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), + JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }), '{not json', // corrupt crash fragment, no turn/end committed ].join('\n') + '\n' // The contiguous prefix (turn/start seq 0) is preserved; the corrupt @@ -1107,9 +1283,9 @@ describe('JsonlSessionPersistence: scanLog unit', () => { it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), - JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), - JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail + JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }), + JSON.stringify({ type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), + JSON.stringify({ type: 'step/start', seq: SessionSeq(9), time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail ].join('\n') + '\n' const { events } = scanLog(Buffer.from(log)) expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped @@ -1121,7 +1297,6 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { beforeEach(async () => { root = await freshRoot() ctx = new Context() - await ctx.plugin(SessionStore) // compression: 'none' — these tests assert the textual storage-record layout // (row tags per line); packing is orthogonal to the physical encoding. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) @@ -1159,21 +1334,19 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { it('writes a delta run as one text-chunks row by default and loads back identical events', async () => { const m = meta('packed', '/work') const log = chunkRunLog() - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, log) + await writeLog(ctx.sessionPersistence, m, log) const raw = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean) const tags = raw.slice(1).map(line => (JSON.parse(line) as { type: string }).type) expect(tags).toEqual(['turn/start', 'step/start', 'text-chunks', 'assistant/message', 'step/end', 'turn/end']) - const loaded = await ctx.sessionPersistence.load(m.id) + const loaded = await readAll(ctx.sessionPersistence, m.id) expect(loaded.events).toEqual(log) }) it('packChunks: false writes one event per line and still loads identical events', async () => { const unpackedRoot = await freshRoot() const unpacked = new Context() - await unpacked.plugin(SessionStore) await unpacked.plugin(JsonlSessionPersistence, { root: unpackedRoot, packChunks: false, @@ -1182,15 +1355,14 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { try { const m = meta('unpacked', '/work') const log = chunkRunLog() - await unpacked.sessionPersistence.create(m) - await unpacked.sessionPersistence.append(m.id, log) + await writeLog(unpacked.sessionPersistence, m, log) const records = (await readFile(rawLogPath(unpackedRoot, '/work', m.id), 'utf8')) .split('\n').filter(Boolean).slice(1) .map(line => JSON.parse(line) as { type: string }) expect(records.filter(record => record.type === 'assistant/chunk')).toHaveLength(5) expect(records.some(record => record.type === 'text-chunks')).toBe(false) - expect((await unpacked.sessionPersistence.load(m.id)).events).toEqual(log) + expect((await readAll(unpacked.sessionPersistence, m.id)).events).toEqual(log) } finally { await unpacked.fiber.dispose() } @@ -1200,7 +1372,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { const m = meta('mixed', '/work') const log = chunkRunLog() // First turn written line-per-event by an unpacked-config writer (an old - // file, hand-planted so this packed-config backend adopts it on load). + // file, hand-planted so this packed-config backend adopts it on open). await mkdir(sessionDir(root, '/work', m.id), { recursive: true }) await writeFile(rawLogPath(root, '/work', m.id), [ JSON.stringify({ type: 'session', version: 0, id: 'mixed', createdAt: 1000, cwd: '/work', delegationDepth: 0 }), @@ -1208,15 +1380,15 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { ].join('\n') + '\n') // Adopt the stored log (cursor = stored length), then append a second turn // through THIS packed-config backend. - expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(log) + expect((await readAll(ctx.sessionPersistence, m.id)).events).toEqual(log) const secondTurn: SessionEvent[] = JSON.parse(JSON.stringify(log)) as SessionEvent[] for (const [k, e] of secondTurn.entries()) { ;(e as { seq: number }).seq = 10 + k ;(e.data as { turn: number }).turn = 2 } - await ctx.sessionPersistence.append(m.id, secondTurn) + await appendBatch(ctx.sessionPersistence, m.id, secondTurn) - const loaded = await ctx.sessionPersistence.load(m.id) + const loaded = await readAll(ctx.sessionPersistence, m.id) expect(loaded.events).toEqual([...log, ...secondTurn]) // The packed append really packed: the file's tail carries a text-chunks row. const tags = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean) @@ -1228,13 +1400,13 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { it('scanLog: a packed row advances the seq cursor by its whole run', () => { const logText = [ JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), + JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }), - JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }), + JSON.stringify({ type: 'turn/end', seq: SessionSeq(4), time: 5, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' const { events } = scanLog(Buffer.from(logText)) expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4]) - expect(events[2]).toEqual({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } }) + expect(events[2]).toEqual({ type: 'assistant/chunk', seq: SessionSeq(2), time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } }) }) it('scanLog: a malformed packed row in the committed region rejects like corrupt JSON', () => { @@ -1242,7 +1414,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { JSON.stringify({ type: 'session', version: 0, id: 'bad-row', createdAt: 1, delegationDepth: 0 }), // dt arity mismatch — row validation throws, so the line is a committed hole. JSON.stringify({ type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] } }), - JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), + JSON.stringify({ type: 'turn/end', seq: SessionSeq(2), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' expect(() => scanLog(Buffer.from(logText))).toThrow(/unparsable committed event/) }) @@ -1250,7 +1422,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => { const logText = [ JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), + JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }), // seq0 skips 1 — the run's first member is already a gap; no turn/end follows. JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }), ].join('\n') + '\n' @@ -1283,57 +1455,56 @@ describe('JsonlSessionPersistence: edge cases', () => { beforeEach(async () => { root = await freshRoot() ctx = new Context() - await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) }) afterEach(async () => { await ctx.fiber.dispose() }) - it('append rejects non-JSON-serializable undefined-producing data', async () => { - const m = meta('undef') - await ctx.sessionPersistence.create(m) - // A value whose JSON.stringify yields undefined (a bare function as data). - const bad = [{ type: 'user/message', seq: 0, time: 1, data: (() => 0) as unknown }] as unknown as SessionEvent[] - await expect(ctx.sessionPersistence.append(m.id, bad)).rejects.toThrow(/non-JSON-serializable/) - }) - it('create snapshots its meta: mutating the caller object after the call is ignored', async () => { const m = meta('create-snap', '/orig') const p = ctx.sessionPersistence.create(m) // Mutate the caller's meta object immediately after calling create. mutableHeader(m).cwd = '/mutated' - await p - await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog()) + const handle = await p + await handle.append(oneTurnLog()) + await handle.close() // The log materialized under the ORIGINAL cwd, not the mutated one. expect((await stat(rawLogPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true) await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow() }) - it('list discovers sessions across multiple project directories', async () => { - await ctx.sessionPersistence.create(meta('p1', '/projA')) - await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog()) - await ctx.sessionPersistence.create(meta('p2', '/projB')) - await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog()) - await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd project directory - await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog()) + it('create rejects non-JSON metadata and a fractional creation timestamp without reserving the id', async () => { + await expect(ctx.sessionPersistence.create({ ...meta('bad-meta'), extra: 1n } as unknown as SessionHeader)) + .rejects.toThrow('session metadata must be losslessly JSON-serializable') + await expect(ctx.sessionPersistence.create({ ...meta('fractional-created-at'), createdAt: 1.5 })) + .rejects.toThrow('session metadata createdAt must be a non-negative safe integer') - const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort() + // The rejected create reserved nothing: the id is free. + const valid = meta('fractional-created-at') + await writeLog(ctx.sessionPersistence, valid, oneTurnLog()) + expect((await readAll(ctx.sessionPersistence, valid.id)).meta.createdAt).toBe(valid.createdAt) + }) + + it('list discovers sessions across multiple project directories', async () => { + await writeLog(ctx.sessionPersistence, meta('p1', '/projA'), oneTurnLog()) + await writeLog(ctx.sessionPersistence, meta('p2', '/projB'), oneTurnLog()) + await writeLog(ctx.sessionPersistence, meta('p3'), oneTurnLog()) // no cwd → _no-cwd project directory + + const ids = (await ctx.sessionPersistence.list()).map(s => s.header.id).sort() expect(ids).toEqual(['p1', 'p2', 'p3']) }) it('groups sessions whose cwd paths normalize to the same project directory', async () => { const first = meta('normalized-first', '/a/b-c') const second = meta('normalized-second', '/a-b/c') - await ctx.sessionPersistence.create(first) - await ctx.sessionPersistence.append(first.id, oneTurnLog()) - await ctx.sessionPersistence.create(second) - await ctx.sessionPersistence.append(second.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, first, oneTurnLog()) + await writeLog(ctx.sessionPersistence, second, oneTurnLog()) expect(projectDir(root, first.cwd)).toBe(projectDir(root, second.cwd)) expect(await readdir(projectDir(root, first.cwd))).toEqual(expect.arrayContaining([ encodeSegment(first.id), encodeSegment(second.id), ])) - expect((await ctx.sessionPersistence.list()).map(header => header.id).sort()) + expect((await ctx.sessionPersistence.list()).map(s => s.header.id).sort()) .toEqual([first.id, second.id].sort()) }) @@ -1343,16 +1514,15 @@ describe('JsonlSessionPersistence: edge cases', () => { it('keeps the transcript in an extensible session-owned directory', async () => { const m = meta('owned-directory', '/project') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) const dir = sessionDir(root, m.cwd, m.id) await writeFile(join(dir, 'metadata.json'), '{}\n') await writeFile(join(projectDir(root, m.cwd), 'README'), 'project metadata\n') await mkdir(join(projectDir(root, m.cwd), 'reserved-session'), { recursive: true }) expect(await readdir(dir)).toEqual(expect.arrayContaining(['metadata.json', 'session.jsonl'])) - expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id) - expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog()) + expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id) + expect((await readAll(ctx.sessionPersistence, m.id)).events).toEqual(oneTurnLog()) }) it('rejects the obsolete flat-file layout instead of ignoring stored sessions', async () => { @@ -1366,7 +1536,7 @@ describe('JsonlSessionPersistence: edge cases', () => { '', ].join('\n')) - await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/) + await expect(ctx.sessionPersistence.open(m.id, 'read')).rejects.toThrow(/unsupported flat-file layout/) await expect(ctx.sessionPersistence.list()).rejects.toThrow(/unsupported flat-file layout/) }) @@ -1377,15 +1547,14 @@ describe('JsonlSessionPersistence: edge cases', () => { await mkdir(project, { recursive: true }) await writeFile(join(project, `${encodeSegment(m.id)}.jsonl.zstd`), 'legacy') - await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/) + await expect(ctx.sessionPersistence.open(m.id, 'read')).rejects.toThrow(/unsupported flat-file layout/) }) it('list skips empty and non-header session logs (metadata-only read)', async () => { // A real session… - await ctx.sessionPersistence.create(meta('real', '/p')) - await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog()) + await writeLog(ctx.sessionPersistence, meta('real', '/p'), oneTurnLog()) // …alongside junk session directories whose fixed transcript is empty or - // lacks a header. Both remain unmaterialized and are skipped. + // lacks a header. Both remain unlisted. for (const [id, content] of [ ['empty', ''], ['notheader', '{"type":"turn/start"}\n'], @@ -1396,7 +1565,7 @@ describe('JsonlSessionPersistence: edge cases', () => { await writeFile(path, content) } - const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort() + const ids = (await ctx.sessionPersistence.list()).map(s => s.header.id).sort() expect(ids).toEqual(['real']) }) @@ -1407,7 +1576,7 @@ describe('JsonlSessionPersistence: edge cases', () => { await mkdir(sessionDir(root, undefined, id), { recursive: true }) const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) }) await writeFile(rawLogPath(root, undefined, id), bigHeader + '\n') - const ids = (await ctx.sessionPersistence.list()).map(x => x.id) + const ids = (await ctx.sessionPersistence.list()).map(s => s.header.id) expect(ids).toContain('big') }) @@ -1419,8 +1588,7 @@ describe('JsonlSessionPersistence: edge cases', () => { it('list rejects a header whose cwd does not identify its physical log', async () => { const m = meta('misplaced', '/stored') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' }) await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/) @@ -1428,8 +1596,7 @@ describe('JsonlSessionPersistence: edge cases', () => { it('accepts an alternate project path only when it identifies the same physical log', async () => { const m = meta('physical-alias', '/stored') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, m, oneTurnLog()) const path = rawLogPath(root, m.cwd, m.id) const aliasCwd = '/alias' await symlink( @@ -1439,8 +1606,8 @@ describe('JsonlSessionPersistence: edge cases', () => { ) await rewriteHeader(path, (header) => { header.cwd = aliasCwd }) - expect((await ctx.sessionPersistence.load(m.id)).meta.cwd).toBe(aliasCwd) - expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id) + expect((await readAll(ctx.sessionPersistence, m.id)).meta.cwd).toBe(aliasCwd) + expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id) }) it('list rejects a session header whose id cannot name a storage path', async () => { @@ -1453,7 +1620,7 @@ describe('JsonlSessionPersistence: edge cases', () => { await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/) }) - it('load and list reject one id materialized in multiple project directories', async () => { + it('open and list reject one id materialized in multiple project directories', async () => { const id = SessionId('duplicate') for (const cwd of ['/a', '/b']) { const m = meta(id, cwd) @@ -1462,100 +1629,26 @@ describe('JsonlSessionPersistence: edge cases', () => { await writeFile(rawLogPath(root, cwd, id), content) } - await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple project directories/) + await expect(ctx.sessionPersistence.open(id, 'read')).rejects.toThrow(/appears in multiple project directories/) await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple project directories/) }) - it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => { - // Session A materializes a log under id "reuse". - const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => { - const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } }) - appendLog(a, oneTurnLog()) - }, { inject: ['sessions'] })) - // Drain A, then dispose ITS fiber (the live session A is gone) while the - // backend stays loaded. - for (const s of ctx.sessions.list()) await ctx.sessions.flush(s) - await sessFiberA.dispose() - - // A new Session object reuses the id. Object-keyed initialization must run independently, - // detect the disk collision, and reject instead of appending through session A's stale cursor. - let b!: Session - await ctx.plugin(Object.assign((inner: Context) => { - b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/) - }) - - it('a no-cwd live session cannot adopt a same-id log from another cwd', async () => { - // Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then - // dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map — - // the HMR/reload path with no tracked collision state). - await ctx.sessionPersistence.create(meta('x', '/w')) - await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog()) - await ctx.fiber.dispose() - - // Backend 2 creates a no-cwd session whose id exists only in `/w`. The - // stored cwd check rejects instead of grafting no-cwd events onto that log. + it('create rejects an id already on disk under a different project directory', async () => { + // Persist the id under cwd A. + const a = meta('dup-id', '/projA') + await writeLog(ctx.sessionPersistence, a, oneTurnLog()) + // A fresh backend creating the SAME id under cwd B must still refuse: + // opens identify by id across all projects, so a second log would make + // resume nondeterministic. create scans every project, not just meta.cwd's. const ctx2 = new Context() - await ctx2.plugin(SessionStore) await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - let b!: Session - await ctx2.plugin(Object.assign((inner: Context) => { - b = inner.sessions.create(SessionId('x')) // no cwd - }, { inject: ['sessions'] })) - await expect(ctx2.sessions.flush(b)).rejects.toThrow(/different cwd|id collision/) - - // The "/w" log is untouched — no no-cwd events were grafted onto it, and no - // `_no-cwd` log for "x" was created. - const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x')))) - expect(inW.meta.cwd).toBe('/w') - expect(inW.events).toHaveLength(6) - await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow() + await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB'))) + .rejects.toThrow(/already exists/) await ctx2.fiber.dispose() }) - it('a seed with matching seq/type/time but DIFFERENT data is rejected (deep prefix compare)', async () => { - // Materialize and load (ownerless, cursor = 6). - await ctx.sessionPersistence.create(meta('divergent', '/a')) - await ctx.sessionPersistence.append(SessionId('divergent'), oneTurnLog()) - await ctx.sessionPersistence.load(SessionId('divergent')) - - // A seed that keeps every seq/type/time but mutates a payload must NOT be - // accepted as "the same session" — otherwise drain filters those seqs as - // already persisted and the divergent payload is silently lost. - const tampered = structuredClone(oneTurnLog()) - const userMsg = tampered[1] - if (userMsg?.type === 'user/message') { - (userMsg.data as { content: unknown[] }).content = [{ type: 'text', text: 'DIFFERENT' }] - } - let bad!: Session - await ctx.plugin(Object.assign((inner: Context) => { - bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/) - }) - - it('a second live session reusing a bound id is rejected', async () => { - // A live session materializes and owns the id. - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) - a.append('turn/start', { turn: 1 }) - a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }, { inject: ['sessions'] })) - for (const s of ctx.sessions.list()) await ctx.sessions.flush(s) - await firstFiber.dispose() - - let second!: Session - await ctx.plugin(Object.assign((inner: Context) => { - second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(second)) - .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/) - }) - it('list returns nothing when the root directory does not exist', async () => { const ctx2 = new Context() - await ctx2.plugin(SessionStore) await ctx2.plugin(JsonlSessionPersistence, { root: join(root, 'does-not-exist-yet'), compression: 'none', @@ -1568,7 +1661,6 @@ describe('JsonlSessionPersistence: edge cases', () => { const filePath = join(root, 'not-a-dir') await writeFile(filePath, 'x') const ctx2 = new Context() - await ctx2.plugin(SessionStore) await expect(ctx2.plugin(JsonlSessionPersistence, { root: filePath, compression: 'none' })).rejects.toThrow(/ENOTDIR/) await ctx2.fiber.dispose() }) @@ -1590,147 +1682,38 @@ describe('JsonlSessionPersistence: edge cases', () => { it('materialization surfaces a project-directory storage fault', async () => { const cwd = '/x' - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await writeFile(projectDir(root, cwd), 'x') // project path is now a file - let s!: Session - await ctx2.plugin(Object.assign((inner: Context) => { - s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) - appendClosedTurn(s) - }, { inject: ['sessions'] })) - await expectFlushCode(ctx2.sessions.flush(s), ['EEXIST', 'ENOTDIR']) - await ctx2.fiber.dispose() + const handle = await ctx.sessionPersistence.create(meta('exists-fault', cwd)) + try { + await expectCode(handle.append(oneTurnLog()), ['EEXIST', 'ENOTDIR']) + } finally { + await handle.close() + } }) - it('append() to a disk-only session adopts it and repairs a crash tail', async () => { - // Persist a session, then corrupt its tail, all through ONE backend. - const m = meta('disk-append', '/d') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await writeFile(rawLogPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' }) - - // A FRESH backend with no in-memory state: append directly (no prior load) - // → append must adopt from disk, and the adopt's load schedules a repair - // that the same append then performs before writing. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - await ctx2.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[]) - const loaded = await ctx2.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - await ctx2.fiber.dispose() - }) - - it('a header-only log (open turn, no turn/end) preserves the open turn on load and closes it', async () => { - // A session whose only durable content is an unclosed first turn. scanLog - // preserves the turn/start; loadCore closes it with a synthetic - // turn/end {interrupted} so the returned log is balanced. - const m = meta('open-turn', '/h') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - ] as SessionEvent[]) - const { events } = await ctx.sessionPersistence.load(m.id) - expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end']) - const end = events[1]! - expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' }) - }) - - - it('createCore rejects an id already on disk under a different project directory', async () => { - // Persist the id under cwd A. - const a = meta('dup-id', '/projA') - await ctx.sessionPersistence.create(a) - await ctx.sessionPersistence.append(a.id, oneTurnLog()) - // A fresh backend creating the SAME id under cwd B must still refuse: load - // identifies by id across all projects, so a second log would make resume - // nondeterministic. create scans every project, not just meta.cwd's. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB'))) - .rejects.toThrow(/already has a persisted log on disk/) - await ctx2.fiber.dispose() - }) - - it('flush keeps buffered events when the append fails (no silent loss)', async () => { - root = await freshRoot() - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - const session = ctx2.sessions.create(SessionId('flush-fail')) - // A full turn lands in the write-behind buffer. - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // Make the durable materialize fail on the next flush. - const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise } - const origMat = backend.materialize.bind(backend) - backend.materialize = () => Promise.reject(new Error('disk full')) - await expectFlushError(ctx2.sessions.flush(session), /disk full/) - // The events are STILL buffered (not silently dropped): a retry persists them. - backend.materialize = origMat - await ctx2.sessions.flush(session) - const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2]) - await ctx2.fiber.dispose() - }) - - it('rejects non-JSON event data: BigInt, function, circular, Map, undefined property', async () => { - const m = meta('serial') - await ctx.sessionPersistence.create(m) - const bad = (extra: unknown) => [{ - type: 'user/message', - seq: 0, - time: 1, - data: { - id: MessageId('invalid-json'), - role: 'user', - content: [{ type: 'text', text: 'x' }], - source: { kind: 'user' }, - extra, - }, - }] as unknown as SessionEvent[] - await expect(ctx.sessionPersistence.append(m.id, bad(1n))).rejects.toThrow(/non-JSON-serializable/) - await expect(ctx.sessionPersistence.append(m.id, bad(() => 0))).rejects.toThrow(/non-JSON-serializable/) - await expect(ctx.sessionPersistence.append(m.id, bad(Symbol('s')))).rejects.toThrow(/non-JSON-serializable/) - await expect(ctx.sessionPersistence.append(m.id, bad(new Map()))).rejects.toThrow(/non-JSON-serializable/) - await expect(ctx.sessionPersistence.append(m.id, bad(undefined))).rejects.toThrow(/non-JSON-serializable/) - await expect(ctx.sessionPersistence.append(m.id, bad(Infinity))).rejects.toThrow(/non-JSON-serializable/) - // a circular structure - const circ: Record = {} - circ.self = circ - await expect(ctx.sessionPersistence.append(m.id, bad(circ))).rejects.toThrow(/non-JSON-serializable/) - // The session was never materialized by any of the rejected appends. - expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) + it('backend teardown closes handles left open and fails later operations loudly', async () => { + const m = meta('teardown') + const handle = await ctx.sessionPersistence.create(m) + await handle.append(oneTurnLog()) + await ctx.fiber.dispose() + await expect(handle.append([ + { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } }, + ])).rejects.toThrow(/on a closed handle/) + // Reload the backend so the shared afterEach dispose stays valid. + ctx = new Context() + await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) }) it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => { const m = meta('json-ok') - await ctx.sessionPersistence.create(m) - const ev = [{ type: 'user/message', seq: 0, time: 1, data: createUserMessage({ - content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: { a: null, b: true, c: [1, 2, { d: 'nested' }] }, - }) }] as unknown as SessionEvent[] - await ctx.sessionPersistence.append(m.id, ev) - expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) + const events = [{ type: 'user/message', seq: SessionSeq(0), time: 1, data: { + id: MessageId('json-ok'), + role: 'user', + content: [{ type: 'text', text: 'x' }], + source: { kind: 'user' }, + extra: { a: null, b: true, c: [1, 2, { d: 'nested' }] }, + }, surfaceOp: 'append' }] as unknown as SessionEvent[] + await writeLog(ctx.sessionPersistence, m, events) + expect((await readAll(ctx.sessionPersistence, m.id)).events).toEqual(events) }) - - it('Session.append rejects a non-serializable event at the source (never enters the log)', () => { - const session = ctx.sessions.create(SessionId('reject-bad')) - // Serializability is enforced at the source: Session.append throws on a BigInt-bearing - // event before it enters session.snapshotEvents(), so the durable log can never diverge from the live - // log. The error therefore surfaces synchronously at append, not later during backend flush. - expect(() => { - session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' }) - }).toThrow(/non-JSON-serializable/) - // The bad event was rejected, so the log stayed empty. - expect(session.snapshotEvents().length).toBe(0) - }) - }) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts index a8da30bd28..cb13896983 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, + compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, scanZstdFrames, } from '../src/zstd.ts' import { NodePrivateZstdFrameDecoder } from '../src/zstd-private-decoder.ts' import { PublicZstdFrameDecoder } from '../src/zstd-public-decoder.ts' @@ -34,6 +34,5 @@ describe('JSONL Zstandard compatibility', () => { const eventFrame = encoded.subarray(frames[1]!.start, frames[1]!.end) const missingChecksumByte = eventFrame.subarray(0, -1) expect(scanZstdFrames(missingChecksumByte)).toEqual({ frames: [], tornStart: 0 }) - expect((await decompressZstdPrefix(missingChecksumByte)).toString()).toContain('"type":"turn/start"') }) }) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 9d311d2b39..828529cd26 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -5,8 +5,9 @@ import type { FileHandle } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { performance } from 'node:perf_hooks' -import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts' import { @@ -16,7 +17,6 @@ import { import { NodePrivateZstdFrameDecoder } from '../src/zstd-private-decoder.ts' import { PublicZstdFrameDecoder } from '../src/zstd-public-decoder.ts' import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' -import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const MAGIC = Buffer.from([0x28, 0xB5, 0x2F, 0xFD]) const roots: string[] = [] @@ -43,7 +43,6 @@ async function freshRoot(prefix = 'dsh-jsonl-zstd-'): Promise { async function mount(root: string, compression?: JsonlCompression): Promise { const ctx = new Context() contexts.push(ctx) - await ctx.plugin(SessionStore) await ctx.plugin(JsonlSessionPersistence, { root, ...(compression === undefined ? {} : { compression }), @@ -51,6 +50,36 @@ async function mount(root: string, compression?: JsonlCompression): Promise { + const handle = await persistence.create(m) + try { + await handle.append(events) + } finally { + await handle.close() + } +} + +/** Open a read handle, read the whole log, and close. */ +async function readAll(persistence: SessionPersistence, id: SessionId): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> { + const handle = await persistence.open(id, 'read') + try { + return { meta: handle.header, events: await handle.read() } + } finally { + await handle.close() + } +} + +/** Append one contiguous batch through a temporary write handle. */ +async function appendBatch(persistence: SessionPersistence, id: SessionId, events: readonly SessionEvent[]): Promise { + const handle = await persistence.open(id, 'write') + try { + await handle.append(events) + } finally { + await handle.close() + } +} + async function decodeCompleteFrames(buffer: Buffer): Promise { const { frames, tornStart } = scanZstdFrames(buffer) expect(tornStart).toBeUndefined() @@ -61,10 +90,8 @@ async function decodeCompleteFrames(buffer: Buffer): Promise { return Buffer.concat(plaintext) } -async function tornFrame( - plaintext: string, - accepts: (decoded: string) => boolean, -): Promise { +/** Truncate one compressed frame so a scan reports it torn and the recovered plaintext satisfies `accepts`. */ +async function tornFrame(plaintext: string, accepts: (decoded: string) => boolean = () => true): Promise { const frame = await compressZstdFrame(plaintext) const candidateEnds = [ frame.length - 1, @@ -114,33 +141,34 @@ afterEach(async () => { runPersistenceContract('jsonl-zstd', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-zstd-contract-')) - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(JsonlSessionPersistence, { root }) + const instance = async (): Promise<{ persistence: SessionPersistence; dispose: () => Promise }> => { + const ctx = new Context() + const fiber = await ctx.plugin(JsonlSessionPersistence, { root }) + return { + persistence: ctx.sessionPersistence, + dispose: async () => { await fiber.dispose() }, + } + } + const primary = await instance() return { - persistence: ctx.sessionPersistence, + persistence: primary.persistence, dispose: async () => { - await fiber.dispose() + await primary.dispose() await rm(root, { recursive: true, force: true }) }, - } -}) - -runCoordinatorContract('jsonl-zstd', async (): Promise => { - const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-zstd-coordinator-')) - return { - mount: async ctx => ctx.plugin(JsonlSessionPersistence, { root }), + reopen: instance, + // A torn final frame: the batch's append never resolved, so the whole + // frame is an uncommitted crash fragment for the write path to truncate. corruptTail: async (id, cwd) => { const line = JSON.stringify({ type: 'assistant/chunk', - seq: 8, + seq: SessionSeq(8), time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } }, }) + '\n' - const partial = await tornFrame(line, decoded => decoded.length > 0 && !decoded.endsWith('\n')) + const partial = await tornFrame(line, decoded => !decoded.includes('\n')) await appendFile(logPath(root, cwd, id, 'zstd'), partial) }, - cleanup: async () => { await rm(root, { recursive: true, force: true }) }, } }) @@ -332,51 +360,30 @@ describe('Zstandard frame structure', () => { }) describe('JsonlSessionPersistence: default Zstandard encoding', () => { - it('lists seeded metadata from the header frame without decoding the event body', async () => { - const root = await freshRoot() - const ctx = await mount(root) - const header = { ...meta('zstd-header-only-seeded', '/work'), isSeeded: true } - const path = logPath(root, '/work', header.id, 'zstd') - await mkdir(sessionDir(root, '/work', header.id), { recursive: true }) - const headerFrame = await compressZstdFrame( - `${JSON.stringify(toHeaderLine(header, SessionLogOffset(0)))}\n`, - ) - await writeFile(path, Buffer.concat([headerFrame, Buffer.from('invalid event frame')])) - - await expect(ctx.sessionPersistence.list()).resolves.toEqual([ - expect.objectContaining({ id: header.id, isSeeded: true }), - ]) - }) - it('materializes an explicitly durable empty session as one header frame', async () => { const root = await freshRoot() const ctx = await mount(root) - const session = ctx.sessions.create(SessionId('empty-zstd'), { meta: { cwd: '/work' } }) + const m = meta('empty-zstd', '/work') + const handle = await ctx.sessionPersistence.create(m) + await handle.flush() + await handle.close() - await ctx.sessionPersistence.ensureMaterialized(session) - - const buffer = await readFile(logPath(root, '/work', session.id, 'zstd')) + const buffer = await readFile(logPath(root, '/work', m.id, 'zstd')) expect(scanZstdFrames(buffer).frames).toHaveLength(1) - expect((await decodeCompleteFrames(buffer)).toString()).toBe(`${JSON.stringify(toHeaderLine(session.header))}\n`) - await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ - meta: session.header, - inheritedEventCount: 0, - events: [], - }) + expect((await decodeCompleteFrames(buffer)).toString()).toBe(`${JSON.stringify(toHeaderLine(m))}\n`) + await expect(readAll(ctx.sessionPersistence, m.id)).resolves.toMatchObject({ events: [] }) }) it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('default-zstd', '/work') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') const buffer = await readFile(path) expect(buffer.subarray(0, 4)).toEqual(MAGIC) await expect(stat(logPath(root, header.cwd, header.id, 'none'))).rejects.toThrow() - expect(ctx.sessionPersistence.locate(header)).toEqual({ kind: 'jsonl', path }) const scan = scanZstdFrames(buffer) expect(scan.frames).toHaveLength(2) @@ -386,57 +393,30 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { ...oneTurnLog().map(e => JSON.stringify(e)), '', ].join('\n')) - expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) + expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual(oneTurnLog()) }) - it('readRaw decodes the compressed artifact back to the original JSONL text', async () => { - const root = await freshRoot() - const ctx = await mount(root) - const header = meta('raw-read-zstd', '/work') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) - const raw = await ctx.sessionPersistence.readRaw(header.id) - expect(raw).toBeDefined() - // The logical name drops the physical encoding suffix. - expect(raw!.filename).toBe('session.jsonl') - expect(raw!.meta.id).toBe(header.id) - expect(raw!.content).toBe([ - JSON.stringify(toHeaderLine(header)), - ...oneTurnLog().map(e => JSON.stringify(e)), - '', - ].join('\n')) - const scanned = scanLog(Buffer.from(raw!.content)) - expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) - }) - - it('readRaw rejects a present zstd artifact that carries no frame', async () => { + it('a read rejects a present zstd artifact that carries no frame', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('raw-zero-frame', '/work') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) // The path still exists, so zero frames is corruption rather than absence. await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) - await expect(ctx.sessionPersistence.readRaw(header.id)) - .rejects.toThrow('empty or header-less Zstandard session log') + await expect(readAll(ctx.sessionPersistence, header.id)).rejects.toThrow() }) it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() contexts.push(ctx) - await ctx.plugin(SessionStore) let backend!: JsonlSessionPersistence - await ctx.plugin(Object.assign((inner: Context) => { + await ctx.plugin((inner: Context) => { backend = new JsonlSessionPersistence(inner, { root }) - }, { inject: ['sessions'] })) + }) const header = meta('direct-default') const path = logPath(root, header.cwd, header.id, 'zstd') - expect(backend.locate(header)).toEqual({ - kind: 'jsonl', - path, - }) const base = oneTurnLog() const events: SessionEvent[] = [ @@ -453,50 +433,49 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { time: event.time + 3, })), ] - await backend.create(header) - await backend.append(header.id, events) + await writeLog(backend, header, events) const plaintext = (await decodeCompleteFrames(await readFile(path))).toString() const recordTypes = plaintext.trimEnd().split('\n') .map(line => (JSON.parse(line) as { type: string }).type) expect(recordTypes).toContain('text-chunks') - expect((await backend.load(header.id)).events).toEqual(events) + expect((await readAll(backend, header.id)).events).toEqual(events) }) it('appends one frame per durable batch without rewriting prior bytes', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('append-frame') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const handle = await ctx.sessionPersistence.create(header) + await handle.append(oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') const before = await readFile(path) - const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[] - await ctx.sessionPersistence.append(header.id, secondTurn) + const secondTurn: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + await handle.append(secondTurn) + await handle.close() const after = await readFile(path) expect(after.subarray(0, before.length)).toEqual(before) expect(scanZstdFrames(after).frames).toHaveLength(3) - expect((await ctx.sessionPersistence.load(header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) + expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) }) it('lists from a multi-chunk header frame without decoding a corrupt event frame', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('large-header', `/work/${'x'.repeat(24_000)}`) - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') const buffer = Buffer.from(await readFile(path)) const eventFrame = scanZstdFrames(buffer).frames[1]! buffer[eventFrame.end - 1] = buffer[eventFrame.end - 1]! ^ 0xFF await writeFile(path, buffer) - expect((await ctx.sessionPersistence.list()).map(item => item.id)).toEqual([header.id]) - await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/frame at byte .* failed validation/) + expect((await ctx.sessionPersistence.list()).map(item => item.header.id)).toEqual([header.id]) + await expect(readAll(ctx.sessionPersistence, header.id)).rejects.toThrow(/frame at byte .* failed validation/) }) it('stops multi-frame inspection when cancellation arrives at a slice deadline', async () => { @@ -541,8 +520,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { const root = await freshRoot() const ctx = await mount(root, compression) const header = meta(`cancel-${compression}-header-read`, '/work') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) await ctx.sessionPersistence.list() const path = logPath(root, header.cwd, header.id, compression) const probe = await open(path, 'r') @@ -563,107 +541,162 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { return result }) - await expect(ctx.sessionPersistence.list(controller.signal)).rejects.toBe(reason) + await expect(ctx.sessionPersistence.list({ signal: controller.signal })).rejects.toBe(reason) expect(read).toHaveBeenCalledTimes(1) }, ) - it('preserves complete records from a torn frame and re-encodes them with crash closers', async () => { + it('recovers complete records from a torn final frame and rewrites them on the next append', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('recover-torn', '/proj') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') const committed = await readFile(path) - const openTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, - { type: 'assistant/chunk', seq: 8, time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } }, - ] as SessionEvent[] + const openTurn: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'step/start', seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } }, + { type: 'assistant/chunk', seq: SessionSeq(8), time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } }, + ] const plaintext = openTurn.map(e => JSON.stringify(e)).join('\n') + '\n' - const partial = await tornFrame(plaintext, (decoded) => { + await appendFile(path, await tornFrame(plaintext, (decoded) => { const newlines = decoded.match(/\n/g)?.length ?? 0 return newlines >= 2 && !decoded.endsWith('\n') - }) - await appendFile(path, partial) + })) - const loaded = await ctx.sessionPersistence.load(header.id) - expect(loaded.events.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + // Complete JSONL records already flushed into the torn frame are real + // emitted events: reads recover them, while the half-written chunk stays + // invisible and the file keeps its bytes until the write path repairs it. + const loaded = await readAll(ctx.sessionPersistence, header.id) + expect(loaded.events.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) expect(loaded.events[6]).toEqual(openTurn[0]) expect(loaded.events[7]).toEqual(openTurn[1]) - expect(loaded.events.some(event => event.type === 'assistant/chunk' && event.seq === 8)).toBe(false) - expect(loaded.events[8]?.type).toBe('step/end') - expect(loaded.events[9]?.type).toBe('turn/end') + + // The first append truncates the torn bytes and rewrites the recovered + // records durably before the new batch, continuing at their next-seq. + const closers: SessionEvent[] = [ + { type: 'step/end', seq: SessionSeq(8), time: 10, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(9), time: 11, data: { turn: 2, reason: { kind: 'interrupted' } } }, + ] + await appendBatch(ctx.sessionPersistence, header.id, closers) expect(warn).toHaveBeenCalledWith('session-persistence-jsonl: session "recover-torn" recovered from a torn tail; incomplete tail bytes were discarded') const repaired = await readFile(path) expect(repaired.subarray(0, committed.length)).toEqual(committed) expect(scanZstdFrames(repaired).tornStart).toBeUndefined() - expect(scanLog(await decodeCompleteFrames(repaired)).events).toEqual(loaded.events) + expect(scanLog(await decodeCompleteFrames(repaired)).events) + .toEqual([...oneTurnLog(), openTurn[0]!, openTurn[1]!, ...closers]) + }) + + it('retries the torn-tail rewrite when its first durable write fails', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('retry-torn-rewrite', '/proj') + vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) + const path = logPath(root, header.cwd, header.id, 'zstd') + const recovered: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'step/start', seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } }, + { type: 'assistant/chunk', seq: SessionSeq(8), time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } }, + ] + await appendFile(path, await tornFrame(recovered.map(e => JSON.stringify(e)).join('\n') + '\n', (decoded) => { + const newlines = decoded.match(/\n/g)?.length ?? 0 + return newlines >= 2 && !decoded.endsWith('\n') + })) + + const handle = await ctx.sessionPersistence.open(header.id, 'write') + try { + const failure = new Error('rewrite refused') + const service = ctx.sessionPersistence as unknown as { persistBatch: () => Promise } + vi.spyOn(service, 'persistBatch').mockRejectedValueOnce(failure) + const closers: SessionEvent[] = [ + { type: 'step/end', seq: SessionSeq(8), time: 10, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(9), time: 11, data: { turn: 2, reason: { kind: 'interrupted' } } }, + ] + // The rewrite of the recovered records fails first; the retained repair + // state makes the retried append rewrite them exactly once. + await expect(handle.append(closers)).rejects.toBe(failure) + await handle.append(closers) + } finally { + await handle.close() + } + + const repaired = await readFile(path) + expect(scanZstdFrames(repaired).tornStart).toBeUndefined() + expect(scanLog(await decodeCompleteFrames(repaired)).events.map(e => e.seq)) + .toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) }) it('drops a frame torn in its header before it has produced plaintext', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('partial-magic') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') const committed = await readFile(path) await appendFile(path, MAGIC.subarray(0, 2)) - expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) - expect(await readFile(path)).toEqual(committed) + expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual(oneTurnLog()) + // Reads never repair: the torn bytes stay until a write-path append. + expect(await readFile(path)).toEqual(Buffer.concat([committed, MAGIC.subarray(0, 2)])) }) - it('recovers complete events when EOF tears only the final frame checksum', async () => { + it('recovers a final frame torn at its checksum byte in full', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('partial-checksum') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') - const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[] + const committed = await readFile(path) + const secondTurn: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] const frame = await compressZstdFrame(secondTurn.map(e => JSON.stringify(e)).join('\n') + '\n') await appendFile(path, frame.subarray(0, -1)) - const loaded = await ctx.sessionPersistence.load(header.id) + // One missing checksum byte leaves the frame structurally torn, but its + // complete records decode in full: reads recover them, and the next + // append rewrites them as a complete checksummed frame. + const loaded = await readAll(ctx.sessionPersistence, header.id) expect(loaded.events).toEqual([...oneTurnLog(), ...secondTurn]) + const thirdTurn: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(8), time: 9, data: { turn: 3 } }, + { type: 'turn/end', seq: SessionSeq(9), time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, + ] + await appendBatch(ctx.sessionPersistence, header.id, thirdTurn) const repaired = await readFile(path) + expect(repaired.subarray(0, committed.length)).toEqual(committed) expect(scanZstdFrames(repaired).tornStart).toBeUndefined() - expect(scanLog(await decodeCompleteFrames(repaired)).events).toEqual(loaded.events) + expect(scanLog(await decodeCompleteFrames(repaired)).events).toEqual([...oneTurnLog(), ...secondTurn, ...thirdTurn]) }) it('rejects a complete frame containing a torn JSONL record', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('complete-bad-jsonl') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await writeLog(ctx.sessionPersistence, header, oneTurnLog()) await appendFile( logPath(root, header.cwd, header.id, 'zstd'), await compressZstdFrame('{"type":"turn/start"'), ) - await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/complete frame contains a torn JSONL record/) + await expect(readAll(ctx.sessionPersistence, header.id)).rejects.toThrow(/complete frame contains a torn JSONL record/) }) it('rolls back a checksummed append frame when fsync fails', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('zstd-fsync-rollback') - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const handle = await ctx.sessionPersistence.create(header) + await handle.append(oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') const before = await readFile(path) - const handle = await open(path, 'r') - const prototype = Object.getPrototypeOf(handle) as { sync: () => Promise } - await handle.close() + const probe = await open(path, 'r') + const prototype = Object.getPrototypeOf(probe) as { sync: () => Promise } + await probe.close() const realSync = prototype.sync let failed = false const spy = vi.spyOn(prototype, 'sync').mockImplementation(async function (this: FileHandle) { @@ -673,15 +706,16 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { } return realSync.call(this) }) - const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[] - await expect(ctx.sessionPersistence.append(header.id, secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/) + const secondTurn: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + await expect(handle.append(secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/) expect(await readFile(path)).toEqual(before) spy.mockRestore() - await ctx.sessionPersistence.append(header.id, secondTurn) - expect((await ctx.sessionPersistence.load(header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) + await handle.append(secondTurn) + await handle.close() + expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) }) it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => { @@ -706,7 +740,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { '', ].join('\n'))) await expect(ctx.sessionPersistence.list()).rejects.toThrow(/first frame is not exactly one header line/) - await expect(ctx.sessionPersistence.load(SessionId('two-lines'))) + await expect(ctx.sessionPersistence.open(twoLinesId, 'read')) .rejects.toThrow(/first frame is not exactly one header line/) }) @@ -722,9 +756,9 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { await writeFile(logPath(root, undefined, SessionId('bad-checksum'), 'zstd'), corruptHeader) const ctx = await mount(root) - await expect(ctx.sessionPersistence.load(SessionId('partial-only'))) + await expect(ctx.sessionPersistence.open(SessionId('partial-only'), 'read')) .rejects.toThrow(/empty or header-less Zstandard session log/) - await expect(ctx.sessionPersistence.load(SessionId('empty-header'))) + await expect(ctx.sessionPersistence.open(SessionId('empty-header'), 'read')) .rejects.toThrow(/first frame is not exactly one header line/) await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header frame failed validation/) }) @@ -734,17 +768,13 @@ describe('JsonlSessionPersistence: encoding selection', () => { it('rejects roots owned by the opposite encoding in both directions', async () => { const rawRoot = await freshRoot('dsh-jsonl-raw-mismatch-') const raw = await mount(rawRoot, 'none') - const rawHeader = meta('raw-log') - await raw.sessionPersistence.create(rawHeader) - await raw.sessionPersistence.append(rawHeader.id, oneTurnLog()) + await writeLog(raw.sessionPersistence, meta('raw-log'), oneTurnLog()) const defaultBackend = await mount(rawRoot) await expect(defaultBackend.sessionPersistence.list()).rejects.toThrow(/configured for compression "zstd"/) const zstdRoot = await freshRoot('dsh-jsonl-zstd-mismatch-') const zstd = await mount(zstdRoot) - const zstdHeader = meta('zstd-log') - await zstd.sessionPersistence.create(zstdHeader) - await zstd.sessionPersistence.append(zstdHeader.id, oneTurnLog()) + await writeLog(zstd.sessionPersistence, meta('zstd-log'), oneTurnLog()) const rawBackend = await mount(zstdRoot, 'none') await expect(rawBackend.sessionPersistence.list()).rejects.toThrow(/configured for compression "none"/) }) @@ -761,9 +791,8 @@ describe('JsonlSessionPersistence: encoding selection', () => { ...oneTurnLog().map(e => JSON.stringify(e)), '', ].join('\n')) - await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/) - await expect((ctx.sessionPersistence as JsonlSessionPersistence).loadStored(loadHeader.id)) - .rejects.toThrow(/uses \.jsonl/) + await expect(ctx.sessionPersistence.open(loadHeader.id, 'read')).rejects.toThrow(/uses \.jsonl/) + await expect(ctx.sessionPersistence.open(loadHeader.id, 'write')).rejects.toThrow(/uses \.jsonl/) await expect(ctx.sessionPersistence.list()).rejects.toThrow(/uses \.jsonl/) }) @@ -772,14 +801,15 @@ describe('JsonlSessionPersistence: encoding selection', () => { const ctx = await mount(root) await ctx.sessionPersistence.list() const header = meta('late-raw-materialize', '/late') - await ctx.sessionPersistence.create(header) + const handle = await ctx.sessionPersistence.create(header) await mkdir(sessionDir(root, header.cwd, header.id), { recursive: true }) await writeFile(logPath(root, header.cwd, header.id, 'none'), [ JSON.stringify(toHeaderLine(header)), ...oneTurnLog().map(e => JSON.stringify(e)), '', ].join('\n')) - await expect(ctx.sessionPersistence.append(header.id, oneTurnLog())).rejects.toThrow(/uses \.jsonl/) + await expect(handle.append(oneTurnLog())).rejects.toThrow(/uses \.jsonl/) + await handle.close() expect((await readdir(sessionDir(root, header.cwd, header.id))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false) }) }) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 6cb47067fa..8d50b72854 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/session-persistence/README.md -README.md: 6d430173b985d7e32b615e0b42713a925bc8b344 -README.zh.md: a3b6cd90fa4ccba2b28f0e81517bbabab0ce047e +README.md: 2c7716e1512a9b5dac2ae966c6d59ac45d03297a +README.zh.md: 0039ef280e730afe3f234d3658c11b7a2c4cc2f9 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 6d430173b9..2c7716e151 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-persistence` stores a session's event log durably, reloads it on resume, and lists stored sessions through the backend-neutral `ctx.sessionPersistence` service. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type. `SessionHeader.isSeeded` makes lineage visible to lightweight listing, while the exact `inheritedEventCount` accompanies every body-bearing storage read and prepared Session. A backend owns its storage, while the service owns append-only logs, contiguous sequence numbers, crash recovery that preserves an interrupted turn instead of truncating it, and durable writes that resolve only after the batch is safe. The shipped JSONL provider implements this service with one artifact per Session; third-party providers may implement the same contract without changing the loop or model. +`dsh-session-persistence` stores a session's event log durably and addresses each stored session through one per-session handle: the backend-neutral service (`ctx.sessionPersistence`) exposes `create`/`open`/`stat`/`list`, and `create`/`open` return a `SessionHandle` that carries every log read and write plus single-writer ownership. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type — and non-replayable metadata (format version, working directory, lineage, seed boundary) travels separately as `SessionHeader`. Backends own their storage, the seam owns the semantics: append-only contiguous logs, best-effort appends behind an explicit `flush` durability barrier, a torn physical tail that never reaches a reader, fail-closed validation of stored records, and in-process exclusion of a second writer. Mount the shipped [JSONL backend](../session-persistence-jsonl/README.md) (one artifact per session) and agent-loop persists and resumes sessions without the loop or the model knowing which backend is underneath. ## Table of Contents @@ -25,33 +25,44 @@ English | [中文](README.zh.md) ## Use this package -Mount one persistence backend to make sessions durable. The backend registers itself as `ctx.sessionPersistence`; nothing else in the composition changes — the loop, resume, and replay all call the same service. +Mount one persistence backend to make sessions durable. The backend registers itself as `ctx.sessionPersistence` and routes every published session's live events into that session's active write handle; agent-loop — the production publication point for sessions — acquires each session's write handle before publication, so nothing else in the composition changes. ### Choosing a backend -The seam ships the [JSONL](../session-persistence-jsonl/README.md) backend. It stores one append-only `.jsonl.zstd` artifact per Session and returns its absolute path from `locate(meta)`. A third-party backend may implement the service directly; the [backend contract](#understand-the-implementation) below is what it must honor. +The seam ships the [JSONL](../session-persistence-jsonl/README.md) backend: one append-only `.jsonl.zstd` log per session. A third-party backend may implement the service directly; the [backend contract](#understand-the-implementation) below is what it must honor. ### What the service provides -With a backend mounted, you can store a session's events durably, reload the stored log, and list what is stored: +With a backend mounted, five service methods address stored sessions: ```text -await ctx.sessionPersistence.create(meta, inheritedEventCount) // cut required when meta.isSeeded -await ctx.sessionPersistence.ensureMaterialized(session) // persist an empty resumable session -await ctx.sessionPersistence.append(id, events) // durably persist a batch -const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(id) -const headers = await ctx.sessionPersistence.list() // every stored session +const handle = await ctx.sessionPersistence.create(header) // store a new session, take write ownership +const handle = await ctx.sessionPersistence.open(id, 'write') // claim single-writer ownership of an existing session +const reader = await ctx.sessionPersistence.open(id, 'read') // observe without ownership +const snap = await ctx.sessionPersistence.stat(id) // header + revision (+ eventCount / sizeBytes) without a log read +const all = await ctx.sessionPersistence.list() // one snapshot per visible stored session +await ctx.sessionPersistence.flush() // backend-wide durability barrier over every active write handle ``` -`append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create(meta, inheritedEventCount)` remains lazy; `meta.isSeeded: true` requires the sibling exact cut, while unseeded metadata may omit it and rejects a nonzero value. The first materializing batch for a seeded session must reach the complete inherited prefix, so storage never exposes metadata whose cut exceeds its log. A lifecycle frontend calls `ensureMaterialized` only when an empty session must itself appear in durable listing without inventing an event. `load` returns an immutable balanced log and commits any needed crash recovery; `inspect` reads the same complete view without committing recovery. `readFrom` accepts a `SessionLogOffset` and returns a detached `SessionEventSuffix` carrying that `fromSeq`, the unchanged inherited cut, and only stored events at or after the cut. A session's artifact location (`locate`) resolves without filesystem I/O. +Service-level `flush()` drains every active write handle's routed events and materializes its session, exactly as each handle's own `flush` would; failures aggregate per session as an `AggregateError` without abandoning the sweep, and a handle closed mid-sweep counts as flushed because close itself drains durably. + +Every log read and write flows through the returned `SessionHandle`; there are no id-addressed append or load methods. `handle.read(offset?, length?)` returns validated contiguous prefix slices — never a torn tail, and repeated reads on one handle never observe an older state than a prior read; a write handle reads its own successful appends. `handle.append(events)` appends a contiguous batch whose first `seq` equals the stored next-seq; persistence is best-effort on resolution — the batch is accepted, ordered, and visible to reads on this backend instance, and only a resolved `flush` promises it survives a crash (the shipped JSONL backend happens to persist each batch immediately). `handle.flush()` is the durability barrier and also materializes an empty created session so it becomes durably listable. `handle.close()` is idempotent and uncancellable: a read handle frees local resources, a write handle completes pending durability and releases write ownership. Once an `append` or `flush` resolves, reads started afterwards on the same backend instance — on any handle, or through `stat`/`list` — observe at least that prefix. + +### Ownership and visibility + +`create` and `open(id, 'write')` take in-process single-writer ownership: a second write open while an owner is active rejects with `SessionAlreadyOwnedError`, `create` on an occupied id rejects with `SessionAlreadyExistsError`, and a mutation on a `read` handle rejects with `SessionReadOnlyError` — one handle type, runtime refusal. Any operation on a closed handle rejects with `SessionHandleClosedError`, and `SessionOwnershipLostError` marks a write handle whose ownership is permanently gone (close it and reopen). A created session is observable in this process from the moment `create` resolves, while the backend may defer physical materialization until the first `append` or `flush`; other processes see only materialized sessions, and a session that never materialized before a crash never existed. + +### The live write path and shutdown drain + +The backend owns the live write path: it installs the session listeners once and routes every published session's events by id to that session's active write handle — `session/event` copies into a bounded internal batching window, `session/flush` is the immediate durability and error-observation barrier, and `session/disposed` runs the final drain and closes the handle. A published session without an active write handle persists nothing. A background write failure retains its events in order, pauses the automatic path, and is logged; the next explicit flush retries and rejects loudly. `close()` itself drains the routed buffer through the still-open storage before releasing ownership, so backend teardown's close sweep keeps application shutdown lossless even though root-fiber disposal runs fibers' disposers concurrently. ### Resuming and crash recovery -Resume is `load` plus session preparation: the stored log comes back with its header lineage and exact inherited cut intact, so ownership checks do not infer the cut from a marker or the full restore length. A session that crashed mid-turn reloads with its interrupted final turn preserved and balanced: `load` appends synthetic `tool/result` and `turn/end {interrupted}` closers for unanswered calls instead of dropping the events — a single turn can be large, and those events were durably written before the crash. Only a never-fully-written torn tail fragment is discarded. +Persistence returns the physically valid log; semantic repair belongs to the reader. A session that crashed mid-turn keeps its open final turn — a single turn can be large, and those events were durably appended before the crash; only the incomplete fragment of a never-acknowledged torn tail is discarded — complete records recovered from it are durably rewritten by the write path before the handle's first new append. Resume (agent-loop) reads the stored log through its write handle, computes `interruptedTurnClosers` — synthetic `tool/result` errors, any open `step/end`, and `turn/end {interrupted}` — and appends them through the same handle as an ordinary batch. Read-only observers (session-query) balance an interrupted cold log with the same closers in memory only. ### Failures and recovery -A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SESSION_FORMAT_VERSION` remains v0 and this build provides no format-migration path; a newer version instructs the operator to upgrade the harness. The decoder accepts only the bounded same-version record variants named below. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`. A `load` on an id still bound to a live session first flushes its snapshot and rejects while its turn is open; a cold load applies recovery. +A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SESSION_FORMAT_VERSION` remains v0 and this build provides no format-migration path; a newer version instructs the operator to upgrade the harness. The decoder accepts only the bounded same-version record variants named below. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`. ----- @@ -65,33 +76,35 @@ This section explains how the seam realizes durable storage and how backends plu ### Design concept -The package is the Service Definition of a capability seam with two halves. The abstract `SessionPersistence` service is the public contract; a `PersistenceCoordinator` provides backend-neutral orchestration for buffering, serialization, materialization, repair, adoption, and quiescent disposal. The JSONL provider implements the small durable primitives for stored reads, append, repair, and listing; a third-party provider may reuse the same coordinator or implement the service directly. +The package is a seam, not a backend framework: it exports the abstract `SessionPersistence` service, the `SessionHandle` contract, the stable error classes consumers catch, the pure stored-record validation helpers (`storage-contract`), and the branded revision — nothing else. Each provider owns its complete storage runtime (handle class, mutation ordering, single-writer bookkeeping, live-event routing, teardown), and two shared test suites — `runPersistenceContract` and `runLiveWritePathContract` under `tests/` — pin the observable behavior every provider must agree on. Deliberate consequence: providers may resemble each other where their storage happens to be similar, but no implementation machinery crosses the package boundary. ### The invariants every backend honors -- **Append-only; a crashed turn is closed, not truncated.** Flushed events are never rewritten; `load` preserves an interrupted final turn and durably appends synthetic closers. -- **Contiguous `seq`.** A gap in the middle of the log rejects; `append`'s first `seq` must equal the stored next-seq. -- **Lossless JSON data.** Batches pass the shared one-pass lossless-JSON boundary; non-serializable payloads reject at the append site. -- **Durability.** `append` resolves only once the batch is durable. +- **Append-only, contiguous `seq`.** Committed events are never rewritten; `append`'s first `seq` must equal the stored next-seq, and a gap rejects. +- **A torn physical tail never reaches a reader.** It belongs to an append that never resolved; the write path truncates it durably before its first new append. +- **Lossless JSON data.** Batches and headers pass the shared one-pass validate-and-snapshot boundary (`materializeAppendBatch`/`materializeCreateHeader`); non-serializable payloads reject at the call site. +- **Durability.** `append` persists best-effort; `flush` — per handle or service-wide — is the barrier that promises storage and also materializes an empty session. +- **Fail-closed reads.** `validateStoredEvents` refuses unknown event vocabulary and retired pre-release shapes; `assertVersion` refuses foreign format versions. +- **Single writer per backend instance.** The provider's in-process claim is taken at `create`/`open('write')` and released at handle close. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `SessionPersistence` service and re-exported metadata types | -| [`src/coordinator.ts`](src/coordinator.ts) | Shared write orchestration: batching, serialization, repair, adoption, disposal, format refusal | -| [`src/write-behind.ts`](src/write-behind.ts) | The per-session bounded write controller and flush barrier | -| [`src/preparations.ts`](src/preparations.ts) | Bounded retention of unpublished Session preparations for resume reuse | +| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `SessionPersistence` service and re-exported seam vocabulary | +| [`src/handle.ts`](src/handle.ts) | The `SessionHandle` contract: read/append/flush/close semantics and freshness rules | +| [`src/storage-contract.ts`](src/storage-contract.ts) | Shared validation: version gate, fail-closed vocabulary, batch materialization, contiguity | +| [`src/errors.ts`](src/errors.ts) | Stable handle/ownership failures and format refusals | | [`src/revision.ts`](src/revision.ts) | The branded opaque revision token | | — | No runtime invariant companion is published; persistence correctness requires backend round-trip and crash-tail tests; this package exposes no continuously observable in-process relation. | ### The write path at a glance -Each `session/event` copies the event into its session's controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. Expiry starts one durable append; events admitted during that write form a separately bounded follow-up batch. `session/flush` cancels the wait and drains through quiescence, so the loop uses it as the ordering and error-observation checkpoint before the next turn. A rejected background write retains its events and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately. +Each `session/event` for the writer's session copies into that handle's internal buffer. The first pending event starts a fixed batching window; later events join without resetting its deadline. Expiry drains the pending prefix through the handle's mutation chain; events admitted during a drain coalesce into the next chained batch, in order. `session/flush` cancels the wait and drains through quiescence, then runs `handle.flush()`, so the loop uses it as the ordering and error-observation checkpoint before the next turn. A rejected background drain retains its events and pauses the automatic timer; explicit flush, writer close, or backend teardown retries immediately and rejects loudly. Constructor seed events never emit `session/event`, so a seed appended through the handle before publication is never re-enqueued. -### Stored-record compatibility +### Stored-record validation -Backend reads normalize only the explicitly supported v0 record variants before validating current records. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR adoption. Reads do not rewrite stored records, and later appends use current v0. The [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) notes own these bounded exceptions; they are not a general format-migration promise. +Backend reads validate current v0 records only and never rewrite them; appends write current v0 ([rationale](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md)). Every backend runs the same `storage-contract` helpers on every read path — handle reads and write-open priming — refusing an unknown event type as `SessionFormatUnsupportedError` and a retired payload variant of a current type as `SessionPersistenceCorruptionError`, with the raw-log `SessionLocation` attached when the backend keeps one artifact per session. ----- @@ -101,9 +114,10 @@ Backend reads normalize only the explicitly supported v0 record variants before Read these pages when the package-level contract is not enough. They move from the shared durability model to the shipped backends and the decision evidence. -- [Session persistence subsystem](../../../docs/subsystems/persistence.md) — the full service contract, flush checkpoint, crash recovery, and generated Cordis API. +- [Session persistence subsystem](../../../docs/subsystems/persistence.md) — the full service contract, handle semantics, flush checkpoint, crash recovery, and generated Cordis API. +- [Handle-based persistence Agent Note](../../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) — the seam design and its ownership model. - [JSONL persistence backend](../session-persistence-jsonl/README.md) — the shipped per-session-file backend. -- [Session checkpoint policy](../session-checkpoint-policy/README.md) — the plugin that flushes through this service at semantic boundaries. +- [Session checkpoint policy](../session-checkpoint-policy/README.md) — the plugin that flushes through `session/flush` at semantic boundaries. - [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages. ----- @@ -132,9 +146,12 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov These limits define where the seam's guarantees stop. They are current package constraints, not a task backlog. +- **Write ownership is in-process only** — the provider's writer table excludes a second writer inside one backend instance; the durable cross-process lease is the planned next layer on the same handle shape, and until it lands another process must not write the same session. +- **A backend plugin reload under live sessions fails their writers loudly** — a reloaded backend cannot serve handles the old instance issued; writes fail until the sessions restart, and nothing silently re-adopts the logs. +- **Only handle-acquired sessions persist** — `ctx.sessions.create` + `session/flush` alone stores nothing; agent-loop is the production acquisition point, and tests seed storage through `create`/`append`/`close`. - **No deletion or retention API** — 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. -- **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. +- **`list()` is unpaginated and unfiltered** — it returns every stored session's snapshot; fine for local stores, unindexed at scale. +- **Synthetic closers are the only crash story** — resume appends `interruptedTurnClosers` through the write handle; there is no partial-turn resume that continues an interrupted turn instead of closing it. ### Dev Note diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index a3b6cd90fa..0039ef280e 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-persistence` 通过后端无关的 `ctx.sessionPersistence` 服务持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型。`SessionHeader.isSeeded` 让轻量列表可见血缘,而精确的 `inheritedEventCount` 随每次带正文的存储读取与 prepared Session 一同传输。后端拥有自己的存储,而服务拥有仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。随产品交付的 JSONL provider 用每个 Session 一份产物实现该服务;第三方 provider 可以实现同一约定,而不改变 loop 或模型。 +`dsh-session-persistence` 持久存储会话的事件日志,并通过一个逐会话句柄寻址每个已存储会话:后端无关服务(`ctx.sessionPersistence`)暴露 `create`/`open`/`stat`/`list`,`create`/`open` 返回承载全部日志读写与单写者所有权的 `SessionHandle`。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型——不可回放的元数据(格式版本、工作目录、血缘、种子边界)作为 `SessionHeader` 单独传输。后端拥有自己的存储,seam 拥有语义:仅追加的连续日志、以显式 `flush` 持久性屏障托底的尽力而为 append、绝不到达读取方的撕裂物理尾部、失败即关闭的存储记录校验,以及进程内排除第二个写入方。挂载随产品交付的 [JSONL 后端](../session-persistence-jsonl/README.zh.md)(每个会话一份产物),agent-loop 就会持久化并恢复会话,loop 与模型无需知道下面是哪个后端。 ## 目录 @@ -25,33 +25,44 @@ kind: "package-reference" ## 使用本包 -挂载一个持久化后端即可让会话持久化。后端把自己注册为 `ctx.sessionPersistence`;组合中的其他部分不变——loop、恢复与回放调用的是同一个服务。 +挂载一个持久化后端即可让会话持久化。后端把自己注册为 `ctx.sessionPersistence`,并把每个已发布会话的实时事件路由进该会话的活跃写句柄;agent-loop——会话在生产环境中的发布点——在发布之前获取每个会话的写句柄,因此组合中的其他部分不变。 ### 选择后端 -seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端。它把每个 Session 存为一份仅追加 `.jsonl.zstd` 产物,并由 `locate(meta)` 返回绝对路径。第三方后端可以直接实现该服务;必须遵守的[后端约定](#understand-the-implementation)见下文。 +seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端。它把每个 Session 存为一份仅追加 `.jsonl.zstd` 产物。第三方后端可以直接实现该服务;必须遵守的[后端约定](#understand-the-implementation)见下文。 ### 服务提供什么 -挂载后端后,你可以持久存储会话事件、重新加载已存储日志并列出已存储内容: +挂载后端后,五个服务方法寻址已存储会话: ```text -await ctx.sessionPersistence.create(meta, inheritedEventCount) // cut required when meta.isSeeded -await ctx.sessionPersistence.ensureMaterialized(session) // persist an empty resumable session -await ctx.sessionPersistence.append(id, events) // durably persist a batch -const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(id) -const headers = await ctx.sessionPersistence.list() // every stored session +const handle = await ctx.sessionPersistence.create(header) // store a new session, take write ownership +const handle = await ctx.sessionPersistence.open(id, 'write') // claim single-writer ownership of an existing session +const reader = await ctx.sessionPersistence.open(id, 'read') // observe without ownership +const snap = await ctx.sessionPersistence.stat(id) // header + revision (+ eventCount / sizeBytes) without a log read +const all = await ctx.sessionPersistence.list() // one snapshot per visible stored session +await ctx.sessionPersistence.flush() // backend-wide durability barrier over every active write handle ``` -`append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create(meta, inheritedEventCount)` 保持惰性;`meta.isSeeded: true` 要求单独的精确 cut,unseeded metadata 可以省略它并拒绝非零值。seeded 会话的首个物化批次必须到达完整继承前缀,因此存储绝不公开 cut 超过日志的 metadata。只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复;`inspect` 读取同一份完整视图但不提交恢复。`readFrom` 接受 `SessionLogOffset`,并返回分离的 `SessionEventSuffix`,其中携带该 `fromSeq`、不变的继承 cut,以及 cut 位置或之后的存储事件。会话的产物位置(`locate`)不经文件系统 I/O 即可解析。 +服务级 `flush()` 排空每个活跃写句柄已路由的事件并把其会话实体化,效果与各句柄自己的 `flush` 完全相同;失败按会话聚合为一个 `AggregateError` 而不中途放弃清扫,清扫途中被关闭的句柄视同已 flush,因为 close 本身会持久排空。 + +每一次日志读写都流经返回的 `SessionHandle`;不存在按 id 寻址的 append 或 load 方法。`handle.read(offset?, length?)` 返回经过验证的连续前缀切片——绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态;写句柄能读到自己成功的 append。`handle.append(events)` 追加一个连续批次,其第一个 `seq` 等于已存储 next-seq;完成时的持久化是尽力而为的——批次被接受、有序,并对同一后端实例上的读取可见,只有完成的 `flush` 才承诺它在崩溃后依然存在(交付的 JSONL 后端恰好会立即持久化每个批次)。`handle.flush()` 是持久性屏障,同时把空的已创建会话实体化,使其可被持久列出。`handle.close()` 幂等且不可取消:读句柄释放本地资源,写句柄完成待处理的持久化并释放写所有权。一旦某次 `append` 或 `flush` 完成,其后在同一后端实例上开始的读取——无论经由任何句柄,还是经由 `stat`/`list`——至少能观察到该前缀。 + +### 所有权与可见性 + +`create` 与 `open(id, 'write')` 取得进程内单写者所有权:在持有者活跃期间第二次以写模式打开会以 `SessionAlreadyOwnedError` 拒绝,对已占用 id 执行 `create` 会以 `SessionAlreadyExistsError` 拒绝,在 `read` 句柄上执行修改会以 `SessionReadOnlyError` 拒绝——一种句柄类型,运行时拒绝。对已关闭句柄的任何操作会以 `SessionHandleClosedError` 拒绝,`SessionOwnershipLostError` 标记写所有权已永久丢失的写句柄(关闭并重新打开)。已创建的会话自 `create` 完成之刻起即可在本进程内被观察到,而后端可以把物理实体化推迟到第一次 `append` 或 `flush`;其他进程只能看到已实体化的会话,一个在崩溃前从未实体化的会话等于从未存在。 + +### 实时写路径与关闭排空 + +实时写路径由后端自持:它一次性安装会话监听器,把每个已发布会话的事件按 id 路由到该会话的活跃写句柄——`session/event` 复制进有界的内部批处理窗口,`session/flush` 是即时的持久性与错误观察屏障,`session/disposed` 执行最终排空并关闭句柄。没有活跃写句柄的已发布会话不做任何持久化。后台写入失败时按序保留其事件、暂停自动路径并记入日志;下一次显式 flush 会重试并响亮地拒绝。`close()` 本身会先经由仍然打开的存储排空路由缓冲区再释放所有权,因此即便根 fiber 的 dispose 并发运行各 fiber 的 disposer,后端 teardown 的关闭清扫也能保证应用关闭不丢数据。 ### 恢复与崩溃恢复 -恢复就是 `load` 加会话准备:存储日志连同其 header 血缘与精确继承切点一起返回,因此所有权检查不从标记或完整恢复长度推断切点。中途崩溃的会话重新加载时,其被中断的最终轮次会保留并保持平衡:`load` 为未获回答的调用追加合成 `tool/result` 与 `turn/end {interrupted}` closer,而不是丢弃事件——单个轮次可能很大,而这些事件在崩溃前已持久写入。只有从未完整写入的撕裂尾部碎片会被丢弃。 +持久化返回物理上有效的日志;语义修复属于读方。中途崩溃的会话保留其未闭合的最终轮次——单个轮次可能很大,而这些事件在崩溃前已持久追加;只有从未确认的撕裂尾部中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在句柄的第一次新 append 之前持久重写。恢复(agent-loop)通过其写句柄读取已存储日志,计算 `interruptedTurnClosers`——合成 `tool/result` 错误、任何未闭合的 `step/end`,以及 `turn/end {interrupted}`——并把它们作为普通批次通过同一句柄追加。只读观察方(session-query)仅在内存中用同样的 closer 配平被中断的冷日志。 ### 失败与恢复 -当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SESSION_FORMAT_VERSION` 保持 v0,本构建不提供格式迁移路径;更高版本会要求操作者升级 harness。解码器只接受下文点名的有限同版本记录变体。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。对仍绑定到活动会话的 id 执行 `load`,会先刷新其快照并在轮次开放时拒绝;冷 load 应用恢复。 +当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SESSION_FORMAT_VERSION` 保持 v0,本构建不提供格式迁移路径;更高版本会要求操作者升级 harness。解码器只接受下文点名的有限同版本记录变体。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。 ----- @@ -65,33 +76,35 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi ### 设计理念 -本包是能力 seam 的 Service Definition,分两半。抽象的 `SessionPersistence` 服务是公开约定;`PersistenceCoordinator` 为缓冲、串行化、物化、修复、接管与完全停稳的 dispose 提供后端无关编排。JSONL provider 实现存储读取、追加、修复与列出所需的小型持久原语;第三方 provider 可以复用同一 coordinator,也可以直接实现该服务。 +本包是 seam,而不是后端框架:它只导出抽象 `SessionPersistence` 服务、`SessionHandle` 约定、消费方捕获的稳定 error 类、纯函数的存储记录校验辅助(`storage-contract`)以及带品牌类型的 revision——再无其他。每个 provider 拥有自己完整的存储运行时(句柄类、修改排序、单写者记账、实时事件路由、teardown),`tests/` 下的两套共享测试套件——`runPersistenceContract` 与 `runLiveWritePathContract`——钉住每个 provider 都必须一致的可观察行为。有意为之的后果:各 provider 在存储恰好相似之处可以彼此相像,但没有任何实现机制跨越包边界。 ### 每个后端必须遵守的不变量 -- **仅追加;崩溃轮次会被关闭,而非截断。** 已 flush 事件绝不重写;`load` 保留中断的最终轮次并持久追加合成 closer。 -- **连续 `seq`。** 日志中间的缺口会被拒绝;`append` 的第一个 `seq` 必须等于已存储 next-seq。 -- **无损 JSON 数据。** 批次经过共享单遍无损 JSON 边界;无法序列化的载荷在 append 处被拒绝。 -- **持久性。** `append` 只在批次持久后返回。 +- **仅追加,连续 `seq`。** 已提交事件绝不重写;`append` 的第一个 `seq` 必须等于已存储 next-seq,缺口会被拒绝。 +- **撕裂的物理尾部绝不到达读取方。** 它属于一次从未完成的 append;写路径在第一次新 append 之前将其持久截断。 +- **无损 JSON 数据。** 批次与 header 经过共享的单遍校验并快照边界(`materializeAppendBatch`/`materializeCreateHeader`);无法序列化的载荷在调用处被拒绝。 +- **持久性。** `append` 尽力而为地持久化;`flush`——逐句柄或服务级——是承诺存储并同时把空会话实体化的屏障。 +- **失败即关闭的读取。** `validateStoredEvents` 拒绝未知事件词汇与已废弃的预发布形态;`assertVersion` 拒绝外来格式版本。 +- **每个后端实例单写者。** provider 的进程内认领在 `create`/`open('write')` 时取得,在句柄关闭时释放。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SessionPersistence` 服务与重新导出的元数据类型 | -| [`src/coordinator.ts`](src/coordinator.ts) | 共享写入编排:批处理、串行化、修复、接管、dispose、格式拒绝 | -| [`src/write-behind.ts`](src/write-behind.ts) | 每会话有界写入控制器与 flush 屏障 | -| [`src/preparations.ts`](src/preparations.ts) | 为恢复复用而有界保留的未发布 Session 准备结果 | +| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SessionPersistence` 服务与重新导出的 seam 词汇 | +| [`src/handle.ts`](src/handle.ts) | `SessionHandle` 约定:read/append/flush/close 语义与新鲜度规则 | +| [`src/storage-contract.ts`](src/storage-contract.ts) | 共享校验:版本门、失败即关闭词汇表、批次实体化、连续性 | +| [`src/errors.ts`](src/errors.ts) | 稳定的句柄/所有权失败与格式拒绝 | | [`src/revision.ts`](src/revision.ts) | 带品牌类型的不透明修订值 token | -| — | 不发布运行时不变式伴生入口;协调器断言存储/活动身份与 cwd。 | +| — | 不发布运行时不变式伴生入口;持久化正确性需要后端往返与崩溃尾部测试;本包不暴露可持续观察的进程内关系。 | ### 写入路径概览 -每个 `session/event` 把事件复制到其会话的 controller。第一个待处理事件开启固定批处理窗口;后续事件加入但不重置截止时间。窗口到期后启动一次持久追加;该次写入期间接纳的事件形成另一个独立有界的后续批次。`session/flush` 取消等待并排空至完全停稳,因此 loop 在下一轮次前把它用作排序与错误观察检查点。被拒绝的后台写入保留其事件并暂停自动重试;新事件开启新窗口,而显式 flush 或后端拆卸会立即重试。 +写入器会话的每个 `session/event` 都复制进该句柄的内部缓冲。第一个待处理事件开启固定批处理窗口;后续事件加入但不重置截止时间。窗口到期后经由句柄的修改链排空待处理前缀;排空期间接纳的事件按顺序合并进下一个链上的批次。`session/flush` 取消等待并排空至完全停稳,随后运行 `handle.flush()`,因此 loop 在下一轮次前把它用作排序与错误观察检查点。被拒绝的后台排空保留其事件并暂停自动计时器;显式 flush、写入器 close 或后端 teardown 会立即重试并响亮地拒绝。构造 seed 事件绝不发出 `session/event`,因此发布前通过句柄追加的 seed 绝不会被重新入队。 -### 存储记录兼容 +### 存储记录校验 -后端读取只会在校验当前记录之前,规范化明确支持的 v0 记录变体。协调器对 `load`、`inspect`、`readFrom`、无所有者状态认领与 HMR 接管使用同一份规范化视图。读取不会重写已存记录,后续追加使用当前 v0。[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md)笔记规定这些有限例外;它们不构成通用格式迁移承诺。 +后端读取只校验当前 v0 记录且绝不重写它们;追加写入当前 v0([理由](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md))。每个后端在每条读取路径——句柄读取与写打开预热——上运行同一套 `storage-contract` 辅助函数,把未知事件类型作为 `SessionFormatUnsupportedError` 拒绝,把当前类型的已废弃载荷变体作为 `SessionPersistenceCorruptionError` 拒绝,并在后端为每个会话保留一份产物时附上原始日志的 `SessionLocation`。 ----- @@ -101,9 +114,10 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi 当包级约定不够用时阅读以下页面。它们从共享持久性模型逐步进入随产品交付的后端与决策证据。 -- [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——完整服务约定、flush 检查点、崩溃恢复与生成的 Cordis API。 +- [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——完整服务约定、句柄语义、flush 检查点、崩溃恢复与生成的 Cordis API。 +- [基于句柄的持久化 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)——seam 设计及其所有权模型。 - [JSONL 持久化后端](../session-persistence-jsonl/README.zh.md)——随产品交付、按会话存储文件的后端。 -- [会话检查点策略](../session-checkpoint-policy/README.zh.md)——在语义边界上经由本服务刷新的插件。 +- [会话检查点策略](../session-checkpoint-policy/README.zh.md)——在语义边界上经由 `session/flush` 刷新的插件。 - [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。 ----- @@ -132,9 +146,12 @@ seam 不添加提示词或 schema。恢复会将已存储的表层事件还原 这些限制界定 seam 保证的终点。它们是当前包约束,不是任务积压。 +- **写所有权仅限进程内**——provider 的写入器表只在单个后端实例内排除第二个写入方;持久的跨进程租约是计划在同一句柄形态上叠加的下一层,在它落地之前另一进程不得写入同一会话。 +- **在有活跃会话时重载后端插件会使其写入器响亮地失败**——重载后的后端无法服务旧实例签发的句柄;写入会持续失败直到会话重启,没有任何机制静默重新接管日志。 +- **只有通过句柄获取的会话才会持久化**——仅靠 `ctx.sessions.create` + `session/flush` 不存储任何内容;agent-loop 是生产环境的获取点,测试通过 `create`/`append`/`close` 播种存储。 - **无删除或保留接口**——剪枝已存储会话属于带外后端维护。 -- **`list()` 无分页且无过滤**——它返回每个已存储会话的 header;适合本地存储,大规模时无索引。 -- **合成 closer 是唯一崩溃方案**——后端必须在 load 时合成 `tool/result`/`step/end`/`turn/end` closer;没有继续中断轮次而不先关闭它的部分轮次恢复。 +- **`list()` 无分页且无过滤**——它返回每个已存储会话的快照;适合本地存储,大规模时无索引。 +- **合成 closer 是唯一崩溃方案**——恢复通过写句柄追加 `interruptedTurnClosers`;没有继续中断轮次而不先关闭它的部分轮次恢复。 ### 开发备注 diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts deleted file mode 100644 index c16fae6d40..0000000000 --- a/packages/session/session-persistence/src/coordinator.ts +++ /dev/null @@ -1,1564 +0,0 @@ -/** - * Shared buffering, serialization, adoption, repair, and disposal orchestration - * for first-party backends. Third-party backends may implement the public - * persistence seam directly. - * @module @deepseek-ai/dsh-session-persistence/coordinator - */ - -import { Context } from '@deepseek-ai/cordis' -import { - adoptSessionEvent, - interruptedTurnClosers, - KNOWN_SESSION_EVENT_TYPES, - SESSION_FORMAT_VERSION, - SessionLogOffset, - SessionPreparation, - SessionSeq, - snapshotSessionEvent, -} from '@deepseek-ai/dsh-session' -import type { - Session, - SessionEvent, - SessionId, - SessionHeader, - SessionLogOffset as SessionLogOffsetType, - SessionSeq as SessionSeqType, -} from '@deepseek-ai/dsh-session' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' -import type { - BorrowedSessionSource, - SessionEventSuffix, - SessionInspection, - SessionLocation, - SessionStorageMetadata, -} from './index.ts' -import { SessionPersistenceNotFoundError } from './errors.ts' -import type { SessionPersistenceRevision } from './revision.ts' -import { observeQueuedAbort, SessionPreparations } from './preparations.ts' -import type { SessionPreparationReservation } from './preparations.ts' -import { SessionWriteBehind } from './write-behind.ts' - -/** Default number of detached session preparations retained by a coordinator. */ -export const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5 - -/** Default maximum intentional wait before a live session batch starts writing. */ -export const DEFAULT_WRITE_BATCH_MAX_DELAY_MS = 200 - -/** Largest write batching delay accepted by Node's timer implementation. */ -export const MAX_WRITE_BATCH_DELAY_MS = MAX_TIMER_DELAY_MS - -/** Durable session contents failed validation after a successful backend read. */ -export class SessionPersistenceCorruptionError extends Error { - /** - * @param message - stable corruption context. - * @param options - original validation failure. - */ - constructor(message: string, options: ErrorOptions) { - super(message, options) - this.name = 'SessionPersistenceCorruptionError' - } -} - -/** - * The stored log is intact but this runtime cannot faithfully interpret it: - * the header carries an unsupported format version, or an event's type is - * unknown to this build and the event is not marked ignorable. Distinct from - * {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log - * remains readable at {@link location} when the backend keeps one artifact - * per session. - */ -export class SessionFormatUnsupportedError extends Error { - /** - * @param message - stable reason the log cannot be interpreted, already - * including the raw-log path when one exists. - * @param location - the backend's artifact location, when one exists. - */ - constructor(message: string, readonly location?: SessionLocation) { - super(message) - this.name = 'SessionFormatUnsupportedError' - } -} - -/** - * Direction-aware refusal text for a stored session whose format version this - * build does not read. Shared by the coordinator's load-time check and by - * backends that must refuse BEFORE decoding version-dependent structure (a - * future format may not satisfy this build's structural checks at all, and the - * user must see "upgrade the harness", never "corrupt"). - * @param id - the stored session id, for message context. - * @param version - the stored format version. - * @returns the stable refusal text, without a raw-log path suffix. - */ -export function sessionFormatVersionRefusal(id: string, version: number): string { - return version > SESSION_FORMAT_VERSION - ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` - : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` -} - -/** Coordinator policy supplied by a concrete persistence backend. */ -export interface PersistenceCoordinatorOptions { - /** Maximum completed unpublished preparations retained for reuse. */ - readonly preparedSessionCacheSize: number - /** Maximum intentional batching wait after an idle live queue receives work. */ - readonly writeBatchMaxDelayMs: number -} - -/** - * A stored session's header, valid contiguous event prefix, source-qualified - * revision, and optional opaque torn-tail marker. The revision identifies the - * exact detached prefix. The coordinator only checks marker presence and - * returns its value to {@link PersistenceBackend.commitRepair}; each backend - * owns the marker type. - */ -export interface StoredPrefix extends SessionStorageMetadata { - events: SessionEvent[] - /** Revision observed for exactly this detached prefix. */ - revision: SessionPersistenceRevision - tornMarker?: TornMarker -} - -/** - * A stored session's header plus the events at or past a requested seq — the - * return shape of the optional seek-capable - * {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no - * torn marker: there is nothing to repair. - */ -export interface StoredSuffix extends SessionStorageMetadata { - events: SessionEvent[] -} - -/** - * The storage contract between {@link PersistenceCoordinator} and a concrete - * backend: the minimal set of durable primitives the orchestration calls. A - * backend implements these (over files, rows, an object store, …); the - * coordinator supplies everything else (buffering, serialization, cursors, - * adoption, crash repair sequencing, dispose quiescence). - * - * @typeParam TornMarker - the backend's opaque torn-tail repair token (see - * {@link StoredPrefix}). The coordinator treats it as fully opaque. - */ -export interface PersistenceBackend { - /** Human-readable backend name, used in the dispose-failure AggregateError. */ - readonly name: string - - /** - * Read a stored prefix by id, scanning every backend storage scope. Returns - * `undefined` if no stored artifact exists. Returned metadata must identify - * `id` before repair or state publication. Used by resume/load, live adoption, - * and — via `!== undefined` — the create-collision probe. The returned - * `tornMarker` is present iff there is a torn tail to truncate. Every header - * and event graph must be fresh, mutually unaliased, and unretained by the - * backend because preparation freezes and publishes them in place. The - * returned revision must identify exactly those values and use the same - * representation as {@link readStoredRevision}. - * @param id - persisted session id to resolve. - * @param signal - optional cancellation for backend read work. - */ - loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> - - /** - * Read the current source-qualified revision for one stored session without - * loading its event log. Returns `undefined` when the identity is absent. - * @param id - persisted session id to observe. - * @param signal - optional cancellation for backend read work. - */ - readStoredRevision(id: SessionId, signal?: AbortSignal): Promise - - /** - * Optional seek-capable suffix read behind the service's `readFrom`: return - * the header plus the stored events with `seq >= fromSeq` without reading - * the whole log. A backend whose medium can address events by seq implements - * this so `readFrom` scales with the suffix; sequential backends - * omit it and the coordinator falls back to {@link loadStored} plus a - * forward skip. Non-mutating (no truncation, no closers). Validation of the - * region strictly below `fromSeq` is limited to seq contiguity — the - * service contract scopes this read to the suffix — unless that suffix - * contains a supported legacy shape whose normalization needs earlier - * message-identity facts, in which case the coordinator falls back - * to the complete stored prefix. - * Unknown-type refusal follows the same suffix scope: a seek-capable - * backend's `readFrom` checks only the returned suffix, while the - * sequential fallback parses the whole artifact and refuses on an unknown - * required event anywhere in it — over-refusal on the sequential side is - * accepted rather than widening the seek read. - * @param id - persisted session id to resolve. - * @param fromSeq - first event seq to include (non-negative safe integer, - * validated by the coordinator before this hook runs). - * @param signal - optional cancellation for backend read work. - */ - loadStoredFrom?(id: SessionId, fromSeq: SessionLogOffsetType, signal?: AbortSignal): Promise - - /** Durably create an empty header-only session artifact. */ - materializeHeader?(storage: SessionStorageMetadata): Promise - - /** - * Durably append a CONTIGUOUS batch, lazily materializing the session first - * when `!isMaterialized`. The materialize-write and the first event batch MUST - * commit ATOMICALLY (a crash between them must not leave a materialized-but- - * empty session). Returns once the batch is durable. - * The coordinator calls this only after a first batch reaches the declared - * inherited prefix length. - */ - appendBatch( - storage: SessionStorageMetadata, - events: readonly SessionEvent[], - isMaterialized: boolean, - ): Promise - - /** - * Make a crash repair durable: truncate the torn tail (iff - * `tornMarker !== undefined`) and append `closers` (iff any). NOT required to - * be atomic — a file backend may truncate-then-append in two fsync'd steps. - * Used by load (truncate + synthetic closers) and by live-adoption (truncate - * only, `closers = []`). - */ - commitRepair( - storage: SessionStorageMetadata, - tornMarker: TornMarker | undefined, - closers: readonly SessionEvent[], - ): Promise - - /** - * List all stored (materialized) sessions' metadata. - * @param signal - optional cancellation for backend listing work. - */ - list(signal?: AbortSignal): Promise - - /** - * Optional side-effect-free artifact locator, used to point refusal - * diagnostics ({@link SessionFormatUnsupportedError}) at the raw log. - * Backends without one artifact per session omit it or return `undefined`. - * @param meta - the header whose artifact is requested. - */ - locate?(meta: SessionHeader): SessionLocation | undefined - - /** - * Optional lifecycle teardown (e.g. close a database handle). Awaited by the - * coordinator's dispose effect AFTER the quiescence drain. A stateless file - * backend omits it. - */ - close?(): Promise -} - -/** Per-session write state held by the coordinator's in-memory bookkeeping. */ -interface SessionState { - storage: SessionStorageMetadata - /** The next seq the backend expects to append (the stored log length). */ - cursor: SessionLogOffsetType - /** - * Whether lazy creation has produced a durable artifact. The first append - * atomically materializes the header with events; reclaim logic uses this to - * distinguish an unused id from a persisted collision. - */ - materialized: boolean - /** - * The live Session this state was bound to via `onCreated`, if any. State - * created through the public `create()`/`load()` API has no owner; state bound - * to a live session lets `onCreated` reject a second, unrelated session on the - * same id (a collision) instead of silently no-opping. - */ - owner?: Session -} - -/** One live session's initialization and bounded write-behind controller. */ -interface LiveSessionState { - init: Promise - writes: SessionWriteBehind -} - -/** One validated cold source and the exact unpublished Session built from it. */ -interface PreparedSessionSource { - readonly inspection: SessionInspection - readonly session: Session - readonly revision: SessionPersistenceRevision - /** Session length after constructor-owned seed markers were appended. */ - readonly sessionLength: SessionLogOffsetType - readonly tornMarker: TornMarker | undefined - readonly closers: readonly SessionEvent[] -} - -/** Collect the rejection reasons from a set of promises (none-throwing). */ -async function settledErrors(promises: Iterable>): Promise { - const settled = await Promise.allSettled([...promises]) - const errors: unknown[] = [] - for (const result of settled) { - if (result.status === 'rejected') errors.push(result.reason) - } - return errors -} - -/** Whether a live session seed reproduces a persisted prefix exactly. */ -function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean { - return prefix.length <= seed.length - && prefix.every((event, index) => { - const seedEvent = seed[index] - return seedEvent !== undefined && JSON.stringify(seedEvent) === JSON.stringify(event) - }) -} - -/** Normalize the exact fork cut paired with one logical Session header. */ -function storageMetadata( - meta: SessionHeader, - inheritedEventCount?: SessionLogOffsetType, -): SessionStorageMetadata { - if (meta.isSeeded && inheritedEventCount === undefined) { - throw new TypeError('seeded session metadata requires an inherited event count') - } - const cut = SessionLogOffset(inheritedEventCount ?? 0) - if (!meta.isSeeded && cut !== 0) { - throw new TypeError('unseeded session metadata inherited event count must be 0') - } - return { meta, inheritedEventCount: cut } -} - -/** Exact storage metadata owned by one live Session. */ -function sessionStorageMetadata(session: Session): SessionStorageMetadata { - return storageMetadata(session.header, session.inheritedEventCount) -} - -/** Reject events from an obsolete v0 vocabulary that this build cannot replay. */ -function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void { - const legacyType: string = 'request/header-delta' - const legacy = events.find(event => event.type === legacyType) - if (legacy !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`) - } - const legacyModeType: string = 'mode/set' - const legacyMode = events.find(event => event.type === legacyModeType) - if (legacyMode !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${legacyMode.seq}`) - } - const fallback = events.find(event => event.type === 'request/header' - && (event.data as { reason?: string }).reason === 'fallback') - if (fallback !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`) - } -} - -/** Return an object record without widening arrays into message payloads. */ -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : undefined -} - -/** Whether a record contains every required key and no key outside the optional extension set. */ -function hasOnlyKeys( - record: Record, - required: readonly string[], - optional: readonly string[] = [], -): boolean { - const allowed = [...required, ...optional] - return Object.keys(record).every(key => allowed.includes(key)) - && required.every(key => Object.hasOwn(record, key)) -} - -type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] - -/** Mint the stable import identity for a message persisted before identities existed. */ -function legacyMessageId(id: SessionId, seq: SessionSeqType): PersistedMessageId { - return `legacy-message:${id}:${seq}` as PersistedMessageId -} - -/** Read a replacement target while leaving malformed surface metadata to the session validator. */ -function replacementStart(event: SessionEvent): SessionSeqType | undefined { - const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) - if (op?.['op'] !== 'replace' || typeof op['start'] !== 'number') return undefined - try { - return SessionSeq(op['start']) - } catch { - return undefined - } -} - -/** Whether one suffix event needs facts available only from the preceding stored prefix. */ -function needsLegacyPrefix(event: SessionEvent): boolean { - const data = asRecord(event.data) - const legacySteeringType: string = 'steering/message' - if (event.type === legacySteeringType) return true - if (data === undefined) return false - switch (event.type) { - case 'user/message': - return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content') - case 'assistant/message': - return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content') - case 'tool/result': - return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId') - default: - return false - } -} - -/** Upgrade the removed steering surface event into its current user-message equivalent. */ -function migrateLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { - const legacyType: string = 'steering/message' - if (event.type !== legacyType) return event - const data = asRecord(event.data) - if (data === undefined) { - throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) - } - const wrapped = asRecord(data['message']) - if (wrapped !== undefined && Number.isSafeInteger(data['turn']) - && hasOnlyKeys(data, ['turn', 'message'])) { - return { ...event, type: 'user/message', data: wrapped } as SessionEvent - } - if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) { - throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) - } - const { turn: _turn, ...message } = data - return { - ...event, - type: 'user/message', - data: { - ...message, - id: legacyMessageId(id, event.seq), - role: 'user', - }, - } as SessionEvent -} - -/** Remove the obsolete trigger after verifying the complete old turn-start envelope. */ -function migrateLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent { - if (event.type !== 'turn/start') return event - const data = asRecord(event.data) - if (data === undefined || !Object.hasOwn(data, 'trigger')) return event - const trigger = asRecord(data['trigger']) - if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 - || !hasOnlyKeys(data, ['turn', 'trigger']) - || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) { - throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`) - } - return { ...event, data: { turn: data['turn'] } } as SessionEvent -} - -/** Upgrade an obsolete turn ending while preserving the latest-master envelope. */ -function migrateLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent { - if (event.type !== 'turn/end') return event - const data = asRecord(event.data) - /* v8 ignore next -- a non-record current envelope cannot match a legacy shape. */ - if (data === undefined) return event - const malformed = (): never => { - throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`) - } - const reason = asRecord(data['reason']) - if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 - || !hasOnlyKeys(data, ['turn', 'reason']) - || reason === undefined || typeof reason['kind'] !== 'string') return malformed() - - let currentReason: Record | undefined - switch (reason['kind']) { - case 'completed': - case 'blocked': - case 'max-tokens': - case 'interrupted': - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - return event - case 'aborted': - if (Object.hasOwn(reason, 'reason')) return event - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - currentReason = { kind: 'aborted', reason: { kind: 'legacy' } } - break - case 'disposed': - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - currentReason = { kind: 'aborted', reason: { kind: 'disposed' } } - break - case 'error': { - if (Object.hasOwn(reason, 'error')) return event - if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed() - const failure = asRecord(reason['failure']) - if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure']) - && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId']) - && typeof failure['message'] === 'string' && typeof failure['code'] === 'string' - && (failure['status'] === undefined || typeof failure['status'] === 'number') - && (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number') - && (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) { - currentReason = { kind: 'error', error: failure } - break - } - const messageKeys = reason['code'] === undefined - ? ['kind', 'step', 'message'] - : ['kind', 'step', 'message', 'code'] - if (!hasOnlyKeys(reason, messageKeys) - || typeof reason['message'] !== 'string' - || (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed() - currentReason = { - kind: 'error', - error: { - message: reason['message'], - code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN', - }, - } - break - } - default: - return event - } - - return { - ...event, - data: { - ...data, - reason: currentReason, - }, - } as SessionEvent -} - -/** - * Upgrade one pre-identity message event into the current wrapper shape. - * Current-looking malformed events remain untouched so validation rejects them - * instead of disguising corruption as legacy data. - */ -function migrateLegacyMessageEvent( - event: SessionEvent, - id: SessionId, - messageIds: ReadonlyMap, -): SessionEvent { - const data = asRecord(event.data) - if (data === undefined) return event - switch (event.type) { - case 'user/message': { - if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role') - || Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event - return { - ...event, - data: { - ...data, - id: legacyMessageId(id, event.seq), - role: 'user', - }, - } as SessionEvent - } - case 'assistant/message': { - if (Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event - const { content, provenance, ...eventData } = data - return { - ...event, - data: { - ...eventData, - message: { - id: legacyMessageId(id, event.seq), - role: 'assistant', - content, - source: { - ...asRecord(provenance), - kind: 'model', - }, - }, - }, - } as SessionEvent - } - case 'tool/result': { - if (Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content') - || !Object.hasOwn(data, 'isError')) return event - const { callId, content, isError, ...eventData } = data - const inheritedId = replacementStart(event) - return { - ...event, - data: { - ...eventData, - message: { - id: inheritedId === undefined - ? legacyMessageId(id, event.seq) - : messageIds.get(inheritedId), - role: 'user', - content: [{ - type: 'tool-result', - toolCallId: callId, - content, - isError, - }], - source: { - kind: 'tool', - callId, - }, - }, - }, - } as SessionEvent - } - default: - return event - } -} - -/** Read the identified message carried by one validated current event. */ -function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { - const data = asRecord(event.data) - const message = event.type === 'user/message' ? data : asRecord(data?.['message']) - return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined -} - -/** Materialize stored events as upgraded, validated snapshots with immutable messages. */ -function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] { - assertSupportedEvents(events, id) - const messageIds = new Map() - return events.map((event) => { - const migratedStart = migrateLegacyTurnStartEvent(event, id) - const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) - const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id) - const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds)) - const messageId = eventMessageId(snapshot) - if (messageId !== undefined) messageIds.set(snapshot.seq, messageId) - return snapshot - }) -} - -/** Upgrade and validate an exclusively owned backend result without copying it. */ -function adoptStoredEvents(events: SessionEvent[], id: SessionId): SessionEvent[] { - assertSupportedEvents(events, id) - const messageIds = new Map() - for (const [index, event] of events.entries()) { - const migratedStart = migrateLegacyTurnStartEvent(event, id) - const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) - const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id) - const adopted = adoptSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds)) - events[index] = adopted - const messageId = eventMessageId(adopted) - if (messageId !== undefined) messageIds.set(adopted.seq, messageId) - } - return events -} - -/** - * Owns the backend-agnostic session write-path orchestration. A backend - * constructs one (`new PersistenceCoordinator(ctx, this)`), implements - * {@link PersistenceBackend}, and delegates its write/read service methods to - * the matching coordinator methods. - * - * All per-id operations are serialized (a per-id promise chain) so concurrent - * flushes / a flush racing a load never interleave storage writes. The - * constructor installs the write-path listeners, per-session retirement, and - * the backend dispose effect. - * - * @typeParam TornMarker - the backend's opaque torn-tail repair token. - */ -export class PersistenceCoordinator { - /** Backend bookkeeping keyed by session id (NOT the live Session object). */ - private states = new Map() - /** Lifecycle and write-behind state keyed by the exact live Session. */ - private live = new Map() - /** Exact disposed lifecycles whose buffered tail is still draining. */ - private retirements = new Map>() - /** Shared cold reads, unpublished reservations, and completed LRU entries. */ - private readonly preparations: SessionPreparations, SessionState> - /** - * Per-session serialization: every operation chains onto the prior one for the - * same id, so writes for one session never interleave. Keyed by session id. - */ - private chains = new Map>() - /** Resolved fixed write-batching window shared by per-session controllers. */ - private readonly writeBatchMaxDelayMs: number - - constructor( - private ctx: Context, - private backend: PersistenceBackend, - options: PersistenceCoordinatorOptions = { - preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE, - writeBatchMaxDelayMs: DEFAULT_WRITE_BATCH_MAX_DELAY_MS, - }, - ) { - if (!Number.isSafeInteger(options.preparedSessionCacheSize) - || options.preparedSessionCacheSize < 1) { - throw new TypeError('preparedSessionCacheSize must be a positive safe integer') - } - if (!Number.isSafeInteger(options.writeBatchMaxDelayMs) - || options.writeBatchMaxDelayMs < 1 - || options.writeBatchMaxDelayMs > MAX_WRITE_BATCH_DELAY_MS) { - throw new TypeError(`writeBatchMaxDelayMs must be an integer between 1 and ${MAX_WRITE_BATCH_DELAY_MS}`) - } - this.writeBatchMaxDelayMs = options.writeBatchMaxDelayMs - this.preparations = new SessionPreparations(options.preparedSessionCacheSize) - this.installWritePath() - } - - // --- Public API (the backend's service methods delegate here) --- - - /** - * Register detached session metadata for lazy creation on the first append. - * @param meta - header to snapshot; duplicate tracked or persisted ids reject. - * @param inheritedEventCount - exact inherited prefix length; required for - * a seeded header and omitted only for an unseeded header. - */ - create(meta: SessionHeader, inheritedEventCount?: SessionLogOffsetType): Promise { - // Snapshot before queueing so caller mutation cannot diverge the key and header. - const snapshot = snapshotJsonValue(meta) - if (snapshot === undefined) { - return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable')) - } - if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) { - return Promise.reject(new TypeError('session metadata createdAt must be a non-negative safe integer')) - } - let storage: SessionStorageMetadata - try { - storage = storageMetadata(snapshot, inheritedEventCount) - } catch (error: unknown) { - /* v8 ignore next -- Session storage validation only throws Error instances. */ - return Promise.reject(error instanceof Error - ? error - : new TypeError('invalid session storage metadata', { cause: error })) - } - return this.serialize(snapshot.id, () => this.createCore(storage)) - } - - /** - * Materialize one exact live session without inventing a session event. - * @param session - live session already registered through the write path. - */ - async ensureMaterialized(session: Session): Promise { - await this.flush(session) - await this.serialize(session.id, async () => { - const state = this.states.get(session.id) - /* v8 ignore next -- successful live flush always initializes the exact session state. */ - if (state === undefined) throw new Error(`session "${session.id}" is not registered for persistence`) - if (state.materialized) return - if (this.backend.materializeHeader === undefined) { - throw new Error('session persistence backend cannot materialize an empty session') - } - await this.backend.materializeHeader(state.storage) - state.materialized = true - this.preparations.invalidate(session.id) - }) - } - - private async createCore(storage: SessionStorageMetadata): Promise { - const { meta } = storage - // Do NOT clobber an existing session: the SessionId IS the identity. - if (this.states.has(meta.id) || this.preparations.has(meta.id)) { - throw new Error(`session "${meta.id}" already exists in this backend`) - } - // A persisted artifact under this id (in ANY scope) blocks creation: load/ - // resume identify a session by id alone, so a second artifact would make - // resume nondeterministic. - if (await this.backend.loadStored(meta.id) !== undefined) { - throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`) - } - // Pure lazy: record intent only. No artifact until the first append. - this.states.set(meta.id, { - storage, - cursor: SessionLogOffset(0), - materialized: false, - }) - } - - // `async` so synchronous materialization failures below reject (not throw) per - // the Promise contract — callers use `await expect(...).rejects`. - /** - * Durably persist a batch of events. Honors the append-only and contiguous-seq - * contracts; rejects non-JSON-serializable `event.data`. - * @param id - the session the batch belongs to. - * @param events - the contiguous batch to persist, in seq order; materialized - * as a detached lossless-JSON snapshot at call time. - */ - async append(id: SessionId, events: readonly SessionEvent[]): Promise { - // Validate and deep-snapshot the complete batch HERE, in one traversal, - // before the op waits behind the per-session chain. A check followed by - // structuredClone would reread accessors and could sanitize an exotic value - // into an apparently valid record; the single-pass materializer makes the - // checked value exactly the value persisted. - const batch = snapshotJsonValue(events) - if (batch === undefined) { - throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data') - } - return this.serialize(id, () => this.appendCore(id, batch)) - } - - private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { - // Every append route converges here: the public service, live write-behind - // drains, and HMR seed/suffix adoption. Legacy-shape rejection stays at - // this shared boundary so a stale JavaScript plugin cannot persist a - // retired shape this backend refuses to load. The unknown-type guard is - // deliberately read-side only: an append-time refusal would stall a live - // session's durability mid-flight, which costs more than a loud refusal at - // the log's next load (trade-off owned by the session-log-version-mechanism - // Agent Note). - assertSupportedEvents(events, id) - if (events.length === 0) return - this.preparations.assertWritable(id) - let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) - - // Contiguity contract: each event's seq must continue the stored log. - for (const [i, event] of events.entries()) { - if (event.seq !== state.cursor + i) { - throw new Error(`append seq mismatch for "${id}": expected ${state.cursor + i} at index ${i}, got ${event.seq}`) - } - } - - const nextCursor = SessionLogOffset(state.cursor + events.length) - if (!state.materialized && nextCursor < state.storage.inheritedEventCount) { - throw new Error(`session "${id}" cannot materialize before its inherited prefix is complete`) - } - - await this.backend.appendBatch(state.storage, events, state.materialized) - // The durable write is the transaction: mark materialized + advance the - // cursor as soon as it commits (uniform across backends). - state.materialized = true - state.cursor = nextCursor - this.preparations.invalidate(id) - } - - /** - * Prepare and reserve the exact unpublished Session used by resume. - * Revision retries converge once the durable log remains unchanged for one - * read/check round trip; continuous external writers may delay completion. - * @param id - persisted session to prepare. - * @param signal - optional cancellation for reading and repair. - * @returns an owned preparation released after publication or rollback. - */ - async prepare(id: SessionId, signal?: AbortSignal): Promise { - for (;;) { - await this.waitForRetirement(id, signal) - if (this.ctx.sessions.get(id) !== undefined) { - throw new Error(`cannot prepare session "${id}" while it is live`) - } - const reservation = await this.preparations.reserve( - id, - () => this.serialize(id, () => this.prepareCore(id)), - source => this.serialize(id, () => this.commitPrepared(source), signal), - signal, - ) - if (reservation === undefined) continue - if (this.ctx.sessions.get(id) !== undefined) { - this.preparations.release(reservation, false) - throw new Error(`cannot prepare session "${id}" while it is live`) - } - return SessionPreparation.create(reservation.source.session, { - release: () => { - this.preparations.release( - reservation, - reservation.state.owner === undefined - && reservation.source.session.seq === reservation.source.sessionLength, - ) - }, - }) - } - } - - /** - * Commit recovery and return its immutable logical view without publication. - * Revision retries converge once the durable log remains unchanged for one - * read/check round trip; continuous external writers may delay completion. - * @param id - persisted session to load. - * @returns prepared header and balanced events. - */ - async load(id: SessionId): Promise { - for (;;) { - await this.waitForRetirement(id) - const live = this.ctx.sessions.get(id) - if (live !== undefined) return this.loadLiveSnapshot(live) - const reservation = await this.preparations.reserve( - id, - () => this.serialize(id, () => this.prepareCore(id)), - source => this.serialize(id, () => this.commitPrepared(source)), - ) - if (reservation === undefined) continue - const attached = this.ctx.sessions.get(id) - if (attached !== undefined) { - this.preparations.discard(reservation) - return this.loadLiveSnapshot(attached) - } - this.preparations.discard(reservation) - return reservation.source.inspection - } - } - - /** - * Inspect a logical session without publishing it or committing recovery. - * A stale ready source is reloaded. A source already committing or reserved - * for resume remains exclusive, and inspection may borrow its immutable view. - * Revision retries converge once the log is stable for one read/check round - * trip; continuous external writers may delay completion. - * @param id - persisted session to inspect. - * @param signal - optional cancellation for preparation work. - * @returns immutable prepared metadata and events; a live view may have an open turn. - */ - async inspect(id: SessionId, signal?: AbortSignal): Promise { - for (;;) { - signal?.throwIfAborted() - if (this.retirements.has(id)) await this.waitForRetirement(id, signal) - const live = this.ctx.sessions.get(id) - if (live !== undefined) return this.inspectLive(live) - try { - const source = await this.preparations.inspect( - id, - () => this.serialize(id, () => this.prepareCore(id)), - signal, - ) - const attached = this.ctx.sessions.get(id) - if (attached !== undefined) return this.inspectLive(attached) - const current = await this.serialize( - id, - () => this.isPreparedSourceCurrent(source, signal), - signal, - ) - const published = this.ctx.sessions.get(id) - if (published !== undefined) return this.inspectLive(published) - if (current) return source.inspection - if (this.preparations.discardReady(id, source) === 'retained') { - return source.inspection - } - } catch (error: unknown) { - signal?.throwIfAborted() - const attached = this.ctx.sessions.get(id) - if (attached !== undefined) return this.inspectLive(attached) - throw error - } - } - } - - /** - * Borrow one exact logical view while pinning its reusable prepared Session. - * @param id - persisted session to observe. - * @param signal - optional cancellation for preparation work. - * @returns a disposable observation retaining the prepared source. - */ - async borrowSession(id: SessionId, signal?: AbortSignal): Promise { - for (;;) { - signal?.throwIfAborted() - if (this.retirements.has(id)) await this.waitForRetirement(id, signal) - const live = this.ctx.sessions.get(id) - if (live !== undefined) { - return { source: 'live', inspection: this.inspectLive(live), [Symbol.dispose]: () => {} } - } - const observation = await this.preparations.borrow( - id, - () => this.serialize(id, () => this.prepareCore(id)), - signal, - ) - const source = observation.source - try { - const attached = this.ctx.sessions.get(id) - if (attached !== undefined) { - observation[Symbol.dispose]() - return { source: 'live', inspection: this.inspectLive(attached), [Symbol.dispose]: () => {} } - } - const current = await this.serialize( - id, - () => this.isPreparedSourceCurrent(source, signal), - signal, - ) - const published = this.ctx.sessions.get(id) - if (published !== undefined) { - observation[Symbol.dispose]() - return { source: 'live', inspection: this.inspectLive(published), [Symbol.dispose]: () => {} } - } - if (current || this.preparations.discardReady(id, source) === 'retained') { - return { - source: 'prepared', - inspection: source.inspection, - revision: source.revision, - preparedSession: source.session, - [Symbol.dispose]: () => { observation[Symbol.dispose]() }, - } - } - } catch (error: unknown) { - observation[Symbol.dispose]() - signal?.throwIfAborted() - const attached = this.ctx.sessions.get(id) - if (attached !== undefined) { - return { source: 'live', inspection: this.inspectLive(attached), [Symbol.dispose]: () => {} } - } - throw error - } - observation[Symbol.dispose]() - } - } - - /** - * Read the stored events from `fromSeq` onward, detached and non-mutating - * (the read-from-seq primitive behind the service's `readFrom`). Runs on - * the same per-id chain as writes; a backend with the seek-capable - * {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix, - * every other backend reads its stored prefix and skips forward here. - * @param id - 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 stored metadata, the requested offset, and valid events with `seq >= fromSeq`. - */ - readFrom( - id: SessionId, - fromSeq: SessionLogOffsetType, - signal?: AbortSignal, - ): Promise { - try { - SessionLogOffset(fromSeq) - } catch (error: unknown) { - /* v8 ignore next -- Session log-offset validation only throws Error instances. */ - return Promise.reject(error instanceof Error - ? error - : new TypeError('invalid session read offset', { cause: error })) - } - const retired = Promise.resolve(this.retirements.get(id)) - const waited = signal === undefined ? retired : observeQueuedAbort(retired, signal, () => false) - return waited.then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal)) - } - - private async readFromCore( - id: SessionId, - fromSeq: SessionLogOffsetType, - signal?: AbortSignal, - ): Promise { - signal?.throwIfAborted() - if (this.backend.loadStoredFrom !== undefined) { - let suffix: StoredSuffix | undefined - try { - suffix = await this.backend.loadStoredFrom(id, fromSeq, signal) - } catch (error: unknown) { - if (signal?.aborted) signal.throwIfAborted() - throw error - } - signal?.throwIfAborted() - if (suffix === undefined) throw new SessionPersistenceNotFoundError(id) - this.assertStoredId(id, suffix.meta) - this.assertVersion(suffix.meta) - if (suffix.events.some(needsLegacyPrefix)) { - const whole = await this.readStoredPrefix(id, signal) - return { - meta: whole.meta, - inheritedEventCount: whole.inheritedEventCount, - fromSeq, - events: whole.events.filter(event => event.seq >= fromSeq), - } - } - const events = snapshotStoredEvents(suffix.events, id) - this.assertEventsSupported(suffix.meta, events) - return { - meta: structuredClone(suffix.meta), - inheritedEventCount: SessionLogOffset(suffix.inheritedEventCount), - fromSeq, - events, - } - } - const whole = await this.readStoredPrefix(id, signal) - // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. - return { - meta: whole.meta, - inheritedEventCount: whole.inheritedEventCount, - fromSeq, - events: whole.events.slice(fromSeq), - } - } - - /** Read one detached physical prefix without logical recovery or caching. */ - private async readStoredPrefix( - id: SessionId, - signal?: AbortSignal, - ): Promise { - signal?.throwIfAborted() - const stored = await this.backend.loadStored(id, signal) - signal?.throwIfAborted() - if (stored === undefined) throw new SessionPersistenceNotFoundError(id) - this.assertStoredId(id, stored.meta) - this.assertVersion(stored.meta) - const events = snapshotStoredEvents(stored.events, id) - this.assertEventsSupported(stored.meta, events) - return { - meta: structuredClone(stored.meta), - inheritedEventCount: SessionLogOffset(stored.inheritedEventCount), - events, - } - } - - /** Read, repair in memory, validate, and freeze one cold source once. */ - private async prepareCore(id: SessionId): Promise> { - const stored = await this.backend.loadStored(id) - if (stored === undefined) throw new SessionPersistenceNotFoundError(id) - try { - const { meta, inheritedEventCount, events, revision, tornMarker } = stored - this.assertStoredId(id, meta) - this.assertVersion(meta) - const storedEvents = adoptStoredEvents(events, id) - this.assertEventsSupported(meta, storedEvents) - if (inheritedEventCount > storedEvents.length) { - throw new Error(`session "${id}" inherited event count exceeds its stored event count`) - } - - // Preserve complete interrupted events and synthesize only missing closers. - const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) - const balanced = [...storedEvents, ...closers] - const session = this.ctx.sessions.prepare(id, { - seed: balanced, - meta, - inheritedEventCount, - seedSource: 'persistence', - }) - const inspection: SessionInspection = Object.freeze({ - meta: session.header, - inheritedEventCount: session.inheritedEventCount, - events: Object.freeze(balanced), - }) - return { - inspection, - session, - revision, - sessionLength: session.seq, - tornMarker, - closers, - } - } catch (error: unknown) { - // An unsupported format is a refusal over an intact log, not damage — - // surface it unwrapped so callers can point at the raw artifact. - if (error instanceof SessionFormatUnsupportedError) throw error - throw new SessionPersistenceCorruptionError( - `stored session "${id}" failed validation: ${String(error)}`, - { cause: error }, - ) - } - } - - /** Commit one prepared repair and establish its ownerless durable cursor. */ - private async commitPrepared( - source: PreparedSessionSource, - ): Promise<{ source: PreparedSessionSource; state: SessionState } | undefined> { - const id = source.inspection.meta.id - const cursor = SessionLogOffset(source.inspection.events.length) - const existing = this.states.get(id) - if (existing?.owner !== undefined) { - throw new Error(`session "${id}" already has a live persistence owner`) - } - if (!await this.isPreparedSourceCurrent(source)) return undefined - if (source.tornMarker !== undefined || source.closers.length > 0) { - await this.backend.commitRepair(source.inspection, source.tornMarker, source.closers) - // The repair changed the durable revision. Reload the exact committed - // graph instead of associating the old in-memory view with a newer revision. - return undefined - } - const state = existing ?? { - storage: source.inspection, - cursor, - materialized: true, - } - state.storage = source.inspection - state.cursor = cursor - state.materialized = true - this.states.set(id, state) - return { - source, - state, - } - } - - /** Whether one cached source still names the current durable log revision. */ - private async isPreparedSourceCurrent( - source: PreparedSessionSource, - signal?: AbortSignal, - ): Promise { - return await this.backend.readStoredRevision(source.inspection.meta.id, signal) === source.revision - } - - /** Return one durable immutable view of an already-live Session. */ - private async loadLiveSnapshot(session: Session): Promise { - const events = session.snapshotEvents() - await this.flush(session) - const state = this.states.get(session.id) - /* v8 ignore next -- successful flush always publishes this live session's durable state */ - if (state === undefined) throw new Error(`session "${session.id}" lost persistence state during load`) - if (events.length === 0 && !state.materialized) throw new Error(`session "${session.id}" not found`) - if (interruptedTurnClosers(events).length > 0) { - throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`) - } - return Object.freeze({ - meta: state.storage.meta, - inheritedEventCount: state.storage.inheritedEventCount, - events, - }) - } - - /** Borrow one immutable view from an already-live Session. */ - private inspectLive(session: Session): SessionInspection { - return Object.freeze({ - meta: session.header, - inheritedEventCount: session.inheritedEventCount, - events: session.snapshotEvents(), - }) - } - - /** Await one retiring lifecycle with caller cancellation. */ - private waitForRetirement(id: SessionId, signal?: AbortSignal): Promise { - const retired = Promise.resolve(this.retirements.get(id)) - return signal === undefined - ? retired - : observeQueuedAbort(retired, signal, () => false) - } - - // Listing is a direct backend read and needs no coordinator state. - - // --- per-id serialization + adoption helpers --- - - /** - * Run `op` after any in-flight operation for the same session id, so writes for - * one session never interleave. Errors do not poison the chain. NOTE: serialized - * public methods must NOT call each other (deadlock); they call the unserialized - * `*Core` helpers instead. - */ - private serialize( - id: SessionId, - op: () => Promise | T, - signal?: AbortSignal, - ): Promise { - const prior = this.chains.get(id) ?? Promise.resolve() - let started = false - const run = (): Promise | T => { - signal?.throwIfAborted() - started = true - return op() - } - const next = prior.then(run, run) - // Keep the chain alive but swallow this op's rejection for the NEXT waiter - // (the caller still sees the real rejection via `next`). - const tail = next.then(() => undefined, () => undefined) - this.chains.set(id, tail) - // Settled tails carry no serialization value. Delete only the exact tail - // installed above: a later operation may already have replaced it. - void tail.then(() => { - if (this.chains.get(id) === tail) this.chains.delete(id) - }) - return signal === undefined ? next : observeQueuedAbort(next, signal, () => started) - } - - /** Build a state for a session discovered in storage but not yet in memory. */ - private async adopt(id: SessionId): Promise { - // This runs inside the id's serialization chain, so it uses core helpers - // instead of re-entering through public prepare/load methods. - for (;;) { - const source = this.preparations.takeReady(id) ?? await this.prepareCore(id) - const committed = await this.commitPrepared(source) - if (committed !== undefined) return committed.state - } - } - - private assertVersion(meta: SessionHeader): void { - if (meta.version === SESSION_FORMAT_VERSION) return - throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version)) - } - - /** - * Refuse a log containing an event type this build does not know, unless the - * writer marked the event ignorable: an unrecognized required event may - * change how the rest of the log must be interpreted, so silently skipping - * it would reconstruct a wrong session (the envelope contract on - * `SessionEvent.ignorable`). Runs on NORMALIZED events — after - * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes - * this build still reads and rejected the ones it does not, so those keep - * their specific diagnostics. - */ - private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void { - for (const event of events) { - if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue - throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`) - } - } - - /** Build a format refusal that points at the raw artifact when the backend has one. */ - private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError { - const location = this.backend.locate?.(meta) - return new SessionFormatUnsupportedError( - location === undefined ? reason : `${reason} (raw log: ${location.path})`, - location, - ) - } - - /** Reject backend metadata that is not bound to the requested session id. */ - private assertStoredId(id: SessionId, meta: SessionHeader): void { - if (meta.id !== id) { - throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`) - } - } - - // --- write path (session/event → flush drain) --- - - private installWritePath(): void { - const ctx = this.ctx - - // Register the disposer BEFORE the listeners. Cordis tears effects down in - // reverse registration order, so event admission closes before this final - // drain reaches quiescence and closes the backend. - ctx.effect(() => async () => { - let disposeError: unknown - try { - const errors = await settledErrors([...this.live.keys()].map(session => this.flush(session))) - while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]) - if (errors.length > 0) { - throw new AggregateError(errors, `${this.backend.name} dispose failed`) - } - } catch (error: unknown) { - disposeError = error - throw error - } finally { - try { - await this.backend.close?.() - } catch (closeError: unknown) { - // A close failure can only add teardown context; keep the already- - // captured drain AggregateError as the primary failure rather than - // masking it. Only surface the close error if the drain succeeded. - /* v8 ignore start -- close failure racing disposal is a defensive teardown edge */ - if (disposeError === undefined) throw closeError - /* v8 ignore stop */ - } - } - }, `${this.backend.name} write path`) - - // Capture the header on creation and persist a fork's seed once. - ctx.on('session/created', (session) => { - void this.initFor(session) - }) - - // Keep a persistence-owned copy of each frozen event and start its bounded window. - ctx.on('session/event', (session, event) => { - const live = this.initFor(session) - live.writes.enqueue(event) - }) - - // Callers use flush as the immediate durability barrier for buffered writes. - ctx.on('session/flush', session => this.flush(session)) - - // Session disposal is observe-only, so retirement contains its own failure. - ctx.on('session/disposed', (session) => { this.retire(session) }) - - // HMR does not replay session/created, so seed existing live sessions. - for (const session of ctx.sessions.list()) void this.initFor(session) - } - - /** Start and observe one disposed session's final drain. */ - private retire(session: Session): void { - if (!this.live.has(session)) return - const retirement = this.retireCore(session) - this.retirements.set(session.id, retirement) - const forget = (): void => { - if (this.retirements.get(session.id) === retirement) this.retirements.delete(session.id) - } - void retirement.then(forget, forget) - void retirement.catch((error: unknown) => { - this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`) - }) - } - - /** Drain and release state owned by one exact disposed Session lifecycle. */ - private async retireCore(session: Session): Promise { - await this.flush(session) - const id = session.header.id - await this.serialize(id, () => { - this.live.delete(session) - if (this.states.get(id)?.owner === session) this.states.delete(id) - }) - } - - /** Return the one lifecycle controller for a live session, creating it if needed. */ - private initFor(session: Session): LiveSessionState { - const existing = this.live.get(session) - if (existing) return existing - const reservation = this.preparations.reservationFor(session) - if (reservation !== undefined) { - const restored = this.attachPrepared(session, reservation) - this.live.set(session, restored) - return restored - } - // Session owns this stable deep-frozen snapshot; backends only serialize it. - const seed = session.snapshotEvents() - const live: LiveSessionState = { - init: Promise.resolve(), - writes: this.createWriteBehind(session, () => live.init), - } - this.live.set(session, live) - live.init = this.serialize(session.header.id, () => this.onCreated(session, seed)) - live.init.catch(() => { /* observed by flush/dispose through the controller */ }) - return live - } - - /** Bind one exact prepared Session and persist only its unpublished suffix. */ - private attachPrepared( - session: Session, - reservation: SessionPreparationReservation, SessionState>, - ): LiveSessionState { - const { source, state } = reservation - if (source.session !== session || state.owner !== undefined - || state.cursor !== source.inspection.events.length - || session.firstLiveSeq !== state.cursor) { - throw new Error(`session "${session.id}" preparation no longer matches its persistence state`) - } - const suffix = session.snapshotEvents(state.cursor).map(event => structuredClone(event)) - this.preparations.attach(reservation) - state.owner = session - const live: LiveSessionState = { - init: Promise.resolve(), - writes: this.createWriteBehind(session, () => live.init), - } - if (suffix.length > 0) { - live.init = this.serialize(session.id, () => this.appendCore(session.id, suffix)) - live.init.catch(() => { /* observed by flush/dispose through the controller */ }) - } - return live - } - - /** - * Whether a live session's `seed` reproduces the first `cursor` persisted - * events. A `cursor` of 0 (nothing persisted yet) trivially matches. Used when - * a live session claims ownerless state left by a prior `load()`/`create()`. - */ - private async seedMatchesPersisted( - id: SessionId, - seed: readonly SessionEvent[], - cursor: SessionLogOffsetType, - ): Promise { - if (cursor === 0) return true - const stored = await this.backend.loadStored(id) - /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ - if (stored === undefined) return false - this.assertStoredId(id, stored.meta) - return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor)) - } - - /** - * On session/created: sync the backend's in-memory state to a live Session. - * - * Cases, by whether this backend tracks the id and whether an artifact exists: - * 1. Already tracked → no-op (or claim ownerless state if the seed matches, - * or reclaim a truly-abandoned id, else reject as a collision). - * 2. Not tracked, an artifact EXISTS at the same cwd and is a seq-aligned - * PREFIX of the live events → ADOPT it, persisting any live suffix. - * 3. Not tracked, an artifact EXISTS at another cwd or is NOT a prefix → - * REJECT (collision). - * 4. Not tracked and NO artifact → a genuinely new session: register meta - * (lazy) and persist its seed once. - */ - private async onCreated(session: Session, seed: readonly SessionEvent[]): Promise { - const id = session.header.id - const tracked = this.states.get(id) - if (tracked !== undefined) { - // case 1: already tracked. - /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */ - if (tracked.owner === session) return - if (tracked.owner === undefined) { - // Ownerless state from the public create()/load() API. The FIRST live - // session claims it — but ONLY if BOTH the cwd scope and the seed match. - // A same-id ownerless artifact at a different cwd is a collision, not a - // claim: accepting it would append this live session's events through - // the stored header's cwd. The seed guard then ensures the live events - // reproduce the persisted prefix; otherwise a fresh session reusing the - // id could have its leading events filtered as already written. - if (tracked.storage.meta.cwd !== session.header.cwd) { - throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.storage.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) - } - if (tracked.storage.inheritedEventCount !== session.inheritedEventCount) { - throw new Error(`session "${id}" is already persisted with a different inherited event count (id collision)`) - } - if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { - throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) - } - tracked.owner = session - // Persist the seed SUFFIX beyond the persisted prefix. Constructor seed - // events never emit session/event, so the buffer never sees them. - const suffix = seed.slice(tracked.cursor) - if (suffix.length > 0) await this.appendCore(id, suffix) - return - } - const owner = this.live.get(tracked.owner) - if (!tracked.materialized && !owner?.writes.hasWork) { - this.states.delete(id) - } else { - throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`) - } - } - - // case 2/3: resolve the id once across storage, then let adoption reject a - // cwd mismatch before repair or state publication. - const live = await this.backend.loadStored(id) - if (live !== undefined) { - // Do NOT route through cold preparation: that crash-repairs open turns as - // interrupted, which is wrong for HMR while the live Session is still the - // authority and may append the real step/turn end later. - await this.adoptLivePrefix(session, seed, live) - return - } - - // case 4: a genuinely new session. Register its meta (lazy), then persist its - // seed (events present at creation time) once. - const storage = sessionStorageMetadata(session) - await this.createCore({ - meta: { ...storage.meta }, - inheritedEventCount: storage.inheritedEventCount, - }) - // Bind this state to the live session so a later DIFFERENT session reusing - // the id is detected as a collision (case 1) rather than silently no-opped. - const created = this.states.get(id) - /* v8 ignore next -- create() always sets the state for the id */ - if (created !== undefined) created.owner = session - if (seed.length > 0) await this.appendCore(id, seed) - } - - /** - * Adopt a stored prefix as a live session's history (HMR/reload): verify the - * seed covers the stored prefix, truncate any torn tail (NOT the open turn — - * the live Session is still the authority), bind ownership, and persist the - * live suffix that was ahead of the stored prefix. - */ - private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { - const { meta, inheritedEventCount, events, tornMarker } = stored - this.assertStoredId(session.header.id, meta) - if (meta.cwd !== session.header.cwd) { - throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) - } - if (inheritedEventCount !== session.inheritedEventCount) { - throw new Error(`session "${session.header.id}" is already persisted with a different inherited event count (id collision)`) - } - this.assertVersion(meta) - const storedEvents = snapshotStoredEvents(events, session.header.id) - this.assertEventsSupported(meta, storedEvents) - if (!seedCoversPrefix(seed, storedEvents)) { - throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) - } - // Truncate-only repair (no closers): the open turn is NOT closed here. - if (tornMarker !== undefined) await this.backend.commitRepair(stored, tornMarker, []) - this.states.set(session.header.id, { - storage: { - meta: { ...meta }, - inheritedEventCount, - }, - cursor: SessionLogOffset(storedEvents.length), - materialized: true, - owner: session, - }) - const suffix = seed.slice(storedEvents.length) - if (suffix.length > 0) await this.appendCore(session.header.id, suffix) - } - - private async flush(session: Session): Promise { - const live = this.initFor(session) - live.writes.cancelAutomaticWait() - try { - await live.init - } catch (error: unknown) { - // Admission is closed during retirement/teardown, but an ordinary flush - // may have raced one last enqueue while initialization was pending. - live.writes.cancelAutomaticWait() - throw error - } - await live.writes.flush() - } - - /** Build one package-private write controller around initialization and id serialization. */ - private createWriteBehind(session: Session, ready: () => Promise): SessionWriteBehind { - return new SessionWriteBehind({ - maxDelayMs: this.writeBatchMaxDelayMs, - write: async (batch) => { - await ready() - await this.serialize(session.header.id, () => this.appendLiveBatch(session.header.id, batch)) - }, - reportBackgroundFailure: (error) => { - this.ctx.logger.warn(`${this.backend.name}: background write for session "${session.id}" failed (buffered events retained): ${String(error)}`) - }, - }) - } - - /** Append one controller-owned prefix after filtering events initialization already stored. */ - private async appendLiveBatch(id: SessionId, batch: readonly SessionEvent[]): Promise { - const state = this.states.get(id) - /* v8 ignore next -- state is always set by the awaited initialization */ - const cursor = state?.cursor ?? 0 - const fresh = batch.filter(e => e.seq >= cursor) - await this.appendCore(id, fresh) - } -} diff --git a/packages/session/session-persistence/src/errors.ts b/packages/session/session-persistence/src/errors.ts index 0731b0edc7..62e232741e 100644 --- a/packages/session/session-persistence/src/errors.ts +++ b/packages/session/session-persistence/src/errors.ts @@ -1,8 +1,15 @@ -/** Stable failures exposed by the session-persistence service. */ +/** + * Stable failures exposed by the session-persistence service and its handles, + * including the format refusals shared by every backend: a stored log this + * build cannot faithfully interpret is refused, never misread, and the + * refusal points at the raw artifact when the backend keeps one per session. + * @module @deepseek-ai/dsh-session-persistence/errors + */ +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' -/** The requested Session identity has no materialized durable log. */ +/** The requested Session identity has no durable log visible to this caller. */ export class SessionPersistenceNotFoundError extends Error { /** @param sessionId - absent durable Session identity. */ constructor(readonly sessionId: SessionId) { @@ -10,3 +17,121 @@ export class SessionPersistenceNotFoundError extends Error { this.name = 'SessionPersistenceNotFoundError' } } + +/** `create` targeted a Session identity that already exists in this backend. */ +export class SessionAlreadyExistsError extends Error { + /** @param sessionId - the occupied durable Session identity. */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" already exists`) + this.name = 'SessionAlreadyExistsError' + } +} + +/** A write open found the session already bound to an active write handle. */ +export class SessionAlreadyOwnedError extends Error { + /** @param sessionId - the session whose write ownership is taken. */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is already owned by an active write handle`) + this.name = 'SessionAlreadyOwnedError' + } +} + +/** A mutation (`append`/`flush`) was called on a read handle. */ +export class SessionReadOnlyError extends Error { + /** + * @param sessionId - the session the read handle observes. + * @param operation - the refused mutating operation name. + */ + constructor(readonly sessionId: SessionId, operation: string) { + super(`session "${sessionId}": ${operation} is not available on a read handle`) + this.name = 'SessionReadOnlyError' + } +} + +/** + * A write handle's ownership is permanently gone: its lease expired, a renewal + * failed, or the durable ownership record no longer names this handle. The + * handle never re-acquires ownership — close it and reopen for write. + * + * Declared for the cross-process lease layer; the shipped in-process backends + * never throw it yet. + */ +export class SessionOwnershipLostError extends Error { + /** @param sessionId - the session whose write ownership this handle lost. */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}": write ownership was lost; close this handle and reopen`) + this.name = 'SessionOwnershipLostError' + } +} + +/** An operation was called on a handle after `close()` was called. */ +export class SessionHandleClosedError extends Error { + /** + * @param sessionId - the session the closed handle addressed. + * @param operation - the refused operation name. + */ + constructor(readonly sessionId: SessionId, operation: string) { + super(`session "${sessionId}": ${operation} on a closed handle`) + this.name = 'SessionHandleClosedError' + } +} + +/** + * A backend-resolved, per-session local artifact location. Carried only by + * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can + * find the raw log a build refused to interpret; it is not a consumer-facing + * query — log access goes through a session handle's `read`. + */ +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 session contents failed validation after a successful backend read. */ +export class SessionPersistenceCorruptionError extends Error { + /** + * @param message - stable corruption context. + * @param options - original validation failure. + */ + constructor(message: string, options: ErrorOptions) { + super(message, options) + this.name = 'SessionPersistenceCorruptionError' + } +} + +/** + * The stored log is intact but this runtime cannot faithfully interpret it: + * the header carries an unsupported format version, or an event's type is + * unknown to this build. Distinct from {@link SessionPersistenceCorruptionError} + * — nothing is damaged; the raw log remains readable at {@link location} when + * the backend keeps one artifact per session. + */ +export class SessionFormatUnsupportedError extends Error { + /** + * @param message - stable reason the log cannot be interpreted, already + * including the raw-log path when one exists. + * @param location - the backend's artifact location, when one exists. + */ + constructor(message: string, readonly location?: SessionLocation) { + super(message) + this.name = 'SessionFormatUnsupportedError' + } +} + +/** + * Direction-aware refusal text for a stored session whose format version this + * build does not read. Shared by load-time checks and by backends that must + * refuse BEFORE decoding version-dependent structure (a future format may not + * satisfy this build's structural checks at all, and the user must see + * "upgrade the harness", never "corrupt"). + * @param id - the stored session id, for message context. + * @param version - the stored format version. + * @returns the stable refusal text, without a raw-log path suffix. + */ +export function sessionFormatVersionRefusal(id: string, version: number): string { + return version > SESSION_FORMAT_VERSION + ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` + : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` +} diff --git a/packages/session/session-persistence/src/handle.ts b/packages/session/session-persistence/src/handle.ts new file mode 100644 index 0000000000..a583bc8615 --- /dev/null +++ b/packages/session/session-persistence/src/handle.ts @@ -0,0 +1,106 @@ +/** + * The per-session storage handle: one open channel onto a stored session's + * append-only event log, returned by `SessionPersistence.create`/`open`. + * @module @deepseek-ai/dsh-session-persistence/handle + */ + +import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' + +/** + * Log access granted by an open. `write` is read-write: the session's single + * mutator, which also reads its own log. `read` only observes — it never + * takes ownership and works while another handle or process holds `write`. + */ +export type SessionAccess = 'read' | 'write' + +/** Options for {@link SessionHandle.read}. */ +export interface SessionHandleReadOptions { + /** Optional cancellation for backend read work. */ + readonly signal?: AbortSignal +} + +/** Options for {@link SessionHandle.append}. */ +export interface SessionHandleAppendOptions { + /** Optional cancellation observed before the write starts. */ + readonly signal?: AbortSignal +} + +/** Options for {@link SessionHandle.flush}. */ +export interface SessionHandleFlushOptions { + /** Optional cancellation observed before the barrier starts. */ + readonly signal?: AbortSignal +} + +/** + * One open channel onto a stored session. A handle is single-owner state, not + * a shared service: `read` never backtracks below what this handle already + * observed, a `write` handle reads its own successful appends, and `close()` + * is the one teardown (idempotent, uncancellable; `Symbol.asyncDispose` + * delegates to it). Every operation on a closed handle rejects with + * `SessionHandleClosedError`. + * + * Freshness across handles: once an `append` or `flush` resolves on a write + * handle, every read STARTED afterwards on the same backend instance — on any + * handle, or through `stat`/`list` — observes at least that prefix. + * Reads concurrent with a mutation carry no ordering promise beyond the valid + * contiguous prefix. + */ +export interface SessionHandle extends AsyncDisposable { + /** The stored session this handle addresses. */ + readonly id: SessionId + /** The immutable stored header, fixed at `create`/`open`. */ + readonly header: SessionHeader + /** + * Exact fork-inherited prefix length stored with the log; `0` when + * `header.isSeeded` is false. Storage metadata paired with the header for + * every body read, never part of the replayable event log. + */ + readonly inheritedEventCount: SessionLogOffset + /** Whether this handle may mutate the log. */ + readonly access: SessionAccess + + /** + * Read a slice of the valid contiguous logical log. The slice is a legal log + * prefix segment: a torn physical tail is never returned, and repeated reads + * on this handle never observe an older state than a prior read. + * @param offset - first logical event seq to include; defaults to `0`. + * @param length - maximum number of events to return; defaults to the rest + * of the log. An offset at or past the end returns an empty list. + * @param options - optional cancellation. + * @returns the events with `seq >= offset`, at most `length` of them. + */ + read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise + + /** + * Append a contiguous batch continuing the current logical end. The first + * event's `seq` MUST equal the stored next-seq; committed events are never + * rewritten. Persistence is best-effort: on resolution the batch is + * accepted, ordered, and visible to reads on this backend instance, but + * only a resolved {@link flush} promises it survives a crash — a backend + * may buffer or batch physical writes behind append. Rejects with + * `SessionReadOnlyError` on a read handle and `SessionOwnershipLostError` + * when write ownership is gone. + * @param events - the contiguous batch, in seq order. + * @param options - optional cancellation observed before the write starts. + */ + append(events: readonly SessionEvent[], options?: SessionHandleAppendOptions): Promise + + /** + * The durability barrier — the one operation that promises storage: on + * resolution every acknowledged append is durable and the session is + * materialized for other processes; an empty created session becomes + * durably listable here. Callers that must survive a crash flush; a backend + * whose `append` already persists on resolution treats this as + * materialize-if-needed. Rejects with `SessionReadOnlyError` on a read + * handle. + * @param options - optional cancellation observed before the barrier starts. + */ + flush(options?: SessionHandleFlushOptions): Promise + + /** + * Release the handle: a read handle frees local resources; a write handle + * completes pending durability and releases write ownership. Idempotent, + * asynchronous, and deliberately not cancellable. + */ + close(): Promise +} diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index d110703d3a..154e4f04e8 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -1,34 +1,79 @@ /** * Durable session-persistence Service Definition (`ctx.sessionPersistence`). Backends store * {@link SessionEvent}s as the event-sourced log and carry non-replayable - * {@link SessionHeader} metadata separately. + * {@link SessionHeader} metadata separately; callers address one stored + * session through a {@link SessionHandle} obtained from `create`/`open`. * @module @deepseek-ai/dsh-session-persistence */ import { Context, Service } from '@deepseek-ai/cordis' -import { SessionPreparation, SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { - Session, - SessionEvent, - SessionId, - SessionHeader, -} from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionHandle, SessionAccess } from './handle.ts' import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' -export { SessionPersistenceNotFoundError } from './errors.ts' +export type { + SessionAccess, + SessionHandle, + SessionHandleAppendOptions, + SessionHandleFlushOptions, + SessionHandleReadOptions, +} from './handle.ts' +export { + SessionAlreadyExistsError, + SessionAlreadyOwnedError, + SessionFormatUnsupportedError, + SessionHandleClosedError, + SessionOwnershipLostError, + SessionPersistenceCorruptionError, + SessionPersistenceNotFoundError, + SessionReadOnlyError, + sessionFormatVersionRefusal, +} from './errors.ts' +export type { SessionLocation } from './errors.ts' +export { + assertContiguous, + assertStoredId, + assertVersion, + materializeAppendBatch, + materializeCreateHeader, + validateStoredEvents, +} from './storage-contract.ts' -/** Lightweight immutable source identity returned without loading a full log. */ +/** + * Lightweight stored-session observation returned by {@link SessionPersistence.stat} + * and {@link SessionPersistence.list} without reading the full event 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 + /** Detached metadata for one stored session. */ + readonly header: SessionHeader + /** Opaque change token; see {@link SessionPersistence.stat}. */ + readonly revision: SessionPersistenceRevision + /** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */ + readonly eventCount?: number + /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ + readonly sizeBytes?: number } -/** Logical Session header paired with its exact inherited cut for body-bearing storage operations. */ +/** Options for {@link SessionPersistence.create}. */ +export interface SessionPersistenceCreateOptions { + /** Optional cancellation observed before backend work starts. */ + readonly signal?: AbortSignal + /** + * Exact fork-inherited prefix length. Required when `header.isSeeded` is + * true and must be omitted (or `0`) otherwise; the backend refuses a + * mismatch at create. + */ + readonly inheritedEventCount?: SessionLogOffset +} + +/** + * Logical Session header paired with its exact inherited cut for body-bearing + * storage operations. `isSeeded` marks fork lineage on the header; the + * numeric cut travels beside it, never inside the replayable event log. + */ export interface SessionStorageMetadata { /** Validated immutable Session header. */ readonly meta: SessionHeader @@ -36,64 +81,29 @@ export interface SessionStorageMetadata { readonly inheritedEventCount: SessionLogOffset } -/** Immutable logical session prepared from persistence or a live owner. */ +/** Immutable logical session read: storage metadata plus the complete validated event log. */ export interface SessionInspection extends SessionStorageMetadata { - /** Validated contiguous logical event log. */ + /** Contiguous validated events from seq 0. */ readonly events: readonly SessionEvent[] } -/** Detached logical suffix returned by one explicit stored-log offset read. */ -export interface SessionEventSuffix extends SessionStorageMetadata { - /** First requested log offset; {@link events} contains only seqs at or after it. */ - readonly fromSeq: SessionLogOffset - /** Valid contiguous stored events at or after {@link fromSeq}; not a complete Session log when the offset is nonzero. */ - readonly events: readonly SessionEvent[] +/** Options for {@link SessionPersistence.open}. */ +export interface SessionPersistenceOpenOptions { + /** Optional cancellation observed before backend work starts. */ + readonly signal?: AbortSignal } -/** A borrowed exact Session source returned from a cold materialization or concurrent live owner. */ -export type BorrowedSessionSource = Disposable & ( - | { - /** A reusable unpublished Session is pinned until this observation is disposed. */ - readonly source: 'prepared' - /** Immutable header and logical event prefix observed together. */ - readonly inspection: SessionInspection - /** Durable revision represented by the prepared source. */ - readonly revision: SessionPersistenceRevision - /** Exact unpublished Session retained for a later {@link prepare}. */ - readonly preparedSession: Session - } - | { - /** A live Session won source resolution while the persistence read was starting. */ - readonly source: 'live' - /** Immutable live header and event prefix observed together. */ - readonly inspection: SessionInspection - } -) - -/** A backend's own raw artifact text for one session, verbatim. */ -export interface SessionRawArtifact extends SessionStorageMetadata { - /** The artifact's base filename on disk, without any physical encoding suffix. */ - readonly filename: string - /** The artifact's full text content, decoded from the backend's physical encoding. */ - readonly content: string +/** Options for {@link SessionPersistence.stat}. */ +export interface SessionPersistenceStatOptions { + /** Optional cancellation for backend metadata reads. */ + readonly signal?: AbortSignal } -// 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, - SessionFormatUnsupportedError, - SessionPersistenceCorruptionError, - sessionFormatVersionRefusal, -} from './coordinator.ts' -export type { - PersistenceBackend, - PersistenceCoordinatorOptions, - StoredPrefix, - StoredSuffix, -} from './coordinator.ts' +/** Options for {@link SessionPersistence.list}. */ +export interface SessionPersistenceListOptions { + /** Optional cancellation for backend listing work. */ + readonly signal?: AbortSignal +} declare module '@deepseek-ai/cordis' { interface Context { @@ -102,22 +112,24 @@ declare module '@deepseek-ai/cordis' { } /** - * 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. + * Durable append-only session storage addressed through per-session handles. + * + * Storage semantics shared by every backend: events are contiguous from seq 0 + * and never rewritten; a torn physical tail is never returned to a reader and + * is truncated by the write path before its first append; reads validate + * current-format records only and refuse unknown vocabulary fail-closed. + * `append` persists best-effort; `flush` — per handle or service-wide — is + * the durability barrier. + * + * Visibility: a created session is observable through `stat`/`list`/`open` + * in this process from the moment `create` resolves, even while a backend + * defers physical materialization (a pure optimization); other processes see + * the session only once it materializes, and a session that never + * materialized before a crash never existed. `SessionHandle.flush` forces + * materialization. + * + * Freshness: once an `append` or `flush` resolves, reads started afterwards + * on this backend instance observe at least that prefix. */ export abstract class SessionPersistence extends Service { constructor(ctx: Context) { @@ -125,183 +137,64 @@ export abstract class SessionPersistence extends Service { } /** - * Resolve this backend's independent local artifact for a session without - * reading, creating, flushing, or otherwise materializing it. A backend - * that does not own one artifact per Session returns `undefined`. - * @param meta - the immutable session header whose artifact is requested. - * @returns the backend-specific absolute location, when one exists. + * Create a new stored session and take its write ownership. + * @param header - the immutable header (id, version, cwd, lineage) to store. + * @param options - optional cancellation. + * @returns a `write` handle owned by the caller; close it to release ownership. + * @throws {SessionAlreadyExistsError} when the id already exists. */ - abstract locate(meta: SessionHeader): SessionLocation | undefined + abstract create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise /** - * Whether this backend exposes one verbatim raw artifact per session. - * A backend that declares `true` must override {@link readRaw}. - */ - abstract readonly supportsRawArtifacts: boolean - - /** - * Read a session's backend-owned artifact text verbatim — the exact durable - * bytes the backend wrote (decoded from its physical encoding, e.g. a - * decompressed JSONL). The returned `content` is the raw text, not a - * reconstruction from parsed events, so it preserves backend-specific - * serialization (chunk packing, key order, line breaks). Callers first test - * {@link supportsRawArtifacts}; `undefined` then means only that the requested - * session has no materialized artifact. - * @param _id - the persisted session to read (unused by the default: no - * per-session artifact). - * @param signal - optional cancellation for backend read work. - * @returns the raw artifact plus its parsed header, or `undefined` when the - * session is absent. - * @throws when this backend does not expose per-session raw artifacts. - */ - readRaw(_id: SessionId, signal?: AbortSignal): Promise { - if (signal?.aborted === true) { - return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')) - } - return Promise.reject(new Error('this session persistence backend does not expose raw artifacts')) - } - - /** - * 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. - * @param inheritedEventCount - exact fork-inherited prefix length. Required - * for a seeded header and omitted only for an unseeded header. - */ - abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise - - /** - * Ensure a live session has a durable header even when it has no events. - * Ordinary sessions remain lazily materialized; lifecycle frontends call - * this only when an empty session itself is a durable resumable resource. - * @param _session - exact live session whose registered header is materialized. - */ - ensureMaterialized(_session: Session): Promise { - return Promise.reject(new Error('this session persistence backend cannot materialize an empty session')) - } - - /** - * 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. - * A seeded session's first materializing batch must reach its complete - * inherited prefix. - * @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 - - /** - * 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 { - 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), - inheritedEventCount: SessionLogOffset(loaded.inheritedEventCount), - 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 - - /** - * 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 - - /** - * Borrow one exact inspection while retaining any reusable prepared source. - * A cold observation must pin the exact prepared Session that a later - * {@link prepare} reserves. Implementations must not degrade this operation - * to a detached {@link inspect} result. - * @param id - persisted session to observe. - * @param signal - optional cancellation for preparation work. - * @returns a disposable immutable observation. - */ - abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise - - /** - * 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). - * A backend whose medium can seek by seq may read only the suffix; - * sequential media such as JSONL 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 offset to include. - * @param signal - optional cancellation for queued and backend read work. - * @returns storage metadata, the requested offset, and stored events with `seq >= fromSeq`. - */ - abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): - Promise - - /** - * 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 - - /** - * List materialized sessions with cheap per-log change tokens. + * Open an existing stored session. * - * 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. + * `read` never takes ownership and works while another handle (or process) + * holds write ownership. `write` atomically claims single-writer ownership; + * an existing active owner rejects. + * @param id - the stored session to open. + * @param access - `read` or `write`. + * @param options - optional cancellation. + * @returns the open handle. + * @throws {SessionPersistenceNotFoundError} when the session does not exist. + * @throws {SessionAlreadyOwnedError} for `write` when ownership is taken. */ - abstract listSnapshots(signal?: AbortSignal): Promise + abstract open(id: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions): Promise + + /** + * Flush every active write handle owned by this service instance in one + * durability barrier: each handle's routed live events drain durably and + * its session materializes, exactly as that handle's own + * `SessionHandle.flush` would. Read handles buffer nothing and are + * untouched. A handle closed concurrently counts as flushed — close itself + * drains durably. + * @returns resolution once every write handle active at the call has flushed. + * @throws {AggregateError} naming each session whose flush failed; the + * remaining handles still flush. + */ + abstract flush(): Promise + + /** + * Observe one stored session without reading its event log or taking + * ownership. + * + * The snapshot's `revision` is an opaque change token comparable only + * against revisions from the same service instance and session id: equal + * revisions may be treated as an unchanged log; unequal revisions promise + * nothing. Write-ownership churn does not change a revision. It exists for + * derived read-model caches keyed off `stat`/`list`; it plays no part in + * open, read, or resume. + * @param id - the stored session to observe. + * @param options - optional cancellation. + * @returns the snapshot, or `undefined` when the session does not exist. + */ + abstract stat(id: SessionId, options?: SessionPersistenceStatOptions): Promise + + /** + * List every stored session visible to this process, in no promised order. + * @param options - optional cancellation. + * @returns one snapshot per stored session. + */ + abstract list(options?: SessionPersistenceListOptions): Promise } export default SessionPersistence diff --git a/packages/session/session-persistence/src/preparations.ts b/packages/session/session-persistence/src/preparations.ts deleted file mode 100644 index 96ad4352f7..0000000000 --- a/packages/session/session-persistence/src/preparations.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * 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 { - readonly id: SessionId - readonly result: Promise - phase: PreparationPhase - source?: Source - reservation?: SessionPreparationReservation - reservationSettled?: Promise - settleReservation?: () => void - pins: number -} - -/** A borrowed prepared source that remains outside ready-entry eviction until released. */ -export interface PreparationLease extends Disposable { - /** Shared immutable prepared source. */ - readonly source: Source -} - -/** One exclusively held prepared source and its committed persistence state. */ -export interface SessionPreparationReservation { - readonly entry: PreparationEntry - readonly source: Source - readonly state: CommitState -} - -/** Per-coordinator cold-read sharing, exclusive reservation, and ready-entry LRU. */ -export class SessionPreparations { - private readonly entries = new Map>() - - 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, - signal?: AbortSignal, - ): Promise { - 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 - } - - /** - * Borrow one prepared source and pin its ready entry against LRU eviction. - * @param id - session identity. - * @param load - cold loader used when no entry exists. - * @param signal - optional cancellation signal while waiting. - * @returns a caller-owned observation lease. - */ - async borrow( - id: SessionId, - load: () => Promise, - signal?: AbortSignal, - ): Promise> { - const entry = this.entryFor(id, load) - const pinned = this.entries.get(id) === entry - if (pinned) entry.pins += 1 - let loaded: Source - try { - loaded = signal === undefined - ? await entry.result - : await observeQueuedAbort(entry.result, signal) - } catch (error: unknown) { - if (pinned && this.entries.get(id) === entry) { - entry.pins -= 1 - if (entry.phase === 'ready') this.touch(entry) - } - throw error - } - const source = entry.source ?? loaded - if (this.entries.get(id) !== entry) { - return { source, [Symbol.dispose]: () => {} } - } - if (entry.phase === 'ready') this.touch(entry) - let released = false - return { - source, - [Symbol.dispose]: () => { - if (released) return - released = true - if (this.entries.get(id) !== entry) return - entry.pins -= 1 - if (entry.phase === 'ready') this.touch(entry) - }, - } - } - - /** - * 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, - commit: (source: Source) => Promise<{ source: Source; state: CommitState } | undefined>, - signal?: AbortSignal, - ): Promise | 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() - 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 = { - 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 | 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): 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): 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, - 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, - ): PreparationEntry { - const existing = this.entries.get(id) - if (existing !== undefined) return existing - const deferred = Promise.withResolvers() - const entry: PreparationEntry = { - id, - result: deferred.promise, - phase: 'loading', - pins: 0, - } - this.entries.set(id, entry) - let loading: Promise - 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): 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): 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): 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' || candidate.pins > 0) 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( - operation: Promise, - signal: AbortSignal, - started: () => boolean = () => false, -): Promise { - return new Promise((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) -} diff --git a/packages/session/session-persistence/src/storage-contract.ts b/packages/session/session-persistence/src/storage-contract.ts new file mode 100644 index 0000000000..d921f00064 --- /dev/null +++ b/packages/session/session-persistence/src/storage-contract.ts @@ -0,0 +1,148 @@ +/** + * Backend-shared storage validation: the version gate, the fail-closed event + * vocabulary, append-batch materialization, and contiguity — one place so + * every backend refuses the same inputs identically. + * @module @deepseek-ai/dsh-session-persistence/storage-contract + */ + +import { + adoptSessionEvent, + KNOWN_SESSION_EVENT_TYPES, + SESSION_FORMAT_VERSION, +} from '@deepseek-ai/dsh-session' +import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { + SessionFormatUnsupportedError, + SessionPersistenceCorruptionError, + sessionFormatVersionRefusal, + type SessionLocation, +} from './errors.ts' + +/** Build a format refusal that points at the raw artifact when the backend has one. */ +function unsupported(reason: string, location: SessionLocation | undefined): SessionFormatUnsupportedError { + return new SessionFormatUnsupportedError( + location === undefined ? reason : `${reason} (raw log: ${location.path})`, + location, + ) +} + +/** + * Refuse stored metadata that is not bound to the requested session id. + * @param id - the requested session id. + * @param meta - the stored header. + */ +export function assertStoredId(id: SessionId, meta: SessionHeader): void { + if (meta.id !== id) { + throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`) + } +} + +/** + * Refuse a stored header whose format version this build does not read. + * @param meta - the stored header. + * @param location - the backend's artifact location for the refusal, when one exists. + */ +export function assertVersion(meta: SessionHeader, location?: SessionLocation): void { + if (meta.version !== SESSION_FORMAT_VERSION) { + throw unsupported(sessionFormatVersionRefusal(meta.id, meta.version), location) + } +} + +/** + * Validate one exclusively owned stored event array in place: adopt each + * record (validating and freezing it) and refuse any event type this build + * does not know, unless its writer marked it `ignorable: true` — silently + * skipping an unknown required event could reconstruct a wrong session (the + * envelope contract on `SessionEvent.ignorable`). Both newer vocabularies and + * retired pre-release shapes refuse here; this build ships no migration. + * @param meta - the stored header the events belong to. + * @param events - exclusively owned decoded events; validated in place. + * @param location - the backend's artifact location for refusals, when one exists. + * @returns the same array, validated and frozen. + * @throws {SessionFormatUnsupportedError} for unknown event types. + * @throws {SessionPersistenceCorruptionError} for records that fail validation. + */ +export function validateStoredEvents( + meta: SessionHeader, + events: SessionEvent[], + location?: SessionLocation, +): SessionEvent[] { + for (const event of events) { + if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable !== true) { + throw unsupported( + `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, + location, + ) + } + // The one retired shape hiding under a known type: the removed delta codec's + // full-header "fallback" reason. Everything else retired was a whole type. + if (event.type === 'request/header') { + const data: unknown = event.data + if (typeof data === 'object' && data !== null + && (data as Record)['reason'] === 'fallback') { + throw unsupported( + `session "${meta.id}" contains a request/header event (seq ${event.seq}) with the unsupported legacy reason "fallback"; refusing to interpret the log — it was written by a retired pre-release harness`, + location, + ) + } + } + } + try { + for (const [index, event] of events.entries()) events[index] = adoptSessionEvent(event) + } catch (error: unknown) { + if (error instanceof SessionFormatUnsupportedError) throw error + throw new SessionPersistenceCorruptionError( + `stored session "${meta.id}" failed validation: ${String(error)}`, + { cause: error }, + ) + } + return events +} + +/** + * Validate and deep-snapshot a header passed to `create` in one traversal. + * @param header - the caller's header. + * @returns the detached lossless-JSON header. + * @throws {TypeError} for non-JSON metadata or an invalid `createdAt`. + */ +export function materializeCreateHeader(header: SessionHeader): SessionHeader { + const snapshot = snapshotJsonValue(header) + if (snapshot === undefined) { + throw new TypeError('session metadata must be losslessly JSON-serializable') + } + if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) { + throw new TypeError('session metadata createdAt must be a non-negative safe integer') + } + return snapshot +} + +/** + * Validate and deep-snapshot one append batch in a single traversal, so the + * checked value is exactly the value persisted (a check followed by a copy + * could reread accessors into a different record). + * @param events - the caller's batch. + * @returns the detached lossless-JSON batch. + * @throws {TypeError} when any event data is not losslessly JSON-serializable. + */ +export function materializeAppendBatch(events: readonly SessionEvent[]): readonly SessionEvent[] { + const batch = snapshotJsonValue(events) + if (batch === undefined) { + throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data') + } + return batch +} + +/** + * Refuse a batch that does not contiguously continue the stored log. + * @param id - the session the batch belongs to. + * @param events - the batch, in seq order. + * @param cursor - the stored next-seq. + */ +export function assertContiguous(id: SessionId, events: readonly SessionEvent[], cursor: number): void { + for (const [index, event] of events.entries()) { + if (event.seq !== cursor + index) { + throw new Error(`append seq mismatch for "${id}": expected ${cursor + index} at index ${index}, got ${event.seq}`) + } + } +} diff --git a/packages/session/session-persistence/src/write-behind.ts b/packages/session/session-persistence/src/write-behind.ts deleted file mode 100644 index 97797732f6..0000000000 --- a/packages/session/session-persistence/src/write-behind.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * 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 - /** 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 | undefined - private active: Promise | undefined - private barrier: Promise | 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 { - if (this.barrier !== undefined) return this.barrier - this.cancelTimer() - this.deadlineExpired = false - this.automaticPaused = false - const barrier = Promise.withResolvers() - 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 { - 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 { - 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 - } -} diff --git a/packages/session/session-persistence/tests/contract.ts b/packages/session/session-persistence/tests/contract.ts index b8f611eebb..aaafa649bc 100644 --- a/packages/session/session-persistence/tests/contract.ts +++ b/packages/session/session-persistence/tests/contract.ts @@ -1,47 +1,60 @@ /** - * 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. + * Reusable handle 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 (plus teardown, an optional + * same-storage reopen, and an optional physical tail corruptor), so every + * backend is held to the same create/open/handle semantics: append-only + * contiguous seqs, single-writer ownership, lazy materialization, fail-closed + * vocabulary, freshness, and torn-tail repair. Backend-specific behavior + * (file layout, encodings, artifact export) stays in each backend's own spec. * * @module @deepseek-ai/dsh-session-persistence/tests/contract */ import { describe, expect, it } from 'vitest' +import { SessionSeq, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' import { - SESSION_FORMAT_VERSION, - Session, - SessionId, - SessionLogOffset, - SessionSeq, - TOOL_NOT_STARTED, - TOOL_OUTCOME_UNKNOWN, -} from '@deepseek-ai/dsh-session' -import type { - SessionEvent, - SessionHeader, - SessionLogOffset as SessionLogOffsetType, - SurfaceEventType, - SurfaceIntent, -} from '@deepseek-ai/dsh-session' -import { ToolCallId, MessageId, createMessage, freezeMessage } from '@deepseek-ai/dsh-llm' -import type { SessionPersistence } from '../src/index.ts' + SessionAlreadyExistsError, + SessionAlreadyOwnedError, + SessionFormatUnsupportedError, + SessionHandleClosedError, + SessionPersistenceNotFoundError, + SessionReadOnlyError, +} from '../src/index.ts' +import type { SessionHandle, SessionPersistence } from '../src/index.ts' -/** A backend under test plus its teardown. */ -export interface ContractBackend { +/** One backend service instance under test plus its teardown. */ +interface ContractBackendInstance { persistence: SessionPersistence dispose: () => Promise } +/** A backend under test: the primary instance plus optional storage-level capabilities. */ +export interface ContractBackend extends ContractBackendInstance { + /** + * Open a FRESH backend instance over the SAME storage, as another process + * would after this one exits. Enables the cross-instance visibility and + * reopen-continuation tests; a backend without shared storage omits it and + * those tests self-skip. + */ + reopen?: () => Promise + /** + * Inject a torn physical tail after the committed log of one stored session, + * simulating a crash mid-write. Enables the torn-tail tests. + */ + corruptTail?: (id: SessionId, cwd: string | undefined) => Promise +} + /** 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 } : {}, isSeeded: false, + ...cwd !== undefined ? { cwd } : {}, } } @@ -72,422 +85,487 @@ export function oneTurnLog(): SessionEvent[] { ] } -/** - * 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 - 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) - } - } + +/** A contiguous second-turn batch continuing {@link oneTurnLog}. */ +function secondTurn(startSeq = 6): SessionEvent[] { + return [ + { type: 'turn/start', seq: SessionSeq(startSeq), time: 9, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(startSeq + 1), time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, + ] } /** - * Run the backend-agnostic contract suite. `make()` MUST return a fresh, empty - * backend each call. + * Run the backend-agnostic handle contract suite. `make()` MUST return a + * fresh backend over fresh, empty storage each call. + * @param name - suite label, e.g. `jsonl-none` / `sqlite`. + * @param make - factory producing one fresh {@link ContractBackend} per test. */ export function runPersistenceContract(name: string, make: () => Promise): void { describe(`SessionPersistence contract: ${name}`, () => { - it('round-trips a session: create + append → load returns identical meta and byte-identical events', async () => { + it('round-trips through one write handle: append, self-read, offset/length defaults', async () => { const { persistence, dispose } = await make() try { - const m = meta('s1', '/work') + const m = meta('round-trip', '/work') const log = oneTurnLog() - await persistence.create(m) - await persistence.append(m.id, log) + const handle = await persistence.create(m) + expect(handle.access).toBe('write') + expect(handle.id).toBe(m.id) + expect(handle.header).toMatchObject(m) - 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) + await handle.append(log) + // An empty batch is a no-op, not an error. + await handle.append([]) + // A write handle reads its own successful appends. + expect(await handle.read()).toEqual(log) + expect(await handle.read(3)).toEqual(log.slice(3)) + expect(await handle.read(0, 2)).toEqual(log.slice(0, 2)) + expect(await handle.read(1, 3)).toEqual(log.slice(1, 4)) + // At/past the stored end: an empty list, never an error. + expect(await handle.read(log.length)).toEqual([]) + expect(await handle.read(log.length + 100)).toEqual([]) + // flush after a durable append is a satisfied barrier, not an error. + await handle.flush() + await handle.close() } finally { await dispose() } }) - it('rejects a fractional creation timestamp without reserving its session id', async () => { + it('read rejects negative or fractional offsets and lengths', 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) + const handle = await persistence.create(meta('read-args')) + await expect(handle.read(-1)).rejects.toThrow(/non-negative safe integer/) + await expect(handle.read(1.5)).rejects.toThrow(/non-negative safe integer/) + await expect(handle.read(0, -1)).rejects.toThrow(/non-negative safe integer/) + await handle.close() } finally { await dispose() } }) - it('requires an explicit inherited cut exactly when the header is seeded', async () => { + it('duplicate create rejects against a live pending session and allows the id after an erasing close', async () => { const { persistence, dispose } = await make() try { - const seeded = { ...meta('seeded-create'), isSeeded: true } - await expect(persistence.create(seeded)) - .rejects.toThrow('seeded session metadata requires an inherited event count') - await expect(persistence.create(meta('unseeded-nonzero'), SessionLogOffset(1))) - .rejects.toThrow('unseeded session metadata inherited event count must be 0') - - await persistence.create(seeded, SessionLogOffset(0)) - await persistence.append(seeded.id, oneTurnLog()) - await expect(persistence.load(seeded.id)).resolves.toMatchObject({ - meta: { isSeeded: true }, - inheritedEventCount: 0, - }) + const first = await persistence.create(meta('dup-pending')) + await expect(persistence.create(meta('dup-pending'))).rejects.toBeInstanceOf(SessionAlreadyExistsError) + // Closing the creator without ever appending erases the session, so + // the id is free again. + await first.close() + const second = await persistence.create(meta('dup-pending')) + await second.close() } finally { await dispose() } }) - it('does not materialize a seeded artifact before its complete inherited prefix', async () => { + it('concurrent duplicate creates: one wins, the loser rejects SessionAlreadyExistsError', async () => { const { persistence, dispose } = await make() try { - const seeded = { ...meta('seeded-partial-create'), isSeeded: true } - await persistence.create(seeded, SessionLogOffset(2)) - await expect(persistence.append(seeded.id, oneTurnLog().slice(0, 1))) - .rejects.toThrow('cannot materialize before its inherited prefix is complete') - expect((await persistence.list()).map(header => header.id)).not.toContain(seeded.id) - - await persistence.append(seeded.id, oneTurnLog()) - await expect(persistence.load(seeded.id)).resolves.toMatchObject({ - inheritedEventCount: 2, - }) + const m = meta('dup-race') + // Both calls pass the stored-existence check before either registers, + // so the loser is refused at the claim, still as a duplicate create. + const results = await Promise.allSettled([persistence.create(m), persistence.create(m)]) + const winners = results.filter(r => r.status === 'fulfilled') + const losers = results.filter(r => r.status === 'rejected') + expect(winners).toHaveLength(1) + expect(losers).toHaveLength(1) + expect((losers[0] as PromiseRejectedResult).reason).toBeInstanceOf(SessionAlreadyExistsError) + await (winners[0] as PromiseFulfilledResult).value.close() } 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() + it('duplicate create rejects against a materialized artifact seen by a fresh instance', async () => { + const backend = 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: SessionSeq(6), time: 7, data: { turn: 2 } }, - { type: 'step/start', seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } }, - ]) - const beforeRepair = (await persistence.listSnapshots()) - .find(snapshot => snapshot.header.id === m.id)?.revision + if (backend.reopen === undefined) return + const m = meta('dup-stored', '/work') + const handle = await backend.persistence.create(m) + await handle.append(oneTurnLog()) + await handle.close() - 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: SessionSeq(10), time: 9, data: { turn: 3 } }, - { type: 'turn/end', seq: SessionSeq(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: SessionSeq(6), time: 7, data: { turn: 2 } }, - { type: 'step/start', seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } }, - { type: 'assistant/message', seq: SessionSeq(8), time: 9, data: { - turn: 2, step: 1, - message: createMessage({ - role: 'assistant', - content: [ - { type: 'tool-call', id: ToolCallId('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: ToolCallId('call-x') }, - content: [{ type: 'tool-result', toolCallId: ToolCallId('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(ToolCallId('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: SessionSeq(0), time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: SessionSeq(2), time: 3, data: { - turn: 1, step: 1, - message: createMessage({ - role: 'assistant', - content: [ - { type: 'tool-call', id: ToolCallId('call-risk'), name: 'write', arguments: '{}' }, - ], - source: { - kind: 'model', - ...{ provider: 'mock', model: 'mock' }, - }, - }), - }, surfaceOp: 'append' }, - { type: 'tool/call', seq: SessionSeq(3), time: 4, data: { turn: 1, step: 1, callId: ToolCallId('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') + const reopened = await backend.reopen() + try { + await expect(reopened.persistence.create(meta('dup-stored', '/work'))) + .rejects.toBeInstanceOf(SessionAlreadyExistsError) + } finally { + await reopened.dispose() } - 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: ToolCallId('call-risk'), isError: true, - }) + } finally { + await backend.dispose() + } + }) + + it('open of an absent session rejects with SessionPersistenceNotFoundError for both accesses', async () => { + const { persistence, dispose } = await make() + try { + await expect(persistence.open(SessionId('absent'), 'read')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError) + await expect(persistence.open(SessionId('absent'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError) } finally { await dispose() } }) - it('list() excludes a created-but-never-appended (zero-event) session', async () => { + it('write ownership is single-holder per instance and released by close', 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')) + const m = meta('owned') + const creator = await persistence.create(m) + // The creator holds ownership even before materialization. + await expect(persistence.open(m.id, 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + await creator.append(oneTurnLog()) + await expect(persistence.open(m.id, 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + await creator.close() + + // After close, a new write handle continues at the stored next-seq. + const writer = await persistence.open(m.id, 'write') + await writer.append(secondTurn()) + expect((await writer.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + await writer.close() } finally { await dispose() } }) - it('rejects pre-aborted observation reads with the exact cancellation reason', async () => { + it('a read handle refuses append and flush with SessionReadOnlyError', 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) + const m = meta('read-only') + const writer = await persistence.create(m) + await writer.append(oneTurnLog()) + await writer.close() - 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'), - SessionLogOffset(0), - controller.signal, - )) - .rejects.toBe(reason) + const reader = await persistence.open(m.id, 'read') + expect(reader.access).toBe('read') + await expect(reader.append(secondTurn())).rejects.toBeInstanceOf(SessionReadOnlyError) + await expect(reader.flush()).rejects.toBeInstanceOf(SessionReadOnlyError) + // The refusals mutated nothing. + expect(await reader.read()).toEqual(oneTurnLog()) + await reader.close() } finally { await dispose() } }) - it('readFrom returns exactly the stored suffix from the requested seq, without mutating the log', async () => { + it('operations on a closed handle reject; close is idempotent; asyncDispose releases ownership', 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 m = meta('closed') + const handle = await persistence.create(m) + await handle.append(oneTurnLog()) + await handle.close() + await handle.close() + await expect(handle.read()).rejects.toBeInstanceOf(SessionHandleClosedError) + await expect(handle.append(secondTurn())).rejects.toBeInstanceOf(SessionHandleClosedError) + await expect(handle.flush()).rejects.toBeInstanceOf(SessionHandleClosedError) - const whole = await persistence.readFrom(m.id, SessionLogOffset(0)) - expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' }) - expect(whole.fromSeq).toBe(0) - expect(whole.events).toEqual(log) - - const suffix = await persistence.readFrom(m.id, SessionLogOffset(3)) - expect(suffix.fromSeq).toBe(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, SessionLogOffset(log.length))) - .resolves.toMatchObject({ fromSeq: log.length, events: [] }) - await expect(persistence.readFrom(m.id, SessionLogOffset(log.length + 100))) - .resolves.toMatchObject({ fromSeq: log.length + 100, events: [] }) - - // Non-mutating: an interrupted-turn log is served as stored, no closers. - await persistence.append(m.id, [ - { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, - ]) - const tail = await persistence.readFrom(m.id, SessionLogOffset(6)) - expect(tail.events.map(event => event.type)).toEqual(['turn/start']) - - await expect(persistence.readFrom(SessionId('absent-read-from'), SessionLogOffset(0))) - .rejects.toThrow('not found') - await expect(persistence.readFrom(m.id, -1 as SessionLogOffsetType)) - .rejects.toThrow('non-negative safe integer') - await expect(persistence.readFrom(m.id, 1.5 as SessionLogOffsetType)) - .rejects.toThrow('non-negative safe integer') + { + await using writer = await persistence.open(m.id, 'write') + await writer.append(secondTurn()) + } + // Leaving the block disposed the handle, so ownership is free again. + const reopened = await persistence.open(m.id, 'write') + await reopened.close() } finally { await dispose() } }) - it('lists stable lightweight revisions that change after an append', async () => { + it('service-level flush materializes every active write handle and counts a closing one as flushed', async () => { + const backend = await make() + try { + const materialized = await backend.persistence.create(meta('flush-all')) + const abandoned = await backend.persistence.create(meta('flush-all-closing')) + // Close starts before the barrier: the swept handle refuses its flush, + // which counts as flushed — close itself drained durably. + const closing = abandoned.close() + await backend.persistence.flush() + await closing + + if (backend.reopen !== undefined) { + const reopened = await backend.reopen() + try { + // The barrier materialized the empty session durably... + expect(await reopened.persistence.stat(SessionId('flush-all'))).toBeDefined() + // ...while the one that closed unappended never existed. + expect(await reopened.persistence.stat(SessionId('flush-all-closing'))).toBeUndefined() + } finally { + await reopened.dispose() + } + } + await materialized.close() + } finally { + await backend.dispose() + } + }) + + it('a created-but-unappended session is visible to this instance and invisible to a fresh one', async () => { + const backend = await make() + try { + const m = meta('lazy', '/work') + const creator = await backend.persistence.create(m) + // The creator's own reads see the empty log before materialization. + expect(await creator.read()).toEqual([]) + + const snapshot = await backend.persistence.stat(m.id) + expect(snapshot?.header).toMatchObject(m) + expect((await backend.persistence.list()).map(s => s.header.id)).toContain(m.id) + const reader = await backend.persistence.open(m.id, 'read') + expect(await reader.read()).toEqual([]) + await reader.close() + + if (backend.reopen !== undefined) { + const reopened = await backend.reopen() + try { + expect(await reopened.persistence.stat(m.id)).toBeUndefined() + expect((await reopened.persistence.list()).map(s => s.header.id)).not.toContain(m.id) + await expect(reopened.persistence.open(m.id, 'read')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError) + } finally { + await reopened.dispose() + } + } + await creator.close() + } finally { + await backend.dispose() + } + }) + + it('close without an append erases the created session from this instance', 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) + const m = meta('never-was') + const creator = await persistence.create(m) + await creator.close() - await persistence.append(m.id, [{ - type: 'turn/start', - seq: SessionSeq(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) + expect(await persistence.stat(m.id)).toBeUndefined() + expect((await persistence.list()).map(s => s.header.id)).not.toContain(m.id) + await expect(persistence.open(m.id, 'read')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError) } finally { await dispose() } }) - it('append rejects a batch whose first seq does not match the stored next-seq', async () => { + it('flush materializes an empty session durably for a fresh instance', async () => { + const backend = await make() + try { + if (backend.reopen === undefined) return + const m = meta('durable-empty', '/work') + const creator = await backend.persistence.create(m) + await creator.flush() + await creator.close() + + const reopened = await backend.reopen() + try { + expect((await reopened.persistence.list()).map(s => s.header.id)).toContain(m.id) + expect((await reopened.persistence.stat(m.id))?.header).toMatchObject(m) + const reader = await reopened.persistence.open(m.id, 'read') + expect(await reader.read()).toEqual([]) + await reader.close() + } finally { + await reopened.dispose() + } + } finally { + await backend.dispose() + } + }) + + it('freshness: reads started after an append resolves observe that prefix on any handle', 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() + const m = meta('fresh') + const writer = await persistence.create(m) + await writer.append(oneTurnLog()) + + const before = await persistence.open(m.id, 'read') + expect(await before.read()).toEqual(oneTurnLog()) + + await writer.append(secondTurn()) + // Both a pre-existing read handle and a freshly opened one observe the + // append once it has resolved. + expect((await before.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + const after = await persistence.open(m.id, 'read') + expect((await after.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + await before.close() + await after.close() + await writer.close() } finally { await dispose() } }) - it('append rejects a mid-batch seq gap', async () => { + it('a fresh instance continues the stored log at the committed next-seq', async () => { + const backend = await make() + try { + if (backend.reopen === undefined) return + const m = meta('continue', '/work') + const creator = await backend.persistence.create(m) + await creator.append(oneTurnLog()) + await creator.close() + + const reopened = await backend.reopen() + try { + const writer = await reopened.persistence.open(m.id, 'write') + await writer.append(secondTurn()) + expect((await writer.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + await writer.close() + } finally { + await reopened.dispose() + } + } finally { + await backend.dispose() + } + }) + + it('append rejects a batch that does not contiguously continue the log, naming the expected seq', async () => { const { persistence, dispose } = await make() try { - const m = meta('s4') - await persistence.create(m) + const m = meta('contiguity') + const handle = await persistence.create(m) + await handle.append(oneTurnLog()) // seqs 0..5, next-seq = 6 + // A re-append of an already-stored seq is rejected, not duplicated. + await expect(handle.append(oneTurnLog())).rejects.toThrow(/expected 6/) + // A mid-batch gap is rejected as a whole. const gapped: SessionEvent[] = [ - { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }, // gap: missing seq 1 + { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(8), time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] - await expect(persistence.append(m.id, gapped)).rejects.toThrow() + await expect(handle.append(gapped)).rejects.toThrow(/expected 7/) + // Neither rejection changed the stored log. + expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) + await handle.close() } finally { await dispose() } }) - it('append rejects non-JSON-serializable event data, naming the event type', async () => { + it('append rejects non-JSON-serializable event data without storing anything', 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 = { 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: SessionSeq(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/) + const m = meta('non-json') + const handle = await persistence.create(m) + const bad = (extra: unknown): SessionEvent[] => [{ + type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, extra }, + }] as unknown as SessionEvent[] + await expect(handle.append(bad(1n))).rejects.toThrow(TypeError) + await expect(handle.append(bad(1n))).rejects.toThrow(/losslessly JSON-serializable/) + await expect(handle.append(bad(undefined))).rejects.toThrow(/losslessly JSON-serializable/) + // The rejected batches left no events behind: seq 0 is still free. + await handle.append(oneTurnLog()) + expect(await handle.read()).toEqual(oneTurnLog()) + await handle.close() + } finally { + await dispose() + } + }) + + it('vocabulary fail-closed: an unknown stored event type refuses reads and write opens', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('foreign-vocabulary') + const handle = await persistence.create(m) + // The append side is permissive — a newer producer's event type is + // stored verbatim… + await handle.append(oneTurnLog()) + await handle.append([ + { type: 'mystery/event', seq: SessionSeq(6), time: 7, data: { payload: true } }, + ] as unknown as SessionEvent[]) + await handle.close() + + // …but this build refuses to interpret the stored log: a write open + // rejects, and a read handle (or its first read) rejects. + await expect(persistence.open(m.id, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) + // The failed write open released its ownership claim: retrying yields + // the same refusal, never SessionAlreadyOwnedError. + await expect(persistence.open(m.id, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) + const readFailure = await persistence.open(m.id, 'read').then( + async (reader) => { + try { + return await reader.read().then(() => undefined, (error: unknown) => error) + } finally { + await reader.close() + } + }, + (error: unknown) => error, + ) + expect(readFailure).toBeInstanceOf(SessionFormatUnsupportedError) + expect((readFailure as Error).message).toContain('mystery/event') + } finally { + await dispose() + } + }) + + it('a torn physical tail is never served and is durably truncated by the write path', async () => { + const backend = await make() + try { + if (backend.reopen === undefined || backend.corruptTail === undefined) return + const m = meta('torn', '/work') + const creator = await backend.persistence.create(m) + await creator.append(oneTurnLog()) + await creator.close() + await backend.corruptTail(m.id, m.cwd) + + // A reader over the corrupted artifact serves only the committed prefix. + const readerInstance = await backend.reopen() + try { + const reader = await readerInstance.persistence.open(m.id, 'read') + expect(await reader.read()).toEqual(oneTurnLog()) + await reader.close() + + // A write open + first append durably truncates the torn tail and + // continues at the committed next-seq. + const writer = await readerInstance.persistence.open(m.id, 'write') + await writer.append(secondTurn()) + expect((await writer.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + await writer.close() + } finally { + await readerInstance.dispose() } + + // The repaired log is intact for the next instance. + const verifyInstance = await backend.reopen() + try { + const verify = await verifyInstance.persistence.open(m.id, 'read') + expect((await verify.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + await verify.close() + } finally { + await verifyInstance.dispose() + } + } finally { + await backend.dispose() + } + }) + + it('stat and list agree on stable revisions that change after an append', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('revisions', '/work') + const writer = await persistence.create(m) + await writer.append(oneTurnLog()) + + const statFirst = await persistence.stat(m.id) + const statAgain = await persistence.stat(m.id) + const listFirst = (await persistence.list()).find(s => s.header.id === m.id) + expect(statFirst).toBeDefined() + expect(statAgain?.revision).toBe(statFirst?.revision) + expect(listFirst?.revision).toBe(statFirst?.revision) + + await writer.append(secondTurn()) + const statChanged = await persistence.stat(m.id) + expect(statChanged?.revision).not.toBe(statFirst?.revision) + const listChanged = (await persistence.list()).find(s => s.header.id === m.id) + expect(listChanged?.revision).toBe(statChanged?.revision) + + // Snapshot headers carry the stored header, identically everywhere. + const reader = await persistence.open(m.id, 'read') + expect(statChanged?.header).toEqual(reader.header) + expect(listChanged?.header).toEqual(reader.header) + expect(statChanged?.header).toMatchObject(m) + await reader.close() + await writer.close() + + expect(await persistence.stat(SessionId('absent-stat'))).toBeUndefined() } finally { await dispose() } diff --git a/packages/session/session-persistence/tests/coordinator-contract.ts b/packages/session/session-persistence/tests/coordinator-contract.ts deleted file mode 100644 index 587d7ff1c3..0000000000 --- a/packages/session/session-persistence/tests/coordinator-contract.ts +++ /dev/null @@ -1,1593 +0,0 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' -/** - * Shared write-path orchestration contract for backends using {@link PersistenceCoordinator}. - * Unlike the public storage-semantics suite in `contract.ts`, it covers SessionStore event wiring, - * lazy creation, fork seed persistence, four adoption/collision cases, crash-tail repair, reload, - * flush, and disposal quiescence through public APIs rather than storage primitives. - * - * Each real backend supplies a shared storage scope and optional torn-tail injector; backend specs - * retain only storage-mechanics tests, while these scenarios run once per backend. - * @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract - */ - -import { describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from '@deepseek-ai/cordis' -import { scopeTarget } from '@deepseek-ai/dsh-scope' -import SessionStore, { - SESSION_FORMAT_VERSION, - Session, - SessionId, - SessionLogOffset, - SessionSeq, -} from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { meta, oneTurnLog, appendLog } from './contract.ts' - -/** - * The backend-specific capabilities the orchestration suite needs beyond the - * public service API. A fresh fixture is created per test (isolated storage); - * the suite mounts/disposes backend instances on it and cleans it up at the end. - */ -export interface CoordinatorFixture { - /** Mount the real backend through `ctx.plugin` over shared storage and return only that fiber. */ - mount: (ctx: Context) => Promise - - /** - * Inject a never-committed partial record after the durable region so `loadCore` reaches - * `commitRepair`. Omit only when the backend structurally cannot produce torn tails. - */ - corruptTail?: (id: SessionId, cwd: string | undefined) => Promise - - /** Tear down the storage scope (remove the temp dir / file). */ - cleanup: () => Promise -} - -/** A constant absolute cwd; JSONL keys directories off it and memory ignores it. */ -const WORK = '/w' -const OTHER = '/other' - -/** Append a whole event log to a live session, event by event (drives session/event). */ -function send(session: Session, events: readonly SessionEvent[]): void { - appendLog(session, events) -} - -/** A valid persisted log from immediately before messages gained wrappers and identities. */ -export function legacyMessageLog(): SessionEvent[] { - return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { - type: 'user/message', - seq: 1, - time: 2, - data: { 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, - content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }], - provenance: { provider: 'mock', model: 'mock' }, - }, - surfaceOp: 'append', - }, - { - type: 'tool/call', - seq: 4, - time: 5, - data: { turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}' }, - }, - { - type: 'tool/result', - seq: 5, - time: 6, - data: { - turn: 1, - step: 1, - callId: 'call-1', - content: [{ type: 'text', text: 'full result' }], - isError: false, - }, - sourceEventSeqs: [4], - surfaceOp: 'append', - }, - { - type: 'tool/result', - seq: 6, - time: 8, - data: { - turn: 1, - step: 1, - callId: 'call-1', - content: [{ type: 'text', text: 'pruned' }], - isError: false, - }, - sourceEventSeqs: [5], - surfaceOp: { op: 'replace', start: 5, end: 5 }, - }, - { type: 'step/end', seq: 7, time: 9, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 8, time: 10, data: { turn: 1, reason: { kind: 'completed' } } }, - ] as unknown as SessionEvent[] -} - -/** A complete log in the durable event vocabulary of the react-loop refactor base. */ -export function preReactLoopLog(): SessionEvent[] { - const prompt = createUserMessage({ - content: [{ type: 'text', text: 'old prompt' }], - source: { kind: 'user' }, - }) - const steering = createUserMessage({ - content: [{ type: 'text', text: 'old steering' }], - source: { kind: 'user' }, - }) - return [ - { - type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, - }, - { type: 'user/message', seq: 1, time: 2, data: prompt, surfaceOp: 'append' }, - { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, - { - type: 'steering/message', seq: 3, time: 4, - data: { turn: 1, message: steering }, - 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' } } }, - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'retry' } } }, - { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, - { type: 'step/end', seq: 8, time: 9, data: { turn: 2, step: 1 } }, - { - type: 'turn/end', seq: 9, time: 10, - data: { - turn: 2, - reason: { - kind: 'error', - step: 1, - failure: { message: 'old provider failure', code: 'SERVER' }, - }, - }, - }, - { - type: 'turn/start', seq: 10, time: 11, - data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }, - }, - { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'aborted' } } }, - { - type: 'turn/start', seq: 12, time: 13, - data: { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }, - }, - { type: 'turn/end', seq: 13, time: 14, data: { turn: 4, reason: { kind: 'disposed' } } }, - { - type: 'turn/start', seq: 14, time: 15, - data: { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } }, - }, - { type: 'step/start', seq: 15, time: 16, data: { turn: 5, step: 1 } }, - { type: 'step/end', seq: 16, time: 17, data: { turn: 5, step: 1 } }, - { - type: 'turn/end', seq: 17, time: 18, - data: { turn: 5, reason: { kind: 'error', step: 1, message: 'old thrown value' } }, - }, - { - type: 'turn/start', seq: 18, time: 19, - data: { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } }, - }, - { - type: 'turn/end', seq: 19, time: 20, - data: { - turn: 6, - reason: { - kind: 'error', - step: 0, - failure: { - message: 'old detailed provider failure', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 1000, - requestId: 'request-1', - }, - }, - }, - }, - { - type: 'turn/start', seq: 20, time: 21, - data: { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } }, - }, - { - type: 'turn/end', seq: 21, time: 22, - data: { turn: 7, reason: { kind: 'error', step: 0, message: 'old coded error', code: 'CODED' } }, - }, - ] as unknown as SessionEvent[] -} - -/** A live session created inside its OWN fiber, so it survives a backend reload. */ -async function liveSessionInFiber( - ctx: Context, id: string, cwd: string | undefined, -): Promise { - let session!: Session - await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId(id), cwd !== undefined ? { meta: { cwd } } : undefined) - }, { inject: ['sessions'] })) - return session -} - -/** - * Run the coordinator orchestration suite against a backend. `makeFixture()` - * MUST return a fresh fixture (isolated storage) each call. - */ -export function runCoordinatorContract(name: string, makeFixture: () => Promise): void { - describe(`PersistenceCoordinator orchestration: ${name}`, () => { - /** Mount SessionStore + a backend instance on a fresh context over the fixture's storage. */ - async function freshCtx(fix: CoordinatorFixture): Promise<{ ctx: Context; fiber: Fiber }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await fix.mount(ctx) - return { ctx, fiber } - } - - // --- write path: live session → flush → reload --- - - it('persists a live session driven through the store, surviving reload', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } }) - send(session, oneTurnLog()) - await ctx.sessions.flush(session) - - const loaded = await ctx.sessionPersistence.load(SessionId('live')) - expect(loaded.events).toHaveLength(6) - expect(loaded.meta.cwd).toBe(WORK) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('rejects crash-repair load while a live session owns the persisted prefix', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - let session!: Session - const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('live-load'), { meta: { cwd: WORK } }) - }, { inject: ['sessions'] })) - try { - session.append('turn/start', { turn: 1 }) - await ctx.sessions.flush(session) - - await expect(ctx.sessionPersistence.load(session.id)) - .rejects.toThrow(`cannot load session "${session.id}" while its live turn is open`) - - send(session, oneTurnLog().slice(1)) - await ctx.sessions.flush(session) - await sessionFiber.dispose() - - await vi.waitFor(async () => { - const loaded = await ctx.sessionPersistence.load(session.id) - expect(loaded.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) - expect(loaded.events.at(-1)).toMatchObject({ - type: 'turn/end', - data: { reason: { kind: 'completed' } }, - }) - }) - } finally { - await sessionFiber.dispose() - await fiber.dispose() - await fix.cleanup() - } - }) - - it('rechecks live ownership after a cold load enters the per-id chain', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const id = SessionId('queued-load-live-race') - const header = meta(id, WORK) - const start: SessionEvent = { - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { turn: 1 }, - } - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(id, [start]) - - const loading = ctx.sessionPersistence.load(id) - const live = ctx.sessions.create(id, { seed: [start], meta: header }) - await expect(loading).rejects.toThrow(/live turn is open/) - - live.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(live) - const loaded = await ctx.sessionPersistence.load(id) - // The constructor's end-seed event persisted between the stored - // turn/start and the turn/end appended live. - expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed', 'turn/end']) - expect(loaded.events.at(-1)).toMatchObject({ - type: 'turn/end', - data: { reason: { kind: 'completed' } }, - }) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('does not load an unmaterialized empty live session', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const session = ctx.sessions.create(SessionId('empty-live'), { meta: { cwd: WORK } }) - await expect(ctx.sessionPersistence.load(session.id)).rejects.toThrow(/not found/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('preserves the exact inherited cut through load, inspect, suffix read, prepare, and reopen', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - const inherited = oneTurnLog() - try { - let session!: Session - const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('forked-child'), { - seed: inherited, - inheritedEventCount: SessionLogOffset(inherited.length), - meta: { cwd: WORK, isSeeded: true }, - }) - }, { inject: ['sessions'] })) - await ctx.sessions.flush(session) - await sessionFiber.dispose() - - const loaded = await ctx.sessionPersistence.load(SessionId('forked-child')) - expect(loaded.meta.isSeeded).toBe(true) - expect(loaded.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) - expect((await ctx.sessionPersistence.inspect(SessionId('forked-child'))).inheritedEventCount) - .toBe(SessionLogOffset(inherited.length)) - const suffix = await ctx.sessionPersistence.readFrom( - SessionId('forked-child'), - SessionLogOffset(3), - ) - expect(suffix.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) - expect(suffix.fromSeq).toBe(3) - expect(suffix.events[0]?.seq).toBe(3) - const preparation = await ctx.sessionPersistence.prepare(SessionId('forked-child')) - expect(preparation.session.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) - preparation[Symbol.dispose]() - - await fiber.dispose() - const reopened = await freshCtx(fix) - try { - const reopenedLoad = await reopened.ctx.sessionPersistence.load(SessionId('forked-child')) - expect(reopenedLoad.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) - const reopenedPreparation = await reopened.ctx.sessionPersistence.prepare(SessionId('forked-child')) - expect(reopenedPreparation.session.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) - reopenedPreparation[Symbol.dispose]() - } finally { - await reopened.fiber.dispose() - } - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('round-trips the delegation depth through persistence', async () => { - // A subagent child's recursion budget lives in its header; a reload that - // dropped it would reset the child to top-level and un-bound maxDepth - // The backend must preserve it in stored metadata. - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - let session!: Session - const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('delegated-child'), { - meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 }, - }) - }, { inject: ['sessions'] })) - send(session, oneTurnLog()) - await ctx.parallel('session/flush', session) - await sessionFiber.dispose() - - const loaded = await ctx.sessionPersistence.load(SessionId('delegated-child')) - expect(loaded.meta.delegationDepth).toBe(2) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1 }) - const ev = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - expect(() => { - ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' - }).toThrow(TypeError) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - - const loaded = await ctx.sessionPersistence.load(SessionId('mutate')) - const message = loaded.events.find(event => event.type === 'user/message') - expect(message?.type === 'user/message' && (message.data.content[0] as { text: string }).text).toBe('original') - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('load and inspect return immutable identified-message snapshots', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const id = SessionId('immutable-read') - const session = ctx.sessions.create(id, { meta: { cwd: WORK } }) - send(session, oneTurnLog()) - await ctx.sessions.flush(session) - - for (const snapshot of [ - await ctx.sessionPersistence.load(id), - await ctx.sessionPersistence.inspect(id), - ]) { - const event = snapshot.events.find(candidate => candidate.type === 'user/message') - if (event?.type !== 'user/message') throw new Error('fixture lacks user/message') - expect(Object.isFrozen(event.data)).toBe(true) - expect(Object.isFrozen(event.data.content)).toBe(true) - expect(() => { - ;(event.data as { id: string }).id = 'rewritten' - }).toThrow(TypeError) - expect(() => { - ;(event.data.content[0] as { type: 'text'; text: string }).text = 'rewritten' - }).toThrow(TypeError) - } - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('loads pre-identity message logs into resumable current sessions', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const id = SessionId('legacy-message-load') - await ctx.sessionPersistence.create(meta(id, WORK)) - await ctx.sessionPersistence.append(id, legacyMessageLog()) - - for (const snapshot of [ - await ctx.sessionPersistence.inspect(id), - await ctx.sessionPersistence.load(id), - ]) { - const messages: { id: string }[] = [] - for (const event of snapshot.events) { - if (event.type === 'user/message') messages.push(event.data) - else if (event.type === 'assistant/message' - || event.type === 'tool/result') messages.push(event.data.message) - } - expect(messages.map(message => message.id)).toEqual([ - `legacy-message:${id}:1`, - `legacy-message:${id}:3`, - `legacy-message:${id}:5`, - `legacy-message:${id}:5`, - ]) - expect(messages.every(message => Object.isFrozen(message))).toBe(true) - - const resumed = Session.create(id, snapshot.events, snapshot.meta) - expect(resumed.deriveMessages().map(message => message.id)).toEqual([ - `legacy-message:${id}:1`, - `legacy-message:${id}:3`, - `legacy-message:${id}:5`, - ]) - } - - const replacementSuffix = await ctx.sessionPersistence.readFrom(id, SessionLogOffset(6)) - expect(replacementSuffix.events[0]).toMatchObject({ - type: 'tool/result', - seq: 6, - data: { message: { id: `legacy-message:${id}:5` } }, - }) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('loads pre-react-loop session logs into resumable current sessions', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const id = SessionId('pre-react-loop-load') - const log = preReactLoopLog() - const legacySteering = log[3] as unknown as { data: { message: { id: string } } } - await ctx.sessionPersistence.create(meta(id, WORK)) - await ctx.sessionPersistence.append(id, log) - - const snapshots = [ - await ctx.sessionPersistence.inspect(id), - await ctx.sessionPersistence.readFrom(id, SessionLogOffset(0)), - await ctx.sessionPersistence.load(id), - ] - for (const snapshot of snapshots) { - expect(snapshot.events.some(event => (event.type as string) === 'steering/message')).toBe(false) - expect(snapshot.events.filter(event => event.type === 'turn/start').map(event => event.data)) - .toEqual([ - { turn: 1 }, { turn: 2 }, { turn: 3 }, { turn: 4 }, { turn: 5 }, { turn: 6 }, { turn: 7 }, - ]) - expect(snapshot.events.filter(event => event.type === 'turn/end').map(event => event.data)).toEqual([ - { turn: 1, reason: { kind: 'completed' } }, - { - turn: 2, - reason: { kind: 'error', error: { message: 'old provider failure', code: 'SERVER' } }, - }, - { turn: 3, reason: { kind: 'aborted', reason: { kind: 'legacy' } } }, - { turn: 4, reason: { kind: 'aborted', reason: { kind: 'disposed' } } }, - { - turn: 5, - reason: { kind: 'error', error: { message: 'old thrown value', code: 'UNKNOWN' } }, - }, - { - turn: 6, - reason: { - kind: 'error', - error: { - message: 'old detailed provider failure', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 1000, - requestId: 'request-1', - }, - }, - }, - { - turn: 7, - reason: { kind: 'error', error: { message: 'old coded error', code: 'CODED' } }, - }, - ]) - - const resumed = Session.create(id, snapshot.events, snapshot.meta) - expect(resumed.deriveMessages().map(message => message.content)).toEqual([ - [{ type: 'text', text: 'old prompt' }], - [{ type: 'text', text: 'old steering' }], - ]) - } - - const suffix = await ctx.sessionPersistence.readFrom(id, SessionLogOffset(3)) - expect(suffix.events[0]).toMatchObject({ - type: 'user/message', - seq: 3, - data: { id: legacySteering.data.message.id }, - }) - expect(suffix.events.filter(event => event.type === 'turn/end') - .every(event => !Object.hasOwn(event.data, 'step'))).toBe(true) - - const flatId = SessionId('pre-react-loop-flat-steering') - await ctx.sessionPersistence.create(meta(flatId, WORK)) - await ctx.sessionPersistence.append(flatId, [{ - type: 'steering/message', - seq: 0, - time: 1, - data: { - turn: 1, - content: [{ type: 'text', text: 'flat steering' }], - source: { kind: 'user' }, - }, - surfaceOp: 'append', - } as unknown as SessionEvent]) - expect((await ctx.sessionPersistence.inspect(flatId)).events[0]).toMatchObject({ - type: 'user/message', - data: { - id: `legacy-message:${flatId}:0`, - role: 'user', - content: [{ type: 'text', text: 'flat steering' }], - }, - }) - - const extendedId = SessionId('current-extended-turn-end') - await ctx.sessionPersistence.create(meta(extendedId, WORK)) - await ctx.sessionPersistence.append(extendedId, [ - { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, - { - type: 'turn/end', seq: 1, time: 2, - data: { turn: 1, reason: { kind: 'extension-reason' } }, - } as unknown as SessionEvent, - ]) - expect((await ctx.sessionPersistence.inspect(extendedId)).events[1]).toMatchObject({ - type: 'turn/end', - data: { reason: { kind: 'extension-reason' } }, - }) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('rejects malformed persisted message events before returning them', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const id = SessionId('invalid-message-read') - await ctx.sessionPersistence.create(meta(id, WORK)) - await ctx.sessionPersistence.append(id, [{ - type: 'user/message', - seq: 0, - time: 1, - surfaceOp: 'append', - data: { - id: 'wrong-role', - role: 'assistant', - content: [{ type: 'text', text: 'wrong' }], - source: { kind: 'user' }, - }, - } as unknown as SessionEvent]) - - await expect(ctx.sessionPersistence.inspect(id)) - .rejects.toThrow('message must have role "user"') - await expect(ctx.sessionPersistence.load(id)) - .rejects.toThrow('message must have role "user"') - - const malformedLegacy: { id: string; event: SessionEvent; message: string }[] = [ - { - id: 'invalid-old-turn-start', - event: { - type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: null }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop turn/start', - }, - { - id: 'invalid-old-steering', - event: { - type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append', - data: { turn: 1, content: [], source: { kind: 'user' }, extra: true }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop steering/message', - }, - { - id: 'invalid-old-steering-data', - event: { - type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append', data: null, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop steering/message', - }, - { - id: 'invalid-old-turn-end', - event: { - type: 'turn/end', seq: 0, time: 1, - data: { turn: 1, reason: { kind: 'completed', extra: true } }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop turn/end', - }, - { - id: 'invalid-old-turn-end-reason', - event: { - type: 'turn/end', seq: 0, time: 1, - data: { turn: 1, reason: null }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop turn/end', - }, - { - id: 'unsupported-intermediate-turn-end-step', - event: { - type: 'turn/end', seq: 0, time: 1, - data: { turn: 1, step: 1, reason: { kind: 'completed' } }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop turn/end', - }, - { - id: 'invalid-old-turn-end-aborted', - event: { - type: 'turn/end', seq: 0, time: 1, - data: { turn: 1, reason: { kind: 'aborted', extra: true } }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop turn/end', - }, - { - id: 'invalid-old-turn-end-disposed', - event: { - type: 'turn/end', seq: 0, time: 1, - data: { turn: 1, reason: { kind: 'disposed', extra: true } }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop turn/end', - }, - { - id: 'invalid-old-turn-end-error-step', - event: { - type: 'turn/end', seq: 0, time: 1, - data: { turn: 1, reason: { kind: 'error', step: -1, message: 'bad step' } }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop turn/end', - }, - { - id: 'invalid-old-turn-end-error-code', - event: { - type: 'turn/end', seq: 0, time: 1, - data: { turn: 1, reason: { kind: 'error', step: 0, message: 'bad code', code: 1 } }, - } as unknown as SessionEvent, - message: 'malformed pre-react-loop turn/end', - }, - ] - for (const malformed of malformedLegacy) { - const malformedId = SessionId(malformed.id) - await ctx.sessionPersistence.create(meta(malformedId, WORK)) - await ctx.sessionPersistence.append(malformedId, [malformed.event]) - await expect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(malformed.message) - await expect(ctx.sessionPersistence.readFrom(malformedId, SessionLogOffset(0))) - .rejects.toThrow(malformed.message) - } - - const malformedReplacementId = SessionId('invalid-old-tool-result-replacement') - await ctx.sessionPersistence.create(meta(malformedReplacementId, WORK)) - await ctx.sessionPersistence.append(malformedReplacementId, [{ - type: 'tool/result', - seq: 0, - time: 1, - surfaceOp: { op: 'replace', start: -1, end: -1 }, - data: { - turn: 1, - step: 1, - callId: 'call', - content: [{ type: 'text', text: 'result' }], - isError: false, - }, - } as unknown as SessionEvent]) - await expect(ctx.sessionPersistence.inspect(malformedReplacementId)) - .rejects.toThrow('invalid replace surfaceOp') - - for (const type of ['tool/result'] as const) { - const malformedId = SessionId(`invalid-${type}`) - await ctx.sessionPersistence.create(meta(malformedId, WORK)) - await ctx.sessionPersistence.append(malformedId, [{ - type, - seq: 0, - time: 1, - surfaceOp: 'append', - data: { message: null }, - } as unknown as SessionEvent]) - await expect(ctx.sessionPersistence.inspect(malformedId)) - .rejects.toThrow('lacks an identified message') - } - - // An out-of-repo event type passes only with the envelope's ignorable - // marker (unknown-type refusal otherwise), and its non-object data is - // not message-validated. - const pluginId = SessionId('non-object-plugin-event') - await ctx.sessionPersistence.create(meta(pluginId, WORK)) - await ctx.sessionPersistence.append(pluginId, [{ - type: 'plugin/test', - seq: 0, - time: 1, - data: null, - ignorable: true, - } as unknown as SessionEvent]) - await expect(ctx.sessionPersistence.inspect(pluginId)) - .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) - await expect(ctx.sessionPersistence.readFrom(pluginId, SessionLogOffset(0))) - .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) - - for (const type of ['user/message', 'assistant/message'] as const) { - const missingContentId = SessionId(`invalid-${type}-without-content`) - await ctx.sessionPersistence.create(meta(missingContentId, WORK)) - await ctx.sessionPersistence.append(missingContentId, [{ - type, - seq: 0, - time: 1, - surfaceOp: 'append', - data: {}, - } as unknown as SessionEvent]) - await expect(ctx.sessionPersistence.readFrom(missingContentId, SessionLogOffset(0))) - .rejects.toThrow('lacks an identified message') - } - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('append snapshots the batch: mutating the caller array/events after the call is ignored', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const m = meta('snapshot', WORK) - await ctx.sessionPersistence.create(m) - const events = structuredClone(oneTurnLog()) // seqs 0..5 - const userMsg = events[1] // the user/message event - const p = ctx.sessionPersistence.append(m.id, events) - // Mutate the caller's array AND an event object after the call but before - // the queued op runs: the snapshot taken at call time must shield the copy. - events.push({ type: 'turn/start', seq: SessionSeq(6), time: 99, data: { turn: 2 } }) - if (userMsg?.type === 'user/message') { - (userMsg.data as { content: unknown[] }).content = [{ type: 'text', text: 'MUTATED' }] - } - await p - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) // not 0..6 - const persisted = JSON.stringify(loaded.events) - expect(persisted).toContain('hi') // original content - expect(persisted).not.toContain('MUTATED') - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - // --- fork / resume --- - - it('fork: a seeded new session persists its seed once (no double-write on a no-op flush)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const seed = oneTurnLog() - // A fork: a brand-new id whose seed came from elsewhere. - const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } }) - await ctx.sessions.flush(forked) // onCreated persisted the seed - const loaded = await ctx.sessionPersistence.load(SessionId('forked')) - // Fork is where the marker earns its keep: the inherited prefix may - // carry a bracket the still-running parent owns. - expect(loaded.events.slice(0, seed.length)).toEqual(seed) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: seed.length }) - // A flush with no NEW events must not double-write. - await ctx.sessions.flush(forked) - const reloaded = await ctx.sessionPersistence.load(SessionId('forked')) - expect(reloaded.events).toEqual(loaded.events) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq', async () => { - // Separate backend lifecycles distinguish persisted-seed adoption from an in-memory continuation. - const fix = await makeFixture() - const first = await freshCtx(fix) - try { - const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } }) - send(s1, oneTurnLog()) - await first.ctx.sessions.flush(s1) - } finally { - await first.fiber.dispose() - } - - const second = await freshCtx(fix) - try { - const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) - const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } }) - await second.ctx.sessions.flush(s2) // let onCreated adopt - s2.append('turn/start', { turn: 2 }) - s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await second.ctx.sessions.flush(s2) - - const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) - // 0-5 the resumed seed, 6 end-seed, 7-8 the new turn. - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) - expect(reloaded.events[6]).toMatchObject({ type: 'session/end-seed' }) - } finally { - await second.fiber.dispose() - await fix.cleanup() - } - }) - - // --- HMR --- - - it('HMR: applying the plugin seeds existing live sessions', async () => { - const fix = await makeFixture() - const ctx = new Context() - await ctx.plugin(SessionStore) - // A session exists BEFORE the persistence plugin is applied. - const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - const fiber = await fix.mount(ctx) - try { - // The plugin seeded it on apply; a subsequent flush persists its events. - await ctx.sessions.flush(session) - const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing')) - expect(loaded.events.length).toBeGreaterThanOrEqual(2) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('HMR: dispose drains remaining buffers', async () => { - const fix = await makeFixture() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await fix.mount(ctx) - const session = await liveSessionInFiber(ctx, 'drain', WORK) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // No explicit flush — dispose must drain. - await fiber.dispose() - - // A fresh backend instance reads what the disposed one drained. - const second = await freshCtx(fix) - try { - const loaded = await second.ctx.sessionPersistence.load(SessionId('drain')) - expect(loaded.events.length).toBeGreaterThanOrEqual(2) - } finally { - await second.fiber.dispose() - await fix.cleanup() - } - }) - - it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => { - const fix = await makeFixture() - const ctx = new Context() - await ctx.plugin(SessionStore) - // The session lives in its OWN fiber so it survives the backend reload. - const session = await liveSessionInFiber(ctx, 'hmr-adopt', WORK) - try { - // Backend instance 1 materializes the session. - const backend1 = await fix.mount(ctx) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - - // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the - // session stays live. The new instance has no coordinator state but must adopt the - // materialized prefix, then persist another turn rather than rejecting it as a collision. - await backend1.dispose() - await fix.mount(ctx) - session.append('turn/start', { turn: 2 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'again' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await expect(ctx.sessions.flush(session)).resolves.not.toThrow() - - const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt')) - expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) - } finally { - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - - it('HMR: adoption persists the live SUFFIX that was ahead of the stored prefix', async () => { - const fix = await makeFixture() - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = await liveSessionInFiber(ctx, 'hmr-suffix', WORK) - try { - // Instance 1 flushes turn 1. - const backend1 = await fix.mount(ctx) - session.append('turn/start', { turn: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - - // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT - // flushing turn 2: it is now ONLY in the live session's events; the new - // backend never buffered it via session/event. - await backend1.dispose() - session.append('turn/start', { turn: 2 }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - - // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the - // live suffix (turn 2) carried in the session's events. - await fix.mount(ctx) - await ctx.sessions.flush(session) - const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3]) - expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) - } finally { - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - - it('HMR adoption does NOT crash-repair an active open turn as interrupted (truncate without closers)', async () => { - const fix = await makeFixture() - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = await liveSessionInFiber(ctx, 'hmr-open', WORK) - try { - const first = await fix.mount(ctx) - session.append('turn/start', { turn: 1 }) - session.append('step/start', { turn: 1, step: 1 }) - await ctx.sessions.flush(session) - - // Crash-tail a torn fragment past the (open) committed turn, then reload. - await first.dispose() - if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK) - const second = await fix.mount(ctx) - // The live session is still the authority: it appends the REAL step/turn - // end. Adoption must truncate the torn tail but NOT synthesize closers. - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - - const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) - expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) - expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) - await second.dispose() - } finally { - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - - // --- collision / id reuse --- - - it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => { - const fix = await makeFixture() - const first = await freshCtx(fix) - try { - const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) - send(s1, oneTurnLog()) - await first.ctx.sessions.flush(s1) - } finally { - await first.fiber.dispose() - } - - // A fresh backend + a NEW live session with the same id but NO explicit resume. onCreated - // treats it as new; create() rejects because a log already exists, and `flush()` surfaces - // that initialization rejection. - const second = await freshCtx(fix) - try { - const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) - s2.append('turn/start', { turn: 1 }) - await expect(second.ctx.sessions.flush(s2)) - .rejects.toThrow(/already has a persisted log|id collision/) - } finally { - await second.fiber.dispose() - await fix.cleanup() - } - }) - - it('an abandoned lazy session (never materialized) releases its id for reuse', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // A live session created then disposed BEFORE its first append: cursor 0, - // never materialized. A new live session reusing the id must reclaim it. - let firstSession!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) - }, { inject: ['sessions'] })) - await ctx.sessions.flush(firstSession) // register the lazy state - await firstFiber.dispose() // disposed before any append → never materialized - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) - }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(reuse)).resolves.toBe(true) - reuse.append('turn/start', { turn: 1 }) - reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(reuse) - const loaded = await ctx.sessionPersistence.load(SessionId('abandoned')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('session disposal drains buffered events before retiring ownership', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - let first!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } }) - }, { inject: ['sessions'] })) - await ctx.sessions.flush(first) - // Append a turn but do NOT flush — events sit in the write-behind buffer. - first.append('turn/start', { turn: 1 }) - first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await firstFiber.dispose() - - // Disposal is an observe-only notification. Poll storage rather than - // assuming the owning fiber awaits the coordinator's detached drain. - await vi.waitFor(async () => { - expect((await ctx.sessionPersistence.list()).map(meta => meta.id)).toContain(SessionId('buffered')) - }) - expect((await ctx.sessionPersistence.load(SessionId('buffered'))).events.map(event => event.seq)).toEqual([0, 1]) - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } }) - }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/persisted log|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('initFor is idempotent: re-emitting session/created does not re-initialize', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - // Re-emit session/created for the SAME live session (idempotent initFor). - ctx.emit(scopeTarget(session, undefined), 'session/created', session) - await ctx.sessions.flush(session) - const loaded = await ctx.sessionPersistence.load(SessionId('idem')) - expect(loaded.events).toHaveLength(3) // not doubled - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - // --- ownerless-state claim (public create()/load() then a live session arrives) --- - - it('a live session claims cursor-0 ownerless state created via the public API and persists its seed', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // create() registers ownerless state with cursor 0 (lazy, nothing persisted). - await ctx.sessionPersistence.create(meta('lazy-claim', WORK)) - // A live session with that id arrives and claims it (cursor 0 matches - // trivially), persisting its seed. - const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(ctx.sessions.flush(live)).resolves.toBe(true) - const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) - // Seeded 0-5 plus the constructor's end-seed event at 6. - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6]) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' }) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // Materialize a log, then load() it WITHOUT a live session — ownerless - // state, cursor at the persisted length. - await ctx.sessionPersistence.create(meta('preview', WORK)) - await ctx.sessionPersistence.append(SessionId('preview'), oneTurnLog()) - await ctx.sessionPersistence.load(SessionId('preview')) - - // A FRESH (empty-seed) live session reusing that id must be rejected: its - // seq 0..cursor-1 events would otherwise be filtered as already-persisted. - let fresh!: Session - await ctx.plugin(Object.assign((inner: Context) => { - fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } }) - }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(fresh)) - .rejects.toThrow(/do not match this live session|already has a persisted log|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a live session whose complete seed matches loaded ownerless state claims it without appending', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const id = SessionId('claim-exact') - const completeSeed = [ - ...oneTurnLog(), - { type: 'session/end-seed', seq: 6, time: 7, data: {} }, - ] as SessionEvent[] - await ctx.sessionPersistence.create(meta(id, WORK)) - await ctx.sessionPersistence.append(id, completeSeed) - const { events } = await ctx.sessionPersistence.load(id) - - const live = ctx.sessions.create(id, { seed: events, meta: { cwd: WORK } }) - await expect(ctx.sessions.flush(live)).resolves.toBe(true) - expect((await ctx.sessionPersistence.load(id)).events).toEqual(events) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // Materialize and load (ownerless, cursor = 6). - const storedMeta = meta('claim', WORK) - await ctx.sessionPersistence.create(storedMeta) - await ctx.sessionPersistence.append(SessionId('claim'), oneTurnLog()) - const { events, meta: durableMeta } = await ctx.sessionPersistence.load(SessionId('claim')) - - // A live session SEEDED with the loaded log PLUS a new turn claims the - // ownerless state and persists only the suffix. - let cont!: Session - const contFiber = await ctx.plugin(Object.assign((inner: Context) => { - cont = inner.sessions.create(SessionId('claim'), { seed: [ - ...events, - { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ], meta: { cwd: WORK, createdAt: 2000 } }) - }, { inject: ['sessions'] })) - await ctx.sessions.flush(cont) - const loaded = await ctx.sessionPersistence.load(SessionId('claim')) - // 6-7 the claimed suffix; 8 end-seed after the whole seed. - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' }) - expect(loaded.meta).toEqual(durableMeta) - expect(loaded.meta.createdAt).toBe(1000) - - await contFiber.dispose() - await vi.waitFor(async () => { - expect((await ctx.sessionPersistence.load(SessionId('claim'))).meta).toEqual(durableMeta) - }) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a live session at a DIFFERENT cwd cannot claim cursor-0 ownerless state (cwd scope)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // create() registers ownerless state at cwd /a (cursor 0 — claims would - // otherwise match trivially on the seed). - await ctx.sessionPersistence.create(meta('wrong-cwd-claim', OTHER)) - // A live session reusing the id but at cwd WORK must NOT claim it — the - // cwd scope is the fence (without it, WORK events would append under the - // OTHER header). Rejected as a collision. - const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a live session cannot claim ownerless state with a different inherited cut', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const id = SessionId('wrong-cut-claim') - await ctx.sessionPersistence.create( - { ...meta(id, WORK), isSeeded: true }, - SessionLogOffset(0), - ) - const live = ctx.sessions.create(id, { - seed: oneTurnLog(), - inheritedEventCount: SessionLogOffset(1), - meta: { cwd: WORK, isSeeded: true }, - }) - - await expect(ctx.sessions.flush(live)) - .rejects.toThrow(/different inherited event count|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a live session cannot adopt a stored prefix with a different inherited cut', async () => { - const fix = await makeFixture() - const id = SessionId('wrong-cut-adoption') - const first = await freshCtx(fix) - try { - await first.ctx.sessionPersistence.create( - { ...meta(id, WORK), isSeeded: true }, - SessionLogOffset(0), - ) - await first.ctx.sessionPersistence.append(id, oneTurnLog()) - } finally { - await first.fiber.dispose() - } - - const second = await freshCtx(fix) - try { - const live = second.ctx.sessions.create(id, { - seed: oneTurnLog(), - inheritedEventCount: SessionLogOffset(1), - meta: { cwd: WORK, isSeeded: true }, - }) - - await expect(second.ctx.sessions.flush(live)) - .rejects.toThrow(/different inherited event count|id collision/) - } finally { - await second.fiber.dispose() - await fix.cleanup() - } - }) - - it('a live session at a DIFFERENT cwd cannot claim loaded-prefix ownerless state (cwd scope)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // Materialize + load at cwd OTHER (ownerless, cursor = 6). - await ctx.sessionPersistence.create(meta('wrong-cwd-load', OTHER)) - await ctx.sessionPersistence.append(SessionId('wrong-cwd-load'), oneTurnLog()) - const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load')) - // A live session whose SEED matches the loaded prefix but whose cwd is - // WORK must still be rejected — the cwd guard runs before the seed check. - const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // Ownerless state created WITHOUT a cwd (the `_no-cwd` project directory). - await ctx.sessionPersistence.create(meta('no-cwd-state')) - // A live session reusing the id but WITH cwd WORK is a cwd mismatch - // (undefined vs WORK) and must be rejected. - const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - // --- append adopts a storage-only session (fresh instance, no prior create/load) --- - - it('append adopts a storage-only session (fresh instance) and continues the seq', async () => { - const fix = await makeFixture() - const first = await freshCtx(fix) - try { - const m = meta('adopt-append', WORK) - await first.ctx.sessionPersistence.create(m) - await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) - } finally { - await first.fiber.dispose() - } - - // A fresh instance appends a second turn WITHOUT a prior create/load: append - // must adopt the stored session (cursor = stored length) and continue. - const second = await freshCtx(fix) - try { - await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [ - { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ]) - const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - } finally { - await second.fiber.dispose() - await fix.cleanup() - } - }) - - // --- small public-API edges that the coordinator owns uniformly --- - - it('append of an empty batch is a no-op (stays lazy)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const m = meta('empty-batch', WORK) - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, []) - expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('load and inspect reject a missing session', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/) - await expect(ctx.sessionPersistence.inspect(SessionId('nope'))).rejects.toThrow(/not found/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('create rejects a duplicate id (in memory and on a persisted log)', async () => { - const fix = await makeFixture() - const first = await freshCtx(fix) - try { - const m = meta('dup', WORK) - await first.ctx.sessionPersistence.create(m) - // Same in-memory state. - await expect(first.ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists in this backend/) - await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) - } finally { - await first.fiber.dispose() - } - - // A fresh instance over the same storage sees the persisted log. - const second = await freshCtx(fix) - try { - await expect(second.ctx.sessionPersistence.create(meta('dup', WORK))) - .rejects.toThrow(/already has a persisted log on disk/) - } finally { - await second.fiber.dispose() - await fix.cleanup() - } - }) - - it('rejects a newer format version on load, naming the upgrade direction', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK, isSeeded: false } - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) - expect(failure?.name).toBe('SessionFormatUnsupportedError') - expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('rejects an older format version on load without claiming an upgrade path', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK, isSeeded: false } - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) - expect(failure?.name).toBe('SessionFormatUnsupportedError') - expect(failure?.message).toMatch(/older than the supported v0.*no upgrade path/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('rejects an unknown event type on load unless the event is marked ignorable', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const required = meta('unknown-required', WORK) - await ctx.sessionPersistence.create(required) - await ctx.sessionPersistence.append(required.id, [ - ...oneTurnLog(), - { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 } } as unknown as SessionEvent, - ]) - const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error) - expect(failure?.name).toBe('SessionFormatUnsupportedError') - expect(failure?.message).toMatch(/event type "future\/event".*not marked ignorable/) - - const skippable = meta('unknown-ignorable', WORK) - await ctx.sessionPersistence.create(skippable) - await ctx.sessionPersistence.append(skippable.id, [ - ...oneTurnLog(), - { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 }, ignorable: true } as unknown as SessionEvent, - ]) - const loaded = await ctx.sessionPersistence.load(skippable.id) - expect(loaded.events.some(event => (event.type as string) === 'future/event')).toBe(true) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('round-trips a header with parentSession (fork lineage)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - const m = { - version: SESSION_FORMAT_VERSION, - id: SessionId('forked-child'), - createdAt: 1, - cwd: WORK, - parentSession: SessionId('the-parent'), - isSeeded: false, - } - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.meta.parentSession).toBe('the-parent') - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('flush before init resolves uses cursor 0', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // Append directly to a live session and flush IMMEDIATELY, before the - // async onCreated init has necessarily set state (exercises the - // state-undefined cursor path). - const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate')) - expect(loaded.events).toHaveLength(3) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - // --- crash-tail repair THROUGH the coordinator (real storage torn tail) --- - - it('torn-tail load: a never-committed tail is truncated and the open turn closed during load (commitRepair w/ tornMarker)', async () => { - const fix = await makeFixture() - if (!fix.corruptTail) { - // A memory-style store has no torn tails (every write is atomic in RAM), - // so there is no tornMarker path to exercise. Assert that explicitly - // instead of silently skipping, then bail. - expect(fix.corruptTail).toBeUndefined() - await fix.cleanup() - return - } - const first = await freshCtx(fix) - try { - const m = meta('torn', WORK) - await first.ctx.sessionPersistence.create(m) - await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed 0..5 (balanced) - // A second turn whose real events are durable but never closed (open turn). - await first.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, - { type: 'step/start', seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } }, - ]) - } finally { - await first.fiber.dispose() - } - // Inject a torn fragment past the committed region (never-committed tail). - await fix.corruptTail(SessionId('torn'), WORK) - - // A FRESH instance loads: the torn tail is truncated (tornMarker !== - // undefined) AND the open turn 2 is closed with synthetic step/end + - // turn/end {interrupted} — commitRepair runs with BOTH a torn marker and - // closers. The preserved real events (0..7) are never truncated. - const second = await freshCtx(fix) - try { - const loaded = await second.ctx.sessionPersistence.load(SessionId('torn')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) - 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 + synthetic closers - ]) - const last = loaded.events.at(-1)! - expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' }) - - // The repair is durable: the next append continues at the balanced length - // (seq 10) and a reload round-trips identically. - await second.ctx.sessionPersistence.append(SessionId('torn'), [ - { type: 'turn/start', seq: SessionSeq(10), time: 9, data: { turn: 3 } }, - { type: 'turn/end', seq: SessionSeq(11), time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, - ]) - const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn')) - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) - } finally { - await second.fiber.dispose() - await fix.cleanup() - } - }) - }) -} diff --git a/packages/session/session-persistence/tests/live-write-contract.ts b/packages/session/session-persistence/tests/live-write-contract.ts new file mode 100644 index 0000000000..c0f57ba9fa --- /dev/null +++ b/packages/session/session-persistence/tests/live-write-contract.ts @@ -0,0 +1,275 @@ +/** + * Shared live-write-path contract for any {@link SessionPersistence} backend: + * published live events route by session id into the active write handle, + * `session/flush` is the durability and error-observation barrier, + * `session/disposed` drains and closes, and `close()` itself drains the + * routed buffer — including through backend teardown with no cross-fiber + * ordering. Each provider owns its storage runtime; this suite pins the + * equivalent observable behavior the seam requires. + * + * @module @deepseek-ai/dsh-session-persistence/tests/live-write-contract + */ + +import { describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '../src/index.ts' + +/** One mounted backend under a session store, plus same-storage remount support. */ +export interface LiveWriteBackend { + /** Context with SessionStore and the persistence backend mounted. */ + readonly ctx: Context + /** Mount a FRESH context over the SAME storage, as after a process restart. */ + readonly remount: () => Promise +} + +async function readAll(persistence: SessionPersistence, id: ReturnType): Promise { + const reader = await persistence.open(id, 'read') + try { + return await reader.read() + } finally { + await reader.close() + } +} + +/** + * Run the backend-agnostic live-write-path suite. + * @param name - suite label, e.g. `jsonl` / `sqlite`. + * @param batchDelayMs - the provider's fixed live batching window. + * @param make - factory producing one fresh mounted backend per test; every + * created context is disposed by the test that made it. + */ +export function runLiveWritePathContract( + name: string, + batchDelayMs: number, + make: () => Promise, +): void { + describe(`live session write path: ${name}`, () => { + it('routes published events into the active write handle within one batching window', async () => { + const { ctx } = await make() + const session = ctx.sessions.create(SessionId('routed')) + const handle = await ctx.sessionPersistence.create(session.header) + vi.useFakeTimers() + try { + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await vi.advanceTimersByTimeAsync(batchDelayMs - 1) + // One tick short of the window: nothing stored yet (in-process + // visibility serves the created-but-empty session). + expect(await readAll(ctx.sessionPersistence, session.id)).toEqual([]) + await vi.advanceTimersByTimeAsync(1) + } finally { + vi.useRealTimers() + } + // The deadline started a background write; wait for its durability. + await vi.waitFor(async () => { + expect((await readAll(ctx.sessionPersistence, session.id)).map(event => [event.type, event.seq])).toEqual([ + ['turn/start', 0], + ['turn/end', 1], + ]) + }) + await handle.close() + await ctx.fiber.dispose() + }) + + it('a session without an active write handle persists nothing', async () => { + const { ctx } = await make() + const session = ctx.sessions.create(SessionId('unrouted')) + session.append('turn/start', { turn: 1 }) + await ctx.sessions.flush(session) + await expect(ctx.sessionPersistence.stat(session.id)).resolves.toBeUndefined() + await ctx.fiber.dispose() + }) + + it('session/flush drains immediately and surfaces a retained background failure', async () => { + const { ctx } = await make() + const session = ctx.sessions.create(SessionId('flush-surfaces')) + const handle = await ctx.sessionPersistence.create(session.header) + const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const failure = new Error('backend write refused') + // Inject at the service's storage primitive: the routed drain writes + // through the handle's internal chain, not the public append. + const persist = vi.spyOn(ctx.sessionPersistence as unknown as { persistBatch: () => Promise }, 'persistBatch') + .mockRejectedValue(failure) + + vi.useFakeTimers() + session.append('turn/start', { turn: 1 }) + await vi.advanceTimersByTimeAsync(batchDelayMs) + vi.useRealTimers() + await vi.waitFor(() => { + expect(warned.mock.calls.join('\n')).toContain('background write for session "flush-surfaces" failed') + }) + + // The barrier retries the retained batch and rejects loudly... + await expect(ctx.sessions.flush(session)).rejects.toBe(failure) + // ...and once the backend recovers, the same events land exactly once. + persist.mockRestore() + await expect(ctx.sessions.flush(session)).resolves.toBe(true) + expect((await readAll(ctx.sessionPersistence, session.id)).map(event => event.seq)).toEqual([0]) + warned.mockRestore() + await handle.close() + await ctx.fiber.dispose() + }) + + it('service-level flush drains every active handle and aggregates the failures', async () => { + const { ctx } = await make() + const healthy = ctx.sessions.create(SessionId('flush-all-healthy')) + const failing = ctx.sessions.create(SessionId('flush-all-failing')) + const healthyHandle = await ctx.sessionPersistence.create(healthy.header) + const failingHandle = await ctx.sessionPersistence.create(failing.header) + const service = ctx.sessionPersistence as unknown as { + persistBatch: (header: { id: string }, ...rest: unknown[]) => Promise + } + const original = service.persistBatch.bind(service) + const failure = new Error('backend write refused') + const persist = vi.spyOn(service, 'persistBatch').mockImplementation((header, ...rest) => + header.id === failing.id ? Promise.reject(failure) : original(header, ...rest)) + + vi.useFakeTimers() + try { + healthy.append('turn/start', { turn: 1 }) + failing.append('turn/start', { turn: 1 }) + // No batching window elapses: the service barrier itself drains both + // buffers and reports the one failure without abandoning the sweep. + await expect(ctx.sessionPersistence.flush()).rejects.toSatisfy((error: unknown) => + error instanceof AggregateError && error.errors.length === 1 && error.errors[0] === failure) + } finally { + vi.useRealTimers() + } + // The healthy session flushed durably despite its neighbor's failure... + expect((await readAll(ctx.sessionPersistence, healthy.id)).map(event => event.seq)).toEqual([0]) + // ...and the failed batch is retained: a recovered backend flushes it exactly once. + persist.mockRestore() + await ctx.sessionPersistence.flush() + expect((await readAll(ctx.sessionPersistence, failing.id)).map(event => event.seq)).toEqual([0]) + await healthyHandle.close() + await failingHandle.close() + await ctx.fiber.dispose() + }) + + it('session/disposed drains buffered events and closes the handle', async () => { + const { ctx } = await make() + let session: ReturnType | undefined + const owner = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('disposed-drains')) + }, { inject: ['sessions'] })) + if (session === undefined) throw new Error('session was not created') + const handle = await ctx.sessionPersistence.create(session.header) + session.append('turn/start', { turn: 1 }) + // Buffered, not yet written: disposal must drain before the close. + await owner.dispose() + await vi.waitFor(async () => { + expect((await readAll(ctx.sessionPersistence, SessionId('disposed-drains'))).map(event => event.seq)).toEqual([0]) + }) + await expect(handle.append([])).rejects.toThrow(/closed handle/) + await ctx.fiber.dispose() + }) + + it('a failing final drain on disposal is warned, not thrown', async () => { + const { ctx } = await make() + let session: ReturnType | undefined + const owner = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('disposed-drain-fails')) + }, { inject: ['sessions'] })) + if (session === undefined) throw new Error('session was not created') + const handle = await ctx.sessionPersistence.create(session.header) + const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + vi.spyOn(handle, 'close').mockRejectedValue(new Error('drain exploded')) + session.append('turn/start', { turn: 1 }) + await owner.dispose() + await vi.waitFor(() => { + expect(warned.mock.calls.join('\n')).toContain('final drain for session "disposed-drain-fails" failed') + }) + warned.mockRestore() + await ctx.fiber.dispose() + }) + + it('backend teardown drains buffered events through the close sweep', async () => { + const backend = await make() + const { ctx } = backend + const session = ctx.sessions.create(SessionId('teardown-drains')) + const handle = await ctx.sessionPersistence.create(session.header) + session.append('turn/start', { turn: 1 }) + // Root disposal closes the still-open handle; close drains the buffer. + await ctx.fiber.dispose() + await expect(handle.append([])).rejects.toThrow(/closed handle/) + + const verify = await backend.remount() + expect((await readAll(verify.sessionPersistence, session.id)).map(event => event.seq)).toEqual([0]) + await verify.fiber.dispose() + }) + + it('close itself surfaces a failing drain and still releases write ownership', async () => { + const { ctx } = await make() + const session = ctx.sessions.create(SessionId('close-drain-fails')) + const handle = await ctx.sessionPersistence.create(session.header) + const failure = new Error('storage refused the drain') + vi.spyOn(ctx.sessionPersistence as unknown as { persistBatch: () => Promise }, 'persistBatch') + .mockRejectedValue(failure) + session.append('turn/start', { turn: 1 }) + await expect(handle.close()).rejects.toBe(failure) + // Ownership was released despite the failed drain: the id is claimable. + const second = await ctx.sessionPersistence.create(session.header) + await second.close() + await ctx.fiber.dispose() + }) + + it('close normalizes a non-Error drain failure', async () => { + const { ctx } = await make() + const session = ctx.sessions.create(SessionId('close-drain-string')) + const handle = await ctx.sessionPersistence.create(session.header) + vi.spyOn(ctx.sessionPersistence as unknown as { persistBatch: () => Promise }, 'persistBatch') + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error arm is the case under test. + .mockImplementation(() => Promise.reject('backend string refusal')) + session.append('turn/start', { turn: 1 }) + await expect(handle.close()).rejects.toThrow('backend string refusal') + await ctx.fiber.dispose() + }) + + it('a failed drain retains order, quiets the timer, and recovers exactly once', async () => { + const { ctx } = await make() + const session = ctx.sessions.create(SessionId('retained-order')) + const handle = await ctx.sessionPersistence.create(session.header) + const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const host = ctx.sessionPersistence as unknown as { persistBatch: (...args: unknown[]) => Promise } + const real = host.persistBatch.bind(host) + const persist = vi.spyOn(host, 'persistBatch').mockRejectedValue(new Error('first drain refused')) + + vi.useFakeTimers() + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + await vi.advanceTimersByTimeAsync(batchDelayMs) + // Events arriving after the failure join the retained queue, and no new + // timer fires while the automatic path is paused. + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await vi.advanceTimersByTimeAsync(batchDelayMs * 4) + vi.useRealTimers() + expect(persist).toHaveBeenCalledTimes(1) + + persist.mockImplementation(real) + await expect(ctx.sessions.flush(session)).resolves.toBe(true) + expect((await readAll(ctx.sessionPersistence, session.id)).map(event => event.seq)).toEqual([0, 1, 2, 3]) + warned.mockRestore() + await handle.close() + await ctx.fiber.dispose() + }) + + it('a second write handle after close routes subsequent events', async () => { + const { ctx } = await make() + const session = ctx.sessions.create(SessionId('rebind')) + const first = await ctx.sessionPersistence.create(session.header) + session.append('turn/start', { turn: 1 }) + await ctx.sessions.flush(session) + await first.close() + + const second = await ctx.sessionPersistence.open(session.id, 'write') + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.sessions.flush(session) + expect((await readAll(ctx.sessionPersistence, session.id)).map(event => event.seq)).toEqual([0, 1]) + await second.close() + await ctx.fiber.dispose() + }) + }) +} diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts deleted file mode 100644 index dd002882f7..0000000000 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ /dev/null @@ -1,2376 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import SessionStore, { - Session, - SessionId, - SessionLogOffset, - SessionSeq, -} from '@deepseek-ai/dsh-session' -import { isJsonValue } from '@deepseek-ai/dsh-util-values' -import type { - SessionEvent, - SessionHeader, - SessionLogOffset as SessionLogOffsetType, -} from '@deepseek-ai/dsh-session' -import { - DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionEventSuffix, type SessionInspection, type SessionPersistenceSnapshot, - type SessionStorageMetadata, type StoredPrefix, type StoredSuffix, -} from '../src/index.ts' -import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' -import { - legacyMessageLog, preReactLoopLog, runCoordinatorContract, type CoordinatorFixture, -} from './coordinator-contract.ts' - -/** The durable store shape: materialized sessions only (no lazy entries). */ -type MemoryStore = Map - -/** Test-store revision that changes for any metadata or event mutation. */ -function memoryRevision(entry: { - meta: SessionHeader - inheritedEventCount?: SessionLogOffsetType - events: SessionEvent[] -}): SessionPersistenceRevision { - return SessionPersistenceRevision(JSON.stringify(entry)) -} - -/** An obsolete event fixture that emulates an untyped pre-change producer. */ -function legacyHeaderDelta(seq = 0): SessionEvent { - return { - type: 'request/header-delta', - seq, - time: 1, - data: { config: { model: 'legacy' } }, - } as unknown as SessionEvent -} - -/** An unsupported named-mode fixture emulating an untyped producer. */ -function legacyModeSet(seq = 0): SessionEvent { - return { - type: 'mode/set', - seq, - time: 1, - data: { mode: 'plan' }, - } as unknown as SessionEvent -} - -/** An obsolete full-header reason fixture from the removed delta codec. */ -function legacyFallbackHeader(seq = 0): SessionEvent { - return { - type: 'request/header', - seq, - time: 1, - data: { header: { config: { model: 'legacy' } }, reason: 'fallback' }, - } as unknown as SessionEvent -} - -/** Optional plugin config: an EXTERNAL store shared across backend instances. */ -interface MemoryConfig { store?: MemoryStore } - -/** Test-only view of the coordinator containers whose retirement is the contract under test. */ -interface CoordinatorInternals { - states: Map - live: Map | undefined; hasWork: boolean } - }> - chains: Map - retirements: Map> -} - -/** - * Reference {@link PersistenceCoordinator} vehicle and abstract-service coverage, backed by a - * dependency-free map with atomic writes and no torn-tail marker. Supplying the map lets multiple - * instances share materialized sessions, the in-memory analogue of reload over one artifact; - * durable behavior is covered by the JSONL provider. - */ -class MemoryPersistence extends SessionPersistence implements PersistenceBackend { - override readonly supportsRawArtifacts = false - - static inject = ['sessions'] - - override readonly name = 'session-persistence-memory' - - /** The whole durable store: materialized sessions only (no lazy entries). */ - private store: MemoryStore - private coordinator: PersistenceCoordinator - - constructor(ctx: Context, config?: MemoryConfig) { - super(ctx) - // Assign the store BEFORE constructing the coordinator: the coordinator's - // constructor installs the write path and synchronously seeds existing live - // sessions through loadStored(), so store must exist first. - this.store = config?.store ?? new Map() - this.coordinator = new PersistenceCoordinator(this.ctx, this) - } - - // --- Service API (delegated to the coordinator) --- - - locate(_meta: SessionHeader): undefined { - return undefined - } - - create(m: SessionHeader, inheritedEventCount?: SessionLogOffsetType): Promise { - return this.coordinator.create(m, inheritedEventCount) - } - - override ensureMaterialized(session: Session): Promise { - return this.coordinator.ensureMaterialized(session) - } - - append(id: SessionId, events: readonly SessionEvent[]): Promise { - return this.coordinator.append(id, events) - } - - override prepare(id: SessionId, signal?: AbortSignal): ReturnType { - return this.coordinator.prepare(id, signal) - } - - load(id: SessionId): Promise { - return this.coordinator.load(id).then(loaded => ({ - meta: loaded.meta, - inheritedEventCount: loaded.inheritedEventCount, - events: [...loaded.events], - })) - } - - inspect(id: SessionId, signal?: AbortSignal): Promise { - return this.coordinator.inspect(id, signal) - .then(loaded => ({ - meta: loaded.meta, - inheritedEventCount: loaded.inheritedEventCount, - events: [...loaded.events], - })) - } - - borrowSession(id: SessionId, signal?: AbortSignal): ReturnType { - return this.coordinator.borrowSession(id, signal) - } - - readFrom(id: SessionId, fromSeq: SessionLogOffsetType, signal?: AbortSignal): Promise { - return this.coordinator.readFrom(id, fromSeq, signal) - } - - // --- PersistenceBackend hooks (the Map storage primitives) --- - - // A Map-backed store has no torn tails, so `tornMarker` is never set. - async loadStored(id: SessionId): Promise | undefined> { - const entry = this.store.get(id) - if (!entry) return undefined - return { - meta: structuredClone(entry.meta), - inheritedEventCount: SessionLogOffset(entry.inheritedEventCount ?? 0), - events: structuredClone(entry.events), - revision: memoryRevision(entry), - } - } - - async readStoredRevision(id: SessionId): Promise { - const entry = this.store.get(id) - return entry === undefined ? undefined : memoryRevision(entry) - } - - async appendBatch( - storage: SessionStorageMetadata, - events: readonly SessionEvent[], - _isMaterialized: boolean, - ): Promise { - // Defense-in-depth: the coordinator already validates serializability, but a - // durable store must reject non-JSON data at its own boundary too. - for (const e of events) { - if (!isJsonValue(e.data)) throw new Error(`event "${e.type}" carries non-JSON-serializable data`) - } - const { meta: m, inheritedEventCount } = storage - const existing = this.store.get(m.id) - if (!existing) { - // The coordinator sends the first batch for materialization; later batches append. - this.store.set(m.id, { - meta: structuredClone(m), - inheritedEventCount, - events: structuredClone(events) as SessionEvent[], - }) - } else { - existing.events.push(...structuredClone(events) as SessionEvent[]) - } - } - - materializeHeader(storage: SessionStorageMetadata): Promise { - const { meta: m, inheritedEventCount } = storage - this.store.set(m.id, { meta: structuredClone(m), inheritedEventCount, events: [] }) - return Promise.resolve() - } - - async commitRepair( - storage: SessionStorageMetadata, - _tornMarker: undefined, - closers: readonly SessionEvent[], - ): Promise { - // No torn tails in a Map store, so `_tornMarker` is always undefined; only the - // synthetic closers are appended (the same DELETE+INSERT a DB backend does, - // minus the truncate). - const entry = this.store.get(storage.meta.id) - /* v8 ignore next -- commitRepair only runs for a materialized (stored) session */ - if (!entry) return - if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[]) - } - - async list(signal?: AbortSignal): Promise { - signal?.throwIfAborted() - return [...this.store.values()].map(e => structuredClone(e.meta)) - } - - async listSnapshots(signal?: AbortSignal): Promise { - signal?.throwIfAborted() - return [...this.store.values()].map(entry => ({ - header: structuredClone(entry.meta), - revision: memoryRevision(entry), - })) - } -} - -/** Controllable storage primitive for serialization and retirement failure tests. */ -class ControlledBackend implements PersistenceBackend { - readonly name = 'session-persistence-controlled' - readonly store: MemoryStore = new Map() - readonly lifecycle: string[] = [] - lastAppendedBatch: readonly SessionEvent[] | undefined - appendAttempts = 0 - loadAttempts = 0 - repairAttempts = 0 - beforeAppend?: (attempt: number) => Promise - beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise - /** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */ - seekHook?: ( - id: SessionId, - fromSeq: SessionLogOffsetType, - signal?: AbortSignal, - ) => Promise - - loadStoredFrom( - id: SessionId, - fromSeq: SessionLogOffsetType, - signal?: AbortSignal, - ): Promise { - if (this.seekHook === undefined) throw new Error('seekHook not configured for this test') - return this.seekHook(id, fromSeq, signal) - } - - async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { - const attempt = ++this.loadAttempts - await this.beforeLoadStored?.(attempt, signal) - const entry = this.store.get(id) - if (entry === undefined) return undefined - return { - meta: structuredClone(entry.meta), - inheritedEventCount: SessionLogOffset(entry.inheritedEventCount ?? 0), - events: structuredClone(entry.events), - revision: memoryRevision(entry), - } - } - - async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - const entry = this.store.get(id) - return entry === undefined ? undefined : memoryRevision(entry) - } - - async appendBatch( - storage: SessionStorageMetadata, - events: readonly SessionEvent[], - _isMaterialized: boolean, - ): Promise { - const { meta: m, inheritedEventCount } = storage - this.lastAppendedBatch = events - const attempt = ++this.appendAttempts - await this.beforeAppend?.(attempt) - const entry = this.store.get(m.id) - if (entry === undefined) { - this.store.set(m.id, { - meta: structuredClone(m), - inheritedEventCount, - events: structuredClone(events) as SessionEvent[], - }) - } else { - entry.events.push(...structuredClone(events) as SessionEvent[]) - } - } - - async commitRepair( - storage: SessionStorageMetadata, - _tornMarker: undefined, - closers: readonly SessionEvent[], - ): Promise { - this.repairAttempts += 1 - const entry = this.store.get(storage.meta.id) - if (entry !== undefined) entry.events.push(...structuredClone(closers) as SessionEvent[]) - } - - async list(): Promise { - return [...this.store.values()].map(entry => structuredClone(entry.meta)) - } - - async close(): Promise { - this.lifecycle.push('close') - } -} - -runPersistenceContract('memory', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence) - return { - persistence: ctx.sessionPersistence, - dispose: async () => { await fiber.dispose() }, - } -}) - -describe('the inherited readRaw default', () => { - it('rejects unsupported reads distinctly from absence and honors an aborted signal', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(MemoryPersistence) - expect(ctx.sessionPersistence.supportsRawArtifacts).toBe(false) - await expect( - ctx.sessionPersistence.readRaw(SessionId('any-session')), - ).rejects.toThrow('does not expose raw artifacts') - await expect( - ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()), - ).rejects.toThrow() - // A non-Error abort reason falls back to a wrapped Error rejection. - const controller = new AbortController() - controller.abort('boom') - await expect( - ctx.sessionPersistence.readRaw(SessionId('any-session'), controller.signal), - ).rejects.toThrow('aborted') - }) -}) - -// Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are -// atomic; the suite asserts that skip while JSONL covers the repair branch. -runCoordinatorContract('memory', async (): Promise => { - const store: MemoryStore = new Map() - return { - mount: async ctx => ctx.plugin(MemoryPersistence, { store }), - cleanup: async () => { store.clear() }, - } -}) - -describe('PersistenceCoordinator seed ownership', () => { - it('retains the immutable session seed without cloning it', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const session = ctx.sessions.create(SessionId('shared-seed'), { seed: oneTurnLog() }) - const seed = session.snapshotEvents() - await ctx.sessions.flush(session) - - expect(backend.lastAppendedBatch).toBe(seed) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) -}) - -describe('PersistenceCoordinator bounded writes', () => { - it('cancels the batching deadline when live initialization rejects', async () => { - vi.useFakeTimers() - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const failure = new Error('initialization failed') - backend.beforeLoadStored = () => Promise.reject(failure) - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - new PersistenceCoordinator(inner, backend, { - preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE, - writeBatchMaxDelayMs: MAX_WRITE_BATCH_DELAY_MS, - }) - }, { inject: ['sessions'] })) - - try { - const session = ctx.sessions.create(SessionId('bounded-init-failure')) - session.append('turn/start', { turn: 1 }) - - await expect(ctx.sessions.flush(session)).rejects.toBe(failure) - expect(vi.getTimerCount()).toBe(0) - try { - await fiber.dispose() - } catch { - // The initialization failure was already asserted at the flush boundary. - } - expect(vi.getTimerCount()).toBe(0) - } finally { - try { - await fiber.dispose() - } catch { - // The expected initialization failure was asserted above; cleanup only - // needs to release any remaining parent effects. - } - try { - await ctx.fiber.dispose() - } catch { - // The child failure was already asserted through the backend fiber. - } - vi.useRealTimers() - } - }) - - it('starts a follow-up batch for events admitted during an in-flight write', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const appendGate = Promise.withResolvers() - backend.beforeAppend = async (attempt) => { - if (attempt === 1) await appendGate.promise - } - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - new PersistenceCoordinator(inner, backend, { - preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE, - writeBatchMaxDelayMs: 1, - }) - }, { inject: ['sessions'] })) - - try { - const session = ctx.sessions.create(SessionId('bounded-follow-up')) - await ctx.sessions.flush(session) - session.append('turn/start', { turn: 1 }) - await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) - - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - appendGate.resolve(true) - - await vi.waitFor(() => { - expect(backend.appendAttempts).toBe(2) - expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1]) - }) - } finally { - appendGate.resolve(true) - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('retries a failed overlapping background write at the explicit flush barrier', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const appendGate = Promise.withResolvers() - backend.beforeAppend = async (attempt) => { - if (attempt === 1) { - await appendGate.promise - throw new Error('transient background failure') - } - } - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - new PersistenceCoordinator(inner, backend, { - preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE, - writeBatchMaxDelayMs: 1, - }) - }, { inject: ['sessions'] })) - - try { - const session = ctx.sessions.create(SessionId('bounded-flush-retry')) - await ctx.sessions.flush(session) - session.append('turn/start', { turn: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) - - const barriers = [ctx.sessions.flush(session), ctx.sessions.flush(session)] - appendGate.resolve(true) - - await expect(Promise.all(barriers)).resolves.toEqual([true, true]) - expect(backend.appendAttempts).toBe(2) - expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1]) - } finally { - appendGate.resolve(true) - await fiber.dispose() - await ctx.fiber.dispose() - } - }) -}) - -describe('PersistenceCoordinator stored identity', () => { - it('rejects a mismatched backend header before repair or state publication', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const requested = SessionId('requested') - backend.store.set(requested, { - meta: meta('different'), - events: [{ - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { turn: 1 }, - }], - }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - try { - await expect(coordinator.load(requested)).rejects.toThrow(/stored session identity mismatch/) - expect(backend.repairAttempts).toBe(0) - expect((coordinator as unknown as CoordinatorInternals).states.size).toBe(0) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('rejects an inherited cut beyond the stored prefix before repair', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('invalid-inherited-cut') - backend.store.set(id, { - meta: { ...meta(id), isSeeded: true }, - inheritedEventCount: SessionLogOffset(2), - events: [{ - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { turn: 1 }, - }], - }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - try { - await expect(coordinator.load(id)).rejects.toThrow(/inherited event count exceeds its stored event count/) - expect(backend.repairAttempts).toBe(0) - expect((coordinator as unknown as CoordinatorInternals).states.size).toBe(0) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('reserves a cold id across asynchronous storage repair', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('cold-load-reservation') - const header = meta(id) - const start: SessionEvent = { - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { turn: 1 }, - } - backend.store.set(id, { meta: header, events: [start] }) - const loadGate = Promise.withResolvers() - backend.beforeLoadStored = async () => { await loadGate.promise } - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const loading = coordinator.load(id) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) - - await expect(ctx.plugin(Object.assign((inner: Context) => { - inner.sessions.create(id, { seed: [start], meta: header }) - }, { inject: ['sessions'] }))).rejects.toThrow(/persisted state already owns this identity/) - expect(ctx.sessions.get(id)).toBeUndefined() - - loadGate.resolve(true) - const loaded = await loading - expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) - - const resumed = ctx.sessions.create(id, { seed: loaded.events, meta: loaded.meta }) - await expect(ctx.sessions.flush(resumed)).resolves.toBe(true) - } finally { - loadGate.resolve(true) - await fiber.dispose() - await ctx.fiber.dispose() - } - }) -}) - -describe('PersistenceCoordinator session preparations', () => { - it.each([0, 1.5])('rejects invalid preparation cache capacity %s', (capacity) => { - const ctx = new Context() - const backend = new ControlledBackend() - - expect(() => new PersistenceCoordinator(ctx, backend, { - preparedSessionCacheSize: capacity, - writeBatchMaxDelayMs: DEFAULT_WRITE_BATCH_MAX_DELAY_MS, - })).toThrow(/positive safe integer/) - }) - - it.each([0, 1.5, MAX_WRITE_BATCH_DELAY_MS + 1])('rejects invalid write batch delay %s', (delay) => { - const ctx = new Context() - const backend = new ControlledBackend() - - expect(() => new PersistenceCoordinator(ctx, backend, { - preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE, - writeBatchMaxDelayMs: delay, - })).toThrow(/writeBatchMaxDelayMs must be an integer between/) - }) - - it('retries invalidated prepare and load reservations', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const prepareId = SessionId('prepare-reservation-retry') - const loadId = SessionId('load-reservation-retry') - backend.store.set(prepareId, { meta: meta(prepareId), events: oneTurnLog() }) - backend.store.set(loadId, { meta: meta(loadId), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const preparations = (coordinator as unknown as { - preparations: { reserve: (...args: unknown[]) => Promise } - }).preparations - const reserve = vi.spyOn(preparations, 'reserve') - - try { - reserve.mockResolvedValueOnce(undefined) - const preparation = await coordinator.prepare(prepareId) - preparation[Symbol.dispose]() - - reserve.mockResolvedValueOnce(undefined) - await expect(coordinator.load(loadId)).resolves.toMatchObject({ meta: { id: loadId } }) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('prefers a session that becomes live across preparation reads', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const prepareId = SessionId('prepare-became-live') - const loadId = SessionId('load-became-live') - const inspectId = SessionId('inspect-became-live') - const validatedInspectId = SessionId('validated-inspect-became-live') - const failedInspectId = SessionId('failed-inspect-became-live') - for (const id of [prepareId, loadId, inspectId, validatedInspectId]) { - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - } - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const prepareLive = Session.create(prepareId, oneTurnLog(), meta(prepareId)) - const prepareGet = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(prepareLive) - await expect(coordinator.prepare(prepareId)).rejects.toThrow(/while it is live/) - prepareGet.mockRestore() - - const loadLive = Session.create(loadId, oneTurnLog(), meta(loadId)) - const loadGet = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(loadLive) - await expect(coordinator.load(loadId)).resolves.toMatchObject({ meta: { id: loadId } }) - loadGet.mockRestore() - - const inspectLive = Session.create(inspectId, oneTurnLog(), meta(inspectId)) - const inspectGet = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(inspectLive) - await expect(coordinator.inspect(inspectId)).resolves.toMatchObject({ meta: { id: inspectId } }) - inspectGet.mockRestore() - - const validatedInspectLive = Session.create(validatedInspectId, oneTurnLog(), meta(validatedInspectId)) - const validatedInspectGet = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(validatedInspectLive) - await expect(coordinator.inspect(validatedInspectId)) - .resolves.toMatchObject({ meta: { id: validatedInspectId } }) - validatedInspectGet.mockRestore() - - const failedInspectLive = Session.create(failedInspectId, oneTurnLog(), meta(failedInspectId)) - backend.beforeLoadStored = () => Promise.reject(new Error('load failed')) - const failedInspectGet = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(failedInspectLive) - await expect(coordinator.inspect(failedInspectId)) - .resolves.toMatchObject({ meta: { id: failedInspectId } }) - failedInspectGet.mockRestore() - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('rejects a prepared commit when durable state already has a live owner', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('prepared-commit-live-owner') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const owner = Session.create(id, oneTurnLog(), meta(id)) - const states = (coordinator as unknown as { - states: Map - }).states - states.set(id, { - meta: owner.header, - cursor: SessionLogOffset(oneTurnLog().length), - materialized: true, - owner, - }) - - try { - await expect(coordinator.prepare(id)).rejects.toThrow(/live persistence owner/) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('rejects publication after a preparation state no longer matches', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('prepared-publication-mismatch') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const preparation = await coordinator.prepare(id) - const preparations = (coordinator as unknown as { - preparations: { - reservationFor: (session: Session) => { state: { cursor: SessionLogOffsetType } } | undefined - } - }).preparations - const reservation = preparations.reservationFor(preparation.session) - if (reservation === undefined) throw new Error('test preparation must stay reserved') - reservation.state.cursor = SessionLogOffset(reservation.state.cursor + 1) - const detach = ctx.sessions.enter(preparation.session) - - try { - expect(() => { ctx.sessions.announce(preparation.session) }).toThrow(/no longer matches/) - } finally { - detach() - preparation[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('observes a restored suffix initialization failure', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('prepared-suffix-init-failure') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const preparation = await coordinator.prepare(id) - const internals = coordinator as unknown as { - preparations: { reservationFor: (session: Session) => object | undefined } - attachPrepared: (session: Session, reservation: object) => { init: Promise } - } - const reservation = internals.preparations.reservationFor(preparation.session) - if (reservation === undefined) throw new Error('test preparation must stay reserved') - const failure = new Error('restored suffix append failed') - backend.beforeAppend = () => Promise.reject(failure) - preparation.session.append('turn/start', { turn: 2 }) - - try { - const live = internals.attachPrepared(preparation.session, reservation) - await expect(live.init).rejects.toBe(failure) - } finally { - preparation[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('writes new events after publishing a preparation with no unpublished suffix', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('prepared-live-write') - const stored = [ - ...oneTurnLog(), - { type: 'session/end-seed', seq: 6, time: 7, data: {} } as SessionEvent, - ] - backend.store.set(id, { meta: meta(id), events: stored }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const preparation = await coordinator.prepare(id) - const detach = ctx.sessions.enter(preparation.session) - - try { - ctx.sessions.announce(preparation.session) - preparation.session.append('turn/start', { turn: 2 }) - preparation.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - - await expect(ctx.sessions.flush(preparation.session)).resolves.toBe(true) - expect(backend.store.get(id)?.events.map(event => event.seq)) - .toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) - } finally { - detach() - preparation[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('reuses the exact Session from inspect through repeated unpublished prepare calls', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('inspect-prepare-reuse') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - let first: Awaited> | undefined - let second: Awaited> | undefined - - try { - const inspected = await coordinator.inspect(id) - first = await coordinator.prepare(id) - - expect(backend.loadAttempts).toBe(1) - expect(first.session.snapshotEvents()[0]).toBe(inspected.events[0]) - - first[Symbol.dispose]() - second = await coordinator.prepare(id) - expect(second.session).toBe(first.session) - expect(backend.loadAttempts).toBe(1) - } finally { - second?.[Symbol.dispose]() - first?.[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('reloads a cached inspection after the durable revision changes', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('inspect-revision-refresh') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const first = await coordinator.inspect(id) - backend.store.get(id)!.events.push( - { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ) - - const refreshed = await coordinator.inspect(id) - expect(refreshed.events).toHaveLength(8) - expect(refreshed.events[0]).not.toBe(first.events[0]) - expect(backend.loadAttempts).toBe(2) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('does not restore from a cached inspection after the durable revision changes', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('prepare-revision-refresh') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - let preparation: Awaited> | undefined - - try { - const inspected = await coordinator.inspect(id) - backend.store.get(id)!.events.push( - { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ) - - preparation = await coordinator.prepare(id) - expect(preparation.session.snapshotEvents()).toHaveLength(9) - expect(preparation.session.snapshotEvents()[0]).not.toBe(inspected.events[0]) - expect(backend.loadAttempts).toBe(2) - } finally { - preparation?.[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('retains a reserved preparation when inspection observes a newer external revision', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('reserved-inspect-revision-race') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - let preparation: Awaited> | undefined - let detach: (() => void) | undefined - - try { - const cached = await coordinator.inspect(id) - preparation = await coordinator.prepare(id) - backend.store.get(id)!.events.push( - { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ) - - await expect(coordinator.inspect(id)).resolves.toBe(cached) - const preparations = (coordinator as unknown as { - preparations: { reservationFor: (session: Session) => object | undefined } - }).preparations - expect(preparations.reservationFor(preparation.session)).toBeDefined() - - detach = ctx.sessions.enter(preparation.session) - expect(() => { ctx.sessions.announce(preparation!.session) }).not.toThrow() - expect(preparations.reservationFor(preparation.session)).toBeUndefined() - } finally { - detach?.() - preparation?.[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('queues a same-tick cold append behind preparation readiness', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('inspect-cold-append-race') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const inspection = coordinator.inspect(id) - const append = coordinator.append(id, [{ - type: 'turn/start', - seq: SessionSeq(oneTurnLog().length), - time: 7, - data: { turn: 2 }, - }]) - - await expect(inspection).resolves.toMatchObject({ - meta: { id }, - events: [...oneTurnLog(), { seq: 6 }, { seq: 7 }], - }) - await expect(append).resolves.toBeUndefined() - expect(backend.loadAttempts).toBe(2) - expect(backend.store.get(id)?.events).toHaveLength(oneTurnLog().length + 1) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('allows a same-tick cold append to start before inspection', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('cold-append-inspect-race') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const append = coordinator.append(id, [{ - type: 'turn/start', - seq: SessionSeq(oneTurnLog().length), - time: 7, - data: { turn: 2 }, - }]) - const inspection = coordinator.inspect(id) - - await expect(append).resolves.toBeUndefined() - await expect(inspection).resolves.toMatchObject({ - meta: { id }, - events: [...oneTurnLog(), { seq: 6 }, { seq: 7 }], - }) - expect(backend.loadAttempts).toBe(2) - expect(backend.store.get(id)?.events).toHaveLength(oneTurnLog().length + 1) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('retries cold append adoption when the prepared revision becomes stale', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('append-adoption-revision-refresh') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - const readStoredRevision = backend.readStoredRevision.bind(backend) - vi.spyOn(backend, 'readStoredRevision') - .mockResolvedValueOnce(SessionPersistenceRevision('stale-revision')) - .mockImplementation(readStoredRevision) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - await coordinator.append(id, [{ - type: 'turn/start', - seq: SessionSeq(oneTurnLog().length), - time: 7, - data: { turn: 2 }, - }]) - - expect(backend.loadAttempts).toBe(2) - expect(backend.appendAttempts).toBe(1) - expect(backend.store.get(id)?.events).toHaveLength(oneTurnLog().length + 1) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('inspects an open live turn without balancing it', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const session = ctx.sessions.create(SessionId('inspect-live-open-turn')) - session.append('turn/start', { turn: 1 }) - - const inspected = await coordinator.inspect(session.id) - expect(inspected.events).toBe(session.snapshotEvents()) - expect(inspected.events.map(event => event.type)).toEqual(['turn/start']) - await expect(coordinator.load(session.id)).rejects.toThrow(/live turn is open/) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('keeps synthetic recovery in memory during inspect and commits it only once on prepare', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('inspect-repair-commit') - backend.store.set(id, { - meta: meta(id), - events: [{ - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { turn: 1 }, - }], - }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - let first: Awaited> | undefined - let second: Awaited> | undefined - - try { - const inspected = await coordinator.inspect(id) - expect(inspected.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) - expect(backend.store.get(id)?.events.map(event => event.type)).toEqual(['turn/start']) - expect(backend.repairAttempts).toBe(0) - - first = await coordinator.prepare(id) - expect(backend.repairAttempts).toBe(1) - expect(backend.store.get(id)?.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) - first[Symbol.dispose]() - - second = await coordinator.prepare(id) - expect(second.session).toBe(first.session) - expect(backend.loadAttempts).toBe(2) - expect(backend.repairAttempts).toBe(1) - } finally { - second?.[Symbol.dispose]() - first?.[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('reloads the committed graph when another writer appends after repair', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('repair-external-append') - backend.store.set(id, { - meta: meta(id), - events: [{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }], - }) - const commitRepair = backend.commitRepair.bind(backend) - vi.spyOn(backend, 'commitRepair').mockImplementation(async (header, tornMarker, closers) => { - await commitRepair(header, tornMarker, closers) - const entry = backend.store.get(id) - if (entry === undefined) throw new Error('test repair must keep storage materialized') - const seq = entry.events.length - entry.events.push( - { type: 'turn/start', seq: SessionSeq(seq), time: 3, data: { turn: 2 } }, - { type: 'turn/end', seq: SessionSeq(seq + 1), time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, - ) - }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - let preparation: Awaited> | undefined - - try { - preparation = await coordinator.prepare(id) - - expect(preparation.session.snapshotEvents().map(event => event.type)).toEqual([ - 'turn/start', - 'turn/end', - 'turn/start', - 'turn/end', - 'session/end-seed', - ]) - expect(backend.loadAttempts).toBe(2) - expect(backend.repairAttempts).toBe(1) - } finally { - preparation?.[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('rejects preparation when storage disappears during the post-repair reload', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('repair-disappeared') - backend.store.set(id, { - meta: meta(id), - events: [{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }], - }) - const commitRepair = backend.commitRepair.bind(backend) - vi.spyOn(backend, 'commitRepair').mockImplementation(async (header, tornMarker, closers) => { - await commitRepair(header, tornMarker, closers) - backend.store.delete(id) - }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - await expect(coordinator.prepare(id)).rejects.toThrow(/not found/) - expect(backend.repairAttempts).toBe(1) - expect(backend.loadAttempts).toBe(2) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('waits for an existing reservation and reuses it after release', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('prepare-reservation-wait') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - let first: Awaited> | undefined - let second: Awaited> | undefined - - try { - first = await coordinator.prepare(id) - let secondResolved = false - const waiting = coordinator.prepare(id).then((preparation) => { - secondResolved = true - return preparation - }) - await Promise.resolve() - expect(secondResolved).toBe(false) - - first[Symbol.dispose]() - second = await waiting - expect(second.session).toBe(first.session) - expect(backend.loadAttempts).toBe(1) - } finally { - second?.[Symbol.dispose]() - first?.[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('evicts only ready preparations by LRU capacity', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const firstId = SessionId('preparation-lru-first') - const secondId = SessionId('preparation-lru-second') - backend.store.set(firstId, { meta: meta(firstId), events: oneTurnLog() }) - backend.store.set(secondId, { meta: meta(secondId), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend, { - preparedSessionCacheSize: 1, - writeBatchMaxDelayMs: DEFAULT_WRITE_BATCH_MAX_DELAY_MS, - }) - }, { inject: ['sessions'] })) - - try { - await coordinator.inspect(firstId) - await coordinator.inspect(secondId) - await coordinator.inspect(firstId) - expect(backend.loadAttempts).toBe(3) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('rejects append while an unpublished preparation owns the persisted cursor', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('reserved-append') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - let preparation: Awaited> | undefined - - try { - preparation = await coordinator.prepare(id) - await expect(coordinator.append(id, [{ - type: 'turn/start', - seq: SessionSeq(oneTurnLog().length), - time: 7, - data: { turn: 2 }, - }])).rejects.toThrow(/persisted preparation is reserved/) - } finally { - preparation?.[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) -}) - -describe('PersistenceCoordinator seek reads', () => { - it('loads the whole prefix only when a bounded legacy suffix needs earlier message identities', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('seek-read-from-legacy') - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - backend.seekHook = async (hookId, fromSeq) => { - const entry = backend.store.get(hookId) - if (entry === undefined) return undefined - return { - meta: structuredClone(entry.meta), - inheritedEventCount: SessionLogOffset(entry.inheritedEventCount ?? 0), - events: entry.events.filter(e => e.seq >= fromSeq), - } - } - - try { - const assertLegacySuffixUsesWholePrefix = async ( - events: SessionEvent[], - fromSeq: SessionLogOffsetType, - firstType: SessionEvent['type'], - ): Promise => { - backend.store.set(id, { meta: meta(id), events }) - const loadsBefore = backend.loadAttempts - const result = await coordinator.readFrom(id, fromSeq) - expect(result.events[0]?.type).toBe(firstType) - expect(backend.loadAttempts).toBe(loadsBefore + 1) - } - const legacyMessages = legacyMessageLog() - await assertLegacySuffixUsesWholePrefix(legacyMessages, SessionLogOffset(1), 'user/message') - await assertLegacySuffixUsesWholePrefix(legacyMessages, SessionLogOffset(3), 'assistant/message') - await assertLegacySuffixUsesWholePrefix(legacyMessages, SessionLogOffset(5), 'tool/result') - await assertLegacySuffixUsesWholePrefix(preReactLoopLog(), SessionLogOffset(3), 'user/message') - - backend.store.set(id, { meta: meta(id), events: legacyMessages }) - const directCurrent = await coordinator.readFrom(id, SessionLogOffset(0)) - backend.store.set(id, { meta: meta(id), events: [...directCurrent.events] }) - const loadsBeforeCurrent = backend.loadAttempts - await coordinator.readFrom(id, SessionLogOffset(1)) - await coordinator.readFrom(id, SessionLogOffset(3)) - await coordinator.readFrom(id, SessionLogOffset(5)) - expect(backend.loadAttempts).toBe(loadsBeforeCurrent) - - for (const [type, data] of [ - ['user/message', {}], - ['assistant/message', {}], - ['tool/result', {}], - ] as const) { - backend.store.set(id, { - meta: meta(id), - events: [{ type, seq: 0, time: 1, data } as unknown as SessionEvent], - }) - const loadsBefore = backend.loadAttempts - await expect(coordinator.readFrom(id, SessionLogOffset(0))).rejects.toThrow('lacks an identified message') - expect(backend.loadAttempts).toBe(loadsBefore) - } - backend.store.set(id, { - meta: meta(id), - events: [{ - type: 'external/null', seq: 0, time: 1, data: null, ignorable: true, - } as unknown as SessionEvent], - }) - const loadsBeforeNullData = backend.loadAttempts - expect((await coordinator.readFrom(id, SessionLogOffset(0))).events[0]?.data).toBeNull() - expect(backend.loadAttempts).toBe(loadsBeforeNullData) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) -}) - -describe('PersistenceCoordinator observation cancellation', () => { - it('borrows live Sessions before, during, and after cold source validation', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const afterBorrowId = SessionId('borrow-became-live-before-validation') - const afterValidationId = SessionId('borrow-became-live-after-validation') - for (const id of [afterBorrowId, afterValidationId]) { - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - } - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const immediate = ctx.sessions.create(SessionId('borrow-already-live')) - const immediateSource = await coordinator.borrowSession(immediate.id) - expect(immediateSource).toMatchObject({ source: 'live', inspection: { meta: { id: immediate.id } } }) - immediateSource[Symbol.dispose]() - - const afterBorrow = Session.create(afterBorrowId, oneTurnLog(), meta(afterBorrowId)) - const afterBorrowGet = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValue(afterBorrow) - const attachedSource = await coordinator.borrowSession(afterBorrowId) - expect(attachedSource).toMatchObject({ source: 'live', inspection: { meta: { id: afterBorrowId } } }) - attachedSource[Symbol.dispose]() - afterBorrowGet.mockRestore() - - const afterValidation = Session.create(afterValidationId, oneTurnLog(), meta(afterValidationId)) - const afterValidationGet = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(undefined) - .mockReturnValue(afterValidation) - const publishedSource = await coordinator.borrowSession(afterValidationId) - expect(publishedSource).toMatchObject({ - source: 'live', inspection: { meta: { id: afterValidationId } }, - }) - publishedSource[Symbol.dispose]() - afterValidationGet.mockRestore() - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('returns and releases a current prepared observation', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('borrow-current-prepared') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const source = await coordinator.borrowSession(id) - expect(source).toMatchObject({ source: 'prepared', inspection: { meta: { id } } }) - source[Symbol.dispose]() - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('reloads a stale prepared observation and retains one claimed concurrently', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const staleId = SessionId('borrow-stale-prepared') - const retainedId = SessionId('borrow-retained-prepared') - for (const id of [staleId, retainedId]) { - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - } - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const readRevision = backend.readStoredRevision.bind(backend) - const revision = vi.spyOn(backend, 'readStoredRevision') - .mockResolvedValueOnce(SessionPersistenceRevision('stale')) - .mockImplementation(readRevision) - const stale = await coordinator.borrowSession(staleId) - expect(stale.source).toBe('prepared') - expect(backend.loadAttempts).toBe(2) - stale[Symbol.dispose]() - revision.mockRestore() - - const preparations = (coordinator as unknown as { - preparations: { discardReady: (id: SessionId, source: unknown) => string } - }).preparations - vi.spyOn(backend, 'readStoredRevision').mockResolvedValue(SessionPersistenceRevision('changed')) - const discard = vi.spyOn(preparations, 'discardReady').mockReturnValue('retained') - const retained = await coordinator.borrowSession(retainedId) - expect(retained.source).toBe('prepared') - expect(discard).toHaveBeenCalledOnce() - retained[Symbol.dispose]() - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('falls back to a concurrently attached Session after revision validation fails', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('borrow-failed-validation-became-live') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const attached = Session.create(id, oneTurnLog(), meta(id)) - const get = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(undefined) - .mockReturnValue(attached) - vi.spyOn(backend, 'readStoredRevision').mockRejectedValue(new Error('revision failed')) - - try { - const source = await coordinator.borrowSession(id) - expect(source).toMatchObject({ source: 'live', inspection: { meta: { id } } }) - source[Symbol.dispose]() - } finally { - get.mockRestore() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('rethrows revision validation failure when no live Session won the race', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('borrow-failed-validation') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - const failure = new Error('revision failed') - vi.spyOn(backend, 'readStoredRevision').mockRejectedValue(failure) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - await expect(coordinator.borrowSession(id)).rejects.toBe(failure) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('promptly rejects a queued inspect without invoking it and keeps the same-id chain healthy', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('queued-inspect-cancellation') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - const loadGate = Promise.withResolvers() - backend.beforeLoadStored = async (attempt) => { - if (attempt === 1) await loadGate.promise - } - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - const prior = coordinator.inspect(id) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) - const controller = new AbortController() - const reason = new Error('queued inspect cancelled') - const queued = coordinator.inspect(id, controller.signal) - let observedReason: unknown - const observedAbort = queued.catch((error: unknown) => { - observedReason = error - }) - - controller.abort(reason) - - await vi.waitFor(() => { expect(observedReason).toBe(reason) }) - expect(backend.loadAttempts).toBe(1) - const subsequent = coordinator.inspect(id) - expect(backend.loadAttempts).toBe(1) - - loadGate.resolve(true) - await expect(prior).resolves.toMatchObject({ meta: { id } }) - await observedAbort - await expect(subsequent).resolves.toMatchObject({ meta: { id } }) - expect(backend.loadAttempts).toBe(1) - await vi.waitFor(() => { - expect((coordinator as unknown as CoordinatorInternals).chains.size).toBe(0) - }) - } finally { - loadGate.resolve(true) - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('keeps a shared cold read alive when its creating inspect is cancelled', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('creating-inspect-cancellation') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - const loadGate = Promise.withResolvers() - backend.beforeLoadStored = () => loadGate.promise.then(() => undefined) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - let prepared: Awaited> | undefined - - try { - const controller = new AbortController() - const reason = new Error('creating inspect cancelled') - const inspection = coordinator.inspect(id, controller.signal) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) - const reservation = coordinator.prepare(id) - - controller.abort(reason) - await expect(inspection).rejects.toBe(reason) - loadGate.resolve(true) - prepared = await reservation - expect(prepared.session.id).toBe(id) - expect(backend.loadAttempts).toBe(1) - } finally { - loadGate.resolve(true) - prepared?.[Symbol.dispose]() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('preserves inspect cancellation when the session concurrently becomes live', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('cancelled-inspect-became-live') - backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - const controller = new AbortController() - const reason = new Error('inspect cancelled while publishing') - backend.beforeLoadStored = async () => { - controller.abort(reason) - throw new Error('load stopped after cancellation') - } - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const live = Session.create(id, oneTurnLog(), meta(id)) - const get = vi.spyOn(ctx.sessions, 'get') - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(live) - - try { - await expect(coordinator.inspect(id, controller.signal)).rejects.toBe(reason) - } finally { - get.mockRestore() - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('readFrom via the seek hook: serves the suffix, maps undefined to not-found, and relays hook failures by abort state', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('seek-read-from') - const log = oneTurnLog() - backend.store.set(id, { meta: meta(id), events: log }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - - try { - // Happy path through the hook: only the suffix comes back, detached. - backend.seekHook = async (hookId, fromSeq) => { - const entry = backend.store.get(hookId) - if (entry === undefined) return undefined - return { - meta: structuredClone(entry.meta), - inheritedEventCount: SessionLogOffset(entry.inheritedEventCount ?? 0), - events: entry.events.filter(e => e.seq >= fromSeq), - } - } - const suffix = await coordinator.readFrom(id, SessionLogOffset(3)) - expect(suffix.events).toEqual(log.slice(3)) - // The hook's `undefined` is the backend contract's not-found result. - await expect(coordinator.readFrom(SessionId('missing-seek'), SessionLogOffset(0))) - .rejects.toThrow('not found') - - // A hook failure with no cancellation in play propagates as-is. - const hookFailure = new Error('seek backend exploded') - backend.seekHook = () => Promise.reject(hookFailure) - await expect(coordinator.readFrom(id, SessionLogOffset(0))).rejects.toBe(hookFailure) - - // A hook failure after cancellation surfaces the caller's abort reason, - // not the backend's internal teardown error. The abort fires only once - // the hook is provably entered, so the failure exercises the catch (not - // the pre-invocation throwIfAborted). - const controller = new AbortController() - const reason = new Error('read-from cancelled mid-hook') - let hookEntered = false - backend.seekHook = async (_hookId, _fromSeq, signal) => { - hookEntered = true - await new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - throw new Error('backend teardown after abort') - } - const pending = coordinator.readFrom(id, SessionLogOffset(0), controller.signal) - const observed = pending.catch((error: unknown) => error) - await vi.waitFor(() => { expect(hookEntered).toBe(true) }) - controller.abort(reason) - expect(await observed).toBe(reason) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('rejects a cancelled inspect while an in-flight retirement drain is still pending', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator - const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const internals = coordinator as unknown as CoordinatorInternals - const appendGate = Promise.withResolvers() - backend.beforeAppend = async () => { await appendGate.promise } - - try { - const id = SessionId('retiring-inspect') - let session!: Session - const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(id) - }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // Dispose the session so retirement starts; its append is gated, so the - // retirement promise stays pending in the coordinator. - await sessionFiber.dispose() - await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(true) }) - const baselineLoads = backend.loadAttempts - - const controller = new AbortController() - const reason = new Error('inspect cancelled during retirement') - const pending = coordinator.inspect(id, controller.signal) - let observedReason: unknown - const observed = pending.catch((error: unknown) => { observedReason = error }) - - // Cancel before the gated retirement can settle: the inspect must reject - // promptly instead of waiting for the drain, and must never reach the - // backend read. - controller.abort(reason) - await vi.waitFor(() => { expect(observedReason).toBe(reason) }) - expect(backend.loadAttempts).toBe(baselineLoads) - - appendGate.resolve(true) - await observed - } finally { - appendGate.resolve(true) - await backendFiber.dispose() - await ctx.fiber.dispose() - } - }) -}) - -describe('PersistenceCoordinator retirement', () => { - it('a retiring unmaterialized owner without buffered events releases its id', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const loadGate = Promise.withResolvers() - backend.beforeLoadStored = async (attempt) => { - if (attempt === 1) await loadGate.promise - } - - try { - const id = SessionId('retiring-lazy-owner') - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessions.create(id) - }, { inject: ['sessions'] })) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) - await firstFiber.dispose() - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create(id) - }, { inject: ['sessions'] })) - const reuseFlush = ctx.sessions.flush(reuse) - - loadGate.resolve(true) - await expect(reuseFlush).resolves.toBe(true) - } finally { - loadGate.resolve(true) - await backendFiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('a superseded retirement leaves the successor lifecycle\'s pending drain in place', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator - const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const internals = coordinator as unknown as CoordinatorInternals - const readGate = Promise.withResolvers() - - try { - const id = SessionId('superseded-retirement') - // First lifecycle: unmaterialized (zero events), so a same-id successor - // may legally reclaim the abandoned id later. - let first!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create(id) - }, { inject: ['sessions'] })) - await ctx.sessions.flush(first) - - // Occupy the per-id serialize chain with a gated physical read: - // inspect() correctly borrows the still-live Session without entering - // the backend chain, while both retirements must queue behind readFrom(). - const readEntered = Promise.withResolvers() - backend.seekHook = async () => { - readEntered.resolve(undefined) - await readGate.promise - return undefined - } - const parked = coordinator.readFrom(id, SessionLogOffset(0)).catch((error: unknown) => error) - await readEntered.promise - - // First retirement queues behind the gate and stays pending. - await firstFiber.dispose() - await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(true) }) - const firstRetirement = internals.retirements.get(id) - - // Successor lifecycle retires while the first drain is still in flight: - // retire() replaces the map entry synchronously. - const secondFiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessions.create(id) - }, { inject: ['sessions'] })) - await secondFiber.dispose() - await vi.waitFor(() => { - expect(internals.retirements.get(id)).not.toBe(firstRetirement) - }) - - // Release the chain: the first drain settles and its forget() must not - // delete the successor's entry (exact-entry guard); the successor's own - // forget() then clears the map. - readGate.resolve(true) - expect(await parked).toBeInstanceOf(Error) // the parked inspect (not found) is observed - await firstRetirement - await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(false) }) - } finally { - readGate.resolve(true) - await backendFiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('a replacement queued before retirement cleanup still collides with the live owner', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const appendGate = Promise.withResolvers() - - try { - const id = SessionId('retiring-live-owner') - let first!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create(id) - }, { inject: ['sessions'] })) - await ctx.sessions.flush(first) - backend.beforeAppend = async () => { await appendGate.promise } - first.append('turn/start', { turn: 1 }) - first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) - await firstFiber.dispose() - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create(id) - }, { inject: ['sessions'] })) - const reuseFlush = ctx.sessions.flush(reuse) - - appendGate.resolve(true) - await expect(reuseFlush).rejects.toThrow(/bound to a different live session/) - expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1]) - } finally { - appendGate.resolve(true) - await backendFiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('a racing cold load survives retirement cleanup and rejects same-id reuse', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator - const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const appendGate = Promise.withResolvers() - const loadGate = Promise.withResolvers() - - try { - const id = SessionId('retiring-buffered-owner') - let first!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create(id) - }, { inject: ['sessions'] })) - await ctx.sessions.flush(first) - backend.beforeAppend = async () => { await appendGate.promise } - first.append('turn/start', { turn: 1 }) - first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) - await firstFiber.dispose() - const baselineLoads = backend.loadAttempts - backend.beforeLoadStored = async () => { await loadGate.promise } - const coldLoad = coordinator.load(id) - - appendGate.resolve(true) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) }) - - await expect(ctx.plugin(Object.assign((inner: Context) => { - inner.sessions.create(id) - }, { inject: ['sessions'] }))).rejects.toThrow(/persisted state already owns this identity/) - - loadGate.resolve(true) - await expect(coldLoad).resolves.toMatchObject({ - events: [{ seq: 0 }, { seq: 1 }], - }) - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create(id) - }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/id collision/) - await vi.waitFor(() => { - expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1]) - }) - } finally { - appendGate.resolve(true) - loadGate.resolve(true) - await backendFiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('a settled chain tail cannot delete a newer operation for the same id', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const internals = coordinator as unknown as CoordinatorInternals - const first = Promise.withResolvers() - const second = Promise.withResolvers() - backend.beforeAppend = async (attempt) => { - if (attempt === 1) await first.promise - if (attempt === 2) await second.promise - } - - try { - const id = SessionId('chain-tail') - await coordinator.create(meta(id)) - const firstAppend = coordinator.append(id, [{ - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { turn: 1 }, - }]) - const secondAppend = coordinator.append(id, [{ - type: 'turn/end', - seq: SessionSeq(1), - time: 2, - data: { turn: 1, reason: { kind: 'completed' } }, - }]) - - await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) - first.resolve(true) - await vi.waitFor(() => { expect(backend.appendAttempts).toBe(2) }) - expect(internals.chains.size).toBe(1) - second.resolve(true) - await Promise.all([firstAppend, secondAppend]) - await vi.waitFor(() => { expect(internals.chains.size).toBe(0) }) - expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1]) - } finally { - first.resolve(true) - second.resolve(true) - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('backend teardown retries a failed session retirement before close', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator - const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const internals = coordinator as unknown as CoordinatorInternals - let retryEnabled = false - backend.beforeAppend = async () => { - if (!retryEnabled) { - backend.lifecycle.push('append-failed') - throw new Error('transient append failure') - } - backend.lifecycle.push('append-committed') - } - - try { - let session!: Session - const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('retry-retirement')) - }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await sessionFiber.dispose() - - await vi.waitFor(() => { - expect(backend.appendAttempts).toBeGreaterThanOrEqual(1) - expect([...internals.live.values()][0]?.writes.pending).toEqual(expect.arrayContaining([ - expect.objectContaining({ seq: 0 }), - expect.objectContaining({ seq: 1 }), - ])) - }) - - retryEnabled = true - await backendFiber.dispose() - expect(backend.store.get(SessionId('retry-retirement'))?.events.map(event => event.seq)).toEqual([0, 1]) - expect(backend.lifecycle.at(-2)).toBe('append-committed') - expect(backend.lifecycle.at(-1)).toBe('close') - } finally { - await backendFiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('backend teardown waits for an in-flight session retirement before close', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator - const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const internals = coordinator as unknown as CoordinatorInternals - const appendGate = Promise.withResolvers() - backend.beforeAppend = async () => { - backend.lifecycle.push('append-started') - await appendGate.promise - backend.lifecycle.push('append-committed') - } - - try { - let session!: Session - const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('inflight-retirement')) - }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await sessionFiber.dispose() - await vi.waitFor(() => { - expect(backend.appendAttempts).toBe(1) - expect(internals.live.size).toBe(1) - expect([...internals.live.values()][0]?.writes.active).toBeInstanceOf(Promise) - }) - - let disposed = false - const teardown = backendFiber.dispose().then(() => { disposed = true }) - await Promise.resolve() - expect(disposed).toBe(false) - expect(backend.lifecycle).toEqual(['append-started']) - - appendGate.resolve(true) - await teardown - expect(backend.store.get(SessionId('inflight-retirement'))?.events.map(event => event.seq)).toEqual([0, 1]) - expect(backend.lifecycle).toEqual(['append-started', 'append-committed', 'close']) - } finally { - appendGate.resolve(true) - await backendFiber.dispose() - await ctx.fiber.dispose() - } - }) - - it('backend teardown waits for a detached public append before close', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - const appendGate = Promise.withResolvers() - backend.beforeAppend = async () => { - backend.lifecycle.push('append-started') - await appendGate.promise - backend.lifecycle.push('append-committed') - } - - try { - const id = SessionId('inflight-public-append') - await coordinator.create(meta(id)) - const append = coordinator.append(id, [{ - type: 'turn/start', - seq: SessionSeq(0), - time: 1, - data: { turn: 1 }, - }]) - await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) - - let disposed = false - const teardown = fiber.dispose().then(() => { disposed = true }) - await Promise.resolve() - expect(disposed).toBe(false) - - appendGate.resolve(true) - await Promise.all([append, teardown]) - expect(backend.lifecycle).toEqual(['append-started', 'append-committed', 'close']) - } finally { - appendGate.resolve(true) - await fiber.dispose() - await ctx.fiber.dispose() - } - }) -}) - -describe('SessionPersistence service registration', () => { - it('materializes an explicitly durable live session without adding events', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(MemoryPersistence) - const session = ctx.sessions.create(SessionId('durable-empty'), { meta: { cwd: '/workspace' } }) - - await ctx.sessionPersistence.ensureMaterialized(session) - await ctx.sessionPersistence.ensureMaterialized(session) - - await expect(ctx.sessionPersistence.list()).resolves.toEqual([session.header]) - await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ - meta: session.header, - inheritedEventCount: SessionLogOffset(0), - events: [], - }) - await ctx.fiber.dispose() - }) - - it('fails loud when a direct backend does not support empty materialization', async () => { - const session = Session.create(SessionId('unsupported-empty')) - await expect(SessionPersistence.prototype.ensureMaterialized.call({} as SessionPersistence, session)) - .rejects.toThrow(/cannot materialize an empty session/) - }) - - it('fails loud when a coordinator backend omits empty materialization', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - let coordinator!: PersistenceCoordinator - await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, new ControlledBackend()) - }, { inject: ['sessions'] })) - const session = ctx.sessions.create(SessionId('unsupported-coordinator')) - - await expect(coordinator.ensureMaterialized(session)).rejects.toThrow(/cannot materialize an empty session/) - await ctx.fiber.dispose() - }) - - it('accepts current aborted and error turn endings without legacy conversion', async () => { - const store: MemoryStore = new Map() - const ctx = new Context() - await ctx.plugin(SessionStore) - const endings: SessionEvent[] = [ - { - type: 'turn/end', seq: SessionSeq(5), time: 6, - data: { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }, - }, - { - type: 'turn/end', seq: SessionSeq(5), time: 6, - data: { turn: 1, reason: { kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } } }, - }, - ] - for (const [index, ending] of endings.entries()) { - const m = meta(`current-ending-${index}`) - store.set(m.id, { meta: m, events: [...oneTurnLog().slice(0, -1), ending] }) - } - await ctx.plugin(MemoryPersistence, { store }) - await Promise.all([...store.keys()].map(id => ctx.sessionPersistence.load(SessionId(id)))) - await ctx.fiber.dispose() - }) - - it('rejects preparing an id that already has a live Session', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(MemoryPersistence) - const session = ctx.sessions.create(SessionId('live-prepare-conflict')) - - await expect(ctx.sessionPersistence.prepare(session.id)).rejects.toThrow(/while it is live/) - await ctx.fiber.dispose() - }) - - it('provides a cancellation-aware default preparation for simple backends', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence) - const m = meta('default-preparation') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const defaultPrepare = SessionPersistence.prototype.prepare.bind(ctx.sessionPersistence) - - const preparation = await defaultPrepare(m.id) - expect(preparation.session.header).toEqual(m) - preparation[Symbol.dispose]() - - const preAborted = new AbortController() - const preAbortReason = new Error('pre-aborted preparation') - preAborted.abort(preAbortReason) - await expect(defaultPrepare(m.id, preAborted.signal)) - .rejects.toBe(preAbortReason) - - const postAborted = new AbortController() - const postAbortReason = new Error('post-load preparation abort') - const originalLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) - ctx.sessionPersistence.load = async (id) => { - const loaded = await originalLoad(id) - postAborted.abort(postAbortReason) - return loaded - } - await expect(defaultPrepare(m.id, postAborted.signal)) - .rejects.toBe(postAbortReason) - - await fiber.dispose() - }) - - it('requires SessionStore for the default preparation', async () => { - const id = SessionId('default-preparation-without-store') - const persistence = { - ctx: new Context(), - load: () => Promise.resolve({ meta: meta(id), events: oneTurnLog() }), - } as unknown as SessionPersistence - - await expect(SessionPersistence.prototype.prepare.call(persistence, id)) - .rejects.toThrow(/SessionStore is not configured/) - }) - - it('registers as ctx.sessionPersistence and is removed on fiber dispose (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence) - expect(ctx.sessionPersistence).toBeInstanceOf(SessionPersistence) - - await fiber.dispose() - expect(ctx.sessionPersistence).toBeUndefined() - }) - - it('round-trips through the registered service instance', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence) - const m = meta('reg') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.events).toHaveLength(6) - await fiber.dispose() - }) - - it('rejects non-JSON session metadata before registering lazy state', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence) - const invalid = { ...meta('invalid-meta'), createdAt: 1n as unknown as number } - - await expect(ctx.sessionPersistence.create(invalid)) - .rejects.toThrow('session metadata must be losslessly JSON-serializable') - await fiber.dispose() - }) - - it('rejects a legacy header delta from a pre-change live producer', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence) - const session = ctx.sessions.create(SessionId('legacy-live'), { meta: { cwd: '/legacy' } }) - // Model the runtime shape available to JavaScript or a hot-loaded plugin - // compiled against the obsolete event vocabulary. - const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent - expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } })) - .toThrow(/unsupported legacy request\/header-delta format/) - expect(session.snapshotEvents()).toHaveLength(0) - await fiber.dispose() - }) - - it('rejects a legacy fallback header buffered by a pre-change live producer', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence) - const session = ctx.sessions.create(SessionId('legacy-fallback-live'), { meta: { cwd: '/legacy' } }) - const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent - - expect(() => appendLegacy('request/header', legacyFallbackHeader().data)) - .toThrow('unsupported legacy request/header reason "fallback"') - expect(session.snapshotEvents()).toHaveLength(0) - await fiber.dispose() - }) - - it('rejects a legacy stored prefix during live HMR adoption', async () => { - const id = SessionId('legacy-hmr') - const m = meta(id, '/legacy') - const legacy = legacyHeaderDelta() - const store: MemoryStore = new Map([[id, { meta: m, events: [legacy] }]]) - const ctx = new Context() - await ctx.plugin(SessionStore) - // A current live session cannot carry the obsolete event in its seed, but - // HMR still has to identify the persisted prefix as unsupported rather than - // treating it as an ordinary live-prefix collision. - const session = ctx.sessions.create(id, { meta: { cwd: '/legacy' } }) - const fiber = await ctx.plugin(MemoryPersistence, { store }) - - await expect(ctx.sessions.flush(session)) - .rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/) - await Promise.allSettled([fiber.dispose()]) - }) - - it('rejects a stored legacy fallback header during load', async () => { - const id = SessionId('legacy-fallback-load') - const m = meta(id, '/legacy') - const store: MemoryStore = new Map([[id, { meta: m, events: [legacyFallbackHeader()] }]]) - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence, { store }) - - await expect(ctx.sessionPersistence.load(id)) - .rejects.toThrow('unsupported legacy request/header reason "fallback" at seq 0') - await fiber.dispose() - }) - - it('rejects a stored legacy named-mode event during load', async () => { - const id = SessionId('legacy-mode-load') - const m = meta(id, '/legacy') - const store: MemoryStore = new Map([[id, { meta: m, events: [legacyModeSet()] }]]) - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence, { store }) - - await expect(ctx.sessionPersistence.load(id)) - .rejects.toThrow('unsupported legacy mode/set event at seq 0') - await fiber.dispose() - }) - - it('retires all coordinator bookkeeping for disposed sessions', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(MemoryPersistence) - const { coordinator } = ctx.sessionPersistence as unknown as { coordinator: CoordinatorInternals } - - try { - for (let index = 0; index < 3; index += 1) { - let session!: Session - const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId(`disposed-${index}`)) - }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - await sessionFiber.dispose() - } - - await vi.waitFor(() => { - expect(ctx.sessions.list()).toHaveLength(0) - expect({ - states: coordinator.states.size, - live: coordinator.live.size, - chains: coordinator.chains.size, - }).toEqual({ states: 0, live: 0, chains: 0 }) - }) - } finally { - await fiber.dispose() - } - }) -}) diff --git a/packages/session/session-persistence/tests/preparations.spec.ts b/packages/session/session-persistence/tests/preparations.spec.ts deleted file mode 100644 index 7635ffd223..0000000000 --- a/packages/session/session-persistence/tests/preparations.spec.ts +++ /dev/null @@ -1,432 +0,0 @@ -/** 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(2) - const id = SessionId('shared-inspection') - const gate = Promise.withResolvers() - 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(1) - const id = SessionId('cancelled-first-observer') - const gate = Promise.withResolvers() - 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(1) - const firstId = SessionId('cancelled-ready-first') - const secondId = SessionId('cancelled-ready-second') - const firstGate = Promise.withResolvers() - const secondGate = Promise.withResolvers() - 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(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() - 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() - 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(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(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(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 borrowing', () => { - it('returns a detached lease when loading invalidates its own entry', async () => { - const preparations = new SessionPreparations(1) - const id = SessionId('borrow-invalidated-load') - const source = prepared(id) - - const lease = await preparations.borrow(id, () => { - preparations.invalidate(id) - return Promise.resolve(source) - }) - - expect(lease.source).toBe(source) - expect(preparations.has(id)).toBe(false) - expect(() => { lease[Symbol.dispose]() }).not.toThrow() - }) - - it('releases pins after cancellation while loading and after readiness', async () => { - const preparations = new SessionPreparations(1) - const loadingId = SessionId('borrow-cancelled-loading') - const loading = Promise.withResolvers() - const loadingAbort = new AbortController() - const pending = preparations.borrow(loadingId, () => loading.promise, loadingAbort.signal) - loadingAbort.abort(new Error('cancelled while loading')) - await expect(pending).rejects.toThrow('cancelled while loading') - loading.resolve(prepared(loadingId)) - await loading.promise - await Promise.resolve() - - const readyId = SessionId('borrow-cancelled-ready') - const ready = prepared(readyId) - await preparations.inspect(readyId, () => Promise.resolve(ready)) - const readyAbort = new AbortController() - readyAbort.abort(new Error('cancelled while ready')) - await expect(preparations.borrow(readyId, () => Promise.resolve(ready), readyAbort.signal)) - .rejects.toThrow('cancelled while ready') - - await preparations.inspect(SessionId('borrow-eviction'), () => Promise.resolve(prepared('borrow-eviction'))) - expect(preparations.has(loadingId)).toBe(false) - }) - - it('makes borrowed lease disposal idempotent across ready, invalidated, and reserved entries', async () => { - const preparations = new SessionPreparations(3) - - const ready = prepared('borrow-ready-release') - const readyLease = await preparations.borrow(ready.session.id, () => Promise.resolve(ready)) - readyLease[Symbol.dispose]() - readyLease[Symbol.dispose]() - - const invalidated = prepared('borrow-invalidated-release') - const invalidatedLease = await preparations.borrow( - invalidated.session.id, - () => Promise.resolve(invalidated), - ) - preparations.invalidate(invalidated.session.id) - invalidatedLease[Symbol.dispose]() - - const reserved = prepared('borrow-reserved-release') - const reservation = await preparations.reserve( - reserved.session.id, - () => Promise.resolve(reserved), - committed, - ) - expect(reservation).toBeDefined() - const reservedLease = await preparations.borrow( - reserved.session.id, - () => Promise.resolve(prepared('unused')), - ) - reservedLease[Symbol.dispose]() - preparations.release(reservation!, false) - }) -}) - -describe('SessionPreparations reservation', () => { - it('waits for an existing reservation, republishes the exact Session, and attaches once', async () => { - const preparations = new SessionPreparations(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(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(1) - const id = SessionId('failed-commit') - const commitStarted = Promise.withResolvers() - 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(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(1) - const id = SessionId('invalidated-commit-cancel') - const source = prepared(id) - const commitStarted = Promise.withResolvers() - const commitGate = Promise.withResolvers() - 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(1) - const id = SessionId('invalidated-successful-commit') - const source = prepared(id) - const commitStarted = Promise.withResolvers() - const commitGate = Promise.withResolvers() - 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(1) - const id = SessionId('invalidated-reservation') - const gate = Promise.withResolvers() - 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(1) - const id = SessionId('take-ready') - const gate = Promise.withResolvers() - 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(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() - 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() - 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(() => {}), controller.signal)) - .rejects.toBe('pre-aborted') - }) - - it('lets an operation that already started own cancellation settlement', async () => { - const operation = Promise.withResolvers() - 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') - }) -}) diff --git a/packages/session/session-persistence/tests/storage-contract.spec.ts b/packages/session/session-persistence/tests/storage-contract.spec.ts new file mode 100644 index 0000000000..8a7d224b6c --- /dev/null +++ b/packages/session/session-persistence/tests/storage-contract.spec.ts @@ -0,0 +1,321 @@ +/** + * Unit tests for the backend-shared storage validation helpers and the + * stable error vocabulary. + */ + +import { describe, expect, it } from 'vitest' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + SessionAlreadyExistsError, + SessionAlreadyOwnedError, + SessionFormatUnsupportedError, + SessionHandleClosedError, + SessionOwnershipLostError, + SessionPersistenceCorruptionError, + SessionPersistenceNotFoundError, + SessionPersistenceRevision, + SessionReadOnlyError, + assertContiguous, + assertStoredId, + assertVersion, + materializeAppendBatch, + sessionFormatVersionRefusal, + validateStoredEvents, +} from '../src/index.ts' +import type { SessionLocation } from '../src/index.ts' +import { meta } from './contract.ts' + +const LOCATION: SessionLocation = { kind: 'jsonl', path: '/store/session.jsonl' } + +/** A stored user/message event with a well-formed identified message. */ +function userMessage(seq: number): SessionEvent { + return { + type: 'user/message', + seq, + time: seq + 1, + data: { + id: 'stored-user', + role: 'user', + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + } as unknown as SessionEvent +} + +describe('assertStoredId', () => { + it('accepts a bound header and names both ids on a mismatch', () => { + const m = meta('bound') + expect(() => { assertStoredId(m.id, m) }).not.toThrow() + expect(() => { assertStoredId(SessionId('requested'), m) }) + .toThrow('stored session identity mismatch: requested "requested", header contains "bound"') + }) +}) + +describe('assertVersion', () => { + it('accepts the current format version', () => { + expect(() => { assertVersion(meta('current')) }).not.toThrow() + }) + + it('refuses a newer version with the upgrade direction and the artifact location', () => { + const newer = { ...meta('newer'), version: SESSION_FORMAT_VERSION + 42 } + let refusal: unknown + try { + assertVersion(newer, LOCATION) + } catch (error) { + refusal = error + } + expect(refusal).toBeInstanceOf(SessionFormatUnsupportedError) + expect((refusal as Error).message).toContain('written by a newer harness — upgrade the harness to open it') + expect((refusal as Error).message).toContain(`(raw log: ${LOCATION.path})`) + expect((refusal as SessionFormatUnsupportedError).location).toBe(LOCATION) + }) + + it('refuses an older version without a location suffix when the backend has no artifact', () => { + const older = { ...meta('older'), version: SESSION_FORMAT_VERSION - 1 } + let refusal: unknown + try { + assertVersion(older) + } catch (error) { + refusal = error + } + expect(refusal).toBeInstanceOf(SessionFormatUnsupportedError) + expect((refusal as Error).message).toBe(sessionFormatVersionRefusal('older', SESSION_FORMAT_VERSION - 1)) + expect((refusal as Error).message).toContain('this build ships no upgrade path for it') + expect((refusal as Error).message).not.toContain('raw log') + expect((refusal as SessionFormatUnsupportedError).location).toBeUndefined() + }) +}) + +describe('sessionFormatVersionRefusal', () => { + it('states the direction for newer and older stored versions', () => { + expect(sessionFormatVersionRefusal('s', SESSION_FORMAT_VERSION + 1)) + .toBe(`session "s" uses log format v${SESSION_FORMAT_VERSION + 1}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`) + expect(sessionFormatVersionRefusal('s', SESSION_FORMAT_VERSION - 1)) + .toBe(`session "s" uses log format v${SESSION_FORMAT_VERSION - 1}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`) + }) +}) + +describe('validateStoredEvents', () => { + it('adopts and freezes the events in place, returning the same array', () => { + const m = meta('adopted') + const events = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + userMessage(1), + ] as SessionEvent[] + const validated = validateStoredEvents(m, events) + expect(validated).toBe(events) + expect(Object.isFrozen(validated[1]!.data)).toBe(true) + }) + + it('adopts a merge-extended turn/end reason outside the closed built-in set', () => { + const m = meta('extended-reason') + const events = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'plugin/custom-outcome' } } }, + ] as SessionEvent[] + expect(validateStoredEvents(m, events)).toBe(events) + }) + + it('refuses an unknown event type before adopting anything', () => { + const m = meta('unknown-type') + const events = [ + { type: 'mystery/event', seq: 0, time: 1, data: {} }, + ] as unknown as SessionEvent[] + expect(() => validateStoredEvents(m, events)).toThrow(SessionFormatUnsupportedError) + expect(() => validateStoredEvents(m, events)).toThrow( + 'session "unknown-type" contains event type "mystery/event" (seq 0) unknown to this harness', + ) + // With an artifact, the refusal points at the raw log. + let refusal: unknown + try { + validateStoredEvents(m, events, LOCATION) + } catch (error) { + refusal = error + } + expect((refusal as Error).message).toContain(`(raw log: ${LOCATION.path})`) + expect((refusal as SessionFormatUnsupportedError).location).toBe(LOCATION) + }) + + it('retains an unknown event type its writer marked ignorable', () => { + const m = meta('ignorable-unknown') + const events = [ + { type: 'foreign/telemetry', seq: 0, time: 1, data: {}, ignorable: true }, + ] as unknown as SessionEvent[] + expect(validateStoredEvents(m, events)).toBe(events) + expect(events[0]).toMatchObject({ type: 'foreign/telemetry', ignorable: true }) + }) + + it('refuses the retired request/header "fallback" reason while accepting current headers', () => { + const m = meta('retired-reason') + const retired = [ + { + type: 'request/header', + seq: 0, + time: 1, + data: { header: { config: { provider: 'mock', model: 'legacy' } }, reason: 'fallback' }, + }, + ] as unknown as SessionEvent[] + expect(() => validateStoredEvents(m, retired)).toThrow(SessionFormatUnsupportedError) + expect(() => validateStoredEvents(m, retired)).toThrow( + 'session "retired-reason" contains a request/header event (seq 0) with the unsupported legacy reason "fallback"', + ) + + const current = [ + { + type: 'request/header', + seq: 0, + time: 1, + data: { header: { config: { provider: 'mock', model: 'current' } }, reason: 'initial' }, + }, + ] as unknown as SessionEvent[] + expect(validateStoredEvents(m, current)).toBe(current) + }) + + it('wraps a record validation failure as corruption with its cause', () => { + const m = meta('damaged') + const events = [ + { + type: 'user/message', + seq: 0, + time: 1, + // No identified message: adoption refuses the record. + data: { role: 'user', content: [], source: { kind: 'user' } }, + }, + ] as unknown as SessionEvent[] + let failure: unknown + try { + validateStoredEvents(m, events) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(SessionPersistenceCorruptionError) + expect((failure as Error).message).toContain('stored session "damaged" failed validation') + expect((failure as Error).message).toContain('lacks an identified message') + expect((failure as Error).cause).toBeInstanceOf(Error) + }) + + it('passes an adoption-side format refusal through unwrapped', () => { + // The adoption step may itself refuse a record as unsupported rather than + // damaged; such a refusal must reach the caller as-is, never rebranded as + // corruption. + const m = meta('adoption-refusal') + const passthrough = new SessionFormatUnsupportedError('refused during adoption') + const trap = { + type: 'user/message', + seq: 0, + time: 1, + get data(): never { + throw passthrough + }, + } as unknown as SessionEvent + expect(() => validateStoredEvents(m, [trap])).toThrow(passthrough) + }) +}) + +describe('materializeAppendBatch', () => { + it('returns a detached lossless-JSON snapshot of the batch', () => { + const original = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ] as SessionEvent[] + const batch = materializeAppendBatch(original) + expect(batch).not.toBe(original) + expect(batch).toEqual(original) + // The snapshot is detached: mutating the caller's batch afterwards cannot + // change what a backend persists. + ;(original[0]!.data as { turn: number }).turn = 99 + expect((batch[0]!.data as { turn: number }).turn).toBe(1) + }) + + it('rejects every non-JSON value, not only BigInt', () => { + // Every value `snapshotJsonValue` refuses must be refused here — a backend + // passing this helper cannot accept values that corrupt the durable + // round-trip. + const cyclic: Record = { 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 bad of badValues) { + const events = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, extra: bad } }, + ] as unknown as SessionEvent[] + expect(() => materializeAppendBatch(events)).toThrow(TypeError) + expect(() => materializeAppendBatch(events)).toThrow(/losslessly JSON-serializable/) + } + }) +}) + +describe('assertContiguous', () => { + it('accepts a batch continuing exactly at the cursor', () => { + const events = [ + { type: 'turn/start', seq: 6, time: 1, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 2, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + expect(() => { assertContiguous(SessionId('ok'), events, 6) }).not.toThrow() + expect(() => { assertContiguous(SessionId('ok'), [], 6) }).not.toThrow() + }) + + it('names the expected seq for a stale batch and for a mid-batch gap', () => { + const stale = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ] as SessionEvent[] + expect(() => { assertContiguous(SessionId('stale'), stale, 6) }) + .toThrow('append seq mismatch for "stale": expected 6 at index 0, got 0') + + const gapped = [ + { type: 'turn/start', seq: 6, time: 1, data: { turn: 2 } }, + { type: 'turn/end', seq: 8, time: 2, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + expect(() => { assertContiguous(SessionId('gapped'), gapped, 6) }) + .toThrow('append seq mismatch for "gapped": expected 7 at index 1, got 8') + }) +}) + +describe('error vocabulary', () => { + it('carries stable names, messages, and the session id', () => { + const id = SessionId('errored') + const notFound = new SessionPersistenceNotFoundError(id) + expect(notFound.name).toBe('SessionPersistenceNotFoundError') + expect(notFound.sessionId).toBe(id) + expect(notFound.message).toBe('session "errored" not found') + + const exists = new SessionAlreadyExistsError(id) + expect(exists.name).toBe('SessionAlreadyExistsError') + expect(exists.message).toBe('session "errored" already exists') + + const owned = new SessionAlreadyOwnedError(id) + expect(owned.name).toBe('SessionAlreadyOwnedError') + expect(owned.message).toBe('session "errored" is already owned by an active write handle') + + const readOnly = new SessionReadOnlyError(id, 'append') + expect(readOnly.name).toBe('SessionReadOnlyError') + expect(readOnly.message).toBe('session "errored": append is not available on a read handle') + + const lost = new SessionOwnershipLostError(id) + expect(lost.name).toBe('SessionOwnershipLostError') + expect(lost.message).toBe('session "errored": write ownership was lost; close this handle and reopen') + + const closed = new SessionHandleClosedError(id, 'flush') + expect(closed.name).toBe('SessionHandleClosedError') + expect(closed.message).toBe('session "errored": flush on a closed handle') + + const cause = new Error('detail') + const corruption = new SessionPersistenceCorruptionError('bad log', { cause }) + expect(corruption.name).toBe('SessionPersistenceCorruptionError') + expect(corruption.cause).toBe(cause) + }) +}) + +describe('SessionPersistenceRevision', () => { + it('brands the backend token without changing its runtime value', () => { + expect(SessionPersistenceRevision('rev:1')).toBe('rev:1') + }) +}) diff --git a/packages/session/session-persistence/tests/write-behind.spec.ts b/packages/session/session-persistence/tests/write-behind.spec.ts deleted file mode 100644 index 8b33dc34d0..0000000000 --- a/packages/session/session-persistence/tests/write-behind.spec.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { SessionSeq, 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: SessionSeq): 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(SessionSeq(0)) - - controller.enqueue(first) - first.data.turn = 99 - await vi.advanceTimersByTimeAsync(150) - controller.enqueue(event(SessionSeq(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(SessionSeq(0))) - for (let seq = 1; seq < 20; seq += 1) { - await vi.advanceTimersByTimeAsync(10) - controller.enqueue(event(SessionSeq(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() - 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(SessionSeq(0))) - const first = controller.flush() - const second = controller.flush() - expect(second).toBe(first) - await Promise.resolve() - expect(batches).toEqual([[0]]) - - controller.enqueue(event(SessionSeq(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(SessionSeq(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() - 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(SessionSeq(0))) - await vi.advanceTimersByTimeAsync(200) - expect(batches).toEqual([[0]]) - controller.enqueue(event(SessionSeq(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() - 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(SessionSeq(0))) - await vi.advanceTimersByTimeAsync(200) - controller.enqueue(event(SessionSeq(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(SessionSeq(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(SessionSeq(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() - 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(SessionSeq(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(SessionSeq(0))) - await expect(controller.flush()).rejects.toBe(failure) - expect(report).not.toHaveBeenCalled() - expect(controller.hasWork).toBe(true) - - controller.enqueue(event(SessionSeq(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(SessionSeq(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) - }) -}) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index edfa4820b3..d29c5bb021 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -418,9 +418,9 @@ export class SessionProjectionRegistry extends Service { * yields an end below every watermark and the restore rejects for a full * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). - * @returns the seq to hand the persistence `readFrom`, or `undefined` - * when no unit is registered (no read needed — {@link restore} would - * serve empty values regardless). + * @returns the offset for the stored-log suffix read (`SessionHandle.read`), + * or `undefined` when no unit is registered (no read needed — + * {@link restore} would serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined { let floor: number | undefined @@ -472,8 +472,8 @@ export class SessionProjectionRegistry extends Service { * Cold read: fold every persisted unit over a stored log suffix, seeding * each from its checkpoint row when usable — the one read recipe (cached * state + forward tail replay + `view`) applied without a live `Session`. - * Call with the events returned by a persistence - * `readFrom(id, restoreFloor(checkpoint))` and that same floor as + * Call with the stored events at or past `restoreFloor(checkpoint)` (a + * `SessionHandle.read` slice) and that same floor as * `baseSeq`; the floor's one-below anchor makes the supplied end honest, * so a shrunk log is detected here. A row is usable iff its * `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq` diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index 24150051fb..6914499907 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -46,10 +46,10 @@ const handoffCursor = new WeakMap() /** * Install the telemetry capture side onto a context for one backend. * - * Live capture registers the persistence-coordinator listener set plus the - * `agent/error` relay, all through `ctx.effect()`/`ctx.on()` on the composing - * fiber, and sweeps already-live sessions (a hot reload does not replay - * `session/created`). A `session/disposed` captures the session's `shutdown` + * Live capture registers its own `session/created` / `session/event` / + * `session/disposed` listener set plus the `agent/error` relay, all through + * `ctx.effect()`/`ctx.on()` on the composing fiber, and sweeps already-live + * sessions (a hot reload does not replay `session/created`). A `session/disposed` captures the session's `shutdown` * operational record at its own termination edge and retires it from the * adopted set. On-demand capture registers none of those continuous listeners; * {@link captureSession} reads the canonical log explicitly and never creates diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts index fa7c5bd833..755dd916af 100644 --- a/packages/session/session-title/tests/persistence.spec.ts +++ b/packages/session/session-title/tests/persistence.spec.ts @@ -23,31 +23,41 @@ afterEach(async () => { async function appendPersistedTitle(ctx: Context, id: ReturnType): Promise { const session = ctx.sessions.create(id) - session.append('turn/start', { - turn: 1, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Persist this session title' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessionTitle.refresh(session) + const handle = await ctx.sessionPersistence.create(session.header) + try { + session.append('turn/start', { + turn: 1, + }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Persist this session title' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.sessionTitle.refresh(session) + } finally { + await handle.close() + } } async function expectPersistedTitle(ctx: Context, id: ReturnType): Promise { - const loaded = await ctx.sessionPersistence.load(id) - expect(foldSessionTitle(loaded.events)).toMatchObject({ - title: 'Persist this session title', - messageSeqs: [1], - source: { kind: 'fallback' }, - eventSeq: 3, - }) - expect(loaded.events.map(event => event.type)).toEqual([ - 'turn/start', - 'user/message', - 'turn/end', - 'session/title', - ]) + const handle = await ctx.sessionPersistence.open(id, 'read') + try { + const events = await handle.read() + expect(foldSessionTitle(events)).toMatchObject({ + title: 'Persist this session title', + messageSeqs: [1], + source: { kind: 'fallback' }, + eventSeq: 3, + }) + expect(events.map(event => event.type)).toEqual([ + 'turn/start', + 'user/message', + 'turn/end', + 'session/title', + ]) + } finally { + await handle.close() + } } describe('session title persistence round trips', () => { diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index cbfe47cb5c..57a271577a 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/shell/shell-env/README.md -README.md: 0004e0c73321b73a61c4ffa64ad45fc13a290231 -README.zh.md: ba923d644c85c7e150e24d1440c78c152c5fac88 +README.md: f85fb0005e94dc59939cfd2e9f0d69e5cdc7d133 +README.zh.md: f0d02de700c2e5e7d8fe0081813cda96bd099a78 diff --git a/packages/shell/shell-env/README.md b/packages/shell/shell-env/README.md index 0004e0c733..f85fb0005e 100644 --- a/packages/shell/shell-env/README.md +++ b/packages/shell/shell-env/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-shell-env` provides the trusted `DSH_*` environment that every model shell call — bash or pwsh — runs with: built-in facts such as `DSH_HOME`, `DSH_SHELL=1`, and the agent's `DSH_SESSION_ID`, plus `DSH_SESSION_JSONL` when the active persistence backend locates a JSONL artifact. Plugin authors can register their own facts with declared keys, collected per execution and disposed with their plugin; duplicate ownership or undeclared runtime keys fail loudly instead of silently overwriting. The registry changes nothing else the model sees — the shell tools own their own schemas and prompts. Choose it in any composition that mounts a model shell tool; configuration only picks the Harness home directory. +`dsh-shell-env` provides the trusted `DSH_*` environment that every model shell call — bash or pwsh — runs with: built-in facts such as `DSH_HOME`, `DSH_SHELL=1`, and the agent's `DSH_SESSION_ID`. Plugin authors can register their own facts with declared keys, collected per execution and disposed with their plugin; duplicate ownership or undeclared runtime keys fail loudly instead of silently overwriting. The registry changes nothing else the model sees — the shell tools own their own schemas and prompts. Choose it in any composition that mounts a model shell tool; configuration only picks the Harness home directory. ## Table of Contents @@ -29,7 +29,7 @@ Load this plugin in any composition that mounts a model shell tool (`dsh-tool-ba ### What every shell call receives -Every call receives `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, and, for agent calls, `DSH_SESSION_ID` (the calling session's id). When the active persistence backend locates a JSONL artifact for the session, calls also receive `DSH_SESSION_JSONL` with its absolute target path — a location hint, not a guarantee: the file may not exist before the first flush and may not contain the current buffered turn, and the value is not an authorization credential. +Every call receives `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, and, for agent calls, `DSH_SESSION_ID` (the calling session's id). ### Adding your own environment facts @@ -80,13 +80,13 @@ This section explains the design decisions behind the registry and points at the - **Trusted namespace, rebuilt per call.** The environment is a Harness-owned `DSH_*` namespace: the shell executor discards inherited `DSH_*` values and merges the registry's current snapshot for each execution, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. - **Declared ownership, loud conflicts.** Contributors declare their keys up front so duplicate ownership is detected before the first command; resolvers may only return declared keys. -- **Built-ins stay here.** `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID` are reserved for the registry; `DSH_SESSION_JSONL` is contributed by this plugin's own persistence translator, which reads the backend-neutral `sessionPersistence.locate()` seam. +- **Built-ins stay here.** `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID` are reserved for the registry; contributors cannot claim them. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry, `ShellEnvRegistry` service, built-in facts and the persistence contributor | +| [`src/index.ts`](src/index.ts) | Plugin entry, `ShellEnvRegistry` service, and the built-in facts | | — | No runtime invariant companion is published; the environment registry validates ownership and collected values at each registration/collection; it publishes no independent snapshot that a companion could cross-check. | ### Collection @@ -128,7 +128,6 @@ The managed environment never enters the request prefix, so it does not invalida These limits define when the registry is a poor fit or needs care. They are current package constraints, not a task backlog. - **`list()` enumerates plugin-contributed variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. -- **`DSH_SESSION_JSONL` is a location hint, not a guarantee** — the file may not exist before the first flush and may not contain the current buffered turn, and the value is not an authorization credential. ### Dev Note diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index ba923d644c..f0d02de700 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-shell-env` 提供每次模型 shell 调用——bash 或 pwsh——所运行的受信 `DSH_*` 环境:内置事实如 `DSH_HOME`、`DSH_SHELL=1` 与 agent(智能体)的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 产物时的 `DSH_SESSION_JSONL`。插件作者可以注册自己的事实,带声明键、按每次执行收集,并随插件释放;重复所有权或未声明的运行时键会响亮失败,而不是静默覆盖。注册表不会改变模型看到的其他任何内容——shell 工具拥有各自的 schema 与提示词。任何挂载了模型 shell 工具的组合都适合选择它;配置只决定 Harness 主目录。 +`dsh-shell-env` 提供每次模型 shell 调用——bash 或 pwsh——所运行的受信 `DSH_*` 环境:内置事实如 `DSH_HOME`、`DSH_SHELL=1` 与 agent(智能体)的 `DSH_SESSION_ID`。插件作者可以注册自己的事实,带声明键、按每次执行收集,并随插件释放;重复所有权或未声明的运行时键会响亮失败,而不是静默覆盖。注册表不会改变模型看到的其他任何内容——shell 工具拥有各自的 schema 与提示词。任何挂载了模型 shell 工具的组合都适合选择它;配置只决定 Harness 主目录。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 每次 shell 调用都会收到什么 -每次调用都会收到 `DSH_HOME`(Harness 主目录的绝对路径)、`DSH_SHELL=1`,agent 调用还会收到 `DSH_SESSION_ID`(调用方会话的 id)。当活跃持久化后端为该会话定位到 JSONL 产物时,调用还会收到带绝对目标路径的 `DSH_SESSION_JSONL`——这只是位置提示,不是保证:首次 flush 之前文件可能不存在,也可能不包含当前缓冲中的轮次,而且该值不是授权凭据。 +每次调用都会收到 `DSH_HOME`(Harness 主目录的绝对路径)、`DSH_SHELL=1`,agent 调用还会收到 `DSH_SESSION_ID`(调用方会话的 id)。 ### 添加你自己的环境事实 @@ -80,13 +80,13 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 - **受信命名空间,每次调用重建。** 环境是归 Harness 所有的 `DSH_*` 命名空间:shell 执行器丢弃继承的 `DSH_*` 值,并为每次执行合并注册表的当前快照,因此嵌套 harness 与并发的父子 agent 无法泄漏陈旧身份,`process.env` 也永不被修改。 - **声明的所有权,响亮的冲突。** contributor 预先声明键,使重复所有权在第一条命令之前就被发现;resolver 只能返回已声明的键。 -- **内置键留在这里。** `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID` 为注册表保留;`DSH_SESSION_JSONL` 由本插件自己的持久化翻译器贡献,它读取与后端无关的 `sessionPersistence.locate()` seam。 +- **内置键留在这里。** `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID` 为注册表保留;contributor 不能声称拥有它们。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口、`ShellEnvRegistry` 服务、内置事实与持久化 contributor | +| [`src/index.ts`](src/index.ts) | 插件入口、`ShellEnvRegistry` 服务与内置事实 | | — | 不发布运行时不变式伴生入口;收集可通过工具执行观察。 | ### 收集 @@ -128,7 +128,6 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 这些限制说明注册表何时不合适或需要小心使用。它们是当前包约束,不是任务积压。 - **`list()` 只枚举插件贡献的变量**——注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。 -- **`DSH_SESSION_JSONL` 只是位置提示,不是保证**——首次 flush 之前文件可能不存在,也可能不包含当前缓冲中的轮次,而且该值不是授权凭据。 ### 开发备注 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 4b2bfce68b..2b4726b7f2 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -29,7 +29,6 @@ "peerDependencies": { "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -41,7 +40,6 @@ "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/shell/shell-env/src/index.ts b/packages/shell/shell-env/src/index.ts index 88f329d281..4b52f28c01 100644 --- a/packages/shell/shell-env/src/index.ts +++ b/packages/shell/shell-env/src/index.ts @@ -14,7 +14,6 @@ import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-persistence' declare module '@deepseek-ai/cordis' { interface Context { @@ -70,7 +69,6 @@ export interface BashEnvVariableInfo extends BashEnvVariable { const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const -const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const const RESERVED_BASH_ENV_KEYS = new Set([ DSH_HOME_ENV, DSH_SHELL_KEY, @@ -193,25 +191,10 @@ export class ShellEnvRegistry extends Service { } /** - * Load the shell-env plugin: register the `ctx.shellEnv` service and the - * shell-agnostic persistence contributor (`DSH_SESSION_JSONL`). + * Load the shell-env plugin: register the `ctx.shellEnv` registry service. * @param ctx - Cordis context that owns the service and registrations. * @param config - home-directory configuration for the built-in variables. */ export function apply(ctx: Context, config: Config = {}): void { - const registry = new ShellEnvRegistry(ctx, config) - registry.register({ - name: 'session-persistence', - variables: { - [DSH_SESSION_JSONL_KEY]: { - description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', - }, - }, - resolve(execution) { - const agent = execution.agent - if (agent === undefined) return {} - const location = ctx.get('sessionPersistence')?.locate(agent.session.header) - return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} - }, - }) + new ShellEnvRegistry(ctx, config) } diff --git a/packages/shell/shell-env/tests/shell-env.spec.ts b/packages/shell/shell-env/tests/shell-env.spec.ts index 4df99a41a4..1aedc3cfe1 100644 --- a/packages/shell/shell-env/tests/shell-env.spec.ts +++ b/packages/shell/shell-env/tests/shell-env.spec.ts @@ -199,40 +199,10 @@ describe('ShellEnvRegistry', () => { expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL') }) - it('the plugin registers the service and the persistence contributor on load', async () => { + it('the plugin registers the service with no contributors on load', async () => { const ctx = new Context() await ctx.plugin(BashEnvPlugin) expect(ctx.shellEnv).toBeInstanceOf(ShellEnvRegistry) - expect(ctx.shellEnv.list()).toEqual([ - { - contributor: 'session-persistence', - description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', - key: 'DSH_SESSION_JSONL', - }, - ]) - }) - - it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => { - const ctx = new Context() - await ctx.plugin(BashEnvPlugin) - ctx.provide('sessionPersistence', { - locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }), - }) - expect(ctx.shellEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl') - }) - - it('the persistence contributor omits the variable for a non-jsonl backend', async () => { - const ctx = new Context() - await ctx.plugin(BashEnvPlugin) - ctx.provide('sessionPersistence', { - locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }), - }) - expect(ctx.shellEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') - }) - - it('the persistence contributor omits the variable without a persistence backend', async () => { - const ctx = new Context() - await ctx.plugin(BashEnvPlugin) - expect(ctx.shellEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + expect(ctx.shellEnv.list()).toEqual([]) }) }) diff --git a/packages/shell/shell-env/tsconfig.json b/packages/shell/shell-env/tsconfig.json index fa1cbc5430..6db2523824 100644 --- a/packages/shell/shell-env/tsconfig.json +++ b/packages/shell/shell-env/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../../core/tools" - }, - { - "path": "../../session/session-persistence" } ] } diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index 7923382bc8..f06f4d22f3 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' @@ -96,14 +96,14 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise { - it('first-turn bash receives session identity before the lazy JSONL file materializes', async () => { + it('first-turn bash receives session identity in a scrubbed DSH_* namespace', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-bash-session-env-')) dirs.push(root) const dshHome = join(root, 'dsh-home') vi.stubEnv('DSH_STALE_PARENT', 'stale') const adapter = new MockAdapter([ toolCallResponse('call-1', 'bash', { - command: 'printf \'%s\\n%s\\n%s\\n%s\\n%s\\n\' "$DSH_HOME" "$DSH_SHELL" "$DSH_SESSION_ID" "$DSH_SESSION_JSONL" "${DSH_STALE_PARENT-unset}"; if [ -e "$DSH_SESSION_JSONL" ]; then printf \'present\\n\'; else printf \'absent\\n\'; fi', + command: 'printf \'%s\\n%s\\n%s\\n%s\\n\' "$DSH_HOME" "$DSH_SHELL" "$DSH_SESSION_ID" "${DSH_STALE_PARENT-unset}"', description: 'inspect session environment', }), textResponse('Session environment inspected.'), @@ -114,18 +114,12 @@ describe('bash tool through the agent loop', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) const agent = handle.agent - const location = ctx.sessionPersistence.locate(agent.session.header) - expect(location?.kind).toBe('jsonl') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) const result = findEvent(events(agent), 'tool/result') - expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`) - await ctx.sessions.flush(agent.session) - expect(existsSync(location!.path)).toBe(true) - const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string } - expect(header).toMatchObject({ type: 'session', id: 'session-env-id' }) + expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\nunset\n`) await handle.dispose() }) @@ -135,7 +129,7 @@ describe('bash tool through the agent loop', () => { textResponse('The command printed integration-ok.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -167,7 +161,7 @@ describe('bash tool through the agent loop', () => { textResponse('It failed with code 9.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -200,7 +194,7 @@ describe('bash tool through the agent loop', () => { textResponse('Background job finished.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index ca8ce99c89..747d98b48d 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -11,8 +11,7 @@ import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepse import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' -import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { SessionId } from '@deepseek-ai/dsh-session' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import ApprovalService from '@deepseek-ai/dsh-user-approval' @@ -205,37 +204,20 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - const events: Array<{ - type: string - seq: ReturnType - time: number - data: Record - }> = [{ type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }] - if (mode !== undefined) { - events.push({ type: 'sandbox/mode', seq: SessionSeq(1), time: 1, data: { mode } }) - } + const events: Array<{ type: string; data?: Record; seq: number }> = [{ type: 'turn/start', seq: 0, data: { turn: 1 } }] + if (mode !== undefined) events.push({ type: 'sandbox/mode', seq: events.length, data: { mode } }) const id = SessionId('sandbox-session') return { id, ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx }, session: { id, - header: { version: 0, id, createdAt: 0, isSeeded: false }, - inheritedEventCount: SessionLogOffset(0), - firstLiveSeq: SessionLogOffset(0), - get seq() { return SessionLogOffset(events.length) }, - eventAt: (seq: ReturnType) => events[seq], - snapshotEvents: ( - fromSeq = SessionLogOffset(0), - toSeqExclusive = SessionLogOffset(events.length), - ) => events.slice(fromSeq, toSeqExclusive), + header: { version: 0, id, createdAt: 0 }, + get seq() { return events.length }, + eventAt: (seq: number) => events[seq], + snapshotEvents: () => events, append: (type: string, data: Record) => { - const event = { - type, - seq: SessionSeq(events.length), - time: events.length, - data, - } + const event = { type, data, seq: events.length } events.push(event) onAppend?.(type) return event @@ -644,10 +626,11 @@ describe('sandbox escalation through the generic task producer', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.append as unknown as ( - type: string, - data: Record, - ) => unknown)('sandbox/mode', { mode: 'unknown-mode' }) + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string }; seq: number }>).push({ + type: 'sandbox/mode', + data: { mode: 'unknown-mode' }, + seq: malformed.session.seq, + }) expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider') }) @@ -1131,15 +1114,11 @@ describe('the model-facing bash tool builds its request from named args only (no } } - async function setupRecording(withJsonl = false) { + async function setupRecording() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - if (withJsonl) { - await ctx.plugin(SessionStore) - await ctx.plugin(JsonlSessionPersistence, { root: join(spillDir, 'jsonl') }) - } await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome }) @@ -1152,13 +1131,12 @@ describe('the model-facing bash tool builds its request from named args only (no const { ctx } = await setupRecording() const description = ctx.tools.get('bash')?.description ?? '' expect(description).toContain('$DSH_*') - expect(description).not.toContain('DSH_SESSION_JSONL') }) - it('injects the session id and JSONL target path into a foreground request', async () => { - const { ctx, bash } = await setupRecording(true) + it('injects built-ins and the stable session id into a foreground request', async () => { + const { ctx, bash } = await setupRecording() const agent = registerFakeAgent(ctx, 'request-fg', () => undefined) - const path = ctx.sessionPersistence.locate(agent.session.header)?.path + const ambient = process.env.DSH_SESSION_ID await ctx.tools.execute({ signal: testToolSignal, @@ -1171,15 +1149,14 @@ describe('the model-facing bash tool builds its request from named args only (no expect(bash.requests[0]?.dshEnv).toEqual({ DSH_HOME: recordingDshHome, DSH_SESSION_ID: 'request-fg', - DSH_SESSION_JSONL: path, DSH_SHELL: '1', }) + expect(process.env.DSH_SESSION_ID).toBe(ambient) }) it('injects the same trusted variables into a background request without forwarding model env', async () => { - const { ctx, bash } = await setupRecording(true) + const { ctx, bash } = await setupRecording() const agent = registerFakeAgent(ctx, 'request-bg', () => undefined) - const path = ctx.sessionPersistence.locate(agent.session.header)?.path await ctx.tools.execute({ signal: testToolSignal, @@ -1189,7 +1166,7 @@ describe('the model-facing bash tool builds its request from named args only (no command: 'sleep 1', description: 'run command', run_in_background: true, - env: { DSH_SESSION_ID: 'spoofed', DSH_SESSION_JSONL: '/tmp/spoofed' }, + env: { DSH_SESSION_ID: 'spoofed' }, }, agent, }) @@ -1198,34 +1175,12 @@ describe('the model-facing bash tool builds its request from named args only (no expect(bash.requests[0]?.dshEnv).toEqual({ DSH_HOME: recordingDshHome, DSH_SESSION_ID: 'request-bg', - DSH_SESSION_JSONL: path, DSH_SHELL: '1', }) }) - it('injects built-ins and the stable session id when no JSONL locator is available', async () => { - const { ctx, bash } = await setupRecording() - const agent = registerFakeAgent(ctx, 'request-id-only', () => undefined) - const ambient = process.env.DSH_SESSION_ID - - await ctx.tools.execute({ - signal: testToolSignal, - callId: ToolCallId('session-env-id-only'), - name: 'bash', - arguments: { command: 'true', description: 'run command' }, - agent, - }) - - expect(bash.requests[0]?.dshEnv).toEqual({ - DSH_HOME: recordingDshHome, - DSH_SESSION_ID: 'request-id-only', - DSH_SHELL: '1', - }) - expect(process.env.DSH_SESSION_ID).toBe(ambient) - }) - it('keeps parent and child agent session environments isolated', async () => { - const { ctx, bash } = await setupRecording(true) + const { ctx, bash } = await setupRecording() const parent = registerFakeAgent(ctx, 'request-parent', () => undefined) const child = registerFakeAgent(ctx, 'request-child', () => undefined) @@ -1243,17 +1198,14 @@ describe('the model-facing bash tool builds its request from named args only (no { DSH_HOME: recordingDshHome, DSH_SESSION_ID: 'request-parent', - DSH_SESSION_JSONL: ctx.sessionPersistence.locate(parent.session.header)?.path, DSH_SHELL: '1', }, { DSH_HOME: recordingDshHome, DSH_SESSION_ID: 'request-child', - DSH_SESSION_JSONL: ctx.sessionPersistence.locate(child.session.header)?.path, DSH_SHELL: '1', }, ]) - expect(bash.requests[0]?.dshEnv?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.dshEnv?.DSH_SESSION_JSONL) }) it('does not forward trusted-only fields even when the model includes them as extra arguments', async () => { diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index 67b0dd9d70..e761f070af 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -43,7 +43,7 @@ async function setup(script: Script) { await ctx.plugin(Spawn, { providerName: 'spawn' }) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 0219824497..ac9a994c29 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -49,7 +49,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentRuntime) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index d5f26b8558..4d86cb3705 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -48,7 +48,7 @@ async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agen await ctx.plugin(ApprovalService) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create( + const parent = await ctx.agentLoop.create( SessionId('parent'), { provider: 'mock', model: 'mock' }, { cwd: workspace }, diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index c145204ea2..2b5350d951 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -78,7 +78,7 @@ async function setup(script: Script, options: SetupOptions = {}) { start: (request: ResolvedSubagentStartRequest) => startInProcessRun(request, {}), }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter, disposeProvider } } diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index df0250cf3f..c669e614b6 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -33,7 +33,7 @@ async function setup(script: Script, parentOptions: Partial = {}) await ctx.plugin(SubagentRuntime) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock', ...parentOptions }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock', ...parentOptions }) return { ctx, parent, adapter } } @@ -71,7 +71,7 @@ describe('startInProcessRun', () => { it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => { const { ctx } = await setup([textResponse('driver answer')]) - const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' }) + const parent = await ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' }) const run = await startInProcessRun({ ...request(parent), agentOptions: { provider: 'mock', model: 'mock' }, @@ -307,7 +307,7 @@ describe('startInProcessRun', () => { // no provider/model is fabricated, so the child's turn errors for want of a // route rather than silently adopting one. const { ctx } = await setup([]) - const parent = ctx.agentLoop.create(SessionId('routeless-parent'), {}) + const parent = await ctx.agentLoop.create(SessionId('routeless-parent'), {}) const run = await startInProcessRun(request(parent), {}) const child = ctx.agents.get(run.id)! expect(child.options).toEqual({ subagentDepth: 1 }) diff --git a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts index 8a17743341..3e764070f7 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts @@ -23,7 +23,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( it('a parent delegates to a child that writes a file on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) ctx = await spawnHarness(workdir) - const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const parent = await ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) parent.followup(createUserMessage({ content: [{ type: 'text', text: diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index c46f99514f..dc9d3ed4d1 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -43,7 +43,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter } } @@ -334,7 +334,7 @@ describe('dsh-subagent-spawn-in-process', () => { await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) const controller = new AbortController() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'q' }], @@ -362,7 +362,7 @@ describe('dsh-subagent-spawn-in-process', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) const parentEffects = parent.ctx.fiber.getEffects().length const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 2103ce2270..edaa0d0eea 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -33,7 +33,7 @@ import type { import { ReasoningEffortId, boundContextSummary, contentHasImage, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId , SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' @@ -455,53 +455,94 @@ export class SubagentContinuationManager { // parent's future, not to this child. const delegatedPolicies = captureDelegatedPolicyOverrides(parent) - const prepared = await this.host.prepareContinuable(spec.provider, { - sessionId: childId, - parent, - signal: spec.signal, - }) - spec.signal.throwIfAborted() - this.assertAdmitting(parent) - - const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0) - const seed = seedDescriptorTurn(childId, prepared.seed, descriptor) - const messageId = await this.locks.run(childId, async () => { + // Hold the parent's own Activation open across the establishment awaits: + // an idle continuation-managed parent must not settle while a caller is + // still creating its child, or the admitted delivery would find a stale + // parent identity. A turn-scoped delegation never needs this (the parent + // is `running`), but this service is also callable outside a turn. + const releaseHold = this.holdOwnership(parent, childId) + try { + const prepared = await this.host.prepareContinuable(spec.provider, { + sessionId: childId, + parent, + signal: spec.signal, + }) spec.signal.throwIfAborted() this.assertAdmitting(parent) - this.assertChildIdAvailable(childId) - if (spec.childId !== undefined) { - const persisted = await persistence.listSnapshots(spec.signal) + + const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0) + const seed = seedDescriptorTurn(childId, prepared.seed, descriptor) + const messageId = await this.locks.run(childId, async () => { spec.signal.throwIfAborted() this.assertAdmitting(parent) this.assertChildIdAvailable(childId) - if (persisted.some(snapshot => snapshot.header.id === childId)) { - throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') + if (spec.childId !== undefined) { + const persisted = await persistence.stat(childId, { signal: spec.signal }) + spec.signal.throwIfAborted() + this.assertAdmitting(parent) + this.assertChildIdAvailable(childId) + if (persisted !== undefined) { + throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') + } } - } - const activation = await this.materialize({ - childId, - provider: spec.provider, - parent, - create: { - seed, - meta: childSessionMeta(parent, childDepth, prepared.seed !== undefined), - inheritedEventCount, - delegatedPolicies, - }, - agentOptions, - composition: { persona: request.persona, toolFilter: request.toolFilter }, - signal: spec.signal, + const activation = await this.materialize({ + childId, + provider: spec.provider, + parent, + create: { seed, meta: childSessionMeta(parent, childDepth, prepared.seed !== undefined), inheritedEventCount, delegatedPolicies }, + agentOptions, + composition: { persona: request.persona, toolFilter: request.toolFilter }, + signal: spec.signal, + }) + return this.submitMaterialized( + activation, + isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', activation.handle.agent)) + ? continuableInitialPrompt(parent.id, request.prompt) + : request.prompt, + { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, + parent, + ) }) - return this.submitMaterialized( - activation, - isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', activation.handle.agent)) - ? continuableInitialPrompt(parent.id, request.prompt) - : request.prompt, - { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, - parent, + return { childId, messageId } + } catch (error: unknown) { + releaseHold() + throw error + } + } + + /** + * Pre-register `childId` in a continuation-managed parent's owned set so the + * parent cannot settle while a caller is still establishing or resuming that + * child. Returns a releaser for the failure path; it removes only a hold + * this call added, and leaves ownership in place once a live Activation for + * the child exists (an admitted delivery owns it from then on). A parent + * without an Activation needs no hold: only this manager settles parents. + * @param parent - the live direct parent the operation is admitted under. + * @param childId - the durable child the operation addresses. + * @returns the failure-path releaser; a no-op when nothing was added. + * @throws {SubagentError} `ACTIVATION_CLOSING` when the parent's own + * disposal transaction is already open. + */ + private holdOwnership(parent: Agent, childId: SessionId): () => void { + const parentActivation = this.activations.get(parent.id) + if (parentActivation === undefined || parentActivation.handle.agent !== parent) return () => {} + if (parentActivation.disposal !== undefined) { + throw new SubagentError( + `subagent parent "${parent.id}" is being disposed; the child was not established`, + 'ACTIVATION_CLOSING', ) - }) - return { childId, messageId } + } + if (parentActivation.ownedChildren.has(childId)) return () => {} + parentActivation.ownedChildren.add(childId) + return () => { + const live = this.activations.get(childId) + /* v8 ignore next 4 -- reaching this arm needs another delivery to establish the child + * between this operation's failure and its releaser running, which no test can schedule + * deterministically: the ownership edge then belongs to that live Activation, so the + * conservative keep leaves it for finishDisposal's releaseOwnership. */ + if (live !== undefined && live.disposal === undefined) return + if (parentActivation.ownedChildren.delete(childId)) this.wake(parentActivation) + } } /** Reject one child identity already owned by a live Agent or Session. */ @@ -583,6 +624,24 @@ export class SubagentContinuationManager { options: ChildDeliveryOptions, ): Promise { this.assertAdmitting(parent) + // Same hold as `startContinuable`: an idle continuation-managed parent + // must not settle underneath a cold resume it is authorizing. + const releaseHold = this.holdOwnership(parent, childId) + try { + return await this.deliverFollowup(parent, childId, content, options) + } catch (error: unknown) { + releaseHold() + throw error + } + } + + /** The delivery loop behind {@link deliverToChild}, run under the parent hold. */ + private async deliverFollowup( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + options: ChildDeliveryOptions, + ): Promise { while (true) { const live = await this.locks.run(childId, async () => { const activation = this.activations.get(childId) @@ -995,7 +1054,9 @@ export class SubagentContinuationManager { // Fold only the child's own suffix: a fork seed replays the parent's log, // which may carry an ANCESTOR's descriptor when the parent is itself a // continuable child. - const descriptor = foldSubagentDescriptor(source.events.slice(source.inheritedEventCount)) + const descriptor = foldSubagentDescriptor( + source.events.slice(source.inheritedEventCount), + ) if (descriptor === undefined || descriptor.mode !== 'continuable') { throw new SubagentError( `subagent "${childId}" has no supported continuation state and cannot be resumed; choose a different target`, diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 97b05c98af..d1dde79a41 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -27,6 +27,7 @@ import ApprovalService from '@deepseek-ai/dsh-user-approval' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentRuntime from '../src/index.ts' import { TestSessionQuery } from './test-session-query.ts' +import { loadStoredSession } from './persistence-helpers.ts' type Script = ConstructorParameters[0] @@ -54,7 +55,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } @@ -105,7 +106,7 @@ describe('continuable policy inheritance', () => { expect(ctx.approval.overrideOf(child.session)).toBe('never') await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(policyEvents(loaded.events)).toMatchObject([ { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } }, { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, @@ -136,7 +137,7 @@ describe('continuable policy inheritance', () => { const started = await starting await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') }) @@ -147,7 +148,7 @@ describe('continuable policy inheritance', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(policyEvents(loaded.events)).toMatchObject([ { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) @@ -165,8 +166,7 @@ describe('continuable policy inheritance', () => { const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) - expect(loaded.meta.isSeeded).toBe(true) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(loaded.inheritedEventCount).toBeGreaterThan(0) expect(policyEvents(loaded.events)).toMatchObject([ { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, @@ -190,7 +190,7 @@ describe('continuable policy inheritance', () => { expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') }) @@ -213,7 +213,7 @@ describe('continuable policy inheritance', () => { ) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ { data: { mode: 'read-only', source: 'delegation' } }, ]) @@ -238,8 +238,7 @@ describe('continuable policy inheritance', () => { const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) - expect(loaded.meta.isSeeded).toBe(true) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(loaded.inheritedEventCount).toBeGreaterThan(0) expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ { data: { mode: 'workspace-write' } }, diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 851720f228..82298d6eaf 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -24,6 +24,7 @@ import SubagentRuntime, { import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' import * as SubagentInvariant from '../src/invariant.ts' import { TestSessionQuery } from './test-session-query.ts' +import { loadStoredSession } from './persistence-helpers.ts' type Script = ConstructorParameters[0] @@ -94,7 +95,7 @@ async function setupWith( await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, disposePersistence, root } } @@ -219,7 +220,7 @@ describe('SubagentRuntime.startContinuable', () => { expect(adapter.requests).toEqual([]) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'child task')).toBe(true) }) @@ -245,7 +246,7 @@ describe('SubagentRuntime.startContinuable', () => { release.resolve(undefined) await waitNoActivation(ctx, reservedId) - const loaded = await ctx.sessionPersistence.load(reservedId) + const loaded = await loadStoredSession(ctx.sessionPersistence, reservedId) expect(loaded.meta.id).toBe(reservedId) await expect(ctx.subagents.startContinuable({ @@ -283,7 +284,7 @@ describe('SubagentRuntime.startContinuable', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const descriptorIndex = loaded.events.findIndex(event => event.type === 'subagent/descriptor') const turnStartIndex = loaded.events.findIndex(event => event.type === 'turn/start') expect(descriptorIndex).toBeGreaterThanOrEqual(0) @@ -329,7 +330,7 @@ describe('SubagentRuntime.startContinuable', () => { }, }) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) .toMatchObject({ agentReasoningEffort: 'max' }) @@ -410,7 +411,7 @@ describe('SubagentRuntime.startContinuable', () => { const { ctx } = await setup([]) // A routeless parent declares no provider/model, and this start declares no // persona or tool filter, so the descriptor records only what exists. - const routeless = ctx.agentLoop.create(SessionId('routeless'), {}) + const routeless = await ctx.agentLoop.create(SessionId('routeless'), {}) const started = await ctx.subagents.startContinuable(startSpec(routeless)) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) @@ -441,7 +442,7 @@ describe('SubagentRuntime.startContinuable', () => { }, execute: () => Promise.resolve({}), })) - const routeless = ctx.agentLoop.create(SessionId('routeless-filtered'), {}) + const routeless = await ctx.agentLoop.create(SessionId('routeless-filtered'), {}) const started = await ctx.subagents.startContinuable({ ...startSpec(routeless), request: { prompt: message('filtered work'), parent: routeless, toolFilter: { deny: ['noop'] } }, @@ -465,7 +466,7 @@ describe('SubagentRuntime.startContinuable', () => { it('cold-resumes without inventing a model route the descriptor never declared', async () => { const { ctx, root } = await setup([textResponse('first')]) - const routeless = ctx.agentLoop.create(SessionId('routeless-resume'), {}) + const routeless = await ctx.agentLoop.create(SessionId('routeless-resume'), {}) const started = await ctx.subagents.startContinuable(startSpec(routeless)) await waitNoActivation(ctx, started.childId) @@ -480,7 +481,7 @@ describe('SubagentRuntime.startContinuable', () => { await fresh.plugin(TestSessionQuery) await fresh.plugin(SubagentRuntime) await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) - const freshParent = fresh.agentLoop.create(SessionId('routeless-resume'), {}) + const freshParent = await fresh.agentLoop.create(SessionId('routeless-resume'), {}) await queuePrompt(fresh, freshParent, started.childId, message('resume routeless')) const resumed = await vi.waitFor(() => { @@ -505,7 +506,7 @@ describe('SubagentRuntime.startContinuable', () => { const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const descriptorIndex = loaded.events.findIndex(event => event.type === 'subagent/descriptor') const childTurn = loaded.events.slice(descriptorIndex + 1) .find(event => event.type === 'turn/start') @@ -529,14 +530,14 @@ describe('SubagentRuntime.startContinuable', () => { }) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const descriptor = loaded.events.find(event => event.type === 'subagent/descriptor') expect(descriptor?.data).toMatchObject({ persona: 'You are scoped.' }) // Cold resume reconstructs the declared composition from that descriptor. await queuePrompt(ctx, parent, started.childId, message('resume it')) await waitNoActivation(ctx, started.childId) - const resumed = await ctx.sessionPersistence.load(started.childId) + const resumed = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(resumed.events, 'resume it')).toBe(true) }) }) @@ -563,7 +564,7 @@ describe('continuable image Queue prompts', () => { .rejects.toMatchObject({ code: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }) expect(resolve).toHaveBeenCalledWith('mock', 'mock', testSignal) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'see this')).toBe(false) await drainManager(ctx) }) @@ -589,7 +590,7 @@ describe('continuable image Queue prompts', () => { releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const delivered = loaded.events.find(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'image')) expect(delivered?.type === 'user/message' && delivered.data.content).toEqual([ @@ -634,14 +635,14 @@ describe('continuable image Queue prompts', () => { await expect(delivery).rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' }) await draining - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(loaded.events.some(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'image'))).toBe(false) }) it('defers to the text-only projection when the descriptor declares no model route', async () => { const { ctx } = await setup([]) - const routeless = ctx.agentLoop.create(SessionId('routeless-image'), {}) + const routeless = await ctx.agentLoop.create(SessionId('routeless-image'), {}) const started = await ctx.subagents.startContinuable(startSpec(routeless)) await waitNoActivation(ctx, started.childId) const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo') @@ -688,7 +689,7 @@ describe('direct-child Queue residency routing', () => { releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(userTexts(loaded.events)).toEqual(['child task', 'first follow-up', 'second follow-up']) }) @@ -701,7 +702,7 @@ describe('direct-child Queue residency routing', () => { expect(messageId).toBeTypeOf('string') await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(userTexts(loaded.events)).toEqual(['child task', 'continue please']) // One descriptor only: cold resume never re-seeds it. expect(loaded.events.filter(event => event.type === 'subagent/descriptor')).toHaveLength(1) @@ -735,7 +736,7 @@ describe('direct-child Queue residency routing', () => { expect(starts.map(info => info.provider)).toEqual(['retired', 'retired']) expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId)) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(userTexts(loaded.events)).toEqual(['child task', 'continue without provider']) }) @@ -771,18 +772,49 @@ describe('direct-child Queue residency routing', () => { releaseGrandchild.resolve(undefined) await waitNoActivation(ctx, grandchild.childId) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) // This child is itself a parent, so its grandchild's settlement notice is // an ordinary later user message in its log. expect(userTexts(loaded.events).slice(0, 2)).toEqual(['child task', 'while waiting']) expect(userTexts(loaded.events).slice(2).join('\n')).toContain('finished and will do no further work') }) + it('holds one ownership edge per child: a followup under an existing hold is a no-op re-hold', async () => { + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + { chunks: textResponse('extra delivery') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // startContinuable held the child→grandchild ownership edge; a followup + // from the same managed parent to the same child re-holds it as a no-op, + // and its failure-path release must not drop the established edge. + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + const aborted = AbortSignal.abort(new Error('followup abandoned')) + await expect(queuePrompt(ctx, child, grandchild.childId, message('while owned'), aborted)) + .rejects.toThrow('followup abandoned') + // The edge survives the released duplicate hold: the grandchild delivery + // still completes and settles normally. + await expect(queuePrompt(ctx, child, grandchild.childId, message('after release'))) + .resolves.toBeTypeOf('string') + + releaseGrandchild.resolve(undefined) + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + }) + it('rejects a parent that is not the durable direct parent', async () => { const { ctx, parent } = await setup([textResponse('first')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) await expect(queuePrompt(ctx, stranger, started.childId, message('mine now'))) .rejects.toThrow(/belongs to another parent session/) @@ -822,8 +854,9 @@ describe('direct-child Queue residency routing', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) const inspectStarted = Promise.withResolvers() - const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession').mockImplementation((_id, signal) => { + const inspect = vi.spyOn(ctx.sessionPersistence, 'open').mockImplementation((_id, _access, options) => { return new Promise((_resolve, reject) => { + const signal = options?.signal if (signal === undefined) { reject(new Error('cold inspection must receive the followup signal')) return @@ -874,7 +907,7 @@ describe('direct-child Queue residency routing', () => { await expect(delivery).resolves.toBeTypeOf('string') await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'raced')).toBe(true) }) }) @@ -918,7 +951,7 @@ describe('continuable child ownership', () => { }) describe('continuable durability and teardown', () => { - it('settles when the best-effort final flush has no listeners', async () => { + it('settles despite the persistence backend being disposed mid-run', async () => { const releaseResponse = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, @@ -929,12 +962,15 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - // Remove every persistence listener; the final flush is advisory. + // Tear down the whole backend under the child's open write path: its + // teardown closes the handle (draining what was buffered) and detaches + // the durability listeners, so the final flush finds no participant and + // never pins the Activation. await disposePersistence!() releaseResponse.resolve(undefined) await waitNoActivation(ctx, started.childId) - expect(warnings.some(warning => warning.includes('final session flush'))).toBe(false) + expect(warnings.join('\n')).not.toContain('final session flush') }) it('logs a failed final flush after every listener settles without failing the Activation', async () => { @@ -1013,7 +1049,7 @@ describe('continuable durability and teardown', () => { expect(disposals.indexOf(grandchild.childId)) .toBeLessThan(disposals.indexOf(started.childId)) // Durable sessions survive process-local teardown. - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(loaded.meta.id).toBe(started.childId) }) @@ -1028,7 +1064,7 @@ describe('continuable durability and teardown', () => { { chunks: textResponse('sibling follow-up') }, ]) const { ctx, parent } = await setupWith(adapter) - const siblingParent = ctx.agentLoop.create( + const siblingParent = await ctx.agentLoop.create( SessionId('sibling-parent'), { provider: 'mock', model: 'mock' }, ) @@ -1126,7 +1162,11 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) const cancel = vi.spyOn(targetAgent, 'cancel') - const drained = ctx.subagents.drainContinuableChildren(parent, [target.childId, target.childId]) + // A repeated id folds to one teardown and an absent target is an accepted no-op. + const drained = ctx.subagents.drainContinuableChildren( + parent, + [target.childId, target.childId, SessionId('never-materialized')], + ) expect(cancel).toHaveBeenCalledWith({ kind: 'parent' }) expect(ctx.agents.get(sibling.childId)).toBe(siblingAgent) @@ -1174,7 +1214,7 @@ describe('continuable durability and teardown', () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: release.promise }]) const { ctx, parent } = await setupWith(adapter) - const other = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) + const other = await ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) const target = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) @@ -1394,7 +1434,7 @@ describe('continuable durability and teardown', () => { await drained await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) // Only what actually reached the log is reconstructable. expect(hasUserText(loaded.events, 'never logged')).toBe(false) }) @@ -1433,8 +1473,12 @@ describe('continuable review regressions', () => { ) await resumed.promise await originalParent.dispose() - const replacement = await ctx.agents.create({ - sessionId: parentId, + // The durable store still holds the parent, so a same-id replacement is a + // resume of the persisted session — a distinct exact Agent identity. + // Called through the unmocked bound original: the spy above must keep + // gating only the child's in-flight cold resume. + const replacement = await originalResume({ + resumeSessionId: parentId, agentOptions: { provider: 'mock', model: 'mock' }, }) releaseResume.resolve(undefined) @@ -1442,7 +1486,7 @@ describe('continuable review regressions', () => { await expect(delivery).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) resumeSpy.mockRestore() await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'must not cross parent replacement')).toBe(false) await replacement.dispose() }) @@ -1511,7 +1555,7 @@ describe('continuable review regressions', () => { // Nothing was enqueued, so no later turn can carry it. releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'cancelled')).toBe(false) expect(before).toBeGreaterThan(0) }) @@ -1718,7 +1762,7 @@ describe('continuable review regressions', () => { await drained await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'discarded')).toBe(false) }) @@ -1744,7 +1788,7 @@ describe('continuable review regressions', () => { // Retaining the discarded id would pin residency at `running` forever, so // reaching no-Activation without an explicit drain is the assertion. await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'doomed')).toBe(false) }) @@ -1827,7 +1871,7 @@ describe('continuable review regressions', () => { // Two child turns; the third request is the parent's own turn on the // settlement notice. expect(adapter.requests.filter(request => request.sessionId === started.childId)).toHaveLength(2) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'queued')).toBe(true) }) }) @@ -2058,7 +2102,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) // The parent must not be told the child finished: the delivery it is still // waiting on was claimed out of the inbox and then swallowed by the failure. - const child = await ctx.sessionPersistence.load(started.childId) + const child = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(child.events, 'second task')).toBe(false) expect(settlementNotices(parent)[0]!.text).toBe( `Background subagent ${started.childId} failed before it finished.` @@ -2416,7 +2460,7 @@ describe('continuable settlement delivery', () => { expect(settlementNotices(resumed.agent)).toEqual([]) await resumed.dispose() // The account is still in the durable log: delivered, then cancelled unread. - const persisted = await ctx.sessionPersistence.load(parentId) + const persisted = await loadStoredSession(ctx.sessionPersistence, parentId) expect(persisted.events.flatMap(event => event.type === 'agent/inbox/spliced' ? [{ inserted: event.data.inserted.length, removed: event.data.removedCount ?? 0 }] : [])).toEqual([{ inserted: 1, removed: 0 }, { inserted: 0, removed: 1 }]) @@ -2545,7 +2589,7 @@ describe('continuable public API', () => { await expect(queuePrompt(ctx, parent, started.childId, message('aborted'), controller.signal)) .rejects.toThrow() - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'aborted')).toBe(false) }) @@ -2566,7 +2610,7 @@ describe('continuable public API', () => { releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(loaded.events, 'survives')).toBe(true) }) }) @@ -2662,11 +2706,11 @@ describe('continuable errors', () => { await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) // The other branch still released, and durable sessions survive. expect(ctx.agents.get(started.childId)).toBeUndefined() - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(loaded.meta.id).toBe(started.childId) }) - it('rolls the transfer back when ownership registration fails after handle transfer', async () => { + it('rejects a new child at admission when the parent disposal transaction is already open', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('parent child'), gate: hold.promise }, @@ -2679,9 +2723,8 @@ describe('continuable errors', () => { expect(found).toBeDefined() return found! }) - // Begin the would-be parent's disposal, then race a grandchild into it. The - // handle transfers before ownership registration rejects, so the rollback - // must leave no Activation and no live Agent behind. + // The would-be parent's disposal is already open at the entry hold, so the + // establishment rejects before any grandchild resource exists. const manager = (ctx.subagents as unknown as { continuations: { activations: Map | undefined }> } }).continuations @@ -2696,6 +2739,95 @@ describe('continuable errors', () => { hold.resolve(undefined) }) + it('rolls the transfer back when the parent begins disposal during materialization', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('parent child'), gate: hold.promise }, + { chunks: textResponse('unused') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const outer = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(outer.childId) + expect(found).toBeDefined() + return found! + }) + const manager = (ctx.subagents as unknown as { + continuations: { + activations: Map | undefined }> + ownerCtx: Context + } + }).continuations + const ownerAgents = manager.ownerCtx.agents + const before = new Set(ctx.agents.list().map(agent => agent.id)) + // Open the would-be parent's disposal only once the grandchild's Agent is + // being created: the entry hold has already passed, so the post-transfer + // ownership registration must reject and roll the transfer back with no + // Activation and no live Agent left behind. + const originalCreate = ownerAgents.create.bind(ownerAgents) + const createSpy = vi.spyOn(ownerAgents, 'create').mockImplementation((options) => { + manager.activations.get(outer.childId)!.disposal = Promise.resolve() + createSpy.mockRestore() + return originalCreate(options) + }) + + await expect(ctx.subagents.startContinuable(startSpec(child))) + .rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' }) + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([]) + }) + hold.resolve(undefined) + }) + + it('releases the parent hold when a cold delivery fails, so the parent can settle', async () => { + const release = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done'), gate: release.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + + // The failed delivery to a missing child must give back the hold it put on + // the delivering parent; a leaked hold would pin the parent in `waiting`. + await expect(queuePrompt(ctx, child, SessionId('no-such-child'), message('hello'))) + .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) + + release.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) + + it('follows up on an already-owned running grandchild without a duplicate hold', async () => { + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + { chunks: textResponse('follow-up answer') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(adapter.requests.length).toBeGreaterThanOrEqual(2) }) + + // The delivering parent already owns this running child, so the entry hold + // is a no-op and the delivery is accepted as ordinary inbox work. + const accepted = await queuePrompt(ctx, child, grandchild.childId, message('one more')) + expect(accepted).toBeTypeOf('string') + + releaseGrandchild.resolve(undefined) + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + }) + it('reapplies the descriptor model route and reasoning effort on cold resume', async () => { const effort = ReasoningEffortId('high') const adapter = new MockAdapter([textResponse('first'), textResponse('resumed')], { @@ -2712,7 +2844,7 @@ describe('continuable errors', () => { }, }) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) .toMatchObject({ agentProvider: 'mock', @@ -2729,7 +2861,7 @@ describe('continuable errors', () => { }) }) await waitNoActivation(ctx, started.childId) - const resumed = await ctx.sessionPersistence.load(started.childId) + const resumed = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(resumed.events.flatMap(event => event.type === 'request/header' ? [event.data.header.config.reasoningEffort] : [])).toEqual([effort, effort]) @@ -2751,7 +2883,7 @@ describe('continuable errors', () => { const serviceFiber = await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) @@ -2798,7 +2930,7 @@ describe('SubagentRuntime.interrupt', () => { // run before it in the existing FIFO order. await queuePrompt(ctx, parent, started.childId, message('waking D')) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(userTexts(loaded.events)).toEqual(['child task', 'parked B', 'parked C', 'waking D']) const turnEnds = loaded.events .filter(event => event.type === 'turn/end') @@ -2837,7 +2969,7 @@ describe('SubagentRuntime.interrupt', () => { releaseGrandchild.resolve(undefined) await waitNoActivation(ctx, grandchild.childId) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(grandchild.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, grandchild.childId) const turnEnds = loaded.events .filter(event => event.type === 'turn/end') .map(event => (event).data.reason.kind) @@ -2909,7 +3041,7 @@ describe('SubagentRuntime.interrupt', () => { const siblingStart = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) const sibling = ctx.agents.get(siblingStart.childId)! - const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) const stale = { ...parent, id: parent.id } as unknown as Agent const cancelSpy = vi.spyOn(target, 'cancel') diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 54d34f8c95..9c5c7c1e4e 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -6,8 +6,9 @@ import { z } from 'zod' import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset, SessionSeq, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -29,6 +30,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { TestSessionQuery } from './test-session-query.ts' +import { seedStoredSession } from './persistence-helpers.ts' type Script = ConstructorParameters[0] @@ -71,9 +73,9 @@ async function setup( const parent = loop === undefined ? (() => { const session = ctx.sessions.create(SessionId('parent')) - return { id: session.id, session } as ReturnType + return { id: session.id, session } as Awaited> })() - : loop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + : await loop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } @@ -82,7 +84,7 @@ const testSignal = new AbortController().signal /** Start one continuable child through the real service path and await Activation release. */ async function startChild( ctx: Context, - parent: ReturnType, + parent: Agent, label: string, ): Promise { const started = await ctx.subagents.startContinuable({ @@ -103,33 +105,60 @@ async function authorChild( id: string, header: Partial, events: SessionEvent[], - inheritedEventCount = SessionLogOffset(0), + inheritedEventCount?: number, ): Promise { const sessionId = SessionId(id) - await ctx.sessionPersistence.create({ + await seedStoredSession(ctx.sessionPersistence, { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, - isSeeded: false, + isSeeded: inheritedEventCount !== undefined, ...header, - }, header.isSeeded === true ? inheritedEventCount : undefined) - await ctx.sessionPersistence.append(sessionId, events) + }, events, inheritedEventCount === undefined ? undefined : SessionLogOffset(inheritedEventCount)) return sessionId } +/** + * Serve one cold session's point read (the open handle) with a transformed + * header while the enumeration listing keeps reporting the stored original — + * the re-published-lifecycle shape the sameLifecycle check exists for. + */ +function mutateStoredHeader( + ctx: Context, + target: SessionId, + mutate: (meta: SessionHeader) => SessionHeader, +): void { + const originalOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + ctx.sessionPersistence.open = async (sessionId, access, options) => { + const handle = await originalOpen(sessionId, access, options) + if (sessionId !== target) return handle + return { + id: handle.id, + access: handle.access, + header: mutate(handle.header), + inheritedEventCount: handle.inheritedEventCount, + read: (offset, length, readOptions) => handle.read(offset, length, readOptions), + append: (events, appendOptions) => handle.append(events, appendOptions), + flush: flushOptions => handle.flush(flushOptions), + close: () => handle.close(), + [Symbol.asyncDispose]: () => handle[Symbol.asyncDispose](), + } + } +} + /** Minimal complete-turn child log with one descriptor payload. */ function childEvents(descriptor: unknown): SessionEvent[] { return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: 2, data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }), surfaceOp: 'append', }, - { type: 'subagent/descriptor', seq: 2, time: 3, data: descriptor }, - { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'subagent/descriptor', seq: SessionSeq(2), time: 3, data: descriptor }, + { type: 'turn/end', seq: SessionSeq(3), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, ] as SessionEvent[] } @@ -275,15 +304,14 @@ describe('SubagentRuntime.listChildren', () => { const { ctx } = await setup([]) // A parent that exists only in persistence — the restart shape. const coldParent = SessionId('00000000-0000-4000-8000-00000000cccc') - await ctx.sessionPersistence.create({ + await seedStoredSession(ctx.sessionPersistence, { version: SESSION_FORMAT_VERSION, id: coldParent, createdAt: 1, isSeeded: false, - }) - await ctx.sessionPersistence.append(coldParent, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + }, [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] as SessionEvent[]) const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', { parentSession: coldParent, @@ -482,7 +510,7 @@ describe('SubagentRuntime.listChildren', () => { live.append('turn/start', { turn: 1 }) live.append('subagent/descriptor', descriptorPayload('was valid')) expect(ctx.sessionProjections.snapshot(live).values.subagent) - .toEqual({ mode: 'continuable', label: 'was valid', seq: 1 }) + .toEqual({ mode: 'continuable', label: 'was valid', seq: SessionSeq(1) }) // Last-wins: the malformed follow-up resets the identity to the sentinel. live.append( 'subagent/descriptor', @@ -524,14 +552,14 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('disk label'))) - // The unseeded child's cut is exactly 0, so its cached identity is final - // and the log is never re-read — the + // seq 2 >= seedLength 0: the cached identity provably comes from the + // child's own suffix, so it is final and the log is never re-read — the // divergent label proves the row, not the log, produced the entry. ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: SessionSeq(2), values: { subagent: { mode: 'continuable', label: 'cached own', seq: SessionSeq(2) } }, }) - const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') + const inspect = vi.spyOn(ctx.sessionPersistence, 'open') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: child, label: 'cached own', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -546,22 +574,21 @@ describe('SubagentRuntime.listChildren', () => { const seed = childEvents(descriptorPayload('ancestor label')) const events = [ ...seed, - { type: 'turn/start', seq: 4, time: 5, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'subagent/descriptor', seq: 5, time: 6, data: descriptorPayload('own label') }, - { type: 'turn/end', seq: 6, time: 7, data: { turn: 2, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(4), time: 5, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'subagent/descriptor', seq: SessionSeq(5), time: 6, data: descriptorPayload('own label') }, + { type: 'turn/end', seq: SessionSeq(6), time: 7, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae02', { parentSession: parent.id, - isSeeded: true, origin: 'subagent', - }, events, SessionLogOffset(seed.length)) - // A seeded header does not expose the integer cut, so a misleading cached - // ANCESTOR identity is bypassed and authoritative preparation rules. + }, events, seed.length) + // A creation-window checkpoint carried the ANCESTOR identity: its seq 2 + // fails the own-suffix gate (< seedLength 4), so preparation rules. ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: SessionSeq(2), values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: SessionSeq(2) } }, }) - const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') + const inspect = vi.spyOn(ctx.sessionPersistence, 'open') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: forkChild, label: 'own label', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -584,13 +611,8 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('reborn child'))) - const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) - ctx.sessionPersistence.borrowSession = async (sessionId, signal) => { - const result = await original(sessionId, signal) - if (sessionId !== reborn) return result - // The id was re-published as a different lifecycle after enumeration. - return { ...result, inspection: { ...result.inspection, meta: mutate(result.inspection.meta) } } - } + // The id was re-published as a different lifecycle after enumeration. + mutateStoredHeader(ctx, reborn, mutate) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' }) expect(entries).toContainEqual({ @@ -607,7 +629,7 @@ describe('SubagentRuntime.listChildren', () => { }, childEvents(descriptorPayload('actually valid'))) // A stale cached sentinel must not out-rank the authoritative re-fold. ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: SessionSeq(0), values: { subagent: null } }) - const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') + const inspect = vi.spyOn(ctx.sessionPersistence, 'open') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -623,14 +645,14 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: 2, data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }), }, - { type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') }, + { type: 'subagent/descriptor', seq: SessionSeq(2), time: 3, data: descriptorPayload('broken surface') }, ] as SessionEvent[]) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }]) @@ -666,9 +688,8 @@ describe('SubagentRuntime.listChildren', () => { const seed = childEvents(descriptorPayload('ancestor label')) const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { parentSession: parent.id, - isSeeded: true, origin: 'subagent', - }, seed, SessionLogOffset(seed.length)) + }, seed, seed.length) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: forkChild, reason: 'corrupt' }]) }) @@ -753,12 +774,12 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('flaky storage'))) - const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) - ctx.sessionPersistence.borrowSession = (sessionId, signal) => { + const original = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + ctx.sessionPersistence.open = (sessionId, access, options) => { if (sessionId === flaky) { return Promise.reject(new Error('backend read failed')) } - return original(sessionId, signal) + return original(sessionId, access, options) } // Per-child isolation: the failed child degrades to one diagnostic while // the healthy sibling stays complete. @@ -770,7 +791,7 @@ describe('SubagentRuntime.listChildren', () => { }) // Nothing is memoized: with the backend healthy again, the next listing // folds the same child to its identity. - ctx.sessionPersistence.borrowSession = original + ctx.sessionPersistence.open = original await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({ kind: 'child', id: flaky, label: 'flaky storage', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -824,10 +845,10 @@ describe('SubagentRuntime.listChildren', () => { origin: 'subagent', }, childEvents(descriptorPayload('grandchild'))) const inspected: SessionId[] = [] - const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) - ctx.sessionPersistence.borrowSession = (sessionId, signal) => { + const original = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + ctx.sessionPersistence.open = (sessionId, access, options) => { inspected.push(sessionId) - return original(sessionId, signal) + return original(sessionId, access, options) } const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([ @@ -856,10 +877,10 @@ describe('SubagentRuntime.listChildren', () => { live.append('subagent/descriptor', descriptorPayload('live mixed child')) const inspected: SessionId[] = [] - const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) - ctx.sessionPersistence.borrowSession = (sessionId, signal) => { + const original = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + ctx.sessionPersistence.open = (sessionId, access, options) => { inspected.push(sessionId) - return original(sessionId, signal) + return original(sessionId, access, options) } const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toHaveLength(3) @@ -875,11 +896,11 @@ describe('SubagentRuntime.listChildren', () => { const childId = await startChild(ctx, parent, 'cached child') // The child's turn/end and disposal are the cache's mandatory checkpoint // points; both writes are fail-soft asynchronous, so wait for the row. - const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId) + const header = (await ctx.sessionPersistence.list()).find(snapshot => snapshot.header.id === childId)?.header await vi.waitFor(() => { expect(ctx.sessionProjectionCache.cachedSnapshot(header!, SessionLogOffset(0))?.values.subagent).toBeDefined() }, { timeout: 5_000 }) - const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') + const inspect = vi.spyOn(ctx.sessionPersistence, 'open') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: childId, label: 'cached child', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -898,7 +919,7 @@ describe('SubagentRuntime.listChildren', () => { activity: 'inactive', hasChildren: false, }] // No stored row at all for a foreign child this process never ran. - const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') + const inspect = vi.spyOn(ctx.sessionPersistence, 'open') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected) expect(inspect).toHaveBeenCalledTimes(1) // A stored row whose cut predates the descriptor: the subagent key is @@ -915,7 +936,7 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('uncacheable child'))) - const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') + const inspect = vi.spyOn(ctx.sessionPersistence, 'open') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -934,7 +955,7 @@ describe('SubagentRuntime.listChildren', () => { // is derived data, so its failure must not become a verdict. throw new Error('poisoned cache row') } - const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') + const inspect = vi.spyOn(ctx.sessionPersistence, 'open') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -948,8 +969,8 @@ describe('SubagentRuntime.listChildren', () => { await authorChild(ctx, '00000000-0000-4000-8000-0000000000f1', { parentSession: childId, }, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] as SessionEvent[]) await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ @@ -989,10 +1010,10 @@ describe('SubagentRuntime.listChildren', () => { const { ctx, parent } = await setup([]) const controller = new AbortController() const entered = Promise.withResolvers() - ctx.sessionPersistence.list = (signal) => { + ctx.sessionPersistence.list = (options) => { entered.resolve(undefined) return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { + options?.signal?.addEventListener('abort', () => { reject(new Error('backend listing aborted')) }, { once: true }) }) @@ -1013,10 +1034,10 @@ describe('SubagentRuntime.listChildren', () => { }, childEvents(descriptorPayload('cancelled cold read'))) const controller = new AbortController() const entered = Promise.withResolvers() - ctx.sessionPersistence.borrowSession = (_sessionId, signal) => { + ctx.sessionPersistence.open = (_sessionId, _access, options) => { entered.resolve(undefined) return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { + options?.signal?.addEventListener('abort', () => { reject(new Error('backend read aborted')) }, { once: true }) }) @@ -1036,9 +1057,9 @@ describe('SubagentRuntime.listChildren', () => { origin: 'subagent', }, childEvents(descriptorPayload('cancelled mid-listing'))) const controller = new AbortController() - const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) - ctx.sessionPersistence.borrowSession = async (sessionId, signal) => { - const result = await original(sessionId, signal) + const original = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + ctx.sessionPersistence.open = async (sessionId, access, options) => { + const result = await original(sessionId, access, options) controller.abort() return result } @@ -1055,7 +1076,7 @@ describe('SubagentRuntime.listChildren', () => { origin: 'subagent', }, childEvents(descriptorPayload('aborted behind a failure'))) const controller = new AbortController() - ctx.sessionPersistence.borrowSession = () => { + ctx.sessionPersistence.open = () => { // The read fails while the caller aborts: cancellation normalization // must fail the listing rather than return a one-diagnostic success. controller.abort() @@ -1241,8 +1262,8 @@ describe('SubagentRuntime.listDescendants', () => { createdAt: 1, origin: 'subagent', }, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] as SessionEvent[]) const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000eee2', { parentSession: bare, @@ -1289,18 +1310,8 @@ describe('SubagentRuntime.listDescendants', () => { createdAt: 1, origin: 'subagent', }, childEvents(descriptorPayload('lineage checked'))) - const realInspect = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) - ctx.sessionPersistence.borrowSession = async (sessionId, signal) => { - const inspected = await realInspect(sessionId, signal) - // The exact read reports a different durable parent than enumeration did. - return { - ...inspected, - inspection: { - ...inspected.inspection, - meta: { ...inspected.inspection.meta, parentSession: SessionId('someone-else') }, - }, - } - } + // The exact read reports a different durable parent than enumeration did. + mutateStoredHeader(ctx, childId, meta => ({ ...meta, parentSession: SessionId('someone-else') })) await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([ { kind: 'diagnostic', id: childId, reason: 'corrupt', parentId: parent.id, depth: 1 }, ]) diff --git a/packages/subagent/subagent/tests/persistence-helpers.ts b/packages/subagent/subagent/tests/persistence-helpers.ts new file mode 100644 index 0000000000..a23eede5a1 --- /dev/null +++ b/packages/subagent/subagent/tests/persistence-helpers.ts @@ -0,0 +1,33 @@ +/** Handle-based session-persistence helpers shared by the subagent test suites. */ + +import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' + +/** Read one stored session's header and complete event log through a read handle. */ +export async function loadStoredSession( + persistence: SessionPersistence, + id: SessionId, +): Promise<{ meta: SessionHeader; inheritedEventCount: SessionLogOffset; events: readonly SessionEvent[] }> { + const handle = await persistence.open(id, 'read') + try { + return { meta: handle.header, inheritedEventCount: handle.inheritedEventCount, events: await handle.read() } + } finally { + await handle.close() + } +} + +/** Author one stored session directly against the backend: create, append, flush, close. */ +export async function seedStoredSession( + persistence: SessionPersistence, + header: SessionHeader, + events: readonly SessionEvent[], + inheritedEventCount?: SessionLogOffset, +): Promise { + const handle = await persistence.create(header, inheritedEventCount === undefined ? {} : { inheritedEventCount }) + try { + if (events.length > 0) await handle.append(events) + await handle.flush() + } finally { + await handle.close() + } +} diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index d62e6a6ae7..8b190d8092 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -65,7 +65,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) { await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) parkParent(ctx, parent) return { ctx, parent, adapter } } diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 082dbde76e..021d64b6f6 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -19,6 +19,7 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a import * as tool from '../src/index.ts' import { parkParent } from './park-parent.ts' import { TestSessionQuery } from './test-session-query.ts' +import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts' /** One scripted response that may wait on a caller-released gate before streaming. */ interface GatedEntry { @@ -67,7 +68,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter, park = true) { await ctx.plugin(SubagentFork, { providerName: 'fork' }) await ctx.plugin(tool) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) if (park) parkParent(ctx, parent) return { ctx, parent, adapter } } @@ -151,7 +152,7 @@ describe('dsh-tool-subagent-control', () => { release.resolve(undefined) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const promptIndex = loaded.events.findIndex(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'text' && block.text === 'fork task')) expect(loaded.meta.isSeeded).toBe(true) @@ -167,7 +168,7 @@ describe('dsh-tool-subagent-control', () => { it('JSON-encodes a caller-supplied parent id in the initial return instruction', async () => { const { ctx } = await setup([textResponse('child done')]) - const parent = ctx.agentLoop.create(SessionId('parent"\nagent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent"\nagent'), { provider: 'mock', model: 'mock' }) parkParent(ctx, parent) const started = await ctx.subagents.startContinuable({ provider: 'spawn', @@ -176,7 +177,7 @@ describe('dsh-tool-subagent-control', () => { signal: testToolSignal, }) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const prompt = loaded.events.find(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'text' && block.text === 'encoded task')) if (prompt?.type !== 'user/message') throw new Error('expected the encoded initial task') @@ -247,7 +248,7 @@ describe('dsh-tool-subagent-control', () => { expect(text(result)).toBe(`message delivered to agent ${started.childId}`) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const followUp = loaded.events.findLast(event => event.type === 'user/message') // The durable message source records the calling agent without granting authority. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ @@ -278,7 +279,7 @@ describe('dsh-tool-subagent-control', () => { expect(result.isError).toBe(false) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' && !block.text.startsWith('Your parent agent id is ') @@ -311,7 +312,7 @@ describe('dsh-tool-subagent-control', () => { signal: testToolSignal, }) await waitNoActivation(ctx, started.childId) - const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) const result = await callTool(ctx, 'send_message', { agent_id: started.childId, @@ -401,7 +402,7 @@ describe('dsh-tool-subagent-control interrupt_agent', () => { }, parent) expect(waking.isError).toBe(false) await waitNoActivation(ctx, started.childId) - const loaded = await ctx.sessionPersistence.load(started.childId) + const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' && !block.text.startsWith('Your parent agent id is ') @@ -477,7 +478,7 @@ describe('dsh-tool-subagent-control interrupt_agent', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) const targetAgent = ctx.agents.get(target.childId)! const siblingAgent = ctx.agents.get(sibling.childId)! - const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) const cancelSpy = vi.spyOn(targetAgent, 'cancel') const self = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, targetAgent) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 7dccef4f41..e5f9502a43 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -19,6 +19,7 @@ import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -1195,7 +1196,7 @@ describe('dsh-tool-subagent continuable background mode', () => { ctx.llm.registerAdapter(['mock'], new MockAdapter([ textResponse('continuable answer'), ])) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } @@ -1245,7 +1246,7 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(ctx.agents.get(SessionId(childId!))).toBeUndefined() }, { timeout: 5_000 }) // The child id names a durable session carrying its continuation descriptor. - const loaded = await ctx.sessionPersistence.load(SessionId(childId!)) + const loaded = await loadStoredSession(ctx.sessionPersistence, SessionId(childId!)) expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) }) @@ -1322,7 +1323,7 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(cancelledChildId).toBeDefined() expect(survivingChildId).toBeDefined() expect(ctx.agents.get(cancelledChildId!)).toBeUndefined() - await expect(ctx.sessionPersistence.load(cancelledChildId!)).rejects.toThrow(/not found/) + await expect(loadStoredSession(ctx.sessionPersistence, cancelledChildId!)).rejects.toThrow(/not found/) expect(succeeded.isError ? undefined : succeeded.value).toEqual({ kind: 'continuable', @@ -1331,7 +1332,7 @@ describe('dsh-tool-subagent continuable background mode', () => { await vi.waitFor(() => { expect(ctx.agents.get(survivingChildId!)).toBeUndefined() }, { timeout: 5_000 }) - const loaded = await ctx.sessionPersistence.load(survivingChildId!) + const loaded = await loadStoredSession(ctx.sessionPersistence, survivingChildId!) expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) }) diff --git a/packages/test-support/loader-smoke/src/agent-turn.ts b/packages/test-support/loader-smoke/src/agent-turn.ts index 5638445cbe..91e5ebebae 100644 --- a/packages/test-support/loader-smoke/src/agent-turn.ts +++ b/packages/test-support/loader-smoke/src/agent-turn.ts @@ -38,8 +38,21 @@ function assistantText(event: Extract block.text).join('') } -function onlyRootAgent(ctx: Context): Agent { - const agents = ctx.get('agents')?.roots() ?? [] +async function onlyRootAgent(ctx: Context): Promise { + const registry = ctx.get('agents') + if (registry === undefined) throw new Error('fixture turn requires exactly one top-level agent, found 0') + // Configured agents publish asynchronously (persistence create/resume runs + // before publication), so a settled Loader does not imply a registered + // agent yet; wait for the first publication instead of requiring it. + if (registry.roots().length === 0) { + await new Promise((resolve) => { + const dispose = ctx.on('agent/created', () => { + dispose() + resolve() + }) + }) + } + const agents = registry.roots() const [agent] = agents if (agent === undefined || agents.length !== 1) { throw new Error(`fixture turn requires exactly one top-level agent, found ${agents.length}`) @@ -54,7 +67,7 @@ function onlyRootAgent(ctx: Context): Agent { * @returns the final assistant text and accumulated model usage. */ export async function runFixtureTurn(ctx: Context, options: FixtureTurnOptions): Promise { - const agent = onlyRootAgent(ctx) + const agent = await onlyRootAgent(ctx) await agent.whenIdle() const message = createUserMessage({ diff --git a/packages/test-support/loader-smoke/tests/agent-turn.spec.ts b/packages/test-support/loader-smoke/tests/agent-turn.spec.ts index 4f04efc303..d5a83fb6e2 100644 --- a/packages/test-support/loader-smoke/tests/agent-turn.spec.ts +++ b/packages/test-support/loader-smoke/tests/agent-turn.spec.ts @@ -54,11 +54,33 @@ describe('runFixtureTurn', () => { ['no agent registry', undefined, 0], ['multiple roots', { roots: () => [{}, {}] }, 2], ])('rejects %s', async (_label, registry, count) => { - const ctx = { get: () => registry } as unknown as Context + const ctx = { get: () => registry, on: () => () => {} } as unknown as Context await expect(runFixtureTurn(ctx, { task: 'ignored' })) .rejects.toThrow(`fixture turn requires exactly one top-level agent, found ${count}`) }) + it('waits for the configured agent to publish before requiring it', async () => { + // Configured agents publish asynchronously, so an initially empty registry + // waits for agent/created instead of rejecting. + const roots: object[] = [] + let created: (() => void) | undefined + const dispose = vi.fn() + const ctx = { + get: (name: string) => name === 'agents' ? { roots: () => [...roots] } : undefined, + on: (name: string, callback: () => void) => { + if (name === 'agent/created') created = callback + return dispose + }, + } as unknown as Context + const pending = runFixtureTurn(ctx, { task: 'ignored' }) + // Publication with a second root still fails the exactly-one requirement, + // proving the count is re-checked after the wait. + roots.push({}, {}) + created?.() + await expect(pending).rejects.toThrow('fixture turn requires exactly one top-level agent, found 2') + expect(dispose).toHaveBeenCalledOnce() + }) + it('observes only the owned interval and returns its final text and deduplicated usage', async () => { const harness = turnHarness() const observed: SessionEvent[] = [] diff --git a/packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts b/packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts index 5c38967a12..7baaddb57a 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts @@ -13,7 +13,7 @@ export const inject = ['agents', 'sessionPersistence', 'subagents'] * - `PLACEHOLDER_CHILD_ID` in a scripted `send_message` is remapped to the real * child so both follow-ups queue onto the same live inbox in FIFO order. * - The unknown-id `send_message` (`UNKNOWN_CHILD_ID`) resolves through a - * persistence load fenced behind both accepted follow-ups, so the transcript + * persistence stat fenced behind both accepted follow-ups, so the transcript * records the same order on every runner. * - The child's final continuation turn fails its durability checkpoint with a * fixed message, so the scenario proves child-first disposal survives a failed @@ -35,7 +35,7 @@ export function apply(ctx: Context): void { let parentClosed = false const publishedFailure = process.env.DSH_SUBAGENT_PUBLISHED_FAILURE === '1' const persistence = ctx.sessionPersistence - const load = persistence.load.bind(persistence) + const stat = persistence.stat.bind(persistence) const agents = ctx.agents const create = agents.create.bind(agents) @@ -56,13 +56,13 @@ export function apply(ctx: Context): void { // The unavailable-child lookup is real asynchronous I/O. Fence it behind both // authored follow-ups so runner speed cannot reorder the exact log. - persistence.load = async (id) => { + persistence.stat = async (id, options) => { if (id === UNKNOWN_CHILD_ID) await followupsAccepted.promise - return load.call(persistence, id) + return stat(id, options) } ctx.effect(() => () => { agents.create = create - persistence.load = load + persistence.stat = stat followupsAccepted.resolve(undefined) parentTurnClosed.resolve(undefined) }, 'subagent snapshot ordering') diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 2ca91bb10f..696a1ef99e 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -60,7 +60,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Plan recorded.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan a two-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) @@ -88,7 +88,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Done planning.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' }) + const agent = await ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan then update' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts index 686c342ad5..ed67dccc0a 100644 --- a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts @@ -42,7 +42,7 @@ async function setup(script: Script) { await ctx.plugin(spawn, { providerName: 'spawn' }) await ctx.plugin(WorkerThreadWorkflowEngine, {}) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter } } diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 87edb28cad..5e96de7ee4 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -126,11 +126,11 @@ export class WorkspaceRegistry extends Service { await this.recoverPendingMutation() this.validateStoredState(this.state) if (!this.state.initialized) { - const headers = await this.ctx.sessionPersistence.list() + const headers = await this.listStoredHeaders() await this.replaceHeaderIndex(headers) await this.bootstrap(headers) } else if (this.table.size > 0) { - await this.replaceHeaderIndex(await this.ctx.sessionPersistence.list()) + await this.replaceHeaderIndex(await this.listStoredHeaders()) } await this.indexLiveSessions() @@ -263,7 +263,7 @@ export class WorkspaceRegistry extends Service { private async sessionKnown(id: SessionId): Promise { if (this.ctx.get('sessions')?.get(id) !== undefined) return true if (this.headers.has(id)) return true - await this.indexHeaders(await this.ctx.sessionPersistence.list()) + await this.indexHeaders(await this.listStoredHeaders()) return this.headers.has(id) } @@ -589,6 +589,12 @@ export class WorkspaceRegistry extends Service { } } + /** Every stored session's header, projected from the persistence snapshot listing. */ + private async listStoredHeaders(): Promise { + const snapshots = await this.ctx.sessionPersistence.list() + return snapshots.map(snapshot => snapshot.header) + } + private async indexLiveSessions(): Promise { const sessions = this.ctx.get('sessions') if (sessions === undefined) return @@ -621,7 +627,7 @@ export class WorkspaceRegistry extends Service { const cached = this.headers.get(id) if (cached !== undefined) return cached - const headers = await this.ctx.sessionPersistence.list() + const headers = await this.listStoredHeaders() await this.indexHeaders(headers) const header = this.headers.get(id) if (header === undefined) { diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 49348cea2a..27571591c4 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -9,6 +9,8 @@ import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' import WorkspaceRegistry, { WorkspaceId, @@ -46,10 +48,11 @@ async function harness(options: HarnessOptions = {}) { ctx.provide('storageDomain', facility) let listed = options.sessions ?? [] - const list = vi.fn(async () => listed) - const load = vi.fn(() => { throw new Error('event bodies must not be loaded') }) - const inspect = vi.fn(() => { throw new Error('event bodies must not be inspected') }) - ctx.provide('sessionPersistence', { list, load, inspect } as never) + const list = vi.fn(async (): Promise => + listed.map(header => ({ header, revision: SessionPersistenceRevision(`rev-${header.id}`) }))) + const open = vi.fn(() => { throw new Error('event bodies must not be opened') }) + const stat = vi.fn(() => { throw new Error('per-session stat must not be needed') }) + ctx.provide('sessionPersistence', { list, open, stat } as never) if (options.sessionStore === true) { await ctx.plugin(SessionStore) @@ -74,8 +77,8 @@ async function harness(options: HarnessOptions = {}) { changes, initChanges, list, - load, - inspect, + open, + stat, setSessions: (headers: SessionHeader[]) => { listed = headers }, } } @@ -192,7 +195,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { expect(ctx.get('workspaceRegistry')).toBeUndefined() expect(pool.media.has('workspace')).toBe(false) - const list = vi.fn(async () => [] as SessionHeader[]) + const list = vi.fn(async () => [] as SessionPersistenceSnapshot[]) ctx.provide('sessionPersistence', { list } as never) await fiber.await() expect(ctx.workspaceRegistry.list()).toEqual([]) @@ -220,8 +223,8 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { }) expect(result.list).toHaveBeenCalledTimes(1) - expect(result.load).not.toHaveBeenCalled() - expect(result.inspect).not.toHaveBeenCalled() + expect(result.open).not.toHaveBeenCalled() + expect(result.stat).not.toHaveBeenCalled() expect(result.registry.list().map(workspace => workspace.path)).toEqual([newer, older]) expect(result.registry.list().map(workspace => workspace.sessionIds)).toEqual([ ['newer-only'], @@ -491,8 +494,8 @@ describe('WorkspaceRegistry create and lookup', () => { expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false) await expect(realpath(dir)).resolves.toBe(dir) expect(result.list).toHaveBeenCalledTimes(1) - expect(result.load).not.toHaveBeenCalled() - expect(result.inspect).not.toHaveBeenCalled() + expect(result.open).not.toHaveBeenCalled() + expect(result.stat).not.toHaveBeenCalled() const reregistered = await result.registry.create(dir) expect(reregistered.id).not.toBe(workspace.id) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4521b1bbf4..91d39d1577 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5600,9 +5600,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl @@ -5652,9 +5649,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl @@ -7616,9 +7610,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e0f068e0d7..8290725faa 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -411,13 +411,17 @@ export const LINK_MAP: Readonly> = { CreateSessionOptions: 'persistence.md', PrepareSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', - SessionInspection: 'persistence.md', - SessionEventSuffix: 'persistence.md', - BorrowedSessionSource: 'persistence.md', SessionLocation: 'persistence.md', SessionPreparation: 'persistence.md', + SessionAccess: 'persistence.md', + SessionHandle: 'persistence.md', + SessionPersistenceCreateOptions: 'persistence.md', + SessionPersistenceOpenOptions: 'persistence.md', + SessionPersistenceStatOptions: 'persistence.md', + SessionPersistenceListOptions: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', - SessionRawArtifact: 'persistence.md', + SessionInspection: 'persistence.md', + SessionStorageMetadata: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', SandboxMode: 'sandbox.md', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 63054fd183..11702c568a 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -51,6 +51,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'], + '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'], } as const satisfies HostDependencyExports /** Exact import specifier to reviewed runtime exports. */ diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7def55bbee..a2ad93dd52 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -545,6 +545,11 @@ "source": "packages/core/session/src/index.ts", "projection": "public-api" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionHandle", + "source": "packages/session/session-persistence/src/handle.ts" + }, { "doc": "docs/subsystems/persistence.md", "symbol": "SessionHeader", @@ -576,30 +581,10 @@ "source": "packages/core/session/src/preparation.ts", "projection": "public-api" }, - { - "doc": "docs/subsystems/persistence.md", - "symbol": "SessionStorageMetadata", - "source": "packages/session/session-persistence/src/index.ts" - }, - { - "doc": "docs/subsystems/persistence.md", - "symbol": "SessionInspection", - "source": "packages/session/session-persistence/src/index.ts" - }, - { - "doc": "docs/subsystems/persistence.md", - "symbol": "SessionEventSuffix", - "source": "packages/session/session-persistence/src/index.ts" - }, { "doc": "docs/subsystems/persistence.md", "symbol": "SessionLocation", - "source": "packages/session/session-persistence/src/index.ts" - }, - { - "doc": "docs/subsystems/persistence.md", - "symbol": "SessionRawArtifact", - "source": "packages/session/session-persistence/src/index.ts" + "source": "packages/session/session-persistence/src/errors.ts" }, { "doc": "docs/subsystems/session-query.md", diff --git a/snapshots/web/navigation-panes/session.jsonl b/snapshots/web/navigation-panes/session.jsonl index cdc6d9536a..eb63fd8bfb 100644 --- a/snapshots/web/navigation-panes/session.jsonl +++ b/snapshots/web/navigation-panes/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{session:1}}","createdAt":1785011380476,"cwd":"{{cwd}}/workspace"} -{"type":"turn/start","data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpc:1}}"}}}} -{"type":"user/message","data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"}},"surfaceOp":"append"} +{"type":"turn/start","data":{"turn":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"NavScenario: first run bash to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -18,13 +18,13 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:2}}"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false}],"source":{"kind":"tool","callId":"call_00_kFKHaEXcTYEex0iDZw0C2432"},"id":"{{message:3}}"}},"sourceEventSeqs":[134],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"{{cwd}}/workspace/nav-a.md\nfile\n\n1: # alpha nav\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[136],"surfaceOp":"append"} -{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"{{cwd}}/workspace/nav-b.md\nfile\n\n1: # beta nav\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[137],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"{{cwd}}/workspace/nav-a.md\nfile\n\n1: # alpha nav\n\n(End of file - total 1 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212"},"id":"{{message:4}}"}},"sourceEventSeqs":[136],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"{{cwd}}/workspace/nav-b.md\nfile\n\n1: # beta nav\n\n(End of file - total 1 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224"},"id":"{{message:5}}"}},"sourceEventSeqs":[137],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -35,11 +35,11 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST_DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:6}}"}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpc:1}}"}}}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"}},"surfaceOp":"append"} +{"type":"turn/start","data":{"turn":2}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[125,23,1,0,0,88,0,0,5,0,1,0,0,7,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," specific"," format","."," Let"," me"," do"," that","."]}} @@ -49,6 +49,6 @@ {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:8}}"}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/pwsh-terminal/session.jsonl b/snapshots/web/pwsh-terminal/session.jsonl index 443a468d25..b417e1110b 100644 --- a/snapshots/web/pwsh-terminal/session.jsonl +++ b/snapshots/web/pwsh-terminal/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{session:1}}","createdAt":1784974100747} -{"type":"turn/start","data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpc:1}}"}}}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Run a PowerShell command that fails, then stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"}},"surfaceOp":"append"} +{"type":"turn/start","data":{"turn":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Run a PowerShell command that fails, then stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Run a PowerShell command","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -12,8 +12,8 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Run the failing pwsh command."},{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Run the failing pwsh command."},{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:2}}"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","content":[{"type":"text","text":"[stderr]\nGet-Item : Cannot find path 'missing.txt' because it does not exist.\n[exit code: 1]"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_pwsh_fail_0001","content":[{"type":"text","text":"[stderr]\nGet-Item : Cannot find path 'missing.txt' because it does not exist.\n[exit code: 1]"}],"isError":false}],"source":{"kind":"tool","callId":"call_pwsh_fail_0001"},"id":"{{message:3}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/seeded-history/session.jsonl b/snapshots/web/seeded-history/session.jsonl index 445758f663..f1b1298e79 100644 --- a/snapshots/web/seeded-history/session.jsonl +++ b/snapshots/web/seeded-history/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{session:1}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"} -{"type":"turn/start","data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpc:1}}"}}}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"}},"surfaceOp":"append"} +{"type":"turn/start","data":{"turn":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -15,11 +15,11 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:2}}"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"} -{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[66],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"call_00_OsndvlcKnCcUmae7QXal8633"},"id":"{{message:3}}"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"id":"{{message:4}}"}},"sourceEventSeqs":[66],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -31,6 +31,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:5}}"}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}