diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml b/.agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml similarity index 66% rename from .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml rename to .agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml index fc26b05e5c..4032225487 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md -2026-08-18-sqlite-physical-chunk-row-compression.md: 3324d15abbc87b69a222c74f784fc565e287a38a -2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 57b252e2f4561f4659e0ed0ad34e0b4b5db68227 +2026-08-18-sqlite-physical-chunk-row-compression.md: 031e9a27575b9e802718dac040f9735335d39d0a +2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 1bc493c690de12c5eb9805f615cc32280cc3e608 diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md b/.agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md rename to .agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md index 3324d15abb..031e9a2757 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md +++ b/.agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md @@ -1,6 +1,7 @@ # Agent Note: SQLite physical chunk-row compression Status: implemented +Archived: 2026-08-30 English | [中文](2026-08-18-sqlite-physical-chunk-row-compression.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md b/.agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md rename to .agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md index 57b252e2f4..1bc493c690 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md +++ b/.agents/notes/archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md @@ -1,6 +1,7 @@ # Agent Note: SQLite 物理分片行压缩 Status: implemented +Archived: 2026-08-30 [English](2026-08-18-sqlite-physical-chunk-row-compression.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index a770e0ac92..18173d7d3d 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -52,6 +52,9 @@ "architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc", "architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf", "architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa", + "architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml": "sha256:42bce930799cb511e9fb245dec5e26efd78bdab4c9b75f7393e37b40fbee4d10", + "architecture/2026-08-18-sqlite-physical-chunk-row-compression.md": "sha256:4fe241f1b272278d9f3ca1a4431971220e1fa54411df043826ef6f59225bf949", + "architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md": "sha256:73178c9ec5abf571680d8facfb145cbadc1efbb2e67e3f039747c2f9cf4bb730", "bug-fix/2026-07-20-code-mode-result-card-completeness.i18n.yaml": "sha256:1035dae11d049d32ab09fd7d4f950eceae44bf46ba498b3cfaf3c75102b9fb64", "bug-fix/2026-07-20-code-mode-result-card-completeness.md": "sha256:6ca2c9d4df98be18813ef38b7462db880900b5bcd6944fbcd1b8f2258006b93e", "bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md": "sha256:ed85fa7f935e5f525d566bc37a92014614983e649c75de9a9f244939097a7991", 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 8f9aa62e49..3bdde9ece6 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: 62228bd2f5b25b13880a563818d08f3a2d52d956 -2026-06-14-session-persistence.zh.md: ebf004333c383336cd025aa8a4aabc9d1e07f0e5 +2026-06-14-session-persistence.md: 50ec79de83f0cef4a3ec94b689cc25937e334016 +2026-06-14-session-persistence.zh.md: 7b66aed6f077ac484802cfa1e23e1ba7ac3ae985 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 62228bd2f5..50ec79de83 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -21,16 +21,16 @@ Key durable, contested choices: - **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. - **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. -- **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 boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) +- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service and coordinator remain available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut. +- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` obtains the exact unpublished Session through `ctx.sessionPersistence.prepare()`, publishes it under the persisted id, and continues its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns reuse between history inspection and resume. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## 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. +Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there. +Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract. ## Consequences -Two new packages and the metadata contract 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. +The Service Definition, JSONL provider, and metadata contract in `dsh-session` (`session.header`, the `create(id?, options?)` signature) buy durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log. The reusable `runPersistenceContract` suite holds the provider and future implementations 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. 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 ebf004333c..7b66aed6f0 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 @@ -21,16 +21,16 @@ Status: implemented - **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 - **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 -- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `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 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。) +- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务与 coordinator 继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。 +- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。) - **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 -上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 +上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定。 ## 后果 -新增两个包,以及 `dsh-session` 中的元数据约定(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端可在同一接口下替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。 +Service Definition、JSONL provider 与 `dsh-session` 中的元数据约定(`session.header`,`create(id?, options?)` 签名)带来持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml index 936e601b48..46d2df95a6 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.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-session-surface.md -2026-06-18-session-surface.md: 3682ae7b8b58b9e5d40695732c3a1531d0651d5e -2026-06-18-session-surface.zh.md: 8cba9645dc6d0c8a4d1ee096668fc0bc38aaa725 +2026-06-18-session-surface.md: 95298da0e4bd16e822cb5960718d23ecda7a1b5c +2026-06-18-session-surface.zh.md: 7dd05d79f635b193b2c11cb3599264ebf2424d79 diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md index 3682ae7b8b..95298da0e4 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md @@ -41,7 +41,7 @@ Delta processing is O(1) when no new events and O(new events) when new events ar ### Persistence -The new fields are serialized as top-level JSON properties. The JSONL backend requires zero changes — `JSON.stringify`/`JSON.parse` preserve everything transparently. The SQLite backend's `events` table carries two nullable TEXT columns (`source_event_seqs`, `surface_op`). The on-disk `SCHEMA_VERSION` is bumped to reflect the column set, and — per the pre-release bump-and-reject policy — a database written by any other build is REJECTED on open rather than migrated (there is no persisted user data to upgrade). The session format `version` is pinned at `SESSION_FORMAT_VERSION = 0` (the "unstable / pre-release" stance): the optional surface fields are absorbed without bumping it. +The new fields are serialized as top-level JSON properties. JSONL storage requires no separate column mapping: its lossless JSON boundary preserves both values. The session format `version` is pinned at `SESSION_FORMAT_VERSION = 0`; the optional surface fields are absorbed without bumping it. ### Crash recovery @@ -64,7 +64,6 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d - **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). - **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Each `assistant/message` cites its chunk seqs; each `tool/result` cites its `tool/call` seq. -- **`packages/session/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration). - **`packages/session/session-persistence-jsonl`**: No changes required. - **`packages/session/session-persistence`**: Abstract interface unchanged. diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md index 8cba9645dc..7dd05d79f6 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md @@ -41,7 +41,7 @@ export type SurfaceOp = ### 持久化 -新字段作为顶层 JSON 属性序列化。JSONL 后端无需任何改动:`JSON.stringify`/`JSON.parse` 透明地保留一切。SQLite 后端的 `events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`)。磁盘上的 `SCHEMA_VERSION` 递增以反映列集变化,并且按照预发布的 bump-and-reject 策略,由其他构建写入的数据库在打开时被拒绝而非迁移(没有需要升级的持久化用户数据)。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`(「不稳定/预发布」立场):可选的 surface 字段被吸收而不递增版本号。 +新字段作为顶层 JSON 属性序列化。JSONL 存储无需单独列映射:其无损 JSON 边界会保留两个值。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`;可选 surface 字段被吸收而不递增版本号。 ### 崩溃恢复 @@ -64,7 +64,6 @@ export type SurfaceOp = - **`packages/core/session`**:`surface.ts`(`SurfaceManager`)维护一个用于候选接纳和实时投影的有序 seq 数组;`SessionSurface` 是其只读公共视图。`SurfaceOp`/`SurfaceIntent` 与顶层会话事件字段记录条目如何加入它。`append()` 要求 surface 事件携带 `SurfaceIntent`,`deriveMessages()` 以遍历 surface 作为唯一派生路径,`repair.ts` 则发出 surface 感知的闭合事件。种子构造函数拒绝缺少 `surfaceOp` 标记的可进入 surface 的种子事件(见「不变式」一节)。 - **`packages/core/agent-loop`**:所有涉及 surface 事件的追加操作都传入 surface 选项。每个 `assistant/message` 都引用产生它的分片 seq;每个 `tool/result` 都引用它的 `tool/call` seq。 -- **`packages/session/session-persistence-sqlite`**:`events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`);`SCHEMA_VERSION` 递增(bump-and-reject,无迁移)。 - **`packages/session/session-persistence-jsonl`**:无需改动。 - **`packages/session/session-persistence`**:抽象接口不变。 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 5148c8a648..a6f873d0f8 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: 8392ec726ff44e8a7173f48ef7d5cc4826b7e882 -2026-06-18-shared-persistence-write-coordinator.zh.md: e160f29247ae5cd02aaa8388c141faec64001857 +2026-06-18-shared-persistence-write-coordinator.md: a61ceb9b2197a6dd8ed86c1c971373a2706607aa +2026-06-18-shared-persistence-write-coordinator.zh.md: 777d5f5972ac1096c2e3434f9e0ac5aec27e8c26 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 8392ec726f..a61ceb9b21 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 @@ -6,11 +6,11 @@ English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) ## Problem -`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind control, per-id operation serialization, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the Service Definition package; the remaining orchestration was still correctness-heavy and received the same fixes twice. Only the storage primitives (write bytes vs. INSERT rows) differed. +The JSONL provider needs correctness-heavy write orchestration around its storage primitives: per-Session state, `session/created` adoption, prefix reads, write-behind control, per-id operation serialization, HMR seeding, and dispose drains. Keeping that lifecycle in the Service Definition prevents an out-of-tree provider from copying it. The removed first-party database provider demonstrated the duplication cost; the [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns its removal. ## 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`/`prepare`/`load`/`inspect`/`readFrom`) to it. Backend-owned metadata and revision listing bypass the coordinator. +`dsh-session-persistence` exports a backend-agnostic `PersistenceCoordinator`. The JSONL provider composes one (`new PersistenceCoordinator(ctx, this)`), implements the 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 risk that a coordinator makes 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`. @@ -27,26 +27,26 @@ The coordinator retires a session from `session/disposed`: it waits for the cont Five required members plus optional empty-materialization and lifecycle hooks 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). 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. +- `loadStored(id)` — read one stored prefix by id across every storage scope. 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. Ordinary creation therefore cannot leave an abandoned materialized-but-empty session. - `materializeHeader?(meta)` — explicitly persist a header-only session for `SessionPersistence.ensureMaterialized(session)`. This is reserved for a lifecycle frontend that treats an empty session itself as a resumable durable resource; [standard ACP automation controls](../feature/2026-08-22-standard-acp-automation-controls.md) are the first consumer. Backends that support that lifecycle implement the hook; lazy creation remains the default. -- `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 = []`). +- `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. 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. +- `close?()` — optional lifecycle teardown for a provider with owned resources; JSONL omits it. The dispose effect awaits it after the quiescence drain so a close failure never masks a drain error. ### The opaque torn marker -The single design choice that keeps the seam clean: the crash-repair "where is the torn tail" token is OPAQUE to the coordinator. The coordinator computes the synthetic closers (it owns `interruptedTurnClosers` from `dsh-session`), but it only ever tests `tornMarker !== undefined` and passes the value straight back to `commitRepair` — it never inspects it. Each backend picks its own marker type: JSONL carries the byte offset to truncate to plus any complete events decoded from an incomplete final frame, while SQLite carries the seq to delete from. The coordinator therefore knows neither byte lengths nor frame recovery state. +The single design choice that keeps the seam clean: the crash-repair "where is the torn tail" token is opaque to the coordinator. The coordinator computes the synthetic closers (it owns `interruptedTurnClosers` from `dsh-session`), but it only tests `tornMarker !== undefined` and passes the value straight back to `commitRepair`; it never inspects it. JSONL carries the byte offset to truncate to plus any complete events decoded from an incomplete final frame, while another provider may choose its own marker type. The coordinator therefore knows neither byte lengths nor frame recovery state. ## Testing -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`, `preparations.spec.ts`, and `write-behind.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, fixed-window follow-up batches, live-controller cleanup, same-id chain-tail races, failed-batch 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` proves that JSONL `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 provider disposal drains, and crash-tail repair through an in-memory reference and JSONL. `persistence.spec.ts`, `preparations.spec.ts`, and `write-behind.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, fixed-window follow-up batches, live-controller cleanup, same-id chain-tail races, failed-batch retry, and close ordering. JSONL specs retain storage mechanics and the through-coordinator torn-tail case that exercises the opaque-marker branch. ## Alternatives considered - **A base class the backends extend** — rejected for composition: a backend exposes only the hooks, cannot reach the coordinator's private orchestration state, and a third-party backend may still implement the abstract service directly without the coordinator at all. -- **A wider hook API** — each candidate hook folds away: there is no scope-specific live lookup because `loadStored` plus the coordinator's cwd check preserves the collision boundary, no storage-locator generic because validated JSONL metadata reproduces its path while SQLite is already id-bound, no separate `materialize` hook because the first batch must commit atomically with materialization, no separate create-collision probe because it is `loadStored(id) !== undefined`, and no coordinator pass-through for `list()` because listing needs none of the orchestration. +- **A wider hook API** — each candidate hook folds away: there is no scope-specific live lookup because `loadStored` plus the coordinator's cwd check preserves the collision boundary, no storage-locator generic because validated JSONL metadata reproduces its path, no separate `materialize` hook because the first batch must commit atomically with materialization, no separate create-collision probe because it is `loadStored(id) !== undefined`, and no coordinator pass-through for `list()` because listing needs none of the orchestration. ## Consequences -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 bounded 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 for the JSONL provider and future implementations. 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 provider 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. A new provider implements storage primitives rather than copy the bounded 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 e160f29247..777d5f5972 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 @@ -6,11 +6,11 @@ Status: implemented ## 问题 -`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 约定,但它们重复实现了写入路径编排:每会话状态、`session/created` 接管、后端特定的前缀读取、write-behind(延迟写入)控制、按 id 串行执行操作、HMR(热模块替换)种子注入与 dispose(资源释放)排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 Service Definition 包;剩余的编排仍然对正确性要求很高,且同样的修复被应用了两次。唯一的差异在于存储原语(写字节 vs. INSERT 行)。 +JSONL provider 需要在其存储原语周围执行对正确性要求很高的写入编排:逐 Session 状态、`session/created` 接管、前缀读取、write-behind 控制、按 id 串行执行、HMR 种子注入与 dispose 排空。把该生命周期放在 Service Definition 中,可以避免仓库外 provider 重复实现。已删除的 first-party 数据库 provider 证明了这种重复成本;其删除由 [JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责。 ## 决策 -将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 +`dsh-session-persistence` 导出后端无关的 `PersistenceCoordinator`。JSONL provider 组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`)、实现小型 `PersistenceBackend` 钩子接口,并把有状态公开方法(`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。协调器让非常规后端与继承层级作斗争的风险由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 @@ -27,26 +27,26 @@ Status: implemented 五个必需成员加可选的空会话实体化与生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 -- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 +- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 - `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话。因此,普通创建不会留下被放弃的已物化空会话。 - `materializeHeader?(meta)`——为 `SessionPersistence.ensureMaterialized(session)` 显式持久化仅含 header 的会话。它只供把空会话本身视为可恢复持久资源的生命周期前端使用;[标准 ACP 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)是第一个 consumer。支持该生命周期的后端实现此钩子;惰性创建仍是默认行为。 -- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `prepare`/`load`(截断 + 合成收尾事件)和存活会话接管(仅截断,`closers = []`)。 +- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync,先截断再追加。用于 `prepare`/`load`(截断 + 合成收尾事件)和存活会话接管(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 -- `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await,因此 close 失败不会掩盖排空错误。 +- `close?()`——供拥有资源的 provider 使用的可选生命周期清理;JSONL 省略该钩子。dispose effect 在排空至完全停稳后 await 它,因此 close 失败不会掩盖排空错误。 ### 不透明的 torn marker -保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成收尾事件(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 携带要截断到的字节偏移,以及从不完整最终帧中解码出的任何完整事件;SQLite 则携带要从其开始删除的 seq。协调器因此既不了解字节长度,也不了解帧恢复状态。 +保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成收尾事件(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`,从不检视其内容。JSONL 携带要截断到的字节偏移,以及从不完整最终帧中解码出的任何完整事件;其他 provider 可以选择自己的 marker 类型。协调器因此既不了解字节长度,也不了解帧恢复状态。 ## 测试 -共享的 `runPersistenceContract`(公开 API 约定)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts`、`preparations.spec.ts` 与 `write-behind.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、固定窗口后续批次、存活控制器清理、同 id 链尾竞态、失败批次重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为约定中的崩溃用例会产生合成收尾事件,却不会产生 torn marker。 +共享 `runPersistenceContract` 证明 JSONL 的 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现与 JSONL 覆盖接管、HMR、碰撞、Session 与 provider dispose 排空和崩溃尾部修复。`persistence.spec.ts`、`preparations.spec.ts` 与 `write-behind.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、固定窗口后续批次、存活控制器清理、同 id 链尾竞态、失败批次重试与关闭顺序。JSONL 规格保留存储机制,以及覆盖不透明 marker 分支的经由协调器崩溃尾部用例。 ## 曾考虑的替代方案 - **后端继承的基类**——否决,改用组合:后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍可完全不使用协调器、直接实现抽象服务。 -- **更宽的钩子 API**——每个候选钩子都被折叠掉:没有限定存储范围的存活会话查找,因为 `loadStored` 加上协调器的 cwd 检查即可维持碰撞边界;没有存储定位器泛型,因为经验证的 JSONL 元数据可还原其路径,而 SQLite 已按 id 绑定;没有单独的 `materialize` 钩子,因为首批事件必须与物化原子提交;没有单独的创建碰撞探测,因为它就是 `loadStored(id) !== undefined`;`list()` 也不经由协调器透传,因为列举不需要任何编排。 +- **更宽的钩子 API**——每个候选钩子都被折叠掉:没有限定存储范围的存活会话查找,因为 `loadStored` 加上协调器的 cwd 检查即可维持碰撞边界;没有存储定位器泛型,因为经验证的 JSONL 元数据可还原其路径;没有单独的 `materialize` 钩子,因为首批事件必须与物化原子提交;没有单独的创建碰撞探测,因为它就是 `loadStored(id) !== undefined`;`list()` 也不经由协调器透传,因为列举不需要任何编排。 ## 后果 -协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。 +协调器增加一层间接、一个不透明 torn marker、脱离 Session 生命周期的退役任务,以及有界的已准备 Session 状态,但为 JSONL provider 与未来实现集中管理对正确性要求很高的编排。Session dispose 仍是仅观察事件,因此 Session owner 不等待持久化退役;协调器收容失败、在存活控制器中保留待处理事件,并以 provider teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义。新 provider 只需实现存储原语,而无需复制有界写入生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index dad9c50a36..a0af3cb0e6 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: eaed6c263cb062dafedb5a2b798a85e57ae9d01d -2026-06-21-bounded-llm-request-recovery.zh.md: 3b4caeabeb58ebfb85d0cf0fef4a86dc7656ebec +2026-06-21-bounded-llm-request-recovery.md: 42bf460e52133b2a5471479fa3d7647e70092b48 +2026-06-21-bounded-llm-request-recovery.zh.md: 2a13f0a740348a5f74bd3d90120a148b25f2e870 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index eaed6c263c..42bf460e52 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -116,7 +116,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random hooks, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success inside the same turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compaction-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry records its own chunk seqs and provider/model route. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, real Web composition tests cover partial transport failure through recovery and exhausted recovery's terminal error row beside the settled retry chain, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. +- The plugin-owned `llm/retry` event is non-surface, survives a JSONL round trip, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, real Web composition tests cover partial transport failure through recovery and exhausted recovery's terminal error row beside the settled retry chain, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 3b4caeabeb..2a13f0a740 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -116,7 +116,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数钩子,以及退避期间中止。 - 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在同一轮次内重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compaction-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试会记录自己的分片 seq 和提供方/模型路由。 -- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,以及耗尽后终态错误行与定格重试链并列的画面,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 +- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,以及耗尽后终态错误行与定格重试链并列的画面,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 - 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 - `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 1548b145b4..05c4325e05 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 1fa442e8db2d8b2d2ec66730700c9c88dceddbae -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: ea9a6e247402e6a2d15fb4bfc0ebd6e65fc021df +2026-07-25-web-client-session-scope-and-provide-channel.md: b4566d70c79607bbf736ee02e3e37a79c2391232 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1d1acf00fa6a1efc868c3613715a5ff781e0323a diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 1fa442e8db..b4566d70c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -61,7 +61,7 @@ Session instances share the scope's lifecycle; liveness eligibility = host-liste A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable): -- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk. +- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the JSONL provider's lazy-create contract guarantees a never-appended Session never enters `persistence.list()`, so blank never touches disk. - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). - The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index ea9a6e2474..1d1acf00fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -61,7 +61,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 约定保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。 +- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended Session 不进入 `persistence.list()`,所以 blank 从不落盘。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml index cfa8a21a63..186d4321e2 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.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-29-package-regrouping.md -2026-07-29-package-regrouping.md: 8d7c84434bf45568a7a78006759e002edbfc02d6 -2026-07-29-package-regrouping.zh.md: fce454181061a1af51e46a6a2395a47cf6cfdec1 +2026-07-29-package-regrouping.md: 2d585547db03fa54701850fe48bf936eb0ec4fd5 +2026-07-29-package-regrouping.zh.md: 0667bff7e0697978955823977ca6b98dd71ab4f4 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md index 8d7c84434b..2d585547db 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md @@ -21,13 +21,13 @@ Five regrouping decisions remain current; every other group keeps its prior boun | Group | Members (folder names) | From | |---|---|---| -| `session/` | session-persistence, session-persistence-jsonl, session-persistence-sqlite, session-checkpoint-policy, session-projection, session-projection-cache, session-title, session-title-llm, session-title-first-prompt-llm, session-title-all-prompts-llm, session-telemetry, session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` | +| `session/` | session-persistence, session-persistence-jsonl, session-checkpoint-policy, session-projection, session-projection-cache, session-title, session-title-llm, session-title-first-prompt-llm, session-title-all-prompts-llm, session-telemetry, session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` | | `interaction/` | user-questions, user-approval, permission-presets, tool-ask-user, commands, tui | `ui/` | | `boot/` | app-boot | `ui/` | | `guard/` | repeat-tool-reminder, timeout-policy | `guard/` + `timeout/` | | `extensions/` | tool-cordis | `cordis/` | -- **`session/`** is the durable session data plane: the persistence seam with its backends and checkpoint policy, the projection fold that serves whole values from that log, log-backed titles, and OTel reporting. The title fold is itself load-bearing for the read side (`session-query` peer-depends on `dsh-session-title`), so titles belong with the data plane, not in a derived-services annex. The plain name is deliberate (prefer names a human would say); the nearby `core/session` package remains the live in-memory service, while this group is the durable family around it. `session-query/` stays a standalone group — the read/tool surface has its own model tools and SQLite FTS backend and is consumed independently of persistence internals. +- **`session/`** is the durable session data plane: the persistence seam with its JSONL provider and checkpoint policy, the projection fold that serves whole values from that log, log-backed titles, and OTel reporting. The title fold is itself load-bearing for the read side (`session-query` peer-depends on `dsh-session-title`), so titles belong with the data plane, not in a derived-services annex. The plain name is deliberate (prefer names a human would say); the nearby `core/session` package remains the live in-memory service, while this group is the durable family around it. `session-query/` stays a standalone group — the read/tool surface has its own model tools and SQLite FTS backend and is consumed independently of persistence internals. - **`interaction/`** is the human-collaboration plane plus the terminal channel that answers it: the question/approval seams, the permission preset, the model-facing `ask_user_question` tool, the human-command registry (`plan-mode` and `command-goal` already consume `commands` together with the interaction seams), and `tui` — the interactive channel is the plane's richest provider and consumer (peer edges to `commands` and `user-questions`), and a one-package `tui/` group would spend a top-level name on one plugin. - **`boot/`** is a role-complete single-package group: the shared boot glue that belongs to no channel and no assembly (consumed by `apps/cli` and test-only Loader drivers). - **`guard/`** keeps its documented role, loop-hygiene guards, and gains the tool-call timeout enforcer, dissolving the one-package `timeout/` group whose name collided with `util/timeout`. diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md index fce4541810..0667bff7e0 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md @@ -21,13 +21,13 @@ Status: implemented | 组 | 成员(目录名) | 来源 | |---|---|---| -| `session/` | session-persistence、session-persistence-jsonl、session-persistence-sqlite、session-checkpoint-policy、session-projection、session-projection-cache、session-title、session-title-llm、session-title-first-prompt-llm、session-title-all-prompts-llm、session-telemetry、session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` | +| `session/` | session-persistence、session-persistence-jsonl、session-checkpoint-policy、session-projection、session-projection-cache、session-title、session-title-llm、session-title-first-prompt-llm、session-title-all-prompts-llm、session-telemetry、session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` | | `interaction/` | user-questions、user-approval、permission-presets、tool-ask-user、commands、tui | `ui/` | | `boot/` | app-boot | `ui/` | | `guard/` | repeat-tool-reminder、timeout-policy | `guard/` + `timeout/` | | `extensions/` | tool-cordis | `cordis/` | -- **`session/`** 是持久会话数据平面:持久化 seam 连同其各后端与检查点策略、从该日志折叠(fold)出全量值并对外提供的投影、基于日志的标题,以及 OTel 上报。标题折叠本身就是读取侧的承重构件(`session-query` 对 `dsh-session-title` 声明对等依赖),所以标题属于数据平面,而非某个「派生服务」附属区。用这个朴素的名字是有意为之(名字要像人起的);旁边的 `core/session` 包仍是常驻内存的实时服务,本组则是围绕它的持久家族。`session-query/` 保持独立成组:这个读取/工具面自带模型工具和 SQLite FTS 后端,其消费不依赖持久化内部实现。 +- **`session/`** 是持久会话数据平面:持久化 seam 连同其 JSONL provider 与检查点策略、从该日志折叠(fold)出全量值并对外提供的投影、基于日志的标题,以及 OTel 上报。标题折叠本身就是读取侧的承重构件(`session-query` 对 `dsh-session-title` 声明对等依赖),所以标题属于数据平面,而非某个「派生服务」附属区。用这个朴素的名字是有意为之(名字要像人起的);旁边的 `core/session` 包仍是常驻内存的实时服务,本组则是围绕它的持久家族。`session-query/` 保持独立成组:这个读取/工具面自带模型工具和 SQLite FTS 后端,其消费不依赖持久化内部实现。 - **`interaction/`** 是人机协作平面加上应答它的终端通道:提问/批准 seam、权限预设、面向模型的 `ask_user_question` 工具、人类命令注册表(`plan-mode` 与 `command-goal` 已经把 `commands` 和各交互 seam 放在一起消费),以及 `tui`——这个交互通道是该平面功能最丰富的提供方与消费方(对 `commands` 与 `user-questions` 均有对等依赖边),而一个单包 `tui/` 组会把一个顶层名字花在一个插件上。 - **`boot/`** 是角色完备的单包组:不归属任何通道也不归属任何组装的共享 boot 胶水(被 `apps/cli` 与仅限测试的 Loader driver 消费)。 - **`guard/`** 保留其文档记载的角色(循环卫生守卫),并新纳入强制执行工具调用超时的包;那个与 `util/timeout` 撞名的单包组 `timeout/` 随之解散。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index ad1a5c6eb1..105e1367ce 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 9d5fffbd4d69713fe733235cc0352bc93c9ce55c -2026-08-03-per-session-agent-presets.zh.md: 406d546828489ccd172205cde7d4b5e0ba96a39b +2026-08-03-per-session-agent-presets.md: dabbb74855d884ac0185a1f9b3eb15ca4cd06bde +2026-08-03-per-session-agent-presets.zh.md: c863a62c6a3fc0121aad1821e5a9368963c669ab diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 9d5fffbd4d..dabbb74855 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -63,7 +63,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default. -**A durable header field is not durable until every backend writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and neither persistence backend carried it: the JSONL header line, the SQLite `sessions` row, and the derived query index each map the header column by column, so a resumed session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same shape — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it. +**A durable header field is not durable until the provider writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and the JSONL provider omitted it; the derived query index also maps header fields explicitly, so a resumed Session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same form — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it. **The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 406d546828..c863a62c6a 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -64,7 +64,7 @@ Status: implemented **preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。 -**持久化的头部字段,在每个后端都写入之前都算不上持久。** `agentPreset` 带着正确的理由落在了 `SessionHeader` 上,而两个持久化后端都没有携带它:JSONL 头部行、SQLite `sessions` 行、以及派生的查询索引各自逐列映射头部,于是被恢复的会话回来时没有 preset,所有据以命名它的表层随之失声。`summarizeCold` 是同一个形状——它手工拼装冷列表行,而没有复用共享的投影。声明为持久的字段,需要一个跨越真实存储的测试,而不只是声明它的那个类型。 +**持久化 header 字段在 provider 写入前都算不上持久。** `agentPreset` 带着正确理由落在 `SessionHeader` 上,而 JSONL provider 遗漏了它;派生 query index 也显式映射 header 字段,于是恢复后的 Session 没有 preset,所有据以命名它的 surface 随之失声。`summarizeCold` 是同一种形式——它手工拼装 cold list row,而没有复用共享 projection。声明为持久的字段,需要一个跨越真实 store 的测试,而不只是声明它的类型。 **这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 8e06654baf..29613b58c7 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 5124d5df9edafe5c11a68aff7a0dd2f7929bd020 -2026-08-06-subagent-list-identity-projection.zh.md: 77bf34790f7dfe93fdd8f725b707ecdc4ab4cf03 +2026-08-06-subagent-list-identity-projection.md: aeed828530f615b1bb4958a360b5ba4db543f714 +2026-08-06-subagent-list-identity-projection.zh.md: b2b64eaa7c06b738734a7b975adb5948704465bd diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index 5124d5df9e..aeed828530 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -149,7 +149,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Alternatives considered -**mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format. +**mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header change propagates into the persistence provider and compatibility check; pre-existing JSONL can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format. **The projection-cache ladder (`cachedSnapshot ?? cold fold` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent. diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index 77bf34790f..b2b64eaa7c 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -149,7 +149,7 @@ export type SubagentListEntry = ## 考虑过的替代方案 -**mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是「第一次列表一次 `inspect` 现算」,不碰持久格式。 +**mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 变更会传导到持久化 provider 与兼容性检查;存量 JSONL 只能降级为 unknown 或 backfill。读时现算对存量的答案是「第一次列表一次 `inspect` 现算」,不碰持久格式。 **projection-cache 阶梯(`cachedSnapshot ?? cold fold` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml index 41bd67bef1..0ff8938b30 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md -2026-08-08-bounded-session-persistence-write-batching.md: fc22a10537ebb9009ab1ae1ec21ca625c9025651 -2026-08-08-bounded-session-persistence-write-batching.zh.md: c2763e157fcfc2e004b14116694054c604fbfb9c +2026-08-08-bounded-session-persistence-write-batching.md: 20c16991b0be30ffe546a94c257bc65f86cb57eb +2026-08-08-bounded-session-persistence-write-batching.zh.md: ac0384f4e28175922f84d23296dfb13848cf5dd3 diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md index fc22a10537..20c16991b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-bounded-session-persistence-write-batching.zh.md) ## Problem -Streaming responses can emit many `assistant/chunk` events in a short interval. The persistence coordinator previously scheduled a backend append as soon as an idle queue received one event. Events arriving while that append was active shared a follow-up batch, but a fast backend could still produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix, while each SQLite append opens and commits a transaction and increments the session revision. +Streaming responses can emit many `assistant/chunk` events in a short interval. The persistence coordinator previously scheduled a provider append as soon as an idle queue received one event. Events arriving while that append was active shared a follow-up batch, but a fast provider could still produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix. Dropping chunk events or replacing them with assembled messages would reduce logical storage, but it would also change the event log, replay, sequence numbers, timestamps, and the chunk seqs cited by assistant messages. The write-amplification problem does not require that larger semantic change. @@ -14,13 +14,13 @@ Dropping chunk events or replacing them with assembled messages would reduce log Repository fixtures make the logical volume concrete. Decoding the current packed rows in [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) yields 2,098 events: 2,017 chunks (96.1%). Their unpacked JSONL lines occupy 332,647 of 379,225 event bytes (87.7%), while chunk packing reduces the committed file to 89,176 bytes and 182 storage rows, including 23 packed chunk rows. [`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) yields 813 events: 746 chunks (91.8%) and 118,935 of 184,821 unpacked event bytes (64.4%); its packed file is 84,917 bytes and 123 storage rows, including 14 packed rows. These are tracked deterministic fixtures, not a production workload distribution, but they demonstrate why deleting chunks would reduce logical volume and why the existing packed-row layout already removes much of their JSON envelope cost. -SQLite stores one row per logical event, so those same logical logs would retain 2,098 and 813 event rows respectively; batching does not change those counts. JSONL writes one Zstandard frame and fsync per durable append batch, while SQLite performs one transaction and one session-revision increment per batch. Runtime files do not record former append boundaries, so fixture row counts cannot honestly be presented as fsync or transaction counts. +JSONL writes one Zstandard frame and fsync per durable append batch. Runtime files do not record former append boundaries, so fixture row counts cannot honestly be presented as fsync counts. The scheduling bound is deterministic. With an immediately resolving sink, the former immediate controller could issue one append for each event arriving after the previous append completed. A controller test admits 20 events 10 ms apart: the 200 ms fixed window hands all 20 to one append. This is a 20-to-1 reduction for that cadence, not a universal ratio. Sparse events, mandatory flushes, slow prior writes, and different arrival rates produce different batch sizes. ## Decision -The first-party JSONL and SQLite plugins expose `writeBatchMaxDelayMs`, a positive integer no greater than Node's timer limit. Its default is `200`. Each plugin resolves the value at load and passes it to `PersistenceCoordinator`; the coordinator remains the single owner of batching behavior. +The JSONL provider exposes `writeBatchMaxDelayMs`, a positive integer no greater than Node's timer limit. Its default is `200`. The provider resolves the value at load and passes it to `PersistenceCoordinator`; the coordinator remains the single owner of batching behavior. Each live Session receives a package-private `SessionWriteBehind`. When its pending queue changes from empty to non-empty, the controller starts one fixed window. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, the controller hands the complete pending prefix to the existing per-id serialization and `appendBatch` path. At most one write for a Session is active. Events admitted during that write form a new pending prefix with their own fixed deadline; if that deadline expires before the active write completes, the new prefix starts immediately after it. @@ -28,7 +28,7 @@ Each live Session receives a package-private `SessionWriteBehind`. When its pend `session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects. -Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame, and SQLite can insert more event rows in one transaction, without changing either on-disk format or schema version. +Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame without changing its on-disk format. A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary. @@ -42,18 +42,18 @@ This decision supersedes only the immediate scheduling cadence in [Collapse live **Debounce from the latest event.** Rejected: a continuously streaming response could postpone its first write indefinitely. A fixed window from the first pending event provides a real upper bound on intentional coalescing wait. -**Implement timers separately in JSONL and SQLite.** Rejected: scheduling, failure retention, flush races, and teardown are backend-neutral lifecycle concerns. Duplicating them would reopen the drift that `PersistenceCoordinator` removed. +**Implement the timer inside JSONL.** Rejected: scheduling, failure retention, flush races, and teardown are provider-neutral lifecycle concerns that belong in `PersistenceCoordinator`; an out-of-tree provider can reuse the same behavior. ## Verification -The controller tests use a fake clock to prove the fixed, non-resetting 200 ms window; immediate and shared flush barriers; events admitted during a barrier; an over-budget tail behind an active write; ordered failure retention; paused automatic retry; and explicit retry of an overlapping background failure. Coordinator tests run the controller through Session notifications, retirement, collision reclamation, and teardown. The JSONL and SQLite suites retain their storage-format, transaction, recovery, and shared persistence-contract coverage. +The controller tests use a fake clock to prove the fixed, non-resetting 200 ms window; immediate and shared flush barriers; events admitted during a barrier; an over-budget tail behind an active write; ordered failure retention; paused automatic retry; and explicit retry of an overlapping background failure. Coordinator tests run the controller through Session notifications, retirement, collision reclamation, and teardown. The JSONL suite retains storage-format, recovery, and shared persistence-contract coverage. ## Consequences High-frequency event bursts normally produce fewer durable append operations while preserving the exact logical event count. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches. -This decision does not cap pending event count or bytes behind a slow backend, and it does not reduce SQLite rows or the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule. +This decision does not cap pending event count or bytes behind a slow provider, and it does not reduce the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule. An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait. -The new deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; backends retain only durable storage primitives. Neither `SESSION_FORMAT_VERSION` nor SQLite `SCHEMA_VERSION` changes. +The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. `SESSION_FORMAT_VERSION` remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md index c2763e157f..ac0384f4e2 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前,只要空闲队列收到一个事件,持久化协调器就会立即调度一次后端追加。该追加仍在进行时到达的事件会共用一个后续批次,但如果后端速度很快,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀,而每次 SQLite 追加都会打开并提交一个事务,同时递增会话修订版本。 +流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前,只要空闲队列收到一个事件,持久化协调器就会立即调度一次 provider 追加。该追加仍在进行时到达的事件会共用一个后续批次,但如果 provider 速度很快,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀。 丢弃分片事件或用组装后的消息替代它们可以减少逻辑存储量,但也会改变事件日志、回放、序列号、时间戳,以及助手消息引用的分片 seq。写放大问题不要求采取这项语义变化更大的方案。 @@ -14,13 +14,13 @@ Status: implemented 仓库 fixture(测试前置数据)让逻辑数据量有了具体依据。对当前 [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) 中的打包行进行解码,可得到 2,098 个事件,其中 2,017 个是分片(96.1%)。这些分片解包后的 JSONL 行共 332,647 字节,占全部事件 379,225 字节的 87.7%;分片打包则把仓库中的已提交文件缩小到 89,176 字节和 182 个存储行,其中包括 23 个打包分片行。[`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) 可得到 813 个事件,其中 746 个是分片(91.8%);这些分片解包后的 JSONL 行共 118,935 字节,占全部事件 184,821 字节的 64.4%。其打包文件为 84,917 字节,共 123 个存储行,其中包括 14 个打包行。这些是纳入版本控制的确定性 fixture,不代表生产工作负载分布;但它们说明了删除分片为何会降低逻辑数据量,也说明现有打包行布局已经消除了大量 JSON 包装开销。 -SQLite 每个逻辑事件存储一行,因此同样的逻辑日志会分别保留 2,098 和 813 个事件行;批处理不会改变这些数量。JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync,SQLite 每个批次会执行一次事务并递增一次会话修订版本。运行时文件不记录原有追加边界,因此不能把 fixture 的存储行数当作 fsync 或事务次数。 +JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync。运行时文件不记录原有追加边界,因此不能把 fixture 的存储行数当作 fsync 次数。 调度上界是确定的。当写入端会立即完成每次操作时,原来的即时控制器可能对每个在前一次追加完成后到达的事件分别发起一次追加。一个控制器测试以 10 ms 的间隔接纳 20 个事件:200 ms 固定窗口会把全部 20 个事件交给一次追加。对于这种到达节奏,追加次数从 20 次降至 1 次,但这不是普遍比例。稀疏事件、强制 flush、较慢的前序写入和不同到达速率都会产生不同的批次大小。 ## 决策 -第一方 JSONL 与 SQLite 插件公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 Node 计时器上限的正整数,默认值为 `200`。每个插件都会在加载时解析该值,再传给 `PersistenceCoordinator`;批处理行为仍只由协调器负责。 +JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 Node 计时器上限的正整数,默认值为 `200`。provider 在加载时解析该值,再传给 `PersistenceCoordinator`;批处理行为仍只由协调器负责。 每个活跃的会话都有一个包私有 `SessionWriteBehind`。当其待处理队列从空变为非空时,控制器会启动一个固定窗口。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,控制器会把完整的待处理前缀交给现有的按 id 串行化机制,并沿 `appendBatch` 路径写入。同一会话同时最多有一个活跃写入。该写入期间接纳的事件会形成新的待处理前缀,并拥有自己的固定截止时间;如果该截止时间在活跃写入完成前到期,新前缀会在前一次写入完成后立即开始写入。 @@ -28,7 +28,7 @@ SQLite 每个逻辑事件存储一行,因此同样的逻辑日志会分别保 `session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。 -每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,SQLite 可以在一个事务中插入更多事件行,而无需改变任一种磁盘格式或 schema 版本。 +每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,而无需改变其磁盘格式。 后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。 @@ -42,18 +42,18 @@ SQLite 每个逻辑事件存储一行,因此同样的逻辑日志会分别保 **按最新事件重置防抖窗口。** 不采纳:持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口,为主动合并等待提供了真正的上界。 -**分别在 JSONL 与 SQLite 中实现计时器。** 不采纳:调度、失败保留、flush 竞态和 teardown 都是后端无关的生命周期问题。重复实现这些机制会重新引入 `PersistenceCoordinator` 已消除的实现漂移。 +**在 JSONL 内实现计时器。** 不采纳:调度、失败保留、flush 竞态和 teardown 都是 provider 无关的生命周期问题,属于 `PersistenceCoordinator`;仓库外 provider 可以复用同一行为。 ## 验证 -控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试,以及对重叠发生的后台失败进行显式重试。协调器测试会在会话通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 与 SQLite 测试套件继续覆盖存储格式、事务、恢复和共享持久化约定。 +控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试,以及对重叠发生的后台失败进行显式重试。协调器测试会在会话通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。 ## 后果 高频事件突发通常会减少持久化追加操作,同时保持逻辑事件数量完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。 -本决策不会限制因后端缓慢而积压的待处理事件数量或字节数,也不会减少 SQLite 行数或解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。 +本决策不会限制因 provider 缓慢而积压的待处理事件数量或字节数,也不会减少解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。 接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。 -新的 deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;后端仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 与 SQLite `SCHEMA_VERSION` 均不变。 +deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index 93a831b4f9..a36497c9e7 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 82748212b10edf5b201f2be7395cbdb54108fbf7 -2026-08-10-session-log-version-mechanism.zh.md: 3950f41398d032d02a7d6c4487220c6da78388f4 +2026-08-10-session-log-version-mechanism.md: 0d4c9e73acc6abd4a67123e3d7b0e4f94e0b5a23 +2026-08-10-session-log-version-mechanism.zh.md: 6c57da618a09c1d423323940ea36dbd4caccde02 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 82748212b1..0d4c9e73ac 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -10,7 +10,7 @@ Session logs must be upgradable after release, and the runtime that ships first ## Decision -**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent. +**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance; design time rarely reveals whether the next change will turn out "major". **The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers. @@ -20,7 +20,7 @@ Session logs must be upgradable after release, and the runtime that ships first ## Consequences -What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, currently `SCHEMA_VERSION` 20), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; its retention and replacement condition lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md). An external informational event carrying the marker remains reloadable, while an unknown required event refuses resume. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating this format version's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. +What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, JSONL, and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; its retention and replacement condition lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md). An external informational event carrying the marker remains reloadable, while an unknown required event refuses resume. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL provider refuses a foreign version from the raw header line before validating this format version's header or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt". ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index 3950f41398..6c57da618a 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -10,7 +10,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 ## 决定 -**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺(设计时很少能预知下一个变更算不算"大")。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。 +**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺;设计时很少能预知下一个变更算不算"大"。 **升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。 @@ -20,7 +20,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 ## 影响 -v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,当前为 `SCHEMA_VERSION` 20)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建。第一方写入方不通过 `Session.append` 设置 `ignorable`,但当前有一个仓库外插件依赖该字段;其保留条件与替代机制要求由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义。带该标记的外部信息性事件可以继续重新加载,未知必需事件则会拒绝恢复。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验本格式版本的 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。 +v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、JSONL 和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建。第一方写入方不通过 `Session.append` 设置 `ignorable`,但当前有一个仓库外插件依赖该字段;其保留条件与替代机制要求由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义。带该标记的外部信息性事件可以继续重新加载,未知必需事件则会拒绝恢复。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL provider 会在校验本格式版本的 header、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏"。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml index a0e3fe3b06..d8af00effc 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md 2026-08-15-packed-session-history-transport.md: 01e36509b7ad2c878ae4ea04c3a10f029e1b8f3d -2026-08-15-packed-session-history-transport.zh.md: 590385dcfac901ab01e472ee75e766e51bf4b001 +2026-08-15-packed-session-history-transport.zh.md: 6ef847a14da1b4ec1bd59e5aaad9093162b42d84 diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md index 590385dcfa..6ef847a14d 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md @@ -48,7 +48,7 @@ Conversation 接受 Session 保留的同一组 `{ type, event }` entry。Definit **只依赖 HTTP 内容编码。** gzip 与 Brotli 会减少网络字节,但不会移除重复的 JSON 解析、校验、分配、索引与 fold 工作。 -**直接按物理持久化行分页。** 这还可以避免冷 Host 读取时的逻辑展开,但页面切分取决于追加来源消息与替换 provenance,而不是后端行边界。当前决策让 API 保持对 JSONL、SQLite 与未来持久化布局的独立性。 +**直接按物理持久化行分页。** 这还可以避免 cold Host 读取时的逻辑展开,但页面切分取决于追加来源消息与替换 provenance,而不是 provider 行边界。当前决策让 API 保持对 JSONL 与未来持久化布局的独立性。 **只返回组装后的 Assistant 快照。** [仅保留组装消息的否决记录](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md)仍然适用:final message 之外的事件族承载用户可见状态与诊断状态,未完成步骤也需要其实际累计分片。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml index bab3942bad..b7147e9af2 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md -2026-08-25-persistence-latency-and-page-size.md: 3e350ae33655dab82f8c0d7e71b39887e1b6fd34 -2026-08-25-persistence-latency-and-page-size.zh.md: 4bff5ce5e11227594d5cfdce5aebff1b398e6607 +2026-08-25-persistence-latency-and-page-size.md: 3f8147f50feaee4aac10c5fd3920313611a6f449 +2026-08-25-persistence-latency-and-page-size.zh.md: d50563dcc801d684cd2558c29e7180e99dc25cca diff --git a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md index 3e350ae336..3f8147f50f 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md +++ b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md @@ -1,4 +1,4 @@ -# Agent Note: Persistence compression latency and SQLite page size +# Agent Note: JSONL persistence compression latency Status: implemented @@ -6,7 +6,7 @@ English | [中文](2026-08-25-persistence-latency-and-page-size.zh.md) ## Problem -The physical persistence optimizations need to reduce retained storage without moving disproportionate work into full writes, reads, or session forks. The original 105-session corpus showed that JSONL level-19 compression made full writes and forks more than twice as slow. The earlier SQLite page-size experiment predated shared-dictionary row compression and showed negligible savings, so it did not establish the best page size for the current row distribution. +Physical persistence optimizations need to reduce retained storage without moving disproportionate work into full writes, reads, or Session forks. The original 105-Session corpus showed that JSONL level-19 compression made full writes and forks more than twice as slow. The decision needs evidence from more varied sessions, including long event streams and payloads outside the original corpus. The expanded corpus contains 501 real sessions, 16,153,332 logical events, and 2,002,145,570 bytes of serialized event data. @@ -14,20 +14,12 @@ The decision needs evidence from more varied sessions, including long event stre ### Storage encoding stays physical and independently decodable -JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. SQLite stores the same arrays as tagged zigzag-delta or `(start, count)` varints, choosing the smaller encoding. Both readers restore the original `number[]` before exposing an event. - -SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 20 and a test pins their SHA-256 digest; replacing them requires another schema-version bump. +JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. Reading restores the original `number[]` before exposing an event. ### JSONL uses the standard Zstandard level The JSONL writer keeps one checksummed Zstandard frame per durable append batch but uses the compressor's standard level. Lossless `sourceEventSeqs` range encoding remains active. Frames stay independently decodable for suffix reads and torn-tail recovery; only the expensive level-19 search is removed. -### New SQLite databases use 64 KiB pages - -The SQLite provider sets `page_size=65536` before initializing a pristine schema-20 database. An established schema-20 database retains its current page size because SQLite ignores the pragma after allocation. - -The page size is part of schema 20's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas. - ### Expanded benchmark Each candidate was rebuilt five times from the same 501-session corpus with 512-event append batches. Their order rotates between rounds so every candidate occupies each run position once. Each build runs three complete and suffix-read sweeps. For each displayed metric, the highest and lowest build are discarded and the remaining three values are averaged. Complete and suffix read times cover one sweep over all sessions, and fork time covers all 501 sessions. @@ -37,34 +29,22 @@ Each candidate was rebuilt five times from the same 501-session corpus with 512- | JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s | | JSONL with provenance ranges | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) | | JSONL with provenance ranges and level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) | -| SQLite `master` (schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s | -| SQLite with all physical optimizations and 64 KiB pages | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) | Relative to standard-level frames with provenance ranges, level 19 saves another 12.1% of the JSONL bytes but increases full-write time by 67.0% and fork time by 129.8%. Its complete and suffix reads change by -0.4% and -0.5%. The extra search therefore benefits retained size without improving the latency-sensitive operations enough to offset its repeated encoding cost. -An otherwise identical SQLite build isolates the page-size effect: 4 KiB pages use 256.97 MB and 64 KiB pages use 233.18 MB (-9.26%). The `events` table's unused page bytes fall from 30.25 MB to 6.95 MB, while the index changes from 5.92 MB to 6.03 MB. In the paired run, full write, full read, and suffix read change by -0.5%, -0.4%, and -3.8%; fork changes by -14.8%. The space gain therefore comes from better large-row page utilization rather than a smaller index or omitted data, without a measured latency regression. - ## Alternatives considered **Keep JSONL level 19.** Rejected. On the expanded corpus it saves another 12.1% relative to default-level frames but increases full-write time by 67.0% and fork time by 129.8%, while complete and suffix reads differ by less than 1%. Default-level frames plus provenance ranges retain a 14.1% size reduction relative to master without a material latency regression. **Compress one whole JSONL log as a single frame.** Rejected. It improves cross-batch compression but makes suffix reads decompress from the start and removes batch-local torn-tail recovery. -**Keep 4 KiB SQLite pages.** Rejected for pristine databases. The current compressed-row distribution retains 9.26% more bytes because large compressed records leave more unusable space across 4 KiB B-tree pages. Existing databases keep their page size to avoid a historical rewrite. - -**Remove ROWID from `events`.** Rejected. The composite primary key becomes the table B-tree key and repeats through internal pages; the 105-session comparison produced a larger database than ordinary ROWID tables. - **Deduplicate event content.** Rejected. Message restatements and tool arguments can be reconstructed only under assumptions that compaction, retries, and pruning may invalidate. Physical compression preserves every event without adding reconstruction semantics. -**Use per-session SQLite files or DuckDB.** Rejected for the hot store. Per-session files lose cross-session queries, while DuckDB's OLAP write model fits cold batch analysis rather than durable append batches and low-latency suffix reads. - ## Consequences -JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. SQLite exchanges approximately 5–26% more time across the measured operations for a 46.8% retained-size reduction; its full write remains materially faster than JSONL, and its suffix read remains much faster. Its complete read and fork are slightly slower than default-level JSONL on this expanded corpus. - -New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 20 rejects every other schema version rather than migrating it. +JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. The expanded corpus measures a 14.1% retained-size reduction from provenance ranges without a material latency regression. ## Related -- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.md) — owns the packed row model; its earlier page-size conclusion applies to the pre-dictionary layout. +- [JSONL-only first-party Session persistence](../simplification/2026-08-30-jsonl-only-session-persistence.md) — owns deletion of the alternative authoritative backend; the [archived SQLite compression record](../../archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) retains its historical measurements. - [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.md) — owns the checksummed frame-per-batch container and the standard compressor-level policy restored here. diff --git a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md index 4bff5ce5e1..d50563dcc8 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 持久化压缩延迟与 SQLite page size +# Agent Note: JSONL 持久化压缩延迟 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -物理持久化优化需要减少保留存储,同时不能把不成比例的工作转移到完整写入、读取或会话 fork。原有的 105 会话语料显示,JSONL level-19 压缩会让完整写入与 fork 耗时增加一倍以上。此前的 SQLite page-size 实验早于共享字典行压缩,所得空间收益可以忽略,因此无法确定当前行分布的最佳 page size。 +物理持久化优化需要减少保留存储,同时不能把不成比例的工作转移到完整写入、读取或 Session fork。原有的 105-Session 语料显示,JSONL level-19 压缩会让完整写入与 fork 耗时增加一倍以上。 该决策需要来自更多样会话的证据,包括长事件流与原语料之外的 payload。扩展后的语料包含 501 个真实会话、16,153,332 个逻辑事件与 2,002,145,570 字节序列化事件数据。 @@ -14,20 +14,12 @@ Status: implemented ### 存储编码保持为物理层行为并可独立解码 -JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。SQLite 把同一数组存为带 tag 的 zigzag-delta 或 `(start, count)` varint,并选择更小的编码。两个读取方都会在暴露事件前还原原始 `number[]`。 - -SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 20,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。 +JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。读取时会在暴露事件前还原原始 `number[]`。 ### JSONL 使用 Zstandard 标准级别 JSONL 写入方继续为每个持久 append 批次写入一个带 checksum 的 Zstandard frame,但使用压缩器的标准级别。无损 `sourceEventSeqs` 区间编码继续生效。各 frame 仍可独立解码,以支持后缀读取与撕裂尾部恢复;只移除昂贵的 level-19 搜索。 -### 新建 SQLite 数据库使用 64 KiB page - -SQLite 提供方在初始化全新 schema-20 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-20 数据库保留其当前 page size。 - -Page size 属于 schema 20 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。 - ### 扩展基准 每个候选方案都从同一份 501 会话语料独立重建五次,每个 append 批次包含 512 个事件。各轮轮换执行顺序,使每个候选方案在每个运行位置各出现一次。每次重建执行三轮完整读取与后缀读取。下表中的每项指标都去掉最高与最低的一次重建,再平均其余三次。完整读取与后缀读取耗时覆盖对全部会话的一轮扫描,fork 耗时覆盖全部 501 个会话。 @@ -37,34 +29,22 @@ Page size 属于 schema 20 的固定物理布局,并与其他固定 SQLite pra | JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s | | JSONL + 来源区间 | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) | | JSONL + 来源区间 + level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) | -| SQLite `master`(schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s | -| SQLite + 全部物理优化 + 64 KiB page | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) | 相对使用来源区间的标准级别 frame,level 19 可再减少 12.1% 的 JSONL 字节,但会让完整写入增加 67.0%、fork 增加 129.8%;完整读取与后缀读取分别变化 -0.4% 与 -0.5%。因此,更深入的搜索只改善保留体积,无法通过延迟敏感操作的收益抵消反复付出的编码成本。 -其余条件相同的 SQLite 重建可单独观察 page-size 影响:4 KiB page 使用 256.97 MB,64 KiB page 使用 233.18 MB(-9.26%)。`events` 表的 page 内未使用字节从 30.25 MB 降至 6.95 MB,索引则从 5.92 MB 变为 6.03 MB。在该成对运行中,完整写入、完整读取与后缀读取分别变化 -0.5%、-0.4% 与 -3.8%,fork 变化 -14.8%。因此,空间收益来自更高的大记录 page 利用率,而不是索引缩小或数据省略,并且没有测得延迟退化。 - ## 考虑过的替代方案 **保留 JSONL level 19。** 不予采用。在扩展语料上,它相对默认级别 frame 可再减少 12.1%,却让完整写入增加 67.0%、fork 增加 129.8%,而完整读取与后缀读取的差异都不足 1%。默认级别 frame 配合来源区间后,相对 master 仍能缩小 14.1%,且没有实质性延迟退化。 **把整份 JSONL 日志压成单个 frame。** 不予采用。该方案可改善跨批次压缩,但后缀读取必须从头解压,也会失去按批次恢复撕裂尾部的能力。 -**新建 SQLite 数据库继续使用 4 KiB page。** 不予采用。当前压缩行分布会在 4 KiB B-tree page 之间留下更多不可用空间,使保留字节增加 9.26%。已有数据库保留其 page size,避免改写历史数据。 - -**从 `events` 移除 ROWID。** 不予采用。复合主键会成为表 B-tree 键并在内部 page 中重复;105 会话对比所得数据库大于使用普通 ROWID 的表。 - **对事件内容去重。** 不予采用。消息复述与工具参数只能在依赖重建假设时删除,而 compaction、重试和修剪可能让这些假设失效。物理压缩保留每个事件,不增加重建语义。 -**使用逐会话 SQLite 文件或 DuckDB。** 不用于热存储。逐会话文件会失去跨会话查询,DuckDB 的 OLAP 写入模型则更适合冷批量分析,而不是持久 append 批次与低延迟后缀读取。 - ## 后果 -JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。SQLite 以实测各项操作约 5–26% 的额外耗时换取 46.8% 的保留体积缩减;其完整写入仍明显快于 JSONL,后缀读取也仍快得多。在这份扩展语料上,完整读取与 fork 略慢于默认级别 JSONL。 - -新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 20 会拒绝其他所有 schema 版本,而不是迁移它们。 +JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。扩展语料显示,来源区间让保留体积缩小 14.1%,且没有实质性延迟退化。 ## 相关资料 -- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.zh.md) — 定义打包行模型;其此前的 page-size 结论适用于共享字典之前的布局。 +- [JSONL-only first-party Session persistence](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)——负责删除另一种权威 backend;[已归档 SQLite 压缩记录](../../archived/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md)保留其历史测量。 - [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.zh.md) — 定义带 checksum 的按批次 frame 容器,以及本笔记恢复的标准压缩级别策略。 diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml index efc1d40bbb..8c92e95dac 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md -2026-08-30-retain-ignorable-external-session-events.md: 8217f1865f13b695bbd7095b2f7741b065eb5a08 -2026-08-30-retain-ignorable-external-session-events.zh.md: 4f988cf28b40c09d86e23c896da645018e918993 +2026-08-30-retain-ignorable-external-session-events.md: c796a8dab1a9d127473a341fc98bc9934429fbdf +2026-08-30-retain-ignorable-external-session-events.zh.md: 0c635b1082a31a0a35d01669ff9f933a1f218bee diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md index 8217f1865f..c796a8dab1 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md @@ -12,9 +12,7 @@ That producer inventory did not cover a third-party plugin that currently depend ## Decision -The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, SQLite, API transport, generated catalogs, and test fixtures. `PersistenceCoordinator` continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read. - -SQLite schema 20 stores packed physical rows with `ignorable=0`, scalar events marked `ignorable: true` with `ignorable=1`, and other scalar events with `NULL`. This keeps the logical marker and the packed-row discriminator in the same representation without confusing a scalar event whose name matches a physical chunk tag. +The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, API transport, generated catalogs, and test fixtures. `PersistenceCoordinator` continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read. The field is removable only after a replacement supports the current third-party plugin across event production, persistence, reload, and transport, with an explicit cutover for sessions already containing the marker. The [session log versioning decision](2026-08-10-session-log-version-mechanism.md) continues to own the default-required safety rule and format-version policy. @@ -30,6 +28,4 @@ The field is removable only after a replacement supports the current third-party ## Consequences -Third-party informational events can remain reloadable when their stored records carry the explicit marker, while unknown required events still fail loudly. The field remains part of the public event envelope, persistence schemas, transport types, generated references, and their tests until a replacement satisfies the cutover condition. - -SQLite advances from schema 19 to schema 20 because restoring the durable column changes the pre-release physical database format. The provider continues to reject other schema versions rather than migrating them. +Third-party informational events can remain reloadable when their stored records carry the explicit marker, while unknown required events still fail loudly. The field remains part of the public event envelope, JSONL representation, transport types, generated references, and their tests until a replacement satisfies the cutover condition. diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md index 4f988cf28b..0c635b1082 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md @@ -12,9 +12,7 @@ Status: implemented ## 决定 -标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、SQLite、API 传输、生成目录与测试 fixture。`PersistenceCoordinator` 继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。 - -SQLite schema 20 对打包物理行存储 `ignorable=0`,对带 `ignorable: true` 的标量事件存储 `ignorable=1`,对其他标量事件存储 `NULL`。这样,逻辑标记与打包行判别值可以共用一种表示,同时不会把名称与物理分片标签相同的标量事件混淆为打包行。 +标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、API 传输、生成目录与测试 fixture。`PersistenceCoordinator` 继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。 只有替代机制在事件生产、持久化、重新加载与传输中都支持当前第三方插件,并为已包含该标记的会话提供显式切换方案后,才能删除此字段。[Session log 版本决策](2026-08-10-session-log-version-mechanism.zh.md)继续定义默认读取必需的安全规则与格式版本策略。 @@ -30,6 +28,4 @@ SQLite schema 20 对打包物理行存储 `ignorable=0`,对带 `ignorable: tru ## 影响 -第三方信息性事件的已存记录带有显式标记时可以继续重新加载,未知必需事件则仍会明确失败。在替代机制满足切换条件前,该字段继续属于公开事件信封、持久化 schema、传输类型、生成引用及其测试。 - -恢复持久列改变了预发布物理数据库格式,因此 SQLite 从 schema 19 提升到 schema 20。提供方继续拒绝其他 schema 版本,而不是迁移它们。 +第三方信息性事件的已存记录带有显式标记时可以继续重新加载,未知必需事件则仍会明确失败。在替代机制满足切换条件前,该字段继续属于公开事件信封、JSONL 表示、传输类型、生成引用及其测试。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml index 89d49a5d34..1f998e3982 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md -2026-07-20-jsonl-storage-identity.md: 1079eb700c819951dbb81e99376c0b71e3e84617 -2026-07-20-jsonl-storage-identity.zh.md: 6beb0d9f92ac1b1f4c3b03a783aa67e16b5fa7bb +2026-07-20-jsonl-storage-identity.md: e249640b1cd8900fdb7a136e9ab56abbf474ac86 +2026-07-20-jsonl-storage-identity.zh.md: 4775d6b7aa02abbd58ef89cdfa9377dc4f94b8de diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md index 1079eb700c..e249640b1c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md @@ -6,7 +6,7 @@ English | [中文](2026-07-20-jsonl-storage-identity.zh.md) ## Problem -JSONL lookup selects a physical log from the requested session id across project directories, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The project scan also needs a defined result when the same encoded id exists in more than one project directory. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id. +JSONL lookup selects a physical log from the requested session id across project directories, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The project scan also needs a defined result when the same encoded id exists in more than one project directory. A medium that resolves records through one authoritative key may avoid this ambiguity, but the shipped JSONL provider must bind its selected path explicitly. ## Decision @@ -20,7 +20,7 @@ An existing configured JSONL root must be a readable directory when the plugin l **Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without making the check depend on a flat global namespace. -**Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to SQLite, test backends, append, and repair makes every implementation carry a concept only the file backend needs. +**Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to the coordinator, test backends, append, and repair makes every implementation carry a concept only the file backend needs; an out-of-tree provider keeps its medium-specific locator inside its own primitives. **Coordinate multiple live writers.** A dedicated coordination service, process-global registry, or cross-process lock would define a new deployment topology rather than repair identity validation. The supported topology has one live writer; no-overwrite hard-link publication still arbitrates an initial same-id creation race. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md index 6beb0d9f92..4775d6b7aa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个项目目录中时,项目扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。 +JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个项目目录中时,项目扫描也必须给出确定的结果。通过单一权威键解析 record 的介质可能不存在这种歧义,但交付的 JSONL provider 必须显式绑定选定路径。 ## 决策 @@ -20,7 +20,7 @@ JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日 **按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需让检查依赖扁平的全局命名空间,也能消除身份缺陷。 -**通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数,会让每个实现都承担只有文件后端需要的概念。 +**通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为协调器、测试后端、追加和修复操作增加一个泛型和参数,会让每个实现都承担只有文件后端需要的概念;仓库外 provider 把自己的介质定位器保留在自身原语内。 **协调多个活动写入方。** 专用协调服务、进程级全局注册表或跨进程锁会定义新的部署拓扑,而不是修复身份验证。受支持的拓扑只有一个活动写入方;禁止覆盖的硬链接发布仍会裁决初始的同 id 创建竞态。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml index fd88e090d1..a353fc98ef 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md -2026-07-28-load-pre-identity-session-messages.md: 694bf9ed9ec7a24399b5898665c2222a806f93c6 -2026-07-28-load-pre-identity-session-messages.zh.md: 374f1993638fae503736543815a62e634850afa1 +2026-07-28-load-pre-identity-session-messages.md: 6d022cb4b37345cd61cc9a89c6fc55c19ad402a5 +2026-07-28-load-pre-identity-session-messages.zh.md: 86439337b3c646a72b7584fbf4640799226fc9ca diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md index 694bf9ed9e..6d022cb4b3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md @@ -6,9 +6,9 @@ English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md) ## Problem -The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL and SQLite sessions still held the immediately preceding shapes: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-shape validation rejected them before resume could construct a live `Session`. +The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL Sessions still held the immediately preceding forms: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-form validation rejected them before resume could construct a live `Session`. -Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party backends without weakening validation for unrelated obsolete or malformed events. +Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party provider without weakening validation for unrelated obsolete or malformed events. ## Decision @@ -22,15 +22,15 @@ The upgrade is read-only. Stored legacy records remain unchanged; a resumed sess **Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation. -**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require separate atomic replacement mechanisms for JSONL and SQLite, and expand a read compatibility fix into a migration system. +**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require an atomic replacement mechanism, and expand a read compatibility fix into a migration system. **Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends. ## Consequences -Pre-identity JSONL and SQLite sessions resume with their original message content, sources, assistant provider/model fields, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen. +Pre-identity JSONL Sessions resume with their original message content, sources, assistant provider/model fields, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen. -This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference, JSONL, and SQLite backends, including deterministic reload and tool-result replacement identity. +This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference and JSONL provider, including deterministic reload and tool-result replacement identity. ## Related diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md index 374f199363..86439337b3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -带标识的不可变消息变更将四种持久化事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的标头仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造活跃的 `Session`。 +带标识的不可变消息变更将四种持久化事件载荷替换为完整消息值。现有 v0 JSONL Session 仍保留紧邻该变更之前的表示:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些 Session 的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前表示验证会拒绝它们,导致恢复流程无法构造 live `Session`。 -消息表示改变时没有提升版本,导致这些日志无法仅凭标头与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 +消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的 first-party provider 所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 ## 决策 @@ -22,15 +22,15 @@ Status: implemented **按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。 -**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储约定,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。 +**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储约定,还需要原子替换机制,并将一次读取兼容性修复扩大为迁移系统。 **每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。 ## 后果 -消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、assistant 的提供方/模型字段、工具调用关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 +消息标识机制引入前的 JSONL Session 可以恢复,并保留原始消息内容、来源、assistant 的 provider/model 字段、工具调用关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 -这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享的协调器约定会在内存参考实现、JSONL 和 SQLite 后端上验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。 +这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享协调器约定会通过内存参考实现与 JSONL provider 验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。 ## 相关 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index f1e605b30c..a1a69bb1af 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: 387d05e055c2b90f3aa7ee39d624c117ba54b4b1 -2026-07-31-resume-selector-batch-projection.zh.md: febd744b3f7ec58dab94f5d8437feaa270dfffcf +2026-07-31-resume-selector-batch-projection.md: e4809575e03bbd74522b26a8a170ac558d6eee41 +2026-07-31-resume-selector-batch-projection.zh.md: 04646d266c87b96b7c28692663540ffe808d0082 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index 387d05e055..e4809575e0 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -13,7 +13,7 @@ Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per Selector rows fold nothing but titles, and everything else a row shows comes from metadata: - Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, one file read per session), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the disabled "Unreadable session" fallback. -- The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp. +- The activity timestamp never reads a log: a live Session uses its last in-memory event time; a persisted Session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when a provider locates no per-Session artifact or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed Session up — accepted as the price of a metadata-only timestamp. - The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff. The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame, Enter reports that sessions are still loading, and Escape cancels. Closing the overlay aborts the scan through the `AbortSignal` the query methods accept; a signal-ignoring backend's late settlement is dropped by a staleness check. The finished scan swaps rows in through `setCandidates` (clearing a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing, titles, and mtimes, so any scan failure closes the overlay and reports a notice rather than stranding the loading placeholder. @@ -26,7 +26,7 @@ No session-query or session-persistence surface changed. The shipped TUI composi **Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications. -**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, both backends, and the query record shape for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times. +**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, provider, and query record type for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times. **A bespoke persisted title index or TUI-local title cache.** Rejected: the session-projection cache already is the owned durable checkpoint system with an invalidation contract (`stateVersion`, identity binding, shrunk-log anchoring); mounting it beats adding a parallel cache. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index febd744b3f..04646d266c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -13,7 +13,7 @@ Status: implemented 选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据: - 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,每会话一次文件读取),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描每会话一次文件读取。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的「Unreadable session」回退。 -- 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。 +- 活动时间戳从不读取日志:live Session 取内存中最后一个事件的时间;持久化 Session 对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当 provider 定位不到逐 Session 产物或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的 Session 上浮——这是元数据时间戳的代价,予以接受。 - 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。 选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染「Loading sessions…」加载占位符,选择器从第一帧起就拥有终端输入,Enter 提示会话仍在加载,Escape 取消。关闭 overlay 会通过查询方法接受的 `AbortSignal` 中止扫描;忽略信号的后端的迟到结算由陈旧性检查丢弃。扫描完成后通过 `setCandidates`(同时清除陈旧的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询、标题与 mtime 共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告通知,而不会让加载占位符悬置。 @@ -26,7 +26,7 @@ session-query 与 session-persistence 的任何接口都未改变。随附的 TU **只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。 -**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费方再引入。 +**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、provider 和查询记录类型,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费方再引入。 **专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效约定(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml index 73dc1f35b3..0a513e049f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md -2026-08-04-load-pre-react-loop-sessions.md: 277a481f366f1182fd3948caf858607efd550e5e -2026-08-04-load-pre-react-loop-sessions.zh.md: 67f6b361811a0de024b8e6130f31a33f1deaf9ef +2026-08-04-load-pre-react-loop-sessions.md: e95817ee60647ca002060a4f90c2263d4fe7ce42 +2026-08-04-load-pre-react-loop-sessions.zh.md: 98fb1f530f5168fc02b312775d1bb8e6d305b8f8 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md index 277a481f36..e95817ee60 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md @@ -26,11 +26,11 @@ The importer does not synthesize inbox splices. A resumed pre-react-loop agent b **Assign coarse aborted records to an existing caller.** Mapping them to `user`, `parent`, or `hook` would invent a caller that the old record did not name. A dedicated `legacy` cause keeps the stop classification without making a false audit claim. -**Rewrite stored JSONL and SQLite records.** A rewrite would violate the append-only contract and require backend-specific atomic migration machinery for a read compatibility boundary. +**Rewrite stored JSONL records.** A rewrite would violate the append-only contract and require atomic migration machinery for a read compatibility boundary. ## Consequences -Sessions written in the refactor's base format resume through the current AgentLoop with their steering content, turn boundaries, error facts, and stop classification intact. The shared coordinator contract covers in-memory, JSONL, and SQLite `load`/`inspect`/`readFrom`, including the SQLite suffix fallback; an assembled JSONL Agent resume verifies that the historical transcript is visible while both new inbox lists start empty. +Sessions written in the refactor's base format resume through the current AgentLoop with their steering content, turn boundaries, error facts, and stop classification intact. The shared coordinator contract covers in-memory and JSONL `load`/`inspect`/`readFrom`; an assembled JSONL Agent resume verifies that the historical transcript is visible while both new inbox lists start empty. This exception supports the base format, not intermediate formats produced during development of the refactor. In particular, it defines no migration for earlier experimental `agent/inbox/spliced` payloads. Exact-shape recognition keeps malformed current-looking records on their rejection path instead of guessing them into validity. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md index 67f6b36181..98fb1f530f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md @@ -26,11 +26,11 @@ react-loop 简化在保持 `SESSION_FORMAT_VERSION` 为 0 的同时更改了持 **将粗粒度中止记录归因于现有调用方。** 将其映射到 `user`、`parent` 或 `hook` 会凭空指定旧记录未注明的调用方。专用的 `legacy` 原因既能保留停止分类,也不会产生虚假的审计事实。 -**重写已存储的 JSONL 和 SQLite 记录。** 重写会违反仅追加约定,并要求为读取兼容边界建立后端专用的原子迁移机制。 +**重写已存储的 JSONL 记录。** 重写会违反仅追加约定,并要求为读取兼容边界建立原子迁移机制。 ## 后果 -以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。共享协调器约定覆盖内存、JSONL 和 SQLite 的 `load`/`inspect`/`readFrom`,包括 SQLite 后缀回退;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。 +以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。共享协调器约定覆盖内存与 JSONL 的 `load`/`inspect`/`readFrom`;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。 此例外支持基线格式,不支持重构开发期间产生的中间格式。具体而言,它没有为更早的实验性 `agent/inbox/spliced` 载荷定义迁移。通过确切形状识别,当前格式外观相似但结构错误的记录仍会走拒绝路径,不会被猜测性地转换为有效记录。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml index dd1cbe2e66..94a7268a28 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md -2026-08-13-bounded-cold-blank-verification.md: cd50d29f0b5d417077d5d848415607885c39474a -2026-08-13-bounded-cold-blank-verification.zh.md: 851b1fb35126a42623e251bf790dde2029189b36 +2026-08-13-bounded-cold-blank-verification.md: ab2f3b5a0534a98e02a3e2494ca2fff1223efe81 +2026-08-13-bounded-cold-blank-verification.zh.md: 5dc4b62f7ac43ebd3c4a8cef58f5da9af367520a diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md index cd50d29f0b..ab2f3b5a05 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md @@ -24,7 +24,7 @@ A cold summary trusts cached `blank: false`, because a checkpoint prefix contain **Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes. The physical-size eligibility check targets small historical artifacts that can be checked cheaply and degrades larger unknowns toward visibility. It intentionally does not add a persistence operation solely to make the threshold atomic with the read: concurrent growth may increase one probe's read cost, but the additional events can only preserve visibility or change a blank result to non-blank. -**Store blankness and recency in an authoritative persistence index.** Deferred because JSONL has an immutable first line and would require a second durable artifact with ordered updates, while SQLite would require a schema field. The broader exact-index design remains in the [last-activity proposal](../../proposed/architecture/2026-07-29-durable-last-activity-index.md). +**Store blankness and recency in an authoritative persistence index.** Deferred because the shipped JSONL provider has an immutable first line and would require a second durable artifact with ordered updates. An out-of-tree provider may use its own index only with defined update atomicity, versioning, and recovery. The broader exact-index design remains in the [last-activity proposal](../../proposed/architecture/2026-07-29-durable-last-activity-index.md). **Continue ordering JSONL by mtime.** Rejected because mtime records every artifact write, including pickup boundaries, rather than the latest human prompt. Its error direction promotes untouched Sessions to the front. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md index 851b1fb351..5dc4b62f7a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md @@ -24,7 +24,7 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N **读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小资格检查只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。该检查有意不为“让阈值与读取原子化”单独新增 persistence 操作:并发增长可能增加一次探测的读取成本,但新增事件只会保持可见,或把空白结果改为非空。 -**把空白状态与最近时间存入权威 persistence index。** 暂缓,因为 JSONL 的首行不可变,需要增加带有顺序写入要求的第二份持久工件;SQLite 则需要 schema 字段。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)负责。 +**把空白状态与最近时间存入权威 persistence index。** 暂缓,因为交付的 JSONL provider 首行不可变,需要增加带有顺序写入要求的第二份持久工件。仓库外 provider 只有定义更新原子性、版本与恢复语义后才可使用自己的索引。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)负责。 **继续按 mtime 排序 JSONL。** 拒绝,因为 mtime 记录包括拾起边界在内的每一次工件写入,而非最近真人 prompt;其错误方向会把未经操作的 Session 提升到列表开头。 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml index 16bef31c4c..50b5c20bf4 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md -2026-07-10-agent-session-identity-and-log-location.md: 29b0e6c7d26d6d9dd000dfc7d55943de628c6169 -2026-07-10-agent-session-identity-and-log-location.zh.md: 86d93ff237b9394624220f71c7a174f8a945d9d2 +2026-07-10-agent-session-identity-and-log-location.md: 1bd16fa4123aa8a44719aa0e8c40c4e662f7cb3b +2026-07-10-agent-session-identity-and-log-location.zh.md: 1b54949fb34a0593eaa255e8ff548c203c8023c8 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index 29b0e6c7d2..1bd16fa412 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -27,7 +27,7 @@ interface SessionPersistence { } ``` -`path` is an absolute local path to the backend's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. SQLite and any backend without an honest local per-session artifact return `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists. +`path` is an absolute local path to the provider's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. An out-of-tree provider without an honest local per-Session artifact returns `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists. The model-facing bash package owns a `ctx.shellEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment API enumerable for diagnostics and future prompt/UI consumers. @@ -60,7 +60,7 @@ Resume reuses the loaded header and therefore the same id and location. Fork and ## Testing -Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL/SQLite locator contract tests and both hook bridge suites pin available and unavailable transcript dialects. +Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL and no-artifact locator contract tests plus both hook bridge suites pin available and unavailable transcript dialects. A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md index 86d93ff237..1b54949fb3 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -27,7 +27,7 @@ interface SessionPersistence { } ``` -`path` 是该后端为 `meta` 保留的专用日志的本地绝对路径;`kind` 标识其表示形式。JSONL 使用解析后的根目录和路径辅助函数返回 `{ kind: 'jsonl', path }`。SQLite 以及任何无法诚实提供逐会话本地产物的后端均返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告按需创建的目标路径。 +`path` 是 provider 为 `meta` 保留的专用日志本地绝对路径;`kind` 标识其表示。JSONL 使用解析后的 root 与路径 helper 返回 `{ kind: 'jsonl', path }`。无法诚实提供逐 Session 本地产物的仓库外 provider 返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告按需创建的目标路径。 面向模型的 bash 包拥有一个 `ctx.shellEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境 API 可供诊断工具和未来的提示词/UI 消费方枚举。 @@ -60,7 +60,7 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管 ## 测试 -单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL/SQLite 定位器约定测试与两套钩子桥接测试均锁定 transcript 可用和不可用两种方言。 +单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL 与无产物定位器约定测试、两套钩子桥接测试均固定 transcript 可用和不可用两种方言。 一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照测试会固定录制请求 header 中的通用 bash 说明。该约定属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml index 16d4768b03..4f6d006d90 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md -2026-07-10-sqlite-session-query-provider.md: 0e15ea15f516091825276c4a9bcde2184c653123 -2026-07-10-sqlite-session-query-provider.zh.md: f18d394733de29ba297adfc2602efabe33b9b75f +2026-07-10-sqlite-session-query-provider.md: 76ebbc24e16a9429be63d0e45ec84b14c29d43f6 +2026-07-10-sqlite-session-query-provider.zh.md: 6d8518252d0a79906fd9438876d4a92ba414363e diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md index 0e15ea15f5..76ebbc24e1 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -56,4 +56,4 @@ Search has a small provider-neutral API while its only backend owns every derive The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is prompt while queued and quiescent while awaiting sources; synchronous SQLite execution remains a non-preemptible section bracketed by signal checks. -Unit coverage pins extraction, filters, both search scopes, all default surfaces, metadata-before-ranking, snippets, literal escaping, deterministic ties, complete pagination, scoped cursor invalidation, dynamic persistence mount/unmount, restart reconciliation, live shadow/reveal/reopen, schema safety, rollback retry, and queued/in-flight source-wait cancellation. A keyless real-Loader-path test combines the package with the real SQLite persistence backend. +Unit coverage pins extraction, filters, both search scopes, all default surfaces, metadata-before-ranking, snippets, literal escaping, deterministic ties, complete pagination, scoped cursor invalidation, dynamic persistence mount/unmount, restart reconciliation, live shadow/reveal/reopen, schema safety, rollback retry, and queued/in-flight source-wait cancellation. A keyless real-Loader-path test combines the derived SQLite index with the real JSONL persistence provider. diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md index f18d394733..6d8518252d 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md @@ -56,4 +56,4 @@ Service Definition 包还拥有共享的第一方语义提取与提供方无关 选定的分词器以较小的索引体积支持短 token,但不承诺子串召回。字面短语使查询语法安全且可预测,代价是不支持布尔表达式或完整 MATCH 表达式。取消在操作排队期间会及时生效,在等待数据源期间则会等待其完全停稳;同步 SQLite 执行仍是不可抢占区段。 -单元测试将以下行为固化为约定:提取、过滤器、两种搜索范围、所有默认 surface、先过滤元数据再排序、摘要片段、字面量转义、确定性平局处理、完整分页、按范围的游标失效、动态挂载/卸载持久化服务、重启对齐、实时遮蔽、显露与重新打开、schema 安全、回滚重试,以及排队中或进行中的数据源等待取消。一个无需密钥的真实 Loader 路径测试会将该包与真实的 SQLite 持久化后端组合使用。 +单元测试将以下行为固化为约定:提取、过滤器、两种搜索范围、所有默认 surface、先过滤元数据再排序、摘要片段、字面量转义、确定性平局处理、完整分页、按范围的游标失效、动态挂载/卸载持久化服务、重启对齐、实时遮蔽、显露与重新打开、schema 安全、回滚重试,以及排队中或进行中的数据源等待取消。一个无需密钥的真实 Loader 路径测试会把派生 SQLite 索引与真实 JSONL 持久化 provider 组合使用。 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml index cd4101c2bd..f6c61d52d4 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md -2026-07-16-durable-per-step-time-context.md: e8fd04dd52f3c42de64cf64dd16bafa236dd396a -2026-07-16-durable-per-step-time-context.zh.md: 1e3597c339478ee5c6e9a851c682c66a54780424 +2026-07-16-durable-per-step-time-context.md: 9ebc16a054cd93414ea4cfa69a8a49ccfb7145d8 +2026-07-16-durable-per-step-time-context.zh.md: e470b13e6281b2b436a69af494a22d88a7aa0411 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index e8fd04dd52..9ebc16a054 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -68,7 +68,7 @@ Unit and real-loop tests pin timestamp formatting, unique/mixed/missing browser ## Consequences -- Browser-zone meaning is request-local and durable without changing Session, fork, JSONL, or SQLite schemas. +- Browser-zone meaning is request-local and durable without changing Session, fork, or JSONL schemas. - The model receives the requested browser-local assumption on each Schedule Web request step; mixed or missing provenance asks instead of guessing. - Tools remain explicit: context helps the model choose fields but does not become a hidden package-seam default. - Timing context remains append-only until compaction; a positive interval reduces history growth but can omit fresh browser guidance on later requests. diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md index 1e3597c339..e470b13e62 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -68,7 +68,7 @@ Elapsed since the preceding step context: . ## 后果 -- 浏览器时区含义归属于请求并可持久重建,无需更改会话、fork、JSONL 或 SQLite schema。 +- 浏览器时区含义归属于请求并可持久重建,无需更改会话、fork 或 JSONL schema。 - 模型在每个 Schedule Web 请求步骤中都会收到所请求的浏览器本地假设;来源信息混杂或缺失时会询问,而不是猜测。 - 工具仍保持显式边界:上下文帮助模型选择字段,但不会成为包 seam 上隐藏的默认值。 - 时间上下文仅追加并保留到压缩为止;正数间隔会减少历史增长,但也可能使后续请求缺少新的浏览器时区指导。 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 7e46759300..af9ab32475 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md -2026-07-21-log-backed-session-titles.md: d857cc5befa01365460895095c97610e84ec5cb8 -2026-07-21-log-backed-session-titles.zh.md: 0246a084cf11e61f20865edf9bf36c4c96a742c0 +2026-07-21-log-backed-session-titles.md: 7bf9257813c154b674e4b4d632c9fa2b712a1fdd +2026-07-21-log-backed-session-titles.zh.md: e09317b1c0e458b8278ee22d66c8afdb0386d071 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index d857cc5bef..7bf9257813 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -58,7 +58,7 @@ A fork inherits seed title events unchanged, like the rest of its source log — ## Consequences -- Titles survive JSONL and SQLite persistence, replay, and fork inheritance without a separate mutable record. +- Titles survive JSONL persistence, replay, and fork inheritance without a separate mutable record. - Web title delivery stays incremental and log-backed without a title index or persisted-list scan; cold list rows improve after attach. - A fallback appears immediately. Each fresh Web session adds one first-prompt auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session. - Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index 0246a084cf..e09317b1c0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -58,7 +58,7 @@ Status: implemented ## 后果 -- 标题可以在 JSONL 和 SQLite 持久化中存续、重放并遵循 fork 继承语义,而无需单独的可变记录。 +- 标题可以在 JSONL 持久化中存续、重放并遵循 fork 继承语义,而无需单独的可变记录。 - Web 标题仍以增量方式从日志交付,无需标题索引或扫描持久化列表;冷会话的列表项会在会话附加后改用标题。 - 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 - 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV Cache 标识保持不变。 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index 0eb186e446..47eb54b338 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md -2026-07-24-provider-retry-policies.md: 96979b219aebece96a1bcc09aa3dd572d2b9222d -2026-07-24-provider-retry-policies.zh.md: 127769364957788f799ee910d31996201c027789 +2026-07-24-provider-retry-policies.md: 968f40272d3d3cb0efa362c97dcb8630888f80ec +2026-07-24-provider-retry-policies.zh.md: 3274d0311bb79825ef9551549e4783a33c1cb6ee diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index 96979b219a..968f40272d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -62,7 +62,7 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Verification -Adapter tests validate nested policies at provider load, prove explicit profile policies reach registration, prove omission resolves to five retries, and retain the serving policy across in-flight route replacement. LLM service tests prove adapter policies are captured and omission uses the shared five-retry behavior. Resolver tests prove always mode ignores retained normal-only fields but returns a pure always policy. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins omitted DeepSeek and pi-ai policies at five retries, then proves settings can write `{ mode: 'always', maxRetries: 5 }` and obtain a pure always policy. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove explicit profile policies reach registration, prove omission resolves to five retries, and retain the serving policy across in-flight route replacement. LLM service tests prove adapter policies are captured and omission uses the shared five-retry behavior. Resolver tests prove always mode ignores retained normal-only fields but returns a pure always policy. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins omitted DeepSeek and pi-ai policies at five retries, then proves settings can write `{ mode: 'always', maxRetries: 5 }` and obtain a pure always policy. A JSONL test round-trips an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index 1277693649..3274d0311b 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -62,7 +62,7 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,证明显式 profile 策略抵达注册流程,证明省略配置会解析为五次重试,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。LLM 服务测试会证明适配器策略被捕获,且省略配置使用共享的五次重试行为。解析器测试会证明 always 模式忽略残留的 normal 专属字段,但返回纯 always 策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照会把省略配置的 DeepSeek 与 pi-ai 策略固定为五次重试,再证明 settings 可以写入 `{ mode: 'always', maxRetries: 5 }` 并得到纯 always 策略。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明显式 profile 策略抵达注册流程,证明省略配置会解析为五次重试,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。LLM 服务测试会证明适配器策略被捕获,且省略配置使用共享的五次重试行为。解析器测试会证明 always 模式忽略残留的 normal 专属字段,但返回纯 always 策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照会把省略配置的 DeepSeek 与 pi-ai 策略固定为五次重试,再证明 settings 可以写入 `{ mode: 'always', maxRetries: 5 }` 并得到纯 always 策略。JSONL 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 7af7350d5b..8ed90b9b2c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 9924550a04b636535ce1daa329865beb1c9e951d -2026-08-05-agent-teams.zh.md: 91ae807f3005c173a61f9fc32661d2b650692a04 +2026-08-05-agent-teams.md: fbcd8485a972323bc0f8ffb6a5cb7cca9a50044e +2026-08-05-agent-teams.zh.md: 0a1a8a81c47abfca275f5fc3c60b8d514cdddd30 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 9924550a04..fbcd8485a9 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -62,7 +62,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Testing -Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the private Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. +Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the private Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 91ae807f30..0a1a8a81c4 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -62,7 +62,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Testing -Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载私有 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 +Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载私有 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index f125c62af7..6b870f06a2 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md -2026-08-05-context-form-vocabulary.md: 9f20614dcd2ed0164efb51f938de9f74657d3bc3 -2026-08-05-context-form-vocabulary.zh.md: ebdabeab5a074f21e8fac9625a78c99cc401a6d3 +2026-08-05-context-form-vocabulary.md: 912842a9d59fd49491a53f987d172d64d4c0e101 +2026-08-05-context-form-vocabulary.zh.md: 0d4e1d44e4d22b8bfcb6c73bb015bc3240381bd3 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index 9f20614dcd..912842a9d5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -39,7 +39,7 @@ That move also relocates catalog **identity**: the republish digest now covers t Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared. -The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point. +The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a persistence seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that Session at the latest, least diagnosable point. Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining source data as fields. Opaque is the documented default; the contract assigns these unsupported cases to it. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index ebdabeab5a..0d4e1d44e4 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -39,7 +39,7 @@ Status: implemented 两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。 -生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 +生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自持久化 seed,seed 验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该 Session 此后每一步都在最晚、最难定位的点失败。 其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源数据列成字段。opaque 是文档规定的默认;约定要求这些不支持的情况使用它。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index c093041c15..3f5971500e 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 69cc3d9fdfb267242de863c8359994ef25754bb1 -2026-08-10-web-session-log-export.zh.md: 9ab7318a4b89ddbd343739dc730569f4d8f584ba +2026-08-10-web-session-log-export.md: df80ad4d264d835b2c11973ca61cf143576f11f3 +2026-08-10-web-session-log-export.zh.md: 2af86f371f9e7ed5255bb4e57a8a43427c745fe0 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 69cc3d9fdf..df80ad4d26 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -25,6 +25,6 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Consequences - Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. -- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response. +- `supportsRawArtifacts` explicitly separates backend capability from session absence: a backend without one raw artifact per Session reports `false` and the concrete `readRaw` default rejects, while the shipped JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response. - Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button. - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 9ab7318a4b..2af86f371f 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -25,6 +25,6 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 后果 - 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 -- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。 +- `supportsRawArtifacts` 明确区分后端能力与会话缺失:没有每 Session 一份原始工件的后端报告 `false`,具体 `readRaw` 默认会拒绝;交付的 JSONL 覆写报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。 - fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml index f411b9da70..444893c272 100644 --- a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md -2026-08-22-standard-acp-automation-controls.md: 0ab03eac8b99267da5bd26bf2c86bfadca2a4956 -2026-08-22-standard-acp-automation-controls.zh.md: 2e1348a4f2791e90492fc1402c96eaf29abb00a4 +2026-08-22-standard-acp-automation-controls.md: 5523fd547dd850e2e52eb818ef418df5a325106e +2026-08-22-standard-acp-automation-controls.zh.md: 23140eb3e76b26716239d0f53fe3206efd87baa9 diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md index 0ab03eac8b..5523fd547d 100644 --- a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md @@ -30,9 +30,9 @@ Complete ACP lifecycle support requires session persistence. `session/list` read `session/new` explicitly asks persistence to materialize the live session header without inventing a session event, so even an empty session can be closed, listed, and resumed. Other frontends retain the persistence seam's lazy default and leave abandoned empty sessions unmaterialized. `session/resume` rejects active ids and non-top-level or unknown persisted ids, verifies the requested canonical `cwd` before Agent composition, restores the durable session without replaying it to the client, and mounts the MCP declarations supplied by that request. `session/close` leaves the durable log available for a later process. -Persistence deliberately treats `create(meta)` as a live registration: JSONL creates no artifact and SQLite creates no row until the first event append. That default removes abandoned empty sessions, but ACP cannot inherit it because `session/new` publishes a session identity before any prompt and the process may stop after the success response without receiving `session/close`. The bridge materializes only after Agent and MCP composition succeeds and before returning `session/new`; failed composition remains residue-free, while every returned id survives restart. +Persistence deliberately treats `create(meta)` as a live registration: the shipped JSONL provider creates no artifact until the first event append. That default removes abandoned empty sessions, but ACP cannot inherit it because `session/new` publishes a session identity before any prompt and the process may stop after the success response without receiving `session/close`. The bridge materializes only after Agent and MCP composition succeeds and before returning `session/new`; failed composition remains residue-free, while every returned id survives restart. -`ensureMaterialized(session)` accepts the exact live Session so the coordinator first flushes it, then serializes header-only materialization on the existing per-session write chain using the immutable registered header. JSONL writes one header frame and SQLite writes one metadata row; repeat calls are idempotent, and an unsupported backend fails session creation instead of promising resumability it cannot provide. Making `create` eager would change every frontend's abandoned-session behavior, appending a synthetic event would invent a sequence and replay fact solely to trigger storage, and waiting until close would make durability race process loss. +`ensureMaterialized(session)` accepts the exact live Session so the coordinator first flushes it, then serializes header-only materialization on the existing per-session write chain using the immutable registered header. JSONL writes one header frame; an out-of-tree provider must materialize equivalent header state atomically or reject the operation. Repeat calls are idempotent. Making `create` eager would change every frontend's abandoned-session behavior, appending a synthetic event would invent a sequence and replay fact solely to trigger storage, and waiting until close would make durability race process loss. ## Standard configuration options diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md index 2e1348a4f2..23140eb3e7 100644 --- a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md @@ -30,9 +30,9 @@ `session/new` 会显式要求持久化在不虚构会话事件的情况下实体化 live session header,因此即使空会话也可以关闭、列出和恢复。其他前端仍保留持久化 seam 的惰性默认行为,不会实体化被放弃的空会话。`session/resume` 拒绝活动 id,以及非顶层或未知的持久 id;在组合 Agent 前校验请求的规范 `cwd`;恢复持久日志但不向客户端重放;挂载该请求提供的 MCP 声明。`session/close` 让持久日志可供后续进程使用。 -持久化有意把 `create(meta)` 视为 live registration:JSONL 在首次追加事件前不创建 artifact,SQLite 在此之前不创建 row。该默认行为会移除被放弃的空会话,但 ACP 不能继承它,因为 `session/new` 会在任何提示词出现前公布会话身份,而进程可能在返回成功响应后、收到 `session/close` 前停止。桥接层只在 Agent 和 MCP 组合成功后、返回 `session/new` 前执行实体化;组合失败仍不留下残留物,每个已返回 id 则都能在重启后继续存在。 +持久化有意把 `create(meta)` 视为 live registration:交付的 JSONL provider 在首次追加事件前不创建 artifact。该默认行为会移除被放弃的空会话,但 ACP 不能继承它,因为 `session/new` 会在任何提示词出现前公布会话身份,而进程可能在返回成功响应后、收到 `session/close` 前停止。桥接层只在 Agent 和 MCP 组合成功后、返回 `session/new` 前执行实体化;组合失败仍不留下残留物,每个已返回 id 则都能在重启后继续存在。 -`ensureMaterialized(session)` 接收确切 live Session,使 coordinator 先 flush 该会话,再通过现有 per-session 写入链,使用已注册的不可变 header 串行执行仅 header 实体化。JSONL 写入一个 header frame,SQLite 写入一条 metadata row;重复调用幂等,不支持该能力的 backend 会让会话创建失败,而不会承诺无法提供的可恢复性。让 `create` 全面 eager 会改变所有前端放弃会话的行为;追加 synthetic event 会仅为触发存储而虚构 sequence 与 replay 事实;等到关闭时再写入则会让持久性与进程丢失竞争。 +`ensureMaterialized(session)` 接收确切 live Session,使 coordinator 先 flush 该会话,再通过现有 per-session 写入链,使用已注册的不可变 header 串行执行仅 header 实体化。JSONL 写入一个 header frame;仓库外 provider 必须原子实体化等价 header 状态,否则拒绝该操作。重复调用幂等。让 `create` 全面 eager 会改变所有前端放弃会话的行为;追加 synthetic event 会仅为触发存储而虚构 sequence 与 replay 事实;等到关闭时再写入则会让持久性与进程丢失竞争。 ## 标准配置选项 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index 06ddb60467..41326cd476 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.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/process/2026-07-06-node-engine-floor.md -2026-07-06-node-engine-floor.md: a6ef430fb374caa8530583f7ffd9b65cdfc24f1a -2026-07-06-node-engine-floor.zh.md: 6bc38322c1a5656af998535338fad8703e10fd37 +2026-07-06-node-engine-floor.md: 9d42fc77e5630289b08bc3499986eda4358cbe61 +2026-07-06-node-engine-floor.zh.md: 4820ade269f7ecf5def11dc239c1c355daa92924 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md index a6ef430fb3..9d42fc77e5 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md @@ -14,7 +14,7 @@ Set `engines.node` to `^22.19.0 || >=24.0.0` and test keyless CI on `['22.19', 2 Two Node features gate the source runtime: -- **`node:sqlite`** — `packages/session/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load. +- **`node:sqlite`** — `packages/storage/storage-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`, and the optional Session-query provider loads it on first search. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load. - **Native TypeScript type-stripping** — the built-mode `apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts` smoke boots the test-support `.ts` driver under plain `node` (no tsx) and loads the `.ts` test adapter (`cli-mock-llm.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use. diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index 6bc38322c1..4820ade269 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -14,7 +14,7 @@ Status: implemented 两个 Node 特性决定了源码运行时的门槛: -- **`node:sqlite`**:`packages/session/session-persistence-sqlite` 在顶层执行 `import { DatabaseSync } from 'node:sqlite'`。该模块在 **22.13**(LTS)和 **23.4**(Current)取消了 `--experimental-sqlite` 标志要求;在此之前,导入它会在加载时抛出异常。 +- **`node:sqlite`**:`packages/storage/storage-sqlite` 在顶层执行 `import { DatabaseSync } from 'node:sqlite'`,可选 Session-query provider 则在首次搜索时加载它。该模块在 **22.13**(LTS)和 **23.4**(Current)取消了 `--experimental-sqlite` 标志要求;在此之前,导入它会在加载时抛出异常。 - **原生 TypeScript 类型剥离**——构建模式的 `apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts` 冒烟测试使用纯 `node`(无 tsx)启动 test-support 的 `.ts` driver,并加载 `.ts` 测试适配器(`cli-mock-llm.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;更早版本需要 `--experimental-strip-types`。 这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步提高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的包声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不相交范围完全排除了 Node 23:Node 23.0–23.5 至少还有一个源码特性需要标志,而 23 线是非 LTS/已 EOL 的,宣传 `>=23.6` 会增加一条已终止的发布线和一条 CI 分支,而没有任何部署应当使用它。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 24312c61ab..904e10dbd4 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: cf656ab2c82fb43b4172a07b6b68fa53a62ef8a3 -2026-08-08-native-windows-pull-request-ci.zh.md: 81ed390d27d7f7918c540a56a2d1fac1c094a447 +2026-08-08-native-windows-pull-request-ci.md: ff01add055b6daa3cd2ea1dd774c4c8973388113 +2026-08-08-native-windows-pull-request-ci.zh.md: 5378607687d03f1c98b4f6abc0b65b5d8e9ed2bb diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index cf656ab2c8..ff01add055 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,13 +18,13 @@ Every pull request also starts four independent native jobs on the organization- `windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts. -`windows-coverage` completes a workspace build before [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) starts four single-worker instrumented shards beside a two-worker exempt-heavy gate. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The SQLite busy-journal pacing fixture injects two busy results followed by success under the normal busy budget and observes each inter-attempt delay, keeping schema-setup scheduling outside its timing assertion. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +`windows-coverage` completes a workspace build before [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) starts four single-worker instrumented shards beside a two-worker exempt-heavy gate. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers after the build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. -Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. The randomized SQLite differential property retains all 100 seeded runs and uses a 120-second Windows budget because simultaneous native jobs can contend for the shared runner host; POSIX keeps the 60-second budget. +Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 81ed390d27..5378607687 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,13 +18,13 @@ Status: implemented `windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。 -`windows-coverage` 会先完成一次工作区构建,再由[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)启动 4 个单 worker 插桩分片,并与一个双 worker 的豁免重型门禁并行运行。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。SQLite busy-journal 节奏 fixture 会在普通 busy 预算内先注入两次 busy 结果,再返回成功,并观察每次尝试之间的延迟,使 schema 设置的调度时间不进入该断言。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +`windows-coverage` 会先完成一次工作区构建,再由[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)启动 4 个单 worker 插桩分片,并与一个双 worker 的豁免重型门禁并行运行。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在构建后调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 -可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。SQLite 随机差分属性测试保留全部 100 次固定 seed 运行,并采用 120 秒 Windows 预算,因为多个原生作业可能争用共享的运行器宿主;POSIX 仍采用 60 秒预算。 +可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml index beb7a7949d..9d7829716d 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md -2026-06-19-drop-mutable-session-summary.md: 91932c50e02fe5aa488599afd913c5c34706b67d -2026-06-19-drop-mutable-session-summary.zh.md: b8612b57bea1e1235452a128ea517848b9d55862 +2026-06-19-drop-mutable-session-summary.md: 73fc99b4d948da90b78d66e801915a2fb38e4749 +2026-06-19-drop-mutable-session-summary.zh.md: 97b1b098489cccfbbfdbd48439d640b554a26c37 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md index 91932c50e0..73fc99b4d9 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md @@ -18,15 +18,15 @@ The summary was designed for a future session picker (recency ordering via `upda ## Decision -Delete the mutable session summary entirely. `SessionSummary` and the `SessionMeta` name are removed; the metadata a backend stores and returns is just `SessionHeader`. `SessionPersistence.update()` is removed from the abstract service and every backend. JSONL loses the whole sidecar machinery (`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` and the load/list overlays); SQLite drops the `updated_at`/`title`/`first_prompt` columns and the per-append `updated_at` bump, and its `SCHEMA_VERSION` goes `1 → 2`. +Delete the mutable session summary entirely. `SessionSummary` and the `SessionMeta` name are absent; the metadata a backend stores and returns is just `SessionHeader`. `SessionPersistence.update()` is absent from the abstract service. The shipped JSONL provider has no summary sidecar machinery (`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` or load/list overlays), and an out-of-tree provider implements the same summary-free service contract. Anything the summary was meant to provide is **derivable from the append-only log** when a consumer actually needs it (`firstPrompt` = first `user/message`; recency = the last event's `time` or the file mtime) or already lives in the immutable header (`createdAt`, `cwd`). The one thing *not* derivable — a user-*edited* title — had no implementation and is pure YAGNI; it can return as its own log event or header field if a real feature ever needs it. -The removal narrows a public service contract and an on-disk format across two backends; the summary was a deliberate forward-looking design, not an accident; and `SessionHeader` now stands where the original Agent Note described `SessionMeta`, which is why the summary vanished. It also unblocks the [shared persistence write coordinator](../architecture/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator's hook interface needs no `updateSummary` hook and the JSONL-sidecar-vs-SQLite-column durability divergence disappears, so the two backends' write paths converge. +The removal narrows the public service contract and JSONL on-disk format; the summary was a deliberate forward-looking design, not an accident; and `SessionHeader` stands where the original Agent Note described `SessionMeta`, which is why the summary vanished. It also simplifies the [shared persistence write coordinator](../architecture/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator needs no `updateSummary` hook, and an out-of-tree provider can reuse the same summary-free orchestration. ## No migration -This is unreleased software (see [root AGENTS.md](../../../../AGENTS.md) § "Pre-release stance: foundation over blast radius"), so there are no on-disk databases or logs to preserve. SQLite does not migrate a v1 database: the `openDatabase` guard now rejects any non-current on-disk `user_version` (`onDisk !== 0 && onDisk !== SCHEMA_VERSION`) — older *or* newer — so a stale v1 DB is cleanly rejected rather than half-read against the new column set. A fresh database stamps the current version; that is the only path that needs to work. +The shipped JSONL provider has no mutable summary format or migration path: it reads and writes only `SessionHeader` plus the append-only log. The repository has no first-party SQLite Session provider. The [JSONL-only persistence decision](2026-08-30-jsonl-only-session-persistence.md) owns the compatibility cut for databases written by the removed provider and directs operators to export them with an older build before upgrading. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md index b8612b57be..97b1b09848 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -18,15 +18,15 @@ Status: implemented ## 决策 -彻底删除可变的会话摘要。`SessionSummary` 与 `SessionMeta` 这个名称一并移除;后端存储和返回的元数据仅为 `SessionHeader`。`SessionPersistence.update()` 从抽象服务和所有后端中移除。JSONL 去掉整套伴随文件机制(`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` 以及 load/list 的覆盖逻辑);SQLite 去掉 `updated_at`/`title`/`first_prompt` 列以及每次追加时的 `updated_at` 更新,其 `SCHEMA_VERSION` 从 `1 → 2`。 +彻底删除可变的会话摘要。`SessionSummary` 与 `SessionMeta` 这个名称均不存在;后端存储和返回的元数据仅为 `SessionHeader`。抽象服务不包含 `SessionPersistence.update()`。交付的 JSONL provider 不包含摘要伴随文件机制(`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` 或 load/list 覆盖逻辑),仓库外 provider 实现相同的无摘要服务约定。 摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一*不可*派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 -这次移除同时收窄两个后端的公开服务约定和磁盘格式;摘要是有意为未来设计的结果,而非意外;如今原 Agent Note 描述 `SessionMeta` 之处已是 `SessionHeader`,这就是摘要消失的原因。它还为[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)扫清障碍:不再有可变摘要后,协调器的钩子接口不需要 `updateSummary` 钩子,JSONL 伴随文件与 SQLite 列之间的持久性分歧也随之消失,使两个后端的写入路径趋于一致。 +这次移除收窄公开服务约定与 JSONL 磁盘格式;摘要是有意为未来设计的结果,而非意外;原 Agent Note 描述 `SessionMeta` 之处由 `SessionHeader` 承担,这就是摘要消失的原因。它还简化了[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.zh.md):没有可变摘要后,协调器不需要 `updateSummary` 钩子,仓库外 provider 可复用相同的无摘要编排。 ## 无需迁移 -这是未发布的软件(见[根 AGENTS.md](../../../../AGENTS.md)「Pre-release stance: foundation over blast radius」一节),因此没有需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:`openDatabase` 守卫现在拒绝任何非当前版本的磁盘 `user_version`(`onDisk !== 0 && onDisk !== SCHEMA_VERSION`),无论版本更旧*还是*更高,因此陈旧的 v1 数据库会被干净地拒绝,而不会按新的列集合进行不完整读取。新建数据库写入当前版本号;这是唯一需要正常工作的路径。 +交付的 JSONL provider 不存在可变摘要格式或迁移路径:它只读写 `SessionHeader` 与仅追加日志。仓库不包含 first-party SQLite Session provider。[JSONL-only 持久化决策](2026-08-30-jsonl-only-session-persistence.zh.md)负责删除 provider 写入的数据库的兼容性切断,并要求 operator 在升级前先用旧 build 导出数据。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml index 68511a8efe..2eb3e56677 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md -2026-07-12-simplify-session-log-representation.md: a0c86b66af78b4c94991d38656f03609297e2314 -2026-07-12-simplify-session-log-representation.zh.md: ceaf90236a47c141e3a4bb2cc78d415b3b9ac2ba +2026-07-12-simplify-session-log-representation.md: 749d0da1774d74586988cbac39e32dd783b79af4 +2026-07-12-simplify-session-log-representation.zh.md: 5f320ab7d768aa682f54c3d42f67aef50c60ad7a diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md index a0c86b66af..749d0da177 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md @@ -20,7 +20,7 @@ The implementation retains append and replacement `sourceEventSeqs`, the `tool/c Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a full snapshot with reason `series`. Ordinary append-only later Turns, further Steps, and retries in that model-message series inherit the latest snapshot. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot. -`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed, append, and persistence-load validation explicitly reject old v0 `request/header-delta` events and full snapshots carrying the removed `fallback` reason. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts. +`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed, append, and persistence-load validation explicitly reject old v0 `request/header-delta` events and full snapshots carrying the removed `fallback` reason. There is no compatibility fold or migration. JSONL tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts. ## Alternatives considered @@ -28,7 +28,7 @@ Request headers use canonical full snapshots only. Initial and resume anchors re ## Verification -Unit coverage pins ordered-surface append/replace behavior, tool pairing, compaction, full-header folding/logging, request reconstruction, and dev invariants. Seed validation plus JSONL and SQLite load tests reject the legacy event before replay. The keyless ACP suite exercises record, refresh, replay, changed-header pinning, and the sandbox mode-switch fixture in the new shape. +Unit coverage pins ordered-surface append/replace behavior, tool pairing, compaction, full-header folding/logging, request reconstruction, and dev invariants. Seed validation plus JSONL load tests reject the legacy event before replay. The keyless ACP suite exercises record, refresh, replay, changed-header pinning, and the sandbox mode-switch fixture in the new shape. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md index ceaf90236a..5f320ab7d7 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md @@ -20,7 +20,7 @@ Status: implemented 请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`,reason 为 `change`;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `series` 的完整快照。普通的仅追加后续 Turn、同一模型消息序列内的后续 Step 与重试沿用最新快照。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。 -`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一失败即报错的边界;ACP(Agent Client Protocol)快照 harness 则把合法的会话中途变更表示为固定的完整请求头和完整可读提示词。 +`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 测试固定了这一失败即报错的边界;ACP(Agent Client Protocol)快照 harness 则把合法的会话中途变更表示为固定的完整请求头和完整可读提示词。 ## 曾考虑的替代方案 @@ -28,7 +28,7 @@ Status: implemented ## 验证 -单元测试覆盖并锁定有序 surface 的追加/替换行为、工具配对、压缩、完整请求头 fold/记录、请求重建和开发不变量。Seed 验证以及 JSONL、SQLite 加载测试会在回放前拒绝旧事件。无密钥 ACP 套件按新的表示覆盖记录、刷新、回放、变更后请求头的固定,以及沙箱模式切换 fixture(测试前置数据)。 +单元测试覆盖并锁定有序 surface 的追加/替换行为、工具配对、压缩、完整请求头 fold/记录、请求重建和开发不变量。Seed 验证以及 JSONL 加载测试会在回放前拒绝旧事件。无密钥 ACP 套件按新的表示覆盖记录、刷新、回放、变更后请求头的固定,以及沙箱模式切换 fixture(测试前置数据)。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml index a00a3fb631..8dfb4281ed 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md -2026-07-23-collapse-persistence-flush-state.md: 2801079e5b322d076eced3a0113e7958d9b4c3b9 -2026-07-23-collapse-persistence-flush-state.zh.md: eb292ac1d357f3fa96715e99d5e2ecfc963938c6 +2026-07-23-collapse-persistence-flush-state.md: dc26f760a9d74ed2a6f6ee13193701dd3357fea0 +2026-07-23-collapse-persistence-flush-state.zh.md: a26868fb0f3daaac20cc4585e30043d3177a1482 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md index 2801079e5b..dc26f760a9 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md @@ -35,7 +35,7 @@ The live-controller map is also the retirement registry. Successful retirement d ## Verification - Focused controller tests use a fake clock to prove the non-resetting fixed window, gate the first append, admit another event during that write, and observe an automatic second durable batch without calling `session/flush`. -- The shared coordinator contract still covers live adoption, collisions, crash repair, and session/backend disposal over the in-memory, JSONL, and SQLite backends. +- The shared coordinator contract still covers live adoption, collisions, crash repair, and Session/provider disposal over the in-memory reference and JSONL provider. - Failure and teardown tests keep rejected batches pending, retry them before close, and prove an in-flight controller delays backend close. - The shared backend contract persists an open live turn, proves `load` rejects without writing synthetic closers, completes and retires the owner, then reloads the exact completed turn. - An AgentLoop regression races `resume()` against a live open turn and proves the original agent can still durably complete it without an injected `interrupted` boundary. diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md index eb292ac1d3..a26868fb0f 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md @@ -35,7 +35,7 @@ Status: implemented ## 验证 - 针对控制器的测试使用假时钟证明固定窗口不会重置,随后阻塞第一次追加,在该次写入期间接纳另一个事件,并在不调用 `session/flush` 的情况下观测到自动执行的第二个持久批次。 -- 共享协调器约定仍覆盖内存、JSONL 和 SQLite 后端上的活跃会话接管、冲突、崩溃修复,以及会话和后端的资源释放。 +- 共享协调器约定仍通过内存参考实现与 JSONL provider 覆盖活跃会话接管、冲突、崩溃修复,以及 Session 和 provider 的资源释放。 - 失败和资源销毁测试会让写入失败的批次保持待处理,在关闭前重试这些批次,并证明尚在执行的控制器会延迟后端关闭。 - 共享后端约定会持久化一个仍打开的活跃轮次,证明 `load` 会拒绝且不会写入合成闭合事件,随后完成该轮次并让其所有者退役,最后重新加载完全相同的已完成轮次。 - AgentLoop 回归测试让 `resume()` 与一个仍打开的活跃轮次发生竞态,并证明原有的 agent(智能体)仍能完成该轮次并将其持久化,其间不会注入 `interrupted` 边界。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index 12184063d7..b201d8a88e 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md -2026-07-28-remove-synthetic-log-only-turns.md: 6fe23b0b34c49cc79d912f86e9ffe548b8e08d19 -2026-07-28-remove-synthetic-log-only-turns.zh.md: ccdda3f7606bc160dbb4d896348ed49bd046c6a7 +2026-07-28-remove-synthetic-log-only-turns.md: e5138ad22a68d2c7dd6d201df5522536e1d26414 +2026-07-28-remove-synthetic-log-only-turns.zh.md: 2fb15ff837b4984a069a2a5306f167fe72177f6b diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md index 6fe23b0b34..e5138ad22a 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -36,7 +36,7 @@ The historical [universal turn-enclosure decision](../../archived/architecture/2 ## Verification -Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, PTC mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. +Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, PTC mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. A JSONL round trip preserves a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md index ccdda3f760..2fb15ff837 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 验证 -核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、PTC mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 +核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、PTC mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.i18n.yaml new file mode 100644 index 0000000000..e697040ad9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.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/simplification/2026-08-30-jsonl-only-session-persistence.md +2026-08-30-jsonl-only-session-persistence.md: f21fb89e3747dffd043d42ace2c05bbe521f3069 +2026-08-30-jsonl-only-session-persistence.zh.md: 4100e576ccdcd46443e12a22cfec6dd3d3495317 diff --git a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md new file mode 100644 index 0000000000..f21fb89e37 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md @@ -0,0 +1,31 @@ +# Agent Note: JSONL-only first-party Session persistence + +Status: implemented + +English | [中文](2026-08-30-jsonl-only-session-persistence.zh.md) + +## Problem + +The product ships and exercises JSONL as its authoritative Session store, while the optional SQLite Session-persistence provider duplicates the same logical service over a second physical format. Every Session contract, event-envelope change, recovery rule, package graph, platform lane, and format transition therefore carries a second implementation and test matrix even though shipped profiles do not select it. Released Session-format migration also needs an exact per-Session source artifact that can be archived before replacement; the single-database provider would require a separate publication design without serving a current deployment. + +The SQLite full-text Session-query provider is not an alternative authoritative store. It observes persistence through `ctx.sessionPersistence` and maintains a separate disposable derived index. The generic SQLite domain-KV provider is also independent of Session logs. + +## Decision + +`@deepseek-ai/dsh-session-persistence-jsonl` is the sole first-party implementation of `ctx.sessionPersistence`. The abstract Service Definition and `PersistenceCoordinator` remain backend-neutral so an out-of-tree provider can implement the same service, but the repository owns and tests one authoritative physical Session format. + +The `@deepseek-ai/dsh-session-persistence-sqlite` package, its schema resources, backend-specific tests, configuration surface, and Windows differential lane are absent. Cross-package persistence tests use the real JSONL provider or an owner-local fake. `@deepseek-ai/dsh-session-query-sqlite` remains the optional FTS5 query provider over a separate rebuildable database, and `@deepseek-ai/dsh-storage-sqlite` remains the generic domain-KV provider. + +Existing databases written by the removed provider are not opened or migrated by the current build. An operator who needs their contents must use a build that still contains that provider and export the logical Session before upgrading. + +## Alternatives considered + +- **Keep SQLite as an opt-in differential backend.** Rejected because an unselected production provider still multiplies every durable-format, lifecycle, platform, and migration obligation; contract fakes and the JSONL provider cover the shared service without retaining a second authoritative format. +- **Keep a read-only SQLite import package.** Rejected because it would preserve the package graph and schema maintenance without a demonstrated deployment need. A recovery tool can be designed later if real retained databases require one. +- **Use the Session-query SQLite database as persistence.** Rejected because that database is a disposable projection with independent ownership, schema, and rebuild semantics; treating it as authority would merge two unrelated storage roles. + +## Consequences + +Session persistence has one first-party physical format and one first-party durability path. The migration stack can archive and atomically replace one per-Session JSONL artifact without implementing a parallel database transaction protocol. SQLite search remains available and its integration tests now prove that it observes JSONL rather than sharing an authoritative database. + +Removing the provider is a deliberate compatibility cut for its opt-in database files. The change reduces implementation and CI surface but also removes the stronger database/WAL storage option; a future provider needs a current owner, deployment need, complete shared-contract evidence, and its own format-transition policy. diff --git a/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md new file mode 100644 index 0000000000..4100e576cc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md @@ -0,0 +1,31 @@ +# Agent Note: JSONL-only first-party Session persistence + +Status: implemented + +[English](2026-08-30-jsonl-only-session-persistence.md) | 中文 + +## Problem + +产品交付并实际使用 JSONL 作为权威 Session store,而可选的 SQLite Session persistence provider 用第二种物理格式重复实现同一逻辑服务。因此,每项 Session 约定、event envelope 变更、恢复规则、package graph、平台测试与格式迁移都要承担第二套实现和测试矩阵,即使交付 profile 并不选择它。已发布 Session 格式的迁移还需要一份可在替换前归档的精确逐 Session 源产物;单数据库 provider 需要另一套发布设计,却没有服务当前部署。 + +SQLite 全文 Session-query provider 不是另一种权威 store。它通过 `ctx.sessionPersistence` 观察持久化,并维护独立、可丢弃的派生索引。通用 SQLite domain-KV provider 也与 Session 日志无关。 + +## Decision + +`@deepseek-ai/dsh-session-persistence-jsonl` 是 `ctx.sessionPersistence` 唯一的 first-party 实现。抽象 Service Definition 与 `PersistenceCoordinator` 保持后端无关,使仓库外 provider 仍可实现同一服务,但仓库只拥有并测试一种权威 Session 物理格式。 + +仓库不再包含 `@deepseek-ai/dsh-session-persistence-sqlite` package、其 schema resource、后端专用测试、配置接口与 Windows differential lane。跨 package 持久化测试使用真实 JSONL provider 或 owner-local fake。`@deepseek-ai/dsh-session-query-sqlite` 继续作为可选 FTS5 query provider 使用独立、可重建的数据库,`@deepseek-ai/dsh-storage-sqlite` 继续作为通用 domain-KV provider。 + +当前 build 不打开或迁移已删除 provider 写出的现有数据库。需要其中内容的 operator 必须先使用仍包含该 provider 的 build 导出逻辑 Session,再执行升级。 + +## Alternatives considered + +- **保留 SQLite 作为可选 differential backend。** 拒绝,因为未被选择的生产 provider 仍会成倍增加每项 durable format、lifecycle、平台与迁移义务;contract fake 与 JSONL provider 已能覆盖共享服务,无需保留第二种权威格式。 +- **保留只读 SQLite import package。** 拒绝,因为在没有实际部署需要时,它仍会保留 package graph 与 schema 维护成本。若真实保留数据库需要恢复,未来可单独设计 recovery tool。 +- **把 Session-query SQLite 数据库作为 persistence。** 拒绝,因为该数据库是拥有独立 ownership、schema 与重建语义的可丢弃 projection;把它当作权威来源会合并两种无关的存储职责。 + +## Consequences + +Session persistence 只有一种 first-party 物理格式和一条 first-party durability path。迁移 stack 可以归档并原子替换逐 Session JSONL 产物,而无需实现并行的数据库 transaction protocol。SQLite search 保持可用,其 integration test 现在证明它观察 JSONL,而不是共享权威数据库。 + +删除 provider 是针对其可选数据库文件的明确 compatibility cut。该变更缩小实现与 CI surface,但也移除更强的 database/WAL 存储选项;未来 provider 需要当前 owner、部署需求、完整 shared-contract evidence,以及自身的 format-transition policy。 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index d923a2b4fe..26fd9e4836 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.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/testing/2026-06-22-fork-child-replay-seed-boundary.md -2026-06-22-fork-child-replay-seed-boundary.md: 0964f31de7038484f19cab2431bcd8e9f6113a37 -2026-06-22-fork-child-replay-seed-boundary.zh.md: 5caa5a8a66bc5630705319b180863b353c0e8249 +2026-06-22-fork-child-replay-seed-boundary.md: cf4a974035b38ee61e4c2d1cab34776b2ad186c8 +2026-06-22-fork-child-replay-seed-boundary.zh.md: bcc60a583a5f9a4d1fd10ec16e90c49c879d295c diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md index 0964f31de7..cf4a974035 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -24,12 +24,9 @@ Record where a session's **inherited** prefix ends, persist it, and have the rep `seedLength` is **explicit**, never inferred from `seed.length`. A reconstruction (resume/load) seeds the session with its WHOLE stored log, so `seed.length` there is the full length, not the original boundary — the resume path passes the persisted `seedLength` back from the loaded header instead. (Same shape as `createdAt`, which is also explicitly preserved on reconstruction rather than re-defaulted to now.) -### 2. Both persistence backends round-trip it +### 2. JSONL round-trips it -- **JSONL**: a `seedLength` field on the header line (`toHeaderLine`/`fromHeaderLine`). -- **SQLite**: a `seed_length` column on the `sessions` table. - -The SQLite layout containing `seed_length`, `source_event_seqs`, and `surface_op` is schema version 4. Earlier version 3 layouts were ambiguous, so every non-current `user_version` is rejected without migration under the pre-release policy. +JSONL stores `seedLength` on the header line (`toHeaderLine`/`fromHeaderLine`) and returns it through the shared persistence contract. ### 3. Replay derives a child script after the boundary @@ -40,10 +37,8 @@ This closes the routing correctness gap, and two recorded fork scenarios exercis ## Alternatives considered - **Derive the boundary heuristically in `llm-replay`** (the seeded prefix is contiguous parent events ending at the last `turn/end` before the child's first `user/message`). Rejected: a brittle heuristic in the test harness that re-derives a fact the producer already knows. Persisting the boundary at its source (the fork backend) is the "explicit > implicit at package boundaries" rule applied across the persistence boundary — the reader of a child fixture never has to reconstruct where the inheritance ended. -- **Pin the format version instead of bumping** (the `SESSION_FORMAT_VERSION = 0` "unstable" stance the event log uses). Rejected for the SQLite *table* layout: `SCHEMA_VERSION` is the monotonic bump-and-reject knob (a small enumerable set of revisions worth telling apart), distinct from the event-vocabulary `version`. Adding a column is precisely the breaking table change it versions, so it bumps. ## Consequences -- A new persisted header field across core + both backends; the subsystems catalog (`persistence.md`) is updated in the same change (its `SessionHeader` / `CreateSessionOptions` `type-equiv` blocks). -- Existing SQLite databases at schema v2 are rejected on open (no user data pre-release). -- Spawn replay is unchanged (`seedLength` 0). Fork replay now routes a child to its own script; covered by a regression in `llm-replay`'s tests (a child fixture whose seeded prefix carries a parent chunk — the derived child script must exclude it, proven red without the slice) and a persistence round-trip test (both backends, via the shared coordinator contract). +- A new persisted header field spans core and the JSONL provider; the subsystems catalog (`persistence.md`) is updated in the same change (its `SessionHeader` / `CreateSessionOptions` `type-equiv` blocks). +- Spawn replay is unchanged (`seedLength` 0). Fork replay now routes a child to its own script; covered by a regression in `llm-replay`'s tests (a child fixture whose seeded prefix carries a parent chunk — the derived child script must exclude it, proven red without the slice) and a JSONL persistence round trip through the shared coordinator contract. diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index 5caa5a8a66..bcc60a583a 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -24,12 +24,9 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm- `seedLength` 是**显式**的,绝不从 `seed.length` 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——恢复路径改为从加载的 header 中取回持久化的 `seedLength`。(做法与 `createdAt` 相同:恢复时显式保留,而非重新默认为当前时间。) -### 2. 两个持久化后端均完整往返 +### 2. JSONL 完整往返 -- **JSONL**:header 行上的 `seedLength` 字段(`toHeaderLine`/`fromHeaderLine`)。 -- **SQLite**:`sessions` 表上的 `seed_length` 列。 - -包含 `seed_length`、`source_event_seqs` 和 `surface_op` 的 SQLite 布局为 schema version 4。更早的 version 3 布局存在歧义,因此在预发布策略下,所有非当前 `user_version` 均直接拒绝,不做迁移。 +JSONL 把 `seedLength` 存在 header 行(`toHeaderLine`/`fromHeaderLine`),并通过共享持久化约定返回它。 ### 3. 回放从边界之后推导子会话脚本 @@ -40,10 +37,8 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm- ## 曾考虑的替代方案 - **在 `llm-replay` 中启发式推导边界**(播种前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头(fork 后端)持久化边界,是「在包边界处显式优于隐式」这条规则跨越持久化边界的应用——子会话 fixture(测试前置数据)的读取者永远不需要重建继承在哪里结束。 -- **固定格式版本而不递增**(事件日志使用的 `SESSION_FORMAT_VERSION = 0`「不稳定」姿态)。对 SQLite *表*布局否决:`SCHEMA_VERSION` 是单调递增并拒绝旧版的旋钮(数量不多、可枚举且值得区分的一组修订),与事件词汇的 `version` 不同。新增列正是它所版本化的那种破坏性表变更,因此需要递增。 ## 后果 -- core 与两个后端新增一个持久化 header 字段;子系统目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions` 的 `type-equiv` 块)。 -- 既有的 schema v2 SQLite 数据库在打开时被拒绝(预发布阶段无用户数据)。 -- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它,不做 slice 时该用例会失败)以及一个持久化往返测试(两个后端,通过共享的 coordinator 约定)。 +- core 与 JSONL provider 新增一个持久化 header 字段;子系统目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions` 的 `type-equiv` 块)。 +- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它,不做 slice 时该用例会失败),以及通过共享 coordinator 约定执行的 JSONL 持久化往返测试。 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml index e736433875..f74492d273 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md -2026-06-16-typed-event-schemas.md: 5082608f2ef4eb1d03d83e93de2814147a3a0b26 -2026-06-16-typed-event-schemas.zh.md: 1a25827b9126c973c193312e7bbd6fd7b4388600 +2026-06-16-typed-event-schemas.md: 3a9788e903c47c2d7cab07ba0f8d2ae9037ca519 +2026-06-16-typed-event-schemas.zh.md: 406d97c934864ec24c5370e27c30b2845c991866 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md index 5082608f2e..3a9788e903 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -10,7 +10,7 @@ The harness models its core vocabulary — content blocks, message sources, fini The pattern is **compile-time only**. The types vanish at runtime: there is no schema object to validate an incoming value against, parse untrusted input with, or enumerate at runtime. The [session-persistence contract](../../implemented/architecture/2026-06-14-session-persistence.md) exposes two consequences: -1. **Persistence treats `event.data` as opaque JSON.** The JSONL/SQLite backends `JSON.stringify`/`JSON.parse` each event verbatim; the only runtime guard is `isJsonValue` (round-trip serializability — rejects BigInt, functions, cycles, non-finite numbers, …), NOT structural validation. A corrupted-but-still-JSON event datum (wrong field types, missing fields) round-trips silently and is only caught later, if at all, by a consumer's `switch`. +1. **Persistence treats `event.data` as opaque JSON.** The JSONL provider `JSON.stringify`s and `JSON.parse`s each event verbatim; the only runtime guard is `isJsonValue` (round-trip serializability — rejects BigInt, functions, cycles, non-finite numbers, …), not structural validation. A corrupted-but-still-JSON event datum (wrong field types, missing fields) round-trips silently and is only caught later, if at all, by a consumer's `switch`. 2. **No runtime contract for plugin-added variants.** A plugin that declaration-merges a new `SessionEventMap` key gets compile-time typing for its own code, but nothing validates that the values it produces match the shape it declared — at the producer, at the persistence boundary, or on reload. This raises whether the event vocabulary should move to **Zod** or another runtime-schema library so durable and plugin boundaries have runtime schemas rather than erased types. diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md index 1a25827b91..406d97c934 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -10,7 +10,7 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 该模式**仅存在于编译期**。类型在运行时消失:没有 schema 对象可供校验传入值、解析不可信输入或在运行时枚举变体。[会话持久化约定](../../implemented/architecture/2026-06-14-session-persistence.zh.md)暴露了两个后果: -1. **持久化将 `event.data` 视为不透明 JSON。** JSONL/SQLite 后端对每个事件原样执行 `JSON.stringify`/`JSON.parse`;唯一的运行时守卫是 `isJsonValue`(往返可序列化性检查:拒绝 BigInt、函数、循环引用、非有限数等),而非结构校验。一个损坏但仍为合法 JSON 的事件数据(字段类型错误、字段缺失)会静默往返,只有在后续消费方的 `switch` 中才可能被捕获。 +1. **持久化将 `event.data` 视为不透明 JSON。** JSONL provider 对每个事件原样执行 `JSON.stringify`/`JSON.parse`;唯一的运行时守卫是 `isJsonValue`(往返可序列化性检查:拒绝 BigInt、函数、循环引用、非有限数等),而非结构校验。一个损坏但仍为合法 JSON 的事件数据(字段类型错误、字段缺失)会静默往返,只有在后续消费方的 `switch` 中才可能被捕获。 2. **插件新增变体没有运行时约定。** 一个通过声明合并添加新 `SessionEventMap` 键的插件,在自身代码中获得了编译期类型,但没有任何机制校验它产出的值是否符合它所声明的形状——无论是在生产者处、持久化边界处还是重新加载时。 由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化边界和插件边界拥有运行时 schema 而非被擦除的类型。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml index 97013fe8df..7d7f43420a 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md -2026-07-24-domain-kv-storage-and-workspace.md: 68e26e6fb55c36c08e1c4d45ed699b05459f1ea6 -2026-07-24-domain-kv-storage-and-workspace.zh.md: 0e837b1fdba3dc7e9764d7a690508a74980f5078 +2026-07-24-domain-kv-storage-and-workspace.md: dc14648c703f60b391cf4dba947c3cb0ddeb8ccb +2026-07-24-domain-kv-storage-and-workspace.zh.md: 1cf72809193b40ab540deaa171b8df1b0d39defb diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md index 68e26e6fb5..dc14648c70 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md @@ -75,7 +75,7 @@ Config is `root` only (required, no default, schemastery); apply registers backe Config is `path` (required, `':memory:'` allowed) plus `journalMode` (enum, default `wal`); apply mirrors json, registering backend `sqlite`. -- `node:sqlite` `DatabaseSync`; the open sequence follows session-persistence-sqlite: mkdir 0o700 → `open(path,'wx',0o600)` exclusive create when missing → `PRAGMA foreign_keys=ON` → journal_mode → version check → create tables. +- `node:sqlite` `DatabaseSync`; the open sequence is mkdir 0o700 → `open(path,'wx',0o600)` exclusive create when missing → `PRAGMA foreign_keys=ON` → journal_mode → version check → create tables. - Physical layout version `STORAGE_SQLITE_SCHEMA_VERSION = 1` in `PRAGMA user_version`: 0 → stamp; ≠ → `version-mismatch`. - DDL (all STRICT; table names concatenated from the restricted character set with the `u_` prefix, no external input ever reaches DDL): @@ -178,7 +178,7 @@ export abstract class SessionPersistence extends Service { ``` - JSONL backend: unlink the session's file (including the `.zstd` variant); neither file nor intent → reject. -- SQLite backend: one transaction `DELETE FROM events…; DELETE FROM sessions…`; zero rows hit and no intent → reject. +- An out-of-tree backend deletes atomically in its own medium and preserves the same unknown-id and canceled-intent results; this proposal defines no other first-party physical path. - After a successful delete, emit `'session-persistence/deleted'(id: SessionId)` (`@mode emit`; the session-persistence event surface, unrelated to `domain/changed`). Derived data (the session-query full-text index and the like) subscribes and cleans itself; the persistence layer never reaches into indexes, and the crash window is covered by derived indexes being droppable-and-rebuildable. Orchestration rules (implemented together with the cascade; the `session.delete` RPC and the workspace cascade reuse the same rules): @@ -256,7 +256,7 @@ Consistency doctrine (the ledger = the only ownership authority; the implementat ### Reuse and the session-backend migration outlook -**Long-term direction**: the pure medium operations inside session-persistence's JSONL/SQLite backends sink into `dsh-storage` backends (the session packages stay; the `SessionPersistence` seam and coordinator semantics do not move — only the file/db operation layer beneath them does). The motive for reuse: the medium layer is all filesystem operations, database calls, and cross-platform grit (Windows permission and atomic-publish variants, fsync semantics, exclusive file creation…), which should be written once; business semantics (how a session appends, when, and what) stay above — while "did this append complete correctly underneath" (durability/atomicity/platform correctness) is the lower layer's responsibility, and the responsibility boundary is the facet primitive contract. The backend interface is therefore designed as **medium owner + data-shape facets**: a session log is an append-only stream, a different shape from KV — forcing them into one set of primitives would deform both, so facets split them (`kv` this phase, `log` at migration) while sharing the medium and its lifecycle. +**Long-term direction**: the pure medium operations inside the Session-persistence JSONL provider may sink into a `dsh-storage` log facet (the Session packages stay; the `SessionPersistence` seam and coordinator semantics do not move — only the file operation layer beneath them does). The motive for reuse: the medium layer owns filesystem operations and cross-platform work such as Windows atomic publication, fsync semantics, and exclusive file creation; business semantics (how a Session appends, when, and what) stay above. A Session log is an append-only stream, a different form from KV, so the interface keeps **medium owner + data-form facets** rather than forcing both through one primitive set. The current reuse audit (an account already legible before the migration): @@ -264,12 +264,10 @@ The current reuse audit (an account already legible before the migration): | --- | --- | --- | | JSONL: temp write + fsync + link/unlink atomic publish, 0o700/0o600 permissions, Windows variant (win32.ts) | pure medium | copied by `dsh-storage-json` this phase (whole-file atomic rewrite is the same protocol); becomes the shared implementation at migration | | JSONL: line-append, first-line header fast read, zstd per-frame compression | log shape | stays put; moves into the `log` facet at migration | -| SQLite: openDatabase (mkdir/exclusive create/PRAGMA sequence/user_version check) | pure medium | copied by `dsh-storage-sqlite` this phase — the two openDatabase copies are already near line-identical and this group is the third user; copy now, extract at migration | -| SQLite: events/sessions schema, same-transaction materialization | log shape | stays put; moves into the `log` facet at migration | | coordinator (per-id write chain, lazy materialization, crash repair, flush barrier) | session semantics | never sinks — event-log domain logic whose counterpart here is the domain layer's write chain; each owns its own | | encodeSegment (id-to-path escaping) | medium utility | unused on the domain side (keys never reach paths); sinks together with the `log` facet (one file per session) at migration | -**This phase does not touch session-persistence's medium code** (only the delete primitive is added); the table above is the migration-phase work list and the design evidence that the backend interface must accommodate the log shape. +**This phase does not touch Session-persistence medium code** (only the delete primitive is added). A future log-facet change needs its own consumer and evidence; the table records the remaining JSONL reuse boundary without promising that extraction. ### Test matrix @@ -279,7 +277,7 @@ The current reuse audit (an account already legible before the migration): | registry/mount | duplicate registration, unmounted access, disposer removal | — | | domain layer | the six open steps, schema rejection, update serialization (concurrent interleaving stress), `domain/changed` per record, global initial-value lazy materialization, routing and `facet-unsupported` | either (json) | | workspace | create/uniqueness/realpath, attach checks (including rejection when sessionPersistence is absent), the four consistency-doctrine cases | mock domain or json | -| session delete contract (future work, joins runPersistenceContract at implementation) | unknown id, deleted-id reuse, un-materialized intent, serialization with in-flight appends, the deleted event | jsonl, sqlite | +| session delete contract (future work, joins runPersistenceContract at implementation) | unknown id, deleted-id reuse, un-materialized intent, serialization with in-flight appends, the deleted event | jsonl | Snapshots: no model-visible or assembly surface this phase, none added; next phase's RPC wiring brings them with the `workspace.*` domain. @@ -288,7 +286,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Not doing | Trigger | Rework point | Groundwork | | --- | --- | --- | --- | | Session deletion (`SessionPersistence.delete`, the deleted event, recursive delete, running checks) | a destructive Session-delete product flow starts | implement the session primitive plus `session.delete`; keep it independent from Workspace registration deletion | orchestration rules and rejection table above remain groundwork; Workspace deletion preserves Sessions and logs | -| The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already | +| The `log` facet and Session-provider migration | any phase after this one | sink JSONL medium operations when a real consumer justifies the facet | the facet organization leaves the option open without committing to extraction | | Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only | | Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process | | Data migration | model changes after the first tagged release | version-driven per-domain migration | versions are on the medium from day one | diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md index 0e837b1fdb..1cf7280919 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -75,7 +75,7 @@ Config 仅 `root`(必填无默认,schemastery);apply 在 `ctx.effect()` Config 为 `path`(必填,`':memory:'` 允许)+ `journalMode`(枚举,默认 `wal`);apply 同 json,注册后端 `sqlite`。 -- `node:sqlite` `DatabaseSync`;打开序列照抄 session-persistence-sqlite:mkdir 0o700 → 不存在则 `open(path,'wx',0o600)` 独占建文件 → `PRAGMA foreign_keys=ON` → journal_mode → 版本检查 → 建表。 +- `node:sqlite` `DatabaseSync`;打开序列为 mkdir 0o700 → 不存在则 `open(path,'wx',0o600)` 独占建文件 → `PRAGMA foreign_keys=ON` → journal_mode → 版本检查 → 建表。 - 物理布局版本 `STORAGE_SQLITE_SCHEMA_VERSION = 1` 存 `PRAGMA user_version`:0 → 盖章;≠ → `version-mismatch`。 - DDL(全 STRICT;表名由受限字符集拼接加 `u_` 前缀,杜绝外部输入进 DDL): @@ -178,7 +178,7 @@ export abstract class SessionPersistence extends Service { ``` - JSONL 后端:unlink 该 session 文件(含 `.zstd` 变体);文件与 intent 均无 → reject。 -- SQLite 后端:单事务 `DELETE FROM events…; DELETE FROM sessions…`;0 行命中且无 intent → reject。 +- 仓库外后端在自己的介质中原子删除,并保留相同的未知 id 与已取消 intent 结果;本提案不定义其他 first-party 物理路径。 - 删除成功后 emit `'session-persistence/deleted'(id: SessionId)`(`@mode emit`;session-persistence 层事件面,与 `domain/changed` 无关)。派生数据(session-query 全文索引等)订阅自清;持久层不直连索引,崩溃窗口靠派生索引可丢弃重建兜底。 编排层规则(随级联删一起实施;`session.delete` RPC 与 workspace 级联复用同一规则): @@ -256,7 +256,7 @@ export class WorkspaceRegistry extends Service { ### 复用与 session 后端迁移展望 -**长期方向**:session-persistence 的 JSONL/SQLite 后端里"纯介质操作"下沉到 `dsh-storage` 后端(session 包不删,`SessionPersistence` seam 与 coordinator 语义不动;动的只是它们脚下的文件/db 操作层)。复用的动机:介质层全是文件系统操作、数据库调用与跨平台兼容的脏活(Windows 权限与原子发布变体、fsync 语义、独占建文件……),这些只应写一遍;业务语义(session 怎么 append、何时 append、append 什么)留在上层——而"底下这次 append 是否正常完成"(持久性/原子性/平台正确性)是底层的责任,责任界面就是 facet 原语的约定。为此后端接口按**介质 owner + 数据形状 facet** 设计:session 日志是仅追加流,与 KV 形状不同——强行统一进 KV 原语会两头变形,所以按 facet 分开(`kv` 本期、`log` 迁移期),介质与生命周期共享。 +**长期方向**:Session-persistence JSONL provider 的纯介质操作可以下沉到 `dsh-storage` log facet(Session package 保留,`SessionPersistence` seam 与 coordinator 语义不动;只移动下层文件操作)。复用动机是让介质层拥有 Windows 原子发布、fsync 语义与独占建文件等文件系统和跨平台工作,业务语义(Session 如何 append、何时 append、append 什么)留在上层。Session 日志是仅追加流,与 KV 形式不同,因此接口保留**介质 owner + 数据形式 facet**,而不强迫二者共用一套原语。 现状复用审计(迁移前就能看清的账): @@ -264,12 +264,10 @@ export class WorkspaceRegistry extends Service { | --- | --- | --- | | JSONL:temp 写 + fsync + link/unlink 原子发布、0o700/0o600 权限、Windows 变体(win32.ts) | 纯介质 | 本期 `dsh-storage-json` 直接抄用(整文件原子覆写正是同一套);迁移期成为共享实现 | | JSONL:逐行 append、首行 header 快读、zstd 逐帧压缩 | log 形状 | 留在原地;迁移期进 `log` facet | -| SQLite:openDatabase(mkdir/独占建文件/PRAGMA 序列/user_version 检查) | 纯介质 | 本期 `dsh-storage-sqlite` 抄用——两处 openDatabase 已几乎逐行同构,本组是第三个使用者;先抄后提,提取放迁移期 | -| SQLite:events/sessions 表结构、同事务物化 | log 形状 | 留在原地;迁移期进 `log` facet | | coordinator(per-id 写链、懒物化、崩溃修复、flush 屏障) | session 语义 | 永不下沉——事件日志的领域逻辑,在 domain 层对应的是写串行链,各归各 | | encodeSegment(id 进路径转义) | 介质工具 | domain 侧 key 不进路径用不到;`log` facet(一 session 一文件)迁移时随之下沉 | -**本期不改 session-persistence 的介质代码**(只加 delete 原语);上表是迁移期的施工清单,也是后端接口"必须装得下 log 形状"的设计依据。 +**本期不改 Session-persistence 的介质代码**(只加 delete 原语)。未来 log-facet 变更需要自己的 consumer 与证据;上表记录剩余 JSONL 复用边界,但不承诺一定提取。 ### 测试矩阵 @@ -279,7 +277,7 @@ export class WorkspaceRegistry extends Service { | 注册表/mount | 重复注册、未挂载访问、disposer 摘除 | — | | domain 层 | open 六步语义、schema 拒绝、update 串行(并发交错压测)、`domain/changed` 逐条、global 初值懒物化、路由与 `facet-unsupported` | 任一(json) | | workspace | create/唯一性/realpath、attach 校验(含 sessionPersistence 缺席拒绝)、一致性口径四情形 | mock domain 或 json | -| session delete 约定(future work,随实施并入 runPersistenceContract) | 未知 id、已删 id 复用、未物化 intent、与在途 append 串行、deleted 事件 | jsonl、sqlite | +| session delete 约定(future work,随实施并入 runPersistenceContract) | 未知 id、已删 id 复用、未物化 intent、与在途 append 串行、deleted 事件 | jsonl | 快照:本期无模型可见面与组装面,不新增;下期 RPC 接线时随 `workspace.*` 域补。 @@ -288,7 +286,7 @@ export class WorkspaceRegistry extends Service { | 不做 | 触发条件 | 返工点 | 预埋 | | --- | --- | --- | --- | | Session 删除(`SessionPersistence.delete`、deleted 事件、递归删除、运行中检查) | 破坏性的 Session 删除产品流启动 | 实现 Session 原语及 `session.delete`;与 Workspace 注册记录删除保持独立 | 上文编排规则和拒绝清单仍是基础;Workspace 删除会保留 Session 与日志 | -| `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 | +| `log` facet 与 Session provider 迁移 | 本期后任意期启动 | 有真实 consumer 证明需要时下沉 JSONL 介质操作 | facet 组织保留选项,但不承诺提取 | | 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 | | 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` | | 数据迁移 | 首个 tagged release 后模型再变 | 版本号驱动逐域迁移 | 版本号自第一天入介质 | diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml index 7d447b4b3b..1862475aae 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md -2026-07-29-durable-last-activity-index.md: d3f21f0b6ddf793ffbc20ddcb2b2f5435c0858d1 -2026-07-29-durable-last-activity-index.zh.md: a690bd87681c0a56cf81446ccf8eedaf105877e7 +2026-07-29-durable-last-activity-index.md: e391b5cd0281c66221d9ca96920a294c98c7e16f +2026-07-29-durable-last-activity-index.zh.md: 453e8a772d2ad8fe4ab2affcfd7049d86da258c5 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md index d3f21f0b6d..e391b5cd02 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md @@ -18,10 +18,7 @@ Making cold ordering exact remains a durable-format decision, which is why it is Store the latest human-prompt time where a listing already reads — the Session index — so `summarizeCold()` can serve it without opening the log or depending on a cache checkpoint. The coordinator computes the value because it sees every append and already owns per-id state; backends persist it. That makes it a new `PersistenceBackend` contract element rather than backend-local bookkeeping, with the same event predicate as the attached projection: `user/message` whose `source.kind` is `user`. -The two shipped backends have opposite constraints, and the proposal is deliberately asymmetric about them: - -- **SQLite** gets a column on `sessions`, written in the same transaction as `appendBatch`, at the cost of a monotonic `SCHEMA_VERSION` bump. -- **JSONL cannot host a mutable header field.** The header is line 1, written once during materialization, and the log is opened for append forever after; `jsonl.spec.ts` pins that committed bytes are never rewritten. A per-append header field would violate an asserted durability invariant, not merely complicate the writer. A per-session sidecar file is the shape to compare against leaving JSONL approximate. +The shipped JSONL backend determines the concrete storage constraint. Its header is line 1, written once during materialization, and the log is opened for append forever after; `jsonl.spec.ts` pins that committed bytes are never rewritten. A per-append header field would violate an asserted durability invariant, not merely complicate the writer. A per-session sidecar file is therefore the shape to compare against leaving JSONL approximate. An out-of-tree backend may store the value in its own index only if it defines the update atomicity, versioning, and recovery semantics for that representation; this proposal does not prescribe another provider's schema. Three questions must be answered before implementation, and none of them is settled here: @@ -47,7 +44,7 @@ Three questions must be answered before implementation, and none of them is sett - A resumed-then-abandoned session does not sort above a session worked in afterwards, in the web session tree and the TUI resume picker, pinned by an assembled snapshot rather than unit tests alone. - The prompt-time rule has one definition: a test proves the stored field and attached fold agree over a log containing human prompts, injected user messages, boundaries, and closers. - Pre-field artifacts load and list without error under the chosen fallback, with the fallback's ordering consequence asserted. -- SQLite's `SCHEMA_VERSION` bump rejects the old on-disk version per the repo's no-migration stance. +- The selected JSONL representation preserves committed log bytes and either updates the activity value atomically with the corresponding append or defines a conservative, observable stale-value failure mode. ## Risks diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md index a690bd8768..453e8a772d 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md @@ -18,10 +18,7 @@ Status: proposed 把最新真人 prompt 时间存到列举本就会读取的 Session 索引,这样 `summarizeCold()` 无需打开日志或依赖 cache checkpoint 就能给出答案。该值由协调器计算,因为它看得到每一次追加,而且本就拥有每 id 状态;由后端负责持久化。这样它就成为 `PersistenceBackend` 约定中新增的一个要素,而不是各后端本地账目,并与已附加投影使用同一个事件谓词:`source.kind` 为 `user` 的 `user/message`。 -两个已交付的后端受到的约束正好相反,本提案对它们有意采取不对称的处理: - -- **SQLite** 在 `sessions` 表上得到一列,与 `appendBatch` 在同一个事务中写入,代价是一次单调的 `SCHEMA_VERSION` 递增。 -- **JSONL 无法承载一个可变的 header 字段。** header 就是第 1 行,在物化时一次写就,此后这份日志永远以追加方式打开;`jsonl.spec.ts` 钉住了「已提交的字节绝不重写」。一个每次追加都要改的 header 字段,违反的是一条被断言的持久性不变式,而不只是让写入方变复杂。要与「让 JSONL 保持近似」相比较的形态,是每会话一个伴随文件。 +随产品交付的 JSONL 后端决定了具体存储约束。它的 header 就是第 1 行,在物化时一次写就,此后这份日志永远以追加方式打开;`jsonl.spec.ts` 钉住了「已提交的字节绝不重写」。一个每次追加都要改的 header 字段,违反的是一条被断言的持久性不变式,而不只是让写入方变复杂。因此,要与「让 JSONL 保持近似」相比较的形态,是每会话一个伴随文件。仓库外后端只有在为自己的表示定义更新原子性、版本与恢复语义后,才可以把该值存入自己的索引;本提案不规定其他提供方的 schema。 实现之前必须回答三个问题,本文对它们都没有定论: @@ -47,7 +44,7 @@ Status: proposed - 在 web 会话树和 TUI 恢复选择器中,一个恢复后即被弃置的会话不会排到此后工作过的会话之前;由一份组装后的快照钉住,而不是只靠单元测试。 - prompt 时间规则只有一个定义:一个测试证明,在包含真人 prompt、注入式 user message、边界和 closer 的日志上,已存储字段与已附加折叠结果一致。 - 在选定的回退方案下,该字段引入之前的产物能够无错误地加载和列举,并且该回退在排序上的后果有断言覆盖。 -- 按本仓库不做迁移的立场,SQLite 的 `SCHEMA_VERSION` 递增会拒绝旧的磁盘版本。 +- 选定的 JSONL 表示保留已提交日志字节,并且要么与对应追加原子地更新活动值,要么定义一种保守且可观察的陈旧值失败模式。 ## 风险 diff --git a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml index 3d772fc810..dbe257b737 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md -2026-08-20-audience-first-documentation-quality.md: ebf9a30a7096f99b0ffa2f2bc0b61be6a178772a -2026-08-20-audience-first-documentation-quality.zh.md: d7d897c592d0ebfa225074978b8b78d7994900a4 +2026-08-20-audience-first-documentation-quality.md: efbb8b482f462f4da33241548d2ce44d766b88ca +2026-08-20-audience-first-documentation-quality.zh.md: 326e4b054f02f81707a20b18394f623a290111a6 diff --git a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md index ebf9a30a70..efbb8b482f 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md @@ -48,7 +48,7 @@ Adopt one audience-first quality contract with five definitions: ### Prototype rules -The [dsh-doc skill](../../../skills/dsh-doc/SKILL.md) owns the first executable version of these rules. The SQLite README pair uses the shipped packed-row implementation as evidence rather than treating its prior prose as authority. +The [dsh-doc skill](../../../skills/dsh-doc/SKILL.md) owns the first executable version of these rules. The `session-persistence-jsonl` README pair uses the shipped append, recovery, and encoding behavior as evidence rather than treating its prior prose as authority. - Every authored package README starts with searchable YAML. A Skill-style `description` and mechanically derived `kind` are required. Four kinds map one-to-one to four skill templates: `package-group` (group map), `package-reference` (plugin or service package), `package-library` (plain module entry), and `package-bundle` (`dsh.bundle.patch`). The counterpart path, hashes, and physical line alignment belong to the merge-safe sidecar and its gate, so README frontmatter contains no `i18n` block. The title or package manifest already owns the name, the document job expresses its audience, and tags remain absent until a governed taxonomy and search consumer proves value beyond full-text search. - Authored pages start with a three-to-five-sentence `Summary`, then a linked `Table of Contents`. Format-owned Agent Notes, postmortems, generated fragments, and machine files keep their required skeletons. @@ -85,7 +85,7 @@ The first prototype should use one large catalog and one mixed subsystem page. I ### Enforcement slices -1. Create and validate `dsh-doc`, then rewrite the `session-persistence-sqlite` README pair as a line-aligned, metadata-bearing prototype without changing runtime claims. +1. Create and validate `dsh-doc`, then rewrite one package README pair as a line-aligned, metadata-bearing prototype without changing runtime claims. 2. Review the rendered prototype with newcomer, user, developer, and agent tasks; revise the skill before enforcing the format elsewhere. 3. Add narrow metadata, section-order, line-alignment, link-resolution, and pairing fixtures. Keep sidecars until every merge and recovery consumer has replacement support. 4. Extract accepted standing rules into one canonical quality reference, condense `docs/AGENTS.md` below its target, and organize one coherent `docs/` topic at a time with atomic link/navigation repair. @@ -93,7 +93,7 @@ The first prototype should use one large catalog and one mixed subsystem page. I This sequence keeps each change independently reviewable. The first three slices improve criteria and correctness without rewriting the corpus; the generated-doc prototype supplies evidence before a broader information-architecture change. -Slices 1–3 have shipped in this form: `dsh-doc` is the consolidated standard (`dsh-doc-standards` and `dsh-doc-site-sync` are folded into it, and the site workflow carries the corrected sidebar values), the `session-persistence-sqlite` README pair is the reference example, and `pnpm run test:docs` enforces the metadata, pairing, and quick documentation checks. Slices 4–5 remain open. +Slices 1–3 have shipped in this form: `dsh-doc` is the consolidated standard (`dsh-doc-standards` and `dsh-doc-site-sync` are folded into it, and the site workflow carries the corrected sidebar values), the `session-persistence-jsonl` README pair is the reference example, and `pnpm run test:docs` enforces the metadata, pairing, and quick documentation checks. Slices 4–5 remain open. ### Non-goals @@ -115,7 +115,7 @@ This proposal does not shorten exhaustive facts, merge audience tiers, publish i - One canonical quality reference defines brief, intuitive, friendly, accurate, and agent-readable documentation by document job. - `.agents/skills/dsh-doc` validates and directly links its metadata, structure/hierarchy, and review/prototype references without duplicating their detailed rules in `SKILL.md`. -- The SQLite README pair demonstrates searchable YAML, Summary, Table of Contents, user-to-developer progression, Further Exploration, final Dev Note, structural parity, and exact line-count equality while preserving verified package contracts. +- The `session-persistence-jsonl` README pair demonstrates searchable YAML, Summary, Table of Contents, user-to-developer progression, Further Exploration, final Dev Note, structural parity, and exact line-count equality while preserving verified package contracts. - `docs/AGENTS.md` links that reference, remains sufficient as standing instruction, and is below its target with at least 5% headroom. - The root user path, Web quick start, first-plugin tutorial, contributor setup, and architecture overview each name an observable outcome and a verification owner without duplicating implementation detail. - The budget manifest records both target and temporary ceiling, and its check reports or rejects a violated headroom/ratchet state. diff --git a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md index d7d897c592..326e4b054f 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md @@ -48,7 +48,7 @@ Status: proposed ### 原型规则 -[dsh-doc skill](../../../skills/dsh-doc/SKILL.md) 负责这些规则的首个可执行版本。SQLite README 对以已交付的分片行实现为证据,而不把其旧版正文当作权威。 +[dsh-doc skill](../../../skills/dsh-doc/SKILL.md) 负责这些规则的首个可执行版本。`session-persistence-jsonl` README 对以已交付的追加、恢复与编码行为为证据,而不把其旧版正文当作权威。 - 每个撰写型包 README 都以可搜索 YAML 开头。Skill 风格的 `description` 与按机制推导的 `kind` 为必填字段。四种 kind 与四个技能模板一一对应:`package-group`(组地图)、`package-reference`(插件或服务包)、`package-library`(纯模块入口)与 `package-bundle`(`dsh.bundle.patch`)。对照文件路径、哈希与物理行对齐由支持自动合并的 sidecar 及其门禁负责,因此 README frontmatter 不包含 `i18n` 块。名称已由标题或包 manifest 归属,受众已由文档职责表达;在受治理的标签分类与搜索消费方证明其价值超过全文检索之前,不加入标签。 - 撰写型页面先写三至五句的 `Summary`,再写带链接的 `Table of Contents`。由格式约束的 Agent Note、事故复盘、生成片段和机器文件保留其必需骨架。 @@ -85,7 +85,7 @@ Status: proposed ### 执行切片 -1. 创建并验证 `dsh-doc`,再把 `session-persistence-sqlite` README 对改写为行对齐、带元数据的原型,同时不改变运行时事实。 +1. 创建并验证 `dsh-doc`,再把一组 package README 对改写为行对齐、带元数据的原型,同时不改变运行时事实。 2. 用新人、用户、开发者和 agent 任务评审渲染后的原型;先修订 skill,再在其他位置强制执行该格式。 3. 添加聚焦的元数据、章节顺序、行对齐、链接解析和配对 fixture。在每个合并与恢复消费方都有替代支持前,保留伴随文件。 4. 把已接受的常驻规则提取到一份规范质量参考,将 `docs/AGENTS.md` 精简到目标以下,并且一次只组织一个内聚的 `docs/` 主题,同时原子地修复链接与导航。 @@ -93,7 +93,7 @@ Status: proposed 该顺序使每项变更都能独立评审。前三个切片在不重写语料的情况下改进标准与正确性;生成文档原型则在更广的信息架构变更前提供证据。 -切片 1–3 已按此形式交付:`dsh-doc` 成为合并后的标准(`dsh-doc-standards` 与 `dsh-doc-site-sync` 已并入其中,站点工作流携带修正后的侧边栏值),`session-persistence-sqlite` README 对是参考示例,`pnpm run test:docs` 强制执行元数据、配对与快速文档检查。切片 4–5 仍待完成。 +切片 1–3 已按此形式交付:`dsh-doc` 成为合并后的标准(`dsh-doc-standards` 与 `dsh-doc-site-sync` 已并入其中,站点工作流携带修正后的侧边栏值),`session-persistence-jsonl` README 对是参考示例,`pnpm run test:docs` 强制执行元数据、配对与快速文档检查。切片 4–5 仍待完成。 ### 非目标 @@ -115,7 +115,7 @@ Status: proposed - 一份规范质量参考按文档职责定义简短、直观、友好、准确和便于 agent 阅读的文档。 - `.agents/skills/dsh-doc` 通过验证,并直接链接其元数据、结构或层级及评审或原型参考,而不在 `SKILL.md` 中复制这些参考的详细规则。 -- SQLite README 对展示可搜索 YAML、Summary、Table of Contents、从用户到开发者的渐进结构、Further Exploration、结尾 Dev Note、结构一致性和精确行数相等,同时保留已验证的包约定。 +- `session-persistence-jsonl` README 对展示可搜索 YAML、Summary、Table of Contents、从用户到开发者的渐进结构、Further Exploration、结尾 Dev Note、结构一致性和精确行数相等,同时保留已验证的包约定。 - `docs/AGENTS.md` 链接该参考,仍足以充当常驻指令,并低于其目标且至少保留 5% 余量。 - 根级用户路径、Web 快速开始、第一个插件教程、贡献者设置和架构概览各自给出一个可观察结果与验证归属者,同时不复制实现细节。 - 预算 manifest 同时记录目标与临时上限,其检查会报告或拒绝违反余量或棘轮规则的状态。 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml index 7027d11d80..2b387384b3 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md -2026-07-04-prune-dead-core-spine-api.md: ecf4e3aa032e47a26d65edec3817e2a50026da40 -2026-07-04-prune-dead-core-spine-api.zh.md: 81cee28d9f9819802b2276b90a4db51035046e1c +2026-07-04-prune-dead-core-spine-api.md: 554244a67cdbe26d90aca1b4bd7a61e1ca0dc7ec +2026-07-04-prune-dead-core-spine-api.zh.md: a0a2520ecf31a4626b629779ca07b7debf8d062d diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md index ecf4e3aa03..554244a67c 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md @@ -20,7 +20,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime | ACP `agentOptions` root export | The helper has only same-file and ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make `agentOptions` source-private and test it through bridge behavior. | | `providerWording` and `completedTurnPrefix` root exports | Each has one same-package production caller; only the balanced-prefix helper has a same-package white-box test. | Make them source-private and test provider behavior. | | `depthOf`, `SubagentDepthError`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. `SENSITIVE_ENV_PATTERN` is excluded because the SDK helper applies it to caller-supplied environments. | Keep depth and exit behavior but make the remaining helpers and error source-private; test through spawn and disposal. Keep the shared credential pattern public. | -| `PersistenceCoordinator.inits`, backend `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep both backends, `SessionHeader`, and SQLite's version contract. | +| `PersistenceCoordinator.inits`, provider `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep the JSONL provider and `SessionHeader`. | | `LlmError.status` and replay status | Adapters/replay populate it, but production branches on stable error code/message and never reads raw status. | Remove the unread field and replay plumbing while preserving error classification. | | `BlockAssembler.push()` return value | Both production callers ignore the returned completed block. | Return `void`; keep the deliberately public `blocks()`/`message()` contract. | | `compactRegion`'s separate `session` argument | The fixed caller passes the same object already present as `agent.session`; the model-visible mount API can also call the method, but accepting two identities permits a mounted plugin to provide an incoherent pair. | Keep the manual-region API while deliberately narrowing it to `agent.session` as the one source of truth. | @@ -43,7 +43,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime ## Proposal -Remove or demote every row as one bounded coordinated public-surface cleanup. Update package READMEs, JSDoc, generated API/event catalogs, type-equivalence records, exports maps where needed, and tests so they exercise the owning public contract instead of preserving test-only entry points. Do not collapse any capability seam, LLM adapter, persistence backend, or lifecycle quiescence contract. +Remove or demote every row as one bounded coordinated public-surface cleanup. Update package READMEs, JSDoc, generated API/event catalogs, type-equivalence records, exports maps where needed, and tests so they exercise the owning public contract instead of preserving test-only entry points. Do not collapse any capability seam, LLM adapter, persistence provider, or lifecycle quiescence contract. ## Alternatives considered @@ -55,7 +55,7 @@ Remove or demote every row as one bounded coordinated public-surface cleanup. Up - Exact-symbol searches show no removed API outside this Agent Note and any implemented-Agent Note amendments. - Every API element listed in this Agent Note is absent or demoted as specified; deliberately retained extension/test contracts outside the inventory are unchanged. -- Tool execution, compaction, both LLM adapters, both persistence backends, workflow isolation, and agent creation/resume retain their shipped behavior. +- Tool execution, compaction, both LLM adapters, the persistence provider, workflow isolation, and agent creation/resume retain their shipped behavior. - Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. ## Risks diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md index 81cee28d9f..a0a2520ecf 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md @@ -20,7 +20,7 @@ Status: proposed | ACP 的 `agentOptions` 根导出 | 该辅助函数只有同文件和 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 `agentOptions` 改为源码私有,通过桥接层行为测试。 | | `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 | | `depthOf`、`SubagentDepthError`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制机制和测试内部实现。`SENSITIVE_ENV_PATTERN` 不在其中,因为 SDK helper 会将它应用于调用方传入的环境。 | 保留深度与退出行为,但将剩余辅助函数和 error 改为源码私有;通过 spawn 和 dispose 测试。保持共享凭据正则公开。 | -| `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本约定。 | +| `PersistenceCoordinator.inits`、provider `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留 JSONL provider 与 `SessionHeader`。 | | `LlmError.status` 与回放 status | 适配器/回放填充它,但生产分支基于稳定的错误码/消息判断,从不读取原始 status。 | 移除未读字段和回放管道,保留错误分类。 | | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 约定。 | | `compactRegion` 的独立 `session` 参数 | 固定调用方传入的对象就是 `agent.session` 中已有的对象;模型可见的 mount API 也可以调用该方法,但同时接受两个独立对象,会让挂载的插件传入不一致的组合。 | 保留手动 region API,同时有意将其收窄为以 `agent.session` 为唯一真源。 | @@ -43,7 +43,7 @@ Status: proposed ## 提案 -以一次有界的、协调的公开接口清理,移除或降级上述每一行。同步更新包 README、JSDoc、生成的 API/事件 catalog、type-equiv 记录、必要的 exports map 以及测试,使测试通过所属的公开约定验证行为,而非保留仅为测试而存在的入口。不折叠任何能力 seam、LLM(大语言模型)适配器、持久化后端或生命周期完全停稳约定。 +以一次有界的、协调的公开接口清理,移除或降级上述每一行。同步更新包 README、JSDoc、生成的 API/事件 catalog、type-equiv 记录、必要的 exports map 以及测试,使测试通过所属的公开约定验证行为,而非保留仅为测试而存在的入口。不折叠任何能力 seam、LLM(大语言模型)适配器、持久化 provider 或生命周期完全停稳约定。 ## 曾考虑的替代方案 @@ -55,7 +55,7 @@ Status: proposed - 精确符号搜索显示:在本 Agent Note 及任何对已实现 Agent Note 的修正之外,没有被移除的接口。 - 本 Agent Note 列出的每个接口均按指定方式移除或降级;清单之外有意保留的扩展/测试约定不变。 -- 工具执行、上下文压缩(context compaction)、两个 LLM 适配器、两个持久化后端、工作流隔离以及 agent 创建/恢复保持其已交付行为。 +- 工具执行、上下文压缩(context compaction)、两个 LLM 适配器、持久化 provider、工作流隔离以及 agent 创建/恢复保持其已交付行为。 - 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建和 hygiene 通过。 ## 风险 diff --git a/.agents/skills/dsh-doc/SKILL.md b/.agents/skills/dsh-doc/SKILL.md index 271c885247..87e6e6b3f1 100644 --- a/.agents/skills/dsh-doc/SKILL.md +++ b/.agents/skills/dsh-doc/SKILL.md @@ -7,7 +7,7 @@ description: Create, restructure, review, audit, or migrate DeepSeek Harness Mar ## Summary -The DeepSeek Harness documentation standard: make every page searchable, newcomer-readable, and exact enough for agents and maintainers, and keep the documentation website a tested projection of repository Markdown. Apply repository `AGENTS.md` files and executed gates first, then this workflow for kind-mapped metadata, progressive detail, line-aligned bilingual pages, corpus audits, and website publication. Preserve one owner per fact: source, tests, generated catalogs, package READMEs, guides, Agent Notes, and scratch each keep their own kind of truth. The `session-persistence-sqlite` README pair is the reference example of the format. +The DeepSeek Harness documentation standard: make every page searchable, newcomer-readable, and exact enough for agents and maintainers, and keep the documentation website a tested projection of repository Markdown. Apply repository `AGENTS.md` files and executed gates first, then this workflow for kind-mapped metadata, progressive detail, line-aligned bilingual pages, corpus audits, and website publication. Preserve one owner per fact: source, tests, generated catalogs, package READMEs, guides, Agent Notes, and scratch each keep their own kind of truth. The `session-persistence-jsonl` README pair is the reference example of the format. ## Table of Contents @@ -108,7 +108,7 @@ Load only the reference needed for the task. Each reference links directly from The four README templates in [`templates/`](templates/) are the working skeletons for the four `kind` labels; open the one your document's kind names before writing. -Use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for sentence-level contract coverage and editorial judgment. The `session-persistence-sqlite` README pair ([English](../../../packages/session/session-persistence-sqlite/README.md), [Chinese](../../../packages/session/session-persistence-sqlite/README.zh.md)) is the reference example: searchable YAML, Summary and Table of Contents, user-to-developer progression with a folded developer section, Further Exploration, canonical Model Experience and Known Limitations sections, and a final Dev Note. +Use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for sentence-level contract coverage and editorial judgment. The `session-persistence-jsonl` README pair ([English](../../../packages/session/session-persistence-jsonl/README.md), [Chinese](../../../packages/session/session-persistence-jsonl/README.zh.md)) is the reference example: searchable YAML, Summary and Table of Contents, user-to-developer progression with a folded developer section, Further Exploration, canonical Model Experience and Known Limitations sections, and a final Dev Note. ## Validation diff --git a/.agents/skills/dsh-doc/references/metadata-links-i18n.md b/.agents/skills/dsh-doc/references/metadata-links-i18n.md index 95c9e21d13..caa9e444e2 100644 --- a/.agents/skills/dsh-doc/references/metadata-links-i18n.md +++ b/.agents/skills/dsh-doc/references/metadata-links-i18n.md @@ -49,9 +49,9 @@ Before assigning `package-library` or `package-bundle`, inspect the facts: read Agents search frontmatter `description` values to shortlist pages before loading full documents. Write each value like a Skill description: state what the page covers and when a reader should open it. Use one or two concrete sentences, include searchable domain terms, and distinguish the page from nearby owners. Do not summarize every section, claim superiority, repeat the title, advertise vaguely, preserve change history, or write a technical status report. -Good: `SQLite session persistence for deployments and maintainers choosing, configuring, or debugging the opt-in packed-row backend.` +Good: `The shipped JSONL session-persistence backend for deployments and maintainers choosing, configuring, or debugging per-session durable logs with optional Zstandard compression.` -Weak: `The best and most advanced SQLite storage implementation with lots of optimizations.` +Weak: `The best and most advanced session storage implementation with lots of optimizations.` ## Repository links and path mentions diff --git a/.agents/skills/dsh-doc/references/review.md b/.agents/skills/dsh-doc/references/review.md index 6e67637812..94b24fb770 100644 --- a/.agents/skills/dsh-doc/references/review.md +++ b/.agents/skills/dsh-doc/references/review.md @@ -2,7 +2,7 @@ ## Summary -Review documentation by whether a reader completes an outcome, not by whether every template heading exists. Verify prose against code and tests, preserve exact contracts, and keep package READMEs useful to consumers while exposing enough implementation detail for maintainers. Run current repository gates; the `session-persistence-sqlite` README pair is the reference example of the format. +Review documentation by whether a reader completes an outcome, not by whether every template heading exists. Verify prose against code and tests, preserve exact contracts, and keep package READMEs useful to consumers while exposing enough implementation detail for maintainers. Run current repository gates; the `session-persistence-jsonl` README pair is the reference example of the format. ## Table of Contents @@ -46,7 +46,7 @@ Do not restate JSDoc or generated catalogs. Link the owner and explain only the ## Reference example -The `session-persistence-sqlite` README pair ([English](../../../../packages/session/session-persistence-sqlite/README.md), [Chinese](../../../../packages/session/session-persistence-sqlite/README.zh.md)) demonstrates the format in production: searchable YAML whose `kind` selects this package-reference standard, a five-sentence Summary, a linked Table of Contents, a user-facing use section (choice, sizing, configuration, migration, safe operation) separated by horizontal rules and followed by a GitHub-native `
` fold under the developer section title (design philosophy, source map, schema tables, write path, read and recovery), Further Exploration, canonical Model Experience and Known Limitations sections, and a final folded Dev Note holding non-authoritative working context such as the annotated benchmark artifact and undecided future directions. Use its structure, evidence standards, and bilingual alignment as the model for package READMEs and cross-package pages; ground every claim the way it grounds the benchmark numbers in the Agent Note. +The `session-persistence-jsonl` README pair ([English](../../../../packages/session/session-persistence-jsonl/README.md), [Chinese](../../../../packages/session/session-persistence-jsonl/README.zh.md)) demonstrates the format in production: searchable YAML whose `kind` selects this package-reference standard, a four-sentence Summary, a linked Table of Contents, a user-facing use section covering selection, configuration, layout, durability, and reading, a GitHub-native `
` fold for developer-facing design and storage details, Further Exploration, canonical Model Experience and Known Limitations sections, and a final Dev Note. Use its structure, evidence standards, and bilingual alignment as the model for package READMEs and cross-package pages; ground every claim in its owning source and evidence. ## Verification diff --git a/.agents/skills/dsh-doc/references/style.md b/.agents/skills/dsh-doc/references/style.md index df6ff18254..9a974fda0d 100644 --- a/.agents/skills/dsh-doc/references/style.md +++ b/.agents/skills/dsh-doc/references/style.md @@ -2,7 +2,7 @@ ## Summary -Page-level style preferences that make DSH pages scannable and difficult to misread: a short Summary, controlled technical English, `-----` separators between major parts, `
` folds that keep section titles visible, and disciplined emphasis. The template is the `session-persistence-sqlite` README pair. +Page-level style preferences that make DSH pages scannable and difficult to misread: a short Summary, controlled technical English, `-----` separators between major parts, `
` folds that keep section titles visible, and disciplined emphasis. The template is the `session-persistence-jsonl` README pair. ## Table of Contents @@ -36,7 +36,7 @@ Separate the major parts of a page with a `-----` horizontal rule on its own lin ## Foldable content sections -Fold developer-facing detail and the final Dev Note behind GitHub-native `
`/`` blocks. Keep the section title (H2 or H3) and its `` anchor visible; fold only the content under the title. Inside the block, put a blank line after ``, keep every Markdown line at column 0 (indented content becomes a code block), and close with `
` after a blank line. Headings, lists, tables, and links inside the fold parse normally and keep their anchors. The `session-persistence-sqlite` README pair demonstrates both folds: the implementation section and the Dev Note. +Fold developer-facing detail and the final Dev Note behind GitHub-native `
`/`` blocks. Keep the section title (H2 or H3) and its `` anchor visible; fold only the content under the title. Inside the block, put a blank line after ``, keep every Markdown line at column 0 (indented content becomes a code block), and close with `
` after a blank line. Headings, lists, tables, and links inside the fold parse normally and keep their anchors. The `session-persistence-jsonl` README pair demonstrates both folds: the implementation section and the Dev Note. In a package README, keep `## Model Experience` and `## Known Limitations and Deferred Work` as the final two H2 headings. Put the limitations anchor immediately after its H2 so it does not become part of the preceding Model Experience body. Place the final Dev Note under the limitations section as an anchored H3. A package that is explicitly exempt from the limitations section can use an H2 Dev Note. diff --git a/.agents/skills/dsh-doc/templates/package-reference.md b/.agents/skills/dsh-doc/templates/package-reference.md index 0e8c5f341b..5fda732779 100644 --- a/.agents/skills/dsh-doc/templates/package-reference.md +++ b/.agents/skills/dsh-doc/templates/package-reference.md @@ -1,6 +1,6 @@ # Template: package-reference -Use this template for a package whose entry is a Cordis plugin — a service default export or an `apply` function — mounted in a composition. This is the default for `packages///README.md`. The `session-persistence-sqlite` README pair is the worked example of this template. +Use this template for a package whose entry is a Cordis plugin — a service default export or an `apply` function — mounted in a composition. This is the default for `packages///README.md`. The `session-persistence-jsonl` README pair is the worked example of this template. ## Frontmatter diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 3d09af1d54..78ad894ac1 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -11,8 +11,8 @@ This skill helps turn a broad "find things to simplify" request into evidence-ba - Read `AGENTS.md`, especially the pre-release stance and the conventions (including the tests-are-not-golden-truth and Agent Notes-are-not-golden-truth doctrines), plus [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) and [docs/testing.md](../../../docs/testing.md). - Skim [docs/architecture.md](../../../docs/architecture.md) before judging anything under `packages/`; simplifications that fight the service map or event taxonomy need extra evidence. -- Use the Agent Note tree and its [rules](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin adapter / dual persistence backend Agent Notes. -- Treat dual LLM adapters and dual persistence backends as intentional by default. Do not propose deleting either twin/backend as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design. +- Use the Agent Note tree and its [rules](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [JSONL-only first-party Session persistence](../../notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin-adapter Agent Notes. +- Treat dual LLM adapters as intentional by default. Session persistence is different: JSONL is the sole first-party provider, while the backend-neutral service remains available to out-of-tree providers. Do not propose deleting an LLM twin or the persistence seam as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design. ## What Counts As A Strong Candidate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 545142b639..056309c0a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -575,7 +575,6 @@ jobs: packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts packages/workflow/tool-ralph/tests/integration.spec.ts packages/subprocess/subprocess-local/tests/process-exit.spec.ts - packages/session/session-persistence-sqlite/tests/differential.spec.ts windows-observational: if: github.event_name == 'pull_request' diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index ff8b88ba60..7453cc5b81 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: db2e3966b4cc4f0a1afa49bf4d7963ac69b1826d -capability-seams.zh.md: a5f0873cf80d22cf06dec8b1272942309a9bc0d6 +capability-seams.md: a6cca7fd2f1d5bd8ee4f3516fa6eb1f5fe828ef8 +capability-seams.zh.md: dcd5e4c71ae0f6db9faf667628b5bee2f27fc862 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index db2e3966b4..a6cca7fd2f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -54,7 +54,6 @@ flowchart LR svc_typertGateway["ctx.typertGateway
Typert Host invocation gateway"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] - pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_tool_bash["tool-bash"] pkg_hooks_claude_code["hooks-claude-code"] pkg_hooks_codex["hooks-codex"] @@ -282,7 +281,6 @@ flowchart LR pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions pkg_session_persistence --> svc_sessionPersistence pkg_session_persistence_jsonl --> svc_sessionPersistence - pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_projection --> svc_sessionProjections pkg_session_projection_cache --> svc_sessionProjectionCache pkg_session_query --> svc_sessionQuery @@ -481,7 +479,7 @@ flowchart LR | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | The JSONL backend persists the SessionEvent vocabulary as one artifact per Session. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a5f0873cf8..dcd5e4c71a 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -56,7 +56,6 @@ flowchart LR svc_typertGateway["ctx.typertGateway
Typert Host invocation gateway"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] - pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_tool_bash["tool-bash"] pkg_hooks_claude_code["hooks-claude-code"] pkg_hooks_codex["hooks-codex"] @@ -284,7 +283,6 @@ flowchart LR pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions pkg_session_persistence --> svc_sessionPersistence pkg_session_persistence_jsonl --> svc_sessionPersistence - pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_projection --> svc_sessionProjections pkg_session_projection_cache --> svc_sessionProjectionCache pkg_session_query --> svc_sessionQuery @@ -483,7 +481,7 @@ flowchart LR | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | JSONL backend 把 SessionEvent 词汇持久化为每个 Session 一份产物。 | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;settings controller 提供经过脱敏的分层描述符,并写入用户层。 | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index f702637696..49cb7bb2c7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: a50c9d6b148bb0bcf05edf198b24278aaf4c62dd -config-catalog.zh.md: 902a41105afe48b4ab4075d671667a22fc170250 +config-catalog.md: ec077edd10962f324db242698b1d652563c3ac2f +config-catalog.zh.md: d349575cb2884bd2e80097c8345db8d0227107c7 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a50c9d6b14..ec077edd10 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1793,33 +1793,6 @@ export type JsonlCompression = 'zstd' | 'none' Source: [`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts) -
- -## `@deepseek-ai/dsh-session-persistence-sqlite` - -Requires: `sessions` - -```ts config-catalog -/** Plugin configuration. */ -export interface Config { - /** SQLite database path, or `:memory:` for an in-process database. */ - path: string - /** Durable SQLite journal mode; defaults to `wal`. */ - journalMode?: JournalMode - /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */ - busyTimeoutMs?: number - /** Maximum cold Session preparations retained for history-to-resume reuse. */ - preparedSessionCacheSize?: number - /** Fixed live-event coalescing window; not a backend completion deadline. */ - writeBatchMaxDelayMs?: number -} - -/** Durable journal modes accepted by the backend. */ -export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' -``` - -Source: [`packages/session/session-persistence-sqlite/src/index.ts:38`](../packages/session/session-persistence-sqlite/src/index.ts) - ## `@deepseek-ai/dsh-session-projection-cache` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 902a41105a..d349575cb2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1795,33 +1795,6 @@ export type JsonlCompression = 'zstd' | 'none' 来源:[`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts) - - -## `@deepseek-ai/dsh-session-persistence-sqlite` - -需要:`sessions` - -```ts config-catalog -/** Plugin configuration. */ -export interface Config { - /** SQLite database path, or `:memory:` for an in-process database. */ - path: string - /** Durable SQLite journal mode; defaults to `wal`. */ - journalMode?: JournalMode - /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */ - busyTimeoutMs?: number - /** Maximum cold Session preparations retained for history-to-resume reuse. */ - preparedSessionCacheSize?: number - /** Fixed live-event coalescing window; not a backend completion deadline. */ - writeBatchMaxDelayMs?: number -} - -/** Durable journal modes accepted by the backend. */ -export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' -``` - -来源:[`packages/session/session-persistence-sqlite/src/index.ts:38`](../packages/session/session-persistence-sqlite/src/index.ts) - ## `@deepseek-ai/dsh-session-projection-cache` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index bd7d42b362..cbec50a70c 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 7177faafa35c11cd7e922e60a3ff50bf24e57969 -module-graph.zh.md: a50aa3bec30b7d1f274f6c6d106cd3f8746d8980 +module-graph.md: 2229efcd7e76b3b224eb307ee7de9ecea0ad85d7 +module-graph.zh.md: 3edca4ea261f68593973149b21e72e4a25d7a504 diff --git a/docs/module-graph.md b/docs/module-graph.md index 7177faafa3..2229efcd7e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -287,7 +287,6 @@ flowchart TD pkg_session_log_deepseek["session-log-deepseek"] pkg_session_persistence["session-persistence"] pkg_session_persistence_jsonl["session-persistence-jsonl"] - pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] pkg_session_stats["session-stats"] @@ -523,10 +522,6 @@ flowchart TD pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence - pkg_session_persistence_sqlite --> pkg_invariants - pkg_session_persistence_sqlite --> pkg_llm - pkg_session_persistence_sqlite --> pkg_session - pkg_session_persistence_sqlite --> pkg_session_persistence pkg_session_projection_cache --> pkg_invariants pkg_session_projection_cache --> pkg_session pkg_session_projection_cache --> pkg_session_projection @@ -1434,7 +1429,6 @@ flowchart TD | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a50aa3bec3..3edca4ea26 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -289,7 +289,6 @@ flowchart TD pkg_session_log_deepseek["session-log-deepseek"] pkg_session_persistence["session-persistence"] pkg_session_persistence_jsonl["session-persistence-jsonl"] - pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] pkg_session_stats["session-stats"] @@ -525,10 +524,6 @@ flowchart TD pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence - pkg_session_persistence_sqlite --> pkg_invariants - pkg_session_persistence_sqlite --> pkg_llm - pkg_session_persistence_sqlite --> pkg_session - pkg_session_persistence_sqlite --> pkg_session_persistence pkg_session_projection_cache --> pkg_invariants pkg_session_projection_cache --> pkg_session pkg_session_projection_cache --> pkg_session_projection @@ -1436,7 +1431,6 @@ flowchart TD | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index f6341b0335..9176b4e312 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: f3340ebeb426fe9cc5c4570b3c8a3440cc1682ec -README.zh.md: 2e4523f03d1a942d9f93abf34ad2cc8687b413b8 +README.md: 7ead36412136b00eb284897bb1114ead7d4d96d4 +README.zh.md: bc7500c00d5bb68cb7d4bf607e3d3a58bd2c55cc diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index f3340ebeb4..7ead364121 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -16,7 +16,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [todo.md](todo.md) | the todo package's whole-list item type, durable event ownership, projection, and open-turn invariant | | [commands.md](commands.md) | the human-command registry service: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | -| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | +| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, the JSONL provider, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 2e4523f03d..bc7500c00d 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -16,7 +16,7 @@ | [todo.md](todo.zh.md) | todo 包的整列表条目类型、持久事件所有权、投影和开放轮次不变量 | | [commands.md](commands.zh.md) | 人类命令注册表服务:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.zh.md) | 完整的 `SessionEventMap` 变体目录、`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | -| [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | +| [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL provider、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.zh.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [credentials.md](credentials.zh.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、提供方来源层 | | [session-query.md](session-query.zh.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 34ee576787..6f08cfb825 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 08c4f5f32ea291d70a3918e92a1f7c4c23a7b3dd -core.zh.md: 48ecdad1e1d45b660215e88e908f3dea288afe64 +core.md: 124d30a5abeddd81edd2eb03a9bb213487b6a38e +core.zh.md: c2d67eabb3b223d3f1d20c42f01280f16656ebef diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 08c4f5f32e..124d30a5ab 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -252,7 +252,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. Every entry carries a monotonic `seq`, a `time`, and a `type`-discriminated `data` payload; surface variants may also list cited earlier events in `sourceEventSeqs` and carry a `surfaceOp`. -The `SessionEvent` envelope's exact conditional fields, the twelve core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `request/header`, `request/context`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The `SessionEvent` envelope's exact conditional fields, the twelve core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `request/header`, `request/context`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## `ToolDefinition` diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 48ecdad1e1..c2d67eabb3 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -260,7 +260,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM 消息历史从日志*派生*(`deriveMessages()`),而非单独存储。每个条目携带单调的 `seq`、`time` 与按 `type` 判别的 `data` payload;surface 变体还可以在 `sourceEventSeqs` 中列出被引用的较早事件,并携带 `surfaceOp`。 -`SessionEvent` 信封的确切条件字段、十二种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`request/header`、`request/context`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。 +`SessionEvent` 信封的确切条件字段、十二种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`request/header`、`request/context`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。 ## `ToolDefinition` diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 0a06fce0bb..757092f100 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 697cbf38ddbdea88947da86d10fc05b0cbc9ddb0 -persistence.zh.md: 6e12ed26fda7d11a8dfe6fd6575395d2f1b5500a +persistence.md: cd7eaa126ea16b17526223498d1fa1591792747c +persistence.zh.md: c96aa87f738e5607c699dfa97e6d2b237767b76d diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 697cbf38dd..cd7eaa126e 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -2,9 +2,9 @@ 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 **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its provider model and shipped JSONL backend, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session/session-persistence), `ctx.sessionPersistence`) 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 three interchangeable providers implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session/session-persistence), `ctx.sessionPersistence`) defines locate/create/append, reusable Session preparation, logical load/inspect, physical suffix reads, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type**. The repository ships [dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl) as its provider; out-of-tree providers may implement the same service contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -20,7 +20,7 @@ Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id ## `SessionLocation` — optional per-session artifact target -`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns the absolute transcript path inside its project/session directory; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. +`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns the absolute transcript path inside its project/session directory; a backend without one independent artifact per session returns `undefined`. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. ```ts type-equiv /** @@ -91,7 +91,7 @@ interface SessionHeader { ## Format refusal — logs a build cannot faithfully read -A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating this format version's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating this format version's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt". An out-of-tree backend must enforce the equivalent direction-aware refusal at its own physical-format boundary. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). ## `CreateSessionOptions` — seeding and metadata @@ -126,7 +126,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi ## `SessionRawArtifact` — verbatim stored artifact text -A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives. Consumers first test `supportsRawArtifacts`: `false` means the backend does not provide this capability (for example SQLite), while `readRaw(...) === undefined` means a supported backend has no materialized artifact for that session. +A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives. Consumers first test `supportsRawArtifacts`: `false` means the backend does not provide this capability, while `readRaw(...) === undefined` means a supported backend has no materialized artifact for that session. ```ts type-equiv /** A backend's own raw artifact text for one session, verbatim. */ @@ -228,12 +228,11 @@ interface SessionPersistenceSnapshot { } ``` -## The backends +## The backend -All implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite: +The shipped provider implements the abstract `SessionPersistence` contract (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and passes the shared `runPersistenceContract` suite: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 20 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them. @@ -252,8 +251,8 @@ Durable append-only session storage. Implementations preserve contiguous, lossle ```ts cordis-catalog /** * Resolve this backend's independent local artifact for a session without - * reading, creating, flushing, or otherwise materializing it. Backends such - * as SQLite that do not own one artifact per session return `undefined`. + * reading, creating, flushing, or otherwise materializing it. A backend + * that does not own one artifact per Session returns `undefined`. * @param meta - the immutable session header whose artifact is requested. * @returns the backend-specific absolute location, when one exists. */ @@ -367,10 +366,10 @@ abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise @@ -252,8 +251,8 @@ Durable append-only session storage. Implementations preserve contiguous, lossle ```ts cordis-catalog /** * Resolve this backend's independent local artifact for a session without - * reading, creating, flushing, or otherwise materializing it. Backends such - * as SQLite that do not own one artifact per session return `undefined`. + * reading, creating, flushing, or otherwise materializing it. A backend + * that does not own one artifact per Session returns `undefined`. * @param meta - the immutable session header whose artifact is requested. * @returns the backend-specific absolute location, when one exists. */ @@ -367,10 +366,10 @@ abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise await ctx.plugin(SqliteSessionPersistence, { - path: join(root, 'sessions.sqlite'), - journalMode: 'delete', - }), - }, ] async function stack( diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index ef3419db8f..9d22fdcfeb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1465,7 +1465,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'abstract locate(meta: SessionHeader): SessionLocation | undefined', - description: 'Resolve this backend\'s independent local artifact for a session without reading, creating, flushing, or otherwise materializing it. Backends such as SQLite that do not own one artifact per session return `undefined`.', + description: 'Resolve this backend\'s independent local artifact for a session without reading, creating, flushing, or otherwise materializing it. A backend that does not own one artifact per Session returns `undefined`.', parameters: [{ name: 'meta', description: 'the immutable session header whose artifact is requested.' }], returns: 'the backend-specific absolute location, when one exists.', }, @@ -1522,7 +1522,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - description: 'Read the stored events from `fromSeq` onward — the read-from-seq primitive for read models that resume from a watermark (e.g. a persisted projection cache folding only the tail past its checkpoint). Unlike inspect, it is a detached physical suffix read: no preparation cache, torn-tail truncation, synthetic closers, or coordinator-state publication. Only events from the valid contiguous stored prefix are returned, so a torn fragment never reaches the caller. `fromSeq` at or beyond the stored prefix returns an empty event list (never an error). 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.', + description: 'Read the stored events from `fromSeq` onward — the read-from-seq primitive for read models that resume from a watermark (e.g. a persisted projection cache folding only the tail past its checkpoint). Unlike inspect, it is a detached physical suffix read: no preparation cache, torn-tail truncation, synthetic closers, or coordinator-state publication. Only events from the valid contiguous stored prefix are returned, so a torn fragment never reaches the caller. `fromSeq` at or beyond the stored prefix returns an empty event list (never an error). A backend whose medium can seek by seq may read only the suffix; sequential media such as JSONL still parse the whole artifact and skip forward. The primitive bounds what is returned and refolded, not every backend\'s physical read.', parameters: [{ name: 'id', description: 'the persisted session to read.' }, { name: 'fromSeq', description: 'first event seq to include; a non-negative safe integer.' }, { name: 'signal', description: 'optional cancellation for queued and backend read work.' }], returns: 'the header and the stored events with `seq >= fromSeq`.', }, @@ -3627,6 +3627,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', }, + { + name: 'ChunkRow', + declaration: 'export type ChunkRow = {\n type: \'text-chunks\';\n seq0: number;\n time0: number;\n data: TextRunData;\n} | {\n type: \'reasoning-chunks\';\n seq0: number;\n time0: number;\n data: TextRunData;\n} | {\n type: \'tool-call-chunks\';\n seq0: number;\n time0: number;\n data: ToolCallRunData;\n};', + }, { name: 'ChunkRowEvent', declaration: 'export type ChunkRowEvent = {\n [Kind in ChunkRow[\'type\']]: {\n readonly type: `chunkrow/${Kind}`;\n readonly seq: number;\n readonly time: number;\n readonly data: Extract[\'data\'];\n };\n}[ChunkRow[\'type\']];', diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index c250d1be3f..85fc3db29d 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -62,7 +62,6 @@ "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index e9108927d5..61c3d2bab5 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' import { RetryId } from '@deepseek-ai/dsh-llm-retry' import type {} from '../src/index.ts' @@ -15,24 +14,20 @@ afterEach(async () => { for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }) }) -async function backend(kind: 'jsonl' | 'sqlite'): Promise { +async function backend(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - if (kind === 'jsonl') { - const root = await mkdtemp(join(tmpdir(), 'dsh-llm-retry-jsonl-')) - dirs.push(root) - await ctx.plugin(JsonlSessionPersistence, { root }) - } else { - await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' }) - } + const root = await mkdtemp(join(tmpdir(), 'dsh-llm-retry-jsonl-')) + dirs.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) return ctx } -describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) => { +describe('JSONL retry-event persistence', () => { it('round-trips the event losslessly without adding a model message', async () => { - const ctx = await backend(kind) + const ctx = await backend() try { - const session = ctx.sessions.create(SessionId(`retry-${kind}`)) + const session = ctx.sessions.create(SessionId('retry-jsonl')) session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { @@ -40,7 +35,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) reason: 'initial', }) const event = session.append('llm/retry', { - retryId: RetryId(`retry-${kind}-chain`), + retryId: RetryId('retry-jsonl-chain'), turn: 1, step: 1, provider: 'mock', diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index c8f260886d..8682a24788 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md -README.md: a46bcdcd6fa6dda8997b3ed07f0ae789d8e3471d -README.zh.md: 348f6c09c8c5de4e8fe7f779b6d0fd4f662e8e34 +README.md: 05340605d8d93f210127156f0a976c63732dd6f1 +README.zh.md: ec126c43af7afec567aca33f22173c658be72be9 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index a46bcdcd6f..05340605d8 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -29,7 +29,7 @@ Use this package when the Web bundle should let users export a session log. It r ### When to choose it -Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write, and it requires a persistence backend that stores a per-session raw artifact (the shipped JSONL backend supports plaintext and zstd; SQLite export is not supported). +Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write, and it requires the shipped JSONL provider's per-Session raw artifact in plaintext or zstd form. ### Composition @@ -121,7 +121,7 @@ None. The log-only command lifecycle and browser download do not change the deri These limits define when this package is a poor fit or needs special operational care. They are current package constraints, not a task backlog. -- **Requires a per-session raw artifact backend** — the download endpoint needs a persistence backend with a per-session raw artifact; the shipped JSONL backend supports plaintext and zstd, and SQLite export is not supported. +- **Requires a per-Session raw artifact** — the download endpoint reads the shipped JSONL provider's plaintext or zstd artifact; an out-of-tree provider without a raw artifact cannot serve this route. - **Browser download, not a Host-path writer** — the browser chooses the local destination; no Host path or native folder action is returned. - **Preflight reports only pre-stream failures** — a descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 348f6c09c8..ec126c43af 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -为需要带可见下载弹窗的用户级会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入,并且它要求持久化后端保存逐会话原始产物(随附 JSONL 后端支持明文与 zstd;不支持 SQLite 导出)。 +为需要带可见下载弹窗的用户级会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入,并且它要求随产品交付的 JSONL provider 提供逐 Session 的明文或 zstd 原始产物。 ### 组合 @@ -121,7 +121,7 @@ Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/ 这些限制说明本包何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。 -- **要求逐会话原始产物后端**——下载端点需要带逐会话原始产物的持久化后端;随附 JSONL 后端支持明文与 zstd,不支持 SQLite 导出。 +- **要求逐 Session 原始产物**——下载端点读取随产品交付的 JSONL provider 所提供的明文或 zstd 产物;没有原始产物的仓库外 provider 无法服务该 route。 - **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。 - **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 diff --git a/packages/session-query/session-query-sqlite/README.i18n.yaml b/packages/session-query/session-query-sqlite/README.i18n.yaml index e4f14642fe..2b5d8690bf 100644 --- a/packages/session-query/session-query-sqlite/README.i18n.yaml +++ b/packages/session-query/session-query-sqlite/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-query-sqlite/README.md -README.md: 5c839ebca39c6646122d1061592a9ac75b468e67 -README.zh.md: e3eaa2196b32f4ca553e6278e5598e92730b2ea5 +README.md: 96ad49d6b14b38849ad800e5802ecdc78344bdc8 +README.zh.md: 241f652bb85cc4a8bee489babd699522e3236b3c diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 5c839ebca3..96ad49d6b1 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -120,7 +120,7 @@ Read these pages when the package-level contract is not enough. They move from t - [dsh-session-query](../session-query/README.md) — the service definition: exact reads, filters, and traces this backend inherits. - [dsh-tool-session-query](../tool-session-query/README.md) — the model-facing consumer that calls these search methods. - [SQLite FTS5 session search](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md) — search semantics, reconciliation, and the tokenizer decision. -- [SQLite session persistence](../../../packages/session/session-persistence-sqlite/README.md) — the sibling persistence backend; never point this package's `path` at its database. +- [JSONL session persistence](../../session/session-persistence-jsonl/README.md) — the authoritative Session store this disposable index observes; keep its root separate from this package's database path. ----- diff --git a/packages/session-query/session-query-sqlite/README.zh.md b/packages/session-query/session-query-sqlite/README.zh.md index e3eaa2196b..241f652bb8 100644 --- a/packages/session-query/session-query-sqlite/README.zh.md +++ b/packages/session-query/session-query-sqlite/README.zh.md @@ -120,7 +120,7 @@ kind: "package-reference" - [dsh-session-query](../session-query/README.zh.md)——服务定义:本后端继承的精确读取、过滤与追踪。 - [dsh-tool-session-query](../tool-session-query/README.zh.md)——调用这些搜索方法的面向模型消费方。 - [SQLite FTS5 会话搜索](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md)——搜索语义、对账与 tokenizer 决策。 -- [SQLite 会话持久化](../../../packages/session/session-persistence-sqlite/README.zh.md)——兄弟持久化后端;切勿把本包的 `path` 指向其数据库。 +- [JSONL 会话持久化](../../session/session-persistence-jsonl/README.zh.md)——本可丢弃索引观察的权威 Session store;其 root 必须与本包的数据库路径分开。 ----- diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 458e2ee9af..9e65292c97 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -51,7 +51,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^" diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts index f38a4d53bb..5306221d0a 100644 --- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -10,7 +10,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine, * as queryModule from '@deepseek-ai/dsh-session-query-sqlite' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -32,12 +32,15 @@ async function temporaryPath(name: string): Promise { describe('dsh-session-query-sqlite real Loader path', () => { it('unwraps, mounts, and searches the real persistence backend', async () => { - const persistencePath = await temporaryPath('canonical.db') + const persistenceRoot = await temporaryPath('canonical') const searchPath = await temporaryPath('derived.db') const ctx = new Context() await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionStore) - const persistence = await ctx.plugin(SqliteSessionPersistence, { path: persistencePath }) + const persistence = await ctx.plugin(JsonlSessionPersistence, { + root: persistenceRoot, + compression: 'none', + }) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(queryModule) as Parameters[0] diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index fb0bfa0ecb..248151580d 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -10,7 +10,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' -import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine, { SESSION_QUERY_SQLITE_SCHEMA_VERSION, } from '@deepseek-ai/dsh-session-query-sqlite' @@ -1774,21 +1774,24 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await persistence.dispose() }) - it('combines the real SQLite persistence backend with the real search service keylessly', async () => { - const persistencePath = await temporaryPath('canonical.db') + it('combines the real JSONL persistence backend with the real SQLite search service keylessly', async () => { + const persistenceRoot = await temporaryPath('canonical') const searchPath = await temporaryPath('derived.db') const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - const persistence = await ctx.plugin(SqliteSessionPersistence, { path: persistencePath }) + const persistence = await ctx.plugin(JsonlSessionPersistence, { + root: persistenceRoot, + compression: 'none', + }) const search = await ctx.plugin(SqliteSessionQueryEngine, { path: searchPath }) const meta = header('real', 10, { cwd: '/work' }) await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(meta.id, messageEvents('real SQLite needle')) + await ctx.sessionPersistence.append(meta.id, messageEvents('real search needle')) - await expect(ctx.sessionQuery.searchSessions({ query: 'SQLite needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'search needle' })) .resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] }) - await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'SQLite needle' })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'search needle' })) .resolves.toMatchObject({ session: meta, items: [{ sessionId: meta.id, seq: 0 }] }) await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) @@ -1797,16 +1800,19 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await persistence.dispose() }) - it('reconciles colliding local revisions when a derived index reopens against another SQLite store', async () => { - const persistencePathA = await temporaryPath('canonical-a.db') - const persistencePathB = await temporaryPath('canonical-b.db') + it('reconciles colliding local revisions when a derived index reopens against another JSONL store', async () => { + const persistenceRootA = await temporaryPath('canonical-a') + const persistenceRootB = await temporaryPath('canonical-b') const searchPath = await temporaryPath('derived-collision.db') const shared = header('same-id', 10) const first = new Context() await first.plugin(SessionStore) await first.plugin(SessionProjectionRegistry) - const persistenceA = await first.plugin(SqliteSessionPersistence, { path: persistencePathA }) + const persistenceA = await first.plugin(JsonlSessionPersistence, { + root: persistenceRootA, + compression: 'none', + }) await first.sessionPersistence.create(shared) await first.sessionPersistence.append(shared.id, messageEvents('alpha source')) const inspectA = vi.spyOn(first.sessionPersistence, 'inspect') @@ -1820,7 +1826,10 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const reopened = new Context() await reopened.plugin(SessionStore) await reopened.plugin(SessionProjectionRegistry) - const persistenceAAgain = await reopened.plugin(SqliteSessionPersistence, { path: persistencePathA }) + const persistenceAAgain = await reopened.plugin(JsonlSessionPersistence, { + root: persistenceRootA, + compression: 'none', + }) const reopenedInspect = vi.spyOn(reopened.sessionPersistence, 'inspect') const searchAAgain = await reopened.plugin(SqliteSessionQueryEngine, { path: searchPath }) await expect(reopened.sessionQuery.searchSessions({ query: 'alpha' })) @@ -1832,7 +1841,10 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const second = new Context() await second.plugin(SessionStore) await second.plugin(SessionProjectionRegistry) - const persistenceB = await second.plugin(SqliteSessionPersistence, { path: persistencePathB }) + const persistenceB = await second.plugin(JsonlSessionPersistence, { + root: persistenceRootB, + compression: 'none', + }) await second.sessionPersistence.create(shared) await second.sessionPersistence.append(shared.id, messageEvents('bravo source')) const inspectB = vi.spyOn(second.sessionPersistence, 'inspect') diff --git a/packages/session/README.i18n.yaml b/packages/session/README.i18n.yaml index 40f7efe568..6a8abb968a 100644 --- a/packages/session/README.i18n.yaml +++ b/packages/session/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/README.md -README.md: 6a5fd2640aa941c9d9344bc23d6bfbf0b0d970bd -README.zh.md: 6a0df7ee2bce59a3e61763cad3076018b3e222d1 +README.md: 63cc118decffaec1073c75d9d8c5967f866016fa +README.zh.md: f24883518d779dd4cd069e828d4d6ba01d8caa07 diff --git a/packages/session/README.md b/packages/session/README.md index 6a5fd2640a..63cc118dec 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The session group makes an agent's conversation durable and reusable outside the live loop: the persistence seam stores the event log and restores it on resume, the checkpoint policy keeps requests, tool side effects, and completed steps durable before the next action, projections serve whole log-derived values to client carriers, titles name each session from its content, and telemetry reports session activity outbound. Pick a persistence backend first — JSONL is the shipped default, SQLite an opt-in single-database backend — then add the checkpoint policy and any projection, title, or telemetry packages the deployment needs. This page maps the group; every package README owns its contract, and `session-query/` is a sibling group whose read/tool surface consumes persistence independently. +The session group makes an agent's conversation durable and reusable outside the live loop: the persistence seam stores the event log and restores it on resume, the checkpoint policy keeps requests, tool side effects, and completed steps durable before the next action, projections serve whole log-derived values to client carriers, titles name each session from its content, and telemetry reports session activity outbound. Mount the shipped JSONL persistence provider first, then add the checkpoint policy and any projection, title, or telemetry packages the deployment needs. This page maps the group; every package README owns its contract, and `session-query/` is a sibling group whose read/tool surface consumes persistence independently. ## Table of Contents @@ -30,7 +30,6 @@ The group splits into four families: durable storage (persistence seam, backends |---|---|---| | [`session-persistence/`](session-persistence/README.md) | Defines the durable session-storage service and the shared write coordination every backend composes | `ctx.sessionPersistence` | | [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | Shipped backend: one append-only JSONL log per session, optionally Zstandard-compressed | registers on `ctx.sessionPersistence` | -| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | Opt-in backend: every session's log in one SQLite database with packed physical rows | registers on `ctx.sessionPersistence` | | [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | Makes model requests, top-level tool side effects, and completed steps durable before the next action | wraps `ctx.llm` and `ctx.tools` | | [`session-log-deepseek/`](session-log-deepseek/README.md) | Uploads the incremental canonical log as optional official DeepSeek request metadata | contributes `dsh_session_log` | diff --git a/packages/session/README.zh.md b/packages/session/README.zh.md index 6a0df7ee2b..f24883518d 100644 --- a/packages/session/README.zh.md +++ b/packages/session/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -session 组让 agent(智能体)的对话在实时 loop 之外持久可复用:持久化 seam 存储事件日志并在恢复时还原,检查点策略让请求、工具副作用与已完成步骤在下一步动作前持久化,投影向客户端载体提供日志派生的完整值,标题根据会话内容为其命名,遥测则向外上报会话活动。先选持久化后端——JSONL 是随产品交付的默认项,SQLite 是可选启用、单库的后端——再按部署需要挂载检查点策略以及投影、标题或遥测包。本页是组的映射;每个包 README 负责各自的约定,`session-query/` 是同级独立组,其读取/工具接口独立消费持久化。 +session 组让 agent(智能体)的对话在实时 loop 之外持久可复用:持久化 seam 存储事件日志并在恢复时还原,检查点策略让请求、工具副作用与已完成步骤在下一步动作前持久化,投影向客户端载体提供日志派生的完整值,标题根据会话内容为其命名,遥测则向外上报会话活动。先挂载随产品交付的 JSONL 持久化 provider,再按部署需要挂载检查点策略以及投影、标题或遥测包。本页是组的映射;每个包 README 负责各自的约定,`session-query/` 是同级独立组,其读取/工具接口独立消费持久化。 ## 目录 @@ -30,7 +30,6 @@ session 组让 agent(智能体)的对话在实时 loop 之外持久可复用 |---|---|---| | [`session-persistence/`](session-persistence/README.zh.md) | 定义持久会话存储服务,以及每个后端组合的共享写入协调机制 | `ctx.sessionPersistence` | | [`session-persistence-jsonl/`](session-persistence-jsonl/README.zh.md) | 随产品交付的后端:每会话一份仅追加 JSONL 日志,可选 Zstandard 压缩 | 注册到 `ctx.sessionPersistence` | -| [`session-persistence-sqlite/`](session-persistence-sqlite/README.zh.md) | 可选后端:所有会话日志存入一个带物理打包行的 SQLite 数据库 | 注册到 `ctx.sessionPersistence` | | [`session-checkpoint-policy/`](session-checkpoint-policy/README.zh.md) | 让模型请求、顶层工具副作用与已完成步骤在下一步动作前持久化 | 包装 `ctx.llm` 与 `ctx.tools` | | [`session-log-deepseek/`](session-log-deepseek/README.zh.md) | 把增量规范日志作为可选的官方 DeepSeek 请求元数据上传 | 贡献 `dsh_session_log` | diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index e4479686f6..faf26c5e13 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 69fb2901d783c327878cd37570aec730a3ca0841 -README.zh.md: 188982028374d4ca11a5a658acabce5fe5df9930 +README.md: 4d815cb3dac33463adfd67a8355bdfac5dfbfa27 +README.zh.md: 1ef84df9b6b2c4d36788c4bd30498d615d57513d diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 69fb2901d7..4d815cb3da 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -29,7 +29,7 @@ Mount this backend when a composition needs durable sessions backed by per-sessi ### When to choose it -Choose this backend when consumers benefit from one artifact per session — navigation, external tooling, or a raw line-readable log. Choose [SQLite](../session-persistence-sqlite/README.md) when a single queryable database fits the deployment instead. The backend keeps sessions under a deployment-controlled root: project-local, shared, temporary, or centralized. +Choose this backend when consumers benefit from one artifact per session — navigation, external tooling, or a raw line-readable log. It is the sole first-party Session-persistence provider. The backend keeps sessions under a deployment-controlled root: project-local, shared, temporary, or centralized. ### Minimal configuration @@ -113,7 +113,6 @@ Read these pages when the package-level contract is not enough. They move from t - [Session persistence subsystem](../../../docs/subsystems/persistence.md) — backend-neutral service semantics and provider relationships. - [Session persistence seam](../session-persistence/README.md) — the service contract this backend implements. -- [SQLite persistence backend](../session-persistence-sqlite/README.md) — the opt-in single-database alternative. - [Project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) — the layout tradeoff behind project and session directories. - [Zstandard JSONL session logs](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md) — the checksummed-frame encoding rationale. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 1889820283..1ef84df9b6 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -当消费方受益于每会话一份产物——导航、外部工具或可逐行读取的原始日志——时选择此后端。当单一可查询数据库更适合部署时,选择 [SQLite](../session-persistence-sqlite/README.zh.md)。后端把会话保存在部署控制的根下:项目本地、共享、临时或集中式。 +当消费方受益于每会话一份产物——导航、外部工具或可逐行读取的原始日志——时选择此后端。它是随产品交付的唯一 Session 持久化 provider。后端把会话保存在部署控制的根下:项目本地、共享、临时或集中式。 ### 最小配置 @@ -113,7 +113,6 @@ kind: "package-reference" - [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——后端无关的服务语义与提供方关系。 - [会话持久化 seam](../session-persistence/README.zh.md)——本后端实现的服务约定。 -- [SQLite 持久化后端](../session-persistence-sqlite/README.zh.md)——可选启用的单数据库替代方案。 - [项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md)——项目与会话目录布局背后的取舍。 - [Zstandard JSONL 会话日志](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md)——带校验和帧编码的理由。 diff --git a/packages/session/session-persistence-sqlite/README.i18n.yaml b/packages/session/session-persistence-sqlite/README.i18n.yaml deleted file mode 100644 index ed423d93f6..0000000000 --- a/packages/session/session-persistence-sqlite/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/session/session-persistence-sqlite/README.md -README.md: ef3aea6ceeebafa228c94a5821cec576eca2c7dd -README.zh.md: d3652eeef934f077b91769ab296ba26187935ac2 diff --git a/packages/session/session-persistence-sqlite/README.md b/packages/session/session-persistence-sqlite/README.md deleted file mode 100644 index ef3aea6cee..0000000000 --- a/packages/session/session-persistence-sqlite/README.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -description: "SQLite session persistence for deployments and maintainers choosing, configuring, or debugging the opt-in packed-row backend." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-session-persistence-sqlite - -English | [中文](README.zh.md) - -## Summary - -`dsh-session-persistence-sqlite` keeps every session's durable history in a single SQLite database: sessions survive restarts, and the deployment's whole history becomes one queryable file you can back up, inspect with SQL, and analyze — instead of one artifact per session. Choosing it changes nothing for the agent loop, the model, or replay, because it serves the same logical `SessionEvent` stream as the JSONL backend; packing, compression, and recovery are storage-internal details. Choose it when a single queryable database fits the deployment; no shipped composition enables it by default. It is a pre-release provider: it rejects database files it does not own instead of migrating them, and its synchronous Node SQLite driver blocks the JavaScript thread during reads and writes. Setup, sizing, and migration guidance come first; the implementation internals live in a collapsible developer section below. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount this provider when a composition needs durable sessions backed by SQLite and accepts a process-local, synchronous database driver. The common path is explicit: load the session service, mount the provider, and give it a database path. - -### When to choose it - -Choose this backend when a local deployment benefits from one queryable database instead of many per-session files. Choose the JSONL backend when consumers need a per-session artifact: this provider returns `undefined` from `locate(meta)`, supports no raw artifacts, and exposes no per-session file. Account for synchronous SQLite and compression work before adopting it for a high-concurrency service. - -### Disk footprint and performance - -The packed layout exchanges some SQLite-local latency for a smaller queryable database. The available 501-session comparison measures schema 19 rather than schema 20; that layout used 233.18 MB against the SQLite comparison baseline's 438.31 MB and compressed JSONL's 148.15 MB. Full writes were about 2.3× faster than JSONL and suffix reads remained much faster; complete reads and forks were slightly slower than JSONL. The [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md) owns the method, complete metrics, and accepted trade-offs. - -The disk cost buys a structured, queryable view of session history: external tooling can analyze `sessions` and `events` with SQL, decoding physical rows the way this provider does — the groundwork for features such as built-in full-text search. - -### Minimal configuration - -Load the session service first, then mount the provider with a database path. Use an absolute path when the location must not depend on the process working directory; relative paths resolve from that directory. `:memory:` is valid for an in-process database whose contents disappear with the process. - -```yaml -- name: '@deepseek-ai/dsh-session' -- name: '@deepseek-ai/dsh-session-persistence-sqlite' - config: - path: /absolute/path/to/sessions.db -``` - -| Field | Default | Meaning | -|---|---|---| -| `path` | required | SQLite database path, or `:memory:` | -| `journalMode` | `wal` | Durable journal mode: `wal`, `delete`, `truncate`, or `persist` | -| `busyTimeoutMs` | `5,000` | Maximum synchronous wait for another connection's lock | -| `preparedSessionCacheSize` | `5` | Cold session preparations retained for resume reuse | -| `writeBatchMaxDelayMs` | `200` | Fixed live-event coalescing window, in milliseconds | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-persistence-sqlite) is the exhaustive source for every accepted field and its JSDoc. - -### Migrating existing JSONL sessions - -There is no built-in migration tool: the JSONL and SQLite stores are separate, and nothing copies sessions between them. Because both backends implement the same logical contract, you can carry a session over with the persistence API — read on the JSONL side, write on the SQLite side. One backend serves `ctx.sessionPersistence` per composition, so run the two halves as separate runs or processes: - -```text -// Export — run against the JSONL composition, per session id: -const { meta, events } = await ctx.sessionPersistence.load(id) - -// Import — run against the SQLite composition, per exported session: -await ctx.sessionPersistence.create(meta) -await ctx.sessionPersistence.append(id, events) -``` - -`list()` enumerates the materialized sessions to export. The exported events keep contiguous `seq` values starting at 0, so `append` accepts them as one ordered batch into a fresh session; `load` also commits any needed cold repair on the source first, so the exported log is balanced. Treat the migration as a one-time cutover: verify that the imported sessions load, then switch the composition to the SQLite provider. Continuing to write through the old JSONL root afterwards would let the two stores diverge. - -### Startup and safe operation - -A fresh database initializes directly at schema version 20 with 64 KiB pages. Existing files are never retuned: databases with any other version, a foreign application identity, an unversioned non-pristine schema, or unexpected schema objects are rejected before any data is exposed or changed. This pre-release provider ships no migration. Every statement and fixed pragma comes from packaged `.sql` resources in `resources/sql/`, and runtime values are bound as SQLite parameters, so package code never assembles query text. - -Each connection disables SQLite trusted schemas and memory-mapped I/O, verifies the requested journal mode, and pins `synchronous=FULL` so a resolved append remains durable across an OS crash or power loss. On POSIX, the database parent directory and file must belong to the current user, the parent must not be group/world-writable, and the file must grant no group or world permissions; Windows additionally rejects symbolic links and non-regular files, while ACL restriction stays the deployment's job. Path and ownership failures reject plugin initialization; Node's SQLite driver loads lazily on the first persistence operation. Ordinary `create` stays lazy until the first append, while `ensureMaterialized` writes a session metadata row with no event rows. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -This section explains the design decisions behind the provider and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package). - -### Design philosophy - -The provider is built on one separation and three commitments: - -- **Logical contract, physical format.** Callers always read and write ordinary `SessionEvent[]`; how rows are packed, stored, and compressed is private to this package. -- **The schema owns the format.** Schema 20 is a frozen physical contract: a database at another version, with a foreign identity, or with unexpected schema objects is rejected, never migrated. Changing the schema, row codec, page size, or dictionary bytes requires a new schema version. -- **Durability is the default.** Appends run in immediate transactions with `synchronous=FULL`, and a resolved `append()` means the batch is durable. Normal appends are insert-only: earlier event rows are never rewritten. -- **Efficiency within strict bounds.** Packing and compression keep the database small, but every limit is a hard format bound — at most 1,024 events and 1 MiB of payload per packed row. - -The packed-row foundation lives in the [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md); the current compression, key, and page-size choices live in the [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md). - -### Source map - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, service registration, coordinator wiring | -| [`src/store.ts`](src/store.ts) | Storage primitives: transactional append, reads, repair, path and ownership validation | -| [`src/schema.ts`](src/schema.ts) | Schema ownership: version gate, connection hardening, row decoding | -| [`src/codec.ts`](src/codec.ts) | Packing: which `assistant/chunk` runs become packed rows, size bounds | -| [`src/compression.ts`](src/compression.ts) | Physical encoding: dictionary compression, sequence lists, row scan and decode | -| [`src/sql.ts`](src/sql.ts) + [`resources/sql/`](resources/sql/) | Every SQL statement as a packaged, closed-name resource | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; packing is observable only by database round-trip) | - -### Database schema - -A fresh database contains three strict tables, defined in [`resources/sql/schema.sql`](resources/sql/schema.sql): - -| Table | Purpose | -|---|---| -| `persistence_state` | One-row store identity | -| `sessions` | One row per session: header fields plus a monotonic revision | -| `events` | Physical event rows: one logical event, or one packed run | - -The exact columns live in [`resources/sql/schema.sql`](resources/sql/schema.sql). `sessions.id` is an internal integer key while `sessions.session_key` retains the public session id. `events.data` holds text or an independently decodable Zstandard blob; compression uses the schema-owned shared dictionary only when the result is smaller. `events.source_event_seqs` uses tagged delta or run encoding. `events.ignorable` is `0` for a packed chunk run, `1` for a scalar logical event carrying `ignorable: true`, and `NULL` for every other scalar event, so a scalar event whose type matches a physical chunk tag remains unambiguous. Packed rows reuse the `seq` of their first logical event, so under the composite `(session_id, seq)` primary key physical order is logical order. - -### Write path - -Each append takes an immediate transaction, re-validates schema ownership, checks the stored tail so a stale writer cannot extend the log, packs only the new batch, inserts its rows, bumps the session revision once, and commits. The coordinator coalesces live events for the configured window, so high-frequency streams produce larger packed runs while physical writes stay proportional to newly durable batches. - -### Read and recovery - -A full read locates the last valid `turn/end` in a reverse pass, then decodes each physical row into its logical events in forward order, rejecting gaps or malformed rows in the committed prefix. A malformed final row is treated as a torn tail: a mutating load may delete it under the write lock and close the log with synthetic closers. Suffix reads (`readFrom`) examine only the physical span that may contain the requested sequence, so they never parse unrelated earlier rows. - -
- ------ - - -## Further Exploration - -Read these pages when the package-level contract is not enough. They move from the shared persistence model to exhaustive configuration and the decision evidence behind the physical layout. - -- [Session persistence subsystem](../../../docs/subsystems/persistence.md) — backend-neutral service semantics and provider relationships. -- [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages. -- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-persistence-sqlite) — every accepted config field and its source declaration. -- [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) — rationale, alternatives, and measurements behind the packed layout. -- [Persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md) — the 501-session benchmark and current storage trade-offs. - ------ - - -## Model Experience - -### Resumed conversation history - -#### What the model sees - -Nothing specific to SQLite. Resume restores the same logical events and derived messages as the JSONL backend; physical packed tags never reach prompts, tools, replay, or live `session/event` delivery. - -#### Token effect - -Zero live-request tokens. Resume pays only for the retained logical history and the current request envelope. - -#### KV Cache effect - -Physical packing does not mutate request prefixes. Provider cache reuse depends on the reconstructed history, current envelope, and model route exactly as with other persistence backends. - -## Known Limitations and Deferred Work - - - - -These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general SQLite comparison or a task backlog. - -- **Pre-release design with no migration** — schema 20 is an interim SQLite-only design; neither schema stability nor migration support is guaranteed. -- **Packing depends on batch boundaries** — a compatible run split by the write-behind window or an explicit flush stays split across physical rows; this avoids rewriting prior rows at the cost of a timing-dependent packing ratio. -- **Synchronous SQLite and compression** — Node's SQLite driver and Zstandard calls block the JavaScript thread. -- **Busy waits block the event loop** — SQLite waits inside synchronous calls; a competing writer can stall the thread for up to the configured `busyTimeoutMs`. -- **External SQL readers must decode physical rows** — a packed `events.type` (`text-chunks`, `reasoning-chunks`, `tool-call-chunks`) is not a logical event type; supported consumers read through this provider. -- **No deletion or historical compaction** — normal appends are insert-only and nothing removes old rows. - - -### Dev Note - -
-Working context for maintainers — click to expand - -The 501-session corpus contains private session data and is not committed. Its aggregate method, complete results, and rejected candidates are recorded in the [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md); the packaged dictionary's hash-pinned resource is part of the schema-20 source of truth. - -
diff --git a/packages/session/session-persistence-sqlite/README.zh.md b/packages/session/session-persistence-sqlite/README.zh.md deleted file mode 100644 index d3652eeef9..0000000000 --- a/packages/session/session-persistence-sqlite/README.zh.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -description: "面向部署方与维护者的 SQLite 会话持久化说明,用于选择、配置或排查这个可选启用的分片行后端。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-session-persistence-sqlite - -[English](README.md) | 中文 - -## 概述 - -`dsh-session-persistence-sqlite` 是 `SessionPersistence` 服务的可选存储后端:它不按会话各留一个文件,而是把所有会话的持久事件日志统一保存在同一个 SQLite 数据库中。它与 JSONL 后端提供完全相同的逻辑 `SessionEvent` 流,因此选择它不会改变 agent loop、模型或回放的任何行为——打包、压缩与恢复都是存储内部细节。仅当单一可查询数据库适合你的部署时才选择它;任何已发布的组合都不会默认启用它。这是预发布提供方:它拒绝而非迁移不属于自己的数据库文件,而且其同步 Node SQLite 驱动会在读写时阻塞 JavaScript 线程。设置、容量评估与迁移指引在前;实现内部细节放在下方可折叠的开发者章节中。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -当组合需要由 SQLite 支撑的持久会话、且可以接受进程本地的同步数据库驱动时,挂载此提供方。常用路径是显式的:加载会话服务、挂载提供方,然后给出数据库路径。 - -### 何时选择 - -当本地部署受益于一个可查询数据库、而非每会话一个独立文件时,选择此后端。当消费方需要按会话产物时,请选择 JSONL 后端:本提供方的 `locate(meta)` 返回 `undefined`,不支持原始产物,也不暴露任何单会话文件。高并发服务在采用前还应考虑同步 SQLite 与压缩工作。 - -### 磁盘占用与性能 - -打包布局以部分 SQLite 本地延迟换取更小的可查询数据库。现有的 501 会话对比测量的是 schema 19,而不是 schema 20;该布局占用 233.18 MB,SQLite 对比基线占用 438.31 MB,压缩 JSONL 占用 148.15 MB。全量写入约比 JSONL 快 2.3 倍,后缀读取也仍快得多;完整读取与 fork 则略慢于 JSONL。方法、完整指标与取舍由[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)记录。 - -磁盘成本换来的是结构化、可查询的会话历史视图:外部工具可以用 SQL 分析 `sessions` 与 `events`,按本提供方的方式解码物理行——这是内置全文搜索等功能的天然基础。 - -### 最小配置 - -先加载会话服务,再用数据库路径挂载提供方。除非位置允许依赖进程工作目录(相对路径从该目录解析),否则请使用绝对路径。`:memory:` 可用于进程内数据库,其内容随进程消失。 - -```yaml -- name: '@deepseek-ai/dsh-session' -- name: '@deepseek-ai/dsh-session-persistence-sqlite' - config: - path: /absolute/path/to/sessions.db -``` - -| 字段 | 默认值 | 含义 | -|---|---|---| -| `path` | 必填 | SQLite 数据库路径,或 `:memory:` | -| `journalMode` | `wal` | 持久 journal mode:`wal`、`delete`、`truncate` 或 `persist` | -| `busyTimeoutMs` | `5,000` | 等待另一连接锁的最长同步时间 | -| `preparedSessionCacheSize` | `5` | 为恢复复用而保留的冷会话准备结果数量 | -| `writeBatchMaxDelayMs` | `200` | 实时事件的固定聚合窗口,单位为毫秒 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-persistence-sqlite)是每个受支持字段及其 JSDoc 的穷尽式真源。 - -### 迁移现有 JSONL 会话 - -没有内置迁移工具:JSONL 与 SQLite 是两个独立存储,没有任何机制在两者之间复制会话。由于两个后端实现相同的逻辑约定,你可以直接用持久化 API 迁移会话——在 JSONL 侧读取,在 SQLite 侧写入。每个组合只有一个后端服务于 `ctx.sessionPersistence`,因此两步请分两次运行或分两个进程执行: - -```text -// Export — run against the JSONL composition, per session id: -const { meta, events } = await ctx.sessionPersistence.load(id) - -// Import — run against the SQLite composition, per exported session: -await ctx.sessionPersistence.create(meta) -await ctx.sessionPersistence.append(id, events) -``` - -用 `list()` 枚举已物化的会话。导出的事件 `seq` 从 0 开始连续,因此 `append` 可以一次性按序写入新会话;`load` 会先在源端提交所需的冷修复,导出的日志因此是平衡的。请把迁移当作一次性切换:确认导入的会话可以加载后,再把组合切换到 SQLite 提供方;之后继续写旧 JSONL 根目录会让两个存储分叉。 - -### 启动与安全运行 - -全新数据库直接初始化为 schema 版本 20,并使用 64 KiB page。已有文件不会被重新调参:任何其他版本、外来应用标识、无版本的非全新 schema 或意外 schema 对象,都会在任何数据暴露或变更之前被拒绝。本预发布提供方不提供迁移。每条语句和固定 pragma 都来自 `resources/sql/` 下打包的 `.sql` 资源,运行时的值以 SQLite 参数绑定,包代码从不拼装查询文本。 - -每个连接都会禁用 SQLite trusted schema 与内存映射 I/O、验证所请求的 journal mode,并固定 `synchronous=FULL`,保证成功返回的追加在操作系统崩溃或断电后依然持久。在 POSIX 上,数据库父目录和文件必须属于当前用户,父目录不得允许组或其他用户写入,文件也不得授予任何组或其他用户权限;Windows 还会拒绝符号链接和非普通文件,ACL 限制则由部署方负责。路径与所有权失败会拒绝插件初始化;Node 的 SQLite 驱动在首次持久化操作时才延迟加载。普通 `create` 会保持惰性直到首次 append,而 `ensureMaterialized` 会写入一条没有事件行的会话元数据记录。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -本节解释提供方背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。 - -### 设计理念 - -本提供方建立在一个分离与三项承诺之上: - -- **逻辑约定,物理格式。** 调用方始终读写普通的 `SessionEvent[]`;行如何打包、存储与压缩是本包私有的存储行为。 -- **schema 拥有格式。** Schema 20 是冻结的物理约定:任何其他版本、外来标识或意外 schema 对象的数据库都会被拒绝,绝不迁移。改变 schema、行 codec、page size 或字典字节都需要新的 schema 版本。 -- **持久性是默认值。** 追加在立即事务中以 `synchronous=FULL` 提交,成功返回的 `append()` 意味着该批次已持久。普通追加仅插入:更早的事件行永远不会被重写。 -- **在严格边界内追求效率。** 打包与压缩让数据库保持小巧,但每个上限都是硬性格式边界——每个打包行至多表示 1,024 个事件、1 MiB 载荷。 - -打包行基础由 [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)记录;当前压缩、键和 page-size 选择由[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)记录。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、服务注册、协调器接线 | -| [`src/store.ts`](src/store.ts) | 存储原语:事务追加、读取、修复、路径与所有权验证 | -| [`src/schema.ts`](src/schema.ts) | schema 归属:版本门禁、连接加固、行解码 | -| [`src/codec.ts`](src/codec.ts) | 打包:哪些 `assistant/chunk` 连续段成为打包行、大小上限 | -| [`src/compression.ts`](src/compression.ts) | 物理编码:字典压缩、序列列表、行扫描与解码 | -| [`src/sql.ts`](src/sql.ts) + [`resources/sql/`](resources/sql/) | 所有 SQL 语句均为打包的闭名资源 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;打包只能通过数据库往返观察) | - -### 数据库 schema - -全新数据库包含三张 STRICT 表,定义于 [`resources/sql/schema.sql`](resources/sql/schema.sql): - -| 表 | 用途 | -|---|---| -| `persistence_state` | 单行存储标识 | -| `sessions` | 每个会话一行:头部字段加单调递增的 revision | -| `events` | 物理事件行:一个逻辑事件,或一个打包连续段 | - -确切的列定义见 [`resources/sql/schema.sql`](resources/sql/schema.sql)。`sessions.id` 是内部整数键,`sessions.session_key` 保留公开会话 id。`events.data` 存放文本或可独立解码的 Zstandard blob;仅在结果更小时才使用 schema 自有的共享字典压缩。`events.source_event_seqs` 使用带 tag 的 delta 或 run 编码。打包分片连续段的 `events.ignorable` 为 `0`,带 `ignorable: true` 的标量逻辑事件为 `1`,其余标量事件为 `NULL`,因此类型与物理分片标签同名的标量事件仍然明确。打包行沿用其首个逻辑事件的 `seq`,因此在复合主键 `(session_id, seq)` 下,物理顺序就是逻辑顺序。 - -### 写入路径 - -每次追加都会开启立即事务、重新验证 schema 归属、检查已存尾部以防止陈旧写入方扩展日志、只打包新批次、插入对应行、递增一次会话 revision,然后提交。协调器按配置窗口聚合实时事件,因此高频流会产生更大的打包行,而物理写入量始终与新持久批次成正比。 - -### 读取与恢复 - -完整读取先反向定位最后一个有效 `turn/end`,再按正向顺序把每个物理行解码为其逻辑事件,并拒绝已提交前缀中的缺口或格式错误行。格式错误的最后一行被视为撕裂尾部:执行恢复的加载可以在写锁下删除它,并用合成闭合事件关闭日志。后缀读取(`readFrom`)只检查可能包含目标序列的物理跨度,因此永远不会解析无关的更早行。 - -
- ------ - - -## 进一步探索 - -当包级约定不够用时阅读以下页面。它们从共享持久化模型逐步进入穷尽式配置,以及物理布局背后的决策证据。 - -- [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——后端无关的服务语义与提供方关系。 -- [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。 -- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-persistence-sqlite)——每个受支持配置字段及其源声明。 -- [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)——打包布局背后的理由、备选方案与测量。 -- [持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)——501 会话基准与当前存储取舍。 - ------ - - -## 模型体验 - -### 恢复的对话历史 - -#### 模型看到什么 - -没有 SQLite 专有内容。恢复会还原与 JSONL 后端相同的逻辑事件和派生消息;物理打包标签永远不会进入提示词、工具、回放或实时 `session/event` 投递。 - -#### Token 影响 - -实时请求 token 为零。恢复只为保留的逻辑历史和当前请求信封消耗 token。 - -#### KV Cache 影响 - -物理打包不会改变请求前缀。提供方缓存复用取决于重建历史、当前信封与模型路由,与其他持久化后端完全相同。 - -## 已知限制与延期工作 - - - - -这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用 SQLite 对比或任务积压。 - -- **预发布设计,无迁移**——schema 20 是临时的 SQLite 专用设计;不保证 schema 稳定性或迁移支持。 -- **打包依赖批次边界**——被写后窗口或显式 flush 拆开的兼容连续段仍分属不同物理行;这避免了重写先前行,代价是打包比例依赖时序。 -- **同步 SQLite 与压缩**——Node 的 SQLite 驱动与 Zstandard 调用会阻塞 JavaScript 线程。 -- **忙等待阻塞事件循环**——SQLite 在同步调用内部等待;竞争写入方最长可让线程停顿配置的 `busyTimeoutMs`。 -- **外部 SQL 读取方必须解码物理行**——打包的 `events.type`(`text-chunks`、`reasoning-chunks`、`tool-call-chunks`)不是逻辑事件类型;受支持的消费方通过本提供方读取。 -- **没有删除或历史压缩**——普通追加仅插入,没有任何机制移除旧行。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -501 会话语料包含私有会话数据,因此不提交到仓库。汇总方法、完整结果与未采用候选记录在[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)中;带 hash 固定的打包字典资源是 schema 20 真源的一部分。 - -
diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json deleted file mode 100644 index 6cc3bc15a5..0000000000 --- a/packages/session/session-persistence-sqlite/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-session-persistence-sqlite", - "description": "SQLite durable session persistence with physical chunk-row packing", - "version": "0.1.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/session/session-persistence-sqlite" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "resources/zstd-dictionary.bin", - "resources/sql/**/*.sql", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^" - }, - "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "typescript": "^6.0.3" - } -} diff --git a/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql b/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql deleted file mode 100644 index 67edb1dd06..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql +++ /dev/null @@ -1 +0,0 @@ -BEGIN IMMEDIATE; diff --git a/packages/session/session-persistence-sqlite/resources/sql/begin.sql b/packages/session/session-persistence-sqlite/resources/sql/begin.sql deleted file mode 100644 index 1775571fa7..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/begin.sql +++ /dev/null @@ -1 +0,0 @@ -BEGIN; diff --git a/packages/session/session-persistence-sqlite/resources/sql/commit.sql b/packages/session/session-persistence-sqlite/resources/sql/commit.sql deleted file mode 100644 index 87ef767444..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/commit.sql +++ /dev/null @@ -1 +0,0 @@ -COMMIT; diff --git a/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql b/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql deleted file mode 100644 index ab5f81d89a..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql +++ /dev/null @@ -1,2 +0,0 @@ -DELETE FROM events -WHERE session_id = ? AND seq >= ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql b/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql deleted file mode 100644 index c8ccb1392a..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA foreign_keys = ON; diff --git a/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql b/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql deleted file mode 100644 index 92b4de310d..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql +++ /dev/null @@ -1,3 +0,0 @@ -INSERT INTO events - (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) -VALUES (?, ?, ?, ?, ?, ?, ?, ?); diff --git a/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql b/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql deleted file mode 100644 index f516014d93..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql +++ /dev/null @@ -1,2 +0,0 @@ -INSERT INTO persistence_state (singleton, store_id) -VALUES (1, ?); diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql deleted file mode 100644 index 0f4c37efca..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA journal_mode = DELETE; diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql deleted file mode 100644 index 02445b260b..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA journal_mode = PERSIST; diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql deleted file mode 100644 index d119c32bec..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA journal_mode = TRUNCATE; diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql deleted file mode 100644 index 2d30d8af9f..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA journal_mode = WAL; diff --git a/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql b/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql deleted file mode 100644 index 22bcd0ea34..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA mmap_size = 0; diff --git a/packages/session/session-persistence-sqlite/resources/sql/page-size.sql b/packages/session/session-persistence-sqlite/resources/sql/page-size.sql deleted file mode 100644 index 3d5845fd61..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/page-size.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA page_size = 65536; diff --git a/packages/session/session-persistence-sqlite/resources/sql/rollback.sql b/packages/session/session-persistence-sqlite/resources/sql/rollback.sql deleted file mode 100644 index 3b18e77376..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/rollback.sql +++ /dev/null @@ -1 +0,0 @@ -ROLLBACK; diff --git a/packages/session/session-persistence-sqlite/resources/sql/schema.sql b/packages/session/session-persistence-sqlite/resources/sql/schema.sql deleted file mode 100644 index fc4c999077..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/schema.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE TABLE persistence_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - store_id TEXT NOT NULL -) STRICT; - -CREATE TABLE sessions ( - id INTEGER PRIMARY KEY, - session_key TEXT NOT NULL UNIQUE, - version INTEGER NOT NULL, - created_at INTEGER NOT NULL, - cwd TEXT, - parent_session TEXT, - seed_length INTEGER, - origin TEXT, - delegation_depth INTEGER, - agent_preset TEXT, - incarnation TEXT NOT NULL, - revision INTEGER NOT NULL -) STRICT; - -CREATE TABLE events ( - session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - time INTEGER NOT NULL, - data ANY NOT NULL, - source_event_seqs ANY, - surface_op TEXT, - ignorable INTEGER CHECK (ignorable IS NULL OR ignorable IN (0, 1)), - PRIMARY KEY (session_id, seq) -) STRICT; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql b/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql deleted file mode 100644 index 3de4abbb88..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA application_id; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql b/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql deleted file mode 100644 index a5748dd974..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable -FROM events -WHERE session_id = ? AND seq >= ? -ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-events.sql b/packages/session/session-persistence-sqlite/resources/sql/select-events.sql deleted file mode 100644 index 76437f8eaf..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-events.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable -FROM events -WHERE session_id = ? -ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql b/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql deleted file mode 100644 index 58e55155f4..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA mmap_size; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql b/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql deleted file mode 100644 index 54a52180fc..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable -FROM events -WHERE session_id = ? AND seq >= ? AND seq < ? - AND type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks') - AND ignorable = 0 -ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql b/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql deleted file mode 100644 index 216925f35a..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT type, name, tbl_name, sql -FROM sqlite_schema -WHERE name NOT GLOB 'sqlite_*' -ORDER BY type, name; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-session-key.sql b/packages/session/session-persistence-sqlite/resources/sql/select-session-key.sql deleted file mode 100644 index a0d3a4688a..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-session-key.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT id -FROM sessions -WHERE session_key = ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-session.sql b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql deleted file mode 100644 index fdd2b1a6da..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-session.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT session_key AS id, version, created_at, cwd, parent_session, seed_length, origin, - delegation_depth, agent_preset, incarnation, revision -FROM sessions -WHERE session_key = ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql deleted file mode 100644 index 52d0a4b61f..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT session_key AS id, version, created_at, cwd, parent_session, seed_length, origin, - delegation_depth, agent_preset, incarnation, revision -FROM sessions; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql b/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql deleted file mode 100644 index 168e09cb80..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT store_id -FROM persistence_state -WHERE singleton = 1; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql b/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql deleted file mode 100644 index b41be01714..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA synchronous; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql b/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql deleted file mode 100644 index 2d958de7c3..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable -FROM events -WHERE session_id = ? -ORDER BY seq DESC -LIMIT ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql b/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql deleted file mode 100644 index d304b1f850..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA trusted_schema; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql b/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql deleted file mode 100644 index 60665bb66e..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT COUNT(*) AS count -FROM sqlite_schema -WHERE name NOT GLOB 'sqlite_*'; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql b/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql deleted file mode 100644 index 4edeca1a4d..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version; diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql b/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql deleted file mode 100644 index 617d10cab3..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA application_id = 1146308688; diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-user-version-20.sql b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-20.sql deleted file mode 100644 index 1882a18a39..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/set-user-version-20.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version = 20; diff --git a/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql b/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql deleted file mode 100644 index b0380b1197..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA synchronous = FULL; diff --git a/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql b/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql deleted file mode 100644 index 973c6d1def..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA trusted_schema = OFF; diff --git a/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql deleted file mode 100644 index 3af6c1c23f..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql +++ /dev/null @@ -1,3 +0,0 @@ -UPDATE sessions -SET revision = revision + 1 -WHERE session_key = ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql deleted file mode 100644 index 45b4a088f2..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql +++ /dev/null @@ -1,14 +0,0 @@ -INSERT INTO sessions - (session_key, version, created_at, cwd, parent_session, seed_length, origin, - delegation_depth, agent_preset, incarnation, revision) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) -ON CONFLICT(session_key) DO UPDATE SET - version = excluded.version, - created_at = excluded.created_at, - cwd = excluded.cwd, - parent_session = excluded.parent_session, - seed_length = excluded.seed_length, - origin = excluded.origin, - delegation_depth = excluded.delegation_depth, - agent_preset = excluded.agent_preset -RETURNING id; diff --git a/packages/session/session-persistence-sqlite/resources/zstd-dictionary.bin b/packages/session/session-persistence-sqlite/resources/zstd-dictionary.bin deleted file mode 100644 index 97fc36feaa..0000000000 --- a/packages/session/session-persistence-sqlite/resources/zstd-dictionary.bin +++ /dev/null @@ -1,420 +0,0 @@ -{"turn":1} -{"policy":"never"} -{"turn":1,"step":2} -{"turn":1,"step":4} -{"turn":1,"step":6} -{"mode":"read-only"} -{"mode":"workspace-write"} -{"mode":"danger-full-access"} -{"compactionId":"{{id:1}}","turn":1} -{"policy":"never","source":"delegation"} -{"turn":1,"reason":{"kind":"max-tokens"}} -{"runId":"{{workflow:1}}","name":"snapshot-flow"} -{"mode":"danger-full-access","source":"delegation"} -{"retryId":"{{retry:1}}","turn":1,"step":1,"retry":1} -{"runId":"{{workflow:1}}","name":"advanced-acp-snapshot"} -{"todos":[{"content":"keep going","status":"in_progress"}]} -{"runId":"{{workflow:1}}","name":"advanced-headless-snapshot"} -{"target":"next-turn","start":0,"removedCount":1,"inserted":[]} -{"turn":1,"step":2,"index":1,"dt":[0,0],"texts":["B","OTH","_OK"]} -{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]} -{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"} -{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}} -{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}} -{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}} -{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}} -{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}} -{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"} -{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}} -{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}} -{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}} -{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}} -{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}} -{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}} -{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}} -{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE."}} -{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}} -{"turn":1,"step":3,"index":1,"dt":[0,0,1],"texts":["PAR","ENT","_D","ONE"]} -{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"max-tokens"}}} -{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}} -{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}} -{"version":3,"mode":"one-shot","provider":"spawn","label":"Start depth one"} -{"version":3,"mode":"one-shot","provider":"spawn","label":"Truncated child"} -{"title":"Do NOT use the read","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ROOT_DONE"}} -{"title":"Use the bash tool to","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}} -{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}} -{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}} -{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}} -{"turn":1,"step":5,"callId":"pty-list","name":"terminal_list","arguments":"{}"} -{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}} -{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"} -{"title":"Call the bash tool to","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Use the read tool (NOT","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Run true once with bash","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Use the write tool (NOT","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"point":"Stop","dialect":"claude-code","handlerId":"claude-code:Stop:2"} -{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000} -{"title":"You are one fresh worker","messageSeqs":[8],"source":{"kind":"fallback"}} -{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}} -{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"} -{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}} -{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}} -{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}} -{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}} -{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}} -{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}} -{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}} -{"title":"Exercise the six PTY tools","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Reply with the single word","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"What is my favorite color?","messageSeqs":[9],"source":{"kind":"fallback"}} -{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"PARENT_COMPLETED"}} -{"title":"Call the run_code tool (NOT","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Use the subagent tool TWICE","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0],"texts":["G","LOB","_S","AM","PL","ED"]} -{"title":"A file named greeting.txt in","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Perform these exact steps in","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Use read_image on red.png in","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}} -{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}} -{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}} -{"title":"This prompt first receives an","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Use the subagent tool exactly","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}} -{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}} -{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}} -{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}} -{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}} -{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}} -{"title":"Run this advanced flow exactly","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Use the web_fetch tool exactly","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":12}}} -{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_EXIT_PRESERVED"}} -{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}} -{"title":"This prompt triggers a recorded","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Attempt one subagent call beyond","messageSeqs":[8],"source":{"kind":"fallback"}} -{"title":"Observe the ACP diagnostic twice","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Using ONE run_code program: call","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CORDIS_INSPECT_JSDOC_OK"}} -{"title":"Call ask_user_question once to ask","messageSeqs":[8],"source":{"kind":"fallback"}} -{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"RUNNER_FAILURES_SURFACED"}} -{"title":"Inspect the configured child model,","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}} -{"title":"Delegate one foreground subagent. Its","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WIDE"}}} -{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0],"texts":["CODE","_","ONE","+","CODE","_T","WO"]} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}} -{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}} -{"title":"Establish a durable compaction premise","messageSeqs":[7],"source":{"kind":"fallback"}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}} -{"title":"Delegate through two child generations.","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Load the editing-cordis-compositions ski","messageSeqs":[7],"source":{"kind":"fallback"}} -{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}} -{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}} -{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}} -{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"} -{"turn":1,"step":1,"callId":"fs-edit-read","name":"read","arguments":"{\"file_path\":\"config.txt\"}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done."}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"partial one"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}} -{"turn":1,"step":4,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}} -{"turn":1,"reason":{"kind":"error","error":{"message":"simulated provider error (HTTP 401)","code":"AUTH"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_COMPLETED"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}} -{"turn":1,"point":"PreToolUse","dialect":"claude-code","handlerId":"claude-code:PreToolUse:1","matcher":"bash"} -{"turn":1,"step":3,"callId":"fs-delete-read-after","name":"read","arguments":"{\"file_path\":\"deleted.txt\"}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}} -{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}} -{"turn":1,"point":"PostToolUse","dialect":"claude-code","handlerId":"claude-code:PostToolUse:2","matcher":"bash"} -{"turn":1,"step":4,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"} -{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"} -{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":2.7725000000000364} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}}} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}}} -{"turn":1,"point":"Stop","handlerId":"claude-code:Stop:2","decision":"pass","exitCode":0,"durationMs":2.744416000000001} -{"turn":1,"step":1,"callId":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"} -{"turn":1,"step":3,"callId":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"} -{"turn":1,"step":2,"callId":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"} -{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"} -{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"terminal_list","argumentsDelta":"{}"}} -{"turn":1,"step":2,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}} -{"turn":1,"point":"PreToolUse","handlerId":"claude-code:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":4.231499999999869} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2880,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}}} -{"turn":1,"step":2,"callId":"fs-overwrite-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"replaced\"}"} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}}} -{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}}} -{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":50,"cacheReadTokens":6272,"reasoningTokens":42}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":170,"outputTokens":28,"cacheReadTokens":2816,"reasoningTokens":25}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":41,"cacheReadTokens":2816,"reasoningTokens":27}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":51,"cacheReadTokens":2816,"reasoningTokens":30}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":214,"outputTokens":20,"cacheReadTokens":2816,"reasoningTokens":17}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":292,"outputTokens":30,"cacheReadTokens":2816,"reasoningTokens":27}}} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":50,"outputTokens":35,"cacheReadTokens":10496,"reasoningTokens":31}}} -{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}}} -{"runId":"{{workflow:1}}","seq":1,"label":"Reply with exactly the word WF_CHILD_OK and not…","phase":"Run","childId":"{{session:2}}"} -{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}}} -{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":4.196374999999989} -{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"} -{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"} -{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}}} -{"turn":1,"point":"UserPromptSubmit","handlerId":"claude-code:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":4.07300000000032} -{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}} -{"turn":1,"step":1,"callId":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"} -{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":3,"callId":"bounded-task-kill","name":"job_kill","arguments":"{\"job_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-edit-read","name":"read","argumentsDelta":"{\"file_path\":\"config.txt\"}"}} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-bounded-read","name":"read","argumentsDelta":"{\"file_path\":\"data.txt\"}"}} -{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"terminal_close","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}} -{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:8}}"} -{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:10}}"} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-overwrite-read","name":"read","argumentsDelta":"{\"file_path\":\"data.txt\"}"}} -{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"} -{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"} -{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:17}}"} -{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"} -{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_query_spill","name":"session_event_read","argumentsDelta":"{\"seq\":10}"}} -{"turn":1,"step":3,"callId":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"} -{"turn":1,"step":2,"callId":"fs-edit-replace","name":"edit","arguments":"{\"file_path\":\"config.txt\",\"old_string\":\"DEBUG\",\"new_string\":\"RELEASE\"}"} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."}}} -{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:2","name":"read_image","arguments":{"file_path":"red.png"}} -{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"} -{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"} -{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," tool"," result"," I"," received"," is",":\n\n","```\n","HE","LL","O","\n","```"]} -{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"} -{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-undefine","name":"cordis_undefine","argumentsDelta":"{\"pluginId\":\"snap-1\"}"}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}} -{"turn":1,"step":2,"callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-edit-read","name":"read","arguments":"{\"file_path\":\"config.txt\"}"}}} -{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}} -{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"} -{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-overwrite-read","name":"read","arguments":"{\"file_path\":\"data.txt\"}"}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}} -{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-output","name":"job_output","argumentsDelta":"{\"job_id\":\"bash-1\",\"wait\":true}"}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command and then reply with DONE."}}} -{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} -{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The replacement was successful. I'll reply with just \"DONE\" as instructed."}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":10}"}}} -{"turn":1,"step":3,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," second"," attempt"," succeeded","."," The"," final"," result"," is"," \"","HE","LL","O","\"."]} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-delete-read-after","name":"read","arguments":"{\"file_path\":\"deleted.txt\"}"}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-delete-read-before","name":"read","arguments":"{\"file_path\":\"deleted.txt\"}"}}} -{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}} -{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."}}} -{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-overwrite-write","name":"write","argumentsDelta":"{\"file_path\":\"data.txt\",\"content\":\"replaced\"}"}} -{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:4}}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}} -{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"terminal_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}}} -{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"}} -{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"{{message:3}}"} -{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"{{message:5}}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} -{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"}}} -{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_root","name":"write","argumentsDelta":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} -{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:3}}"} -{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:5}}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}} -{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}} -{"turn":1,"point":"PreToolUse","handlerId":"claude-code:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":4.22458400000005} -{"content":[{"type":"text","text":"Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":4.116542000000209} -{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"} -{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," has"," been"," created","."," Now"," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"}]} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:11}}"}]} -{"turn":1,"step":1,"callId":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","FIR","ST","\""," and"," stop","."]} -{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"}]} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]} -{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result I got back is:\n\nHELLO\n\nThat's it."}}} -{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:15}}"}]} -{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash is disabled by codex policy in this session\n```"}}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"content":[{"type":"text","text":"Read request event 10 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed SNAPSHOT_OK. Now I need to reply with the single word DONE."}}} -{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_4","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} -{"turn":1,"step":1,"callId":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background job slot\",\"run_in_background\":true}"} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," then"," reply"," with"," D","ONE","."]} -{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"workspace-read","name":"bash","argumentsDelta":"{\"command\":\"cat greeting.txt\",\"description\":\"Read greeting.txt to confirm\"}"}} -{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":2.6014169999998558} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}} -{"turn":1,"step":3,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"texts":["The"," replacement"," was"," successful","."," I","'ll"," reply"," with"," just"," \"","D","ONE","\""," as"," instructed","."]} -{"content":[{"type":"text","text":"Delegate one foreground subagent. Its published run will fail; report that failure as PARENT_OBSERVED_ERROR."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}} -{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","SEC","OND","\""," and"," then"," stop","."]} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The read tool returned lines 5 through 8 as expected. Now I need to reply with exactly the single word \"DONE\"."}}} -{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],"texts":["The"," tool"," output"," was"," rejected"," by"," code","x"," policy","."," Let"," me"," quote"," what"," I"," got"," back","."]} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]} -{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact","compactionId":"workspace-context-fixture"},"role":"user","id":"{{message:5}}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."}}} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"completed\"}]}"}}} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"turn":1,"step":2,"callId":"call_acp_background","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"} -{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}}} -{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\" (with a trailing newline, but that's fine). The user asked me to reply with that output only."}}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":2,"callId":"fs-bounded-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"} -{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"{{message:4}}"}]} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} -{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} -{"turn":1,"step":2,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"workspace-read","name":"bash","arguments":"{\"command\":\"cat greeting.txt\",\"description\":\"Read greeting.txt to confirm\"}"}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"workspace-append","name":"bash","argumentsDelta":"{\"command\":\"printf 'WORLD\\n' >> greeting.txt\",\"description\":\"Append WORLD to greeting.txt\"}"}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}} -{"turn":1,"step":1,"callId":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\", \"run_in_background\":false}"} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"{{message:11}}"}]} -{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:4}}"}]} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."}}} -{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","args":["","{","\"","pattern","\"",": ","\"","*","\"",", ","\"","path","\"",": ","\"","tree","\"","}"]} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}} -{"turn":1,"step":4,"callId":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":2,"callId":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"} -{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["The"," tool"," result"," was",":\n\n","```\n","HE","LL","O","\n","```\n\n","It"," completed"," successfully"," with"," exit"," code"," ","0","."]} -{"turn":1,"step":1,"callId":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."}}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} -{"turn":1,"step":2,"callId":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\", \"run_in_background\": false}"} -{"turn":1,"step":1,"callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\", \"run_in_background\": false}"} -{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."}}} -{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:5}}"}]} -{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://public.test:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}} -{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"workspace-append","name":"bash","arguments":"{\"command\":\"printf 'WORLD\\n' >> greeting.txt\",\"description\":\"Append WORLD to greeting.txt\"}"}}} -{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy","."," I"," need"," to"," report"," this"," error"," verb","atim"," back"," to"," the"," user","."]} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}} -{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":1}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":2}} -{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-side-effect-check","name":"bash","argumentsDelta":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\",\"run_in_background\":false}"}} -{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"ROOT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":2}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Read request event 10 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-tools-api","name":"cordis_inspect_query","argumentsDelta":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," `","echo"," HE","LL","O","`"," using"," the"," bash"," tool"," and"," report"," the"," result"," verb","atim","."]} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNAVAILABLE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-first","name":"bash","argumentsDelta":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background job slot\",\"run_in_background\":true}"}} -{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]} -{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"workspace-append"},"content":[{"type":"tool-result","toolCallId":"workspace-append","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"{{message:4}}"}} -{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_root_child"},"content":[{"type":"tool-result","toolCallId":"call_root_child","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"isError":false}],"role":"user","id":"{{message:4}}"}} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate one foreground subagent. Its published run will fail; report that failure as PARENT_OBSERVED_ERROR."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."}}} -{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3}} -{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"code-image-call"},"content":[{"type":"tool-result","toolCallId":"code-image-call","content":[{"type":"text","text":"{{cwd}}/red.png"}],"isError":false}],"role":"user","id":"{{message:4}}"}} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":10,"outputTokens":2}} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_COMPLETED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":2}} -{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} -{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:17}}"},"usage":{"inputTokens":3,"outputTokens":3}} -{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_1"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_1","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"{{message:4}}"}} -{"turn":1,"step":1,"callId":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\",\"run_in_background\":false}"} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_EXIT_PRESERVED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":1,"outputTokens":1}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":20,"outputTokens":4}} -{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"{{message:4}}"}} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_ERROR"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":2}} -{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}}} -{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_foreground","name":"subagent_acp","argumentsDelta":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}} -{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"RUNNER_FAILURES_SURFACED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":1,"outputTokens":1}} -{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"{{message:9}}"}} -{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}}} -{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","args":["","{","\"","file","_path","\"",": ","\"","notes",".txt","\"",", ","\"","content","\"",": ","\"","hello"," world","\"","}"]} -{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," bash"," tool"," returned"," an"," error"," saying"," it"," requires"," manual"," approval"," in"," this"," session","."," I","'ll"," report"," this"," verb","atim","."]} -{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:17}}"},"usage":{"inputTokens":10,"outputTokens":2}} -{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-bounded-write","name":"write","argumentsDelta":"{\"file_path\":\"data.txt\",\"content\":\"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}} -{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{message:8}}"}} -{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} -{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} -{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":2}} \ No newline at end of file diff --git a/packages/session/session-persistence-sqlite/src/codec.ts b/packages/session/session-persistence-sqlite/src/codec.ts deleted file mode 100644 index bb3a81208f..0000000000 --- a/packages/session/session-persistence-sqlite/src/codec.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Schema-20 physical chunk-row codec. This package owns the durable tags, - * validation, and row-size limits independently from other persistence formats. - * @module @deepseek-ai/dsh-session-persistence-sqlite/codec - */ - -import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' - -/* jscpd:ignore-start -- schema 20 deliberately owns a frozen physical codec; - * importing or sharing the JSONL codec would let that format mutate this database interpreter. */ -type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta' -type DeltaEvent = SessionEvent<'assistant/chunk'> - -interface RunDataBase { - readonly turn: number - readonly step: number - readonly index: number - readonly dt: number[] -} - -interface TextRunData extends RunDataBase { - readonly texts: string[] -} - -interface ToolCallRunData extends RunDataBase { - readonly id: Extract['id'] - readonly name?: string - readonly args: string[] -} - -/** One schema-20 packed physical record. */ -export type ChunkRow = - | { readonly type: 'text-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData } - | { readonly type: 'reasoning-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData } - | { readonly type: 'tool-call-chunks'; readonly seq0: number; readonly time0: number; readonly data: ToolCallRunData } - -/** One scalar event or schema-20 packed physical record. */ -export type StorageRecord = SessionEvent | ChunkRow - -/** Minimum eligible members in a packed physical record. */ -export const MIN_PACKED_ROW_MEMBERS = 3 -/** Maximum logical members represented by one packed physical record. */ -export const MAX_PACKED_ROW_MEMBERS = 1_024 -/** Maximum UTF-8 bytes in one packed physical record's data column. */ -export const MAX_PACKED_DATA_BYTES = 1_048_576 - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function hasExactKeys(value: object, keys: readonly string[]): boolean { - return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)) -} - -function classify(event: SessionEvent): DeltaKind | undefined { - if (event.type !== 'assistant/chunk') return undefined - if (!hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined - if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time)) return undefined - const data: unknown = event.data - if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined - if (typeof data.turn !== 'number' || typeof data.step !== 'number') return undefined - const chunk = data.chunk - if (!isRecord(chunk) || typeof chunk.index !== 'number') return undefined - switch (chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk.text === 'string' - ? chunk.type - : undefined - case 'tool-call-delta': { - const validKeys = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta']) - || (hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta']) - && typeof chunk.name === 'string') - return validKeys && typeof chunk.id === 'string' && typeof chunk.argumentsDelta === 'string' - ? chunk.type - : undefined - } - default: - return undefined - } -} - -function toolCallOf(event: DeltaEvent): { readonly id: string; readonly name?: string } { - return event.data.chunk as { readonly id: string; readonly name?: string } -} - -function indexOf(event: DeltaEvent): number { - return (event.data.chunk as { readonly index: number }).index -} - -function continues(previous: DeltaEvent, next: DeltaEvent, kind: DeltaKind): boolean { - if (next.seq !== previous.seq + 1 || !Number.isSafeInteger(next.time - previous.time)) return false - if (next.data.turn !== previous.data.turn || next.data.step !== previous.data.step) return false - if (indexOf(next) !== indexOf(previous)) return false - if (kind !== 'tool-call-delta') return true - const left = toolCallOf(previous) - const right = toolCallOf(next) - return left.id === right.id - && Object.hasOwn(left, 'name') === Object.hasOwn(right, 'name') - && left.name === right.name -} - -function buildRow(kind: DeltaKind, run: readonly DeltaEvent[]): ChunkRow { - const first = run[0] as DeltaEvent - const base = { - turn: first.data.turn, - step: first.data.step, - index: indexOf(first), - dt: run.slice(1).map((event, index) => event.time - (run[index] as DeltaEvent).time), - } - const envelope = { seq0: first.seq, time0: first.time } - if (kind === 'tool-call-delta') { - const call = toolCallOf(first) - return { - type: 'tool-call-chunks', - ...envelope, - data: { - ...base, - id: call.id as Extract['id'], - ...Object.hasOwn(call, 'name') ? { name: call.name as string } : {}, - args: run.map(event => (event.data.chunk as { readonly argumentsDelta: string }).argumentsDelta), - }, - } - } - const data = { - ...base, - texts: run.map(event => (event.data.chunk as { readonly text: string }).text), - } - return kind === 'text-delta' - ? { type: 'text-chunks', ...envelope, data } - : { type: 'reasoning-chunks', ...envelope, data } -} - -function packedDataBytes(row: ChunkRow): number { - return Buffer.byteLength(JSON.stringify(row.data)) -} - -function emitBoundedRun(out: StorageRecord[], kind: DeltaKind, completeRun: readonly DeltaEvent[]): void { - let offset = 0 - while (completeRun.length - offset >= MIN_PACKED_ROW_MEMBERS) { - let low = MIN_PACKED_ROW_MEMBERS - let high = Math.min(completeRun.length - offset, MAX_PACKED_ROW_MEMBERS) - const largest = buildRow(kind, completeRun.slice(offset, offset + high)) - if (packedDataBytes(largest) <= MAX_PACKED_DATA_BYTES) { - out.push(largest) - offset += high - continue - } - high -= 1 - let accepted = 0 - let acceptedRow: ChunkRow | undefined - while (low <= high) { - const middle = Math.floor((low + high) / 2) - const candidate = buildRow(kind, completeRun.slice(offset, offset + middle)) - if (packedDataBytes(candidate) <= MAX_PACKED_DATA_BYTES) { - accepted = middle - acceptedRow = candidate - low = middle + 1 - } else { - high = middle - 1 - } - } - if (accepted === 0) { - out.push(completeRun[offset] as DeltaEvent) - offset += 1 - continue - } - /* v8 ignore next -- accepted is set only with its same-branch candidate. */ - out.push(acceptedRow ?? malformed(kind, 'bounded encoder lost its accepted row')) - offset += accepted - } - out.push(...completeRun.slice(offset)) -} - -/** - * Pack eligible logical chunk runs into bounded schema-20 records. - * @param events - logical events in sequence order. - * @returns scalar and packed physical records in equivalent order. - */ -export function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[] { - const out: StorageRecord[] = [] - let kind: DeltaKind | undefined - let run: DeltaEvent[] = [] - const flush = (): void => { - if (kind === undefined) out.push(...run) - else emitBoundedRun(out, kind, run) - kind = undefined - run = [] - } - for (const event of events) { - const nextKind = classify(event) - if (nextKind === undefined) { - flush() - out.push(event) - continue - } - const delta = event as DeltaEvent - const previous = run.at(-1) - if (nextKind === kind && previous !== undefined && continues(previous, delta, nextKind)) { - run.push(delta) - continue - } - flush() - kind = nextKind - run = [delta] - } - flush() - return out -} - -function malformed(tag: string, reason: string): never { - throw new Error(`malformed ${tag} storage row: ${reason}`) -} - -function validateRunData( - tag: string, - data: Record, - payloadKey: 'texts' | 'args', - serializedBytes?: number, -): string[] { - if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') { - malformed(tag, 'turn/step/index must be numbers') - } - const payload = data[payloadKey] - if (!Array.isArray(payload) - || payload.length < MIN_PACKED_ROW_MEMBERS - || payload.length > MAX_PACKED_ROW_MEMBERS - || payload.some(member => typeof member !== 'string')) { - malformed(tag, `${payloadKey} must contain ${MIN_PACKED_ROW_MEMBERS}..${MAX_PACKED_ROW_MEMBERS} strings`) - } - const gaps = data.dt - if (!Array.isArray(gaps) || gaps.some(gap => !Number.isSafeInteger(gap))) { - malformed(tag, 'dt must be an array of safe integers') - } - if (gaps.length !== payload.length - 1) malformed(tag, 'dt length must match the member count') - if ((serializedBytes ?? Buffer.byteLength(JSON.stringify(data))) > MAX_PACKED_DATA_BYTES) { - malformed(tag, `data exceeds ${MAX_PACKED_DATA_BYTES} UTF-8 bytes`) - } - return payload as string[] -} - -function validateRow( - value: Record, - tag: ChunkRow['type'], - serializedBytes?: number, -): ChunkRow { - if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) malformed(tag, 'invalid envelope fields') - if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0) malformed(tag, 'seq0 must be non-negative') - if (!Number.isSafeInteger(value.time0)) malformed(tag, 'time0 must be a safe integer') - const data = value.data - if (!isRecord(data)) malformed(tag, 'data must be an object') - let payload: string[] - if (tag === 'tool-call-chunks') { - const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args']) - if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) { - malformed(tag, 'invalid tool-call data fields') - } - if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) { - malformed(tag, 'id and optional name must be strings') - } - payload = validateRunData(tag, data, 'args', serializedBytes) - } else { - if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) malformed(tag, 'invalid text data fields') - payload = validateRunData(tag, data, 'texts', serializedBytes) - } - if (!Number.isSafeInteger((value.seq0 as number) + payload.length - 1)) malformed(tag, 'member seqs exceed safe integers') - let time = value.time0 as number - for (const gap of data.dt as number[]) { - time += gap - if (!Number.isSafeInteger(time)) malformed(tag, 'member times exceed safe integers') - } - return value as unknown as ChunkRow -} - -function expandRow(row: ChunkRow): SessionEvent[] { - const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts - const events: SessionEvent[] = [] - let time = row.time0 - for (let index = 0; index < members.length; index += 1) { - if (index > 0) time += row.data.dt[index - 1] as number - let chunk: StreamChunk - switch (row.type) { - case 'text-chunks': - chunk = { type: 'text-delta', index: row.data.index, text: members[index] as string } - break - case 'reasoning-chunks': - chunk = { type: 'reasoning-delta', index: row.data.index, text: members[index] as string } - break - case 'tool-call-chunks': - chunk = { - type: 'tool-call-delta', - index: row.data.index, - id: row.data.id, - ...Object.hasOwn(row.data, 'name') ? { name: row.data.name as string } : {}, - argumentsDelta: members[index] as string, - } - break - } - events.push({ - type: 'assistant/chunk', - seq: row.seq0 + index, - time, - data: { turn: row.data.turn, step: row.data.step, chunk }, - }) - } - return events -} - -/** - * Decode one scalar or packed schema-20 record. - * @param value - parsed physical-record value. - * @returns the represented logical events. - */ -export function decodeStorageRecord(value: unknown): SessionEvent[] { - if (!isRecord(value)) return [value as SessionEvent] - const tag = value.type - if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') { - return [value as SessionEvent] - } - return expandRow(validateRow(value, tag)) -} - -/** - * Decode one packed row from its exact uncompressed data value. The byte bound - * rejects oversized input before JSON parsing and avoids serializing it again. - * @param tag - validated packed physical type. - * @param seq0 - first represented logical sequence number. - * @param time0 - first represented logical timestamp. - * @param serializedData - decoded SQLite data-column text. - * @returns the represented logical events. - */ -export function decodeSerializedChunkRow( - tag: ChunkRow['type'], - seq0: number, - time0: number, - serializedData: string, -): SessionEvent[] { - const bytes = Buffer.byteLength(serializedData) - if (bytes > MAX_PACKED_DATA_BYTES) malformed(tag, `data exceeds ${MAX_PACKED_DATA_BYTES} UTF-8 bytes`) - return expandRow(validateRow({ type: tag, seq0, time0, data: JSON.parse(serializedData) as unknown }, tag, bytes)) -} -/* jscpd:ignore-end */ diff --git a/packages/session/session-persistence-sqlite/src/compression.ts b/packages/session/session-persistence-sqlite/src/compression.ts deleted file mode 100644 index 84e51020f0..0000000000 --- a/packages/session/session-persistence-sqlite/src/compression.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Fixed physical-record compression for SQLite. Schema-owned functions - * encode logical events and decode tagged rows before persistence consumers - * observe them. - * @module @deepseek-ai/dsh-session-persistence-sqlite/compression - */ - -import { readFileSync } from 'node:fs' -import { TextDecoder } from 'node:util' -import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib' -import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' -import { - decodeSerializedChunkRow, - type ChunkRow, - MAX_PACKED_DATA_BYTES, - type StorageRecord, -} from './codec.ts' -import type { EventRow } from './schema.ts' - -/** One physical row ready for SQLite parameter binding. */ -export interface BoundRecord { - readonly seq: number - readonly type: string - readonly time: number - readonly data: string | Uint8Array - readonly sourceEventSeqs: Uint8Array | null - readonly surfaceOp: string | null - readonly ignorable: number | null -} - -const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }) -const ZSTD_COMPRESSION_LEVEL = 3 -const DELTA_TAG = 0 -const RUN_TAG = 1 -const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER) -const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n -const PACKED_ROW_SENTINEL = 0 -/** - * Schema-20 raw-content zstd dictionary for independently decodable data rows. - * Its exact bytes are part of the physical format; changing the resource - * requires a schema-version bump. - */ -const ZSTD_DICTIONARY = readFileSync(new URL('../resources/zstd-dictionary.bin', import.meta.url)) - -/** Compress options shared by every data-column frame. */ -const DATA_ZSTD_OPTIONS = { - dictionary: ZSTD_DICTIONARY, - params: { [constants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL }, -} as const -const CHUNK_TAGS = ['text-chunks', 'reasoning-chunks', 'tool-call-chunks'] as const -type ChunkTag = typeof CHUNK_TAGS[number] - -function isChunkTag(value: string): value is ChunkTag { - return (CHUNK_TAGS as readonly string[]).includes(value) -} - -/** - * Decode one physical SQLite row into its complete logical event span. - * @param row - detached SQLite event row. - * @returns every logical event represented by the row. - */ -export function decodeRow(row: EventRow): SessionEvent[] { - if (row.ignorable !== PACKED_ROW_SENTINEL) return [decodeScalarRow(row)] - if (!isChunkTag(row.type)) { - throw new Error(`malformed ${row.type} storage row: packed discriminator requires a chunk tag`) - } - if (row.source_event_seqs !== null || row.surface_op !== null) { - throw new Error(`malformed ${row.type} storage row: packed surface fields must be null`) - } - return decodeSerializedChunkRow( - row.type, - row.seq, - row.time, - decodeData(row.data, MAX_PACKED_DATA_BYTES), - ) -} - -/** - * Convert a storage record to SQLite column values. - * @param record - scalar event or packed chunk record. - * @returns column values for one physical insert. - */ -export function bindRecord(record: StorageRecord): BoundRecord { - if (isChunkRow(record)) { - return { - seq: record.seq0, - type: record.type, - time: record.time0, - data: encodeData(JSON.stringify(record.data)), - sourceEventSeqs: null, - surfaceOp: null, - ignorable: PACKED_ROW_SENTINEL, - } - } - const event = record - const surface = event as SessionEvent - return { - seq: event.seq, - type: event.type, - time: event.time, - data: encodeData(JSON.stringify(event.data)), - sourceEventSeqs: surface.sourceEventSeqs === undefined - ? null - : encodeSourceEventSeqs(surface.sourceEventSeqs), - surfaceOp: surface.surfaceOp === undefined ? null : JSON.stringify(surface.surfaceOp), - ignorable: event.ignorable === true ? 1 : null, - } -} - -function encodeData(serialized: string): string | Uint8Array { - const bytes = Buffer.from(serialized) - const compressed = zstdCompressSync(bytes, DATA_ZSTD_OPTIONS) - return compressed.length < bytes.length ? compressed : serialized -} - -function decodeData(value: string | Uint8Array, maxOutputLength?: number): string { - if (typeof value === 'string') return value - const decoded = maxOutputLength === undefined - ? zstdDecompressSync(value, { dictionary: ZSTD_DICTIONARY }) - : zstdDecompressSync(value, { dictionary: ZSTD_DICTIONARY, maxOutputLength }) - return UTF8_DECODER.decode(decoded) -} - -function encodeSourceEventSeqs(values: readonly number[]): Uint8Array { - if (values.length === 0) return new Uint8Array() - const deltas = [DELTA_TAG] - let previous = 0n - for (let index = 0; index < values.length; index += 1) { - const value = values[index] as number - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('sourceEventSeqs must contain non-negative safe integers') - } - const current = BigInt(value) - const encoded = index === 0 - ? current - : current >= previous - ? (current - previous) * 2n - : ((previous - current) * 2n) - 1n - appendVarint(deltas, encoded) - previous = current - } - if (!isStrictlyIncreasing(values)) return Uint8Array.from(deltas) - - const runs = [RUN_TAG] - let start = values[0] as number - let end = start - for (let index = 1; index < values.length; index += 1) { - const value = values[index] as number - if (value === end + 1) { - end = value - continue - } - appendVarint(runs, BigInt(start)) - appendVarint(runs, BigInt(end - start + 1)) - start = value - end = start - } - appendVarint(runs, BigInt(start)) - appendVarint(runs, BigInt(end - start + 1)) - return Uint8Array.from(runs.length < deltas.length ? runs : deltas) -} - -function isStrictlyIncreasing(values: readonly number[]): boolean { - return values.every((value, index) => index === 0 || value > (values[index - 1] as number)) -} - -function appendVarint(bytes: number[], value: bigint): void { - let remaining = value - while (remaining >= 0x80n) { - bytes.push(Number(remaining & 0x7fn) | 0x80) - remaining >>= 7n - } - bytes.push(Number(remaining)) -} - -function decodeSourceEventSeqs(bytes: Uint8Array, maxEntries: number): number[] { - if (bytes.length === 0) return [] - if (bytes.length === 1) { - throw new Error('malformed source_event_seqs storage value: truncated tagged payload') - } - switch (bytes[0]) { - case DELTA_TAG: return decodeDeltaVarints(bytes, 1) - case RUN_TAG: return decodeRunVarints(bytes, 1, maxEntries) - default: throw new Error('malformed source_event_seqs storage value: unknown encoding tag') - } -} - -function decodeDeltaVarints(bytes: Uint8Array, offset: number): number[] { - const values: number[] = [] - let previous = 0n - while (offset < bytes.length) { - const first = values.length === 0 - const decoded = readVarint(bytes, offset, first ? MAX_SAFE_INTEGER : MAX_ZIGZAG_INTEGER) - offset = decoded.offset - const delta = first - ? decoded.value - : (decoded.value & 1n) === 0n - ? decoded.value / 2n - : -((decoded.value + 1n) / 2n) - const value = first ? delta : previous + delta - if (value < 0n || value > MAX_SAFE_INTEGER) { - throw new Error('malformed source_event_seqs storage value: decoded seq is out of range') - } - values.push(Number(value)) - previous = value - } - return values -} - -function decodeRunVarints(bytes: Uint8Array, offset: number, maxEntries: number): number[] { - const values: number[] = [] - let previousEnd = -1 - while (offset < bytes.length) { - const start = readVarint(bytes, offset, MAX_SAFE_INTEGER) - const count = readVarint(bytes, start.offset, MAX_SAFE_INTEGER) - offset = count.offset - const first = Number(start.value) - const length = Number(count.value) - if (length < 1) { - throw new Error('malformed source_event_seqs storage value: run count must be positive') - } - if (first <= previousEnd || !Number.isSafeInteger(first + length - 1)) { - throw new Error('malformed source_event_seqs storage value: runs must ascend within safe integers') - } - if (length > maxEntries - values.length) { - throw new Error('malformed source_event_seqs storage value: run exceeds its event sequence') - } - for (let index = 0; index < length; index += 1) values.push(first + index) - previousEnd = first + length - 1 - } - return values -} - -function readVarint( - bytes: Uint8Array, - offset: number, - limit: bigint, -): { readonly value: bigint; readonly offset: number } { - let value = 0n - let shift = 0n - while (offset < bytes.length) { - const byte = bytes[offset] as number - offset += 1 - value |= BigInt(byte & 0x7f) << shift - if ((byte & 0x80) === 0) { - if (shift > 0n && (byte & 0x7f) === 0) { - throw new Error('malformed source_event_seqs storage value: non-canonical varint') - } - if (value > limit) { - throw new Error('malformed source_event_seqs storage value: varint is out of range') - } - return { value, offset } - } - shift += 7n - if (shift > 56n) { - throw new Error('malformed source_event_seqs storage value: varint is out of range') - } - } - throw new Error('malformed source_event_seqs storage value: truncated varint') -} - -function isChunkRow(record: StorageRecord): record is ChunkRow { - return isChunkTag(record.type) && 'seq0' in record && !('seq' in record) -} - -function decodeScalarRow(row: EventRow): SessionEvent { - const surfaceFields = { - ...row.source_event_seqs === null - ? {} - : { sourceEventSeqs: decodeSourceEventSeqs(row.source_event_seqs, row.seq) }, - ...row.surface_op === null - ? {} - : { surfaceOp: JSON.parse(row.surface_op) as SessionEvent['surfaceOp'] }, - } - return { - type: row.type as SessionEvent['type'], - seq: row.seq, - time: row.time, - data: JSON.parse(decodeData(row.data)) as SessionEvent['data'], - ...surfaceFields, - ...row.ignorable === 1 ? { ignorable: true as const } : {}, - } as SessionEvent -} - -/** - * Validate and flatten physical rows into their logical prefix. A malformed - * row or logical gap is committed corruption when a later valid turn end - * exists; otherwise it starts a removable physical tail. - * @param rows - physical rows ordered by their first logical sequence. - * @param base - logical sequence expected from the first selected row. - * @returns the contiguous logical prefix and optional physical deletion base. - */ -export function scanRows( - rows: readonly EventRow[], - base = 0, -): { preserved: SessionEvent[]; tornFrom?: number } { - let lastTurnEndRow = -1 - for (let index = rows.length - 1; index >= 0; index -= 1) { - try { - if (decodeRow(rows[index] as EventRow).some(event => event.type === 'turn/end')) { - lastTurnEndRow = index - break - } - } catch { - // A malformed row cannot prove that an earlier physical prefix committed. - } - } - - const preserved: SessionEvent[] = [] - let expected = base - for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { - const physical = rows[rowIndex] as EventRow - let logicalEvents: SessionEvent[] | undefined - try { - logicalEvents = decodeRow(physical) - } catch { - // The committed-prefix rule below owns whether this invalid row is fatal or repairable. - } - if (logicalEvents === undefined) { - if (rowIndex <= lastTurnEndRow) { - throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`) - } - return { preserved, tornFrom: physical.seq } - } - let contiguous = true - for (const event of logicalEvents) { - if (event.seq !== expected) { - contiguous = false - break - } - expected += 1 - } - if (!contiguous) { - if (rowIndex <= lastTurnEndRow) { - throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`) - } - return { preserved, tornFrom: physical.seq } - } - preserved.push(...logicalEvents) - } - return { preserved } -} diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts deleted file mode 100644 index 734ca52aac..0000000000 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Opt-in SQLite persistence provider. Logical sessions remain unchanged; - * the physical backend packs eligible chunk runs into schema-20 rows. - * @module @deepseek-ai/dsh-session-persistence-sqlite - */ - -import { Context, Service } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import type { - Session, - SessionEvent, - SessionHeader, - SessionId, - SessionPreparation, -} from '@deepseek-ai/dsh-session' -import { - DEFAULT_PREPARED_SESSION_CACHE_SIZE, - DEFAULT_WRITE_BATCH_MAX_DELAY_MS, - MAX_WRITE_BATCH_DELAY_MS, - type BorrowedSessionSource, - PersistenceCoordinator, - SessionPersistence, - type SessionInspection, - type SessionLocation, - type SessionPersistenceSnapshot, -} from '@deepseek-ai/dsh-session-persistence' -import type { JournalMode } from './schema.ts' -import { SqliteStore } from './store.ts' - -export { SCHEMA_VERSION } from './schema.ts' - -/** Default wait for another SQLite connection's write reservation. */ -export const DEFAULT_BUSY_TIMEOUT_MS = 5_000 -/** Largest busy timeout accepted by SQLite's signed millisecond interface. */ -export const MAX_BUSY_TIMEOUT_MS = 2_147_483_647 - -/** Plugin configuration. */ -export interface Config { - /** SQLite database path, or `:memory:` for an in-process database. */ - path: string - /** Durable SQLite journal mode; defaults to `wal`. */ - journalMode?: JournalMode - /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */ - busyTimeoutMs?: number - /** Maximum cold Session preparations retained for history-to-resume reuse. */ - preparedSessionCacheSize?: number - /** Fixed live-event coalescing window; not a backend completion deadline. */ - writeBatchMaxDelayMs?: number -} - -/** - * SQLite `SessionPersistence` provider with a schema-owned physical codec. - */ -export class SqliteSessionPersistence extends SessionPersistence { - override readonly supportsRawArtifacts = false - override readonly name = 'session-persistence-sqlite' - - static inject = ['sessions'] - - static Config: z = z.object({ - path: z.string().required(), - journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), - busyTimeoutMs: z.number().step(1).min(0).max(MAX_BUSY_TIMEOUT_MS).default(DEFAULT_BUSY_TIMEOUT_MS), - preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE), - writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS) - .default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS), - }) - - private readonly store: SqliteStore - private readonly coordinator: PersistenceCoordinator - - constructor(ctx: Context, public config: Config) { - super(ctx) - const preparedSessionCacheSize = config.preparedSessionCacheSize - ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE - const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs - ?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS - this.store = new SqliteStore({ - path: config.path, - journalMode: config.journalMode ?? 'wal', - busyTimeoutMs: config.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS, - }) - this.coordinator = new PersistenceCoordinator(this.ctx, this.store, { - preparedSessionCacheSize, - writeBatchMaxDelayMs, - }) - } - - /** Reject self-contained path and ownership failures without loading Node SQLite. */ - protected async [Service.init](): Promise { - await this.store.validatePath() - } - - /** SQLite has one database, not an independent per-session artifact. */ - locate(_meta: SessionHeader): SessionLocation | undefined { - return undefined - } - - create(meta: SessionHeader): Promise { - return this.coordinator.create(meta) - } - - override ensureMaterialized(session: Session): Promise { - return this.coordinator.ensureMaterialized(session) - } - - append(id: SessionId, events: readonly SessionEvent[]): Promise { - return this.coordinator.append(id, events) - } - - override prepare(id: SessionId, signal?: AbortSignal): Promise { - return this.coordinator.prepare(id, signal) - } - - load(id: SessionId): Promise { - return this.coordinator.load(id) - } - - inspect(id: SessionId, signal?: AbortSignal): Promise { - return this.coordinator.inspect(id, signal) - } - - override borrowSession(id: SessionId, signal?: AbortSignal): Promise { - return this.coordinator.borrowSession(id, signal) - } - - readFrom( - id: SessionId, - fromSeq: number, - signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.coordinator.readFrom(id, fromSeq, signal) - } - - list(signal?: AbortSignal): Promise { - return this.store.list(signal) - } - - listSnapshots(signal?: AbortSignal): Promise { - return this.store.listSnapshots(signal) - } -} - -export default SqliteSessionPersistence diff --git a/packages/session/session-persistence-sqlite/src/invariant.ts b/packages/session/session-persistence-sqlite/src/invariant.ts deleted file mode 100644 index 8436d5fbe5..0000000000 --- a/packages/session/session-persistence-sqlite/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-sqlite`. - * @module @deepseek-ai/dsh-session-persistence-sqlite/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' - -/** Cordis companion plugin name. */ -export const name = 'session-persistence-sqlite-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: physical packing is observable only by database - * round-trip and row-count checks, not a continuous in-process relation. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts deleted file mode 100644 index e8c393608c..0000000000 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ /dev/null @@ -1,426 +0,0 @@ -/** - * SQLite schema ownership and durable-row validation. - * @module @deepseek-ai/dsh-session-persistence-sqlite/schema - */ - -import { randomUUID } from 'node:crypto' -import { isAbsolute } from 'node:path' -import { performance } from 'node:perf_hooks' -import type { DatabaseSync } from 'node:sqlite' -import { setTimeout as delay } from 'node:timers/promises' -import { brandString } from '@deepseek-ai/dsh-brand' -import { - type SessionHeader, - type SessionId, -} from '@deepseek-ai/dsh-session' -import { sql } from './sql.ts' - -/** Current physical-record schema with packed and compressed event rows. */ -export const SCHEMA_VERSION = 20 -/** Application id reserved for DeepSeek Harness SQLite session databases. */ -export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 - -/** A materialized session's metadata and monotonic revision. */ -export interface SessionRow { - readonly id: string - readonly version: number - readonly created_at: number - readonly cwd: string | null - readonly parent_session: string | null - readonly seed_length: number | null - readonly origin: 'subagent' | null - readonly incarnation: string - readonly revision: number - readonly delegation_depth: number | null - readonly agent_preset: string | null -} - -/** One physical event row; packed rows may represent multiple logical events. */ -export interface EventRow { - readonly seq: number - readonly type: string - readonly time: number - readonly data: string | Uint8Array - readonly source_event_seqs: Uint8Array | null - readonly surface_op: string | null - readonly ignorable: number | null -} - -/** Durable journal modes accepted by the backend. */ -export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - -interface SchemaObjectRow { - readonly type: string - readonly name: string - readonly tbl_name: string - readonly sql: string -} - -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu -const JOURNAL_BUSY_RETRY_INTERVAL_MS = 10 -type DatabaseSyncConstructor = typeof import('node:sqlite')['DatabaseSync'] - -/** - * Open and validate a SQLite session database. - * @param Database - lazily imported Node SQLite constructor. - * @param path - SQLite path, including `:memory:`. - * @param journalMode - validated journal pragma. - * @param busyTimeoutMs - validated maximum wait for a competing SQLite lock. - * @returns the configured database handle. - * @throws when connection settings, schema ownership, or SQLite setup cannot be validated. - */ -export async function openDatabase( - Database: DatabaseSyncConstructor, - path: string, - journalMode: JournalMode, - busyTimeoutMs: number, -): Promise { - const deadline = performance.now() + busyTimeoutMs - const db = new Database(path, { timeout: busyTimeoutMs }) - try { - configureConnectionSecurity(db, path) - configureDatabase(Database, db, path) - await selectJournalMode(db, path, journalMode, deadline) - configureDurability(db, path) - return db - } catch (error: unknown) { - db.close() - throw error - } -} - -function configureConnectionSecurity(db: DatabaseSync, path: string): void { - db.exec(sql('trusted-schema-off')) - const trustedSchema = integerField(db.prepare(sql('select-trusted-schema')).get(), 'trusted_schema') - /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */ - if (trustedSchema !== 0) { - throw new Error(`session database at "${path}" retained trusted_schema=${trustedSchema}, expected 0`) - } - db.exec(sql('mmap-off')) - if (path === ':memory:') return - const mmapSize = integerField(db.prepare(sql('select-mmap-size')).get(), 'mmap_size') - /* v8 ignore next 3 -- supported file-backed SQLite connections return the fixed setting. */ - if (mmapSize !== 0) { - throw new Error(`session database at "${path}" retained mmap_size=${mmapSize}, expected 0`) - } -} - -function configureDatabase( - Database: DatabaseSyncConstructor, - db: DatabaseSync, - path: string, -): void { - db.exec(sql('page-size')) - db.exec(sql('foreign-keys-on')) - let began = false - try { - db.exec(sql('begin-immediate')) - began = true - const onDisk = integerField(db.prepare(sql('select-user-version')).get(), 'user_version') - const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id') - const userObjectCount = integerField(db.prepare(sql('select-user-object-count')).get(), 'count') - if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) { - throw new Error(`session database at "${path}" has an unversioned schema or application identity`) - } - if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { - throw new Error( - `session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`, - ) - } - if (onDisk !== 0 && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { - throw new Error( - `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`, - ) - } - if (onDisk === 0) initializeDatabase(db) - validateRequiredSchema(Database, db, path) - db.exec(sql('commit')) - began = false - } catch (error: unknown) { - /* v8 ignore else -- a failed begin leaves no transaction to roll back. */ - if (began) { - /* v8 ignore next 5 -- retain the original ownership failure if rollback fails too. */ - try { - db.exec(sql('rollback')) - } catch { - // The original database-ownership failure remains actionable. - } - } - throw error - } -} - -async function selectJournalMode( - db: DatabaseSync, - path: string, - journalMode: JournalMode, - deadline: number, -): Promise { - let result: unknown - while (true) { - try { - result = db.prepare(sql(journalResource(journalMode))).get() - break - } catch (error: unknown) { - const remainingMs = Math.max(0, Math.ceil(deadline - performance.now())) - if (!isSqliteBusy(error) || remainingMs === 0) throw error - await delay(Math.min(JOURNAL_BUSY_RETRY_INTERVAL_MS, remainingMs)) - if (performance.now() >= deadline) throw error - } - } - const selected = stringField(result, 'journal_mode').toLowerCase() - const expected = path === ':memory:' ? 'memory' : journalMode - /* v8 ignore next 3 -- SQLite returns the selected mode from these fixed, valid pragmas. */ - if (selected !== expected) { - throw new Error(`session database at "${path}" selected journal mode ${selected}, expected ${expected}`) - } -} - -function configureDurability(db: DatabaseSync, path: string): void { - db.exec(sql('synchronous-full')) - const synchronous = integerField(db.prepare(sql('select-synchronous')).get(), 'synchronous') - /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */ - if (synchronous !== 2) { - throw new Error(`session database at "${path}" retained synchronous=${synchronous}, expected FULL (2)`) - } -} - -function isSqliteBusy(error: unknown): boolean { - return typeof error === 'object' - && error !== null - && Reflect.get(error, 'errcode') === 5 -} - -function journalResource(mode: JournalMode): - | 'journal-mode-wal' - | 'journal-mode-delete' - | 'journal-mode-truncate' - | 'journal-mode-persist' { - switch (mode) { - case 'wal': return 'journal-mode-wal' - case 'delete': return 'journal-mode-delete' - case 'truncate': return 'journal-mode-truncate' - case 'persist': return 'journal-mode-persist' - } -} - -function initializeDatabase(db: DatabaseSync): void { - db.exec(sql('schema')) - db.prepare(sql('insert-persistence-state')).run(randomUUID()) - db.exec(sql('set-application-id')) - db.exec(sql('set-user-version-20')) -} - -let canonicalSchema: readonly SchemaObjectRow[] | undefined - -function expectedSchema(Database: DatabaseSyncConstructor): readonly SchemaObjectRow[] { - if (canonicalSchema !== undefined) return canonicalSchema - const reference = new Database(':memory:') - try { - reference.exec(sql('foreign-keys-on')) - reference.exec(sql('schema')) - canonicalSchema = schemaObjects(reference) - return canonicalSchema - } finally { - reference.close() - } -} - -function schemaObjects(db: DatabaseSync): SchemaObjectRow[] { - return db.prepare(sql('select-schema-objects')).all().map((value) => { - const row = record(value, 'schema object') - return { - type: stringField(row, 'type'), - name: stringField(row, 'name'), - tbl_name: stringField(row, 'tbl_name'), - sql: normalizeSql(stringField(row, 'sql')), - } - }) -} - -function normalizeSql(value: string): string { - return value.replaceAll(/\s+/gu, ' ').trim() -} - -function validateRequiredSchema( - Database: DatabaseSyncConstructor, - db: DatabaseSync, - path: string, -): void { - if (JSON.stringify(schemaObjects(db)) !== JSON.stringify(expectedSchema(Database))) { - throw new Error(`session database at "${path}" does not contain the required schema objects`) - } -} - -/** - * Recheck schema ownership inside the caller's mutation transaction. - * @param Database - constructor used to validate the canonical schema. - * @param db - open owned database with an active immediate transaction. - * @param path - database location used in ownership diagnostics. - * @throws when another writer changed the application identity, schema, or version. - */ -export function validateSchemaForMutation( - Database: DatabaseSyncConstructor, - db: DatabaseSync, - path: string, -): void { - const version = integerField(db.prepare(sql('select-user-version')).get(), 'user_version') - const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id') - if (applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { - throw new Error( - `session database application id changed before mutation (expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}, got ${applicationId})`, - ) - } - validateRequiredSchema(Database, db, path) - if (version !== SCHEMA_VERSION) { - throw new Error(`session database schema changed before mutation (expected ${SCHEMA_VERSION}, got ${version})`) - } -} - -/** - * Decode and validate one durable session row. - * @param value - value returned by SQLite. - * @returns a validated session row. - */ -export function decodeSessionRow(value: unknown): SessionRow { - const row = record(value, 'stored session metadata') - const id = nonemptyStringField(row, 'id') - const version = safeIntegerField(row, 'version') - const cwd = nullableStringField(row, 'cwd') - if (cwd !== null && !isAbsolute(cwd)) throw new Error('stored session cwd must be absolute') - const parent = nullableStringField(row, 'parent_session') - const origin = nullableStringField(row, 'origin') - if (origin !== null && origin !== 'subagent') throw new Error('stored session origin must be subagent or null') - const incarnation = nonemptyStringField(row, 'incarnation') - if (!UUID.test(incarnation)) throw new Error('stored session incarnation must be a UUID') - return { - id, - version, - created_at: nonnegativeSafeIntegerField(row, 'created_at'), - cwd, - parent_session: parent, - seed_length: nullableNonnegativeSafeIntegerField(row, 'seed_length'), - origin, - delegation_depth: nullableNonnegativeSafeIntegerField(row, 'delegation_depth'), - agent_preset: nullableStringField(row, 'agent_preset'), - incarnation, - revision: nonnegativeSafeIntegerField(row, 'revision'), - } -} - -/** - * Decode and validate one durable event row before JSON interpretation. - * @param value - value returned by SQLite. - * @returns a validated physical event row. - */ -export function decodeEventRow(value: unknown): EventRow { - const row = record(value, 'stored event') - const ignorable = nullableSafeIntegerField(row, 'ignorable') - if (ignorable !== null && ignorable !== 0 && ignorable !== 1) { - throw new Error('stored event ignorable must be 0, 1, or null') - } - return { - seq: nonnegativeSafeIntegerField(row, 'seq'), - type: nonemptyStringField(row, 'type'), - time: safeIntegerField(row, 'time'), - data: stringOrBlobField(row, 'data'), - source_event_seqs: nullableBlobField(row, 'source_event_seqs'), - surface_op: nullableStringField(row, 'surface_op'), - ignorable, - } -} - -/** - * Validate the singleton identity read from durable storage. - * @param value - value returned by SQLite. - * @returns the UUID store identity. - */ -export function decodeStoreIdentity(value: unknown): string { - const identity = nonemptyStringField(value, 'store_id') - if (!UUID.test(identity)) throw new Error('stored store_id must be a UUID') - return identity -} - -/** - * Reconstruct an immutable session header from a validated metadata row. - * @param row - validated stored metadata row. - * @returns the session header. - */ -export function rowToMeta(row: SessionRow): SessionHeader { - return { - version: row.version, - id: brandString(row.id), - createdAt: row.created_at, - ...row.cwd === null ? {} : { cwd: row.cwd }, - ...row.parent_session === null ? {} : { parentSession: brandString(row.parent_session) }, - ...row.seed_length === null ? {} : { seedLength: row.seed_length }, - ...row.origin === null ? {} : { origin: row.origin }, - ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, - ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset }, - } -} - -function record(value: unknown, label: string): Record { - if (typeof value !== 'object' || value === null) throw new Error(`${label} must be an object`) - return value as Record -} - -function stringField(value: unknown, key: string): string { - const field = record(value, 'SQLite row')[key] - if (typeof field !== 'string') throw new Error(`stored ${key} must be a string`) - return field -} - -function nonemptyStringField(value: unknown, key: string): string { - const field = stringField(value, key) - if (field.length === 0) throw new Error(`stored ${key} must not be empty`) - return field -} - -function nullableStringField(value: unknown, key: string): string | null { - const field = record(value, 'SQLite row')[key] - if (field === null) return null - if (typeof field !== 'string') throw new Error(`stored ${key} must be a string or null`) - return field -} - -function stringOrBlobField(value: unknown, key: string): string | Uint8Array { - const field = record(value, 'SQLite row')[key] - if (typeof field === 'string' || field instanceof Uint8Array) return field - throw new Error(`stored ${key} must be a string or blob`) -} - -function nullableBlobField(value: unknown, key: string): Uint8Array | null { - const field = record(value, 'SQLite row')[key] - if (field === null || field instanceof Uint8Array) return field - throw new Error(`stored ${key} must be a blob or null`) -} - -function integerField(value: unknown, key: string): number { - const field = record(value, 'SQLite row')[key] - if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer`) - return field as number -} - -function safeIntegerField(value: unknown, key: string): number { - return integerField(value, key) -} - -function nonnegativeSafeIntegerField(value: unknown, key: string): number { - const field = integerField(value, key) - if (field < 0) throw new Error(`stored ${key} must be non-negative`) - return field -} - -function nullableSafeIntegerField(value: unknown, key: string): number | null { - const field = record(value, 'SQLite row')[key] - if (field === null) return null - if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer or null`) - return field as number -} - -function nullableNonnegativeSafeIntegerField(value: unknown, key: string): number | null { - const field = nullableSafeIntegerField(value, key) - if (field !== null && field < 0) throw new Error(`stored ${key} must be non-negative or null`) - return field -} diff --git a/packages/session/session-persistence-sqlite/src/sql.ts b/packages/session/session-persistence-sqlite/src/sql.ts deleted file mode 100644 index 7a72b43a9b..0000000000 --- a/packages/session/session-persistence-sqlite/src/sql.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Closed, package-owned SQL resource loading for SQLite. - * @module @deepseek-ai/dsh-session-persistence-sqlite/sql - */ - -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' - -const SQL_RESOURCES = [ - 'begin', - 'begin-immediate', - 'commit', - 'delete-events-from', - 'foreign-keys-on', - 'insert-event', - 'insert-persistence-state', - 'journal-mode-delete', - 'journal-mode-persist', - 'journal-mode-truncate', - 'journal-mode-wal', - 'mmap-off', - 'page-size', - 'rollback', - 'schema', - 'select-application-id', - 'select-events', - 'select-events-from', - 'select-mmap-size', - 'select-packed-predecessors', - 'select-schema-objects', - 'select-session', - 'select-session-key', - 'select-sessions', - 'select-store-id', - 'select-synchronous', - 'select-tail-events', - 'select-trusted-schema', - 'select-user-object-count', - 'select-user-version', - 'set-application-id', - 'set-user-version-20', - 'synchronous-full', - 'trusted-schema-off', - 'update-session-revision', - 'upsert-session', -] as const - -/** A resource basename selected exclusively by package code. */ -export type SqlResourceName = typeof SQL_RESOURCES[number] - -const cache = new Map() - -/** - * Load an immutable SQL statement by closed resource name. - * @param name - package-owned resource basename. - * @returns the resource text. - */ -export function sql(name: SqlResourceName): string { - const cached = cache.get(name) - if (cached !== undefined) return cached - const statement = readFileSync( - fileURLToPath(new URL(`../resources/sql/${name}.sql`, import.meta.url)), - 'utf8', - ) - cache.set(name, statement) - return statement -} diff --git a/packages/session/session-persistence-sqlite/src/store.ts b/packages/session/session-persistence-sqlite/src/store.ts deleted file mode 100644 index c43e6c62bc..0000000000 --- a/packages/session/session-persistence-sqlite/src/store.ts +++ /dev/null @@ -1,491 +0,0 @@ -/** - * SQLite storage primitives: transactional append-batch packing, physical - * reads, schema validation, revisions, repair, and lifecycle closure. - * @module @deepseek-ai/dsh-session-persistence-sqlite/store - */ - -import { randomUUID } from 'node:crypto' -import { statSync } from 'node:fs' -import { lstat, mkdir, open } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import type { DatabaseSync, StatementSync } from 'node:sqlite' -import { - type SessionEvent, - type SessionHeader, - type SessionId, -} from '@deepseek-ai/dsh-session' -import { - SessionPersistenceRevision, - type PersistenceBackend, - type SessionPersistenceRevision as PersistenceRevision, - type SessionPersistenceSnapshot, - type StoredPrefix, - type StoredSuffix, -} from '@deepseek-ai/dsh-session-persistence' -import { - MAX_PACKED_ROW_MEMBERS, - packChunkRuns, -} from './codec.ts' -import { - bindRecord, - decodeRow, - scanRows, - type BoundRecord, -} from './compression.ts' -import { - type EventRow, - type JournalMode, - decodeEventRow, - decodeSessionRow, - decodeStoreIdentity, - openDatabase, - validateSchemaForMutation, - rowToMeta, - type SessionRow, -} from './schema.ts' -import { sql } from './sql.ts' - -/** Storage options resolved by the service provider. */ -export interface SqliteStoreOptions { - readonly path: string - readonly journalMode: JournalMode - readonly busyTimeoutMs: number -} - -/** SQLite implementation of the coordinator's physical backend hooks. */ -export class SqliteStore implements PersistenceBackend { - readonly name = 'session-persistence-sqlite' - private db!: DatabaseSync - private databaseConstructor!: typeof import('node:sqlite')['DatabaseSync'] - private storeIdentity!: string - private databasePath!: string - private opened = false - private pathReady: Promise | undefined - private ready: Promise | undefined - - constructor(private readonly options: SqliteStoreOptions) {} - - /** - * Validate filesystem ownership without importing or opening Node SQLite. - * @returns settlement of the store's one path-validation operation. - */ - validatePath(): Promise { - this.pathReady ??= this.preparePath(this.options.path) - return this.pathReady - } - - /** - * Lazily open and validate the database on first persistence use. - * @returns settlement of the store's one database-open operation. - */ - open(): Promise { - this.ready ??= this.openDb() - return this.ready - } - - private async preparePath(path: string): Promise { - const actual = path === ':memory:' ? path : resolve(path) - if (actual !== ':memory:') { - await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) - await validateParentDirectory(dirname(actual)) - await validateDatabaseFileIfPresent(actual) - } - this.databasePath = actual - } - - private async openDb(): Promise { - await this.validatePath() - if (this.databasePath !== ':memory:') { - await createDatabaseFile(this.databasePath) - await validateDatabaseFile(this.databasePath) - } - const { DatabaseSync } = await loadNodeSqlite() - this.databaseConstructor = DatabaseSync - this.db = await openDatabase( - DatabaseSync, - this.databasePath, - this.options.journalMode, - this.options.busyTimeoutMs, - ) - try { - const row = this.db.prepare(sql('select-store-id')).get() - if (row === undefined) { - throw new Error(`session database at "${this.databasePath}" has no valid store identity`) - } - let storeId: string - try { - storeId = decodeStoreIdentity(row) - } catch (error: unknown) { - throw new Error(`session database at "${this.databasePath}" has no valid store identity`, { cause: error }) - } - if (this.databasePath === ':memory:') { - this.storeIdentity = `memory:store:${storeId}` - } else { - const identity = statSync(this.databasePath, { bigint: true }) - this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${storeId}` - } - this.opened = true - } catch (error: unknown) { - this.db.close() - throw error - } - } - - async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { - await this.observe(signal) - const snapshot = this.readTransaction(() => { - const row = this.rowFor(id) - if (row === undefined) return undefined - const eventRows = this.db.prepare(sql('select-events')).all(this.sessionKey(id)).map(decodeEventRow) - return { row, eventRows } - }) - signal?.throwIfAborted() - if (snapshot === undefined) return undefined - const scanned = scanRows(snapshot.eventRows) - return { - meta: rowToMeta(snapshot.row), - events: scanned.preserved, - revision: sqliteRevision(this.storeIdentity, snapshot.row), - ...scanned.tornFrom === undefined ? {} : { tornMarker: scanned.tornFrom }, - } - } - - async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise { - await this.observe(signal) - const row = this.rowFor(id) - signal?.throwIfAborted() - return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row) - } - - async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { - await this.observe(signal) - const snapshot = this.readTransaction(() => { - const row = this.rowFor(id) - if (row === undefined) return undefined - return { row, ...this.physicalSpanFrom(this.sessionKey(id), fromSeq) } - }) - signal?.throwIfAborted() - if (snapshot === undefined) return undefined - const { preserved } = scanRows(snapshot.eventRows, snapshot.base) - return { meta: rowToMeta(snapshot.row), events: preserved.filter(event => event.seq >= fromSeq) } - } - - async appendBatch( - meta: SessionHeader, - events: readonly SessionEvent[], - isMaterialized: boolean, - ): Promise { - await this.open() - if (events.length === 0) return - this.db.exec(sql('begin-immediate')) - try { - validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath) - const sessionKey = isMaterialized ? this.sessionKey(meta.id) : this.writeRow(meta) - const tailRows = this.tailRows(sessionKey) - const currentLast = this.logicalLastEvent(meta.id, tailRows) - const expected = currentLast === undefined ? 0 : currentLast.seq + 1 - const first = events[0] as SessionEvent - if (first.seq !== expected) { - throw new Error(`session ${meta.id} append starts at seq ${first.seq}, stored next seq is ${expected}`) - } - - const insert = this.insertStatement() - for (const record of packChunkRuns(events)) this.insertRecord(insert, sessionKey, bindRecord(record)) - this.incrementRevision(meta.id) - this.db.exec(sql('commit')) - } catch (error: unknown) { - this.rollback(error, 'append') - } - } - - async materializeHeader(meta: SessionHeader): Promise { - await this.open() - this.db.exec(sql('begin-immediate')) - try { - validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath) - this.writeRow(meta) - this.db.exec(sql('commit')) - } catch (error: unknown) { - /* v8 ignore next -- validate/write failure uses the same transaction rollback path covered by append and repair. */ - this.rollback(error, 'materialize empty session') - } - } - - async commitRepair( - meta: SessionHeader, - tornMarker: number | undefined, - closers: readonly SessionEvent[], - ): Promise { - await this.open() - if (tornMarker === undefined && closers.length === 0) return - this.db.exec(sql('begin-immediate')) - try { - validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath) - const row = this.rowFor(meta.id) - if (row === undefined) throw new Error(`session ${meta.id} metadata row is missing`) - const sessionKey = this.sessionKey(meta.id) - const currentRows = this.db.prepare(sql('select-events')).all(sessionKey).map(decodeEventRow) - const current = scanRows(currentRows) - if (tornMarker !== undefined) { - if (current.tornFrom !== tornMarker) { - throw new Error(`session ${meta.id} repair is stale: physical tail no longer starts at seq ${tornMarker}`) - } - this.db.prepare(sql('delete-events-from')) - .run(sessionKey, tornMarker) - } else if (current.tornFrom !== undefined) { - throw new Error(`session ${meta.id} repair omitted current torn tail at seq ${current.tornFrom}`) - } - if (closers.length > 0) { - const expected = current.preserved.at(-1)?.seq === undefined - ? 0 - : (current.preserved.at(-1) as SessionEvent).seq + 1 - if (closers[0]?.seq !== expected) { - throw new Error(`session ${meta.id} repair is stale: closer starts at seq ${closers[0]?.seq}, stored next seq is ${expected}`) - } - const insert = this.insertStatement() - for (const closer of closers) this.insertRecord(insert, sessionKey, bindRecord(closer)) - } - this.incrementRevision(meta.id) - this.db.exec(sql('commit')) - } catch (error: unknown) { - this.rollback(error, 'repair') - } - } - - async list(signal?: AbortSignal): Promise { - await this.observe(signal) - const rows = this.sessionRows() - signal?.throwIfAborted() - return rows.map(rowToMeta) - } - - /** - * Return every materialized header with its source-qualified revision. - * @param signal - optional cancellation before or after the metadata query. - * @returns stored headers and revisions without loading event rows. - */ - async listSnapshots(signal?: AbortSignal): Promise { - await this.observe(signal) - const rows = this.sessionRows() - signal?.throwIfAborted() - return rows.map(row => ({ - header: rowToMeta(row), - revision: sqliteRevision(this.storeIdentity, row), - })) - } - - async close(): Promise { - if (this.ready === undefined) { - if (this.pathReady !== undefined) await Promise.allSettled([this.pathReady]) - return - } - await Promise.allSettled([this.ready]) - if (!this.opened) return - this.opened = false - this.db.close() - } - - private rowFor(id: SessionId): SessionRow | undefined { - const value = this.db.prepare(sql('select-session')).get(id) - return value === undefined ? undefined : decodeSessionRow(value) - } - - private sessionKey(id: SessionId): number { - const row = this.db.prepare(sql('select-session-key')).get(id) as { id: number } | undefined - if (row === undefined) throw new Error(`session ${id} metadata row is missing`) - return row.id - } - - private async observe(signal: AbortSignal | undefined): Promise { - signal?.throwIfAborted() - await this.open() - signal?.throwIfAborted() - } - - private readTransaction(read: () => T): T { - this.db.exec(sql('begin')) - try { - const value = read() - this.db.exec(sql('commit')) - return value - } catch (error: unknown) { - this.rollback(error, 'read') - } - } - - private sessionRows(): SessionRow[] { - return this.db.prepare(sql('select-sessions')).all().map(decodeSessionRow) - } - - private rollback(error: unknown, operation: string): never { - try { - this.db.exec(sql('rollback')) - } catch (rollbackError: unknown) { - /* v8 ignore next -- requires SQLite to fail both an operation and its immediate rollback. */ - throw new AggregateError([error, rollbackError], `${this.name} ${operation} failed and rollback also failed`) - } - throw error - } - - private incrementRevision(id: SessionId): void { - const updated = this.db.prepare(sql('update-session-revision')) - .run(id) - /* v8 ignore next -- materialized writes follow coordinator create(); other writes upsert in this transaction. */ - if (Number(updated.changes) !== 1) throw new Error(`session ${id} metadata row is missing`) - } - - private tailRows(sessionKey: number): EventRow[] { - const tail = this.db.prepare(sql('select-tail-events')).all(sessionKey, 2).map(decodeEventRow).reverse() - if (tail.length === 0) return [] - return this.physicalSpanFrom(sessionKey, (tail[0] as EventRow).seq).eventRows - } - - /** Select the bounded physical span that may represent `fromSeq`. */ - private physicalSpanFrom( - sessionKey: number, - fromSeq: number, - ): { readonly base: number; readonly eventRows: EventRow[] } { - const packedFloor = Math.max(0, fromSeq - MAX_PACKED_ROW_MEMBERS + 1) - const packedPredecessors = this.db.prepare(sql('select-packed-predecessors')) - .all(sessionKey, packedFloor, fromSeq) - .map(decodeEventRow) - let base = fromSeq - for (const predecessor of packedPredecessors) { - try { - const last = decodeRow(predecessor).at(-1) - if (last !== undefined && last.seq >= fromSeq) base = Math.min(base, predecessor.seq) - } catch { - // A malformed bounded predecessor may cover fromSeq; include it so the scanner fails closed. - base = Math.min(base, predecessor.seq) - } - } - const eventRows = this.db.prepare(sql('select-events-from')).all(sessionKey, base).map(decodeEventRow) - return { base, eventRows } - } - - private logicalLastEvent(id: SessionId, tailRows: readonly EventRow[]): SessionEvent | undefined { - if (tailRows.length === 0) return undefined - const { preserved, tornFrom } = scanRows(tailRows, (tailRows[0] as EventRow).seq) - if (tornFrom !== undefined) throw new Error(`session ${id} has an invalid physical tail at seq ${tornFrom}`) - return preserved.at(-1) - } - - private insertStatement(): StatementSync { - return this.db.prepare(sql('insert-event')) - } - - private insertRecord(insert: StatementSync, sessionKey: number, record: BoundRecord): void { - insert.run( - sessionKey, - record.seq, - record.type, - record.time, - record.data, - record.sourceEventSeqs, - record.surfaceOp, - record.ignorable, - ) - } - - private writeRow(meta: SessionHeader): number { - const inserted = this.db.prepare(sql('upsert-session')).get( - meta.id, - meta.version, - meta.createdAt, - meta.cwd ?? null, - meta.parentSession ?? null, - meta.seedLength ?? null, - meta.origin ?? null, - meta.delegationDepth ?? null, - meta.agentPreset ?? null, - randomUUID(), - ) as { id: number } - return inserted.id - } -} - -function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision { - return SessionPersistenceRevision( - `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, - ) -} - -async function createDatabaseFile(path: string): Promise { - try { - const handle = await open(path, 'wx', 0o600) - await handle.close() - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - } -} - -async function validateParentDirectory(path: string): Promise { - const parent = await lstat(path) - if (parent.isSymbolicLink() || !parent.isDirectory()) { - throw new Error(`session database parent "${path}" must be a real directory`) - } - const uid = process.getuid?.() - /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful - * uid/mode bits; POSIX tests cover owner and mode rejection. */ - if (uid !== undefined && (parent.uid !== uid || (parent.mode & 0o022) !== 0)) { - throw new Error(`session database parent "${path}" must be owned by the current user and not group/world-writable`) - } - /* v8 ignore stop */ -} - -async function validateDatabaseFile(path: string): Promise { - const file = await lstat(path) - if (file.isSymbolicLink() || !file.isFile()) { - throw new Error(`session database "${path}" must be a regular file, not a symbolic link`) - } - const uid = process.getuid?.() - /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful - * uid/mode bits; POSIX tests cover owner and mode rejection. */ - if (uid !== undefined && (file.uid !== uid || (file.mode & 0o077) !== 0)) { - throw new Error(`session database "${path}" must be owned by the current user and accessible only by that user`) - } - /* v8 ignore stop */ -} - -async function validateDatabaseFileIfPresent(path: string): Promise { - try { - await validateDatabaseFile(path) - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } -} - -let nodeSqlite: Promise | undefined - -/** Load Node SQLite once so concurrent stores share one warning-filter lifetime. */ -function loadNodeSqlite(): Promise { - nodeSqlite ??= importNodeSqlite() - return nodeSqlite -} - -/** Import Node 22's SQLite dependency without its process-wide experimental warning. */ -async function importNodeSqlite(): Promise { - const emitWarning = Reflect.get(process, 'emitWarning') - /* v8 ignore start -- Node 22 alone emits this warning; primary coverage runs on Node 24. */ - const filteredEmitWarning = (warning: string | Error, ...args: unknown[]): void => { - const message = warning instanceof Error ? warning.message : warning - const first = args[0] - const type = warning instanceof Error - ? warning.name - : typeof first === 'string' - ? first - : typeof first === 'object' && first !== null && 'type' in first - ? first.type - : undefined - if (message === 'SQLite is an experimental feature and might change at any time' - && type === 'ExperimentalWarning') return - Reflect.apply(emitWarning, process, [warning, ...args]) - } - Reflect.set(process, 'emitWarning', filteredEmitWarning) - try { - return await import('node:sqlite') - } finally { - Reflect.set(process, 'emitWarning', emitWarning) - } - /* v8 ignore stop */ -} diff --git a/packages/session/session-persistence-sqlite/tests/built-package.spec.ts b/packages/session/session-persistence-sqlite/tests/built-package.spec.ts deleted file mode 100644 index ebbe2fc691..0000000000 --- a/packages/session/session-persistence-sqlite/tests/built-package.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { describe, expect, it } from 'vitest' - -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const builtBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) -const execFileAsync = promisify(execFile) - -const probe = String.raw` -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -const load = path => import(pathToFileURL(resolve(path)).href); -const [{ Context }, { default: SessionStore }, { default: Sqlite }] = await Promise.all([ - load('vendor/cordis/lib/index.js'), - load('packages/core/session/lib/index.js'), - load('packages/session/session-persistence-sqlite/lib/index.js'), -]); -const ctx = new Context(); -await ctx.plugin(SessionStore); -await ctx.plugin(Sqlite, { path: ':memory:' }); -console.log(JSON.stringify(await ctx.sessionPersistence.list())); -await ctx.fiber.dispose(); -` - -describe.skipIf(!existsSync(builtBundle))('SQLite built package', () => { - it('loads packaged SQL resources from the published entry', async () => { - const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], { - cwd: repoRoot, - timeout: 15_000, - }) - expect(stderr).toBe('') - expect(JSON.parse(stdout) as unknown).toEqual([]) - }) -}) diff --git a/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts deleted file mode 100644 index 82dcb365a3..0000000000 --- a/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session' - -vi.mock('node:zlib', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - zstdCompressSync: (input: ArrayBufferView) => Buffer.alloc(input.byteLength + 1), - } -}) - -import { bindRecord } from '../src/compression.ts' - -describe('SQLite compression fallback', () => { - it('keeps data as text when its Zstandard frame is not smaller', () => { - const event = { - type: 'assistant/message', - seq: 0, - time: 1, - data: { text: 'x' }, - } as unknown as SessionEvent - - expect(typeof bindRecord(event).data).toBe('string') - }) -}) diff --git a/packages/session/session-persistence-sqlite/tests/compression.spec.ts b/packages/session/session-persistence-sqlite/tests/compression.spec.ts deleted file mode 100644 index f9d3eb70cc..0000000000 --- a/packages/session/session-persistence-sqlite/tests/compression.spec.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { zstdCompressSync } from 'node:zlib' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' -import { - decodeStorageRecord, - MAX_PACKED_DATA_BYTES, - MAX_PACKED_ROW_MEMBERS, - packChunkRuns, - type StorageRecord, -} from '../src/codec.ts' -import { - bindRecord, - decodeRow, - scanRows, -} from '../src/compression.ts' -import type { EventRow } from '../src/schema.ts' - -function chunk(seq: number, text = `token-${seq}`): SessionEvent { - return { - type: 'assistant/chunk', - seq, - time: 1_000 + seq, - data: { - turn: 1, - step: 1, - chunk: { type: 'text-delta', index: 0, text }, - }, - } -} - -function event(seq: number, time: number, value: StreamChunk, turn = 1, step = 1): SessionEvent { - return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk: value } } -} - -function row(record: StorageRecord): EventRow { - const bound = bindRecord(record) - return { - seq: bound.seq, - type: bound.type, - time: bound.time, - data: bound.data, - source_event_seqs: bound.sourceEventSeqs, - surface_op: bound.surfaceOp, - ignorable: bound.ignorable, - } -} - -describe('SQLite compression', () => { - it('pins the schema-20 dictionary bytes', () => { - const dictionary = readFileSync(new URL('../resources/zstd-dictionary.bin', import.meta.url)) - expect(createHash('sha256').update(dictionary).digest('hex')) - .toBe('dad18fa0247a8fdd886a62d8552eabd36cbd50c25af172873080d2f0ae770d17') - }) - - it('stores a 100-member run in one row and restores every logical event', () => { - const events = Array.from({ length: 100 }, (_, index) => chunk(index)) - const records = packChunkRuns(events) - expect(records).toHaveLength(1) - expect(records[0]?.type).toBe('text-chunks') - expect(scanRows(records.map(row)).preserved).toEqual(events) - }) - - it('partitions long and large runs within schema-owned row limits', () => { - const long = Array.from({ length: MAX_PACKED_ROW_MEMBERS + 3 }, (_, index) => chunk(index)) - const longRecords = packChunkRuns(long) - expect(longRecords).toHaveLength(2) - expect(scanRows(longRecords.map(row)).preserved).toEqual(long) - - const large = Array.from({ length: 4 }, (_, index) => chunk(index, 'x'.repeat(300_000))) - const largeRecords = packChunkRuns(large) - expect(largeRecords).toHaveLength(2) - for (const record of largeRecords) { - if (record.type.endsWith('-chunks')) { - expect(Buffer.byteLength(JSON.stringify(record.data))).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES) - } - } - expect(scanRows(largeRecords.map(row)).preserved).toEqual(large) - - const individuallyLarge = Array.from({ length: 3 }, (_, index) => chunk(index, 'x'.repeat(400_000))) - expect(packChunkRuns(individuallyLarge)).toEqual(individuallyLarge) - - const byteBound = Array.from({ length: 10 }, (_, index) => chunk(index, 'x'.repeat(150_000))) - const byteBoundRecords = packChunkRuns(byteBound) - expect(byteBoundRecords.length).toBeGreaterThan(1) - expect(scanRows(byteBoundRecords.map(row)).preserved).toEqual(byteBound) - }) - - it('packs every owned kind and preserves optional tool-call names', () => { - const events = [ - ...[0, 1, 2].map(seq => event(seq, seq, { type: 'reasoning-delta', index: 1, text: `${seq}` })), - ...[3, 4, 5].map(seq => event(seq, seq, { - type: 'tool-call-delta', index: 2, id: ToolCallId('named'), name: 'write', argumentsDelta: `${seq}`, - })), - ...[6, 7, 8].map(seq => event(seq, seq, { - type: 'tool-call-delta', index: 3, id: ToolCallId('unnamed'), argumentsDelta: `${seq}`, - })), - ] - const records = packChunkRuns(events) - expect(records.map(record => record.type)).toEqual([ - 'reasoning-chunks', 'tool-call-chunks', 'tool-call-chunks', - ]) - expect(records.flatMap(decodeStorageRecord)).toEqual(events) - }) - - it('keeps every off-format delta scalar and splits incompatible runs', () => { - const malformed = (seq: number, data: unknown): SessionEvent => ({ - type: 'assistant/chunk', seq, time: 10 + seq, data, - } as SessionEvent) - const values: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { ...chunk(1), extra: true } as unknown as SessionEvent, - { ...chunk(-1), seq: -1 }, - { ...chunk(3), time: 1.5 }, - malformed(4, 'data'), - malformed(5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' }, extra: 1 }), - malformed(6, { turn: '1', step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }), - malformed(7, { turn: 1, step: 1, chunk: 'chunk' }), - malformed(8, { turn: 1, step: 1, chunk: { type: 'text-delta', index: '0', text: 'x' } }), - malformed(9, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 1 } }), - malformed(10, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 1, argumentsDelta: 'x' } }), - malformed(11, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'id', name: 1, argumentsDelta: 'x' } }), - malformed(12, { turn: 1, step: 1, chunk: { type: 'usage', index: 0, totalTokens: 1 } }), - ] - expect(packChunkRuns(values)).toEqual(values) - - const gap = [chunk(0), chunk(1), chunk(3)] - const step = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 0, text: 'x' }, 1, 2)] - const block = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 1, text: 'x' })] - const unsafeTime = [ - event(0, Number.MIN_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'a' }), - event(1, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'b' }), - event(2, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'c' }), - ] - const toolName = [0, 1, 2].map(seq => event(seq, seq, { - type: 'tool-call-delta', index: 0, id: ToolCallId('id'), - ...seq === 2 ? {} : { name: 'write' }, argumentsDelta: 'x', - })) - for (const events of [gap, step, block, unsafeTime, toolName]) { - expect(packChunkRuns(events)).toEqual(events) - } - }) - - it.each([ - ['extra envelope field', { type: 'text-chunks', seq0: 0, time0: 1, data: {}, extra: true }], - ['negative sequence', { type: 'text-chunks', seq0: -1, time0: 1, data: {} }], - ['fractional time', { type: 'text-chunks', seq0: 0, time0: 1.5, data: {} }], - ['primitive data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'bad' }], - ['text fields', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: [] } }], - ['non-numeric placement', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: '1', step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }], - ['non-array members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: 'abc' } }], - ['too few members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }], - ['too many members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: Array(1_024).fill(0), texts: Array(1_025).fill('a') } }], - ['non-string member', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 1, 'c'] } }], - ['invalid gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0.5], texts: ['a', 'b', 'c'] } }], - ['non-array gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: '00', texts: ['a', 'b', 'c'] } }], - ['gap arity', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b', 'c'] } }], - ['oversized data', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['x'.repeat(400_000), 'x'.repeat(400_000), 'x'.repeat(400_000)] } }], - ['sequence overflow', { type: 'text-chunks', seq0: Number.MAX_SAFE_INTEGER, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }], - ['time overflow', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 1, index: 0, dt: [1, 0], texts: ['a', 'b', 'c'] } }], - ['tool fields', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], args: ['a', 'b', 'c'] } }], - ['tool id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }], - ['tool name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 'id', name: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }], - ])('rejects malformed packed data: %s', (_label, record) => { - expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/) - }) - - it('decodes the schema-20 row vocabulary without another package codec', () => { - const fixture: EventRow = { - seq: 7, - type: 'text-chunks', - time: 90, - data: JSON.stringify({ turn: 2, step: 3, index: 1, dt: [2, -1], texts: ['a', 'b', 'c'] }), - source_event_seqs: null, - surface_op: null, - ignorable: 0, - } - expect(decodeRow(fixture)).toEqual([ - { ...chunk(7, 'a'), time: 90, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'a' } } }, - { ...chunk(8, 'b'), time: 92, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'b' } } }, - { ...chunk(9, 'c'), time: 91, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'c' } } }, - ]) - expect(decodeStorageRecord('scalar')).toEqual(['scalar']) - expect(decodeStorageRecord(chunk(0))).toEqual([chunk(0)]) - }) - - it('rejects surface columns on packed rows', () => { - const packed = row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!) - const invalid: EventRow[] = [ - { ...packed, source_event_seqs: Buffer.alloc(0) }, - { ...packed, surface_op: '"append"' }, - ] - for (const candidate of invalid) { - expect(() => decodeRow(candidate)).toThrow(/surface fields must be null/) - } - }) - - it('rejects the packed discriminator on a scalar event type', () => { - const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) - expect(() => decodeRow({ ...scalar, ignorable: 0 })) - .toThrow(/packed discriminator requires a chunk tag/) - }) - - it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])( - 'preserves an ignorable logical event named %s as a scalar row', - (type) => { - const logical = { - type, - seq: 0, - time: 1, - data: { future: true }, - ignorable: true, - } as unknown as SessionEvent - const physical = row(logical) - expect(physical.ignorable).toBe(1) - expect(decodeRow(physical)).toEqual([logical]) - }, - ) - - it('compresses small repetitive data with the shared dictionary', () => { - const event = { - type: 'tool/result', - seq: 1, - time: 2, - data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'hello world '.repeat(40) }] } }, - sourceEventSeqs: [0], - surfaceOp: 'append', - } as unknown as SessionEvent - const bound = bindRecord(event) - expect(bound.data).toBeInstanceOf(Uint8Array) - expect(decodeRow(row(event))).toEqual([event]) - }) - - it('compresses large data and run-encodes consecutive provenance arrays', () => { - const sources = Array.from({ length: 2_000 }, (_, index) => index + 10) - const event = { - type: 'assistant/message', - seq: sources.at(-1)! + 1, - time: 1, - data: { text: 'x'.repeat(8_192) }, - sourceEventSeqs: sources, - surfaceOp: 'append', - } as unknown as SessionEvent - const bound = bindRecord(event) - expect(bound.data).toBeInstanceOf(Uint8Array) - expect(bound.sourceEventSeqs).toBeInstanceOf(Uint8Array) - expect(bound.sourceEventSeqs?.[0]).toBe(1) - expect(bound.sourceEventSeqs?.byteLength).toBeLessThan(Buffer.byteLength(JSON.stringify(sources))) - expect(decodeRow(row(event))).toEqual([event]) - - const small = bindRecord({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) - expect(typeof small.data).toBe('string') - }) - - it('round-trips empty, descending, and maximum-safe provenance deltas', () => { - for (const sources of [ - [], - [1, 3, 4, 5, 10], - [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2], - ]) { - const event = { - type: 'assistant/message', - seq: Number.MAX_SAFE_INTEGER, - time: 1, - data: {}, - sourceEventSeqs: sources, - surfaceOp: 'append', - } as unknown as SessionEvent - expect(decodeRow(row(event))).toEqual([event]) - } - }) - - it('does not impose a persistence-only provenance length limit', () => { - const sources = Array.from({ length: 1_000_001 }, (_, index) => index) - const event = { - type: 'assistant/message', - seq: sources.length, - time: 1, - data: {}, - sourceEventSeqs: sources, - surfaceOp: 'append', - } as unknown as SessionEvent - expect(bindRecord(event).sourceEventSeqs?.[0]).toBe(1) - }) - - it.each([-1, 0.5])('rejects invalid provenance sequence %s before encoding', (sourceSeq) => { - const event = { - type: 'assistant/message', - seq: 1, - time: 1, - data: {}, - sourceEventSeqs: [sourceSeq], - surfaceOp: 'append', - } as unknown as SessionEvent - expect(() => bindRecord(event)).toThrow(/non-negative safe integers/) - }) - - it('rejects malformed compressed and delta-encoded values', () => { - const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) - expect(() => decodeRow({ ...scalar, data: Buffer.from('not zstd') })).toThrow() - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00]) })) - .toThrow(/truncated tagged payload/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01]) })) - .toThrow(/truncated tagged payload/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x02, 0x00]) })) - .toThrow(/unknown encoding tag/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x80]) })) - .toThrow(/truncated varint/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x80, 0x00]) })) - .toThrow(/non-canonical varint/) - // tag 0, first value 0, then a negative delta (zigzag 0x01) from 0 - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x00, 0x01]) })) - .toThrow(/decoded seq is out of range/) - // tag 0, first value MAX_SAFE_INTEGER, then a positive delta overflowing it - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([ - 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, - ]) })).toThrow(/decoded seq is out of range/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([ - 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10, - ]) })).toThrow(/varint is out of range/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.concat([ - Buffer.from([0x00]), Buffer.alloc(9, 0x80), - ]) })) - .toThrow(/varint is out of range/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01, 0x00, 0x01]) })) - .toThrow(/run exceeds its event sequence/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01, 0x00, 0x00]) })) - .toThrow(/run count must be positive/) - expect(() => decodeRow({ ...scalar, seq: 2, source_event_seqs: Buffer.from([0x01, 0x00, 0x01, 0x00, 0x01]) })) - .toThrow(/runs must ascend/) - }) - - it('rejects an oversized packed data column before JSON decoding', () => { - const oversized: EventRow = { - seq: 0, - type: 'text-chunks', - time: 1, - data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1), - source_event_seqs: null, - surface_op: null, - ignorable: 0, - } - expect(() => decodeRow(oversized)).toThrow(/data exceeds/) - }) - - it('bounds packed data while decompressing', () => { - const serialized = JSON.stringify({ - turn: 1, - step: 1, - index: 0, - dt: [0, 0], - texts: ['x'.repeat(MAX_PACKED_DATA_BYTES), 'b', 'c'], - }) - const oversized: EventRow = { - seq: 0, - type: 'text-chunks', - time: 1, - data: zstdCompressSync(serialized), - source_event_seqs: null, - surface_op: null, - ignorable: 0, - } - expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/) - }) - - it('distinguishes removable and committed physical corruption', () => { - const start = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) - const skipped = row({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }) - expect(scanRows([start, skipped])).toEqual({ preserved: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - ], tornFrom: 2 }) - - const end = row({ - type: 'turn/end', - seq: 3, - time: 3, - data: { turn: 1, reason: { kind: 'completed' } }, - }) - expect(() => scanRows([start, skipped, end])).toThrow(/invalid committed physical row at seq 2/) - - const malformed = { - ...row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!), - data: '{not json', - } - const committedEnd = row({ - type: 'turn/end', - seq: 1, - time: 4, - data: { turn: 1, reason: { kind: 'completed' } }, - }) - expect(() => scanRows([malformed, committedEnd])) - .toThrow(/invalid committed physical row at seq 0/) - }) - - it('treats a malformed packed tail as one removable physical row', () => { - const malformed: EventRow = { - seq: 0, - type: 'text-chunks', - time: 1, - data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }), - source_event_seqs: null, - surface_op: null, - ignorable: 0, - } - expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 }) - }) -}) diff --git a/packages/session/session-persistence-sqlite/tests/differential.spec.ts b/packages/session/session-persistence-sqlite/tests/differential.spec.ts deleted file mode 100644 index 000c0bc78d..0000000000 --- a/packages/session/session-persistence-sqlite/tests/differential.spec.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import fc from 'fast-check' -import { Context } from '@deepseek-ai/cordis' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' -import { meta } from '../../session-persistence/tests/contract.ts' -import { testSql } from './test-sql.ts' - -type BackendName = 'jsonl-zstd' | 'sqlite' - -interface MountedBackend { - readonly persistence: SessionPersistence - dispose(): Promise -} - -const directories: string[] = [] -afterEach(async () => { - for (const directory of directories.splice(0)) { - await rm(directory, { recursive: true, force: true }) - } -}) - -async function freshDirectory(prefix: string): Promise { - const directory = await mkdtemp(join(tmpdir(), prefix)) - directories.push(directory) - return directory -} - -async function mount(name: BackendName, root: string): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - switch (name) { - case 'jsonl-zstd': { - const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: join(root, 'jsonl') }) - return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } } - } - case 'sqlite': { - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: join(root, 'sessions.db') }) - return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } } - } - } -} - -function closedChunkLog( - entries: readonly { readonly chunk: StreamChunk; readonly time: number; readonly ignorable?: true }[], -): SessionEvent[] { - const chunks = entries.map(({ chunk, time, ignorable }, index): SessionEvent => ({ - type: 'assistant/chunk', - seq: index + 2, - time, - data: { turn: 1, step: 1, chunk }, - ...ignorable === true ? { ignorable } : {}, - })) - return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, - ...chunks, - { type: 'step/end', seq: chunks.length + 2, time: 3, data: { turn: 1, step: 1 } }, - { - type: 'turn/end', - seq: chunks.length + 3, - time: 4, - data: { turn: 1, reason: { kind: 'completed' } }, - }, - ] -} - -function packingMatrixLog(): SessionEvent[] { - const entries: { chunk: StreamChunk; time: number; ignorable?: true }[] = [ - ...Array.from({ length: 5 }, (_, index) => ({ - chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` }, - time: 1_000 + index, - })), - ...Array.from({ length: 4 }, (_, index) => ({ - chunk: { type: 'reasoning-delta' as const, index: 1, text: `reason-${index}` }, - time: 990 - index, - })), - ...Array.from({ length: 4 }, (_, index) => ({ - chunk: { - type: 'tool-call-delta' as const, - index: 2, - id: ToolCallId('named-call'), - name: 'write', - argumentsDelta: `{${index}`, - }, - time: 2_000 + index, - })), - ...Array.from({ length: 3 }, (_, index) => ({ - chunk: { - type: 'tool-call-delta' as const, - index: 3, - id: ToolCallId('unnamed-call'), - argumentsDelta: `${index}}`, - }, - time: 3_000 + index, - })), - { chunk: { type: 'block-start', index: 4, blockType: 'text' }, time: 4_000 }, - { chunk: { type: 'text-delta', index: 4, text: 'short-a' }, time: 4_001 }, - { chunk: { type: 'text-delta', index: 4, text: 'short-b' }, time: 4_002 }, - { chunk: { type: 'text-delta', index: 5, text: 'scalar-envelope' }, time: 4_003, ignorable: true }, - { chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 }, - ] - return closedChunkLog(entries) -} - -function storageTagCollisionLog(): SessionEvent[] { - return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - ...['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].map((type, index) => ({ - type, - seq: index + 1, - time: index + 2, - data: { future: true }, - ignorable: true as const, - }) as unknown as SessionEvent), - { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, - ] -} - -function batches(events: readonly SessionEvent[], sizes: readonly number[]): SessionEvent[][] { - const result: SessionEvent[][] = [] - let offset = 0 - let index = 0 - while (offset < events.length) { - const size = sizes[index % sizes.length] as number - result.push(events.slice(offset, offset + size)) - offset += size - index += 1 - } - return result -} - -async function verifyBackend( - name: BackendName, - root: string, - events: readonly SessionEvent[], - sizes: readonly number[], -): Promise { - const header = { ...meta('differential', '/work'), delegationDepth: 0 } - let mounted = await mount(name, root) - try { - await mounted.persistence.create(header) - for (const batch of batches(events, sizes)) { - await mounted.persistence.append(header.id, batch) - } - expect(await mounted.persistence.inspect(header.id), name).toEqual({ meta: header, events }) - expect(await mounted.persistence.list(), name).toEqual([header]) - const revision = (await mounted.persistence.listSnapshots())[0]?.revision - for (let fromSeq = 0; fromSeq <= events.length + 1; fromSeq += 1) { - expect((await mounted.persistence.readFrom(header.id, fromSeq)).events, `${name} seq ${fromSeq}`) - .toEqual(events.slice(fromSeq)) - } - expect((await mounted.persistence.listSnapshots())[0]?.revision, name).toBe(revision) - } finally { - await mounted.dispose() - } - - mounted = await mount(name, root) - try { - expect(await mounted.persistence.inspect(header.id), `${name} reopen`).toEqual({ meta: header, events }) - } finally { - await mounted.dispose() - } -} - -const streamChunkArbitrary: fc.Arbitrary = fc.oneof( - fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }), - fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }), - fc.record({ - type: fc.constant<'tool-call-delta'>('tool-call-delta'), - index: fc.nat(2), - id: fc.constantFrom(ToolCallId('call-1'), ToolCallId('call-2')), - argumentsDelta: fc.string(), - }), - fc.record({ - type: fc.constant<'tool-call-delta'>('tool-call-delta'), - index: fc.nat(2), - id: fc.constantFrom(ToolCallId('call-1'), ToolCallId('call-2')), - name: fc.constantFrom('read', 'write'), - argumentsDelta: fc.string(), - }), - fc.record({ - type: fc.constant<'block-start'>('block-start'), - index: fc.nat(2), - blockType: fc.constant<'text'>('text'), - }), - fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }), -) - -const randomWorkload = fc.record({ - entries: fc.array(fc.record({ - chunk: streamChunkArbitrary, - time: fc.oneof( - { weight: 4, arbitrary: fc.integer({ min: 0, max: 10_000 }) }, - { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) }, - ), - ignorable: fc.option(fc.constant(true), { nil: undefined }), - }), { maxLength: 30 }), - batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }), -}).map(({ entries, batchSizes }) => ({ - events: JSON.parse(JSON.stringify(closedChunkLog(entries.map(({ chunk, time, ignorable }) => ({ - chunk, - time, - ...ignorable === true ? { ignorable } : {}, - }))))) as SessionEvent[], - batchSizes, -})) - -const randomizedDifferentialTimeoutMs = process.platform === 'win32' ? 120_000 : 60_000 - -describe('SQLite cross-backend differential behavior', () => { - it('preserves ignorable logical events whose names match physical storage tags', async () => { - const events = storageTagCollisionLog() - const directory = await freshDirectory('dsh-sqlite-storage-tag-collision-') - const root = join(directory, 'sqlite') - await verifyBackend('sqlite', root, events, [2, 1]) - const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true }) - try { - expect(db.prepare(testSql('count-physical-types')).all()).toEqual([]) - expect(db.prepare(testSql('count-ignorable-events')).get()).toEqual({ count: 3 }) - } finally { - db.close() - } - }) - - it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => { - const events = packingMatrixLog() - for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) { - const directory = await freshDirectory(`dsh-sqlite-matrix-${partitionIndex}-`) - for (const name of ['jsonl-zstd', 'sqlite'] as const) { - const root = join(directory, name) - await verifyBackend(name, root, events, sizes) - if (name === 'sqlite') { - const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true }) - try { - expect(db.prepare(testSql('count-physical-types')).all()).toEqual([ - [ - { type: 'reasoning-chunks', count: 1 }, - { type: 'text-chunks', count: 1 }, - { type: 'tool-call-chunks', count: 2 }, - ], - [], - [ - { type: 'reasoning-chunks', count: 1 }, - { type: 'text-chunks', count: 1 }, - { type: 'tool-call-chunks', count: 1 }, - ], - ][partitionIndex]) - expect(db.prepare(testSql('count-ignorable-events')).get()) - .toEqual({ count: 1 }) - } finally { - db.close() - } - } - } - } - }, 30_000) - - it('matches JSONL/Zstandard across randomized logical logs and append partitions', async () => { - await fc.assert(fc.asyncProperty(randomWorkload, async ({ events, batchSizes }) => { - const directory = await freshDirectory('dsh-sqlite-property-') - for (const name of ['jsonl-zstd', 'sqlite'] as const) { - await verifyBackend(name, join(directory, name), events, batchSizes) - } - }), { numRuns: 100, seed: 0x5A17E }) - }, randomizedDifferentialTimeoutMs) - -}) diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql deleted file mode 100644 index bc0d60887b..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE events ADD COLUMN unexpected TEXT; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql deleted file mode 100644 index b335590faa..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql +++ /dev/null @@ -1,2 +0,0 @@ -SELECT COUNT(*) AS count -FROM events; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql deleted file mode 100644 index 4de44570ec..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT COUNT(*) AS count -FROM events -WHERE ignorable = 1; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql deleted file mode 100644 index 1c11b3dd0b..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT COUNT(*) AS count -FROM events -WHERE type = 'text-chunks' AND ignorable = 0; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql deleted file mode 100644 index ba5e7f9d72..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT type, COUNT(*) AS count -FROM events -WHERE type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks') - AND ignorable = 0 -GROUP BY type -ORDER BY type; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql deleted file mode 100644 index 021a47b9f6..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE persistence_state (singleton ANY, store_id ANY); -CREATE TABLE sessions ( - id ANY, version ANY, created_at ANY, cwd ANY, parent_session ANY, - seed_length ANY, origin ANY, delegation_depth ANY, agent_preset ANY, - incarnation ANY, revision ANY -); -CREATE TABLE events ( - session_id ANY, seq ANY, type ANY, time ANY, data ANY, - source_event_seqs ANY, surface_op ANY, ignorable ANY -); -INSERT INTO persistence_state (singleton, store_id) -VALUES (1, '00000000-0000-4000-8000-000000000000'); -PRAGMA application_id = 1146308688; -PRAGMA user_version = 20; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql deleted file mode 100644 index 23c813df9a..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE TABLE unrelated (value TEXT); diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql deleted file mode 100644 index c337450451..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql +++ /dev/null @@ -1 +0,0 @@ -DELETE FROM persistence_state; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql deleted file mode 100644 index 262d42cdb1..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql +++ /dev/null @@ -1,2 +0,0 @@ -DELETE FROM events -WHERE session_id = (SELECT id FROM sessions WHERE session_key = ?) diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql deleted file mode 100644 index 5d3a64c1e5..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql +++ /dev/null @@ -1,3 +0,0 @@ -UPDATE persistence_state -SET store_id = '' -WHERE singleton = 1; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql deleted file mode 100644 index 3faf305fb0..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql +++ /dev/null @@ -1,2 +0,0 @@ -INSERT INTO events (session_id, seq, type, time, data, ignorable) -VALUES ((SELECT id FROM sessions WHERE session_key = ?), ?, ?, ?, ?, ?); diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql deleted file mode 100644 index 3ca0e6bd0c..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT COUNT(*) AS rows, - COALESCE(MAX(length(CAST(data AS BLOB))), 0) AS largest -FROM events; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql deleted file mode 100644 index e39da0a3f4..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql +++ /dev/null @@ -1,14 +0,0 @@ -PRAGMA foreign_keys = OFF; -ALTER TABLE events RENAME TO strict_events; -CREATE TABLE events ( - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - time INTEGER NOT NULL, - data TEXT NOT NULL, - source_event_seqs TEXT, - surface_op TEXT, - ignorable INTEGER, - PRIMARY KEY (session_id, seq) -); -DROP TABLE strict_events; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql deleted file mode 100644 index 91c3167273..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT seq, rowid -FROM events -ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql deleted file mode 100644 index e7126884f6..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, ignorable -FROM events -WHERE session_id = (SELECT id FROM sessions WHERE session_key = ?) -ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql deleted file mode 100644 index 1f842e8e8f..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT seq, type, data -FROM events -WHERE session_id = (SELECT id FROM sessions WHERE session_key = ?) -ORDER BY seq DESC -LIMIT 1; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-page-size.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-page-size.sql deleted file mode 100644 index cac8d70e58..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/select-page-size.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA page_size; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql deleted file mode 100644 index 4edeca1a4d..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql deleted file mode 100644 index 79d31a3a57..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA application_id = 12345; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-page-size-4096.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-page-size-4096.sql deleted file mode 100644 index 5f78e691ec..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/set-page-size-4096.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA page_size = 4096; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql deleted file mode 100644 index fa5f49e3b2..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version = 15; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql deleted file mode 100644 index 0750749350..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version = 16; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql deleted file mode 100644 index 5aac576e8c..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version = 17; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-18.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-18.sql deleted file mode 100644 index 54cfecd52c..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-18.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version = 18; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-19.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-19.sql deleted file mode 100644 index c1082b72f4..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-19.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version = 19; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-20.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-20.sql deleted file mode 100644 index 1882a18a39..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-20.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version = 20; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql deleted file mode 100644 index 2c34aff7f5..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql +++ /dev/null @@ -1,3 +0,0 @@ -UPDATE sessions -SET origin = 'external', delegation_depth = -1, seed_length = -1 -WHERE session_key = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/vacuum.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/vacuum.sql deleted file mode 100644 index 00c684b79d..0000000000 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/vacuum.sql +++ /dev/null @@ -1 +0,0 @@ -VACUUM; diff --git a/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts b/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts deleted file mode 100644 index 8574d1c97f..0000000000 --- a/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { readdir, readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import ts from 'typescript' -import { describe, expect, it } from 'vitest' - -const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url)) -const SQL_LITERAL = /^\s*(?:ALTER|ATTACH|BEGIN|COMMIT|CREATE|DELETE|DETACH|DROP|INSERT|PRAGMA|REINDEX|RELEASE|ROLLBACK|SAVEPOINT|SELECT|UPDATE|VACUUM|WITH)\s/iu // eslint-disable-line @stylistic/max-len - -async function filesUnder(path: string): Promise { - const entries = await readdir(path, { withFileTypes: true }) - return (await Promise.all(entries.map(async entry => entry.isDirectory() - ? filesUnder(`${path}/${entry.name}`) - : [`${path}/${entry.name}`]))).flat() -} - -function sqlLiteralText(node: ts.Node): string | undefined { - if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text - if (node.kind === ts.SyntaxKind.TemplateHead) { - return (node as ts.Node & { readonly text: string }).text - } - return undefined -} - -function isOwnedSqlSource(node: ts.Expression | undefined, source: ts.SourceFile): boolean { - if (node === undefined) return false - if (ts.isCallExpression(node) - && ts.isIdentifier(node.expression) - && (node.expression.text === 'sql' || node.expression.text === 'testSql')) return true - if (!ts.isIdentifier(node) || node.text !== 'source') return false - const call = node.parent - if (!ts.isCallExpression(call) - || call.arguments.length !== 1 - || call.arguments[0] !== node - || !ts.isPropertyAccessExpression(call.expression) - || call.expression.expression.kind !== ts.SyntaxKind.SuperKeyword - || call.expression.name.text !== 'prepare') return false - let method: ts.Node | undefined = node.parent - while (method !== undefined && !ts.isMethodDeclaration(method)) method = method.parent - if (method === undefined - || method.name.getText(source) !== 'prepare' - || method.parameters.length !== 1 - || method.parameters[0]?.name.getText(source) !== 'source') return false - let classNode: ts.Node | undefined = method.parent - while (classNode !== undefined && !ts.isClassExpression(classNode)) classNode = classNode.parent - if (classNode === undefined || classNode.name?.text !== 'JournalFailureDatabase') return false - const guard = method.body?.statements[0] - if (guard === undefined - || !ts.isIfStatement(guard) - || !ts.isBinaryExpression(guard.expression) - || guard.expression.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken - || guard.expression.left.getText(source) !== 'source' - || guard.expression.right.getText(source) !== "sql('journal-mode-wal')") return false - return ts.isReturnStatement(guard.thenStatement) - && guard.thenStatement.expression === call -} - -describe('SQLite SQL resource boundary', () => { - it('keeps statements and query assembly out of TypeScript files', async () => { - const files = (await Promise.all([ - filesUnder(`${PACKAGE_ROOT}/src`), - filesUnder(`${PACKAGE_ROOT}/tests`), - ])).flat().filter(path => path.endsWith('.ts')) - const violations: string[] = [] - for (const path of files) { - const source = ts.createSourceFile(path, await readFile(path, 'utf8'), ts.ScriptTarget.Latest, true) - const usesNodeSqlite = source.statements.some(statement => ts.isImportDeclaration(statement) - && ts.isStringLiteral(statement.moduleSpecifier) - && statement.moduleSpecifier.text === 'node:sqlite') - const visit = (node: ts.Node): void => { - const literal = sqlLiteralText(node) - if (literal !== undefined && SQL_LITERAL.test(literal)) { - violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: SQL literal`) - } - // Awaited prepare() is SessionPersistence; DatabaseSync.prepare() is synchronous. - if (usesNodeSqlite - && ts.isCallExpression(node) - && ts.isPropertyAccessExpression(node.expression) - && (node.expression.name.text === 'exec' - || (node.expression.name.text === 'prepare' && !ts.isAwaitExpression(node.parent)))) { - const argument = node.arguments[0] - if (!isOwnedSqlSource(argument, source)) { - violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: unowned query source`) - } - } - ts.forEachChild(node, visit) - } - visit(source) - } - expect(violations).toEqual([]) - }) - - it('keeps resource text static instead of interpolated', async () => { - const files = (await Promise.all([ - filesUnder(`${PACKAGE_ROOT}/resources/sql`), - filesUnder(`${PACKAGE_ROOT}/tests/resources/sql`), - ])).flat() - for (const path of files) { - expect(path.endsWith('.sql')).toBe(true) - expect(await readFile(path, 'utf8')).not.toContain('${') - } - }) -}) diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts deleted file mode 100644 index 8fd6df8769..0000000000 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ /dev/null @@ -1,888 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { spawn } from 'node:child_process' -import { Context } from '@deepseek-ai/cordis' -import { once } from 'node:events' -import { chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { performance } from 'node:perf_hooks' -import { pathToFileURL } from 'node:url' -import { DatabaseSync } from 'node:sqlite' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import SessionPersistenceSqlite, { - DEFAULT_BUSY_TIMEOUT_MS, - SCHEMA_VERSION, -} from '@deepseek-ai/dsh-session-persistence-sqlite' -import { - runCoordinatorContract, - type CoordinatorFixture, -} from '../../session-persistence/tests/coordinator-contract.ts' -import { - meta, - runPersistenceContract, -} from '../../session-persistence/tests/contract.ts' -import { MAX_PACKED_DATA_BYTES } from '../src/codec.ts' -import { - decodeEventRow, - decodeSessionRow, - decodeStoreIdentity, - openDatabase, - validateSchemaForMutation, - rowToMeta, - SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, - type SessionRow, -} from '../src/schema.ts' -import { SqliteStore } from '../src/store.ts' -import { sql } from '../src/sql.ts' -import { testSql } from './test-sql.ts' - -const dirs: string[] = [] -afterEach(async () => { - for (const directory of dirs.splice(0)) await rm(directory, { recursive: true, force: true }) -}) - -async function freshDbPath(prefix = 'dsh-sqlite-'): Promise { - const directory = await mkdtemp(join(tmpdir(), prefix)) - dirs.push(directory) - return join(directory, 'sessions.db') -} - -async function backendFailure(path: string): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - try { - await ctx.plugin(SessionPersistenceSqlite, { path }) - await ctx.sessionPersistence.list() - return undefined - } catch (error: unknown) { - return error - } finally { - await ctx.fiber.dispose() - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function databaseWithJournalFailure( - nextFailure: () => Error | undefined, -): typeof DatabaseSync { - return class JournalFailureDatabase extends DatabaseSync { - override prepare(source: string) { - if (source !== sql('journal-mode-wal')) return super.prepare(source) - const statement = super.prepare(sql('journal-mode-wal')) - const get = statement.get.bind(statement) - Object.defineProperty(statement, 'get', { - value: () => { - const failure = nextFailure() - if (failure !== undefined) throw failure - return get() - }, - }) - return statement - } - } -} - -function chunk(seq: number, text = `token-${seq}`): SessionEvent { - return { - type: 'assistant/chunk', - seq, - time: 1_000 + seq, - data: { - turn: 1, - step: 1, - chunk: { type: 'text-delta', index: 0, text }, - }, - } -} - -function chunkLog(count: number): SessionEvent[] { - return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, - ...Array.from({ length: count }, (_, index) => chunk(index + 2)), - { type: 'step/end', seq: count + 2, time: count + 3, data: { turn: 1, step: 1 } }, - { - type: 'turn/end', - seq: count + 3, - time: count + 4, - data: { turn: 1, reason: { kind: 'completed' } }, - }, - ] -} - -async function measureWriteTraffic( - path: string, - events: readonly SessionEvent[], -): Promise<{ - readonly walBytes: number - readonly idleWalBytes: number - readonly rows: number - readonly largest: number - readonly inserted: number - readonly changed: number - readonly removed: number -}> { - interface PhysicalRow { - readonly rowid: number - readonly seq: number - readonly type: string - readonly time: number - readonly data: string | Uint8Array - readonly source_event_seqs: Uint8Array | null - readonly surface_op: string | null - readonly ignorable: number | null - } - const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => ( - typeof left === 'string' || left === null - ? left === right - : right instanceof Uint8Array && Buffer.from(left).equals(Buffer.from(right)) - ) - const sameRow = (left: PhysicalRow, right: PhysicalRow): boolean => ( - left.rowid === right.rowid - && left.seq === right.seq - && left.type === right.type - && left.time === right.time - && sameValue(left.data, right.data) - && sameValue(left.source_event_seqs, right.source_event_seqs) - && left.surface_op === right.surface_op - && left.ignorable === right.ignorable - ) - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceSqlite, { path, writeBatchMaxDelayMs: 200 }) - try { - const header = meta('traffic') - await ctx.sessionPersistence.create(header) - let previous = new Map() - let inserted = 0 - let changed = 0 - let removed = 0 - const probe = new DatabaseSync(path, { readOnly: true }) - try { - const selectRows = probe.prepare(testSql('select-event-rows')) - for (let offset = 0; offset < events.length; offset += 40) { - await ctx.sessionPersistence.append(header.id, events.slice(offset, offset + 40)) - const current = new Map((selectRows.all(header.id) as unknown as PhysicalRow[]) - .map(row => [row.seq, row])) - for (const [seq, row] of current) { - const old = previous.get(seq) - if (old === undefined) inserted += 1 - else if (!sameRow(old, row)) changed += 1 - } - for (const seq of previous.keys()) if (!current.has(seq)) removed += 1 - previous = current - } - } finally { - probe.close() - } - const db = new DatabaseSync(path, { readOnly: true }) - const measured = db.prepare(testSql('measure-write-traffic')).get() as { rows: number; largest: number } - db.close() - const walBytes = (await stat(`${path}-wal`)).size - await new Promise(resolve => setTimeout(resolve, 250)) - return { - walBytes, - idleWalBytes: (await stat(`${path}-wal`)).size, - rows: measured.rows, - largest: measured.largest, - inserted, - changed, - removed, - } - } finally { - await ctx.fiber.dispose() - } -} - -runPersistenceContract('sqlite', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) - return { - persistence: ctx.sessionPersistence, - dispose: async () => { await fiber.dispose() }, - } -}) - -runCoordinatorContract('sqlite', async (): Promise => { - const directory = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-')) - const path = join(directory, 'sessions.db') - return { - mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }), - corruptTail: async (id) => { - const db = new DatabaseSync(path) - const last = db.prepare(testSql('select-last-event')) - .get(id) as { seq: number; type: string; data: string } - const logicalLength = last.type === 'text-chunks' - ? (JSON.parse(last.data) as { texts: string[] }).texts.length - : 1 - const next = last.seq + logicalLength - db.prepare(testSql('insert-corrupt-event')) - .run(id, next, 'assistant/chunk', 99, '{not valid json', null) - db.close() - }, - cleanup: async () => { await rm(directory, { recursive: true, force: true }) }, - } -}) - -describe('SessionPersistenceSqlite physical packing', () => { - it('loads from cordis.yml and packs through the assembled service', async () => { - const path = await freshDbPath('dsh-sqlite-loader-') - const configPath = join(path, '..', 'cordis.yml') - await writeFile(configPath, [ - "- name: '@deepseek-ai/dsh-session'", - "- name: '@deepseek-ai/dsh-session-persistence-sqlite'", - ' config:', - ` path: ${JSON.stringify(path)}`, - '', - ].join('\n')) - - const ctx = new Context() - ctx.baseUrl = pathToFileURL(join(path, '..')).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - ctx.loader.internal = { - version: 'sqlite', - async import(specifier: string) { - if (specifier === '@deepseek-ai/dsh-session') return SessionStore - if (specifier === '@deepseek-ai/dsh-session-persistence-sqlite') { - return SessionPersistenceSqlite - } - throw new Error(`unexpected Loader import: ${specifier}`) - }, - } as unknown as NonNullable - await ctx.loader.create({ - name: 'cordis:include', - config: { path: pathToFileURL(configPath).href }, - }) - await ctx.loader.await() - - const header = meta('loader') - const events = chunkLog(4) - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, events) - expect((await ctx.sessionPersistence.inspect(header.id)).events).toEqual(events) - await ctx.fiber.dispose() - - const db = new DatabaseSync(path) - expect(db.prepare(testSql('count-packed-events')).get()) - .toEqual({ count: 1 }) - db.close() - }) - - it('packs each append once without rewriting earlier rows and seeks inside packed rows', async () => { - const path = await freshDbPath() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path }) - const header = meta('packed') - const events = chunkLog(100) - await ctx.sessionPersistence.create(header) - await ctx.sessionPersistence.append(header.id, events.slice(0, 3)) - await ctx.sessionPersistence.append(header.id, events.slice(3, 4)) - const before = new DatabaseSync(path, { readOnly: true }) - const originalRows = before.prepare(testSql('select-event-rowids')).all() - before.close() - await ctx.sessionPersistence.append(header.id, events.slice(4)) - - const inspected = await ctx.sessionPersistence.inspect(header.id) - expect(inspected.events).toEqual(events) - for (const fromSeq of [0, 2, 25, 101, 104, 105]) { - expect((await ctx.sessionPersistence.readFrom(header.id, fromSeq)).events) - .toEqual(events.filter(event => event.seq >= fromSeq)) - } - await fiber.dispose() - - const db = new DatabaseSync(path) - expect(db.prepare(testSql('select-user-version')).get()).toEqual({ user_version: SCHEMA_VERSION }) - expect(db.prepare(testSql('select-page-size')).get()).toEqual({ page_size: 65_536 }) - expect(db.prepare(testSql('count-events')).get()).toEqual({ count: 7 }) - expect(db.prepare(testSql('count-packed-events')).get()) - .toEqual({ count: 1 }) - expect(db.prepare(testSql('select-event-rowids')).all().slice(0, originalRows.length)) - .toEqual(originalRows) - db.close() - }) - - it.runIf(process.platform !== 'win32')('bounds paced-stream WAL extent without rewriting committed rows', async () => { - const events = chunkLog(1_000) - const measured = await measureWriteTraffic(await freshDbPath('dsh-sqlite-traffic-'), events) - - expect(measured).toMatchObject({ rows: 31, inserted: 31, changed: 0, removed: 0 }) - expect(measured.inserted).toBe(measured.rows) - expect(measured.largest).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES) - expect(measured.idleWalBytes).toBe(measured.walBytes) - }) - - it('includes a packed predecessor when an overlapping scalar tail hides it', async () => { - const path = await freshDbPath('dsh-sqlite-overlap-') - const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - const header = meta('overlap') - await store.appendBatch(header, [chunk(0), chunk(1), chunk(2)], false) - - const db = new DatabaseSync(path) - db.prepare(testSql('insert-corrupt-event')) - .run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), null) - db.close() - - expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)]) - - const malformed = new DatabaseSync(path) - malformed.prepare(testSql('delete-session-events')).run(header.id) - malformed.prepare(testSql('insert-corrupt-event')) - .run(header.id, 0, 'text-chunks', 1, '{not json', 0) - malformed.close() - expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([]) - await store.close() - }) - - it('waits for a competing process within the configured busy timeout', async () => { - const path = await freshDbPath('dsh-sqlite-busy-') - const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: 1_000 }) - const header = meta('busy') - await store.appendBatch(header, [chunk(0)], false) - - const holder = spawn(process.execPath, ['--input-type=module', '-e', String.raw` - import { DatabaseSync } from 'node:sqlite'; - const db = new DatabaseSync(process.argv[1]); - db.exec('BEGIN IMMEDIATE'); - process.stdout.write('locked\n'); - setTimeout(() => { db.exec('COMMIT'); db.close(); }, 100); - `, path], { stdio: ['ignore', 'pipe', 'pipe'] }) - const exited = new Promise((resolve, reject) => { - holder.once('error', reject) - holder.once('exit', resolve) - }) - try { - await once(holder.stdout, 'data') - await expect(store.appendBatch(header, [chunk(1)], true)).resolves.toBeUndefined() - const code = await exited - expect(code).toBe(0) - expect((await store.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)]) - } finally { - if (holder.exitCode === null) holder.kill() - await store.close() - } - }) - - it('rejects an older SQLite physical schema', async () => { - const path = await freshDbPath('dsh-sqlite-old-schema-') - const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS) - seed.exec(testSql('set-user-version-16')) - seed.close() - await chmod(path, 0o600) - await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)) - .rejects.toThrow(/schema version 16.*incompatible/) - }) - - it('keeps the page size of an established schema 20 database', async () => { - const path = await freshDbPath('dsh-sqlite-page-size-') - const seed = await openDatabase(DatabaseSync, path, 'delete', DEFAULT_BUSY_TIMEOUT_MS) - seed.close() - - const resize = new DatabaseSync(path) - resize.exec(testSql('set-page-size-4096')) - resize.exec(testSql('vacuum')) - expect(resize.prepare(testSql('select-page-size')).get()).toEqual({ page_size: 4_096 }) - resize.close() - - const reopened = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS) - expect(reopened.prepare(testSql('select-page-size')).get()).toEqual({ page_size: 4_096 }) - reopened.close() - }) - - it('rejects a stale physical append without replacing the winning tail', async () => { - const path = await freshDbPath('dsh-sqlite-stale-') - const first = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - const second = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - const header = meta(SessionId('stale')) - await first.appendBatch(header, [chunk(0)], false) - await second.appendBatch(header, [chunk(1)], true) - await expect(first.appendBatch(header, [chunk(1)], true)).rejects.toThrow(/stored next seq is 2/) - expect((await first.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)]) - await first.close() - await second.close() - }) - - it('rolls back lazy integer-key materialization after a rejected append', async () => { - const store = new SqliteStore({ - path: await freshDbPath('dsh-sqlite-key-rollback-'), - journalMode: 'wal', - busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS, - }) - const header = meta(SessionId('key-rollback')) - - await expect(store.appendBatch(header, [chunk(1)], false)).rejects.toThrow(/stored next seq is 0/) - await expect(store.appendBatch(header, [chunk(0)], true)).rejects.toThrow(/metadata row is missing/) - await expect(store.appendBatch(header, [chunk(0)], false)).resolves.toBeUndefined() - expect((await store.loadStored(header.id))?.events).toEqual([chunk(0)]) - await store.close() - }) - - it('rejects a stale repair without deleting a newer winning tail', async () => { - const path = await freshDbPath('dsh-sqlite-stale-repair-') - const stale = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - const winner = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - const header = meta(SessionId('stale-repair')) - await stale.appendBatch(header, [chunk(0)], false) - const db = new DatabaseSync(path) - db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null) - db.close() - expect((await stale.loadStored(header.id))?.tornMarker).toBe(1) - await winner.commitRepair(header, 1, []) - await winner.appendBatch(header, [chunk(1), chunk(2)], true) - await expect(stale.commitRepair(header, 1, [])).rejects.toThrow(/repair is stale/) - expect((await stale.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1), chunk(2)]) - await stale.close() - await winner.close() - }) -}) - -describe('SessionPersistenceSqlite schema ownership', () => { - it('accepts every configured journal mode and SQLite memory mode result', async () => { - const resources = { - wal: 'journal-mode-wal', - delete: 'journal-mode-delete', - truncate: 'journal-mode-truncate', - persist: 'journal-mode-persist', - } as const - for (const mode of ['wal', 'delete', 'truncate', 'persist'] as const) { - ;(await openDatabase(DatabaseSync, ':memory:', mode, DEFAULT_BUSY_TIMEOUT_MS)).close() - const path = await freshDbPath(`dsh-sqlite-journal-${mode}-`) - const db = await openDatabase(DatabaseSync, path, mode, DEFAULT_BUSY_TIMEOUT_MS) - expect(db.prepare(sql(resources[mode])).get()).toEqual({ journal_mode: mode }) - expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 }) - expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 }) - expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 }) - db.close() - } - }) - - it('retries a busy journal-mode transition within its retry budget', async () => { - const path = await freshDbPath('dsh-sqlite-journal-busy-') - let attempts = 0 - const BusyOnceDatabase = databaseWithJournalFailure(() => { - attempts += 1 - return attempts === 1 - ? Object.assign(new Error('database is locked'), { - code: 'ERR_SQLITE_ERROR', - errcode: 5, - errstr: 'database is locked', - }) - : undefined - }) - - const db = await openDatabase(BusyOnceDatabase, path, 'wal', 100) - expect(attempts).toBe(2) - expect(db.prepare(sql('journal-mode-wal')).get()).toEqual({ journal_mode: 'wal' }) - expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 }) - expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 }) - expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 }) - db.close() - }) - - it('does not retry journal failures outside the available busy budget', async () => { - for (const { errcode, timeout } of [ - { errcode: 5, timeout: 0 }, - { errcode: 6, timeout: 100 }, - ]) { - let attempts = 0 - const FailingDatabase = databaseWithJournalFailure(() => { - attempts += 1 - return Object.assign(new Error(`SQLite error ${errcode}`), { errcode }) - }) - await expect(openDatabase( - FailingDatabase, - await freshDbPath(`dsh-sqlite-journal-failure-${errcode}-`), - 'wal', - timeout, - )).rejects.toThrow(`SQLite error ${errcode}`) - expect(attempts).toBe(1) - } - }) - - it('starts no journal retry after its open-relative cutoff', async () => { - let attempts = 0 - const BusyDatabase = databaseWithJournalFailure(() => { - attempts += 1 - return Object.assign(new Error('database is locked'), { errcode: 5 }) - }) - const clock = vi.spyOn(performance, 'now') - .mockReturnValueOnce(0) - .mockReturnValueOnce(50) - .mockReturnValueOnce(100) - try { - await expect(openDatabase( - BusyDatabase, - await freshDbPath('dsh-sqlite-journal-cutoff-'), - 'wal', - 100, - )).rejects.toThrow('database is locked') - } finally { - clock.mockRestore() - } - expect(attempts).toBe(1) - }) - - it('paces repeated busy journal-mode attempts', async () => { - const attemptedAt: number[] = [] - const BusyTwiceDatabase = databaseWithJournalFailure(() => { - attemptedAt.push(performance.now()) - return attemptedAt.length <= 2 - ? Object.assign(new Error('database is locked'), { errcode: 5 }) - : undefined - }) - const db = await openDatabase( - BusyTwiceDatabase, - await freshDbPath('dsh-sqlite-journal-paced-'), - 'wal', - DEFAULT_BUSY_TIMEOUT_MS, - ) - db.close() - - expect(attemptedAt).toHaveLength(3) - for (let index = 1; index < attemptedAt.length; index += 1) { - const previous = attemptedAt[index - 1] - const current = attemptedAt[index] - if (previous === undefined || current === undefined) throw new Error('missing journal attempt timestamp') - expect(current - previous).toBeGreaterThanOrEqual(5) - } - }) - - it('rejects unversioned, incompatible, and foreign-application databases', async () => { - const unversionedPath = await freshDbPath('dsh-sqlite-unversioned-') - const unversioned = new DatabaseSync(unversionedPath) - unversioned.exec(testSql('create-unrelated-table')) - unversioned.close() - await expect(openDatabase(DatabaseSync, unversionedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/unversioned schema/) - - const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-') - const incompatible = new DatabaseSync(incompatiblePath) - incompatible.exec(testSql('set-user-version-16')) - incompatible.close() - await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/) - - const foreignPath = await freshDbPath('dsh-sqlite-foreign-') - const foreign = new DatabaseSync(foreignPath) - foreign.exec(testSql('set-user-version-20')) - foreign.exec(testSql('set-application-id-12345')) - foreign.close() - await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/) - }) - - it('rejects changed columns and non-strict owned tables', async () => { - const changedPath = await freshDbPath('dsh-sqlite-columns-') - ;(await openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close() - const changed = new DatabaseSync(changedPath) - changed.exec(testSql('add-unexpected-column')) - changed.close() - await expect(openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/) - - const nonStrictPath = await freshDbPath('dsh-sqlite-nonstrict-') - ;(await openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close() - const nonStrict = new DatabaseSync(nonStrictPath) - nonStrict.exec(testSql('replace-events-with-nonstrict-table')) - nonStrict.close() - await expect(openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/) - - const loosePath = await freshDbPath('dsh-sqlite-loose-') - const loose = new DatabaseSync(loosePath) - loose.exec(testSql('create-loose-schema')) - loose.close() - await expect(openDatabase(DatabaseSync, loosePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/) - }) - - it('rejects schema ownership changes observed at mutation time', async () => { - const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS) - changedVersion.exec(testSql('set-user-version-16')) - expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') }) - .toThrow(/schema changed before mutation/) - changedVersion.close() - - const changedApplication = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS) - changedApplication.exec(testSql('set-application-id-12345')) - expect(() => { validateSchemaForMutation(DatabaseSync, changedApplication, ':memory:') }) - .toThrow(/application id changed before mutation/) - changedApplication.close() - }) - - it('validates creation time and restores every optional header field', () => { - const base: SessionRow = { - id: 'stored-header', - version: 0, - created_at: 1, - cwd: '/project', - parent_session: 'parent', - seed_length: 4, - origin: 'subagent', - incarnation: '00000000-0000-4000-8000-000000000000', - revision: 1, - delegation_depth: 2, - agent_preset: 'minimal', - } - expect(rowToMeta(decodeSessionRow(base))).toMatchObject({ - cwd: '/project', - parentSession: 'parent', - seedLength: 4, - origin: 'subagent', - delegationDepth: 2, - agentPreset: 'minimal', - }) - expect(() => decodeSessionRow({ ...base, created_at: -1 })).toThrow(/created_at/) - expect(() => decodeSessionRow({ ...base, origin: 'external' })).toThrow(/origin/) - expect(() => decodeSessionRow({ ...base, delegation_depth: -1 })).toThrow(/delegation_depth/) - }) - - it('rejects malformed SQLite row primitives generically', () => { - const base: SessionRow = { - id: 'stored-header', - version: 0, - created_at: 1, - cwd: '/project', - parent_session: null, - seed_length: null, - origin: null, - incarnation: '00000000-0000-4000-8000-000000000000', - revision: 1, - delegation_depth: null, - agent_preset: null, - } - for (const [value, message] of [ - [null, /object/], - [{ ...base, id: 1 }, /id.*string/], - [{ ...base, id: '' }, /id.*empty/], - [{ ...base, version: '0' }, /version.*safe integer/], - [{ ...base, cwd: 'relative' }, /cwd.*absolute/], - [{ ...base, cwd: 1 }, /cwd.*string or null/], - [{ ...base, incarnation: 'invalid' }, /incarnation.*UUID/], - [{ ...base, seed_length: '1' }, /seed_length.*safe integer or null/], - [{ ...base, agent_preset: 1 }, /agent_preset.*string or null/], - ] as const) { - expect(() => decodeSessionRow(value)).toThrow(message) - } - - const eventRow = { - seq: 0, type: 'turn/start', time: 1, data: '{}', - source_event_seqs: null, surface_op: null, ignorable: null, - } - for (const [value, message] of [ - [null, /object/], - [{ ...eventRow, seq: '0' }, /seq.*safe integer/], - [{ ...eventRow, type: '' }, /type.*empty/], - [{ ...eventRow, time: '1' }, /time.*safe integer/], - [{ ...eventRow, data: 1 }, /data.*string or blob/], - [{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/], - [{ ...eventRow, ignorable: 2 }, /ignorable.*0, 1, or null/], - ] as const) { - expect(() => decodeEventRow(value)).toThrow(message) - } - expect(() => decodeStoreIdentity({ store_id: 'invalid' })).toThrow(/store_id.*UUID/) - }) - - it('rejects invalid durable metadata before exposing a session header', async () => { - const path = await freshDbPath('dsh-sqlite-metadata-') - const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - const header = meta('invalid-metadata') - await store.appendBatch(header, [chunk(0)], false) - const db = new DatabaseSync(path) - db.prepare(testSql('update-invalid-session-metadata')).run(header.id) - db.close() - await expect(store.list()).rejects.toThrow(/seed_length|origin|delegation_depth/) - await expect(store.loadStored(header.id)).rejects.toThrow(/seed_length|origin|delegation_depth/) - await store.close() - }) - - it('uses the shared persistence application identity', () => { - expect(SESSION_PERSISTENCE_SQLITE_APPLICATION_ID).toBe(0x44534850) - }) -}) - -describe('SessionPersistenceSqlite edge behavior', () => { - it('materializes an explicitly durable empty live session', async () => { - const path = await freshDbPath('dsh-sqlite-empty-') - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceSqlite, { path }) - const session = ctx.sessions.create(SessionId('empty'), { meta: { cwd: '/workspace' } }) - - await ctx.sessionPersistence.ensureMaterialized(session) - - await expect(ctx.sessionPersistence.list()).resolves.toEqual([session.header]) - await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ meta: session.header, events: [] }) - await ctx.fiber.dispose() - }) - - it('keeps a fresh database unopened until the first persistence operation', async () => { - const path = await freshDbPath('dsh-sqlite-lazy-') - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceSqlite, { path }) - await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) - const emitWarning = Reflect.get(process, 'emitWarning') - expect(await ctx.sessionPersistence.list()).toEqual([]) - expect(Reflect.get(process, 'emitWarning')).toBe(emitWarning) - expect(typeof (await stat(path)).size).toBe('number') - await ctx.fiber.dispose() - }) - - it('disposes after path validation without opening the database', async () => { - const path = await freshDbPath('dsh-sqlite-unused-') - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceSqlite, { path }) - await ctx.fiber.dispose() - await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) - - const untouchedPath = await freshDbPath('dsh-sqlite-never-validated-') - const untouched = new SqliteStore({ - path: untouchedPath, - journalMode: 'wal', - busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS, - }) - await untouched.close() - await expect(stat(untouchedPath)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('uses constructor defaults and exposes locate and prepare directly', 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:' }) - }, { inject: ['sessions'] })) - - const header = meta('direct-provider') - const events = chunkLog(3) - expect(persistence.locate(header)).toBeUndefined() - await persistence.create(header) - await persistence.append(header.id, events) - const preparation = await persistence.prepare(header.id) - expect(preparation.session.header).toEqual(header) - preparation[Symbol.dispose]() - await ctx.fiber.dispose() - }) - - it('keeps empty mutations inert and rolls back a repair without metadata', async () => { - const store = new SqliteStore({ - path: ':memory:', - journalMode: 'wal', - busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS, - }) - const header = meta('empty-store') - await store.appendBatch(header, [], false) - await store.commitRepair(header, undefined, []) - expect(await store.readStoredRevision(header.id)).toBeUndefined() - await expect(store.commitRepair(header, 0, [])).rejects.toThrow(/metadata row is missing/) - await store.close() - }) - - it('rejects omitted torn markers and stale closer positions', async () => { - const path = await freshDbPath('dsh-sqlite-repair-validation-') - const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - const header = meta('repair-validation') - await store.appendBatch(header, [chunk(0)], false) - const db = new DatabaseSync(path) - db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null) - db.close() - await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/) - await store.commitRepair(header, 1, []) - await expect(store.commitRepair(header, undefined, [chunk(2)])).rejects.toThrow(/closer starts at seq 2/) - - const cleared = new DatabaseSync(path) - cleared.prepare(testSql('delete-session-events')).run(header.id) - cleared.close() - await store.commitRepair(header, undefined, [chunk(0)]) - expect((await store.loadStored(header.id))?.events).toEqual([chunk(0)]) - await store.close() - }) - - it('rejects malformed physical tail rows before appending', async () => { - const path = await freshDbPath('dsh-sqlite-tail-') - const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - const header = meta('invalid-tail') - await store.appendBatch(header, [chunk(0)], false) - const db = new DatabaseSync(path) - db.prepare(testSql('insert-corrupt-event')) - .run(header.id, 1, 'assistant/chunk', 2, '{not json', null) - db.close() - - await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/) - await store.close() - }) - - it('rejects missing and empty store identities', async () => { - for (const mode of ['missing', 'empty'] as const) { - const path = await freshDbPath(`dsh-sqlite-identity-${mode}-`) - const db = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS) - if (mode === 'missing') db.exec(testSql('delete-persistence-state')) - else db.exec(testSql('empty-store-id')) - db.close() - await chmod(path, 0o600) - - expect(errorMessage(await backendFailure(path))).toMatch(/no valid store identity/) - } - }) - - it('rejects invalid paths during service initialization', async () => { - const path = await freshDbPath('dsh-sqlite-invalid-path-') - const ctx = new Context() - await ctx.plugin(SessionStore) - await expect(ctx.plugin(SessionPersistenceSqlite, { path: `${path}\0` })).rejects.toMatchObject({ - code: 'ERR_INVALID_ARG_VALUE', - }) - await ctx.fiber.dispose() - }) - - it('rejects non-files and symbolic links', async () => { - const directoryPath = await freshDbPath('dsh-sqlite-directory-') - await mkdir(directoryPath) - expect(errorMessage(await backendFailure(directoryPath))) - .toMatch(/must be a regular file/) - - const linkPath = await freshDbPath('dsh-sqlite-link-') - const target = join(linkPath, '..', 'target.db') - await writeFile(target, '') - await symlink(target, linkPath) - expect(errorMessage(await backendFailure(linkPath))) - .toMatch(/not a symbolic link/) - - const parentLinkPath = await freshDbPath('dsh-sqlite-parent-link-') - const realParent = join(parentLinkPath, '..', 'real-parent') - const linkedParent = join(parentLinkPath, '..', 'linked-parent') - await mkdir(realParent, { mode: 0o700 }) - await symlink(realParent, linkedParent) - expect(errorMessage(await backendFailure(join(linkedParent, 'sessions.db')))) - .toMatch(/must be a real directory/) - }) - - it.runIf( - process.getuid !== undefined && process.getuid() !== 0, - )('rejects permissive files and writable parents', async () => { - const permissivePath = await freshDbPath('dsh-sqlite-permissive-') - await writeFile(permissivePath, '') - await chmod(permissivePath, 0o644) - expect(errorMessage(await backendFailure(permissivePath))) - .toMatch(/accessible only by that user/) - - const writableParentPath = await freshDbPath('dsh-sqlite-parent-') - await chmod(join(writableParentPath, '..'), 0o770) - expect(errorMessage(await backendFailure(writableParentPath))) - .toMatch(/not group\/world-writable/) - }) - - it('surfaces database creation failures after path validation', async () => { - const path = await freshDbPath('dsh-sqlite-create-failure-') - const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) - await store.validatePath() - const parent = join(path, '..') - await rm(parent, { recursive: true }) - await writeFile(parent, 'not a directory') - await expect(store.open()).rejects.toThrow(/ENOENT|ENOTDIR/) - await store.close() - }) -}) diff --git a/packages/session/session-persistence-sqlite/tests/test-sql.ts b/packages/session/session-persistence-sqlite/tests/test-sql.ts deleted file mode 100644 index 8b7270c320..0000000000 --- a/packages/session/session-persistence-sqlite/tests/test-sql.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** Test-only loader for fixed SQLite fixtures. */ - -import { readFileSync } from 'node:fs' - -export type TestSqlName = - | 'add-unexpected-column' - | 'count-events' - | 'count-ignorable-events' - | 'count-packed-events' - | 'count-physical-types' - | 'create-loose-schema' - | 'create-unrelated-table' - | 'delete-persistence-state' - | 'delete-session-events' - | 'empty-store-id' - | 'insert-corrupt-event' - | 'measure-write-traffic' - | 'replace-events-with-nonstrict-table' - | 'select-last-event' - | 'select-page-size' - | 'select-event-rowids' - | 'select-event-rows' - | 'select-user-version' - | 'set-application-id-12345' - | 'set-page-size-4096' - | 'set-user-version-15' - | 'set-user-version-16' - | 'set-user-version-17' - | 'set-user-version-18' - | 'set-user-version-19' - | 'set-user-version-20' - | 'update-invalid-session-metadata' - | 'vacuum' - -/** Load one fixed test SQL resource. */ -export function testSql(name: TestSqlName): string { - return readFileSync(new URL(`./resources/sql/${name}.sql`, import.meta.url), 'utf8') -} diff --git a/packages/session/session-persistence-sqlite/tsconfig.json b/packages/session/session-persistence-sqlite/tsconfig.json deleted file mode 100644 index 8865e04321..0000000000 --- a/packages/session/session-persistence-sqlite/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/session" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../session-persistence" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index a5a003cd1a..0ccc83b972 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 00ab8ffc76bac2a87a6ecf9fb3146cc24c7a976d -README.zh.md: 5acc01272685c23039c1f6040c42fcb46c9912b0 +README.md: 687241c604254152538e152a62d0c78b31369cf3 +README.zh.md: 170f194c247bf680db554c4ecb2beca97184ff0b diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 00ab8ffc76..687241c604 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-persistence` stores a session's event log durably, reloads it on resume, and lists stored sessions through one backend-neutral service (`ctx.sessionPersistence`) that every persistence backend implements. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type — and non-replayable metadata (format version, working directory, lineage, seed boundary) travels separately as `SessionHeader`. Backends own their storage, the service owns the semantics: append-only logs, contiguous sequence numbers, crash recovery that preserves an interrupted turn instead of truncating it, and durable writes that resolve only after the batch is safe. Pick a backend (`session-persistence-jsonl` for per-session files, `session-persistence-sqlite` for one database), mount it, and sessions persist and resume without the loop or the model knowing which backend is underneath. +`dsh-session-persistence` stores a session's event log durably, reloads it on resume, and lists stored sessions through the backend-neutral `ctx.sessionPersistence` service. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type — and non-replayable metadata (format version, working directory, lineage, seed boundary) travels separately as `SessionHeader`. A backend owns its storage, while the service owns append-only logs, contiguous sequence numbers, crash recovery that preserves an interrupted turn instead of truncating it, and durable writes that resolve only after the batch is safe. The shipped JSONL provider implements this service with one artifact per Session; third-party providers may implement the same contract without changing the loop or model. ## Table of Contents @@ -29,7 +29,7 @@ Mount one persistence backend to make sessions durable. The backend registers it ### Choosing a backend -The seam ships two interchangeable backends. Choose [JSONL](../session-persistence-jsonl/README.md) when each session should live in its own on-disk artifact: it stores one append-only `.jsonl.zstd` log per session and returns an absolute artifact path from `locate(meta)`. Choose [SQLite](../session-persistence-sqlite/README.md) when one queryable database fits the deployment: it keeps every session's log in a single database with packed physical rows and returns no per-session artifact. A third-party backend may implement the service directly; the [backend contract](#understand-the-implementation) below is what it must honor. +The seam ships the [JSONL](../session-persistence-jsonl/README.md) backend. It stores one append-only `.jsonl.zstd` artifact per Session and returns its absolute path from `locate(meta)`. A third-party backend may implement the service directly; the [backend contract](#understand-the-implementation) below is what it must honor. ### What the service provides @@ -65,7 +65,7 @@ This section explains how the seam realizes durable storage and how backends plu ### Design concept -The package is the Service Definition of a capability seam with two halves. The abstract `SessionPersistence` service is the public contract; a `PersistenceCoordinator` provides backend-neutral orchestration for buffering, serialization, materialization, repair, adoption, and quiescent disposal. A backend implements the small durable primitives for stored reads, append, repair, and listing, so JSONL and SQLite share lifecycle correctness while keeping different storage primitives. +The package is the Service Definition of a capability seam with two halves. The abstract `SessionPersistence` service is the public contract; a `PersistenceCoordinator` provides backend-neutral orchestration for buffering, serialization, materialization, repair, adoption, and quiescent disposal. The JSONL provider implements the small durable primitives for stored reads, append, repair, and listing; a third-party provider may reuse the same coordinator or implement the service directly. ### The invariants every backend honors @@ -103,7 +103,6 @@ Read these pages when the package-level contract is not enough. They move from t - [Session persistence subsystem](../../../docs/subsystems/persistence.md) — the full service contract, flush checkpoint, crash recovery, and generated Cordis API. - [JSONL persistence backend](../session-persistence-jsonl/README.md) — the shipped per-session-file backend. -- [SQLite persistence backend](../session-persistence-sqlite/README.md) — the opt-in single-database backend. - [Session checkpoint policy](../session-checkpoint-policy/README.md) — the plugin that flushes through this service at semantic boundaries. - [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 5acc012726..170f194c24 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-persistence` 通过每个持久化后端都实现的一个后端无关服务(`ctx.sessionPersistence`)持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型——不可回放的元数据(格式版本、工作目录、血缘、种子边界)作为 `SessionHeader` 单独传输。后端拥有自己的存储,服务拥有语义:仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。选一个后端(按会话存储文件的 `session-persistence-jsonl`,或单库的 `session-persistence-sqlite`),挂载它,会话就会持久化并在恢复时还原,loop 与模型无需知道下面是哪个后端。 +`dsh-session-persistence` 通过后端无关的 `ctx.sessionPersistence` 服务持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型——不可回放的元数据(格式版本、工作目录、血缘、种子边界)作为 `SessionHeader` 单独传输。后端拥有自己的存储,而服务拥有仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。随产品交付的 JSONL provider 用每个 Session 一份产物实现该服务;第三方 provider 可以实现同一约定,而不改变 loop 或模型。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 选择后端 -seam 随产品交付两个可互换后端。当每个会话应各占一份磁盘产物时选择 [JSONL](../session-persistence-jsonl/README.zh.md):它把每个会话存为一份仅追加 `.jsonl.zstd` 日志,并由 `locate(meta)` 返回绝对产物路径。当单一可查询数据库适合部署时选择 [SQLite](../session-persistence-sqlite/README.zh.md):它把每个会话的日志连同物理打包行一起存入单个数据库,不返回按会话的产物。第三方后端可以直接实现该服务;必须遵守的[后端约定](#understand-the-implementation)见下文。 +seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端。它把每个 Session 存为一份仅追加 `.jsonl.zstd` 产物,并由 `locate(meta)` 返回绝对路径。第三方后端可以直接实现该服务;必须遵守的[后端约定](#understand-the-implementation)见下文。 ### 服务提供什么 @@ -65,7 +65,7 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi ### 设计理念 -本包是能力 seam 的 Service Definition,分两半。抽象的 `SessionPersistence` 服务是公开约定;`PersistenceCoordinator` 为缓冲、串行化、物化、修复、接管与完全停稳的 dispose 提供后端无关编排。后端实现存储读取、追加、修复与列出所需的小型持久原语,因此 JSONL 与 SQLite 共享生命周期正确性,同时保留不同的存储原语。 +本包是能力 seam 的 Service Definition,分两半。抽象的 `SessionPersistence` 服务是公开约定;`PersistenceCoordinator` 为缓冲、串行化、物化、修复、接管与完全停稳的 dispose 提供后端无关编排。JSONL provider 实现存储读取、追加、修复与列出所需的小型持久原语;第三方 provider 可以复用同一 coordinator,也可以直接实现该服务。 ### 每个后端必须遵守的不变量 @@ -103,7 +103,6 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi - [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——完整服务约定、flush 检查点、崩溃恢复与生成的 Cordis API。 - [JSONL 持久化后端](../session-persistence-jsonl/README.zh.md)——随产品交付、按会话存储文件的后端。 -- [SQLite 持久化后端](../session-persistence-sqlite/README.zh.md)——可选启用的单数据库后端。 - [会话检查点策略](../session-checkpoint-policy/README.zh.md)——在语义边界上经由本服务刷新的插件。 - [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。 diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 876dcd480e..9d394b23eb 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -155,8 +155,8 @@ export interface PersistenceBackend { /** * Optional seek-capable suffix read behind the service's `readFrom`: return * the header plus the stored events with `seq >= fromSeq` without reading - * the whole log. A backend whose medium can address events by seq (SQLite) - * implements this so `readFrom` scales with the suffix; sequential backends + * the whole log. A backend whose medium can address events by seq implements + * this so `readFrom` scales with the suffix; sequential backends * omit it and the coordinator falls back to {@link loadStored} plus a * forward skip. Non-mutating (no truncation, no closers). Validation of the * region strictly below `fromSeq` is limited to seq contiguity — the diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 627098c648..5cb2311239 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -109,8 +109,8 @@ export abstract class SessionPersistence extends Service { /** * Resolve this backend's independent local artifact for a session without - * reading, creating, flushing, or otherwise materializing it. Backends such - * as SQLite that do not own one artifact per session return `undefined`. + * reading, creating, flushing, or otherwise materializing it. A backend + * that does not own one artifact per Session returns `undefined`. * @param meta - the immutable session header whose artifact is requested. * @returns the backend-specific absolute location, when one exists. */ @@ -250,10 +250,10 @@ export abstract class SessionPersistence extends Service { * 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. + * A backend whose medium can seek by seq may read only the suffix; + * sequential media such as JSONL still parse the whole artifact and skip + * forward. The primitive bounds what is returned and refolded, not every + * backend's physical read. * @param id - the persisted session to read. * @param fromSeq - first event seq to include; a non-negative safe integer. * @param signal - optional cancellation for queued and backend read work. diff --git a/packages/session/session-persistence/tests/coordinator-contract.ts b/packages/session/session-persistence/tests/coordinator-contract.ts index 8633faa886..908117a18e 100644 --- a/packages/session/session-persistence/tests/coordinator-contract.ts +++ b/packages/session/session-persistence/tests/coordinator-contract.ts @@ -36,7 +36,7 @@ export interface CoordinatorFixture { cleanup: () => Promise } -/** A constant absolute cwd; jsonl keys directories off it, memory/sqlite ignore it. */ +/** A constant absolute cwd; JSONL keys directories off it and memory ignores it. */ const WORK = '/w' const OTHER = '/other' @@ -46,7 +46,7 @@ function send(session: Session, events: readonly SessionEvent[]): void { } /** A valid persisted log from immediately before messages gained wrappers and identities. */ -function legacyMessageLog(): SessionEvent[] { +export function legacyMessageLog(): SessionEvent[] { return [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { @@ -109,7 +109,7 @@ function legacyMessageLog(): SessionEvent[] { } /** A complete log in the durable event vocabulary of the react-loop refactor base. */ -function preReactLoopLog(): SessionEvent[] { +export function preReactLoopLog(): SessionEvent[] { const prompt = createUserMessage({ content: [{ type: 'text', text: 'old prompt' }], source: { kind: 'user' }, @@ -325,7 +325,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< it('round-trips the seed boundary (seedLength) through persistence', async () => { // A forked child records how many leading events were inherited via the seed; the // boundary must survive a reload (so a resume/replay can tell the inherited prefix from - // the child's own events). JSONL stores it in the header; SQLite uses `seed_length`. + // the child's own events). The backend must preserve it in stored metadata. const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { @@ -348,7 +348,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< it('round-trips the delegation depth through persistence', async () => { // A subagent child's recursion budget lives in its header; a reload that // dropped it would reset the child to top-level and un-bound maxDepth - // (JSONL stores it in the header line; SQLite uses `delegation_depth`). + // The backend must preserve it in stored metadata. const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index c3a166e4a9..b770df776d 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -9,7 +9,9 @@ import { type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, type StoredSuffix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' -import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' +import { + legacyMessageLog, preReactLoopLog, runCoordinatorContract, type CoordinatorFixture, +} from './coordinator-contract.ts' /** The durable store shape: materialized sessions only (no lazy entries). */ type MemoryStore = Map @@ -65,8 +67,8 @@ interface CoordinatorInternals { /** * Reference {@link PersistenceCoordinator} vehicle and abstract-service coverage, backed by a * dependency-free map with atomic writes and no torn-tail marker. Supplying the map lets multiple - * instances share materialized sessions, the in-memory analogue of reload over one file/database; - * durable behavior is covered by the JSONL and SQLite backends. + * instances share materialized sessions, the in-memory analogue of reload over one artifact; + * durable behavior is covered by the JSONL provider. */ class MemoryPersistence extends SessionPersistence implements PersistenceBackend { override readonly supportsRawArtifacts = false @@ -285,7 +287,7 @@ describe('the inherited readRaw default', () => { }) // Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are -// atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch. +// atomic; the suite asserts that skip while JSONL covers the repair branch. runCoordinatorContract('memory', async (): Promise => { const store: MemoryStore = new Map() return { @@ -1204,6 +1206,78 @@ describe('PersistenceCoordinator session preparations', () => { }) }) +describe('PersistenceCoordinator seek reads', () => { + it('loads the whole prefix only when a bounded legacy suffix needs earlier message identities', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('seek-read-from-legacy') + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + backend.seekHook = async (hookId, fromSeq) => { + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: structuredClone(entry.meta), events: entry.events.filter(e => e.seq >= fromSeq) } + } + + try { + const assertLegacySuffixUsesWholePrefix = async ( + events: SessionEvent[], + fromSeq: number, + firstType: SessionEvent['type'], + ): Promise => { + backend.store.set(id, { meta: meta(id), events }) + const loadsBefore = backend.loadAttempts + const result = await coordinator.readFrom(id, fromSeq) + expect(result.events[0]?.type).toBe(firstType) + expect(backend.loadAttempts).toBe(loadsBefore + 1) + } + const legacyMessages = legacyMessageLog() + await assertLegacySuffixUsesWholePrefix(legacyMessages, 1, 'user/message') + await assertLegacySuffixUsesWholePrefix(legacyMessages, 3, 'assistant/message') + await assertLegacySuffixUsesWholePrefix(legacyMessages, 5, 'tool/result') + await assertLegacySuffixUsesWholePrefix(preReactLoopLog(), 3, 'user/message') + + backend.store.set(id, { meta: meta(id), events: legacyMessages }) + const directCurrent = await coordinator.readFrom(id, 0) + backend.store.set(id, { meta: meta(id), events: directCurrent.events }) + const loadsBeforeCurrent = backend.loadAttempts + await coordinator.readFrom(id, 1) + await coordinator.readFrom(id, 3) + await coordinator.readFrom(id, 5) + expect(backend.loadAttempts).toBe(loadsBeforeCurrent) + + for (const [type, data] of [ + ['user/message', {}], + ['assistant/message', {}], + ['tool/result', {}], + ] as const) { + backend.store.set(id, { + meta: meta(id), + events: [{ type, seq: 0, time: 1, data } as unknown as SessionEvent], + }) + const loadsBefore = backend.loadAttempts + await expect(coordinator.readFrom(id, 0)).rejects.toThrow('lacks an identified message') + expect(backend.loadAttempts).toBe(loadsBefore) + } + backend.store.set(id, { + meta: meta(id), + events: [{ + type: 'external/null', seq: 0, time: 1, data: null, ignorable: true, + } as unknown as SessionEvent], + }) + const loadsBeforeNullData = backend.loadAttempts + expect((await coordinator.readFrom(id, 0)).events[0]?.data).toBeNull() + expect(backend.loadAttempts).toBe(loadsBeforeNullData) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) +}) + describe('PersistenceCoordinator observation cancellation', () => { it('borrows live Sessions before, during, and after cold source validation', async () => { const ctx = new Context() diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index ea98ce0fb9..a686ee63a1 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -63,7 +63,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts index 18b5f57aca..fa7c5bd833 100644 --- a/packages/session/session-title/tests/persistence.spec.ts +++ b/packages/session/session-title/tests/persistence.spec.ts @@ -7,7 +7,6 @@ import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title' const CONFIG = { @@ -71,25 +70,4 @@ describe('session title persistence round trips', () => { await expectPersistedTitle(reader, id) await reader.fiber.dispose() }) - - it('round-trips through a remounted SQLite backend', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-title-sqlite-')) - roots.push(root) - const path = join(root, 'sessions.db') - const id = SessionId('title-sqlite') - const writer = new Context() - await writer.plugin(SessionStore) - await writer.plugin(SessionProjectionRegistry) - await writer.plugin(SqliteSessionPersistence, { path }) - await writer.plugin(SessionTitleService, CONFIG) - await appendPersistedTitle(writer, id) - await writer.fiber.dispose() - - const reader = new Context() - await reader.plugin(SessionStore) - await reader.plugin(SessionProjectionRegistry) - await reader.plugin(SqliteSessionPersistence, { path }) - await expectPersistedTitle(reader, id) - await reader.fiber.dispose() - }) }) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 134fc85a62..2f7ee6bbc8 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -544,7 +544,7 @@ describe('dsh-tool-skill', () => { }) it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => { - // Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork, + // Seeds reach `agent.session.events` from persistence on resume or fork, // and seed validation only guarantees a source object with a non-empty // `kind`. A catalog whose entries are missing or wrongly shaped must be // skipped like any foreign record; throwing here would fail every later diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index 45079dcc26..6a58c5da92 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-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/storage/storage-sqlite/README.md -README.md: 4abb767a0dc593c804e33c2822ad07ab2b93360b -README.zh.md: 087c99fc85ce66b9d92f6ecc602698365614d106 +README.md: 243c4bb93e50e81c417634645b611484ca65430e +README.zh.md: ea7e886332e8b7663f1b381991ee1e6c7f09a6e7 diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index 4abb767a0d..243c4bb93e 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -75,7 +75,7 @@ The backend is a document-per-row layout over one `node:sqlite` connection, desi ### Open sequence -Opening the database mirrors the session-persistence SQLite backend: `mkdir` the parent `0o700`, exclusively create a missing file `0o600`, apply `PRAGMA foreign_keys = ON` and the journal mode, check `user_version`, create the `units` and `unit_globals` metadata tables, and stamp fresh databases last so a failure leaves the medium unstamped. +Opening the database creates the parent as `0o700`, exclusively creates a missing file as `0o600`, applies `PRAGMA foreign_keys = ON` and the journal mode, checks `user_version`, creates the `units` and `unit_globals` metadata tables, and stamps fresh databases last so a failure leaves the medium unstamped. ### Source map @@ -129,7 +129,7 @@ These limits define when this backend is a poor fit or needs special operational - **Synchronous driver blocks the event loop** — each write is a synchronous `DatabaseSync` call; the block lasts a single statement, which is acceptable at domain-data scale. - **No busy-wait or retry policy** — a competing connection holding a write lock rejects the operation immediately instead of waiting; the domain layer's write chain serializes writes within one process, and cross-process coordination is out of scope. - **Only the current physical layout version opens** — any other stamped `user_version` is rejected rather than migrated (pre-release stance). -- **Open sequence duplicated from the session packages** — `openDatabase` mirrors the session-persistence SQLite open sequence; extraction into a shared medium layer is deferred to the planned session-backend migration. +- **Open sequence duplicated with the query provider** — `openDatabase` and `session-query-sqlite` both enforce SQLite file ownership, but each package owns a distinct application identity and schema; no shared medium helper couples them. ### Dev Note diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index 087c99fc85..ea7e886332 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -75,7 +75,7 @@ kind: "package-reference" ### 打开顺序 -打开数据库与会话持久化 SQLite 后端一致:`mkdir` 父目录 `0o700`、以 `0o600` 独占创建缺失文件、应用 `PRAGMA foreign_keys = ON` 与 journal mode、检查 `user_version`、创建 `units` 与 `unit_globals` 元数据表,并在最后给全新数据库盖戳,让失败留下未盖戳的介质。 +打开数据库时会以 `0o700` 创建父目录、以 `0o600` 独占创建缺失文件、应用 `PRAGMA foreign_keys = ON` 与 journal mode、检查 `user_version`、创建 `units` 与 `unit_globals` 元数据表,并在最后给全新数据库盖戳,让失败留下未盖戳的介质。 ### 源码地图 @@ -129,7 +129,7 @@ kind: "package-reference" - **同步驱动阻塞事件循环**——每次写入都是一次同步 `DatabaseSync` 调用;阻塞只持续一条语句,在领域数据规模下可以接受。 - **没有忙等待或重试策略**——持有写锁的竞争连接会立即拒绝操作,而不是等待;领域层的写入链在单进程内串行化写入,跨进程协调属于范围外。 - **只打开当前的物理布局版本**——任何其他已标记的 `user_version` 都会被拒绝而不是迁移(预发布立场)。 -- **打开顺序与会话包重复**——`openDatabase` 与会话持久化 SQLite 的打开顺序一致;提取到共享介质层的工作被推迟到计划的会话后端迁移。 +- **打开顺序与 query provider 重复**——`openDatabase` 与 `session-query-sqlite` 都强制执行 SQLite 文件 ownership,但两个 package 分别拥有不同的 application identity 与 schema;没有共享 medium helper 将其耦合。 ### 开发备注 diff --git a/packages/storage/storage-sqlite/src/schema.ts b/packages/storage/storage-sqlite/src/schema.ts index c9ac817415..65f9d289b6 100644 --- a/packages/storage/storage-sqlite/src/schema.ts +++ b/packages/storage/storage-sqlite/src/schema.ts @@ -28,11 +28,10 @@ export const STORAGE_SQLITE_SCHEMA_VERSION = 1 */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' -/* jscpd:ignore-start -- deliberately mirrors the session-persistence-sqlite / - session-query-sqlite open sequence; this group is the third user, and the - shared medium helper is deferred to the log-facet migration so the session - packages stay untouched this phase (see the domain KV storage Agent Note's - reuse audit). */ +/* jscpd:ignore-start -- deliberately mirrors the session-query-sqlite open + sequence. Each package owns a distinct database identity and schema, so a + shared helper would couple otherwise independent storage providers (see the + domain KV storage Agent Note's reuse audit). */ /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 35b72fc16e..fd8a968c2f 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/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/test-support/session-snapshot/README.md -README.md: 9f1907af8b9e50f587bcc05b93d8e3730c8f272c -README.zh.md: 2d4290578a655df91eb60f63177c97fc5d82da6c +README.md: 8d191bc0314f90a919a36b6c0ee90619e2b0f78d +README.zh.md: 7e5dcb1d9f31f4b980eb01326bc73dcdd0c073fd diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 9f1907af8b..8d191bc031 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -87,7 +87,7 @@ A scenario requiring a non-Windows host declares `posixOnly`, which skips its ru ### What can go wrong - **A fixture guard rejects the committed files** — orphan scenario dirs, missing files, multiple pins for one header class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers all fail the suite before comparisons run. -- **The session harvest needs raw JSONL mode** — snapshot configs set the JSONL backend's `compression: 'none'`; compressed JSONL and SQLite compositions have no snapshot-harvest path. +- **The session harvest needs raw JSONL mode** — snapshot configs set the JSONL backend's `compression: 'none'`; compressed JSONL has no snapshot-harvest path. - **Built mode needs current artifacts** — run `pnpm run build` before selecting `DSH_EXAMPLE_MODE=lib`; source mode remains the zero-build path. ----- @@ -154,7 +154,7 @@ None; this package neither assembles nor sends a provider request. These limits define when the kit needs special care. They are current package constraints, not a task backlog. -- **Session harvest requires raw JSONL mode** — `runScenario` collects persisted `.jsonl` logs, so snapshot configs set the JSONL backend's `compression: 'none'`; compressed JSONL and SQLite compositions have no snapshot-harvest path. +- **Session harvest requires raw JSONL mode** — `runScenario` collects persisted `.jsonl` logs, so snapshot configs set the JSONL backend's `compression: 'none'`; compressed JSONL has no snapshot-harvest path. - **Built mode requires current artifacts** — run `pnpm run build` before selecting `DSH_EXAMPLE_MODE=lib`; source mode remains the zero-build path. - **ACP remains for protocol behavior** — cancellation and permission round trips whose stimulus is the ACP client stay on that adapter; assembled one-shot and persistent-control behavior uses headless and SDK adapters. diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 2d4290578a..7e5dcb1d9f 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -87,7 +87,7 @@ defineAcpSnapshotSuite({ ### 可能出什么问题 - **fixture 保护拒绝已提交文件**——遗留场景目录、缺失文件、一个 header 类别包含多个 pin、重复的伴随文件内容、未擦除的 JSONL header 与格式错误的 pin header 都会在比较运行前使套件失败。 -- **会话收集需要原始 JSONL mode**——快照配置使用 JSONL 后端的 `compression: 'none'`;压缩 JSONL 与 SQLite 组合没有快照收集路径。 +- **会话收集需要原始 JSONL mode**——快照配置使用 JSONL 后端的 `compression: 'none'`;压缩 JSONL 没有快照收集路径。 - **构建 mode 需要当前产物**——选择 `DSH_EXAMPLE_MODE=lib` 前先运行 `pnpm run build`;源 mode 仍是零构建路径。 ----- @@ -154,7 +154,7 @@ defineAcpSnapshotSuite({ 这些限制说明何时需要对该工具包特别小心。它们是当前包约束,不是任务积压。 -- **会话收集需要原始 JSONL mode**——`runScenario` 收集持久化 `.jsonl` 日志,因此快照配置使用 JSONL 后端的 `compression: 'none'`;压缩 JSONL 与 SQLite 组合没有快照收集路径。 +- **会话收集需要原始 JSONL mode**——`runScenario` 收集持久化 `.jsonl` 日志,因此快照配置使用 JSONL 后端的 `compression: 'none'`;压缩 JSONL 没有快照收集路径。 - **构建 mode 需要当前产物**——选择 `DSH_EXAMPLE_MODE=lib` 前先运行 `pnpm run build`;源 mode 仍是零构建路径。 - **ACP 继续覆盖协议行为**——刺激来自 ACP 客户端的取消与权限往返留在该适配器;组装式一次性行为与持久控制行为使用 headless 与 SDK 适配器。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 272909451f..326595dfff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4796,9 +4796,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl - '@deepseek-ai/dsh-session-persistence-sqlite': - specifier: workspace:^ - version: link:../../session/session-persistence-sqlite '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection @@ -6562,9 +6559,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl - '@deepseek-ai/dsh-session-persistence-sqlite': - specifier: workspace:^ - version: link:../../session/session-persistence-sqlite '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection @@ -7321,9 +7315,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence - '@deepseek-ai/dsh-session-persistence-sqlite': + '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ - version: link:../../session/session-persistence-sqlite + version: link:../../session/session-persistence-jsonl '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection @@ -7500,40 +7494,6 @@ importers: specifier: workspace:^ version: link:../session-persistence - packages/session/session-persistence-sqlite: - dependencies: - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../session-persistence - typescript: - specifier: ^6.0.3 - version: 6.0.3 - packages/session/session-projection: dependencies: zod: @@ -7733,9 +7693,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../session-persistence-jsonl - '@deepseek-ai/dsh-session-persistence-sqlite': - specifier: workspace:^ - version: link:../session-persistence-sqlite '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../session-projection @@ -10515,9 +10472,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../packages/session/session-persistence-jsonl - '@deepseek-ai/dsh-session-persistence-sqlite': - specifier: workspace:^ - version: link:../../packages/session/session-persistence-sqlite '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../packages/session/session-projection diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index e901582d90..38184d656e 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -72,7 +72,6 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e76c248aa7..f7491f709b 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -160,12 +160,6 @@ const packageFileExtras: Readonly> = { // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], - // SQLite loads its compression dictionary and every statement from immutable - // package resources at runtime. - '@deepseek-ai/dsh-session-persistence-sqlite': [ - 'resources/zstd-dictionary.bin', - 'resources/sql/**/*.sql', - ], '@deepseek-ai/dsh-skill-badge': ['assets'], // tsdown shares the repository/pack code between the lib entry and the bin // through a hashed chunk. The committed bin.js is the link target pnpm can diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index fb7f097e43..07462bd105 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -165,8 +165,8 @@ describe('dsh-doc skill consolidation', () => { it('keeps the reference example linked from the skill', () => { const skill = readFileSync(resolve(root, '.agents/skills/dsh-doc/SKILL.md'), 'utf8') - expect(skill).toContain('session-persistence-sqlite/README.md') - expect(skill).toContain('session-persistence-sqlite/README.zh.md') + expect(skill).toContain('session-persistence-jsonl/README.md') + expect(skill).toContain('session-persistence-jsonl/README.zh.md') }) it('defines controlled English as a precision-preserving review discipline', () => { @@ -252,7 +252,7 @@ describe('dsh-doc skill consolidation', () => { }) describe('reference-example README pair', () => { - const dir = 'packages/session/session-persistence-sqlite' + const dir = 'packages/session/session-persistence-jsonl' it('keeps exact English/Chinese physical line alignment', () => { const sourceLines = readFileSync(resolve(root, dir, 'README.md'), 'utf8').split('\n').length diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 81e3f0b9dd..476641a581 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -226,9 +226,9 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session-persistence', title: 'Durable session persistence seam', mode: 'seam', - implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], + implementations: ['session-persistence-jsonl'], consumers: ['agent-loop', 'tool-bash', 'hooks-claude-code', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'], - note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', + note: 'The JSONL backend persists the SessionEvent vocabulary as one artifact per Session.', }, { key: 'settings', diff --git a/tsconfig.base.json b/tsconfig.base.json index ce5d70102c..3c6bc168d0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -412,8 +412,6 @@ "@deepseek-ai/dsh-session-persistence/invariant": ["./packages/session/session-persistence/src/invariant.ts"], "@deepseek-ai/dsh-session-persistence-jsonl": ["./packages/session/session-persistence-jsonl/src"], "@deepseek-ai/dsh-session-persistence-jsonl/invariant": ["./packages/session/session-persistence-jsonl/src/invariant.ts"], - "@deepseek-ai/dsh-session-persistence-sqlite": ["./packages/session/session-persistence-sqlite/src"], - "@deepseek-ai/dsh-session-persistence-sqlite/invariant": ["./packages/session/session-persistence-sqlite/src/invariant.ts"], "@deepseek-ai/dsh-session-projection": ["./packages/session/session-projection/src"], "@deepseek-ai/dsh-session-projection/invariant": ["./packages/session/session-projection/src/invariant.ts"], "@deepseek-ai/dsh-session-projection-cache": ["./packages/session/session-projection-cache/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 49472def70..e21c47c805 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -158,7 +158,6 @@ { "path": "./packages/session/session-checkpoint-policy" }, { "path": "./packages/session/session-log-deepseek" }, { "path": "./packages/session/session-persistence-jsonl" }, - { "path": "./packages/session/session-persistence-sqlite" }, { "path": "./packages/session/session-projection" }, { "path": "./packages/session/session-projection-cache" }, { "path": "./packages/session/session-stats" },