fix(web): hide verified cold blank sessions

This commit is contained in:
_Kerman
2026-08-13 14:04:16 +08:00
parent 2c456a00ea
commit 3c06c66ac4
26 changed files with 548 additions and 125 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/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.zh.md: 15c0d28be8c15f7076ac90a50bb023cc42fbbdd6

View File

@@ -0,0 +1,37 @@
# Agent Note: Bound cold blank-session verification
Status: implemented
English | [中文](2026-08-13-bounded-cold-blank-verification.zh.md)
## Problem
The Web session tree hides blank Sessions and reuses the selected blank entry as New Session. Attached Sessions can derive blankness from their in-memory event log, but `session.list` normally avoids loading every cold log. Treating every materialized cold Session as non-blank exposes empty Sessions left by older versions. Treating a projection-cache `blank: true` as current can instead hide a real conversation after the log advances and the fail-soft cache remains stale.
The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Session appends `session/end-seed`, so a pickup with no human prompt refreshed mtime and promoted that Session above recently used conversations.
## Decision
`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.
`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.
## Alternatives considered
**Trust cached `blank: true`.** Rejected because the projection cache deliberately permits a persisted log to advance beyond its checkpoint. A crash or fail-soft write failure after the first `turn/start` would hide a real conversation and could make the client reuse it as New Session.
**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes. The physical-size bound targets the small historical artifacts that can be checked cheaply and degrades larger unknowns toward visibility.
**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).
**Continue ordering JSONL by mtime.** Rejected because mtime records every artifact write, including pickup boundaries, rather than the latest human prompt. Its error direction promotes untouched Sessions to the front.
## 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.
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.
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.

View File

@@ -0,0 +1,37 @@
# Agent Note: 有界验证冷空白会话
Status: implemented
[English](2026-08-13-bounded-cold-blank-verification.md) | 中文
## Problem
Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 New Session。已附加 Session 可以从内存事件日志派生空白状态,但 `session.list` 通常不会加载每一份冷日志。把所有已物化的冷 Session 都视为非空,会暴露旧版本留下的空 Session;反过来,把 projection cache 中的 `blank: true` 当成当前事实,则可能在日志已经前进而 fail-soft cache 仍然陈旧时隐藏真实对话。
同一份冷列表还曾用 JSONL 工件的 mtime 作为 `updatedAt`。打开 Session 会追加 `session/end-seed`,因此即使没有真人 prompt,单纯拾起也会刷新 mtime,并把该 Session 提升到最近使用的对话之前。
## Decision
`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 保持可见。
`updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。因此冷 cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。有界 blank 读取不用于补齐缺失的最近时间元数据。
## Alternatives considered
**信任缓存的 `blank: true`。** 拒绝,因为 projection cache 有意允许持久日志前进到 checkpoint 之后。首个 `turn/start` 之后若发生崩溃或 fail-soft 写入失败,真实对话就会被隐藏,客户端还可能把它复用为 New Session。
**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小上限只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。
**把空白状态与最近时间存入权威 persistence index。** 暂缓,因为 JSONL 的首行不可变,需要增加带有顺序写入要求的第二份持久工件;SQLite 则需要 schema 字段。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)负责。
**继续按 mtime 排序 JSONL。** 拒绝,因为 mtime 记录包括拾起边界在内的每一次工件写入,而非最近真人 prompt;其错误方向会把未经操作的 Session 提升到列表开头。
## Consequences
既有的小型空白 JSONL 工件无需依赖 projection cache 是否存在即可被隐藏,陈旧 cache 也无法隐藏已存的 `turn/start`。对于 cache 尚不能证明非空,且物理大小在配置上限内的每个 Session,冷列表可能读取其工件。对默认交付的 Zstandard JSONL 后端,该上限作用于压缩后的字节数。
超过上限的空白工件,以及来自不提供位置的后端的空白 Session 会保持可见。缺失或延迟的最近时间 cache 会回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。
网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了临界大小探测、拒绝陈旧 true、复用单调 false、回退方向、真人 prompt 最近时间和 fiber 销毁。无密钥 Web snapshot 会启动发行版的压缩 JSONL 组合,在没有 cache row 的情况下播种一份小型冷空白工件,并验证侧栏不展示它。