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 a72d46d4f6..c3f4068862 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: 137b2b01126214629952812f3dd3b71985a3acda -2026-06-14-session-persistence.zh.md: 0f00902f5d6d60073bb56aabaf420bf2042e08fc +2026-06-14-session-persistence.md: 00e129e57c7144fd62eec26f5854ee21dec4e964 +2026-06-14-session-persistence.zh.md: 1c98d5771819e8776d9f5cae4a147d2016ee5978 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 137b2b0112..00e129e57c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -14,23 +14,23 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is an abstract **capability seam** ([capability seams](2026-06-13-capability-seams.md), the `dsh-bash` template), not loop or core logic: -1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`. 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: `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. 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 choices recorded here because they are durable, contested, and surprising: -- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but `load` reconstructs 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 the load-validation `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, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. -- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation. +- **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. +- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), and reads use SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, logical interrupted-turn closure, single committed repair, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation. - **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, JSONL validates the decoded header, and SQLite stores it in a strict `INTEGER` column. 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 seam 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 })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. 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 })` 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. ## 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 line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; `load` rejects 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)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. +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)). Stated honestly: 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 later. ## Consequences -Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open. +Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open. 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 0f00902f5d..1c98d57718 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,23 +14,23 @@ Status: implemented 持久化是一个抽象的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-bash` 模板),而非循环或核心逻辑: -1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 +1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md)是默认物理编码,也可通过配置使用原始行。 以下关键选择记录于此,因为它们长期有效、存在争议且出人意料: -- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但 `load` 会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 -- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。 +- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些 closer;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),读取使用 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、逻辑关闭中断轮次、修复只提交一次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(冷准备时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 ## 后果 -新增两个包,以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端可在同一接口下替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。 +新增两个包,以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端可在同一接口下替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 061512da0d..7216c38d38 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/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: 4632351a6f39c44c9ba8af58d508d4665b9e9279 -2026-06-18-shared-persistence-write-coordinator.zh.md: f5a70d7d6e7ab76663620ca8d416c671f81e2f8f +2026-06-18-shared-persistence-write-coordinator.md: 66b73b60ceec9497f1f1226747b8cebd831eb426 +2026-06-18-shared-persistence-write-coordinator.zh.md: 424ce6ec7384e8af7b979a29f58c31379a1d1850 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 4632351a6f..66b73b60ce 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -10,9 +10,9 @@ English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) ## Decision -Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its stateful public methods (`create`/`append`/`load`/`inspect`) to it. Backend-owned metadata and revision listing bypass the coordinator. +Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its stateful public methods (`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`) to it. Backend-owned metadata and revision listing bypass the coordinator. -Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including the non-mutating `inspect` contract used by read models. +Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including immutable logical inspection and the default preparation fallback through `load`. The coordinator holds one controller for each exact live `Session`; the controller combines initialization, pending events, and the shared flush promise. Each `session/event` starts an eager drain, and `session/flush` observes quiescence rather than initiating the ordinary write path. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns this lifecycle. @@ -23,9 +23,9 @@ The coordinator retires a session from `session/disposed`: it waits for the cont Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. -- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. +- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Preparation, logical load/inspection, physical suffix reads, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. - `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). -- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). +- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `prepare`/`load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `list()` — list all stored metadata. - `close?()` — optional lifecycle teardown (SQLite closes its db handle; JSONL omits it), awaited in the dispose effect AFTER the quiescence drain so a close failure never masks a drain error. @@ -35,7 +35,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t ## Testing -The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` leaves interrupted logs and revisions unchanged before `load` performs recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. Coordinator-specific tests cover eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker. +The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. `persistence.spec.ts` and `preparations.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker. ## Alternatives considered @@ -44,4 +44,4 @@ The shared `runPersistenceContract` (public-API contract) runs for every backend ## Consequences -The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, and non-mutating inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn cannot race a new live owner by committing interruption closers. New backends implement storage primitives rather than copy the eager write lifecycle. +The coordinator adds one indirection, an opaque torn marker, detached session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, preparation, and immutable inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn does not commit interruption closers; the [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. New backends implement storage primitives rather than copy the eager write lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index f5a70d7d6e..424ce6ec73 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`load`/`inspect`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 +将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 -组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括供读模型使用、不修改状态的 `inspect` 契约。 +组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 协调器为每个存活的 `Session` 实例持有一个控制器;该控制器统合初始化、待处理事件与共享 flush promise。每个 `session/event` 都会立即启动排空,而 `session/flush` 只观察完全停稳,不会发起常规写入路径。[flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义该生命周期。 @@ -23,9 +23,9 @@ Status: implemented 五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 -- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 +- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 - `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——二者之间发生崩溃时,不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 -- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 +- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `prepare`/`load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 - `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await,因此 close 失败不会掩盖排空错误。 @@ -35,7 +35,7 @@ Status: implemented ## 测试 -共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明在 `load` 执行恢复之前,`inspect` 会保持被中断的日志与修订版本不变。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空,以及崩溃尾部修复。协调器专属测试覆盖立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。 +共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。 ## 曾考虑的替代方案 @@ -44,4 +44,4 @@ Status: implemented ## 后果 -协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查与不修改状态的检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时,不会因提交中断 closers 而与新的存活所有者产生竞态。新后端只需实现存储原语,而无需复制立即写入生命周期。 +协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断 closers;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制立即写入生命周期。 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 d63e6d4b88..acb9d72ef1 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: 287ec94a91101850e9343d36ffd27870daf1333b -2026-07-19-zstandard-jsonl-session-logs.zh.md: 4e578432640651de1eb1977229b7cdd462766c24 +2026-07-19-zstandard-jsonl-session-logs.md: 93fc20f931c75552352834b9340e7d38680d4254 +2026-07-19-zstandard-jsonl-session-logs.zh.md: 061d7fcb55c775eed10e99bae47777d32cc8eee1 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 287ec94a91..93fc20f931 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 @@ -28,7 +28,7 @@ First materialization compresses the two initial frames before opening the tempo ### Read, listing, and crash recovery -A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are decompressed independently and sequentially with Node's default `ZSTD_e_end`, which requires frame completion and validates their checksums, and their plaintext is passed to the existing JSONL scanner. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects. +A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are independently checksum-validated and passed through the [large-session restore pipeline](2026-08-05-large-session-jsonl-restore-pipeline.md), which owns decoder reuse, cooperative yielding, and incremental JSONL scanning. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects. 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. @@ -53,5 +53,5 @@ The shared persistence and coordinator contracts run against both encodings. Bac - Ordinary session roots store `.jsonl.zstd` and retain append-only, fsync, rollback, and interrupted-turn recovery semantics. - Raw JSONL remains a deliberate configuration, but changing encoding requires a fresh/separate root or selecting the mode that matches existing artifacts. - One frame per durable batch adds bounded framing/checksum overhead and allows header-only listing plus repair from an exact append boundary. -- External tools must understand concatenated Zstandard frames or consume raw-mode artifacts; generic one-shot Node decompression reads only the first independent frame, so backend reads walk frames explicitly. +- External tools must understand concatenated Zstandard frames or consume raw-mode artifacts; generic one-shot Node decompression reads only the first independent frame, so backend reads walk frames through the [restore pipeline](2026-08-05-large-session-jsonl-restore-pipeline.md). - The implementation depends on Node's experimental built-in Zstandard API without an npm dependency; the supported-version compatibility gate makes drift visible. 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 4e57843264..061d7fcb55 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 @@ -28,7 +28,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量 ### 读取、列举与崩溃恢复 -帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。后端使用 Node 默认的 `ZSTD_e_end` 独立且按顺序解压完整帧;该模式要求帧完整并验证各帧校验和,再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。 +帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。完整帧会独立验证校验和,再进入[大型会话恢复流水线](2026-08-05-large-session-jsonl-restore-pipeline.md);该流水线负责复用解码器、协作式让出事件循环和增量扫描 JSONL。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。 列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。 @@ -53,5 +53,5 @@ CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配 - 普通会话根目录存储 `.jsonl.zstd`,并保留仅追加、fsync、回滚与中断轮次恢复语义。 - 原始 JSONL 仍是显式配置,但切换编码需要使用全新或单独根目录,或者选择与既有产物匹配的模式。 - 每个持久批次一个帧会增加有界的帧与校验和开销,同时支持仅头部列举和从精确追加边界开始修复。 -- 外部工具必须理解串联的 Zstandard 帧,或者消费原始模式产物;Node 通用的一次性解压只读取第一个独立帧,因此后端读取会显式遍历各帧。 +- 外部工具必须理解串联的 Zstandard 帧,或者消费原始模式产物;Node 通用的一次性解压只读取第一个独立帧,因此后端读取会通过[恢复流水线](2026-08-05-large-session-jsonl-restore-pipeline.md)遍历各帧。 - 实现依赖 Node 的实验性内置 Zstandard API,但不增加 NPM 依赖;受支持版本兼容性门禁会暴露 API 漂移。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml new file mode 100644 index 0000000000..8a4b3606a8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md +2026-08-05-large-session-jsonl-restore-pipeline.md: eab53c683880ef7095233ed8122e532eb5add547 +2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 039e0c193179677b57e742d55f4c7df6bdff852f diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md new file mode 100644 index 0000000000..eab53c6838 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md @@ -0,0 +1,52 @@ +# Agent Note: Large-session JSONL restore pipeline + +Status: implemented + +English | [中文](2026-08-05-large-session-jsonl-restore-pipeline.zh.md) + +## Problem + +Restoring a stored session activates it and materializes its complete authoritative event log before the agent can run. Large JSONL artifacts made that one-time operation pay several avoidable costs: each independent Zstandard frame created and closed a decoder context, decoded plaintext was accumulated and rescanned as whole-log buffers and strings, and freshly parsed events went through generic snapshot and deep-freeze paths designed for borrowed or cyclic values. + +A representative profile contained 61.8 MiB of Zstandard data, 97.1 MiB of plaintext, and 1,307,073 events. The restore path must reduce its CPU and memory cost without weakening checksum validation, committed-region corruption detection, torn-tail recovery, sequence and surface validation, or the session log's immutability. + +## Decision + +Restoration is one ownership-transfer pipeline from the persistence artifact into `Session.fromRestore`. The compressed artifact remains the source buffer, while each decoding and scanning stage consumes the previous stage's output incrementally without retaining a whole-log plaintext or parsed copy; the resulting event array is the only complete decoded representation. + +### Frame decoding + +The structural Zstandard scanner identifies complete frame ranges before decoding. The dedicated first frame is decoded and parsed separately as the session header; subsequent plaintext frames are yielded in order into the JSONL scanner. + +`ZstdFrameDecoder` gives the reader one lifecycle for interchangeable synchronous implementations. The preferred implementation probes the supported Node 22, 24, and 26 stream shape, reuses one private native decoder context and scratch buffer across all complete frames, and closes it once. If that private shape is unavailable, the factory selects a public `zstdDecompressSync` implementation with the same iterator and checksum-error contract. A yielded scratch view is consumed before the iterator advances. + +After approximately 500 ms of accumulated frame work, the asynchronous reader yields at the next frame boundary and observes cancellation before continuing. A single frame remains an indivisible synchronous operation. Complete frames require end-of-frame and checksum validation; only a structurally incomplete final frame uses the existing prefix decoder for recovery. + +### Incremental JSONL scanning + +`SessionLogScanner` searches raw buffers with `Buffer.indexOf(0x0A)` and converts only complete records to UTF-8 for `JSON.parse`. It carries an incomplete record across decoder writes and copies only that fragment because the private decoder may reuse its output buffer. It does not build a whole plaintext buffer or string, a line array, or a second parsed-record array. + +The scanner stops retaining events at the first unparsable row or sequence gap but continues inspecting later complete records. A later `turn/end` proves that the issue lies in the committed region and rejects the log. The Zstandard reader also rejects any unresolved parse, sequence, or partial-record issue after all complete frames; only a structurally torn final frame may contribute a recoverable suffix. Complete records emitted from that torn frame pass through the same scanner and retain the existing repair offset and recovered-event semantics. + +### Restore admission + +Persistence transfers freshly materialized JSON values to `Session.fromRestore`. These values are detached, acyclic trees, and packed chunk rows expand into newly allocated events, so the restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice. + +Borrowed seeds used by ordinary creation and fork paths still take a JSON snapshot and use the generic cycle-safe deep freeze. The specialization therefore changes only durable restoration; it does not weaken acceptance for caller-owned values. + +## Alternatives considered + +- **One asynchronous native operation per frame** — rejected because dispatch and callback overhead dominates logs containing many small durable batches. Cooperative synchronous decoding pays that overhead only at periodic yield boundaries. +- **Process the complete log synchronously without yielding** — rejected because it prevents cancellation and event-loop progress for the full restore duration. Frame-boundary yields retain a bounded observation point without splitting codec operations. +- **Concatenate all plaintext before scanning** — rejected because it retains the compressed input, complete plaintext, whole-log UTF-8 string, line metadata, and parsed rows at the same time, and it rescans a torn-frame prefix. +- **Implement a streaming JSON parser** — rejected because JSONL already provides record boundaries; native newline search plus `JSON.parse` removes the large intermediates without owning another parser or changing JSON semantics. +- **Use a shared `WeakSet` while freezing restored events** — rejected because JSON materialization cannot produce cycles, and the set adds a lookup per object while retaining the complete graph during traversal. +- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and `Session.events` promises immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. + +## Consequences + +On the representative profile, incremental scanning reduced JSONL scan time from about 598 ms to 397 ms and peak RSS from about 1,494 MiB to 1,060 MiB. Restore admission reduced `Session.fromRestore` from 604–608 ms to about 263 ms, including an `assertSessionEventEnvelope` reduction from about 77 ms to 13 ms. These measurements characterize the optimization input rather than establish runtime limits. + +The fast decoder depends on runtime-probed Node internals, but incompatibility selects the public implementation rather than changing correctness. Cancellation is observed around cooperative frame-boundary yields; the deadline is not a hard wall-clock bound inside one frame. The complete event array remains resident because it is the active session's authoritative log; the pipeline removes duplicate representations rather than paginating that state. + +Tests force both decoder implementations, compare their frame order and corruption behavior, exercise cooperative cancellation and torn-tail recovery, and retain the existing session envelope, surface, and immutability contracts. diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md new file mode 100644 index 0000000000..039e0c1931 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 大型会话 JSONL 恢复流水线 + +Status: implemented + +[English](2026-08-05-large-session-jsonl-restore-pipeline.md) | 中文 + +## 问题 + +恢复已存储会话会激活该会话,并在 agent(智能体)运行前物化完整且权威的事件日志。处理大型 JSONL 产物时,这个一次性操作会产生几项不必要的开销:每个独立 Zstandard 帧都会创建并关闭一个解码上下文;解码后的明文会汇总成整份日志的缓冲区和字符串,再进行重复扫描;刚解析出的事件还会进入面向借用值或循环引用值设计的通用快照与深度冻结路径。 + +一份代表性性能剖析包含 61.8 MiB Zstandard 数据、97.1 MiB 明文和 1,307,073 个事件。恢复路径必须降低 CPU 与内存开销,同时保持校验和验证、已提交区域损坏检测、撕裂尾部恢复、序列与 `surface` 校验,以及会话日志不可变性。 + +## 决策 + +恢复过程是一条从持久化产物进入 `Session.fromRestore` 的所有权转移流水线。压缩产物仍作为源缓冲区驻留,但解码与扫描阶段会增量消费上一阶段的输出,不会保留整份日志的明文或解析副本;最终事件数组是唯一完整的已解码表示。 + +### 帧解码 + +Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解码专用首帧并将其解析为会话头部,后续明文帧则按顺序产出并送入 JSONL 扫描器。 + +`ZstdFrameDecoder` 为可互换的同步实现提供统一生命周期。首选实现会探测受支持 Node 22、24 与 26 的流结构,在所有完整帧之间复用一个私有原生解码上下文和临时缓冲区,最后只关闭一次。如果私有结构不可用,工厂会选择使用公共 `zstdDecompressSync` 的实现,并保持相同的迭代器和校验和错误契约。迭代器产出的临时视图会在进入下一次迭代前被消费。 + +累计帧处理时间约达 500 ms 后,异步读取器会在下一帧边界让出事件循环,并在继续前观察取消信号。单个帧仍是不可分割的同步操作。完整帧必须通过帧结束与校验和验证;只有结构上不完整的最终帧才使用既有前缀解码器进行恢复。 + +### 增量 JSONL 扫描 + +`SessionLogScanner` 使用 `Buffer.indexOf(0x0A)` 在原始缓冲区中查找换行,只把完整记录转换为 UTF-8 并交给 `JSON.parse`。扫描器会跨解码写入保留不完整记录;由于私有解码器可能复用输出缓冲区,它只复制这个片段。扫描过程不会构造整份明文缓冲区或字符串,也不会构造行数组或第二份解析记录数组。 + +扫描器在遇到第一条无法解析的记录或序列缺口后停止保留事件,但会继续检查后续完整记录。后续出现 `turn/end`,说明问题位于已提交区域,系统会拒绝该日志。处理完所有完整帧后,如果仍存在未决的解析错误、序列错误或部分记录,Zstandard 读取器同样会拒绝日志;只有结构上撕裂的最终帧才能提供可恢复后缀。该撕裂帧产出的完整记录会经过同一扫描器,并保持既有修复偏移量与恢复事件语义。 + +### 恢复准入 + +持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in` 与 `switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并通过显式 `pending` 数组迭代冻结所拥有的对象图,不使用循环跟踪集合。`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。 + +普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。 + +## 考虑过的替代方案 + +- **每帧执行一次异步原生操作**:不予采纳,因为对于包含大量小型持久化批次的日志,调度与回调开销占据主要部分。协作式同步解码只在周期性让出边界支付这类开销。 +- **同步处理完整日志且不让出事件循环**:不予采纳,因为整个恢复期间都无法响应取消或推进事件循环。帧边界让出机制无需拆分编解码操作,就能保留有界的观察点。 +- **扫描前拼接全部明文**:不予采纳,因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录,并会重新扫描撕裂帧前缀。 +- **实现流式 JSON 解析器**:不予采纳,因为 JSONL 已提供记录边界;使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构,无需自行维护另一套解析器或改变 JSON 语义。 +- **冻结恢复事件时共享一个 `WeakSet`**:不予采纳,因为 JSON 物化不可能产生循环引用,而该集合会对每个对象增加一次查找,并在遍历期间保留完整对象图。 +- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 `Session.events` 承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 + +## 后果 + +在代表性性能剖析中,增量扫描将 JSONL 扫描时间从约 598 ms 降至 397 ms,峰值 RSS 从约 1,494 MiB 降至 1,060 MiB。恢复准入将 `Session.fromRestore` 从 604–608 ms 降至约 263 ms,其中 `assertSessionEventEnvelope` 从约 77 ms 降至 13 ms。这些数据用于描述优化输入,不构成运行时上限。 + +快速解码器依赖运行时探测的 Node 内部接口,但接口不兼容时会改用公共实现,不会改变正确性。系统会在协作式帧边界让出点观察取消信号;截止时间并不是单个帧内部严格的挂钟时间上限。完整事件数组仍会驻留内存,因为它是活跃会话的权威日志;该流水线移除的是重复表示,并未对这份状态做分页。 + +测试会强制执行两种解码器实现,比对帧顺序和损坏处理行为,覆盖协作式取消与撕裂尾部恢复,并保留既有会话信封、`surface` 与不可变性契约。 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 new file mode 100644 index 0000000000..09e63a6a1c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-session-preparation.md +2026-08-05-session-preparation.md: 69d39f552ed3041403a24b5aefb435e4e721b09c +2026-08-05-session-preparation.zh.md: a0ca27eb63552566c918c299bd5fba976687812c diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md new file mode 100644 index 0000000000..69d39f552e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md @@ -0,0 +1,68 @@ +# Agent Note: Reusable Session preparation before publication + +Status: implemented + +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 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. + +## 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()`. + +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-seams.md) without replacing its ownership model. + +## Persisted 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. + +## 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. + +## 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. + +## 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. + +**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. + +**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. + +## 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. 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 new file mode 100644 index 0000000000..a0ca27eb63 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md @@ -0,0 +1,68 @@ +# Agent Note: 发布前可复用的 Session 准备阶段 + +Status: implemented + +[English](2026-08-05-session-preparation.md) | 中文 + +## 问题 + +冷历史检查和 agent(智能体)恢复会分别实体化同一份持久会话日志。对于大型压缩日志,每次操作都会重新完整读取、解压、解析、验证、冻结并构造 Session。因此,历史分页可能反复承担冷读成本;如果改为由历史查询激活 agent,读取生命周期又会与缺少自然退出时机的实时 agent 耦合。 + +新建和持久化恢复也通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent。 + +## 决策 + +`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复则从 `SessionPersistence.prepare()` 取得准备对象。 + +agent loop(智能体循环)通过同一条设置与发布流水线消费这两种形式:先取得准备对象,围绕 `preparation.session` 构建私有 agent 上下文,等待可选设置完成,再发布该精确 Session 和 agent,并在所有退出路径上 dispose 准备对象。发布后,实时生命周期由现有 Session 与 agent 存储接管;`SessionPreparation` 本身不负责任何 agent 行为。 + +该机制细化了 [agent 生命周期与所有权决策](2026-06-18-agent-lifecycle-and-ownership-seams.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.md),同时继续遵循[会话持久化决策](2026-06-14-session-persistence.md)所规定的存储与恢复规则。 + +## 历史与恢复复用 + +历史读取使用 `inspect()`,因此重复分页可以借用同一份不可变准备状态,而不会激活 agent。后续恢复调用 `prepare()`,直接取得检查阶段保留的精确 Session;系统不会再次完整读取、解压、解析、复制、验证或冻结日志。 + +如果持久日志在检查后发生变化,其 revision 也会变化。下一次历史读取或恢复会丢弃保留且处于就绪阶段的 Session,并实体化新日志,因此旧事件对象图不会被关联到较新的快照 revision。已经由进行中恢复操作取得的源不会被淘汰:其独占所有者会持有它直至发布或释放,并发历史读取可以借用同一个不可变视图。 + +冷 continuable subagent 访问沿用同一路径。系统先检查子会话并完成 descriptor 授权,再由 `ctx.agents.resume()` 预留并发布保留的 Session。这样既遵循 [continuable subagent 会话决策](../feature/2026-07-28-continuable-subagent-conversations.md)中的生命周期与授权规则,也消除了重复冷读。 + +## 边界 + +- `readFrom()` 仍是脱离的物理后缀 API。它不会创建或消费准备对象,不会合成逻辑 closer,也不会进入 LRU。 +- HMR(热模块替换)接管继续以实时 Session 为权威,并直接读取已存储前缀。它可以截断撕裂的物理碎片,但绝不把实时开放轮次关闭为中断状态。 +- 缓存属于单个持久化协调器,而不是进程全局 Session map。实时 Session 由现有存储持有,绝不占用准备容量。 +- 新建流程绝不认领相同 id 的冷持久化准备对象。持久化冲突仍会被拒绝。 +- 第三方持久化实现继续获得通过 `load()` 实现的抽象 `prepare()` 回退。它们使用相同发布接口,但只有覆盖准备流程后才能复用精确对象。 +- Revision 校验在复用点和修复提交点建立新鲜性,但不会为后端增加跨进程 writer 排他。持久日志在一次读取与复核往返内保持不变后,重试才能收敛,因此持续的外部写入可能延迟准备。 + +## 验证 + +共享持久化契约覆盖无变更且已配平的冷检查与后续修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、在历史读取与恢复前由 revision 触发刷新、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append,以及只允许发布预留 Session。后端测试覆盖完整读取与轻量读取使用同一 revision 身份。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和拆卸期间从检查到恢复的路径。 + +## 考虑过的替代方案 + +**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。 + +**只缓存 `{ meta, events }`。** 不采用,因为恢复仍需从缓存值重新构造、验证、冻结并复制 Session。真正可复用的单元是精确的未发布 Session。 + +**维护进程全局 Session map。** 不采用,因为它会跨越后端和运行时所有权边界,无界保留身份,并与实时 Session 存储重复。 + +**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读、修复、预留和游标附接都属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界。 + +**把 `readFrom()` 改成逻辑准备流程。** 不采用,因为水位消费方需要脱离的物理后缀;对于可寻址后端,还需要限制实际读取范围。恢复平衡与完整 Session 复用具有不同语义。 + +## 后果 + +一次冷实体化可以同时服务历史分页、subagent descriptor 检查和后续恢复。所有权转移去除了恢复阶段的冗余复制;每个协调器的有界 LRU 限制内存占用,也避免查询创建实时 agent。新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离。 + +首次冷检查需要承担完整验证与 Session 构造成本,并可能保留该未发布 Session 直至淘汰。持久化层必须协调预留、append、修复和发布;调用方必须把检查结果视为借用的不可变状态。依赖默认 `prepare()` 的后端仍然正确,但无法获得复用优化。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 0c63a65219..cf171a14c9 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: 4cad393bd9242da6c6b0c0698554f0eee5ecc7d5 -architecture.zh.md: ee51505fb1b2631e0a044a454e9c9a7c3ba16ab2 +architecture.md: af372c2790ac1efaa87b896681ef8502fb4abdae +architecture.zh.md: bf4afd81be112c43748b72d97a01f3cc0a9ae8f7 diff --git a/docs/architecture.md b/docs/architecture.md index 4cad393bd9..af372c2790 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,15 +66,15 @@ Waterfalls are around-middleware: listeners delegate with `next()`; returning wi ## Default Loop Lifecycle -A **session** is append-only. A **turn** claims one queued follow-up, waits for its predecessor's checkpoint, and may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)); injection claims none. A **step** is one model request plus tools. Quotes in the [sequence](agent-lifecycle.md) mark durable events. +A **session** is append-only. A **turn** claims one queued follow-up, waits for its predecessor's checkpoint, and may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)); injection claims none. A **step** is one model request plus tools. Fresh creation and persisted resume first acquire an exact unpublished `SessionPreparation`; Agent and session publication happen only after private setup against that Session is ready ([decision](../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md)). Quotes in the [sequence](agent-lifecycle.md) mark durable events. Creation without an id mints `-session-`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication; setup failure emits `agent-loop/config-start-failed`. ### Turn Flow ```text -choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup +choose declarative identity and acquire fresh/restored SessionPreparation + -> prepare private agent.ctx around exact Session -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ee51505fb1..bf4afd81be 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -66,15 +66,15 @@ waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委 ## 默认循环生命周期 -**会话**采用仅追加方式。一个**轮次**领取一条已排队的后续消息,等待前一轮次的检查点,并可与其共用 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md));注入不领取输入。一个**步骤**包含一次模型请求及其工具。[时序](agent-lifecycle.md)中的引号标记持久事件。 +**会话**采用仅追加方式。一个**轮次**领取一条已排队的后续消息,等待前一轮次的检查点,并可与其共用 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md));注入不领取输入。一个**步骤**包含一次模型请求及其工具。新建与持久化恢复会先取得精确的未发布 `SessionPreparation`;只有基于该 Session 的私有设置准备完毕后,系统才会发布 agent 与会话([决策](../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md))。[时序](agent-lifecycle.md)中的引号标记持久事件。 创建时若未提供 id,流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度;初始化失败会发出 `agent-loop/config-start-failed`。 ### 轮次流程 ```text -choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup +choose declarative identity and acquire fresh/restored SessionPreparation + -> prepare private agent.ctx around exact Session -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d28427e531..948999e161 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:212`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -1135,13 +1135,15 @@ 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 } /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:58`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -1167,6 +1169,8 @@ export interface Config { * (network mounts). See {@link JournalMode}. */ journalMode?: JournalMode + /** Maximum cold Session preparations retained for history-to-resume reuse. */ + preparedSessionCacheSize?: number } /** @@ -1180,7 +1184,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:66`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 49cb965277..5044b0e5ce 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -297,7 +297,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:158`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:182`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` @@ -535,7 +535,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:71`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:73`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -556,7 +556,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:81`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -579,7 +579,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:95`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -599,7 +599,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) ## `settings/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3f39188896..5913c244b8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise abstract append(id: SessionId, events: readonly SessionEvent[]): Promise /** - * Load a header and balanced contiguous log. 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 with its stored header as a durable snapshot, while an - * open live turn rejects. - * A coordinator-backed cold load reserves the identity across storage awaits, - * so concurrent publication of a same-id live Session rejects. - * Returned events are detached, and every identified message is deeply - * frozen. Coordinator-backed implementations upgrade supported pre-identity - * message events before validation; other malformed messages reject before - * any stored event is returned. + * 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<{ meta: SessionHeader; events: SessionEvent[] }> +abstract load(id: SessionId): Promise /** - * Inspect a header and its valid contiguous stored prefix without repairing - * a torn tail, closing an interrupted turn, or publishing coordinator state. - * This read is serialized with writes for the same id and returns detached - * values with upgraded, deeply frozen identified messages, so observers - * cannot mutate message identity/content or backend-owned state. Other - * malformed messages reject. + * 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 header and valid stored event prefix exactly as observed. + * @returns the validated header and current logical event log. */ -abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract inspect(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). Like - * {@link inspect} it is non-mutating and detached: no torn-tail truncation, - * no synthetic closers, no coordinator-state publication; only events from - * the valid contiguous stored prefix are returned, so a torn fragment never - * reaches the caller. `fromSeq` at or beyond the stored prefix returns an - * empty event list (never an error). Backends whose medium can seek by seq + * projection cache folding only the tail past its checkpoint). Unlike + * {@link inspect}, it is a detached physical suffix read: no preparation + * cache, torn-tail truncation, synthetic closers, or coordinator-state + * publication. Only events from the valid contiguous stored prefix are + * returned, so a torn fragment never reaches the caller. `fromSeq` at or + * beyond the stored prefix returns an empty event list (never an error). + * Backends whose medium can seek by seq * (SQLite) read only the suffix; sequential media (JSONL, both encodings) * still parse the whole artifact and skip forward — the primitive bounds * what is RETURNED and refolded, not every backend's physical read. @@ -1245,9 +1258,9 @@ abstract list(signal?: AbortSignal): Promise abstract listSnapshots(signal?: AbortSignal): Promise ``` -Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) +Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionInspection](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) · [SessionPreparation](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:70`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionProjectionCache` — `SessionProjectionCache` @@ -1602,13 +1615,17 @@ create(id?: SessionId, options?: CreateSessionOptions): Session * before the driver's closing events commit, dropping them. * * @param id - the session id; omitted, the store mints `session-`. - * @param options - seed events and/or creation metadata for the header. + * @param options - seed events and/or creation metadata for the header. With + * `seedSource: 'persistence'`, metadata and events must be fresh detached + * graphs whose ownership transfers to this call: they are validated and + * frozen in place through {@link Session.fromRestore}, so the caller must + * retain no mutable aliases. * @returns the constructed session, NOT yet in the store. * @throws if a session with `id` already exists, metadata is not a plain * lossless-JSON record with valid scalar fields, or `meta.cwd` is a * non-absolute path. */ -prepare(id?: SessionId, options?: CreateSessionOptions): Session +prepare(id?: SessionId, options?: PrepareSessionOptions): Session /** * Enter a {@link prepare}d session into the store: install the module-private @@ -1688,9 +1705,9 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) +Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:767`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:837`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 0d14fe7899..58321e0f22 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/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/core-data-structures/persistence.md -persistence.md: ed19af4e739c153ce1beec7c1e8de06f0c0bca53 -persistence.zh.md: 83d8ba3c0eabe57b9df661fb86ec05d3db98d8b6 +persistence.md: 0968496201defa869d94925e8e5ae3c5da1bbd37 +persistence.zh.md: efb01427b4355e531fb9b86922223cf27d3b3db0 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index ed19af4e73..0968496201 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -4,7 +4,7 @@ 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 backends, 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 textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, crash-repairing load, non-mutating inspect, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining 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** — and two interchangeable backends implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -14,9 +14,9 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite 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)` snapshots the in-memory log, waits until that snapshot is durable, and returns it with the stored header only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. A coordinator-backed cold load reserves the id across backend reads and repair writes, so concurrent publication of a same-id live session rejects and rolls back. HMR also adopts a live prefix without closing its active turn. +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)` is the observer counterpart to recovery: it returns a detached valid stored prefix without truncating a torn record, adding interruption closers, or publishing write state. Same-id serialization keeps it coherent with backend writes. Derived read models use `inspect`, never `load`, so observing a checkpointed open turn cannot mutate the log if live ownership begins concurrently. +`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 @@ -82,7 +82,7 @@ interface SessionHeader { ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -110,6 +110,72 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; 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. + +```ts type-equiv +/** + * Fresh storage values transferred to {@link SessionStore.prepare} without a + * second serialization copy. Callers retain no mutable aliases. + */ +interface RestoredSessionOptions { + /** Fresh detached storage events to validate and freeze in place. */ + readonly seed: SessionEvent[] + /** Fresh detached storage metadata to validate and freeze in place. */ + readonly meta: SessionHeader + /** Select the persistence ownership-transfer path. */ + readonly seedSource: 'persistence' +} +``` + +```ts type-equiv +/** Inputs accepted while constructing an unpublished Session. */ +type PrepareSessionOptions = + | (CreateSessionOptions & { readonly seedSource?: undefined }) + | RestoredSessionOptions +``` + +```ts type-equiv +/** Options for a preparation whose provider retains unpublished state. */ +interface SessionPreparationOptions { + /** Release provider-owned state when the Session was not published. */ + readonly release?: () => void +} +``` + +```ts public-api +/** + * One exact unpublished Session and the provider state that keeps it usable. + * Disposal is synchronous and idempotent. Providers decide whether release + * returns the Session to a cache or discards it; publication may consume that + * state before disposal, making the callback a no-op. + */ +declare class SessionPreparation implements Disposable { + /** The exact Session to use for setup and publication. */ + readonly session: Session; + /** + * Wrap an unpublished Session in one preparation lifetime. + * @param session - exact unpublished Session. + * @param options - optional provider release behavior. + * @returns a preparation disposed after publication or rollback. + */ + static create(session: Session, options?: SessionPreparationOptions): SessionPreparation; + /** Release provider state once when this preparation leaves its caller. */ + [Symbol.dispose](): void; +} +``` + +```ts type-equiv +/** Immutable logical session prepared from persistence or a live owner. */ +interface SessionInspection { + /** Validated immutable session metadata. */ + readonly meta: SessionHeader + /** Validated contiguous logical event log. */ + 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. @@ -134,7 +200,7 @@ interface SessionPersistenceSnapshot { ## The backends -Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/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-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 83d8ba3c0e..efb01427b4 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -4,7 +4,7 @@ 事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述如何使该日志持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一同存储的元数据头。日志承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐项列举。 -该 seam 是典型的[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、会执行崩溃修复的 load、不会修改数据的 inspect,以及轻量的 list/snapshot 观察——**没有平行的持久化类型**——以及两个实现同一契约的可互换后端。见 [session-persistence Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 +该 seam 是典型的[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、可复用的 Session 准备流程、逻辑 load/inspect、物理后缀读取,以及轻量的 list/snapshot 观察——**没有平行的持久化事件类型**——以及两个实现同一契约的可互换后端。见 [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 ## flush 检查点 @@ -14,9 +14,9 @@ 后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,在不改变其前后任何独立事件的情况下配平被中断的执行。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 -修复仅适用于冷会话。对于活跃 id,`SessionPersistence.load(id)` 会对内存日志拍摄快照,等待该快照完成持久化,并且只在日志平衡时连同已存储的 header 返回;若活跃轮次仍未闭合,则拒绝操作,而不是添加合成的中断边界。由协调器管理的冷加载会在后端读取和修复写入期间占用该 id,因此并发发布同 id 的活跃会话会被拒绝并回滚。HMR 也会接管活跃前缀,而不会关闭其中正在进行的轮次。 +修复仅适用于冷会话。对于活跃 id,`SessionPersistence.load(id)` 会等待权威内存快照完成持久化,并且只在日志平衡时返回;若活跃轮次仍未闭合,则拒绝操作,而不是添加合成的中断边界。HMR 会接管活跃前缀,而不会关闭其中正在进行的轮次。 -`SessionPersistence.inspect(id)` 是恢复机制面向观察方的对等操作:它返回已存储有效前缀的独立副本,不截断不完整记录、不添加中断结束事件,也不发布写入状态。同 id 串行化确保它与后端写入保持一致。派生读取模型使用 `inspect`,绝不使用 `load`,因此即使活跃所有权并发建立,观察已落检查点但仍未闭合的轮次也不会修改日志。 +`SessionPersistence.inspect(id)` 会构造一个不可变的逻辑 Session,但不发布它,也不写入恢复内容。冷检查会在内存中配平中断的 turn,同时保持撕裂的物理尾部不变;检查已经实时存在的 Session 则借用其当前不可变快照,因此可能包含打开的 turn。使用协调器的实现会在有界 LRU 中保留这个精确的冷未发布 Session,因此重复历史读取与后续 `prepare(id)` 可复用同一次读取、解压、验证、冻结及 Session 构造。`prepare(id)` 会预留该 Session、提交待处理修复并返回可 dispose 的发布句柄;`load(id)` 使用相同机制提交修复,但不会发布 Session。该生命周期由 [Session 准备阶段决策](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md)定义。 ## `SessionLocation`——可选的逐会话产物目标 @@ -82,7 +82,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -110,6 +110,72 @@ interface CreateSessionOptions { 因此,回放/fork 的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 +## 准备与恢复所有权 + +`SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的新鲜持久化对象图。恢复分支会直接验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。持久化检查只暴露 `SessionInspection`,即从同一个已准备 Session 借用的不可变逻辑视图。 + +```ts type-equiv +/** + * Fresh storage values transferred to {@link SessionStore.prepare} without a + * second serialization copy. Callers retain no mutable aliases. + */ +interface RestoredSessionOptions { + /** Fresh detached storage events to validate and freeze in place. */ + readonly seed: SessionEvent[] + /** Fresh detached storage metadata to validate and freeze in place. */ + readonly meta: SessionHeader + /** Select the persistence ownership-transfer path. */ + readonly seedSource: 'persistence' +} +``` + +```ts type-equiv +/** Inputs accepted while constructing an unpublished Session. */ +type PrepareSessionOptions = + | (CreateSessionOptions & { readonly seedSource?: undefined }) + | RestoredSessionOptions +``` + +```ts type-equiv +/** Options for a preparation whose provider retains unpublished state. */ +interface SessionPreparationOptions { + /** Release provider-owned state when the Session was not published. */ + readonly release?: () => void +} +``` + +```ts public-api +/** + * One exact unpublished Session and the provider state that keeps it usable. + * Disposal is synchronous and idempotent. Providers decide whether release + * returns the Session to a cache or discards it; publication may consume that + * state before disposal, making the callback a no-op. + */ +declare class SessionPreparation implements Disposable { + /** The exact Session to use for setup and publication. */ + readonly session: Session; + /** + * Wrap an unpublished Session in one preparation lifetime. + * @param session - exact unpublished Session. + * @param options - optional provider release behavior. + * @returns a preparation disposed after publication or rollback. + */ + static create(session: Session, options?: SessionPreparationOptions): SessionPreparation; + /** Release provider state once when this preparation leaves its caller. */ + [Symbol.dispose](): void; +} +``` + +```ts type-equiv +/** Immutable logical session prepared from persistence or a live owner. */ +interface SessionInspection { + /** Validated immutable session metadata. */ + readonly meta: SessionHeader + /** Validated contiguous logical event log. */ + readonly events: readonly SessionEvent[] +} +``` + ## 轻量源修订号 派生状态的消费方会在加载完整事件日志之前比较一个低开销的不透明修订号。其表示由持久化后端拥有,并随 append 或会修改数据的 load 修复以事务方式改变;调用方仅比较修订号是否相等。 @@ -134,7 +200,7 @@ interface SessionPersistenceSnapshot { ## 后端 -两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/load/inspect/list/listSnapshots),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: +两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index ad48bad616..eca4715ba9 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.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/core-data-structures/session-query.md -session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e -session-query.zh.md: 8070dfda61a2945fca554939f65ae0f8b85078db +session-query.md: e7514dd6c3bc20a07395663bff40ce65e1363b78 +session-query.zh.md: 4c3dd4d435dbd8a20fbd4db5da1a7d649c2e6d0b diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index d92af4bac3..e7514dd6c3 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -338,6 +338,7 @@ The closed code union distinguishes request validation, missing targets, malform /** Stable machine-routable failure taxonomy for session reads, traces, and search. */ type SessionQueryErrorCode = | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_CORRUPT_SESSION' | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 8070dfda61..4c3dd4d435 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -338,6 +338,7 @@ interface SessionEventTraceObservation extends SessionEventTrace { /** Stable machine-routable failure taxonomy for session reads, traces, and search. */ type SessionQueryErrorCode = | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_CORRUPT_SESSION' | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 041d941c64..565f7275f6 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.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/core-data-structures/session.md -session.md: 34e5ad8f7836b18412b7d51a6083e638a3908aae -session.zh.md: e607e9bb07581c5753b48104993c75586611b675 +session.md: 30f9d7a92f36b0649ec6d61bb3e69a80b125cc73 +session.zh.md: 80762f097bad5f6ab81f3872df5c8b715109241f diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 34e5ad8f78..30f9d7a92f 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -400,6 +400,16 @@ declare class Session { * @returns a detached session. */ static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; + /** + * Restore a detached session by taking ownership of fresh persistence values. + * Storage shape, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the graphs are frozen in place. + * @param id - restored session identity. + * @param seed - fresh detached events whose ownership is transferred. + * @param header - fresh detached metadata whose ownership is transferred. + * @returns a restored detached session. + */ + static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** * An immutable snapshot of the append-only event log. The snapshot is reused * until the next append; a previously returned array does not grow later. diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index e607e9bb07..80762f097b 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -402,6 +402,16 @@ declare class Session { * @returns a detached session. */ static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; + /** + * Restore a detached session by taking ownership of fresh persistence values. + * Storage shape, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the graphs are frozen in place. + * @param id - restored session identity. + * @param seed - fresh detached events whose ownership is transferred. + * @param header - fresh detached metadata whose ownership is transferred. + * @returns a restored detached session. + */ + static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** * An immutable snapshot of the append-only event log. The snapshot is reused * until the next append; a previously returned array does not grow later. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 22815754eb..2377ca0d69 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ 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:158`](../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:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:187`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:69`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a0790be7ad..caff1e7685 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -77,7 +77,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) ## Events @@ -174,7 +174,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -190,7 +190,7 @@ Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `command/*` @@ -419,7 +419,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -431,7 +431,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -484,7 +484,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -520,7 +520,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -529,7 +529,7 @@ Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:208`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -559,7 +559,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `tool/*` @@ -576,7 +576,7 @@ Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -649,7 +649,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) ### `turn/*` @@ -669,7 +669,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -683,7 +683,7 @@ Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) ### `user/*` @@ -700,7 +700,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index e82f4f04d8..f18e7f5954 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\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 }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n 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 seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\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 }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n 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 seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8dbb049ab6..40b354fce3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -581,16 +581,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Durably persist a batch of events. Honors the append-only and contiguous-\n * seq contracts: the first event\'s `seq` MUST equal the stored next-seq\n * (after `load` has durably closed any interrupted turn). Rejects non-JSON-\n * serializable `event.data` with an error naming the offending event type.\n * @param id - the session the batch belongs to.\n * @param events - the contiguous batch to persist, in seq order.\n */', }, { - signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * Returned events are detached, and every identified message is deeply\n * frozen. Coordinator-backed implementations upgrade supported pre-identity\n * message events before validation; other malformed messages reject before\n * any stored event is returned.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + signature: 'async prepare(id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Prepare the exact unpublished Session used by resume. Implementations may\n * reuse object graphs retained by an earlier {@link inspect} after confirming\n * their durable revision is still current; disposal releases an unpublished\n * reservation. Revision retries require the durable log to remain unchanged\n * for one read/check round trip; continuous external writers may delay completion.\n * @param id - persisted session to prepare.\n * @param signal - optional cancellation for preparation work.\n * @returns one owned unpublished Session preparation.\n */', }, { - signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', + signature: 'abstract load(id: SessionId): Promise', + jsDoc: '/**\n * Load an immutable balanced logical view and commit any required cold\n * recovery. A complete interrupted final turn is preserved and durably\n * closed with missing tool errors plus any open step and turn boundaries;\n * only a torn final record is discarded. Unknown versions and corruption in\n * the committed prefix reject. Implementations MUST NOT crash-repair an\n * identity still bound to a live Session: a balanced live log may return as a\n * durable snapshot, while an open live turn rejects. Returned values may be\n * shared with immutable live or prepared state and must not be mutated.\n * Revision-based implementations may wait for one stable read/check round trip.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + }, + { + signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Inspect an immutable logical session without committing recovery or\n * publishing it. A cold complete interrupted turn receives synthetic closers\n * in memory and a torn physical tail remains untouched. An already-live\n * Session instead yields its current immutable snapshot, which may contain an\n * open turn and its `session/end-seed` boundary. Coordinator-backed\n * implementations retain the exact cold unpublished Session for bounded\n * reuse by a later {@link prepare}. A stale ready source is reloaded; a source\n * already committing or reserved for resume remains exclusive, and inspection\n * may borrow its immutable view. Callers borrow only the immutable header and\n * log. Continuous external writers may delay revision convergence.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the validated header and current logical event log.\n */', }, { signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Like\n * {@link inspect} it is non-mutating and detached: no torn-tail truncation,\n * no synthetic closers, no coordinator-state publication; only events from\n * the valid contiguous stored prefix are returned, so a torn fragment never\n * reaches the caller. `fromSeq` at or beyond the stored prefix returns an\n * empty event list (never an error). Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */', + jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Unlike\n * {@link inspect}, it is a detached physical suffix read: no preparation\n * cache, torn-tail truncation, synthetic closers, or coordinator-state\n * publication. Only events from the valid contiguous stored prefix are\n * returned, so a torn fragment never reaches the caller. `fromSeq` at or\n * beyond the stored prefix returns an empty event list (never an error).\n * Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */', }, { signature: 'abstract list(signal?: AbortSignal): Promise', @@ -739,8 +743,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final events are published before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */', }, { - signature: 'prepare(id?: SessionId, options?: CreateSessionOptions): Session', - jsDoc: '/**\n * Build a session WITHOUT entering it into the store — validate the id/cwd and\n * construct the {@link Session} (with its immutable {@link SessionHeader}).\n * Pairs with {@link enter} + {@link announce}: a caller that owns a composite\n * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE\n * effect so a fiber unload tears the session + agent down as a single ORDERED\n * chain rather than as racing sibling effects — which would remove the publication hooks\n * before the driver\'s closing events commit, dropping them.\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the constructed session, NOT yet in the store.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path.\n */', + signature: 'prepare(id?: SessionId, options?: PrepareSessionOptions): Session', + jsDoc: '/**\n * Build a session WITHOUT entering it into the store — validate the id/cwd and\n * construct the {@link Session} (with its immutable {@link SessionHeader}).\n * Pairs with {@link enter} + {@link announce}: a caller that owns a composite\n * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE\n * effect so a fiber unload tears the session + agent down as a single ORDERED\n * chain rather than as racing sibling effects — which would remove the publication hooks\n * before the driver\'s closing events commit, dropping them.\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header. With\n * `seedSource: \'persistence\'`, metadata and events must be fresh detached\n * graphs whose ownership transfers to this call: they are validated and\n * frozen in place through {@link Session.fromRestore}, so the caller must\n * retain no mutable aliases.\n * @returns the constructed session, NOT yet in the store.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path.\n */', }, { signature: 'enter(session: Session): () => void', @@ -2141,6 +2145,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreparedReferencedMessage', declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n additionalContext?: UserMessage;\n}', }, + { + name: 'PrepareSessionOptions', + declaration: 'export type PrepareSessionOptions = (CreateSessionOptions & {\n readonly seedSource?: undefined;\n}) | RestoredSessionOptions;', + }, { name: 'PresetOption', declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', @@ -2317,6 +2325,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedSubagentStartRequest', declaration: 'export interface ResolvedSubagentStartRequest extends SubagentStartRequest {\n readonly descriptor: SubagentDescriptorData;\n}', }, + { + name: 'RestoredSessionOptions', + declaration: 'export interface RestoredSessionOptions {\n readonly seed: SessionEvent[];\n readonly meta: SessionHeader;\n readonly seedSource: \'persistence\';\n}', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', @@ -2375,7 +2387,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\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}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\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: 'SessionAvailability', @@ -2457,6 +2469,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionInspection', + declaration: 'export interface SessionInspection {\n readonly meta: SessionHeader;\n readonly events: readonly SessionEvent[];\n}', + }, { name: 'SessionLineageNode', declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', @@ -2481,6 +2497,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionPersistenceSnapshot', declaration: 'export interface SessionPersistenceSnapshot {\n header: SessionHeader;\n revision: SessionPersistenceRevision;\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: 'SessionProjectionMap', declaration: 'export interface SessionProjectionMap {\n}', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 530fbc0a3c..3f77973d92 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -20,7 +20,7 @@ import type { SessionStartSource, } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' @@ -104,6 +104,30 @@ async function raceAbort(operation: PromiseLike | T, signal: AbortSignal, } } +/** Start an abortable operation and release a value that arrives after cancellation. */ +async function raceAbortCall( + operation: () => PromiseLike | T, + signal: AbortSignal, + id: SessionId, + releaseAbandoned?: (value: T) => void, +): Promise { + if (signal.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) + } + const pending = Promise.resolve().then(operation) + try { + return await raceAbort(pending, signal, id) + } catch (error: unknown) { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited. + if (signal.aborted && releaseAbandoned !== undefined) { + void pending.then(releaseAbandoned, () => undefined) + } + throw error + } +} + /** Resolve the deployment-wide scheduler cap at the owning config boundary. */ function resolveMaxParallelToolCalls(value: number | undefined): number { const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS @@ -524,8 +548,8 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published running agent. */ create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { - const session = this.runtime.ctx.sessions.prepare(id, { meta }) - const prepared = this.prepare(this.ctx, id, options, session) + using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta })) + const prepared = this.prepare(this.ctx, id, options, preparation.session) try { return prepared.publish('startup').agent } catch (error: unknown) { @@ -541,14 +565,14 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { - const session = this.runtime.ctx.sessions.prepare(options.sessionId, { + const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, - }) + })) const published = this.setupAndPublish( ownerCtx, options.sessionId, - session, + preparation, options.agentOptions ?? {}, options.setup, options.signal, @@ -562,12 +586,14 @@ export class AgentLoop extends Service implements AgentFactory { private async setupAndPublish( ownerCtx: Context, id: SessionId, - session: Session, + preparation: SessionPreparation, agentOptions: AgentOptions, setup: AgentSetup | undefined, signal: AbortSignal | undefined, source: SessionStartSource, ): Promise { + using ownedPreparation = preparation + const session = ownedPreparation.session const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal) try { const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id) @@ -613,26 +639,31 @@ export class AgentLoop extends Service implements AgentFactory { ownerAbort.signal, this.ownership.signal, ]) - let loaded: Awaited> + let preparation: SessionPreparation | undefined try { - loaded = await raceAbort(persistence.load(id), fused, id) + try { + preparation = await raceAbortCall( + () => persistence.prepare(id, fused), + fused, + id, + (abandoned) => { abandoned[Symbol.dispose]() }, + ) + } finally { + await unfollowOwner() + } + ownerCtx.fiber.assertActive() + if (!this.ownership.isActive()) throw new Error('agent loop is not active') + return await this.setupAndPublish( + ownerCtx, + id, + preparation, + options.agentOptions ?? {}, + options.setup, + options.signal, + 'resume', + ) } finally { - await unfollowOwner() - } - ownerCtx.fiber.assertActive() - if (!this.ownership.isActive()) throw new Error('agent loop is not active') - const session = this.runtime.ctx.sessions.prepare(id, { - seed: loaded.events, - meta: loaded.meta, - }) - const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal) - try { - const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) - setupCommit?.commit() - return prepared.publish('resume') - } catch (error: unknown) { - await prepared.dispose() - throw error + preparation?.[Symbol.dispose]() } })() this.ownership.trackWrapper(published) 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 673c026356..c0be39e41b 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -296,14 +296,15 @@ describe('config-driven session id', () => { }) it.each(['resolve', 'reject'] as const)( - 'abandons an exact-id persistence lookup that later %s when AgentLoop disposal starts', + 'abandons an exact-id preparation that later %s 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(SessionPersistenceJsonl, { root }) - const loading = Promise.withResolvers>>() - vi.spyOn(ctx.sessionPersistence, 'load').mockReturnValue(loading.promise) + const preparing = Promise.withResolvers() + vi.spyOn(ctx.sessionPersistence, 'prepare').mockReturnValue(preparing.promise) + const released = vi.fn() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const failures: unknown[] = [] ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) @@ -313,18 +314,15 @@ describe('config-driven session id', () => { }) await loop.dispose() if (outcome === 'resolve') { - loading.resolve({ - meta: { - id: SessionId('config-exact-dispose'), - version: 0, - createdAt: Date.now(), - }, - events: [], - }) + preparing.resolve(SessionPreparation.create( + ctx.sessions.prepare(SessionId('config-exact-dispose')), + { release: released }, + )) } else { - loading.reject(new Error('startup cancelled by teardown')) + preparing.reject(new Error('startup cancelled by teardown')) } await Promise.resolve() + if (outcome === 'resolve') await expect.poll(() => released).toHaveBeenCalledOnce() expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined() expect(failures).toEqual([]) expect(warn).not.toHaveBeenCalled() diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index ddd6d9dc2d..62f9e9059e 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,12 +1,12 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -52,6 +52,18 @@ async function persistSession(sessionId: SessionId): Promise { return root } +/** Build a detached preparation for lifecycle-race test doubles. */ +function preparationFromSnapshot( + ctx: Context, + snapshot: { meta: SessionHeader; events: readonly SessionEvent[] }, +): SessionPreparation { + return SessionPreparation.create(ctx.sessions.prepare(snapshot.meta.id, { + seed: structuredClone(snapshot.events) as SessionEvent[], + meta: structuredClone(snapshot.meta), + seedSource: 'persistence', + })) +} + function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { @@ -196,7 +208,7 @@ 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(/live turn is open/) + .rejects.toThrow(/while it is live/) first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(first.session) @@ -446,22 +458,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) - it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => { + it('owner unload aborts a never-settling persistence preparation, 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 lateLoad = Promise.withResolvers() - const loadStarted = Promise.withResolvers() - let loads = 0 - ctx.sessionPersistence.load = (id) => { + 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) => { expect(id).toBe(sessionId) - loads += 1 - if (loads === 1) { - loadStarted.resolve(undefined) - return lateLoad.promise + preparations += 1 + if (preparations === 1) { + preparationStarted.resolve(undefined) + return latePreparation.promise } - return Promise.resolve(structuredClone(snapshot)) + return originalPrepare(id, signal) } const published: string[] = [] @@ -473,7 +487,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 loadStarted.promise + await preparationStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) await promptly(owner.dispose()) @@ -485,23 +499,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(loads).toBe(2) + expect(preparations).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. - lateLoad.resolve(structuredClone(snapshot)) + latePreparation.resolve(abandoned) await Promise.resolve() await Promise.resolve() 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 load and awaits wrapper settlement', async () => { + it('AgentLoop unload aborts persistence preparation and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') const root = await persistSession(sessionId) const ctx = new Context() @@ -515,19 +530,20 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')])) const snapshot = await ctx.sessionPersistence.load(sessionId) - const lateLoad = Promise.withResolvers() - const loadStarted = Promise.withResolvers() - ctx.sessionPersistence.load = (id) => { + const abandoned = preparationFromSnapshot(ctx, snapshot) + const latePreparation = Promise.withResolvers() + const preparationStarted = Promise.withResolvers() + ctx.sessionPersistence.prepare = (id) => { expect(id).toBe(sessionId) - loadStarted.resolve(undefined) - return lateLoad.promise + preparationStarted.resolve(undefined) + return latePreparation.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 loadStarted.promise + await preparationStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/) await promptly(loopFiber.dispose()) await rejection @@ -535,10 +551,11 @@ 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() - lateLoad.resolve(structuredClone(snapshot)) + latePreparation.resolve(abandoned) await Promise.resolve() await Promise.resolve() expect(published).toEqual([]) + abandoned[Symbol.dispose]() await ctx.fiber.dispose() }) @@ -730,6 +747,24 @@ describe('creation and resume cancellation edges', () => { await ctx.fiber.dispose() }) + it('rejects when setup synchronously aborts its caller signal', async () => { + const { ctx } = await persistentHarness(new MockAdapter([])) + const controller = new AbortController() + + const creating = ctx.agents.create({ + sessionId: SessionId('setup-synchronous-abort'), + agentOptions: { provider: 'mock', model: 'mock' }, + signal: controller.signal, + setup() { + controller.abort(new Error('setup synchronously cancelled')) + }, + }) + + await expect(promptly(creating)).rejects.toThrow('setup synchronously cancelled') + expect(ctx.agents.get(SessionId('setup-synchronous-abort'))).toBeUndefined() + await ctx.fiber.dispose() + }) + it('resume with a pre-aborted caller signal rejects out of the load race', async () => { const sessionId = SessionId('resume-pre-aborted') const root = await persistSession(sessionId) @@ -743,19 +778,45 @@ describe('creation and resume cancellation edges', () => { signal: controller.signal, }))).rejects.toThrow('resume abandoned') + const stringReason = new AbortController() + stringReason.abort('resume string reason') + await expect(promptly(ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + signal: stringReason.signal, + }))).rejects.toThrow(/creation aborted/) + expect(ctx.agents.get(sessionId)).toBeUndefined() await ctx.fiber.dispose() }) - it('factory teardown during a hung resume load rejects with loop-inactive', async () => { + it('releases a restored preparation if the loop becomes inactive before setup', async () => { + const sessionId = SessionId('resume-loop-inactive-after-prepare') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([])) + const loop = ctx.agentLoop as unknown as { + ownership: { isActive: () => boolean } + } + vi.spyOn(loop.ownership, 'isActive').mockReturnValueOnce(false) + + await expect(ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + })).rejects.toThrow('agent loop is not active') + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('factory teardown during a hung resume preparation rejects with loop-inactive', 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 gate = Promise.withResolvers() - const loadStarted = Promise.withResolvers() - ctx.sessionPersistence.load = () => { - loadStarted.resolve(undefined) + const abandoned = preparationFromSnapshot(ctx, snapshot) + const gate = Promise.withResolvers() + const preparationStarted = Promise.withResolvers() + ctx.sessionPersistence.prepare = () => { + preparationStarted.resolve(undefined) return gate.promise } @@ -763,27 +824,28 @@ describe('creation and resume cancellation edges', () => { resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) - await loadStarted.promise - // Resolve the load only after teardown began: the post-load ownership + await preparationStarted.promise + // Resolve the preparation only after teardown began: the post-prepare ownership // check, not the abort race, must reject the wrapper. const rejection = expect(promptly(resuming)).rejects.toThrow() const disposal = ctx.fiber.dispose() - gate.resolve(structuredClone(snapshot)) + gate.resolve(abandoned) await rejection await disposal + abandoned[Symbol.dispose]() }) }) describe('configured-start failure edges', () => { - it('a non-Error mid-load abort reason is wrapped for the resume caller', async () => { + it('a non-Error mid-prepare 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 loadStarted = Promise.withResolvers() - ctx.sessionPersistence.load = () => { - loadStarted.resolve(undefined) + const preparationStarted = Promise.withResolvers() + ctx.sessionPersistence.prepare = () => { + preparationStarted.resolve(undefined) return gate.promise } const controller = new AbortController() @@ -793,7 +855,7 @@ describe('configured-start failure edges', () => { agentOptions: { provider: 'mock', model: 'mock' }, signal: controller.signal, }) - await loadStarted.promise + await preparationStarted.promise controller.abort('operator string reason') await expect(promptly(resuming)).rejects.toThrow(/creation aborted/) @@ -808,7 +870,7 @@ describe('configured-start failure edges', () => { // 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.load = () => Promise.reject(new Error('artifact corrupt')) + ctx.sessionPersistence.prepare = () => Promise.reject(new Error('artifact corrupt')) const configured = new Context() await configured.plugin(LlmService) @@ -818,7 +880,7 @@ describe('configured-start failure edges', () => { await configured.plugin(AgentRegistry) await configured.plugin(SessionPersistenceJsonl, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) - configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id) + configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) const configFailures: unknown[] = [] configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) }) const configWarnings: string[] = [] @@ -847,9 +909,9 @@ describe('configured-start failure edges', () => { const ctx = await mountPersistentHarness(root, new MockAdapter([])) const gate = Promise.withResolvers() gate.promise.catch(() => undefined) - const loadStarted = Promise.withResolvers() - ctx.sessionPersistence.load = () => { - loadStarted.resolve(undefined) + const preparationStarted = Promise.withResolvers() + ctx.sessionPersistence.prepare = () => { + preparationStarted.resolve(undefined) return gate.promise } const failures: unknown[] = [] @@ -863,12 +925,12 @@ describe('configured-start failure edges', () => { await configured.plugin(AgentRegistry) await configured.plugin(SessionPersistenceJsonl, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) - configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id) + configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) const loop = await configured.plugin(AgentLoop, { agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }], }) - await loadStarted.promise + await preparationStarted.promise const disposal = loop.dispose() gate.reject(new Error('late backend failure')) await disposal diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index d1cce08d2f..0a16a2bf53 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -187,8 +187,8 @@ export interface AgentFactory { */ createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** - * Load a persisted session and resume an agent on it. Async because it awaits - * both `ctx.sessionPersistence.load` and the optional unpublished setup + * 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}. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0ae2a75869..adac4f28d5 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,13 +13,15 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' +import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' +export { SessionPreparation } from './preparation.ts' +export type { SessionPreparationOptions } from './preparation.ts' export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm' export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' @@ -143,6 +145,17 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { return deepFreeze(record as unknown as SessionHeader) } +/** Validate and freeze one exclusively owned persistence header in place. */ +function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHeader { + if (input !== null && typeof input === 'object' && !Array.isArray(input)) { + const prototype = Reflect.getPrototypeOf(input) + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('session header is not a plain JSON record') + } + } + return validateSessionHeader(id, input) +} + /** Detach, validate, and freeze the creation metadata published by a session. */ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader { const input: unknown = source === undefined @@ -190,23 +203,58 @@ export function snapshotSessionEvent(event: T): T { return adoptSessionEvent(structuredClone(event)) } +/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */ +function freezeRestoredObject(value: T): T { + const pending: object[] = [value] + while (pending.length > 0) { + // The non-empty check proves an object remains to visit. + // oxlint-disable-next-line typescript/no-non-null-assertion + const current = pending.pop()! + Object.freeze(current) + for (const key in current) { + const child = (current as Record)[key] + if (child !== null && typeof child === 'object') pending.push(child) + } + } + return value +} + /** Validate the fixed event envelope after one-pass JSON materialization. */ function assertSessionEventEnvelope(value: Record, index: number): asserts value is SessionEvent { const event = value if (event['type'] === 'request/header-delta') { throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`) } - const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs']) - if (Object.keys(event).some(key => !allowed.has(key)) - || !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string' - || !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number' - || !Number.isSafeInteger(event['seq']) || event['seq'] < 0 - || !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number' - || !Number.isSafeInteger(event['time']) || event['time'] < 0 - || !Object.hasOwn(event, 'data')) { + for (const key in event) { + switch (key) { + case 'type': + case 'seq': + case 'time': + case 'data': + case 'surfaceOp': + case 'sourceEventSeqs': + break + default: + throw new Error(`seed event at index ${index} has an invalid event envelope`) + } + } + const type = event['type'] + const seq = event['seq'] + const time = event['time'] + if (typeof type !== 'string' + || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 + || typeof time !== 'number' || !Number.isSafeInteger(time) + || event['data'] === undefined) { throw new Error(`seed event at index ${index} has an invalid event envelope`) } - assertCurrentLlmShape(event, index) + switch (type) { + case 'request/header': + case 'user/message': + case 'assistant/message': + case 'tool/result': + assertCurrentLlmShape(event, index) + break + } } /** Reject obsolete request headers and malformed messages at the seed/load boundary. */ @@ -236,6 +284,8 @@ function assertCurrentLlmShape(event: Record, index: number): v assertMessageEventShape(event, `seed ${type} at index ${index}`) } +const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens']) + /** Validate adapter-default provenance imported from a durable request header. */ function assertAdapterDefaults( value: unknown, @@ -247,8 +297,7 @@ function assertAdapterDefaults( throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`) } const defaults = value as Record - const allowed = new Set(['reasoningEffort', 'maxTokens']) - if (Object.keys(defaults).some(key => !allowed.has(key)) + if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key)) || Object.values(defaults).some(marker => marker !== true) || defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined || defaults['maxTokens'] === true && config['maxTokens'] === undefined) { @@ -442,7 +491,28 @@ export class Session { return new Session(id, seed, header) } - private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { + /** + * Restore a detached session by taking ownership of fresh persistence values. + * Storage shape, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the graphs are frozen in place. + * @param id - restored session identity. + * @param seed - fresh detached events whose ownership is transferred. + * @param header - fresh detached metadata whose ownership is transferred. + * @returns a restored detached session. + */ + static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session { + return new Session(id, seed, header, 'restore') + } + + private constructor( + id: SessionId, + seed?: readonly SessionEvent[], + header?: SessionHeader, + mode: 'snapshot' | 'restore' = 'snapshot', + ) { + const restoredHeader = mode === 'restore' + ? validateRestoredSessionHeader(id, header) + : undefined if (seed !== undefined) { // Validate the seed to the SAME invariants `append` enforces, so a // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a @@ -454,7 +524,7 @@ export class Session { for (const [index, source] of seed.entries()) { // The seed is a persistence/replay boundary: validate and detach the // complete event in one lossless-JSON pass. - const snapshot = snapshotJsonValue(source) + const snapshot = mode === 'restore' ? source : snapshotJsonValue(source) if (snapshot === undefined) { throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`) } @@ -471,11 +541,11 @@ export class Session { } catch (error: unknown) { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } - this.log.push(deepFreeze(snapshot)) + this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot)) } } this.firstLiveSeq = this.log.length - this.header = snapshotSessionHeader(id, header) + this.header = restoredHeader ?? snapshotSessionHeader(id, header) // Appended here so the marker is already in `events` when a backend // captures the creation seed: no load-time write. Re-marking is skipped // because a cold session is resumed on first touch, so repeatedly opening @@ -816,13 +886,17 @@ export class SessionStore extends Service { * before the driver's closing events commit, dropping them. * * @param id - the session id; omitted, the store mints `session-`. - * @param options - seed events and/or creation metadata for the header. + * @param options - seed events and/or creation metadata for the header. With + * `seedSource: 'persistence'`, metadata and events must be fresh detached + * graphs whose ownership transfers to this call: they are validated and + * frozen in place through {@link Session.fromRestore}, so the caller must + * retain no mutable aliases. * @returns the constructed session, NOT yet in the store. * @throws if a session with `id` already exists, metadata is not a plain * lossless-JSON record with valid scalar fields, or `meta.cwd` is a * non-absolute path. */ - prepare(id?: SessionId, options?: CreateSessionOptions): Session { + prepare(id?: SessionId, options?: PrepareSessionOptions): Session { let sessionId: SessionId if (id === undefined) { do sessionId = SessionId(`session-${++this.counter}`) @@ -831,6 +905,9 @@ export class SessionStore extends Service { sessionId = SessionId(id) } if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) + if (options?.seedSource === 'persistence') { + return Session.fromRestore(sessionId, options.seed, options.meta) + } const seed = options?.seed const meta = options?.meta const header: SessionHeader = { diff --git a/packages/core/session/src/preparation.ts b/packages/core/session/src/preparation.ts new file mode 100644 index 0000000000..ee8fe53747 --- /dev/null +++ b/packages/core/session/src/preparation.ts @@ -0,0 +1,49 @@ +/** + * Ownership of one unpublished Session before registry publication. + * @module @deepseek-ai/dsh-session/preparation + */ + +import type { Session } from './index.ts' + +/** Options for a preparation whose provider retains unpublished state. */ +export interface SessionPreparationOptions { + /** Release provider-owned state when the Session was not published. */ + readonly release?: () => void +} + +/** + * One exact unpublished Session and the provider state that keeps it usable. + * Disposal is synchronous and idempotent. Providers decide whether release + * returns the Session to a cache or discards it; publication may consume that + * state before disposal, making the callback a no-op. + */ +export class SessionPreparation implements Disposable { + private released = false + + /** The exact Session to use for setup and publication. */ + readonly session: Session + + private constructor( + session: Session, + private readonly options: SessionPreparationOptions, + ) { + this.session = session + } + + /** + * Wrap an unpublished Session in one preparation lifetime. + * @param session - exact unpublished Session. + * @param options - optional provider release behavior. + * @returns a preparation disposed after publication or rollback. + */ + static create(session: Session, options?: SessionPreparationOptions): SessionPreparation { + return new SessionPreparation(session, options ?? {}) + } + + /** Release provider state once when this preparation leaves its caller. */ + [Symbol.dispose](): void { + if (this.released) return + this.released = true + this.options.release?.() + } +} diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 38150ec9c2..f6eb6fe6a6 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -308,6 +308,14 @@ function applySurfaceEvent( baseSeq: number, ): SurfaceFoldReplacement | undefined { const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq) + return applySurfacePlan(state, plan) +} + +/** Commit one previously validated surface transition. */ +function applySurfacePlan( + state: SurfaceFoldState, + plan: SurfacePlan | undefined, +): SurfaceFoldReplacement | undefined { if (plan?.kind === 'append') { state.nodes.push(plan.seq) } else if (plan?.kind === 'replace') { @@ -345,6 +353,8 @@ export class SurfaceManager implements SessionSurface { private _state = createFoldState() /** Last processed absolute seq. */ private _lastProcessedSeq: number + /** Candidate already validated by `validateNext`, pending exact log admission. */ + private _pendingPlan: { event: SessionEvent; expectedSeq: number; plan: SurfacePlan | undefined } | undefined /** * @param log - Contiguous complete log or loaded event window. @@ -363,13 +373,12 @@ export class SurfaceManager implements SessionSurface { */ validateNext(event: SessionEvent): void { if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() - planSurfaceEvent( - this._state, + const expectedSeq = this.baseSeq + this.log.length + this._pendingPlan = { event, - this.baseSeq + this.log.length, - this.log, - this.baseSeq, - ) + expectedSeq, + plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq), + } } /** Monotonic count of folded positional replacements. */ @@ -390,7 +399,14 @@ export class SurfaceManager implements SessionSurface { for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) { const index = seq - this.baseSeq // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition - applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq) + const event = this.log[index]! + const pending = this._pendingPlan + if (pending?.event === event && pending.expectedSeq === seq) { + applySurfacePlan(this._state, pending.plan) + } else { + applySurfaceEvent(this._state, event, seq, this.log, this.baseSeq) + } + if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined this._lastProcessedSeq = seq } } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 4e7026e0a3..854e28d2e7 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -93,6 +93,24 @@ export interface CreateSessionOptions { } } +/** + * Fresh storage values transferred to {@link SessionStore.prepare} without a + * second serialization copy. Callers retain no mutable aliases. + */ +export interface RestoredSessionOptions { + /** Fresh detached storage events to validate and freeze in place. */ + readonly seed: SessionEvent[] + /** Fresh detached storage metadata to validate and freeze in place. */ + readonly meta: SessionHeader + /** Select the persistence ownership-transfer path. */ + readonly seedSource: 'persistence' +} + +/** Inputs accepted while constructing an unpublished Session. */ +export type PrepareSessionOptions = + | (CreateSessionOptions & { readonly seedSource?: undefined }) + | RestoredSessionOptions + /** Why an active agent driver was cancelled. */ export type AgentCancelCause = | { readonly kind: 'user' } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 7258726ad1..1394db01e3 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -941,6 +941,36 @@ describe('Session', () => { expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError) }) + it('iteratively freezes deeply nested restored event data', () => { + const depth = 20_000 + const data: Record = {} + let tail = data + for (let index = 0; index < depth; index += 1) { + const child: Record = {} + tail['child'] = child + tail = child + } + const event = { + type: 'test/deep-restore', seq: 0, time: 1, data, + } as unknown as SessionEvent + + expect(() => Session.fromRestore(SessionId('deep-restore'), [event], { + version: SESSION_FORMAT_VERSION, + id: SessionId('deep-restore'), + createdAt: 1, + })).not.toThrow() + + let current: unknown = event + let frozenNodes = 0 + for (let index = 0; index <= depth + 1; index += 1) { + if (!Object.isFrozen(current)) break + frozenNodes += 1 + current = (current as Record)['data'] + ?? (current as Record)['child'] + } + expect(frozenNodes).toBe(depth + 2) + }) + it('returns cached frozen event-array snapshots that do not grow after append', () => { const session = Session.create(SessionId('events-snapshot')) session.append('turn/start', { turn: 1 }) @@ -998,6 +1028,15 @@ describe('Session', () => { expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader())) .toThrow(/not losslessly JSON-serializable/) + expect(() => Session.fromRestore(SessionId('header-invalid'), [], new ExoticHeader())) + .toThrow(/not a plain JSON record/) + for (const header of [null, 1, []]) { + expect(() => Session.fromRestore( + SessionId('header-invalid'), + [], + header as unknown as SessionHeader, + )).toThrow(/not a plain JSON record/) + } expect(() => Session.create(SessionId('header-invalid'), undefined, { version: SESSION_FORMAT_VERSION, id: SessionId('header-invalid'), @@ -1050,7 +1089,6 @@ describe('Session', () => { { ...base, seq: -1 }, { ...base, time: '1' }, { ...base, time: 0.5 }, - { ...base, time: -1 }, { type: base.type, seq: base.seq, time: base.time }, ] diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a61926fa85..838e4f3a92 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -972,7 +972,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) const inspected = await persistence.inspect(sessionId) if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) - return inspected + return { meta: inspected.meta, events: [...inspected.events] } } /** diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index b8d867e1fa..8b01e9005a 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -15,6 +15,12 @@ import { MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { + PersistenceCoordinator, + SessionPersistenceRevision, + type PersistenceBackend, + type StoredPrefix, +} from '@deepseek-ai/dsh-session-persistence' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' @@ -114,6 +120,66 @@ describe('attached updatedAt excludes end-seed', () => { }) }) +describe('cold history recovery view', () => { + it('shows in-memory interruption repair without activating the session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-interrupted') + const meta = header(sessionId, 1000) + const stored: StoredPrefix = { + meta, + events: [{ type: 'turn/start', seq: 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) + ctx.provide('sessionPersistence', { + list: (signal?: AbortSignal) => backend.list(signal), + inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), + locate: () => undefined, + } as never) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) + if (!history.result.ok) throw new Error('history failed') + expect(history.result.value.events.map(entry => entry.event)).toMatchInlineSnapshot(` + [ + { + "data": { + "turn": 1, + }, + "seq": 0, + "time": 1, + "type": "turn/start", + }, + { + "data": { + "reason": { + "kind": "interrupted", + }, + "turn": 1, + }, + "seq": 1, + "time": 1, + "type": "turn/end", + }, + ] + `) + expect(ctx.sessions.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) +}) + describe('subagent ownership fence', () => { it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { const ctx = new Context() diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index 7c10830244..b64ce563b9 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -69,7 +69,7 @@ async function load(root: string): Promise { await ctx.plugin(SessionStore) await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) try { - return (await ctx.sessionPersistence.load(sessionId)).events + return [...(await ctx.sessionPersistence.load(sessionId)).events] } finally { await ctx.fiber.dispose() } diff --git a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml index d9ea8b8176..763b55a1a4 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session-persistence/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-persistence/session-persistence-jsonl/README.md -README.md: 7bf6ba2596fb0cf042a2318cfe01f65689131af5 -README.zh.md: afcf06af31ea0f9774d1d6f91310c3493e3eced2 +README.md: cd087539bde2433fcdb70b2c511ff30880a877e1 +README.zh.md: 144b404e04f8a5fd3623d9329e4fbcafd328524d diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 7bf6ba2596..cd087539bd 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -26,6 +26,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence | `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). An existing root must be a readable directory; an absent root is created on first materialization. | | `packChunks` | `boolean` (default `true`) | Write eligible delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Set `false` for one-event-per-line diagnostics; reading packed rows works regardless of this write-side switch. | | `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. | +| `preparedSessionCacheSize` | positive integer (default `5`) | Maximum unpublished Sessions retained after cold history inspection for reuse by resume. | `locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved project/session directories. It performs no filesystem I/O: the target can be returned before the directory or file exists, and an existing file contains only the last flushed prefix. @@ -41,9 +42,9 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. - **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. -- **Non-mutating inspection.** `inspect()` returns the detached valid prefix without truncating an incomplete tail or closing an interrupted turn, and leaves the lightweight revision unchanged. +- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without truncating an incomplete tail or changing the lightweight revision. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. -- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. It forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another. +- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. A full-prefix read requires the same identity before and after reading the bytes, and `readStoredRevision()` uses that identity to validate retained preparations without loading the log. Snapshot listing forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another. ## Write path diff --git a/packages/session-persistence/session-persistence-jsonl/README.zh.md b/packages/session-persistence/session-persistence-jsonl/README.zh.md index afcf06af31..144b404e04 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.zh.md +++ b/packages/session-persistence/session-persistence-jsonl/README.zh.md @@ -26,6 +26,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d | `root` | `string`(必需) | 所有会话文件的根目录。**无默认值**:`process.cwd()` 默认值会随进程 cwd 变更(bash 调用、子进程)而分散文件。现有根必须是可读目录;缺失根在第一次实体化时创建。 | | `packChunks` | `boolean`(默认 `true`) | 将符合条件的 delta 分片连续段写为打包行(在真实编码会话上测得逻辑日志约小 60%)。设为 `false` 可用于每事件一行诊断;无论该写入侧开关如何,都能读取打包行。 | | `compression` | `'zstd' \| 'none'` | 默认 `'zstd'`;`'none'` 保留换行分隔 UTF-8 文本。 | +| `preparedSessionCacheSize` | 正整数(默认 `5`) | 冷历史检查后保留、供恢复复用的未发布 Session 数量上限。 | `locate(meta)` 返回已解析项目/会话目录内固定 transcript 的 `{ kind: 'jsonl', path }`。它不执行文件系统 I/O:可以在目录或文件存在前返回目标,现有文件也只包含最近一次 flush 完成的前缀。 @@ -41,9 +42,9 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d - **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中。 - **仅追加。** 已 flush 事件绝不重写。后续原始批次 append 行;压缩批次 append 一个 frame。两条路径都执行 `fsync`,并在捕获到写入或同步失败时回滚到之前字节长度。 - **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷属于损坏,会被拒绝。 -- **非变更检查。**`inspect()` 返回脱离的有效前缀,不截断不完整尾部或关闭中断轮次,并保持轻量修订不变。 +- **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会截断不完整尾部或更改轻量修订。 - **连续 seq。**`append` 拒绝第一个 `seq` 不继续已存储日志的批次,并拒绝非 JSON 可序列化 `event.data`,同时命名违规事件类型。 -- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。它通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。 +- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。完整前缀读取要求读取字节前后的身份一致,`readStoredRevision()` 使用同一身份校验保留的 preparation,而不加载日志。快照列表通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。 ## 写入路径 diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index d37b5708a8..96e8221c65 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -217,106 +217,157 @@ export function eventLines(events: readonly SessionEvent[], packChunks: boolean) return records.map(record => JSON.stringify(record)).join('\n') } -/** - * Parse a JSONL log buffer into its preserved event prefix (the header is line - * 0). Event lines pass through verbatim; packed chunk rows expand back into - * their events, so callers see one contiguous event list regardless of layout. - * Fully written events in an interrupted final turn remain part of the - * prefix. The first unparsable record or seq gap after the last `turn/end` - * marks a tolerated torn tail; the same hole in the committed region rejects. - * - * @param buffer - the raw bytes of the log file (header line first). - * @returns the header, the preserved event prefix, and `committedBytes` — the - * byte offset the next append truncates any torn tail to. - */ -export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } { - const text = buffer.toString('utf8') - // Track complete lines by byte offset: a non-newline tail is torn and ignored, - // and a running counter avoids rescanning a long multi-byte log. - const lines: { text: string; endByte: number }[] = [] - let start = 0 - let byteOffset = 0 - for (let i = 0; i < text.length; i++) { - if (text[i] === '\n') { - const lineText = text.slice(start, i) - byteOffset += Buffer.byteLength(lineText, 'utf8') + 1 // +1 for the '\n' (a 1-byte char) - lines.push({ text: lineText, endByte: byteOffset }) - start = i + 1 - } +interface SessionLogScan { + meta: SessionHeader + events: SessionEvent[] + committedBytes: number +} + +/** Parse one complete header record supplied independently from event rows. */ +function parseHeaderRecord(record: Buffer): SessionHeader { + if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) { + throw new Error('empty or header-less session log') } - - const [headerEntry, ...eventEntries] = lines - if (headerEntry === undefined) throw new Error('empty or header-less session log') - - // Line 0 is the header. - let parsedHeader: unknown + let parsed: unknown try { - parsedHeader = JSON.parse(headerEntry.text) + parsed = JSON.parse(record.subarray(0, -1).toString('utf8')) } catch { throw new Error('corrupt session log: header line is not valid JSON') } - if (!isHeaderLine(parsedHeader)) { + if (!isHeaderLine(parsed)) { throw new Error('corrupt session log: first line is not a session header') } - const headerLine = parsedHeader + return fromHeaderLine(parsed) +} - // Parse and decode every complete line first so the last valid `turn/end` - // determines whether an earlier hole interrupts an otherwise closed - // execution or belongs to a tolerable final suffix. One line yields one - // event, or a whole run for a packed chunk row; a row-tagged line that fails - // row validation is a hole, exactly like unparsable JSON. - interface Parsed { ok: boolean; events?: SessionEvent[]; endByte: number } - const parsed: Parsed[] = eventEntries.map((entry) => { - try { - return { ok: true, events: decodeStorageRecord(JSON.parse(entry.text)), endByte: entry.endByte } - } catch { - return { ok: false, endByte: entry.endByte } - } - }) +/** + * Incrementally scan complete JSONL event records after an independently + * supplied header record. Newline search and byte offsets stay on raw buffers; + * only complete records are decoded to UTF-8. A fragment crossing writes is + * copied because a decoder may reuse its output buffer after `write()` returns. + */ +export class SessionLogScanner { + private readonly meta: SessionHeader + private readonly events: SessionEvent[] = [] + private fragments: Buffer[] = [] + private fragmentBytes = 0 + private inputBytes: number + private committedBytes: number + private eventLine = 0 + private issue: Error | undefined + private finished = false - // The last index (into eventEntries) that ends in a valid `turn/end`. A hole - // before this boundary cannot be a torn final suffix because later execution - // already closed. Standalone events after it remain part of the preserved - // contiguous prefix. A packed row never stores a turn/end, so only - // single-event lines can match. - let lastTurnEnd = -1 - for (let i = parsed.length - 1; i >= 0; i--) { - const p = parsed[i] - if (p?.ok && p.events?.some(e => e.type === 'turn/end')) { lastTurnEnd = i; break } + /** + * Create an event scanner from exactly one newline-terminated header record. + * @param headerRecord - the complete first JSONL record, including its newline. + */ + constructor(headerRecord: Buffer) { + this.meta = parseHeaderRecord(headerRecord) + this.inputBytes = headerRecord.length + this.committedBytes = headerRecord.length } - // Preserve the contiguous prefix, including a complete interrupted turn; - // holes through the last committed boundary throw, while later holes stop. - // Contiguity is a cursor over seqs (not the line index): a packed row - // advances the cursor by its whole run. - const preserved: SessionEvent[] = [] - let lastPreservedLine = -1 - scan: for (let i = 0; i < parsed.length; i++) { - const p = parsed[i] - if (!p?.ok || p.events === undefined) { - if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at line ${i + 1}`) - break // torn tail fragment after the last turn/end — stop, tolerate - } - for (const event of p.events) { - if (event.seq !== preserved.length) { - if (i <= lastTurnEnd) { - throw new Error(`corrupt session log: seq gap in committed region at line ${i + 1} (expected ${preserved.length}, got ${event.seq})`) - } - break scan // gap after the last turn/end — torn tail, stop + /** + * Consume the next raw plaintext chunk, retaining only an incomplete final record. + * @param chunk - bytes immediately following all previously supplied bytes. + */ + write(chunk: Buffer): void { + if (this.finished) throw new Error('cannot write to a finished session log scanner') + const chunkStart = this.inputBytes + this.inputBytes += chunk.length + let lineStart = 0 + for ( + let newline = chunk.indexOf(0x0A); + newline !== -1; + newline = chunk.indexOf(0x0A, lineStart) + ) { + const fragment = chunk.subarray(lineStart, newline) + let line = fragment + if (this.fragments.length > 0) { + if (fragment.length > 0) this.fragments.push(fragment) + line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length) + this.fragments = [] + this.fragmentBytes = 0 } - preserved.push(event) + this.consumeEventLine(line, chunkStart + newline + 1) + lineStart = newline + 1 + } + if (lineStart < chunk.length) { + const fragment = Buffer.from(chunk.subarray(lineStart)) + this.fragments.push(fragment) + this.fragmentBytes += fragment.length } - lastPreservedLine = i } - // committedBytes = end of the last FULLY preserved line (header if none): the - // next append truncates any torn bytes past this point before writing the - // synthetic closers + new events. A line is preserved whole or not at all — - // a mid-row seq gap discards the whole row, keeping the truncation offset on - // a line boundary. - const lastPreserved = parsed[lastPreservedLine] - const committedBytes = lastPreserved !== undefined ? lastPreserved.endByte : headerEntry.endByte - return { meta: fromHeaderLine(headerLine), events: preserved, committedBytes } + /** + * Snapshot progress before appending a recoverable torn-frame prefix. + * @returns byte, committed-prefix, and expanded-event cursors. + */ + checkpoint(): { inputBytes: number; committedBytes: number; eventCount: number } { + return { + inputBytes: this.inputBytes, + committedBytes: this.committedBytes, + eventCount: this.events.length, + } + } + + /** + * Finish scanning, ignoring a final record without a newline as a torn tail. + * @returns the header, contiguous event prefix, and safe truncation offset. + */ + finish(): SessionLogScan { + this.finished = true + return { meta: this.meta, events: this.events, committedBytes: this.committedBytes } + } + + /** Decode one complete event row and update the contiguous prefix. */ + private consumeEventLine(line: Buffer, endByte: number): void { + this.eventLine += 1 + let decoded: SessionEvent[] + try { + decoded = decodeStorageRecord(JSON.parse(line.toString('utf8'))) + } catch { + this.issue ??= new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`) + return + } + + if (this.issue !== undefined) { + if (decoded.some(event => event.type === 'turn/end')) throw this.issue + return + } + + const rowStart = this.events.length + for (const event of decoded) { + if (event.seq !== this.events.length) { + const expected = this.events.length + this.events.length = rowStart + this.issue = new Error( + `corrupt session log: seq gap in committed region at line ${this.eventLine} ` + + `(expected ${expected}, got ${event.seq})`, + ) + if (decoded.some(candidate => candidate.type === 'turn/end')) throw this.issue + return + } + this.events.push(event) + } + this.committedBytes = endByte + } +} + +/** + * Parse a complete or torn JSONL buffer into its preserved event prefix. This + * compatibility wrapper supplies the first record separately, then delegates + * event rows to {@link SessionLogScanner}. + * + * @param buffer - the raw bytes of the log file (header line first). + * @returns the header, preserved event prefix, and byte offset safe to append at. + */ +export function scanLog(buffer: Buffer): SessionLogScan { + const headerEnd = buffer.indexOf(0x0A) + if (headerEnd === -1) throw new Error('empty or header-less session log') + const scanner = new SessionLogScanner(buffer.subarray(0, headerEnd + 1)) + scanner.write(buffer.subarray(headerEnd + 1)) + return scanner.finish() } /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 4967010902..130d684208 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -11,24 +11,42 @@ import z from 'schemastery' import { readdirSync } from 'node:fs' import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { scheduler } from 'node:timers/promises' import { randomBytes } from 'node:crypto' import { - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { - encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, toHeaderLine, + encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, + SessionLogScanner, toHeaderLine, type JsonlCompression, } from './format.ts' -import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from './zstd.ts' +import { + compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, +} from './zstd.ts' import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts' export type { JsonlCompression } from './format.ts' const DEFAULT_PACK_CHUNKS = true const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' +/** + * Internal scheduling constant, not deployment configuration: balance + * frame-boundary event-loop yields against `setImmediate` overhead. One frame + * remains an indivisible synchronous decode. + */ +const ZSTD_DECODE_YIELD_INTERVAL_MS = 500 + +/** Assert that the independently decodable first frame contains only the header record. */ +function assertZstdHeaderFrame(plaintext: Buffer): void { + if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) { + throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') + } +} /** Loader schema for the JSONL artifact's physical encoding. */ export const JsonlCompressionSchema: z = z.union([ @@ -56,6 +74,8 @@ 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 } /** Opaque coordinator token for replacing bytes recovered from a torn frame. */ @@ -64,6 +84,25 @@ interface JsonlTornMarker { recoveredEvents: SessionEvent[] } +interface FileRevisionIdentity { + readonly dev: bigint + readonly ino: bigint + readonly size: bigint + readonly mtimeNs: bigint + readonly ctimeNs: bigint +} + +/** Build the source-qualified revision shared by full and lightweight reads. */ +function fileRevision(identity: FileRevisionIdentity): PersistenceRevision { + return SessionPersistenceRevision([ + identity.dev, + identity.ino, + identity.size, + identity.mtimeNs, + identity.ctimeNs, + ].join(':')) +} + /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' @@ -82,6 +121,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi 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), }) /** @@ -102,10 +142,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // 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 this.packChunks = config.packChunks ?? DEFAULT_PACK_CHUNKS this.compression = config.compression ?? DEFAULT_COMPRESSION this.assertUsableRoot() - this.coordinator = new PersistenceCoordinator(this.ctx, this) + this.coordinator = new PersistenceCoordinator(this.ctx, this, { + preparedSessionCacheSize, + }) } // Each backend keeps the typed service surface beside its storage hooks; @@ -126,11 +170,15 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.append(id, events) } - load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + 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<{ meta: SessionHeader; events: SessionEvent[] }> { + inspect(id: SessionId, signal?: AbortSignal): Promise { return this.coordinator.inspect(id, signal) } @@ -156,6 +204,27 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.readPrefix(path, id, signal) } + /** + * Read one log's stat-derived revision without loading its event bytes. + * Resolving an id with unknown cwd still scans the project directories. + */ + async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureRootEncoding() + signal?.throwIfAborted() + const path = await this.findLog(id, signal) + if (path === undefined) return undefined + try { + const identity = await stat(path, { bigint: true }) + signal?.throwIfAborted() + return fileRevision(identity) + } catch (error: unknown) { + signal?.throwIfAborted() + if (isENOENT(error)) return undefined + throw error + } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. @@ -165,9 +234,20 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi expectedId?: SessionId, signal?: AbortSignal, ): Promise> { - const buffer = await readFile(path, { signal }) - signal?.throwIfAborted() - let prefix: StoredPrefix + let buffer: Buffer + let revision: PersistenceRevision + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + buffer = await readFile(path, { signal }) + signal?.throwIfAborted() + const after = fileRevision(await stat(path, { bigint: true })) + if (before === after) { + revision = after + break + } + } + let prefix: Omit, 'revision'> if (this.compression === 'zstd') { prefix = await this.readZstdPrefix(buffer, signal) } else { @@ -185,74 +265,80 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi signal?.throwIfAborted() await this.assertStoredIdentity(path, prefix.meta, expectedId, signal) signal?.throwIfAborted() - return prefix + return { ...prefix, revision } } /** Decode complete frames and retain complete JSONL records from a torn final frame. */ private async readZstdPrefix( buffer: Buffer, signal?: AbortSignal, - ): Promise> { + ): Promise, 'revision'>> { signal?.throwIfAborted() const { frames, tornStart } = scanZstdFrames(buffer) signal?.throwIfAborted() if (frames.length === 0) throw new Error('empty or header-less Zstandard session log') - const plaintextFrames: Buffer[] = [] - for (const frame of frames) { - let plaintext: Buffer - try { + const decoder = createZstdFrameDecoder() + let yieldDeadline = performance.now() + ZSTD_DECODE_YIELD_INTERVAL_MS + try { + const decodedFrames = decoder.decode(buffer, frames) + signal?.throwIfAborted() + const headerFrame = decodedFrames.next() + signal?.throwIfAborted() + /* v8 ignore next -- a non-empty structural frame list makes the decoder yield its first frame or throw. */ + if (headerFrame.done) throw new Error('empty or header-less Zstandard session log') + assertZstdHeaderFrame(headerFrame.value) + const scanner = new SessionLogScanner(headerFrame.value) + + let remainingFrames = frames.length - 1 + for (const plaintext of decodedFrames) { signal?.throwIfAborted() - plaintext = await decompressZstdFrame(buffer.subarray(frame.start, frame.end)) - } catch (error) { - /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ - if (signal?.aborted) signal.throwIfAborted() - throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { cause: error }) + scanner.write(plaintext) + remainingFrames -= 1 + if (remainingFrames > 0 && performance.now() >= yieldDeadline) { + await scheduler.yield() + signal?.throwIfAborted() + yieldDeadline = performance.now() + ZSTD_DECODE_YIELD_INTERVAL_MS + } } signal?.throwIfAborted() - plaintextFrames.push(plaintext) - } + const complete = scanner.checkpoint() + if (complete.committedBytes !== complete.inputBytes) { + throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record') + } + if (tornStart === undefined) { + const prefix = scanner.finish() + return { meta: prefix.meta, events: prefix.events } + } - const headerFrame = plaintextFrames[0] - if (headerFrame === undefined || headerFrame.length === 0 || headerFrame.indexOf(0x0A) !== headerFrame.length - 1) { - throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') - } - signal?.throwIfAborted() - const completePlaintext = Buffer.concat(plaintextFrames) - signal?.throwIfAborted() - const completePrefix = scanLog(completePlaintext) - signal?.throwIfAborted() - if (completePrefix.committedBytes !== completePlaintext.length) { - throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record') - } - if (tornStart === undefined) { - return { meta: completePrefix.meta, events: completePrefix.events } - } - - let recoveredPlaintext: Buffer = Buffer.alloc(0) - try { + let recoveredPlaintext: Buffer = Buffer.alloc(0) + try { + signal?.throwIfAborted() + recoveredPlaintext = await decompressZstdPrefix(buffer.subarray(tornStart)) + } 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. + } signal?.throwIfAborted() - recoveredPlaintext = await decompressZstdPrefix(buffer.subarray(tornStart)) - } catch { + scanner.write(recoveredPlaintext) + const recoveredPrefix = scanner.finish() + signal?.throwIfAborted() + return { + meta: recoveredPrefix.meta, + events: recoveredPrefix.events, + tornMarker: { + truncateTo: tornStart, + recoveredEvents: recoveredPrefix.events.slice(complete.eventCount), + }, + } + } catch (error) { /* 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. - } - signal?.throwIfAborted() - const recoveredPrefix = scanLog(Buffer.concat([completePlaintext, recoveredPlaintext])) - signal?.throwIfAborted() - /* v8 ignore next 3 -- appending plaintext cannot shorten the already-scanned complete prefix */ - if (recoveredPrefix.events.length < completePrefix.events.length) { - throw new Error('corrupt Zstandard session log: recovered prefix does not extend complete frames') - } - return { - meta: recoveredPrefix.meta, - events: recoveredPrefix.events, - tornMarker: { - truncateTo: tornStart, - recoveredEvents: recoveredPrefix.events.slice(completePrefix.events.length), - }, + throw error + } finally { + decoder.close() } } @@ -296,13 +382,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi signal?.throwIfAborted() snapshots.push({ header: artifact.header, - revision: SessionPersistenceRevision([ - identity.dev, - identity.ino, - identity.size, - identity.mtimeNs, - identity.ctimeNs, - ].join(':')), + revision: fileRevision(identity), }) } catch (error: unknown) { signal?.throwIfAborted() @@ -606,9 +686,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error }) } signal?.throwIfAborted() - if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) { - throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') - } + assertZstdHeaderFrame(plaintext) return plaintext.subarray(0, -1).toString('utf8') } } finally { diff --git a/packages/session-persistence/session-persistence-jsonl/src/zstd-private-decoder.ts b/packages/session-persistence/session-persistence-jsonl/src/zstd-private-decoder.ts new file mode 100644 index 0000000000..6cc88aadf7 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/zstd-private-decoder.ts @@ -0,0 +1,178 @@ +/** + * Node-private synchronous Zstandard frame decoder optimization. + * @module dsh-session-persistence-jsonl/zstd-private-decoder + */ + +import { constants as bufferConstants } from 'node:buffer' +import { createZstdDecompress } from 'node:zlib' +import type { ZstdFrameDecoder, ZstdFrameRange } from './zstd.ts' + +const DECODE_CHUNK_SIZE = 1024 * 1024 + +interface NodeZstdPrivateHandle { + writeSync( + flushFlag: number, + input: Buffer, + inputOffset: number, + inputLength: number, + output: Buffer, + outputOffset: number, + outputLength: number, + ): void +} + +type NodeZstdPrivateWriteState = Uint32Array & { 0: number; 1: number } + +interface NodeZstdPrivateState { + [key: symbol]: unknown + _handle: NodeZstdPrivateHandle | null + _writeState: NodeZstdPrivateWriteState + _defaultFlushFlag: number +} + +type NodeZstdPrivateStream = ReturnType & NodeZstdPrivateState + +/** Return the stream with its observed private Node contract, or reject that optimization. */ +function privateZstdStream( + stream: ReturnType, +): { stream: NodeZstdPrivateStream; errorKey: symbol } | undefined { + const candidate = stream as unknown as Partial + const handle = candidate._handle + const errorKey = Reflect.ownKeys(stream).find((key): key is symbol => ( + typeof key === 'symbol' && key.description === 'kError' + )) + /* v8 ignore next -- one test runtime exposes one Node-private shape; the Node 22/24/26 matrix checks compatibility. */ + if ( + typeof handle !== 'object' || handle === null + || typeof (handle as { writeSync?: unknown }).writeSync !== 'function' + || !(candidate._writeState instanceof Uint32Array) + || candidate._writeState.length < 2 + || typeof candidate._defaultFlushFlag !== 'number' + || errorKey === undefined + || candidate[errorKey] !== null + ) return undefined + return { stream: stream as NodeZstdPrivateStream, errorKey } +} + +/** + * Synchronous multi-frame decoder backed by one Node Zstd stream handle. Node + * exposes synchronous decoding only as a one-shot API, so this adapter uses + * the stream's private handle contract to reuse its native context and output + * chunks across frames. + */ +export class NodePrivateZstdFrameDecoder implements ZstdFrameDecoder { + private readonly output = Buffer.allocUnsafe(DECODE_CHUNK_SIZE) + private decoderError?: Error + private started = false + private closed = false + + private constructor( + private readonly stream: NodeZstdPrivateStream, + private readonly errorKey: symbol, + ) { + this.stream.on('error', (error: Error) => { + this.decoderError ??= error + }) + } + + /** + * Create the optimized decoder when this Node release exposes the expected + * private stream shape. + * @returns a shared decoder, or `undefined` when callers must use the public fallback. + */ + static create(): NodePrivateZstdFrameDecoder | undefined { + const stream = createZstdDecompress({ chunkSize: DECODE_CHUNK_SIZE }) + const privateAccess = privateZstdStream(stream) + /* v8 ignore next -- reached only when a supported Node release changes its private stream shape. */ + if (privateAccess !== undefined) { + return new NodePrivateZstdFrameDecoder(privateAccess.stream, privateAccess.errorKey) + } + /* v8 ignore next -- the active Node runtime passed the private-shape probe above. */ + stream.close() + /* v8 ignore next -- the active Node runtime passed the private-shape probe above. */ + return undefined + } + + /** @inheritdoc */ + public *decode(source: Buffer, frames: readonly ZstdFrameRange[]): Generator { + if (this.started) throw new Error('Zstandard frame decoder was already started') + if (this.closed) throw new Error('cannot start a closed Zstandard frame decoder') + this.started = true + try { + for (const frame of frames) { + try { + yield this.decodeFrame(source.subarray(frame.start, frame.end)) + } catch (error) { + throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { + cause: error, + }) + } + } + } finally { + this.close() + } + } + + /** Decode one frame; its returned scratch view remains valid until the next call. */ + private decodeFrame(input: Buffer): Buffer { + const handle = this.stream._handle + /* v8 ignore next -- decode() rejects closed instances before entering this private frame operation. */ + if (this.closed || handle === null) throw new Error('cannot decode with a closed Zstandard frame decoder') + + let inputOffset = 0 + let inputRemaining = input.length + let outputBytes = 0 + const fullChunks: Buffer[] = [] + for (;;) { + handle.writeSync( + this.stream._defaultFlushFlag, + input, + inputOffset, + inputRemaining, + this.output, + 0, + this.output.length, + ) + if (this.decoderError !== undefined) throw this.decoderError + const internalError = this.stream[this.errorKey] + if (internalError !== null) { + if (internalError instanceof Error) throw internalError + throw new Error('Zstandard decoder exposed a non-Error internal failure') + } + + const outputAfter = this.stream._writeState[0] + const inputAfter = this.stream._writeState[1] + const consumed = inputRemaining - inputAfter + const produced = this.output.length - outputAfter + if (produced > 0) { + outputBytes += produced + /* v8 ignore next -- Buffer cannot materialize a frame beyond its own process-wide maximum length. */ + if (outputBytes > bufferConstants.MAX_LENGTH) { + throw new Error(`Zstandard frame output exceeds ${bufferConstants.MAX_LENGTH} bytes`) + } + } + + if (outputAfter !== 0) { + /* v8 ignore next -- structurally scanned ranges contain exactly one complete frame and no trailing bytes. */ + if (inputAfter !== 0) throw new Error('Zstandard frame decoder left trailing input') + const finalChunk = this.output.subarray(0, produced) + if (fullChunks.length === 0) return finalChunk + if (produced > 0) fullChunks.push(Buffer.from(finalChunk)) + const onlyChunk = fullChunks[0] as Buffer + return fullChunks.length === 1 + ? onlyChunk + : Buffer.concat(fullChunks, outputBytes) + } + fullChunks.push(Buffer.from(this.output)) + inputOffset += consumed + inputRemaining = inputAfter + } + } + + /** @inheritdoc */ + close(): void { + if (this.closed) return + this.closed = true + this.stream.close() + } +} diff --git a/packages/session-persistence/session-persistence-jsonl/src/zstd-public-decoder.ts b/packages/session-persistence/session-persistence-jsonl/src/zstd-public-decoder.ts new file mode 100644 index 0000000000..b08c77dfdb --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/zstd-public-decoder.ts @@ -0,0 +1,40 @@ +/** + * Public-API synchronous Zstandard frame decoder fallback. + * @module dsh-session-persistence-jsonl/zstd-public-decoder + */ + +import { zstdDecompressSync } from 'node:zlib' +import type { ZstdFrameDecoder, ZstdFrameRange } from './zstd.ts' + +/** Multi-frame adapter built exclusively from Node's supported one-shot API. */ +export class PublicZstdFrameDecoder implements ZstdFrameDecoder { + private started = false + private closed = false + + /** @inheritdoc */ + public *decode(source: Buffer, frames: readonly ZstdFrameRange[]): Generator { + if (this.started) throw new Error('Zstandard frame decoder was already started') + if (this.closed) throw new Error('cannot start a closed Zstandard frame decoder') + this.started = true + try { + for (const { start, end } of frames) { + let decoded: Buffer + try { + decoded = zstdDecompressSync(source.subarray(start, end)) + } catch (error) { + throw new Error(`corrupt Zstandard session log: frame at byte ${start} failed validation`, { + cause: error, + }) + } + yield decoded + } + } finally { + this.close() + } + } + + /** @inheritdoc */ + close(): void { + this.closed = true + } +} diff --git a/packages/session-persistence/session-persistence-jsonl/src/zstd.ts b/packages/session-persistence/session-persistence-jsonl/src/zstd.ts index e29747e399..01b4d459f0 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/zstd.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/zstd.ts @@ -5,8 +5,12 @@ * @module dsh-session-persistence-jsonl/zstd */ -import { constants, zstdCompress, zstdDecompress, type ZstdOptions } from 'node:zlib' +import { + constants, zstdCompress, zstdDecompress, type ZstdOptions, +} from 'node:zlib' import { promisify } from 'node:util' +import { NodePrivateZstdFrameDecoder } from './zstd-private-decoder.ts' +import { PublicZstdFrameDecoder } from './zstd-public-decoder.ts' const ZSTD_MAGIC = 0xFD2FB528 const zstdCompressAsync = promisify(zstdCompress) @@ -117,6 +121,29 @@ export async function decompressZstdFrame(input: Buffer): Promise { return zstdDecompressAsync(input) } +/** Common lifecycle for interchangeable synchronous multi-frame decoders. */ +export interface ZstdFrameDecoder { + /** + * Decode and checksum complete frames in source order. Each yielded buffer + * remains valid only until the iterator advances to the next frame. + * @param source - concatenated Zstandard frame bytes. + * @param frames - structurally complete ranges within `source`. + * @returns one plaintext buffer per frame. + */ + decode(source: Buffer, frames: readonly ZstdFrameRange[]): Generator + /** Release decoder-owned resources; repeated calls are harmless. */ + close(): void +} + +/** + * Select the shared private decoder when the running Node 22/24/26 shape is + * compatible, otherwise preserve correctness with the public one-shot API. + * @returns a synchronous decoder with an implementation-independent lifecycle. + */ +export function createZstdFrameDecoder(): ZstdFrameDecoder { + return NodePrivateZstdFrameDecoder.create() ?? new PublicZstdFrameDecoder() +} + /** * Recover available plaintext from a structurally incomplete final frame. * `ZSTD_e_flush` deliberately suppresses final-frame and checksum completion; diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index c573948c2c..03a7f90d65 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -8,11 +8,30 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { - encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, 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' +const statRace = vi.hoisted(() => ({ + path: undefined as string | undefined, + reads: 0, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: (async (...args: Parameters) => { + 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 + return { ...identity, mtimeNs: identity.mtimeNs + 1n } + }) as typeof actual.stat, + } +}) + let root: string const dirs: string[] = [] @@ -54,6 +73,8 @@ function rawLogPath(root: string, cwd: string | undefined, id: SessionId): strin } afterEach(async () => { + statRace.path = undefined + statRace.reads = 0 vi.restoreAllMocks() for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) @@ -255,6 +276,57 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await otherCtx.fiber.dispose() }) + 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 SessionPersistenceJsonl + + 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('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 SessionPersistenceJsonl + statRace.path = rawLogPath(root, m.cwd, m.id) + + await expect(persistence.loadStored(m.id)).resolves.toMatchObject({ events: oneTurnLog() }) + expect(statRace.reads).toBe(4) + }) + + it('handles revision-stat races and errors after log discovery', async () => { + const m = meta('stored-revision-race') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as SessionPersistenceJsonl + const internals = persistence as unknown as { + findLog(id: SessionId, signal?: AbortSignal): Promise + } + const path = rawLogPath(root, m.cwd, m.id) + const findLog = vi.spyOn(internals, 'findLog').mockResolvedValue(path) + + await rm(path) + expect(await persistence.readStoredRevision(m.id)).toBeUndefined() + + const invalidPath = `${path}\0` + findLog.mockResolvedValue(invalidPath) + await expect(persistence.readStoredRevision(m.id)).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG_VALUE', + }) + + const reason = new Error('revision read 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) + }) + it('omits a snapshot artifact removed after discovery', async () => { const m = meta('vanishing-snapshot') await ctx.sessionPersistence.create(m) @@ -519,14 +591,12 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { } }) - it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => { + 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) - // A consumer mutates the returned meta's cwd. The backend's stored pathing - // metadata must be unaffected, so a later append still finds the right log. - mutableHeader(loaded.meta).cwd = '/evil' + 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' } } }, @@ -624,6 +694,65 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => describe('SessionPersistenceJsonl: scanLog unit', () => { + it('requires exactly one newline-terminated header record', () => { + const header = JSON.stringify(toHeaderLine(meta('scanner-header'))) + expect(() => new SessionLogScanner(Buffer.alloc(0))).toThrow(/header-less/) + expect(() => new SessionLogScanner(Buffer.from(header))).toThrow(/header-less/) + expect(() => new SessionLogScanner(Buffer.from(`${header}\n${header}\n`))).toThrow(/header-less/) + }) + + it('handles empty writes, boundary newlines, torn fragments, and scanner completion', () => { + const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-lifecycle')))}\n`) + const event = Buffer.from(JSON.stringify(oneTurnLog()[0])) + const scanner = new SessionLogScanner(header) + + scanner.write(Buffer.alloc(0)) + scanner.write(event) + scanner.write(Buffer.from('\nignored torn tail')) + const result = scanner.finish() + + expect(result.events).toEqual([oneTurnLog()[0]]) + expect(result.committedBytes).toBe(header.length + event.length + 1) + expect(() => { scanner.write(Buffer.from('\n')) }).toThrow(/finished/) + }) + + it('keeps scanning after a tolerable corrupt suffix until a committed turn end appears', () => { + const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-corrupt-suffix')))}\n`) + const scanner = new SessionLogScanner(header) + scanner.write(Buffer.from([ + JSON.stringify(oneTurnLog()[0]), + '{not json', + JSON.stringify({ type: 'step/start', seq: 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' } } }), + '', + ].join('\n'))) }).toThrow(/seq gap in committed region/) + }) + + it('incrementally scans records split across reusable decoder chunks', () => { + const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('incremental')))}\n`) + const body = Buffer.from(`${oneTurnLog().map(event => JSON.stringify(event)).join('\n').replace('"hi"', '"你好"')}\n`) + const split = body.indexOf(Buffer.from('你')) + 1 + const firstChunk = Buffer.from(body.subarray(0, split)) + const scanner = new SessionLogScanner(header) + + scanner.write(firstChunk) + const checkpoint = scanner.checkpoint() + firstChunk.fill(0) + scanner.write(body.subarray(split)) + + expect(checkpoint).toMatchObject({ + inputBytes: header.length + split, + eventCount: 1, + }) + expect(scanner.finish()).toEqual(scanLog(Buffer.concat([header, body]))) + }) + it('rejects a header-less / empty log', () => { expect(() => scanLog(Buffer.from(''))).toThrow() }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts index 697b95d58a..a8da30bd28 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from '../src/zstd.ts' +import { + compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, +} from '../src/zstd.ts' +import { NodePrivateZstdFrameDecoder } from '../src/zstd-private-decoder.ts' +import { PublicZstdFrameDecoder } from '../src/zstd-public-decoder.ts' describe('JSONL Zstandard compatibility', () => { it('round-trips concatenated checksummed frames through the built-in Node API', async () => { @@ -16,6 +20,17 @@ describe('JSONL Zstandard compatibility', () => { const decoded = await Promise.all(frames.map(frame => decompressZstdFrame(encoded.subarray(frame.start, frame.end)))) expect(Buffer.concat(decoded).toString()).toContain('"type":"turn/start"') + const preferred = createZstdFrameDecoder() + expect(preferred).toBeInstanceOf(NodePrivateZstdFrameDecoder) + for (const decoder of [preferred, new PublicZstdFrameDecoder()]) { + try { + const plaintext = Array.from(decoder.decode(encoded, frames), chunk => Buffer.from(chunk)) + expect(Buffer.concat(plaintext).toString()).toContain('"type":"turn/start"') + } finally { + decoder.close() + } + } + const eventFrame = encoded.subarray(frames[1]!.start, frames[1]!.end) const missingChecksumByte = eventFrame.subarray(0, -1) expect(scanZstdFrames(missingChecksumByte)).toEqual({ frames: [], tornStart: 0 }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts index ffb262eec1..b459fcac43 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts @@ -4,11 +4,17 @@ import { appendFile, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFil 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 } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts' -import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from '../src/zstd.ts' +import { + compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, + type ZstdFrameDecoder, +} from '../src/zstd.ts' +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' @@ -17,7 +23,7 @@ const roots: string[] = [] const contexts: Context[] = [] interface ZstdReaderInternals { - readZstdPrefix(buffer: Buffer, signal?: AbortSignal): Promise + readZstdPrefix(buffer: Buffer, signal?: AbortSignal): Promise<{ events: SessionEvent[] }> } type HeaderRead = ( @@ -151,6 +157,121 @@ describe('Zstandard frame structure', () => { expect(first[4]! & 0x04).toBe(0x04) expect(second[4]! & 0x04).toBe(0x04) expect((await decompressZstdFrame(first)).toString()).toBe('header\n') + const decoder = createZstdFrameDecoder() + try { + const plaintext = Array.from(decoder.decode(stream, scanZstdFrames(stream).frames), chunk => Buffer.from(chunk)) + expect(Buffer.concat(plaintext).toString()).toBe('header\nevent\n') + } finally { + decoder.close() + } + }) + + it('keeps the public and Node-private synchronous decoders interchangeable', async () => { + const frames = [await compressZstdFrame('first\n'), await compressZstdFrame('second\n')] + const stream = Buffer.concat(frames) + const ranges = scanZstdFrames(stream).frames + const privateDecoder = NodePrivateZstdFrameDecoder.create() + expect(privateDecoder).toBeDefined() + + for (const decoder of [new PublicZstdFrameDecoder(), privateDecoder!]) { + try { + const plaintext = Array.from(decoder.decode(stream, ranges), chunk => Buffer.from(chunk)) + expect(plaintext).toHaveLength(2) + expect(Buffer.concat(plaintext).toString()).toBe('first\nsecond\n') + } finally { + decoder.close() + } + } + }) + + it('falls back to the public decoder when the private Node contract is unavailable', () => { + vi.spyOn(NodePrivateZstdFrameDecoder, 'create').mockReturnValue(undefined) + const decoder = createZstdFrameDecoder() + expect(decoder).toBeInstanceOf(PublicZstdFrameDecoder) + decoder.close() + }) + + it('enforces decoder lifecycle and checksum errors through both implementations', async () => { + const frame = await compressZstdFrame('frame\n') + const range = [{ start: 0, end: frame.length }] + const corrupt = Buffer.from(frame) + corrupt[corrupt.length - 1] = corrupt[corrupt.length - 1]! ^ 0xFF + const factories: Array<() => ZstdFrameDecoder> = [ + () => new PublicZstdFrameDecoder(), + () => NodePrivateZstdFrameDecoder.create()!, + ] + + for (const create of factories) { + const interrupted = create() + const iterator = interrupted.decode(frame, range) + expect(iterator.next().value?.toString()).toBe('frame\n') + iterator.return() + expect(() => Array.from(interrupted.decode(frame, range))).toThrow(/already started/) + interrupted.close() + + const closed = create() + closed.close() + closed.close() + expect(() => Array.from(closed.decode(frame, range))).toThrow(/closed/) + + const invalid = create() + expect(() => Array.from(invalid.decode(corrupt, range))).toThrow(/frame at byte 0 failed validation/) + } + }) + + it('assembles private-decoder output at and beyond its reusable chunk boundary', async () => { + for (const length of [8, 9]) { + const plaintext = Buffer.alloc(length, 0x61) + const frame = await compressZstdFrame(plaintext) + const decoder = NodePrivateZstdFrameDecoder.create()! + ;(decoder as unknown as { output: Buffer }).output = Buffer.allocUnsafe(8) + const [decoded] = Array.from( + decoder.decode(frame, [{ start: 0, end: frame.length }]), + chunk => Buffer.from(chunk), + ) + expect(decoded).toEqual(plaintext) + } + }) + + it('normalizes private decoder stream failures', async () => { + interface PrivateDecoderInternals { + stream: { + [key: symbol]: unknown + emit(event: string, error: Error): boolean + } + errorKey: symbol + } + const frame = await compressZstdFrame('frame\n') + const range = [{ start: 0, end: frame.length }] + + const emitted = NodePrivateZstdFrameDecoder.create()! + const emittedInternals = emitted as unknown as PrivateDecoderInternals + const first = new Error('first emitted decoder failure') + emittedInternals.stream.emit('error', first) + emittedInternals.stream.emit('error', new Error('later emitted decoder failure')) + try { + Array.from(emitted.decode(frame, range)) + throw new Error('expected emitted decoder failure') + } catch (error) { + expect((error as Error).cause).toBe(first) + } + + for (const internalFailure of [new Error('internal decoder failure'), 'not an Error']) { + const decoder = NodePrivateZstdFrameDecoder.create()! + const internals = decoder as unknown as PrivateDecoderInternals + internals.stream[internals.errorKey] = internalFailure + try { + Array.from(decoder.decode(frame, range)) + throw new Error('expected internal decoder failure') + } catch (error) { + const cause = (error as Error).cause + if (internalFailure instanceof Error) { + expect(cause).toBe(internalFailure) + } else { + expect(cause).toMatchObject({ message: 'Zstandard decoder exposed a non-Error internal failure' }) + } + } + } }) it('distinguishes incomplete frame regions from invalid complete structure', () => { @@ -312,7 +433,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/frame at byte .* failed validation/) }) - it('stops multi-frame inspection after cancellation interrupts the active decode', async () => { + it('stops multi-frame inspection when cancellation arrives at a slice deadline', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('cancel-zstd-frames') @@ -320,22 +441,32 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { const eventFrame = await compressZstdFrame(`${JSON.stringify(oneTurnLog()[0])}\n`) const laterFrame = await compressZstdFrame(`${JSON.stringify(oneTurnLog()[1])}\n`) const stream = Buffer.concat([headerFrame, eventFrame, laterFrame]) - expect(scanZstdFrames(stream).frames).toHaveLength(3) const controller = new AbortController() const reason = new Error('cancel after Zstandard decode starts') const reader = ctx.sessionPersistence as unknown as ZstdReaderInternals - const zstdModule = await import('../src/zstd.ts') - const decode = vi.spyOn(zstdModule, 'decompressZstdFrame') - - // readZstdPrefix reaches its first asynchronous decompression before it - // returns this promise. The microtask abort therefore occurs after decode - // starts and must prevent every later frame from reaching the decoder. + vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(501) const pending = reader.readZstdPrefix(stream, controller.signal) queueMicrotask(() => { controller.abort(reason) }) await expect(pending).rejects.toBe(reason) - expect(decode).toHaveBeenCalledTimes(1) - expect(decode).toHaveBeenCalledWith(headerFrame) + }) + + it('continues decoding every frame after a slice deadline yields', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('yield-zstd-frames') + const events = oneTurnLog().slice(0, 2) + const headerFrame = await compressZstdFrame(`${JSON.stringify(toHeaderLine(header))}\n`) + const eventFrames = await Promise.all(events.map(async event => ( + compressZstdFrame(`${JSON.stringify(event)}\n`) + ))) + const stream = Buffer.concat([headerFrame, ...eventFrames]) + const reader = ctx.sessionPersistence as unknown as ZstdReaderInternals + vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(501) + + const prefix = await reader.readZstdPrefix(stream) + + expect(prefix.events).toEqual(events) }) it.each(['none', 'zstd'] as const)( diff --git a/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml b/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml index 8dea06ca1f..3dbbf87a2d 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml +++ b/packages/session-persistence/session-persistence-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-persistence/session-persistence-sqlite/README.md -README.md: 394b10a70fc757d75f19178050c0d63699a59e54 -README.zh.md: 6d2a47aa64cee59c35c558e1923a112b0d72f58b +README.md: d01ba6ebfa1f59a9e4d58f3032bbe1d016970290 +README.zh.md: c11bef5467a3401948b58d5fd8e3301b72ff3d03 diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 394b10a70f..d01ba6ebfa 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -21,8 +21,8 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). - **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. -- **Non-mutating inspection.** `inspect()` returns the detached valid row prefix without deleting a torn tail row or appending recovery closers, and leaves the lightweight revision unchanged. -- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible. +- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without deleting a torn tail row, appending recovery rows, or changing the lightweight revision. +- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. A full-prefix read captures that revision and its event rows in one read transaction, while `readStoredRevision()` queries only the session row to validate retained preparations. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible. ## Configuration (schemastery) @@ -30,6 +30,7 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di interface Config { path: string // SQLite database file path, or ':memory:' for an in-process DB journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' + preparedSessionCacheSize?: number // positive integer; default 5 } ``` diff --git a/packages/session-persistence/session-persistence-sqlite/README.zh.md b/packages/session-persistence/session-persistence-sqlite/README.zh.md index 6d2a47aa64..c11bef5467 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.zh.md +++ b/packages/session-persistence/session-persistence-sqlite/README.zh.md @@ -21,8 +21,8 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 实现(见[ - **Append = 事务。**`append` 围绕批次运行 `BEGIN`/`COMMIT`:它实体化 `sessions` 行(如果仍延迟),并 INSERT 每个事件,首先断言连续 seq 契约(第一个事件 `seq` 必须等于已存储 next-seq)。批次中失败(重复 seq 上的 UNIQUE 违规)会完全回滚,使已存储日志和内存游标保持一致。(`load()` 已平衡已存储日志,因此 `append` 不必修复崩溃尾部。) - **延迟实体化。**`create()` 只在内存记录意图,第一次 `append` 前不写行。从未 append 的会话没有 `sessions` 行,因此不在 `list()` 中(它精确报告有行的会话)。 - **在 load 时关闭中断轮次。**`load()` 实现共享[崩溃恢复契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md):保留有效中断轮次,在一个事务中追加合成关闭事件,并只移除撕裂尾部行。已提交解析错误或序列缺口使会话无法加载。恢复会变更已存储行,因此下一次 append 从平衡日志和准确游标开始。 -- **非变更检查。**`inspect()` 返回脱离的有效行前缀,不删除撕裂尾部行或追加恢复 closer,并保持轻量修订不变。 -- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。 +- **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会删除撕裂尾部行、追加恢复行或更改轻量修订。 +- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。完整前缀读取在同一个读事务中捕获该 revision 及其事件行,`readStoredRevision()` 则只查询 session 行来校验保留的 preparation。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。 ## 配置(schemastery) @@ -30,6 +30,7 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 实现(见[ interface Config { path: string // SQLite database file path, or ':memory:' for an in-process DB journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' + preparedSessionCacheSize?: number // positive integer; default 5 } ``` diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 8472b9836c..173d1bf857 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -14,11 +14,12 @@ import { DatabaseSync } from 'node:sqlite' import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, type StoredSuffix, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' @@ -38,6 +39,13 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { ] } +/** Build the source-qualified revision shared by full and lightweight reads. */ +function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision { + return SessionPersistenceRevision( + `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, + ) +} + /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. @@ -73,6 +81,8 @@ export interface Config { * (network mounts). See {@link JournalMode}. */ journalMode?: JournalMode + /** Maximum cold Session preparations retained for history-to-resume reuse. */ + preparedSessionCacheSize?: number } /** @@ -86,6 +96,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers static Config: z = z.object({ path: z.string().required(), journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), + preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE), }) /** @@ -102,10 +113,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers constructor(ctx: Context, public config: Config) { super(ctx) + // Programmatic wrappers may construct the backend without Schemastery normalization. + const preparedSessionCacheSize = config.preparedSessionCacheSize + ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE // Open asynchronously so directory creation does not block plugin apply; // every storage hook awaits the same readiness promise. this.ready = this.openDb(config.path, (config as Required).journalMode) - this.coordinator = new PersistenceCoordinator(this.ctx, this) + this.coordinator = new PersistenceCoordinator(this.ctx, this, { + preparedSessionCacheSize, + }) } private async openDb(path: string, journalMode: JournalMode): Promise { @@ -153,11 +169,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.append(id, events) } - load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + 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<{ meta: SessionHeader; events: SessionEvent[] }> { + inspect(id: SessionId, signal?: AbortSignal): Promise { return this.coordinator.inspect(id, signal) } @@ -175,6 +195,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.readPrefix(id, signal) } + /** Read one row's revision without loading its events. */ + async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ready + signal?.throwIfAborted() + const row = this.rowFor(id) + return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row) + } + /** * Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the * read scales with the suffix, not the log. Torn rows past the preserved @@ -204,15 +233,33 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers signal?.throwIfAborted() await this.ready signal?.throwIfAborted() - const row = this.rowFor(id) - if (row === undefined) return undefined - const meta = rowToMeta(row) - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq') - .all(id) as unknown as EventRow[] + this.db.exec('BEGIN') + let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined + try { + const row = this.rowFor(id) + if (row !== undefined) { + const eventRows = this.db + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq') + .all(id) as unknown as EventRow[] + snapshot = { row, eventRows } + } + this.db.exec('COMMIT') + } catch (error: unknown) { + /* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */ + this.db.exec('ROLLBACK') + throw error + /* v8 ignore stop */ + } signal?.throwIfAborted() + if (snapshot === undefined) return undefined + const { row, eventRows } = snapshot const { preserved, tornFrom } = scanRows(eventRows) - return { meta, events: preserved, ...tornFrom !== undefined ? { tornMarker: tornFrom } : {} } + return { + meta: rowToMeta(row), + events: preserved, + revision: sqliteRevision(this.storeIdentity, row), + ...tornFrom !== undefined ? { tornMarker: tornFrom } : {}, + } } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a619309fd8..d215696748 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -313,7 +313,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 2, data: createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, - }) }, + }), surfaceOp: 'append' }, ]) await b1.dispose() @@ -576,6 +576,19 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b.dispose() }) + it('binds a full stored prefix to the same revision as a lightweight read', async () => { + const b = await backend() + const m = meta('stored-prefix-revision') + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = b.ctx.sessionPersistence as SessionPersistenceSqlite + + const stored = await persistence.loadStored(m.id) + expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) + expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() + await b.dispose() + }) + it('changes revisions when a deleted session id is materialized again in the same database', async () => { const path = await freshDbPath() const m = meta('recreated-revision') @@ -641,6 +654,38 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('resolves the preparation-cache default without schema normalization', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let persistence!: SessionPersistenceSqlite + await ctx.plugin(Object.assign((inner: Context) => { + persistence = new SessionPersistenceSqlite(inner, { + path: ':memory:', + journalMode: 'wal', + }) + }, { inject: ['sessions'] })) + + expect(await persistence.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('uses the configured preparation cache through the public service', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { + path: ':memory:', + preparedSessionCacheSize: 1, + }) + const m = meta('sqlite-preparation-cache') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + + const preparation = await ctx.sessionPersistence.prepare(m.id) + expect(preparation.session.header).toEqual(m) + preparation[Symbol.dispose]() + await fiber.dispose() + }) + it('rejects and closes a current-schema database with an invalid store identity', async () => { const path = await freshDbPath() const db = openDatabase(path, 'wal') diff --git a/packages/session-persistence/session-persistence/README.i18n.yaml b/packages/session-persistence/session-persistence/README.i18n.yaml index a9491f64de..7c96b1a541 100644 --- a/packages/session-persistence/session-persistence/README.i18n.yaml +++ b/packages/session-persistence/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-persistence/session-persistence/README.md -README.md: 65b50c41670c400284f52c57795b2be49bf21117 -README.zh.md: 7497da2bd6ca03b78d769c1df9995ceb925fdacc +README.md: b29ff5ba17f384e8d3b1700ed3ad6c80aeeb184c +README.zh.md: 8ca4b6a7128383fdca706f35914a06eb1f26de02 diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 65b50c4167..b29ff5ba17 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -13,9 +13,10 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. | | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | -| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the supported same-version message and pre-react-loop event shapes into the current read snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. | -| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The read-from-seq primitive: return the header plus the valid stored events with `seq >= fromSeq`, detached and non-mutating like `inspect` (no truncation, no closers, no coordinator state). A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a legacy event in that suffix requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers (e.g. the persisted projection cache) that fold only the tail past a watermark. | +| `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after supported same-version shape upgrades and commit cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed shapes, and unknown `version` reject. | +| `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The detached physical-suffix primitive: return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a supported old shape requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that fold only the tail past a watermark. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -32,27 +33,28 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l Each `session/event` copies its event into the session controller and starts an eager drain without blocking the producer. Concurrent notifications share the current drain; events admitted during a write remain pending and trigger the next batch. `session/flush` is an observation barrier that waits until the controller has no current or pending batch. An eager failure is logged and retains the batch; the next explicit flush or backend teardown retries it and surfaces failure to its caller. -Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. +Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing unavailable cancellation provenance. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. -The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it. +The side-effect-free `locate`, lightweight `listSnapshots`, and per-id `readStoredRevision` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it. The `PersistenceBackend` hooks (the only seam between the coordinator and storage): | Hook | Role | |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | -| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | +| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; `revision` identifies exactly the returned header and events; an opaque `tornMarker` is present iff a torn tail must be truncated. | +| `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `loadStored` and returns `undefined` when the id is absent. | | `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. This freshness check does not add cross-process writer exclusion. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Metadata and location types diff --git a/packages/session-persistence/session-persistence/README.zh.md b/packages/session-persistence/session-persistence/README.zh.md index 7497da2bd6..8ca4b6a712 100644 --- a/packages/session-persistence/session-persistence/README.zh.md +++ b/packages/session-persistence/session-persistence/README.zh.md @@ -13,9 +13,10 @@ | `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 | | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | -| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会将受支持的同版本消息形状与 react-loop 重构前的事件形状升级为当前读取快照;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 | -| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | read-from-seq 原语:返回 header 和 `seq >= fromSeq` 的有效已存储事件,与 `inspect` 同样脱离且非变更(不截断、不合成 closer、不发布协调器状态)。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非后缀中的旧版事件需要前缀上下文才能完成规范化;顺序后端(JSONL)会解析整个产物并向前跳过。用于从水位续折尾部的 checkpoint 消费方(例如持久投影缓存)。 | +| `prepare(id, signal?): Promise` | 预留恢复使用的精确未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | +| `load(id): Promise<{ meta; events }>` | 在升级受支持的同版本形状后返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的形状和未知 `version` 会被拒绝。 | +| `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;已经实时存在的视图则是当前不可变快照,可能包含打开的 turn。基于协调器的实现会在有界 LRU 中保留精确的冷未发布 Session,供后续 `prepare` 使用,但已存储 revision 变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 脱离的物理后缀原语:返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非受支持的旧形状需要前缀上下文才能完成规范化;顺序后端(JSONL)解析整个产物并向前跳过。用于只续折水位之后尾部的 checkpoint 消费方。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | @@ -32,27 +33,28 @@ 每个 `session/event` 将事件复制到会话 controller,并在不阻塞生产者的情况下立即启动 drain。并发通知共享当前 drain;写入期间接纳的事件保持 pending,并触发下一批。`session/flush` 是观察屏障,会等待 controller 无当前或 pending 批次。即时写入失败会记录日志并保留批次;下一次显式 flush 或后端拆卸会重试该批次,并将失败返回给调用方。 -崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id,因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 +崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源 revision 仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留精确 Session,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 重构前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会在不虚构无法获得的取消来源的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 重构前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 -无副作用 `locate` 和轻量 `listSnapshots` 查询仍由后端负责,因为它们描述存储拓扑和修订身份,而非写入编排。`listSnapshots(signal?)` 将调用方传入的同一个信号传给后端发现流程,使观察者可在不脱离该工作的情况下取消。 +无副作用 `locate`、轻量 `listSnapshots` 和按 id 查询的 `readStoredRevision` 仍由后端负责,因为它们描述存储拓扑和 revision 身份,而非写入编排。`listSnapshots(signal?)` 将调用方传入的同一个信号传给后端发现流程,使观察者可在不脱离该工作的情况下取消。 `PersistenceBackend` 钩子(协调器与存储之间的唯一 seam): | 钩子 | 职责 | |---|---| | `name` | dispose 失败 `AggregateError` 的后端标签。 | -| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于恢复/加载、非修改式 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | +| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于恢复/加载、非修改式 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;`revision` 精确标识返回的 header 和事件;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | +| `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定 revision。它使用与 `loadStored` 相同的 revision 表示;id 不存在时返回 `undefined`。 | | `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非变更、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和实时接管(仅截断)使用。 | | `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 | | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待。 | -协调器断言已存储 id,并在修复或实时接管前比较已存储/实时 cwd。其 `inspect()` 路径验证并克隆前缀,不调用 `commitRepair` 或发布写入状态。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同非变更检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)。 +协调器断言已存储 id,并在修复或实时接管前比较已存储/实时 cwd。其 `inspect()` 路径取得新鲜后端值的所有权,只验证和冻结一次,并在不调用 `commitRepair` 的情况下最多保留配置数量的未发布 Session。只有保留源的 revision 仍等于 `readStoredRevision` 时,系统才会复用或修复它;否则协调器会重新读取。该新鲜性校验不会增加跨进程写入排他。持久日志在一次读取与复核往返内保持不变时,revision 重试才能收敛;持续的外部写入可能延迟 `load`、`inspect` 或 `prepare`。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同非变更检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)。 ## 元数据与位置类型 diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 4c6d940888..1ff5d0d3eb 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -7,21 +7,52 @@ import { Context } from 'cordis' import { + adoptSessionEvent, interruptedTurnClosers, SESSION_FORMAT_VERSION, + SessionPreparation, snapshotJsonValue, snapshotSessionEvent, } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionInspection } from './index.ts' +import type { SessionPersistenceRevision } from './revision.ts' +import { observeQueuedAbort, SessionPreparations } from './preparations.ts' +import type { SessionPreparationReservation } from './preparations.ts' + +/** Default number of detached session preparations retained by a coordinator. */ +export const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5 + +/** 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' + } +} + +/** Coordinator policy supplied by a concrete persistence backend. */ +export interface PersistenceCoordinatorOptions { + /** Maximum completed unpublished preparations retained for reuse. */ + readonly preparedSessionCacheSize: number +} /** - * A stored session's header, valid contiguous event prefix, and optional opaque - * torn-tail marker. The coordinator only checks marker presence and returns its - * value to {@link PersistenceBackend.commitRepair}; each backend owns the type. + * 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 { meta: SessionHeader events: SessionEvent[] + /** Revision observed for exactly this detached prefix. */ + revision: SessionPersistenceRevision tornMarker?: TornMarker } @@ -55,12 +86,24 @@ export interface PersistenceBackend { * `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. + * `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 @@ -138,6 +181,17 @@ interface LiveSessionState { flush: Promise | undefined } +/** 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: number + 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]) @@ -443,6 +497,22 @@ function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): S }) } +/** 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 @@ -463,15 +533,26 @@ export class PersistenceCoordinator { private live = new Map() /** Exact disposed lifecycles whose eager tail is still draining. */ private retirements = new Map>() - /** Cold loads currently reserving an id across backend reads and repair writes. */ - private coldLoads = new Set() + /** 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>() - constructor(private ctx: Context, private backend: PersistenceBackend) { + constructor( + private ctx: Context, + private backend: PersistenceBackend, + options: PersistenceCoordinatorOptions = { + preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE, + }, + ) { + if (!Number.isSafeInteger(options.preparedSessionCacheSize) + || options.preparedSessionCacheSize < 1) { + throw new TypeError('preparedSessionCacheSize must be a positive safe integer') + } + this.preparations = new SessionPreparations(options.preparedSessionCacheSize) this.installWritePath() } @@ -495,7 +576,7 @@ export class PersistenceCoordinator { private async createCore(meta: SessionHeader): Promise { // Do NOT clobber an existing session: the SessionId IS the identity. - if (this.states.has(meta.id)) { + 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/ @@ -537,8 +618,9 @@ export class PersistenceCoordinator { // this same backend will refuse to load. 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) // calls loadCore, not load + 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()) { @@ -552,67 +634,115 @@ export class PersistenceCoordinator { // cursor as soon as it commits (uniform across backends). state.materialized = true state.cursor += events.length + this.preparations.invalidate(id) } /** - * Reload a session: its {@link SessionHeader} plus the event log up to the last - * durable checkpoint, with any interrupted final turn durably closed (synthetic - * boundary events) during load. - * @param id - the persisted session to reload. - * @returns the header plus the event log, ending on a balanced `turn/end`. + * 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 load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - await this.retirements.get(id) - const selected = await this.serialize(id, async () => { - const live = this.ctx.sessions.get(id) - if (live !== undefined) return { live } - this.coldLoads.add(id) - try { - return { loaded: await this.loadCore(id) } - } finally { - this.coldLoads.delete(id) + 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`) } - }) - return 'loaded' in selected ? selected.loaded : this.loadLiveSnapshot(selected.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.events.length === reservation.source.sessionLength, + ) + }, + }) + } } /** - * Read a detached valid stored prefix without recovery mutations or - * coordinator-state publication. - * @param id - persisted session to inspect. - * @param signal - optional cancellation for queued and backend read work. - * @returns stored header and events before any synthetic recovery closers. + * 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. */ - inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - // Waiting for an in-flight retirement drain must honor cancellation too: a - // slow drain would otherwise pin a cancelled inspect until it finishes, - // past the documented boundary. serialize() already races the signal for - // the queued read; do the same for the retirement wait. - const retired = Promise.resolve(this.retirements.get(id)) - const waited = signal === undefined ? retired : observeQueuedAbort(retired, signal, () => false) - return waited.then(() => this.serialize(id, () => this.inspectCore(id, signal), signal)) + 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 + } } - private async inspectCore( - id: SessionId, - signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - signal?.throwIfAborted() - let stored: StoredPrefix | undefined - try { - stored = await this.backend.loadStored(id, signal) - } catch (error: unknown) { - if (signal?.aborted) signal.throwIfAborted() - throw error - } - signal?.throwIfAborted() - if (stored === undefined) throw new Error(`session "${id}" not found`) - this.assertStoredId(id, stored.meta) - this.assertVersion(stored.meta) - const events = snapshotStoredEvents(stored.events, id) - return { - meta: structuredClone(stored.meta), - events, + /** + * 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 + } } } @@ -655,50 +785,136 @@ export class PersistenceCoordinator { this.assertStoredId(id, suffix.meta) this.assertVersion(suffix.meta) if (suffix.events.some(needsLegacyPrefix)) { - const whole = await this.inspectCore(id, signal) + const whole = await this.readStoredPrefix(id, signal) return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } } return { meta: structuredClone(suffix.meta), events: snapshotStoredEvents(suffix.events, id) } } - const whole = await this.inspectCore(id, signal) + const whole = await this.readStoredPrefix(id, signal) // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. return { meta: whole.meta, events: whole.events.slice(fromSeq) } } - private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const stored = await this.backend.loadStored(id) + /** Read one detached physical prefix without logical recovery or caching. */ + private async readStoredPrefix( + id: SessionId, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + signal?.throwIfAborted() + const stored = await this.backend.loadStored(id, signal) + signal?.throwIfAborted() if (stored === undefined) throw new Error(`session "${id}" not found`) - const { meta, events, tornMarker } = stored - this.assertStoredId(id, meta) - this.assertVersion(meta) - const storedEvents = snapshotStoredEvents(events, id) - - // Preserve complete interrupted events and synthesize only missing closers. - const closers = interruptedTurnClosers(storedEvents).map(snapshotSessionEvent) - const balanced = [...storedEvents, ...closers] - - // Repair storage before publishing coordinator state. - if (tornMarker !== undefined || closers.length > 0) { - await this.backend.commitRepair(meta, tornMarker, closers) + this.assertStoredId(id, stored.meta) + this.assertVersion(stored.meta) + return { + meta: structuredClone(stored.meta), + events: snapshotStoredEvents(stored.events, id), } - // Keep coordinator metadata detached from the returned record. - this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) - return { meta: structuredClone(meta), events: balanced } } - /** Return a durable balanced live snapshot without applying cold crash repair. */ - private async loadLiveSnapshot(session: Session): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const events = session.events.map(snapshotSessionEvent) + /** 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 Error(`session "${id}" not found`) + try { + const { meta, events, revision, tornMarker } = stored + this.assertStoredId(id, meta) + this.assertVersion(meta) + const storedEvents = adoptStoredEvents(events, id) + + // 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, + seedSource: 'persistence', + }) + const inspection: SessionInspection = Object.freeze({ + meta: session.header, + events: Object.freeze(balanced), + }) + return { + inspection, + session, + revision, + sessionLength: session.events.length, + tornMarker, + closers, + } + } catch (error: unknown) { + 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 = 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.meta, 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 ?? { + meta: source.inspection.meta, + cursor, + materialized: true, + } + state.meta = source.inspection.meta + 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.events 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`) - const meta = structuredClone(state.meta) if (events.length === 0) 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 { meta, events } + return Object.freeze({ meta: state.meta, events }) + } + + /** Borrow one immutable view from an already-live Session. */ + private inspectLive(session: Session): SessionInspection { + return Object.freeze({ meta: session.header, events: session.events }) + } + + /** 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. @@ -738,13 +954,13 @@ export class PersistenceCoordinator { /** Build a state for a session discovered in storage but not yet in memory. */ private async adopt(id: SessionId): Promise { - // loadCore (NOT load) — adopt runs inside an already-serialized op, so - // re-entering the chain via the public load() would deadlock. - await this.loadCore(id) - const state = this.states.get(id) - /* v8 ignore next -- loadCore always sets the state for the id */ - if (!state) throw new Error(`failed to adopt session "${id}"`) - return state + // 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 { @@ -795,9 +1011,6 @@ export class PersistenceCoordinator { // Capture the header on creation and persist a fork's seed once. ctx.on('session/created', (session) => { - if (this.coldLoads.has(session.id)) { - throw new Error(`cannot publish session "${session.id}" while its persisted history is loading`) - } void this.initFor(session) }) @@ -847,6 +1060,12 @@ export class PersistenceCoordinator { 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 + } const seed = session.events.map(e => structuredClone(e)) const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } this.live.set(session, live) @@ -855,6 +1074,28 @@ export class PersistenceCoordinator { 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.events.slice(state.cursor).map(event => structuredClone(event)) + this.preparations.attach(reservation) + state.owner = session + const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } + 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 @@ -922,7 +1163,7 @@ export class PersistenceCoordinator { // cwd mismatch before repair or state publication. const live = await this.backend.loadStored(id) if (live !== undefined) { - // Do NOT route through loadCore(): that crash-repairs open turns as + // 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) @@ -1011,50 +1252,3 @@ export class PersistenceCoordinator { live.pending.splice(0, batch.length) } } - -/** - * Give an observation caller a prompt cancellation view of queued work. - * - * The serialized `operation` remains in the same-id chain and checks the signal - * before invoking backend work. Observing its settlement here therefore cannot - * detach a storage read or let a later operation overtake its predecessor. - */ -function observeQueuedAbort( - operation: Promise, - signal: AbortSignal, - started: () => boolean, -): 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('persistence 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 provider or AbortSignal reason, including legacy non-Error values. */ -function rejectObservation(reject: (reason?: unknown) => void, reason: unknown): void { - reject(reason) -} diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 5d4f5b616e..94596855df 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -6,6 +6,7 @@ */ import { Context, Service } from 'cordis' +import { SessionPreparation } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' @@ -21,9 +22,26 @@ export interface SessionPersistenceSnapshot { revision: SessionPersistenceRevision } +/** Immutable logical session prepared from persistence or a live owner. */ +export interface SessionInspection { + /** Validated immutable session metadata. */ + readonly meta: SessionHeader + /** Validated contiguous logical event log. */ + readonly events: readonly SessionEvent[] +} + // The backend-agnostic write-path orchestration first-party backends compose. -export { PersistenceCoordinator } from './coordinator.ts' -export type { PersistenceBackend, StoredPrefix, StoredSuffix } from './coordinator.ts' +export { + DEFAULT_PREPARED_SESSION_CACHE_SIZE, + PersistenceCoordinator, + SessionPersistenceCorruptionError, +} from './coordinator.ts' +export type { + PersistenceBackend, + PersistenceCoordinatorOptions, + StoredPrefix, + StoredSuffix, +} from './coordinator.ts' declare module 'cordis' { interface Context { @@ -83,46 +101,72 @@ export abstract class SessionPersistence extends Service { abstract append(id: SessionId, events: readonly SessionEvent[]): Promise /** - * Load a header and balanced contiguous log. 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 with its stored header as a durable snapshot, while an - * open live turn rejects. - * A coordinator-backed cold load reserves the identity across storage awaits, - * so concurrent publication of a same-id live Session rejects. - * Returned events are detached, and every identified message is deeply - * frozen. Coordinator-backed implementations upgrade supported pre-identity - * message events before validation; other malformed messages reject before - * any stored event is returned. + * 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), + 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<{ meta: SessionHeader; events: SessionEvent[] }> + abstract load(id: SessionId): Promise /** - * Inspect a header and its valid contiguous stored prefix without repairing - * a torn tail, closing an interrupted turn, or publishing coordinator state. - * This read is serialized with writes for the same id and returns detached - * values with upgraded, deeply frozen identified messages, so observers - * cannot mutate message identity/content or backend-owned state. Other - * malformed messages reject. + * 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 header and valid stored event prefix exactly as observed. + * @returns the validated header and current logical event log. */ - abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + abstract inspect(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). Like - * {@link inspect} it is non-mutating and detached: no torn-tail truncation, - * no synthetic closers, no coordinator-state publication; only events from - * the valid contiguous stored prefix are returned, so a torn fragment never - * reaches the caller. `fromSeq` at or beyond the stored prefix returns an - * empty event list (never an error). Backends whose medium can seek by seq + * projection cache folding only the tail past its checkpoint). Unlike + * {@link inspect}, it is a detached physical suffix read: no preparation + * cache, torn-tail truncation, synthetic closers, or coordinator-state + * publication. Only events from the valid contiguous stored prefix are + * returned, so a torn fragment never reaches the caller. `fromSeq` at or + * beyond the stored prefix returns an empty event list (never an error). + * Backends whose medium can seek by seq * (SQLite) read only the suffix; sequential media (JSONL, both encodings) * still parse the whole artifact and skip forward — the primitive bounds * what is RETURNED and refolded, not every backend's physical read. diff --git a/packages/session-persistence/session-persistence/src/preparations.ts b/packages/session-persistence/session-persistence/src/preparations.ts new file mode 100644 index 0000000000..2a685f71f9 --- /dev/null +++ b/packages/session-persistence/session-persistence/src/preparations.ts @@ -0,0 +1,348 @@ +/** + * Bounded sharing and exclusive reservation of unpublished Sessions. + * @module @deepseek-ai/dsh-session-persistence/preparations + */ + +import type { Session, SessionId } from '@deepseek-ai/dsh-session' + +interface PreparedSource { + readonly session: Session +} + +type PreparationPhase = 'loading' | 'ready' | 'committing' | 'reserved' + +interface PreparationEntry { + readonly id: SessionId + readonly result: Promise + phase: PreparationPhase + source?: Source + reservation?: SessionPreparationReservation + reservationSettled?: Promise + settleReservation?: () => void +} + +/** 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 + } + + /** + * 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', + } + 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') 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-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 5361dd5b45..a672884e46 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -136,7 +136,7 @@ export function runPersistenceContract(name: string, make: () => Promise e.type)).toEqual([ 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', - 'turn/start', 'step/start', + 'turn/start', 'step/start', 'step/end', 'turn/end', ]) // load PRESERVES the interrupted turn's events (a turn can be huge — they @@ -191,7 +191,7 @@ export function runPersistenceContract(name: string, make: () => Promise +/** Test-store revision that changes for any metadata or event mutation. */ +function memoryRevision(entry: { meta: SessionHeader; 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 { @@ -91,12 +96,17 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.append(id, events) } + override prepare(id: SessionId, signal?: AbortSignal): ReturnType { + return this.coordinator.prepare(id, signal) + } + load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.coordinator.load(id) + return this.coordinator.load(id).then(loaded => ({ meta: loaded.meta, events: [...loaded.events] })) } inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.coordinator.inspect(id, signal) + .then(loaded => ({ meta: loaded.meta, events: [...loaded.events] })) } readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { @@ -109,7 +119,16 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend async loadStored(id: SessionId): Promise | undefined> { const entry = this.store.get(id) if (!entry) return undefined - return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } + return { + meta: structuredClone(entry.meta), + 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(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { @@ -146,7 +165,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend signal?.throwIfAborted() return [...this.store.values()].map(entry => ({ header: structuredClone(entry.meta), - revision: SessionPersistenceRevision(`events:${entry.events.length}`), + revision: memoryRevision(entry), })) } } @@ -162,18 +181,29 @@ class ControlledBackend implements PersistenceBackend { 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: number, signal?: AbortSignal) => Promise | undefined> + seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise - loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise | undefined> { + loadStoredFrom(id: SessionId, fromSeq: number, 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> { - await this.beforeLoadStored?.(++this.loadAttempts, signal) + 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), events: structuredClone(entry.events) } + return { + meta: structuredClone(entry.meta), + 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(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { @@ -187,8 +217,10 @@ class ControlledBackend implements PersistenceBackend { } } - async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise { + async commitRepair(m: SessionHeader, _tornMarker: undefined, closers: readonly SessionEvent[]): Promise { this.repairAttempts += 1 + const entry = this.store.get(m.id) + if (entry !== undefined) entry.events.push(...structuredClone(closers) as SessionEvent[]) } async list(): Promise { @@ -345,7 +377,7 @@ describe('PersistenceCoordinator stored identity', () => { await expect(ctx.plugin(Object.assign((inner: Context) => { inner.sessions.create(id, { seed: [start], meta: header }) - }, { inject: ['sessions'] }))).rejects.toThrow(/persisted history is loading/) + }, { inject: ['sessions'] }))).rejects.toThrow(/persisted state already owns this identity/) expect(ctx.sessions.get(id)).toBeUndefined() loadGate.resolve(true) @@ -362,6 +394,658 @@ describe('PersistenceCoordinator stored identity', () => { }) }) +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, + })).toThrow(/positive safe integer/) + }) + + 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: 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: number } } | undefined + } + }).preparations + const reservation = preparations.reservationFor(preparation.session) + if (reservation === undefined) throw new Error('test preparation must stay reserved') + 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('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.events[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: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 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: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ) + + preparation = await coordinator.prepare(id) + expect(preparation.session.events).toHaveLength(9) + expect(preparation.session.events[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: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 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: 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: 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: 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.events) + 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: 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: 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, time: 3, data: { turn: 2 } }, + { type: 'turn/end', seq: 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.events.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: 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 }) + }, { 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: 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 observation cancellation', () => { it('promptly rejects a queued inspect without invoking it and keeps the same-id chain healthy', async () => { const ctx = new Context() @@ -400,7 +1084,7 @@ describe('PersistenceCoordinator observation cancellation', () => { await expect(prior).resolves.toMatchObject({ meta: { id } }) await observedAbort await expect(subsequent).resolves.toMatchObject({ meta: { id } }) - expect(backend.loadAttempts).toBe(2) + expect(backend.loadAttempts).toBe(1) await vi.waitFor(() => { expect((coordinator as unknown as CoordinatorInternals).chains.size).toBe(0) }) @@ -411,54 +1095,66 @@ describe('PersistenceCoordinator observation cancellation', () => { } }) - it('waits for active cooperative inspection cleanup before rejecting cancellation', async () => { + 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('active-inspect-cancellation') + const id = SessionId('creating-inspect-cancellation') backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) - const cleanupGate = Promise.withResolvers() - let cleanupComplete = false - backend.beforeLoadStored = async (_attempt, signal) => { - await new Promise((resolve) => { - signal?.addEventListener('abort', () => { - void cleanupGate.promise.then(() => { - cleanupComplete = true - resolve() - }) - }, { once: true }) - }) - throw new Error('backend cancellation after cleanup') + 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 { - const controller = new AbortController() - const reason = new Error('active inspect cancelled') - const pending = coordinator.inspect(id, controller.signal) - let observedReason: unknown - const observed = pending.catch((error: unknown) => { - observedReason = error - }) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) - - controller.abort(reason) - await Promise.resolve() - - expect(observedReason).toBeUndefined() - expect(cleanupComplete).toBe(false) - cleanupGate.resolve(true) - await observed - expect(cleanupComplete).toBe(true) - expect(observedReason).toBe(reason) - const backendFailure = new Error('later inspection failure') - backend.beforeLoadStored = () => Promise.reject(backendFailure) - await expect(coordinator.inspect(id)).rejects.toBe(backendFailure) + await expect(coordinator.inspect(id, controller.signal)).rejects.toBe(reason) } finally { - cleanupGate.resolve(true) + get.mockRestore() await fiber.dispose() await ctx.fiber.dispose() } @@ -540,6 +1236,7 @@ describe('PersistenceCoordinator observation cancellation', () => { // 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') @@ -552,7 +1249,7 @@ describe('PersistenceCoordinator observation cancellation', () => { // backend read. controller.abort(reason) await vi.waitFor(() => { expect(observedReason).toBe(reason) }) - expect(backend.loadAttempts).toBe(0) + expect(backend.loadAttempts).toBe(baselineLoads) appendGate.resolve(true) await observed @@ -621,15 +1318,17 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) await ctx.sessions.flush(first) - // Occupy the per-id serialize chain with a gated read: everything the - // two retirements queue stays pending behind it. (Attempt counting - // starts here — an absent beforeLoadStored short-circuits the optional - // call without evaluating its ++ argument.) - backend.beforeLoadStored = async (attempt) => { - if (attempt === 1) await readGate.promise + // 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.inspect(id).catch((error: unknown) => error) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) + const parked = coordinator.readFrom(id, 0).catch((error: unknown) => error) + await readEntered.promise // First retirement queues behind the gate and stays pending. await firstFiber.dispose() @@ -730,7 +1429,7 @@ describe('PersistenceCoordinator retirement', () => { await expect(ctx.plugin(Object.assign((inner: Context) => { inner.sessions.create(id) - }, { inject: ['sessions'] }))).rejects.toThrow(/persisted history is loading/) + }, { inject: ['sessions'] }))).rejects.toThrow(/persisted state already owns this identity/) loadGate.resolve(true) await expect(coldLoad).resolves.toMatchObject({ @@ -937,6 +1636,50 @@ describe('PersistenceCoordinator retirement', () => { }) describe('SessionPersistence service registration', () => { + 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) diff --git a/packages/session-persistence/session-persistence/tests/preparations.spec.ts b/packages/session-persistence/session-persistence/tests/preparations.spec.ts new file mode 100644 index 0000000000..5e12f29a79 --- /dev/null +++ b/packages/session-persistence/session-persistence/tests/preparations.spec.ts @@ -0,0 +1,360 @@ +/** Unit coverage for unpublished Session preparation ownership and sharing. */ + +import { describe, expect, it, vi } from 'vitest' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { observeQueuedAbort, SessionPreparations } from '../src/preparations.ts' + +interface PreparedSource { + readonly session: Session + readonly label: string +} + +function prepared(label: string): PreparedSource { + return { session: Session.create(SessionId(label)), label } +} + +function committed(source: PreparedSource): Promise<{ source: PreparedSource; state: string }> { + return Promise.resolve({ source, state: source.label }) +} + +describe('SessionPreparations inspection', () => { + it('shares in-flight and ready sources, then invalidates them', async () => { + const preparations = new SessionPreparations(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 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-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index 470a0008bb..6d801d9d23 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: df97333be3b2c2cf71dd8c9287959bcbd83a5063 -README.zh.md: 649d06cdc7c3a61f9f2459466bc9fdc3a42554e6 +README.md: b75c1f23264cfa7c9323970b3c1a77ddcfe69be0 +README.zh.md: e1b9727ff892047c56d006e02906f3270fb11293 diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index df97333be3..b75c1f2326 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -17,7 +17,7 @@ English | [中文](README.zh.md) - `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. - `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable; a successfully read durable record that fails Session validation reports `SESSION_QUERY_CORRUPT_SESSION` instead. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index 649d06cdc7..e1b9727ff8 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -17,7 +17,7 @@ - `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接父级向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 - `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。 -持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 +持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败;已经成功读取、但无法通过 Session 校验的持久化记录则以 `SESSION_QUERY_CORRUPT_SESSION` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 ## 过滤与提取 diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 475cc55dbf..a449799193 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -19,6 +19,7 @@ export interface Config { /** Stable machine-routable failure taxonomy for session reads, traces, and search. */ export type SessionQueryErrorCode = | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_CORRUPT_SESSION' | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 649a80965a..8711597b69 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -2,7 +2,7 @@ import type { Context, Fiber } from 'cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' import { SessionQueryError } from './config.ts' import { assertSessionHeadersCompatible } from './sources.ts' @@ -274,6 +274,13 @@ async function inspectPersisted( return await persistence.inspect(sessionId, signal) } catch (error: unknown) { if (signal?.aborted) signal.throwIfAborted() + if (error instanceof SessionPersistenceCorruptionError) { + throw new SessionQueryError( + `stored session "${sessionId}" is corrupt: ${errorMessage(error)}`, + 'SESSION_QUERY_CORRUPT_SESSION', + { cause: error }, + ) + } throw new SessionQueryError( `failed to inspect session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', diff --git a/packages/session-query/tool-session-query/src/service-boundary.ts b/packages/session-query/tool-session-query/src/service-boundary.ts index bf1dbd24f4..495897fddd 100644 --- a/packages/session-query/tool-session-query/src/service-boundary.ts +++ b/packages/session-query/tool-session-query/src/service-boundary.ts @@ -23,6 +23,10 @@ const SAFE_SESSION_QUERY_FAILURES = { code: 'SESSION_QUERY_ABORTED', message: 'session query was cancelled', }, + SESSION_QUERY_CORRUPT_SESSION: { + code: 'SESSION_QUERY_CORRUPT_SESSION', + message: 'session event history is corrupt', + }, SESSION_QUERY_EVENT_NOT_FOUND: { code: 'SESSION_QUERY_EVENT_NOT_FOUND', message: 'session event was not found', diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 2abc52bb29..3644180056 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -688,7 +688,7 @@ export class SubagentContinuationManager { } /** - * Cold-resume a persisted child: load and authorize its Session, fold the + * Cold-resume a persisted child: inspect and authorize its Session, fold the * generic descriptor, create the Activation through `ctx.agents.resume()`, * and submit the waiting turn. This never dispatches through a subagent * provider — the persisted Session already holds the initial prefix and the @@ -701,13 +701,13 @@ export class SubagentContinuationManager { options: SubagentFollowupOptions, ): Promise { const persistence = this.requirePersistence() - let loaded: Awaited> + let loaded: Awaited> try { - loaded = await persistence.load(childId) + loaded = await persistence.inspect(childId, options.signal) } catch (error: unknown) { + options.signal.throwIfAborted() throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) } - // The persistence seam takes no signal; recheck before any child work. options.signal.throwIfAborted() this.assertAdmitting(parent) // Authorize the persisted header before folding: only the durable child's @@ -724,17 +724,24 @@ export class SubagentContinuationManager { 'NOT_RESUMABLE', ) } - const activation = await this.materialize({ - childId, - provider: descriptor.provider, - parent, - agentOptions: { - ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, - ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, - }, - composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, - signal: options.signal, - }) + let activation: Activation + try { + activation = await this.materialize({ + childId, + provider: descriptor.provider, + parent, + agentOptions: { + ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, + ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + }, + composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, + signal: options.signal, + }) + } catch (error: unknown) { + options.signal.throwIfAborted() + if (error instanceof SubagentError) throw error + throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) + } return this.submitMaterialized(activation, content, options.source, parent, options.signal) } diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index c7d861c82f..cabbec121d 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -1,8 +1,9 @@ /** * Read-only interpretation of session-query lineage as durable subagent - * children. The module owns no catalog state and does not consult Activation, - * Agent-registry, continuation-manager, or provider state. A child's - * descriptor distinguishes one-shot work from a continuable conversation. + * children. Only descendants with durable `origin: 'subagent'` enter per-child + * inspection. The module owns no catalog state and does not consult Activation, + * Agent-registry, continuation-manager, or provider state. A child's descriptor + * distinguishes one-shot work from a continuable conversation. * * @module @deepseek-ai/dsh-subagent */ @@ -20,12 +21,13 @@ type SessionQueryRuntime = Pick< > /** - * One entry of a {@link listChildren} result in trace candidate order. A valid - * descriptor produces a `child`, a per-child inspection failure produces a - * `diagnostic`, and a descriptor-less ordinary child is omitted. Healthy rows - * include a one-level, origin-classified descendant hint. Diagnostics are - * transient query results, never session events or catalog state, and never - * expose model-hidden descriptor content. + * One entry of a {@link listChildren} result in trace candidate order. Only a + * candidate whose durable header has `origin: 'subagent'` is inspected. A + * valid descriptor produces a `child`, a per-child inspection failure produces + * a `diagnostic`, and a candidate without its own descriptor is omitted. + * Healthy rows include a one-level, origin-classified descendant hint. + * Diagnostics are transient query results, never session events or catalog + * state, and never expose model-hidden descriptor content. */ export type SubagentListEntry = | { @@ -69,8 +71,9 @@ export type SubagentListEntry = } /** - * Interpret one parent's direct session descendants as session-backed subagents - * without loading or resuming an Agent. + * Interpret one parent's origin-classified direct descendants as session-backed + * subagents without loading or resuming an Agent. Ordinary forks are skipped + * before per-child event inspection. * @see {@link SubagentService.listChildren} for the public cancellation and * failure contract. * @param ctx - context carrying the optional session-query service. @@ -103,6 +106,7 @@ export async function listChildren( ) const entries: SubagentListEntry[] = [] for (const node of trace.descendants) { + if (node.session.header.origin !== 'subagent') continue const hasChildren = node.descendants.some( descendant => descendant.session.header.origin === 'subagent', ) @@ -218,6 +222,8 @@ function perChildDiagnosticReason( ): 'corrupt' | 'unavailable' | undefined { if (!(error instanceof SessionQueryError)) return undefined switch (error.code) { + case 'SESSION_QUERY_CORRUPT_SESSION': + return 'corrupt' case 'SESSION_QUERY_SESSION_NOT_FOUND': case 'SESSION_QUERY_EVENT_NOT_FOUND': case 'SESSION_QUERY_PERSISTENCE_FAILED': diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index de521bfbd1..7b7a2ab541 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -568,6 +568,47 @@ describe('SubagentService.followup residency routing', () => { .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) }) + it('propagates cancellation while inspecting a cold child', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const inspectStarted = Promise.withResolvers() + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect').mockImplementation((_id, signal) => { + return new Promise((_resolve, reject) => { + if (signal === undefined) { + reject(new Error('cold inspection must receive the followup signal')) + return + } + inspectStarted.resolve(undefined) + signal.addEventListener('abort', () => { + reject(reason) + }, { once: true }) + }) + }) + const controller = new AbortController() + const reason = new Error('cold inspection cancelled') + + try { + const delivery = followup(ctx, parent, started.childId, message('cancel me'), controller.signal) + await inspectStarted.promise + controller.abort(reason) + await expect(delivery).rejects.toBe(reason) + } finally { + inspect.mockRestore() + } + }) + + it('preserves a SubagentError raised while cold-materializing a child', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const failure = new SubagentError('materialization denied', 'UNAUTHORIZED') + ctx.agents.resume = () => Promise.reject(failure) + + await expect(followup(ctx, parent, started.childId, message('continue'))) + .rejects.toBe(failure) + }) + it('cold-resumes a delivery that lost the race with final disposal', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('after the race')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 3855356f0f..f8ceab50a8 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -113,7 +113,9 @@ describe('SubagentService.listChildren', () => { const parentId = SessionId('query-only-parent') ctx.sessions.create(parentId) const childId = SessionId('query-only-child') - const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } }) + const child = ctx.sessions.create(childId, { + meta: { parentSession: parentId, origin: 'subagent' }, + }) child.append('turn/start', { turn: 1, }) @@ -192,6 +194,7 @@ describe('SubagentService.listChildren', () => { ] as SessionEvent[]) const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', { parentSession: coldParent, + origin: 'subagent', }, childEvents(descriptorPayload('persisted parent case'))) const entries = await ctx.subagents.listChildren(coldParent) expect(entries).toEqual([ @@ -202,28 +205,33 @@ describe('SubagentService.listChildren', () => { ]) }) - it('orders children by createdAt then id and omits ordinary forks without a diagnostic', async () => { + it('orders children by createdAt then id without inspecting ordinary forks', async () => { const { ctx, parent } = await setup([]) // Authored headers pin the ordering key deterministically: same createdAt // ties break on id, different createdAt orders ascending. const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', { parentSession: parent.id, createdAt: 9, + origin: 'subagent', }, childEvents(descriptorPayload('late child'))) const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', { parentSession: parent.id, createdAt: 5, + origin: 'subagent', }, childEvents(descriptorPayload('tie b'))) const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', { parentSession: parent.id, createdAt: 5, + origin: 'subagent', }, childEvents(descriptorPayload('tie a'))) - // An ordinary session fork shares parentSession but has no descriptor. + // An ordinary session fork shares parentSession but has no subagent origin. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) await ctx.sessions.flush(fork) + const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents') const entries = await ctx.subagents.listChildren(parent.id) expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late]) expect(entries.every(entry => entry.kind === 'child')).toBe(true) + expect(listEvents).not.toHaveBeenCalledWith(fork.id) }) it('reports a live child as running while keeping settled siblings complete', async () => { @@ -232,7 +240,9 @@ describe('SubagentService.listChildren', () => { // A live child session outside persistence: publish a live session with a // descriptor and the parent lineage, without starting an Activation. const liveId = SessionId('live-child') - const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } }) + const live = ctx.sessions.create(liveId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) live.append('turn/start', { turn: 1 }) live.append('subagent/descriptor', descriptorPayload('live child')) const entries = await ctx.subagents.listChildren(parent.id) @@ -259,6 +269,7 @@ describe('SubagentService.listChildren', () => { events[4] = { ...events[4]!, seq: 4 } const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', { parentSession: parent.id, + origin: 'subagent', }, events) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' }) @@ -268,12 +279,13 @@ describe('SubagentService.listChildren', () => { }) }) - it('diagnoses an invalid child event surface as corrupt', async () => { + it('diagnoses a child rejected by persisted Session preparation as corrupt', async () => { const { ctx, parent } = await setup([]) - // The surface-eligible user/message lacks its required surfaceOp, so the - // per-child listEvents fold fails with SESSION_QUERY_INVALID_SURFACE. + // The surface-eligible user/message lacks its required surfaceOp. The + // first-party persistence inspection rejects before session-query can fold it. const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', { parentSession: parent.id, + origin: 'subagent', }, [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, { @@ -292,6 +304,7 @@ describe('SubagentService.listChildren', () => { const { ctx, parent } = await setup([]) const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', { parentSession: parent.id, + origin: 'subagent', }, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 })) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }]) @@ -301,6 +314,7 @@ describe('SubagentService.listChildren', () => { const { ctx, parent } = await setup([]) const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', { parentSession: parent.id, + origin: 'subagent', }, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1))) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }]) @@ -314,6 +328,7 @@ describe('SubagentService.listChildren', () => { await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { parentSession: parent.id, seedLength: seed.length, + origin: 'subagent', }, seed) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([]) @@ -323,6 +338,7 @@ describe('SubagentService.listChildren', () => { const { ctx, parent } = await setup([]) const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', { parentSession: parent.id, + origin: 'subagent', }, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', @@ -353,16 +369,30 @@ describe('SubagentService.listChildren', () => { expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }]) }) - it('maps a mid-scan disappearance to unavailable', async () => { + it.each([ + ['session', 'SESSION_QUERY_SESSION_NOT_FOUND'], + ['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'], + ] as const)('maps a missing child %s to unavailable', async (_target, code) => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'vanishing child') const query = ctx.get('sessionQuery')! query.listEvents = () => - Promise.reject(new SessionQueryError('gone', 'SESSION_QUERY_SESSION_NOT_FOUND')) + Promise.reject(new SessionQueryError('gone', code)) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }]) }) + it('maps an invalid child surface to corrupt', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const childId = await startChild(ctx, parent, 'invalid surface') + const query = ctx.get('sessionQuery')! + query.listEvents = () => + Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE')) + + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }]) + }) + it('diagnoses a read whose header no longer names this parent as corrupt', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'reparented child') @@ -428,6 +458,7 @@ describe('SubagentService.listChildren', () => { const plain = await authorChild(ctx, '00000000-0000-4000-8000-00000000c0de', { parentSession: parent.id, createdAt: 1, + origin: 'subagent', }, childEvents(descriptorPayload('twin child'))) // The compacted twin: a compaction checkpoint replaces the whole surface, // while the append-only log retains the model-hidden descriptor event. @@ -446,6 +477,7 @@ describe('SubagentService.listChildren', () => { const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', { parentSession: parent.id, createdAt: 2, + origin: 'subagent', }, compactedEvents) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4d68840f58..57c3d203d4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -102,8 +102,11 @@ export const LINK_MAP: Readonly> = { StreamChunk: 'llm-streaming.md', SkillProviderControl: 'skills.md', CreateSessionOptions: 'persistence.md', + PrepareSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', + SessionInspection: 'persistence.md', SessionLocation: 'persistence.md', + SessionPreparation: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 9dd7a6bcc9..5d85466347 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -399,6 +399,32 @@ "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "RestoredSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "PrepareSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionPreparationOptions", + "source": "packages/core/session/src/preparation.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionPreparation", + "source": "packages/core/session/src/preparation.ts", + "projection": "public-api" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionInspection", + "source": "packages/session-persistence/session-persistence/src/index.ts" + }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionLocation",