diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml index df384dd2da..b14d9346b1 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.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-09-02-projcache-cross-version-read-compat.md -2026-09-02-projcache-cross-version-read-compat.md: 7463871852234d5df232f6c426dfbec0543777d5 -2026-09-02-projcache-cross-version-read-compat.zh.md: 663a645cd55a16a26add6edba4e414023f8c622c +2026-09-02-projcache-cross-version-read-compat.md: 6cb8454ac2f5e9dfd74144aba76d8aecbed09761 +2026-09-02-projcache-cross-version-read-compat.zh.md: 4c541a3968f56d8edd51ad083b4eb21e3e37b059 diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md index 7463871852..6cb8454ac2 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md @@ -39,24 +39,26 @@ Declared read compatibility — reads tolerate vouched-for older versions, write 5. **Identity matching is stricter than structural admission**: an absent `formatVersion` never matches a current Session, so predecessor rows cannot seed a projection and refold from the authoritative log. Once the format matches, `identityMatches` normalizes absent lineage to unseeded (`?? false` / `?? 0`): exact for an unseeded session, while a seeded expectation fails the match. Poisoned v5 homes therefore boot safely, but their unbound rows are not exposed as current values. 6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `.json.bak.`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). For this domain it supersedes the reset/destroy recovery path of the [2026-07-28 storage recovery proposal](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md), which stays live for authoritative and whole-medium damage. +7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent. + ### v3-v6 → v7 disposition -Versions 3 through 6 remain structurally readable because their record and row representations are valid inputs to the current schema. Their identities lack `formatVersion`, so they are deliberately unusable as current fold shortcuts. A cold read or live checkpoint rebuilds the values from the migrated Session log and writes a v7 record with the complete format and lineage identity. No eager value migration runs at startup; a schema-invalid accepted record follows `backup-and-skip`. +Versions 3 through 6 remain structurally readable because their record and row representations are valid inputs to the current schema. Their identities lack `formatVersion`, so they are deliberately unusable as current fold shortcuts. An unseeded, lifecycle-matching record may still provide its version-compatible title to zero-I/O listing; it cannot provide any authoritative seed. A cold read or live checkpoint rebuilds the values from the migrated Session log and writes a v7 record with the complete format and lineage identity. No eager value migration runs at startup; a schema-invalid accepted record follows `backup-and-skip`. ### Upgrade matrix | home shape | behavior after the fix | |---|---| -| v3 single-file (not poisoned) | bootstrap migrates (3 ∈ accepted set) → boot succeeds; unbound fold waits for cold rebuild | -| v3 + poisoned new tree | new-tree documents parse under optional fields → boot restored; unbound fold waits for cold rebuild | -| v4/v5/v6 per-record | documents read structurally → missing format generation rejects the shortcut; current checkpoint rewrites v7 | +| v3 single-file (not poisoned) | bootstrap migrates (3 ∈ accepted set) → boot succeeds; compatible title is list-visible, unbound fold waits for cold rebuild | +| v3 + poisoned new tree | new-tree documents parse under optional fields → boot restored; compatible title is list-visible, unbound fold waits for cold rebuild | +| v4/v5/v6 per-record | documents read structurally → missing format generation rejects the fold shortcut; compatible title is list-visible; current checkpoint rewrites v7 | | v7 current with matching identity | cached values serve normally | | matching-format record without lineage | unseeded caller may use it; seeded caller rejects it and refolds cold | ## Alternatives considered - **Reject predecessor stamps in the storage layer**: safe for projections, but prevents the guarded legacy bootstrap and loses the ability to retain a structurally sound record until an authoritative refold replaces it. Structural admission plus semantic identity rejection keeps the boot recoverable without serving an unproven value. -- **Treat a missing format generation as current**: preserves cached titles, but lets a pre-migration fold bypass the Session-format edge. Rejected because v0→v1 includes bounded historical normalizers and later edges may change event cardinality. +- **Treat a missing format generation as current for every use**: preserves cached values, but lets a pre-migration fold bypass the Session-format edge. Rejected because v0→v1 includes bounded historical normalizers and later edges may change event cardinality. The title-only listing hint is narrower: it does not seed a fold, and title text is invariant across the installed edges. - **Schema `.default()` fills**: behaviorally equivalent to optional + reader normalization, but bakes the "absent = unseeded" interpretation into the durable schema's output type; ruled for optional — the schema honestly describes every accepted on-disk shape and the interpretation lives at the consumer (user ruling, 2026-09-02). - **Roll the domain version back to 4**: a small diff, but breaks version monotonicity, depends on the "bootstrap skips no versions" bug itself, and drops every poisoned and healthy v5 home's cache. @@ -70,7 +72,7 @@ Versions 3 through 6 remain structurally readable because their record and row r - `storage-json` unit tests: compat-stamped reads / out-of-set discards / writes stamping current; legacy bootstrap migrating only accepted versions (including the migrated-documents-stamp-current assertion); `backupRecord` move / absent read / rewrite / closed guard. - `storage-domain` unit tests: `compatibleVersions` / `invalidRecords` declaration validation; backup-and-skip falling back to fail-loud when the backend has no `backupRecord`. -- `session-projection-cache` unit tests: a matching format with absent lineage serves only an unseeded Session; a predecessor record without a format generation never serves. -- **Archived-fixture recovery tests** (`tests/fixtures.spec.ts` + `tests/fixtures/`): four media archives produced by the real released builds — `v3-single-unit.json` (the 0.1.1-rc.2 whole-unit file), `v4-session-doc.json` (0.1.2-alpha.3), `v5-session-doc.json` (0.1.2-alpha.4), `v5-lineageless-doc.json` (the unguarded bootstrap's poisoned form, synthesized from the v3 record) — each opens through the real storage stack without serving its unbound fold, then a live write replaces it with a v7 record carrying the complete identity and fresh value. The same suite proves backup-and-skip for a schema-failing record: boot survives, `.bak` lands, diagnostics name the failure, and a neighboring predecessor record remains rewritable. +- `session-projection-cache` unit tests: a matching format with absent lineage serves only an unseeded Session; a predecessor record without a format generation cannot seed a fold but may serve only its compatible title hint. +- **Archived-fixture recovery tests** (`tests/fixtures.spec.ts` + `tests/fixtures/`): four media archives produced by the real released builds — `v3-single-unit.json` (the 0.1.1-rc.2 whole-unit file), `v4-session-doc.json` (0.1.2-alpha.3), `v5-session-doc.json` (0.1.2-alpha.4), `v5-lineageless-doc.json` (the unguarded bootstrap's poisoned form, synthesized from the v3 record) — each opens through the real storage stack, serves only its compatible predecessor title, never serves its unbound fold, then accepts a live write that replaces it with a v7 record carrying the complete identity and fresh value. The same suite proves backup-and-skip for a schema-failing record: boot survives, `.bak` lands, diagnostics name the failure, and a neighboring predecessor record remains rewritable. Future bump procedure: add an older domain version to `compatibleVersions` only when the current stored schema can parse it, and let the owning reader decide whether its semantic identity is sufficient. A Session-format change never inherits an absent format generation. The package README requires every bump to land with archived fixtures and tests proving structural admission, semantic use or rejection, and current rewrite. diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md index 663a645cd5..4c541a3968 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md @@ -39,24 +39,26 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;v6 只 5. **identity 匹配比结构准入更严格**:缺失 `formatVersion` 的记录绝不匹配当前 Session,因此前代行不能播种投影,而会从权威日志重新折叠。格式匹配后,`identityMatches` 才把缺失 lineage 归一化为 unseeded(`?? false` / `?? 0`):对 unseeded 会话精确,对 seeded 期望则匹配失败。v5 投毒 home 因而可以安全启动,但其未绑定行不会作为当前值暴露。 6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `.json.bak.`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。对本域而言,该策略取代了 [2026-07-28 存储恢复提案](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md)中 reset/destroy 的恢复途径;该提案对权威介质与整介质损坏仍然有效。 +7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。 + ### v3-v6 → v7 处置 -版本 3 至 6 仍可结构化读取,因为它们的记录与行表示是当前 schema 的有效输入。其 identity 缺少 `formatVersion`,因此有意不能作为当前折叠捷径。冷读或实时检查点会从迁移后的 Session 日志重建值,并写入带完整格式与 lineage identity 的 v7 记录。启动时不运行 eager 值迁移;schema 校验失败的已接受记录执行 `backup-and-skip`。 +版本 3 至 6 仍可结构化读取,因为它们的记录与行表示是当前 schema 的有效输入。其 identity 缺少 `formatVersion`,因此有意不能作为当前折叠捷径。unseeded 且生命周期匹配的记录仍可向零 I/O 列表提供版本兼容的 title,但不能提供任何权威 seed。冷读或实时检查点会从迁移后的 Session 日志重建值,并写入带完整格式与 lineage identity 的 v7 记录。启动时不运行 eager 值迁移;schema 校验失败的已接受记录执行 `backup-and-skip`。 ### 升级矩阵 | home 形态 | 修复后行为 | |---|---| -| v3 单文件(未投毒) | bootstrap 迁移(3 ∈ 接受集)→ 启动成功;未绑定折叠等待冷重建 | -| v3 + 投毒新树 | optional 字段让新树文档可解析 → 启动恢复;未绑定折叠等待冷重建 | -| v4/v5/v6 per-record | 文档结构化读入 → 缺格式代而拒绝捷径;当前检查点重写 v7 | +| v3 单文件(未投毒) | bootstrap 迁移(3 ∈ 接受集)→ 启动成功;兼容 title 在列表可见,未绑定折叠等待冷重建 | +| v3 + 投毒新树 | optional 字段让新树文档可解析 → 启动恢复;兼容 title 在列表可见,未绑定折叠等待冷重建 | +| v4/v5/v6 per-record | 文档结构化读入 → 缺格式代而拒绝 fold shortcut;兼容 title 在列表可见;当前检查点重写 v7 | | identity 匹配的 v7 当前记录 | 正常服务缓存值 | | 格式匹配但缺 lineage 的记录 | unseeded 调用方可以使用;seeded 调用方拒绝并回落冷折叠 | ## 备选方案 - **在存储层拒绝前代版本戳**:对投影安全,但会阻止受保护的 legacy bootstrap,也无法保留结构完好的记录直到权威重折叠替换它。结构准入加语义 identity 拒绝既让启动可恢复,也不服务未经证明的值。 -- **把缺失格式代视为当前代**:可以保留缓存标题,但让迁移前折叠绕过 Session 格式边。不采用,因为 v0→v1 含有界历史规范化,后续边还可能改变事件基数。 +- **在所有用途中把缺失格式代视为当前代**:可以保留缓存值,但让迁移前折叠绕过 Session 格式边。不采用,因为 v0→v1 含有界历史规范化,后续边还可能改变事件基数。title-only 列表 hint 更窄:它不播种 fold,而且 title 文本在已安装 edge 之间保持不变。 - **schema `.default()` 填缺省**:行为与 optional+读点归一化等价,但把"缺失=unseeded"的解释固化进 durable schema 的输出类型;拍板为 optional——schema 如实描述介质上所有被接受的形态,解释权在消费点(2026-09-02 用户裁决)。 - **域版本回退到 4**:改动很小,但破坏版本单调性、依赖"bootstrap 不查版本"这个 bug 本身、且投毒态与正常 v5 home 的缓存全被丢弃。 @@ -70,7 +72,7 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;v6 只 - `storage-json` 单测:compat 版本戳读入/集合外丢弃/写恒当前版本;legacy bootstrap 仅在版本被接受时迁移(含迁移后文档戳当前版本断言);`backupRecord` 移档/读缺席/重写/封闭守卫。 - `storage-domain` 单测:`compatibleVersions`/`invalidRecords` 声明校验;后端无 `backupRecord` 时 backup-and-skip 回退 fail-loud。 -- `session-projection-cache` 单测:格式匹配且缺 lineage 的记录只服务 unseeded Session;缺格式代的前代记录永不服务。 -- **归档 fixtures 独立恢复测试**(`tests/fixtures.spec.ts` + `tests/fixtures/`):真实发布物产出的四份介质存档——`v3-single-unit.json`(0.1.1-rc.2 整域单文件)、`v4-session-doc.json`(0.1.2-alpha.3)、`v5-session-doc.json`(0.1.2-alpha.4)、`v5-lineageless-doc.json`(无守卫 bootstrap 的投毒形态,由 v3 记录合成)——逐一走真实存储栈开域且不服务其未绑定折叠,随后由实时写入替换成带完整 identity 与新值的 v7 记录。同一套件还证明 schema 失败记录的 backup-and-skip:启动不失败、`.bak` 落盘、诊断点名失败,且邻近前代记录仍可重写。 +- `session-projection-cache` 单测:格式匹配且缺 lineage 的记录只服务 unseeded Session;缺格式代的前代记录不能播种 fold,但只能提供其兼容 title hint。 +- **归档 fixtures 独立恢复测试**(`tests/fixtures.spec.ts` + `tests/fixtures/`):真实发布物产出的四份介质存档——`v3-single-unit.json`(0.1.1-rc.2 整域单文件)、`v4-session-doc.json`(0.1.2-alpha.3)、`v5-session-doc.json`(0.1.2-alpha.4)、`v5-lineageless-doc.json`(无守卫 bootstrap 的投毒形态,由 v3 记录合成)——逐一走真实存储栈开域、只服务其兼容 predecessor title、绝不服务其未绑定折叠,随后由实时写入替换成带完整 identity 与新值的 v7 记录。同一套件还证明 schema 失败记录的 backup-and-skip:启动不失败、`.bak` 落盘、诊断点名失败,且邻近前代记录仍可重写。 未来 bump 流程:只有当前存储 schema 能解析旧 domain 版本时,才把它加入 `compatibleVersions`,再由 owner reader 判断其语义 identity 是否充分。Session 格式变化绝不继承缺失的格式代。包 README 要求每次 bump 都随附归档 fixture 与测试,证明结构准入、语义使用或拒绝,以及当前重写。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9914f5bc9a..992e95b8ca 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: bc490f1896ef4e8ab0c8b19c41bcf883a357b2d7 -config-catalog.zh.md: 7cb16a292955a202d74bf852f86604056a457e98 +config-catalog.md: 2bd56cf807ce7433ead009e5f6df73c6151a6664 +config-catalog.zh.md: ef61011aadd14c22c3a8a0fb3d45b029194a166b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bc490f1896..2bd56cf807 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -208,16 +208,12 @@ Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions ```ts config-catalog /** Session Controller deployment policy. */ export interface Config { - /** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */ - readonly coldBlankProbeMaxEvents?: number - /** Maximum stat-reported artifact byte size eligible for one full cold projection observation; `0` disables the byte-size gate. */ - readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean } ``` -Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) @@ -1870,7 +1866,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:61`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:63`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7cb16a2929..ef61011aad 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -210,16 +210,12 @@ export interface Config { ```ts config-catalog /** Session Controller deployment policy. */ export interface Config { - /** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */ - readonly coldBlankProbeMaxEvents?: number - /** Maximum stat-reported artifact byte size eligible for one full cold projection observation; `0` disables the byte-size gate. */ - readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean } ``` -来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) @@ -1872,7 +1868,7 @@ export interface Config { } ``` -来源:[`packages/session/session-projection-cache/src/index.ts:61`](../packages/session/session-projection-cache/src/index.ts) +来源:[`packages/session/session-projection-cache/src/index.ts:63`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index cd4b54a9bf..565c07f036 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md event-producer-consumer.md: 5759a76782c874053834caee2a037fb1c5614f3c -event-producer-consumer.zh.md: 3fd1a6860d8c2312bcae8a6e6c0ff54213074b69 +event-producer-consumer.zh.md: debfdc74cf0f86576b17269ede48dda8df014eb3 diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3fd1a6860d..debfdc74cf 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -76,13 +76,13 @@ | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -## 包源码中出现的非 harness 或未声明事件字符串 +## Non-harness or undeclared event strings seen in package source -| 事件字符串 | 派发方 | 监听方 | +| Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | -维护模式:生成内容。Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析。 +Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index d83dde08ba..5e526daf66 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: 38e671ef1d3791428a46963a24e6c266bbaf7878 -persistence.zh.md: c8b418eb5c5820149bd4bf06df15b061232c23c6 +persistence.md: 579a61400a8a8b7965c58e50ab33ba7b1c385424 +persistence.zh.md: 00823763d9c1d034855df672461f932254a205c7 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 38e671ef1d..579a61400a 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -299,7 +299,7 @@ interface SessionPersistenceSnapshot { } ``` -The optional `eventCount`/`sizeBytes` hints let the session list's cold blank probe bound its work from metadata alone (session-controller config `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`) without opening any log. +The optional `eventCount`/`sizeBytes` fields remain cheap backend observations for consumers that explicitly need them. Session listing does not use either field to open cold logs: it reads headers plus identity-checked projection-cache hints only, so a cache or Session-format upgrade never turns startup into a body scan. ## The backend diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index c8b418eb5c..00823763d9 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -299,7 +299,7 @@ interface SessionPersistenceSnapshot { } ``` -可选的 `eventCount`/`sizeBytes` 提示让会话列表的冷空白探测(cold blank probe)仅凭元数据即可限定其工作量(session-controller 配置 `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`),而无需打开任何日志。 +可选的 `eventCount`/`sizeBytes` 字段仍是供明确需要它们的 consumer 使用的低成本 backend observation。Session 列表不借助这两个字段打开冷日志,只读取 header 与经过 identity 校验的 projection cache hint,因此 cache 或 Session format 升级不会把启动变成 body scan。 ## 后端 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 1de3205623..4d011a8c0b 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: e02ae8e36e79c4630a3799bf125ba8567db732cd -session-projection.zh.md: f95545ab3d5492c80400eea413ad0cfa4ff7fb90 +session-projection.md: 8739b6d6f65b0927a4386e6d611570e686b89474 +session-projection.zh.md: 9ef17bcf9383498e4a79e2ecb6c29b9b2f17da83 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index e02ae8e36e..8739b6d6f6 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -137,6 +137,23 @@ The persisted projection cache service. Opens the `session_projcache` domain at */ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +/** + * Read only a predecessor checkpoint's title as a zero-I/O listing hint. + * + * The authoritative Session header supplies the lifecycle identity. A cache + * checkpoint can lag that log but cannot lead it because writes flush the + * log first, so a matching predecessor title is a genuine (possibly stale) + * fact from this Session. The registry still requires the current title + * projection's row version and schema. No other predecessor projection is + * exposed: format normalization can change their current meaning, and the + * strict {@link cachedSnapshot} / hydration paths continue to reject them. + * @param meta - authoritative listed Session header. + * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. + * @returns a title-only checkpoint view, or `undefined` when the record is + * current, newer, unrelated, missing, or incompatible with the title unit. + */ +cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined + /** * Hydrate projection cells for an already-prepared Session without another * persistence read. The cache seeds matching rows; the supplied exact log diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index f95545ab3d..9ef17bcf93 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -137,6 +137,23 @@ The persisted projection cache service. Opens the `session_projcache` domain at */ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +/** + * Read only a predecessor checkpoint's title as a zero-I/O listing hint. + * + * The authoritative Session header supplies the lifecycle identity. A cache + * checkpoint can lag that log but cannot lead it because writes flush the + * log first, so a matching predecessor title is a genuine (possibly stale) + * fact from this Session. The registry still requires the current title + * projection's row version and schema. No other predecessor projection is + * exposed: format normalization can change their current meaning, and the + * strict {@link cachedSnapshot} / hydration paths continue to reject them. + * @param meta - authoritative listed Session header. + * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. + * @returns a title-only checkpoint view, or `undefined` when the record is + * current, newer, unrelated, missing, or incompatible with the title unit. + */ +cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined + /** * Hydrate projection cells for an already-prepared Session without another * persistence read. The cache seeds matching rows; the supplied exact log diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index c9f428e1c6..7b7b9e0b45 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 82112eeabb2edf989965774b2738b5f8a6971a0f -README.zh.md: b54171073e7846c55ef7920f1bb0cf2b57fb17d7 +README.md: 1b436cadc4992b53b3162b04be1ae256aa44f1d8 +README.zh.md: 38e8b5e9b164204d0ee274590fd7589eb3a33e24 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 82112eeabb..1b436cadc4 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedTime`, `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publish it while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps reopen follow, and an abandoned end publishes no durable settlement. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. @@ -38,8 +38,6 @@ The Session object also carries local submission echoes: `session.beginSubmissio | Field | Default | Meaning | |---|---:|---| -| `coldBlankProbeMaxEvents` | `16` | Maximum stat-reported event count of a cold Session eligible for blankness verification; `0` disables the event-count gate | -| `coldBlankProbeMaxBytes` | `1,024` | Maximum stat-reported artifact byte size of a cold Session eligible for blankness verification when the backend offers no event count; `0` disables the byte-size gate | | `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b54171073e..38e8b5e9b1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" 历史页与 follow opening snapshot 为每个持久 Session event 携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑 stream 内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedTime`、`startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 到达后再发布,而同一步骤中更早的 retry 保持可见。revision、密集 index 或 settlement 缺口会重新打开 follow,abandoned end 不发布持久 settlement。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 @@ -38,8 +38,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 | 字段 | 默认值 | 含义 | |---|---:|---| -| `coldBlankProbeMaxEvents` | `16` | stat 报告的事件数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用事件数门槛 | -| `coldBlankProbeMaxBytes` | `1,024` | 后端不提供事件数时,stat 报告的工件字节数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用字节数门槛 | | `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index e982bf6f72..4149e92aef 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -17,11 +17,7 @@ import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' -import { - ApiSessionList, - DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, - DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS, -} from './list.ts' +import { ApiSessionList } from './list.ts' import { buildModelCatalog } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' @@ -70,10 +66,6 @@ declare module '@deepseek-ai/cordis' { /** Session Controller deployment policy. */ export interface Config { - /** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */ - readonly coldBlankProbeMaxEvents?: number - /** Maximum stat-reported artifact byte size eligible for one full cold projection observation; `0` disables the byte-size gate. */ - readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean } @@ -101,8 +93,6 @@ export class SessionController extends TypertRemoteService { ] static Config: z = z.object({ - coldBlankProbeMaxEvents: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS), - coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), nativeOpen: z.boolean(), }) @@ -117,7 +107,7 @@ export class SessionController extends TypertRemoteService { /** * @param ctx - Host context containing the Session capability assembly. - * @param config - cold-list observation and native-opener deployment policy. + * @param config - native-opener deployment policy. * @param internals - host integrations replaceable by direct unit tests. */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { @@ -132,10 +122,7 @@ export class SessionController extends TypertRemoteService { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) - this.listState = new ApiSessionList(ctx, { - coldBlankProbeMaxEvents: config.coldBlankProbeMaxEvents ?? DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS, - coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, - }) + this.listState = new ApiSessionList(ctx) this.openPath = internals.openPath ?? openNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 3c9d78ecf9..b046f0ca91 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -19,21 +19,6 @@ import type { SessionSearchValue, SessionSummary, } from './types.ts' -/** Default maximum stat-reported event count eligible for one cold projection observation. */ -export const DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS = 16 - -/** Default maximum stat-reported artifact size eligible for one cold projection observation. */ -export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 - -/** Resolved cold-blank probe policy: each threshold gates its stat metric; `0` disables that gate. */ -export interface ColdBlankProbePolicy { - /** Maximum stat-reported `eventCount` eligible for a full observation. */ - readonly coldBlankProbeMaxEvents: number - /** Maximum stat-reported `sizeBytes` eligible for a full observation. */ - readonly coldBlankProbeMaxBytes: number -} - -const COLD_SUMMARY_BATCH_SIZE = 16 const SEARCH_PROVIDER_CALL_LIMIT = 100 const SESSION_SEARCH_QUERY_MAX_CHARS = 500 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) @@ -90,14 +75,8 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin /** Owns list projection registration, bounded cold summaries, and authorized search. */ export class ApiSessionList { - /** - * @param ctx - Host context carrying Session, query, persistence, and projection services. - * @param probe - stat-metadata thresholds gating a full cold observation. - */ - constructor( - private readonly ctx: Context, - private readonly probe: ColdBlankProbePolicy, - ) { + /** @param ctx - Host context carrying Session, query, persistence, and projection services. */ + constructor(private readonly ctx: Context) { ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ key: 'sessionListMetadata', stateSchema: sessionListMetadataSchema, @@ -159,26 +138,13 @@ export class ApiSessionList { if (record.header.cwd === undefined) continue cold.push(record.header) } - for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) { - const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE) - .map(header => this.summarizeCold(header, signal))) - for (const result of settled) { - if (result.status === 'rejected') throw result.reason - items.push(result.value) - } - } + for (const header of cold) items.push(this.summarizeCold(header)) items.sort((left, right) => right.updatedAt - left.updatedAt) return items } - private async summarizeCold( - header: SessionHeader, - signal: AbortSignal | undefined, - ): Promise { - const cached = this.projectionsFor(header, undefined) - const projections = cached?.values.sessionListMetadata?.blank === false - ? cached - : await this.probeSmallCold(header, signal) ?? cached + private summarizeCold(header: SessionHeader): SessionSummary { + const projections = this.projectionsFor(header, undefined) const raced = this.ctx.sessions.get(header.id) if (raced !== undefined) return this.summaryFor(raced) const metadata = projections?.values.sessionListMetadata @@ -193,54 +159,6 @@ export class ApiSessionList { } } - private async probeSmallCold( - header: SessionHeader, - signal: AbortSignal | undefined, - ): Promise { - const { coldBlankProbeMaxEvents, coldBlankProbeMaxBytes } = this.probe - if (coldBlankProbeMaxEvents === 0 && coldBlankProbeMaxBytes === 0) return undefined - const persistence = this.ctx.get('sessionPersistence') - if (persistence === undefined) return undefined - signal?.throwIfAborted() - let snapshot: Awaited> - try { - snapshot = await persistence.stat(header.id, signal === undefined ? {} : { signal }) - } catch (error: unknown) { - // An unreadable single session degrades to unknown state instead of - // failing the whole list request. - signal?.throwIfAborted() - this.ctx.logger.warn( - `api-session.list: cold stat for "${header.id}" failed; serving it as visible: ${String(error)}`, - ) - return undefined - } - if (snapshot === undefined) return undefined - if (snapshot.eventCount !== undefined) { - if (coldBlankProbeMaxEvents === 0 || snapshot.eventCount > coldBlankProbeMaxEvents) return undefined - } else if (snapshot.sizeBytes !== undefined) { - if (coldBlankProbeMaxBytes === 0 || snapshot.sizeBytes > coldBlankProbeMaxBytes) return undefined - } else { - // The backend offers no cheap size hint, so a full observation is unbounded work. - return undefined - } - try { - using observation = await this.ctx.sessionQuery.observeSession(header.id, { - ...(signal === undefined ? {} : { signal }), - projectionMode: 'all', - }) - const block = observation.projections - return block === undefined - ? undefined - : { asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues } - } catch (error: unknown) { - signal?.throwIfAborted() - this.ctx.logger.warn( - `api-session.list: small cold observation for "${header.id}" failed; serving it as visible: ${String(error)}`, - ) - return undefined - } - } - /** * Search current visible message content without activating any matching Session. * @param query - literal message-content query. @@ -354,10 +272,12 @@ export class ApiSessionList { session: Session | undefined, ): SessionProjectionHints | undefined { try { + const cache = this.ctx.get('sessionProjectionCache') const block = session === undefined ? header.isSeeded ? undefined - : this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header, SessionLogOffset(0)) + : cache?.cachedSnapshot(header, SessionLogOffset(0)) + ?? cache?.cachedPredecessorTitle(header, SessionLogOffset(0)) : this.ctx.sessionProjections.cachedSnapshot(session) return block !== undefined && Object.keys(block.values).length > 0 ? { diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 49a0a35b27..6da0420814 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -15,16 +15,14 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { SessionPersistenceRevision, type SessionPersistenceSnapshot, } from '@deepseek-ai/dsh-session-persistence' -import { ApiSessionList } from '../src/list.ts' import { createSessionTestRemote, - installSessionReadTestServices, testSessionPersistence, } from './test-remote.ts' @@ -59,11 +57,6 @@ function statSnapshot( return { header: meta, revision: SessionPersistenceRevision(`test:${meta.id}:stat`), ...metrics } } -/** A stored log whose only event is the seed boundary: still blank. */ -function blankEvents(): SessionEvent[] { - return [{ type: 'session/end-seed', seq: SessionSeq(0), time: 700, data: {} }] as SessionEvent[] -} - /** A stored log with one human prompt at time 1200: proven non-blank. */ function conversationEvents(): SessionEvent[] { return [ @@ -77,7 +70,57 @@ function conversationEvents(): SessionEvent[] { } describe('sessions.list cold merge', () => { - it('serves cold rows from cached projections when stat offers no size metadata', async () => { + it('uses a predecessor title hint with zero cold stat or body reads', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const metas = [header('legacy-title', 100), header('uncached', 200)] + const stat = vi.fn(async (id: SessionId) => statSnapshot( + metas.find(meta => meta.id === id)!, + { sizeBytes: 1 }, + )) + const inspect = vi.fn(async (id: SessionId) => ({ + meta: metas.find(meta => meta.id === id)!, + events: conversationEvents(), + })) + providePersistence(ctx, { + list: () => Promise.resolve(metas), + stat, + inspect, + }) + ctx.provide('sessionProjectionCache', { + cachedSnapshot: () => undefined, + cachedPredecessorTitle: (meta: SessionHeader) => meta.id === sid('legacy-title') + ? { asOfSeq: 3, values: { title: 'Cached predecessor title' } } + : undefined, + } as never) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + + const response = await remote.list(request({})) + + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ + sessionId: sid('uncached'), + blank: false, + updatedAt: 200, + }), + expect.objectContaining({ + sessionId: sid('legacy-title'), + blank: false, + updatedAt: 100, + projections: { asOfSeq: 3, values: { title: 'Cached predecessor title' } }, + }), + ]) + expect(stat).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + }) + + it('serves cold rows from current cached projections without body access', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const metas: SessionHeader[] = [ @@ -104,6 +147,7 @@ describe('sessions.list cold merge', () => { } return undefined }, + cachedPredecessorTitle: () => undefined, } as never) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -113,7 +157,7 @@ describe('sessions.list cold merge', () => { const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item])) expect(byId['cached-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false }) expect(byId['cached-conversation']).toMatchObject({ blank: false, updatedAt: 1000 }) - // A cache miss with a metadata-less stat leaves blankness unknown; the row stays visible. + // A cache miss leaves blankness unknown; the row stays visible without a body read. expect(byId['uncached']).toMatchObject({ blank: false, updatedAt: 300, @@ -128,315 +172,6 @@ describe('sessions.list cold merge', () => { expect(inspect).not.toHaveBeenCalled() }) - it('fully observes only small possibly-blank logs gated by stat eventCount', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const metas: SessionHeader[] = [ - header('small-blank', 100), - header('small-conversation', 200), - header('large-unknown', 300), - header('cached-nonblank', 400), - header('vanished', 600), - { version: SESSION_FORMAT_VERSION, id: sid('missing-cwd'), createdAt: 800, isSeeded: false }, - ] - const inspect = vi.fn(async (id: SessionId) => { - if (id === sid('small-blank')) return { meta: metas[0]!, events: blankEvents() } - if (id === sid('small-conversation')) return { meta: metas[1]!, events: conversationEvents() } - throw new Error(`unexpected cold read: ${id}`) - }) - const stat = vi.fn(async (id: SessionId) => { - if (id === sid('small-blank')) return statSnapshot(metas[0]!, { eventCount: 1 }) - if (id === sid('small-conversation')) return statSnapshot(metas[1]!, { eventCount: 2 }) - if (id === sid('large-unknown')) return statSnapshot(metas[2]!, { eventCount: 17 }) - if (id === sid('vanished')) return undefined - throw new Error(`unexpected stat: ${id}`) - }) - providePersistence(ctx, { - list: () => Promise.resolve(metas), - stat, - inspect, - }) - ctx.provide('sessionProjectionCache', { - cachedSnapshot: (meta: SessionHeader) => { - if (meta.id === sid('small-blank')) { - return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } } - } - if (meta.id === sid('small-conversation')) { - return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } } - } - if (meta.id === sid('cached-nonblank')) { - return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } - } - return undefined - }, - hydratePrepared: (session: Session, events: readonly SessionEvent[]) => - ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)), - } as never) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - - const response = await remote.list(request({})) - expect(response.ok).toBe(true) - if (!response.ok) throw new Error('unreachable') - const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item])) - expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false }) - expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 }) - expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 }) - expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 }) - expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 }) - expect(byId['missing-cwd']).toBeUndefined() - // A cache row proving blank:false is never re-probed. - expect(stat.mock.calls.map(([id]) => id)).not.toContain(sid('cached-nonblank')) - expect(inspect).toHaveBeenCalledTimes(2) - expect(inspect.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([ - sid('small-blank'), - sid('small-conversation'), - ])) - }) - - it('falls back to the stat sizeBytes gate when no eventCount is offered', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const metas = [header('small-jsonl', 100), header('large-jsonl', 200)] - const inspect = vi.fn(async (id: SessionId) => { - if (id === sid('small-jsonl')) return { meta: metas[0]!, events: conversationEvents() } - throw new Error(`unexpected cold read: ${id}`) - }) - providePersistence(ctx, { - list: () => Promise.resolve(metas), - stat: (id: SessionId) => Promise.resolve(id === sid('small-jsonl') - ? statSnapshot(metas[0]!, { sizeBytes: 1024 }) - : statSnapshot(metas[1]!, { sizeBytes: 1025 })), - inspect, - }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - - const response = await remote.list(request({})) - if (!response.ok) throw new Error('list failed') - const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item])) - expect(byId['small-jsonl']).toMatchObject({ blank: false, updatedAt: 1200 }) - expect(byId['large-jsonl']).toMatchObject({ blank: false, updatedAt: 200 }) - expect(inspect).toHaveBeenCalledTimes(1) - expect(inspect).toHaveBeenCalledWith(sid('small-jsonl'), expect.anything()) - }) - - it('skips the observation when stat offers neither eventCount nor sizeBytes', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('no-metrics', 100) - const inspect = vi.fn() - const stat = vi.fn(async () => statSnapshot(meta)) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - stat, - inspect, - }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - - const response = await remote.list(request({})) - if (!response.ok) throw new Error('list failed') - expect(response.value.items).toEqual([ - expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }), - ]) - expect(stat).toHaveBeenCalledOnce() - expect(inspect).not.toHaveBeenCalled() - }) - - it('can disable both probe gates without hiding cold Sessions or calling stat', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('probe-disabled', 100) - const inspect = vi.fn() - const stat = vi.fn() - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - stat, - inspect, - }) - const remote = createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), - cwd: '/tmp', - coldBlankProbeMaxEvents: 0, - coldBlankProbeMaxBytes: 0, - }) - - const response = await remote.list(request({})) - if (!response.ok) throw new Error('unreachable') - expect(response.value.items).toEqual([ - expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }), - ]) - expect(stat).not.toHaveBeenCalled() - expect(inspect).not.toHaveBeenCalled() - }) - - it('treats zero as disabling one gate without falling back to the other metric', async () => { - const bench = async ( - metrics: Partial>, - thresholds: { coldBlankProbeMaxEvents?: number; coldBlankProbeMaxBytes?: number }, - ) => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('gate-off', 100) - const inspect = vi.fn() - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - stat: () => Promise.resolve(statSnapshot(meta, metrics)), - inspect, - }) - const remote = createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), - cwd: '/tmp', - ...thresholds, - }) - const response = await remote.list(request({})) - if (!response.ok) throw new Error('list failed') - expect(response.value.items).toEqual([ - expect.objectContaining({ sessionId: meta.id, blank: false }), - ]) - expect(inspect).not.toHaveBeenCalled() - } - - // An offered eventCount never falls through to the byte gate, even disabled. - await bench({ eventCount: 1, sizeBytes: 10 }, { coldBlankProbeMaxEvents: 0 }) - await bench({ sizeBytes: 10 }, { coldBlankProbeMaxBytes: 0 }) - }) - - it('prefers a Session that attaches during its bounded cold observation', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - const meta = header('attached-during-probe', 100) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - stat: () => { - const session = ctx.sessions.create(meta.id, { - meta, - seed: [{ type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } }], - }) - ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent) - return Promise.resolve(statSnapshot(meta, { eventCount: 1 })) - }, - }) - const remote = createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', - }) - - const response = await remote.list(request({})) - if (!response.ok) throw new Error('list failed') - expect(response.value.items).toEqual([ - expect.objectContaining({ sessionId: meta.id, running: true, blank: false }), - ]) - await ctx.fiber.dispose() - }) - - it('serves a session whose cold stat fails as visible instead of failing the list', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('broken-stat', 100) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - stat: () => Promise.reject(new Error('stat failed')), - }) - const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const remote = createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', - }) - - const response = await remote.list(request({})) - if (!response.ok) throw new Error('list failed') - expect(response.value.items).toEqual([ - expect.objectContaining({ sessionId: meta.id, running: false }), - ]) - expect(warned.mock.calls.join('\n')).toContain('cold stat for "broken-stat" failed') - warned.mockRestore() - await ctx.fiber.dispose() - }) - - it('a stat rejection after cancellation propagates instead of degrading', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('stat-abort', 100) - const controller = new AbortController() - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - stat: () => { - controller.abort(new Error('caller left')) - return Promise.reject(new Error('stat failed')) - }, - }) - const remote = createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', - }) - - await expect(remote.list(request({}), controller.signal)).resolves.toMatchObject({ - ok: false, - error: { code: 'gateway/cancelled' }, - }) - await ctx.fiber.dispose() - }) - - it('serves a small cold Session as visible when its observation fails', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const meta = header('read-failure', 700) - const inspect = vi.fn(async () => { throw new Error('simulated read failure') }) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - stat: () => Promise.resolve(statSnapshot(meta, { eventCount: 1 })), - inspect, - }) - const remote = createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', - }) - - const response = await remote.list(request({})) - if (!response.ok) throw new Error('list failed') - expect(response.value.items).toEqual([ - expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: 700 }), - ]) - expect(inspect).toHaveBeenCalledOnce() - }) - - it('supports an unsignalled probe whose observation has no projection block', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - installSessionReadTestServices(ctx) - const meta = header('unprojected-small', 100) - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - stat: () => Promise.resolve(statSnapshot(meta, { eventCount: 0 })), - } as never) - vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{ - header: meta, live: false, persisted: true, - }]) - vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({ - source: 'prepared', header: meta, inheritedEventCount: SessionLogOffset(0), events: [], cursor: -1, - retain: vi.fn(), [Symbol.dispose]: vi.fn(), - }) - const list = new ApiSessionList(ctx, { coldBlankProbeMaxEvents: 16, coldBlankProbeMaxBytes: 1024 }) - - await expect(list.list()).resolves.toEqual([ - expect.objectContaining({ sessionId: meta.id, blank: false }), - ]) - await ctx.fiber.dispose() - }) - - it('serves a cold row visible when no persistence service can stat it', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - installSessionReadTestServices(ctx) - const meta = header('service-less', 100) - vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{ - header: meta, live: false, persisted: true, - }]) - const list = new ApiSessionList(ctx, { coldBlankProbeMaxEvents: 16, coldBlankProbeMaxBytes: 1024 }) - - await expect(list.list()).resolves.toEqual([ - expect.objectContaining({ sessionId: meta.id, blank: false }), - ]) - await ctx.fiber.dispose() - }) - it('prefers a live row attached during the query without folding its seed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index f44d8a6cc8..c616bc6910 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -96,7 +96,7 @@ function installSearchQuery( describe('session.search', () => { it('rejects search when the query service is absent', async () => { const ctx = await baseContext() - const list = new ApiSessionList(ctx, { coldBlankProbeMaxEvents: 16, coldBlankProbeMaxBytes: 1024 }) + const list = new ApiSessionList(ctx) await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/internal', diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 1b5859b509..00dda55bc9 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -83,8 +83,6 @@ export interface TestSessionRemote { export interface TestSessionRemoteDefaults { readonly defaultModelSelection: () => AgentModelSelection readonly cwd: string - readonly coldBlankProbeMaxEvents?: number - readonly coldBlankProbeMaxBytes?: number readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise @@ -141,8 +139,7 @@ function testReadHandle( /** * Adapt a compact header/inspect persistence double onto the handle-based * abstract the production readers consume: `list` snapshots wrap the double's - * headers, `stat` derives a metadata-less snapshot from the listing (so the - * cold-blank probe skips unless the double declares its own `stat`), and + * headers, `stat` derives a metadata-less snapshot from the listing, and * `open` serves immutable read handles over the double's `inspect` result. */ export function testSessionPersistence( @@ -243,12 +240,6 @@ function installControllers( controller = new SessionController( ctx, { - ...defaults.coldBlankProbeMaxEvents === undefined - ? {} - : { coldBlankProbeMaxEvents: defaults.coldBlankProbeMaxEvents }, - ...defaults.coldBlankProbeMaxBytes === undefined - ? {} - : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, }, { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6c638ccbad..a74f9a94e1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1509,6 +1509,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'inheritedEventCount', description: 'exact inherited prefix length that completes the checkpoint identity.' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.', }, + { + signature: 'cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined', + description: 'Read only a predecessor checkpoint\'s title as a zero-I/O listing hint.\n\nThe authoritative Session header supplies the lifecycle identity. A cache checkpoint can lag that log but cannot lead it because writes flush the log first, so a matching predecessor title is a genuine (possibly stale) fact from this Session. The registry still requires the current title projection\'s row version and schema. No other predecessor projection is exposed: format normalization can change their current meaning, and the strict cachedSnapshot / hydration paths continue to reject them.', + parameters: [{ name: 'meta', description: 'authoritative listed Session header.' }, { name: 'inheritedEventCount', description: 'exact inherited cut completing the lifecycle identity.' }], + returns: 'a title-only checkpoint view, or `undefined` when the record is current, newer, unrelated, missing, or incompatible with the title unit.', + }, { signature: 'hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot', description: 'Hydrate projection cells for an already-prepared Session without another persistence read. The cache seeds matching rows; the supplied exact log advances every unit to the observation cut. No checkpoint is written because the logical observation may contain recovery events not yet durable.', diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 3df50c129f..51e3de943e 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/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-projection-cache/README.md -README.md: 4b454e277d2a9341baaae333f6aa5fa02a86f42c -README.zh.md: c166592d071669e2075040111fd61c1b747c0264 +README.md: 56adf71d1dda7e45df6f3ea14ed958f8cde150bf +README.zh.md: ddaa203f7b9fab48f373edbb6c1fae4a5b8cb43d diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 4b454e277d..56adf71d1d 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -58,13 +58,13 @@ Three mandatory points always write: session creation persists the seed-derived ### Reading cached values -`cachedSnapshot(meta, inheritedEventCount)` synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts only an identity-matching record and version- and schema-matching keys, then returns a `{ asOfSeq, values }` cut at the lowest served-row watermark. An unseeded listing knows that its cut is zero; a seeded header-only listing does not know the numeric cut and must skip this fast path until an authoritative body read supplies it. `coldSnapshot(meta, inheritedEventCount, events)` accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. +`cachedSnapshot(meta, inheritedEventCount)` synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts only an identity-matching record and version- and schema-matching keys, then returns a `{ asOfSeq, values }` cut at the lowest served-row watermark. `cachedPredecessorTitle(meta, inheritedEventCount)` is the narrower listing-only exception: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row. The title is a possibly stale fact from a durable prefix, not a fold seed; all other predecessor rows remain unavailable. An unseeded listing knows that its cut is zero; a seeded header-only listing does not know the numeric cut and skips both fast paths until an authoritative body read supplies it. `coldSnapshot(meta, inheritedEventCount, events)` accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. ### What the cache guarantees The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row folded from another Session format generation or fork cut cannot seed the caller. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. -Upgrades never cost the boot or expose an unproven fold. Records stamped with a version in the spec's `compatibleVersions` remain structurally readable for a current checkpoint rewrite, but a missing `formatVersion` never matches a current Session and therefore refolds cold. Once the format matches, absent lineage fields decode as the unseeded lineage — exact for unseeded sessions, while a seeded caller fails the identity match and refolds cold. A stored record that still fails schema validation is moved aside as `.json.bak.` under the domain's `invalidRecords: 'backup-and-skip'` policy, logged with its cause, and rebuilt by the next checkpoint. +Upgrades never cost the boot or expose an unproven fold. Records stamped with a version in the spec's `compatibleVersions` remain structurally readable for a current checkpoint rewrite, but a missing or older `formatVersion` never matches a current Session and therefore cannot seed hydration. A lifecycle-matching predecessor title remains available only through the listing hint above because title text is invariant across the adjacent Session-format edges and its row still passes the current projection `stateVersion` and schema. Once the format matches, absent lineage fields decode as the unseeded lineage — exact for unseeded sessions, while a seeded caller fails the identity match and refolds cold. A stored record that still fails schema validation is moved aside as `.json.bak.` under the domain's `invalidRecords: 'backup-and-skip'` policy, logged with its cause, and rebuilt by the next checkpoint. ----- diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index c166592d07..ddaa203f7b 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -58,13 +58,13 @@ kind: "package-reference" ### 读取缓存值 -`cachedSnapshot(meta, inheritedEventCount)` 以零 I/O 从存储域的内存表同步提供客户端值。它只接受身份匹配的记录以及版本和 schema 均匹配的 key,再按所服务行的最低水位返回 `{ asOfSeq, values }` 切面。未 seeded 的列表知道切点为零;仅 header 的 seeded 列表不知道数字切点,必须跳过该快速路径,直到权威正文读取提供它。`coldSnapshot(meta, inheritedEventCount, events)` 接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 +`cachedSnapshot(meta, inheritedEventCount)` 以零 I/O 从存储域的内存表同步提供客户端值。它只接受身份匹配的记录以及版本和 schema 均匹配的 key,再按所服务行的最低水位返回 `{ asOfSeq, values }` 切面。`cachedPredecessorTitle(meta, inheritedEventCount)` 是更窄的列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row。该 title 是 durable prefix 中可能过时的事实,而不是 fold seed;其他 predecessor row 仍不可用。未 seeded 的列表知道切点为零;仅 header 的 seeded 列表不知道数字切点,因此两条快速路径都要跳过,直到权威正文读取提供它。`coldSnapshot(meta, inheritedEventCount, events)` 接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 ### 缓存保证什么 日志领先,缓存跟随:实时检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配实时单元 schema 与完整生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此从另一会话格式代或 fork 切点折叠出的行不能播种调用方。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。 -升级绝不拖垮启动,也不会暴露未经证明的折叠结果。版本戳落在 spec `compatibleVersions` 集合内的记录仍可被结构化读取并等待当前检查点重写,但缺失 `formatVersion` 的记录绝不匹配当前 Session,因此会回落冷折叠。格式匹配后,缺失的 lineage 字段解码为 unseeded lineage——对非 fork 会话精确无误,seeded 调用方则通不过身份比对、回落冷折叠。仍然通不过 schema 校验的存量记录会按域的 `invalidRecords: 'backup-and-skip'` 策略移出为 `.json.bak.<时间戳>`、连同原因写入日志,并由下一次检查点重建。 +升级绝不拖垮启动,也不会暴露未经证明的折叠结果。版本戳落在 spec `compatibleVersions` 集合内的记录仍可被结构化读取并等待当前检查点重写,但缺失或更旧的 `formatVersion` 绝不匹配当前 Session,因此不能作为 hydrate seed。生命周期匹配的 predecessor title 只能通过上述列表 hint 读取,因为 title 文本在相邻 Session format edge 之间保持不变,并且该 row 仍须通过当前 projection `stateVersion` 与 schema。格式匹配后,缺失的 lineage 字段解码为 unseeded lineage——对非 fork 会话精确无误,seeded 调用方则通不过身份比对、回落冷折叠。仍然通不过 schema 校验的存量记录会按域的 `invalidRecords: 'backup-and-skip'` 策略移出为 `.json.bak.<时间戳>`、连同原因写入日志,并由下一次检查点重建。 ----- diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index a85064b534..945d651fa5 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -42,6 +42,8 @@ type CurrentCheckpointIdentity = CheckpointIdentity & { inheritedEventCount: SessionLogOffset } +const PREDECESSOR_TITLE_KEY = 'title' as Extract + export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' export type { CheckpointIdentity, CheckpointRecord } from './spec.ts' @@ -147,6 +149,39 @@ export class SessionProjectionCache extends Service { ): ProjectionSnapshot | undefined { const record = this.recordFor(meta.id, identityOf(meta, inheritedEventCount)) if (record === undefined) return undefined + return this.viewRecord(record, keys) + } + + /** + * Read only a predecessor checkpoint's title as a zero-I/O listing hint. + * + * The authoritative Session header supplies the lifecycle identity. A cache + * checkpoint can lag that log but cannot lead it because writes flush the + * log first, so a matching predecessor title is a genuine (possibly stale) + * fact from this Session. The registry still requires the current title + * projection's row version and schema. No other predecessor projection is + * exposed: format normalization can change their current meaning, and the + * strict {@link cachedSnapshot} / hydration paths continue to reject them. + * @param meta - authoritative listed Session header. + * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. + * @returns a title-only checkpoint view, or `undefined` when the record is + * current, newer, unrelated, missing, or incompatible with the title unit. + */ + cachedPredecessorTitle( + meta: SessionHeader, + inheritedEventCount: SessionLogOffset, + ): ProjectionSnapshot | undefined { + const expected = identityOf(meta, inheritedEventCount) + const record = this.requireTable().get(meta.id) + if (record === undefined || !predecessorIdentityMatches(record.identity, expected)) return undefined + return this.viewRecord(record, [PREDECESSOR_TITLE_KEY]) + } + + /** View selected wire rows and bind them to their lowest served watermark. */ + private viewRecord( + record: CheckpointRecord, + keys?: readonly Extract[], + ): ProjectionSnapshot | undefined { const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys) const servedKeys = Object.keys(values) if (servedKeys.length === 0) return undefined @@ -380,7 +415,25 @@ function identityOf( */ function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean { return stored.formatVersion === expected.formatVersion - && stored.createdAt === expected.createdAt + && lifecycleIdentityMatches(stored, expected) +} + +/** Match one predecessor cache record to the authoritative listed lifecycle. */ +function predecessorIdentityMatches( + stored: CheckpointIdentity, + expected: CurrentCheckpointIdentity, +): boolean { + const predecessor = stored.formatVersion === undefined + || stored.formatVersion < expected.formatVersion + return predecessor && lifecycleIdentityMatches(stored, expected) +} + +/** Match the format-independent fields that distinguish one Session lifecycle. */ +function lifecycleIdentityMatches( + stored: CheckpointIdentity, + expected: CurrentCheckpointIdentity, +): boolean { + return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd && (stored.isSeeded ?? false) === expected.isSeeded && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index 8d9b7e5d2f..a94ed5a493 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -85,6 +85,10 @@ export type CheckpointRecord = z.infer * The per-row `ver` guard and the identity match still discard anything the * current fold semantics cannot vouch for. * + * A lifecycle-matching predecessor may still expose its version-compatible + * title through the cache service's listing-only hint; this never relaxes the + * format requirement for hydration or another fold shortcut. + * * `invalidRecords: 'backup-and-skip'`: a stored record that fails the schema * anyway is disposable derived data, so it must never cost the boot — the * domain layer moves the document aside as `.json.bak.`, logs diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index 3e33121920..e4e6ebca0d 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -162,6 +162,13 @@ describe('archived version recovery', () => { SessionLogOffset(0), ['title'], )).toBeUndefined() + expect(cache.cachedPredecessorTitle( + headerFor(SessionId(sid), record.identity), + SessionLogOffset(0), + )).toEqual({ + asOfSeq: record.rows.title?.seq, + values: { title: record.rows.title?.val }, + }) // The one-time bootstrap materialized a current-version document. const migrated = JSON.parse( @@ -189,11 +196,60 @@ describe('archived version recovery', () => { SessionLogOffset(0), ['title'], )).toBeUndefined() + expect(cache.cachedPredecessorTitle( + headerFor(id, doc.record.identity), + SessionLogOffset(0), + )).toEqual({ + asOfSeq: doc.record.rows.title?.seq, + values: { title: doc.record.rows.title?.val }, + }) await assertRewrite(ctx, root, id) }) } + it('serves an explicitly older format title but never a current or newer one through the predecessor path', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + const sessionsDir = join(root, projectionCacheDomainSpec.name, 'sessions') + await mkdir(sessionsDir, { recursive: true }) + const write = async (id: string, formatVersion: number, rowVersion = 1): Promise => { + await writeFile(join(sessionsDir, `${id}.json`), JSON.stringify({ + version: projectionCacheDomainSpec.version, + record: { + identity: { + formatVersion, + createdAt: 10, + cwd: '/work', + isSeeded: false, + inheritedEventCount: 0, + }, + rows: { title: { ver: rowVersion, seq: 2, val: `${id} title` } }, + }, + })) + } + await write('older', SESSION_FORMAT_VERSION - 1) + await write('current', SESSION_FORMAT_VERSION) + await write('newer', SESSION_FORMAT_VERSION + 1) + await write('stale-title', SESSION_FORMAT_VERSION - 1, 2) + + const { cache } = await harness(root) + const listed = (id: string): SessionHeader => ({ + version: SESSION_FORMAT_VERSION, + id: SessionId(id), + createdAt: 10, + cwd: '/work', + isSeeded: false, + }) + expect(cache.cachedPredecessorTitle(listed('older'), SessionLogOffset(0))).toEqual({ + asOfSeq: 2, + values: { title: 'older title' }, + }) + expect(cache.cachedPredecessorTitle(listed('current'), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedPredecessorTitle(listed('newer'), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedPredecessorTitle(listed('stale-title'), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedPredecessorTitle(listed('missing'), SessionLogOffset(0))).toBeUndefined() + }) + it('refuses a lineage-less archive for a seeded caller (identity mismatch, cold rebuild)', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) const id = SessionId('fixture-seeded') @@ -202,6 +258,7 @@ describe('archived version recovery', () => { const { cache } = await harness(root) const seeded = { ...headerFor(id, doc.record.identity), isSeeded: true } expect(cache.cachedSnapshot(seeded, SessionLogOffset(2), ['title'])).toBeUndefined() + expect(cache.cachedPredecessorTitle(seeded, SessionLogOffset(2))).toBeUndefined() }) it('backs up and skips a record that fails schema validation instead of failing the boot', async () => {