fix(apiproxy): harden cold session metadata probing

This commit is contained in:
_Kerman
2026-08-13 15:09:21 +08:00
parent c4226840f4
commit 2be3e12965
18 changed files with 138 additions and 131 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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: 330f5acbd520487732bc72fe75f09496aaa0c028 2026-08-13-bounded-cold-blank-verification.md: bf8d167d742001ce65b3e96713a9603adb19603e
2026-08-13-bounded-cold-blank-verification.zh.md: 15c0d28be8c15f7076ac90a50bb023cc42fbbdd6 2026-08-13-bounded-cold-blank-verification.zh.md: 7cfef77a02308a8e75281877f8a774b41bb9559d
@@ -14,15 +14,15 @@ The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Sess
`dsh-host-apiproxy` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. `dsh-host-apiproxy` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`.
A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its size is at most `coldBlankProbeMaxBytes` (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and verifies whether the stored prefix contains `turn/start`. Files above the bound, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible. A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its observed size is at most the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and folds exact list metadata from the stored prefix. Files above the threshold, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible.
`updatedAt` is the later of `createdAt` and `lastPromptAt`. A cold cache miss or stale checkpoint therefore orders the Session too old rather than promoting it from an unrelated file write. The bounded blank read does not replace missing recency metadata. `updatedAt` is the later of `createdAt` and `lastPromptAt`. An eligible artifact read supplies exact `lastPromptAt` at no additional I/O cost; other cache misses or stale checkpoints order the Session too old rather than promoting it from an unrelated file write. After each asynchronous cold read, the gateway checks the live store again and replaces the cold result with an attached summary when another request resumed that Session meanwhile.
## Alternatives considered ## Alternatives considered
**Trust cached `blank: true`.** Rejected because the projection cache deliberately permits a persisted log to advance beyond its checkpoint. A crash or fail-soft write failure after the first `turn/start` would hide a real conversation and could make the client reuse it as New Session. **Trust cached `blank: true`.** Rejected because the projection cache deliberately permits a persisted log to advance beyond its checkpoint. A crash or fail-soft write failure after the first `turn/start` would hide a real conversation and could make the client reuse it as New Session.
**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes. The physical-size bound targets the small historical artifacts that can be checked cheaply and degrades larger unknowns toward visibility. **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 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).
@@ -30,8 +30,8 @@ A cold summary trusts cached `blank: false`, because a checkpoint prefix contain
## Consequences ## Consequences
Existing small blank JSONL artifacts are hidden without depending on projection-cache availability, and a stale cache cannot hide a stored `turn/start`. A cold list may read each artifact whose physical size is within the configured bound when its cache does not already prove non-blank. The default bound applies to compressed bytes for the shipped Zstandard JSONL backend. Existing small blank JSONL artifacts are hidden without depending on projection-cache availability, and a stale cache cannot hide a stored `turn/start`. A cold list may read each artifact whose observed physical size is within the configured threshold when its cache does not already prove non-blank. The default threshold compares compressed bytes for the shipped Zstandard JSONL backend.
Blank artifacts above the bound and blank Sessions on location-less backends remain visible. Missing or delayed recency cache entries fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened. Blank artifacts above the threshold and blank Sessions on location-less backends remain visible. Missing or delayed recency cache entries for artifacts that are not read fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened.
The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins exact-threshold probing, stale-true rejection, monotonic false reuse, fallback direction, human-prompt recency, and fiber disposal. A keyless Web snapshot boots the shipped compressed JSONL composition, seeds a small cold blank artifact without a cache row, and verifies that the sidebar omits it. The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins exact-threshold eligibility, stale-true rejection, monotonic false reuse, exact small-log recency, live-attachment races, fallback direction, human-prompt recency, and fiber disposal. A keyless Web snapshot boots the shipped compressed JSONL composition, seeds a small cold blank artifact without a cache row, and verifies that the sidebar omits it.
@@ -14,15 +14,15 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N
`dsh-host-apiproxy` 注册 `sessionListMetadata` 投影,其中包含 `blank``lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false`lastPromptAt` 只在来源 kind 为 `user``user/message` 上更新。 `dsh-host-apiproxy` 注册 `sessionListMetadata` 投影,其中包含 `blank``lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false`lastPromptAt` 只在来源 kind 为 `user``user/message` 上更新。
冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其大小不超过 `coldBlankProbeMaxBytes`(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`验证已存前缀是否含有 `turn/start`。超过上限的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。 冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其观测大小不超过 `coldBlankProbeMaxBytes` 资格阈值(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`已存前缀折叠精确列表元数据。超过阈值的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。
`updatedAt``createdAt``lastPromptAt` 中较晚者。因此冷 cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。有界 blank 读取不用于补齐缺失的最近时间元数据 `updatedAt``createdAt``lastPromptAt` 中较晚者。符合资格的工件读取无需额外 I/O 即可提供精确 `lastPromptAt`;其他 cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。每次异步冷读取后,网关都会再次检查实时 store;若另一请求期间已恢复该 Session,则用已附加摘要替换冷结果
## Alternatives considered ## Alternatives considered
**信任缓存的 `blank: true`。** 拒绝,因为 projection cache 有意允许持久日志前进到 checkpoint 之后。首个 `turn/start` 之后若发生崩溃或 fail-soft 写入失败,真实对话就会被隐藏,客户端还可能把它复用为 New Session。 **信任缓存的 `blank: true`。** 拒绝,因为 projection cache 有意允许持久日志前进到 checkpoint 之后。首个 `turn/start` 之后若发生崩溃或 fail-soft 写入失败,真实对话就会被隐藏,客户端还可能把它复用为 New Session。
**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小上限只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。 **读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小资格检查只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。该检查有意不为“让阈值与读取原子化”单独新增 persistence 操作:并发增长可能增加一次探测的读取成本,但新增事件只会保持可见,或把空白结果改为非空。
**把空白状态与最近时间存入权威 persistence index。** 暂缓,因为 JSONL 的首行不可变,需要增加带有顺序写入要求的第二份持久工件;SQLite 则需要 schema 字段。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)负责。 **把空白状态与最近时间存入权威 persistence index。** 暂缓,因为 JSONL 的首行不可变,需要增加带有顺序写入要求的第二份持久工件;SQLite 则需要 schema 字段。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)负责。
@@ -30,8 +30,8 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N
## Consequences ## Consequences
既有的小型空白 JSONL 工件无需依赖 projection cache 是否存在即可被隐藏,陈旧 cache 也无法隐藏已存的 `turn/start`。对于 cache 尚不能证明非空,且物理大小在配置上限内的每个 Session,冷列表可能读取其工件。对默认交付的 Zstandard JSONL 后端,该上限作用于压缩后的字节数。 既有的小型空白 JSONL 工件无需依赖 projection cache 是否存在即可被隐藏,陈旧 cache 也无法隐藏已存的 `turn/start`。对于 cache 尚不能证明非空,且观测物理大小在配置阈值内的每个 Session,冷列表可能读取其工件。对默认交付的 Zstandard JSONL 后端,该阈值比较压缩后的字节数。
超过上限的空白工件,以及来自不提供位置的后端的空白 Session 会保持可见。缺失或延迟的最近时间 cache 会回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。 超过阈值的空白工件,以及来自不提供位置的后端的空白 Session 会保持可见。对于未被读取的工件,缺失或延迟的最近时间 cache 会回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。
网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了临界大小探测、拒绝陈旧 true、复用单调 false、回退方向、真人 prompt 最近时间和 fiber 销毁。无密钥 Web snapshot 会启动发行版的压缩 JSONL 组合,在没有 cache row 的情况下播种一份小型冷空白工件,并验证侧栏不展示它。 网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了临界大小资格、拒绝陈旧 true、复用单调 false、小日志精确最近时间、实时附加竞态、回退方向、真人 prompt 最近时间和 fiber 销毁。无密钥 Web snapshot 会启动发行版的压缩 JSONL 组合,在没有 cache row 的情况下播种一份小型冷空白工件,并验证侧栏不展示它。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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: b530508877adf3a66b158ff659441c4179878d00 2026-07-29-durable-last-activity-index.md: 99e50dd40b789db5d896cb7f9e25fa8893b02ae2
2026-07-29-durable-last-activity-index.zh.md: 4c099bc835c4708fda09811fafd6dabb65dbb575 2026-07-29-durable-last-activity-index.zh.md: e317fb192d53353295e6b52f631707ba6b400b66
@@ -10,7 +10,7 @@ A cold (persisted, unattached) session has no authoritative stored answer to "wh
The gateway previously used JSONL artifact mtime when available. mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including a truncate-repair of a torn tail, synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) appended during pickup. That approximation promoted a Session merely because it was opened. The [bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) removed mtime ordering and accepted the cache's conservative "too old" failure direction as an interim tradeoff. The gateway previously used JSONL artifact mtime when available. mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including a truncate-repair of a torn tail, synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) appended during pickup. That approximation promoted a Session merely because it was opened. The [bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) removed mtime ordering and accepted the cache's conservative "too old" failure direction as an interim tradeoff.
An attached summary can fold the live event log and select the latest human-authored `user/message`, but the cold path deliberately does not read large logs. Reading every log to compute `updatedAt` would make `list()` scale with total conversation bytes rather than Session count. The 1 KiB cold read introduced for blank verification does not solve recency: it is conditional, targets only small artifacts, and does not make large-log ordering exact. An attached summary can fold the live event log and select the latest human-authored `user/message`, but the cold path deliberately does not read large logs. Reading every log to compute `updatedAt` would make `list()` scale with total conversation bytes rather than Session count. The 1 KiB cold read used for metadata verification makes eligible small-artifact recency exact, but it does not make large-log ordering exact.
Making cold ordering exact remains a durable-format decision, which is why it is scoped here rather than in the gateway workaround. Making cold ordering exact remains a durable-format decision, which is why it is scoped here rather than in the gateway workaround.
@@ -61,7 +61,7 @@ Three questions must be answered before implementation, and none of them is sett
## Related ## Related
- [Bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) — removes mtime ordering, defines the interim projection-cache fallback, and limits direct cold reads to blankness checks. - [Bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) — removes mtime ordering, defines the interim projection-cache fallback, and limits direct cold reads to small-artifact metadata verification.
- [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-prompt writes that made mtime unsuitable. - [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-prompt writes that made mtime unsuitable.
- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field. - [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field.
- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into. - [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into.
@@ -10,7 +10,7 @@ Status: proposed
网关以前会在可用时采用 JSONL 产物的 mtime。mtime 回答的是另一件事:这份产物上次是什么时候被写入。每一次持久写入都会刷新它,包括对撕裂尾部的截断修复、平衡中断轮次的合成 closer,以及拾起时追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。这套近似会让 Session 仅仅因为被打开就提升排序。[有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)移除了 mtime 排序,并把 cache 保守的「过旧」错误方向作为现阶段取舍。 网关以前会在可用时采用 JSONL 产物的 mtime。mtime 回答的是另一件事:这份产物上次是什么时候被写入。每一次持久写入都会刷新它,包括对撕裂尾部的截断修复、平衡中断轮次的合成 closer,以及拾起时追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。这套近似会让 Session 仅仅因为被打开就提升排序。[有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)移除了 mtime 排序,并把 cache 保守的「过旧」错误方向作为现阶段取舍。
已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志,会让 `list()` 的开销随对话总字节数而非 Session 数量增长。为空白验证引入的 1 KiB 冷读取并不能解决最近时间:它是条件式的,只针对小产物,也不能让大日志的排序精确。 已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志,会让 `list()` 的开销随对话总字节数而非 Session 数量增长。用于 metadata 验证的 1 KiB 冷读取可以让符合条件的小产物得到精确的最近时间,但不能让大日志的排序精确。
让冷排序变得精确仍是一项持久格式决策,因此其范围留在本文,而不是网关 workaround 中。 让冷排序变得精确仍是一项持久格式决策,因此其范围留在本文,而不是网关 workaround 中。
@@ -61,7 +61,7 @@ Status: proposed
## 相关 ## 相关
- [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)——移除 mtime 排序,定义 projection cache 的过渡回退,并把直接冷读取限制为空白检查 - [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)——移除 mtime 排序,定义 projection cache 的过渡回退,并把直接冷读取限制为小产物 metadata 验证
- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——让 mtime 不适用的非 prompt 写入之一。 - [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——让 mtime 不适用的非 prompt 写入之一。
- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。 - [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。 - [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md # pnpm run verify-translation-pairing --write docs/subsystems/session.md
session.md: 4c40971fe58952b32635aa5eb767a42f73108b00 session.md: 760a3042724472b5f518896b8ff0e56bcfec2799
session.zh.md: d958b03fcdad91b58cc277636e599cce46a2c7fb session.zh.md: 8c56029af5144569f1ab6df73a8fe2278f9ef5b4
+1 -1
View File
@@ -586,7 +586,7 @@ An explicitly supplied empty seed writes `session/end-seed` at seq 0, which dist
It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compaction/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compaction/*`. It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compaction/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compaction/*`.
Activity ordering excludes the boundary through `lastActivityTime(events)`: picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top. Consumers that order Sessions by human activity exclude this boundary: picking a Session up is not work, so ordering by the log tail would float every opened Session to the top.
## Plugin-contributed log-only events ## Plugin-contributed log-only events
+1 -1
View File
@@ -590,7 +590,7 @@ interface TurnEndReasonMap {
它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compaction/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compaction/*` 的原因。 它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compaction/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compaction/*` 的原因。
活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。 按真人活动排序 Session 的消费方会排除该边界:接手 Session 不算工作,因此按日志尾部排序会把每个打开过的 Session 顶到最前。
## 插件贡献的仅日志事件 ## 插件贡献的仅日志事件
+1 -1
View File
@@ -26,7 +26,7 @@ export type { SessionPreparationOptions } from './preparation.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm' export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts' export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts' export type { JsonValue } from './json.ts'
export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts' export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
+1 -20
View File
@@ -1,10 +1,7 @@
/** /**
* Crash-recovery repair for an interrupted session log. It preserves a fully * Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries * written final turn and supplies the missing tool, step, and turn boundaries
* needed to resume with a provider-valid transcript, plus the activity-time * needed to resume with a provider-valid transcript.
* read that must skip the end-seed boundary — which this module does
* not write (`Session`'s constructor does) but whose synthetic closers can
* inherit that boundary's timestamp, the one real coupling between the two.
* @module @deepseek-ai/dsh-session/repair * @module @deepseek-ai/dsh-session/repair
*/ */
@@ -12,22 +9,6 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm' import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts' import type { SessionEvent } from './types.ts'
/**
* The `time` of the log's last event representing actual work, skipping the
* `session/end-seed` boundary — picking a session up is not activity, so
* activity ordering must exclude it.
*
* Excluded by type, so a pickup time still leaks when a boundary is the last
* event of an open turn: {@link interruptedTurnClosers} copies it onto the
* synthetic `turn/end`, which this counts as work. Reachable only by seeding an
* unbalanced log directly — `load()` balances first.
* @param events - the log to scan, in seq order.
* @returns the latest non-boundary event's `time`, or undefined when there is none.
*/
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
return events.findLast(event => event.type !== 'session/end-seed')?.time
}
/** Recovery code for an assistant tool request that never reached a recorded call start. */ /** Recovery code for an assistant tool request that never reached a recorded call start. */
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED' export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
+1 -42
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts' import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts' import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/** /**
@@ -273,44 +273,3 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end']) expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
}) })
}) })
describe('lastActivityTime', () => {
const endSeedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/end-seed', seq, time, data: {} })
it('has no answer for an empty log', () => {
expect(lastActivityTime([])).toBeUndefined()
})
it('reports the log tail when no boundary is present', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(500)
})
it('skips a trailing boundary in favour of the last real work', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
endSeedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
expect(lastActivityTime(events)).toBe(500)
})
it('reports work appended after end-seed', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
endSeedAt(1, 9_000),
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
})
it('has no answer for a log of nothing but boundaries', () => {
// Unreachable via the constructor, but the projection is a pure function.
expect(lastActivityTime([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined()
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 518a7e5640bc62b493244a3d863cfea643386f7f README.md: b7dee9488c1b123172c225d4d4235bdb3c76911d
README.zh.md: 5692e9441d20d9dbec5d8a69263c875eb3f3f907 README.zh.md: 46835a84254c4bbc45300a18d29aae5a6ac66f14
+2 -2
View File
@@ -42,7 +42,7 @@ Pending queued input is a live control-plane contract, not conversation history.
Background jobs ride the same live-push posture. When `ctx.jobs` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/jobs` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `JobView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. Background jobs ride the same live-push posture. When `ctx.jobs` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/jobs` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `JobView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry whether a turn has started: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority. Attached summaries fold the live log. A cold summary trusts cached `blank: false`, but treats cached `true` and a cache miss as unverified; when `locate()` resolves an artifact no larger than `coldBlankProbeMaxBytes` (default 1 KiB), the gateway reads that Session with `readFrom()` and checks for `turn/start`. A larger, location-less, vanished, or unreadable artifact remains visible. `updatedAt` is the latest human `user/message` time from the live fold or projection cache, falling back to `createdAt`; pickup boundaries and other writes never promote a Session. Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry whether a turn has started: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority. Attached summaries fold the live log. A cold summary trusts cached `blank: false`, but treats cached `true` and a cache miss as unverified; when `locate()` reports an artifact no larger than the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB), the gateway reads that Session with `readFrom()` and folds both blankness and the latest human prompt. A larger, location-less, vanished, or unreadable artifact remains visible. After an asynchronous cold read, a Session that attached meanwhile is summarized from its live log instead. `updatedAt` uses the live fold, the exact small-artifact fold, or the projection cache in that order, falling back to `createdAt`; pickup boundaries and other writes never promote a Session.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
@@ -80,4 +80,4 @@ None; this package neither assembles nor sends a provider request.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. - **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). - **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt`, so a recently worked Session may sort too low until the next checkpoint. A blank artifact larger than `coldBlankProbeMaxBytes`, or one from a backend without `locate()`, remains visible because the gateway cannot verify the absence of `turn/start` within the read bound. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). - **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt` unless an eligible small artifact supplies an exact fold, so a recently worked large Session may sort too low until the next checkpoint. A blank artifact larger than `coldBlankProbeMaxBytes`, or one from a backend without `locate()`, remains visible. The threshold is checked before `readFrom()` rather than enforced by persistence, so concurrent artifact growth may increase one probe's read cost without changing blankness safety. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
+2 -2
View File
@@ -42,7 +42,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.jobs` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/jobs` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `JobView` 丢弃 `ownerSession``reported``outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 后台任务沿用同一种实时推送姿态。当组合中有 `ctx.jobs` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/jobs` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `JobView` 丢弃 `ownerSession``reported``outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found``workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带是否已开始过轮次:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威。已附加摘要折叠实时日志。冷摘要信任缓存的 `blank: false`,但把缓存的 `true` 与 cache miss 都视为未经验证;当 `locate()` 解析出的工件不大于 `coldBlankProbeMaxBytes`(默认 1 KiB)时,网关通过 `readFrom()` 读取该 Session 并检查 `turn/start`。更大、无位置、已消失或不可读的工件保持可见。`updatedAt` 取实时折叠或投影缓存中的最新真人 `user/message` 时间,缺失时回退到 `createdAt`;拾起边界及其他写入都不会提升 Session 排序。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found``workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带是否已开始过轮次:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威。已附加摘要折叠实时日志。冷摘要信任缓存的 `blank: false`,但把缓存的 `true` 与 cache miss 都视为未经验证;当 `locate()` 报告的工件不大于 `coldBlankProbeMaxBytes` 资格阈值(默认 1 KiB)时,网关通过 `readFrom()` 读取该 Session,同时折叠空白状态与最新真人 prompt。更大、无位置、已消失或不可读的工件保持可见。异步冷读取结束后,期间已附加的 Session 会改用实时日志生成摘要。`updatedAt` 依次采用实时折叠、小工件精确折叠或 projection cache,缺失时回退到 `createdAt`;拾起边界及其他写入都不会提升 Session 排序。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
@@ -80,4 +80,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 - **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 - **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷列表提示只向“保持可见、排序偏旧”降级**:projection cache miss 或陈旧的 `lastPromptAt` 会回退到 `createdAt`,因此最近工作过的 Session 可能在下一个 checkpoint 前排得偏低。大于 `coldBlankProbeMaxBytes` 的空白工件,或来自不提供 `locate()` 的后端的空白工件会保持可见,因为网关无法在读取上限内验证其中不存在 `turn/start`。[有界空白验证决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)规定了这个安全方向;权威且精确的最近时间索引仍属于[最后活动索引提案](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)的范围。 - **冷列表提示只向“保持可见、排序偏旧”降级**:projection cache miss 或陈旧的 `lastPromptAt` 会回退到 `createdAt`除非符合资格的小工件提供精确折叠,因此最近工作过的 Session 可能在下一个 checkpoint 前排得偏低。大于 `coldBlankProbeMaxBytes` 的空白工件,或来自不提供 `locate()` 的后端的空白工件会保持可见。该阈值在 `readFrom()` 前检查,而非由 persistence 强制,因此工件并发增长可能增加一次探测的读取成本,但不会改变空白状态的安全方向。[有界空白验证决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)规定了这个安全方向;权威且精确的最近时间索引仍属于[最后活动索引提案](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)的范围。
+30 -26
View File
@@ -563,40 +563,40 @@ function summarize(session: Session, running: boolean): SessionSummary {
} }
/** /**
* Verify a possibly blank cold Session only when its physical artifact is * Verify a possibly blank cold Session only when its physical artifact passes
* within the configured per-Session read bound. A stale `blank: true`, an * the configured per-Session size check. A stale `blank: true`, an
* absent cache row, a large or location-less artifact, and read failures all * absent cache row, a large or location-less artifact, and read failures all
* resolve to visible (`false`); listing must never hide a conversation on a * resolve to visible (`false`); listing must never hide a conversation on a
* cache hint or an unavailable optimization. * cache hint or an unavailable optimization.
*/ */
async function probeColdSessionBlank( async function probeColdSessionMetadata(
ctx: Context, ctx: Context,
persistence: SessionPersistence, persistence: SessionPersistence,
meta: SessionHeader, meta: SessionHeader,
maxBytes: number, maxBytes: number,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<boolean> { ): Promise<SessionListMetadata | undefined> {
if (maxBytes === 0) return false if (maxBytes === 0) return undefined
signal?.throwIfAborted() signal?.throwIfAborted()
const location = persistence.locate(meta) const location = persistence.locate(meta)
if (location === undefined) return false if (location === undefined) return undefined
signal?.throwIfAborted() signal?.throwIfAborted()
let size: number let size: number
try { try {
size = (await stat(location.path)).size size = (await stat(location.path)).size
} catch { } catch {
signal?.throwIfAborted() signal?.throwIfAborted()
return false return undefined
} }
if (size > maxBytes) return false if (size > maxBytes) return undefined
try { try {
const { events } = await persistence.readFrom(meta.id, 0, signal) const { events } = await persistence.readFrom(meta.id, 0, signal)
signal?.throwIfAborted() signal?.throwIfAborted()
return !events.some(event => event.type === 'turn/start') return sessionListMetadata(events)
} catch (error) { } catch (error) {
signal?.throwIfAborted() signal?.throwIfAborted()
ctx.logger.warn(`session.list: blank probe for "${meta.id}" failed (serving it as visible): ${String(error)}`) ctx.logger.warn(`session.list: blank probe for "${meta.id}" failed (serving it as visible): ${String(error)}`)
return false return undefined
} }
} }
@@ -609,14 +609,14 @@ async function summarizeCold(
blankProbeMaxBytes: number, blankProbeMaxBytes: number,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<SessionSummary> { ): Promise<SessionSummary> {
const blank = metadata?.blank === false const probed = metadata?.blank === false
? false ? undefined
: await probeColdSessionBlank(ctx, persistence, meta, blankProbeMaxBytes, signal) : await probeColdSessionMetadata(ctx, persistence, meta, blankProbeMaxBytes, signal)
return { return {
sessionId: meta.id, sessionId: meta.id,
updatedAt: sessionListUpdatedAt(meta, metadata), updatedAt: sessionListUpdatedAt(meta, probed ?? metadata),
running: false, running: false,
blank, blank: metadata?.blank === false ? false : probed?.blank ?? false,
// Header-only: reading the log for a blank-window preset switch would // Header-only: reading the log for a blank-window preset switch would
// defeat the same index read, and attaching the session replaces this row // defeat the same index read, and attaching the session replaces this row
// with `summarize()`, which resolves the switch from the events. // with `summarize()`, which resolves the switch from the events.
@@ -1724,14 +1724,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
*/ */
async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> { async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
signal?.throwIfAborted() signal?.throwIfAborted()
const items = ctx.sessions.list().map((session) => { const summarizeAttached = (session: Session): SessionSummary => {
const agent = ctx.agents.get(session.id) const agent = ctx.agents.get(session.id)
const projections = listProjectionsFor(ctx, session.header, session) const projections = listProjectionsFor(ctx, session.header, session)
return { return {
...summarize(session, agent?.status === 'running'), ...summarize(session, agent?.status === 'running'),
...projections === undefined ? {} : { projections }, ...projections === undefined ? {} : { projections },
} }
}) }
const items = ctx.sessions.list().map(summarizeAttached)
signal?.throwIfAborted() signal?.throwIfAborted()
const attached = new Set(items.map(item => item.sessionId)) const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence') const persistence = ctx.get('sessionPersistence')
@@ -1745,17 +1746,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
batch.map(async (meta) => { batch.map(async (meta) => {
// Projection hints remain optional. Blank verification may read // Projection hints remain optional. Blank verification may read
// this Session's artifact only when it fits the configured bound. // this Session's artifact only when it passes the configured size check.
const projections = listProjectionsFor(ctx, meta, undefined) const projections = listProjectionsFor(ctx, meta, undefined)
const summary = await summarizeCold(
ctx,
persistence,
meta,
projections?.values.sessionListMetadata,
coldBlankProbeMaxBytes,
signal,
)
const attachedSession = ctx.sessions.get(meta.id)
if (attachedSession !== undefined) return summarizeAttached(attachedSession)
return { return {
...await summarizeCold( ...summary,
ctx,
persistence,
meta,
projections?.values.sessionListMetadata,
coldBlankProbeMaxBytes,
signal,
),
...projections === undefined ? {} : { projections }, ...projections === undefined ? {} : { projections },
} }
}), }),
+10 -10
View File
@@ -173,25 +173,25 @@ export type QueueAction =
| { kind: 'remove' } | { kind: 'remove' }
| { kind: 'steer' } | { kind: 'steer' }
/** Session list entry (v1 builds no index: list does readdir+stat). */ /** One Session list entry. */
export interface SessionSummary { export interface SessionSummary {
sessionId: SessionId sessionId: SessionId
/** /**
* Last activity. Attached: the last non-`session/end-seed` event, since a * The later of creation and the latest human-authored prompt. Attached
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend * Sessions fold their live log; cold Sessions use a projection-cache hint or
* with no per-session file (README Known Limitations covers the skew). * an exact small-artifact read, falling back to creation time.
*/ */
updatedAt: number updatedAt: number
/** Status of the attached agent; always false for cold (unattached) sessions. */ /** Status of the attached agent; always false for cold (unattached) sessions. */
running: boolean running: boolean
/** /**
* Derived conversation-not-started bit: true while no turn has run (no * Derived conversation-not-started bit: true while no turn has run.
* prompt was accepted yet). Standalone plugin events — command lifecycle * Standalone plugin events — command lifecycle
* records, plan/mode, titles, goals — do not open a turn and therefore do * records, plan/mode, titles, goals — do not open a turn and therefore do
* not clear it. Clients hide blank sessions from lists and reuse them for * not clear it. Clients hide blank Sessions from lists and reuse them for
* New Session on the same workspace. Always false for cold sessions — * New Session on the same workspace. A cold Session is true only when a
* lazy persistence keeps a never-appended session out of the store, and a * small-artifact read verifies that no `turn/start` exists; unavailable
* listed cold session's log holds its turns. * or oversized artifacts conservatively report false.
*/ */
blank: boolean blank: boolean
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */ /** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
@@ -67,7 +67,14 @@ describe('sessions.list cold merge', () => {
if (id === sid('small-conversation')) { if (id === sid('small-conversation')) {
return { return {
meta: metas[1]!, meta: metas[1]!,
events: [{ type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } }] as SessionEvent[], events: [
{ type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } },
{
type: 'user/message', seq: 1, time: 1200,
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
] as SessionEvent[],
} }
} }
if (id === sid('read-failure')) throw new Error('simulated read failure') if (id === sid('read-failure')) throw new Error('simulated read failure')
@@ -105,7 +112,7 @@ describe('sessions.list cold merge', () => {
const byId = Object.fromEntries(response.result.value.items.map(item => [item.sessionId, item])) const byId = Object.fromEntries(response.result.value.items.map(item => [item.sessionId, item]))
expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false }) expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
// A stale true hint cannot hide the turn found in the bounded read. // A stale true hint cannot hide the turn found in the bounded read.
expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 900 }) expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 })
expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 }) expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
// false is monotonic, so this row skips stat/read and keeps cached recency. // false is monotonic, so this row skips stat/read and keeps cached recency.
expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 }) expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
@@ -149,6 +156,62 @@ describe('sessions.list cold merge', () => {
]) ])
expect(readFrom).not.toHaveBeenCalled() expect(readFrom).not.toHaveBeenCalled()
}) })
it('replaces a probed cold row with the live Session that attached during the read', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
const meta = header('attached-during-probe', 100)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
const path = join(root, 'small.log')
writeFileSync(path, 'x')
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
readFrom: async () => {
started.resolve(undefined)
await release.promise
return {
meta,
events: [{ type: 'session/end-seed', seq: 0, time: 110, data: {} }] as SessionEvent[],
}
},
} as never)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listing = api.sessions.list(request({}))
await started.promise
const session = ctx.sessions.create(meta.id, {
seed: [
{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } },
{
type: 'user/message', seq: 1, time: 300,
data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
],
meta: {
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
createdAt: meta.createdAt,
},
})
ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
release.resolve(undefined)
const response = await listing
if (!response.result.ok) throw new Error('list failed')
expect(response.result.value.items).toEqual([
expect.objectContaining({
sessionId: meta.id,
blank: false,
running: true,
updatedAt: 300,
}),
])
})
}) })
describe('attached updatedAt tracks human prompts', () => { describe('attached updatedAt tracks human prompts', () => {