Procházet zdrojové kódy

fix(apiproxy): harden cold session metadata probing

_Kerman před 1 měsícem
rodič
revize
2be3e12965

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md
-2026-08-13-bounded-cold-blank-verification.md: 330f5acbd520487732bc72fe75f09496aaa0c028
-2026-08-13-bounded-cold-blank-verification.zh.md: 15c0d28be8c15f7076ac90a50bb023cc42fbbdd6
+2026-08-13-bounded-cold-blank-verification.md: bf8d167d742001ce65b3e96713a9603adb19603e
+2026-08-13-bounded-cold-blank-verification.zh.md: 7cfef77a02308a8e75281877f8a774b41bb9559d

+ 6 - 6
.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md

@@ -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`.
 
-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
 
 **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).
 
@@ -30,8 +30,8 @@ A cold summary trusts cached `blank: false`, because a checkpoint prefix contain
 
 ## 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.

+ 6 - 6
.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md

@@ -14,15 +14,15 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N
 
 `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
 
 **信任缓存的 `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)负责。
 
@@ -30,8 +30,8 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N
 
 ## 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 - 2
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
-2026-07-29-durable-last-activity-index.md: b530508877adf3a66b158ff659441c4179878d00
-2026-07-29-durable-last-activity-index.zh.md: 4c099bc835c4708fda09811fafd6dabb65dbb575
+2026-07-29-durable-last-activity-index.md: 99e50dd40b789db5d896cb7f9e25fa8893b02ae2
+2026-07-29-durable-last-activity-index.zh.md: e317fb192d53353295e6b52f631707ba6b400b66

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md

@@ -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.
 
-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.
 
@@ -61,7 +61,7 @@ Three questions must be answered before implementation, and none of them is sett
 
 ## 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.
 - [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.

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md

@@ -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 保守的「过旧」错误方向作为现阶段取舍。
 
-已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志,会让 `list()` 的开销随对话总字节数而非 Session 数量增长。为空白验证引入的 1 KiB 冷读取并不能解决最近时间:它是条件式的,只针对小产物,也不能让大日志的排序精确。
+已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志,会让 `list()` 的开销随对话总字节数而非 Session 数量增长。用于 metadata 验证的 1 KiB 冷读取可以让符合条件的小产物得到精确的最近时间,但不能让大日志的排序精确。
 
 让冷排序变得精确仍是一项持久格式决策,因此其范围留在本文,而不是网关 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-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
 - [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。

+ 2 - 2
docs/subsystems/session.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 4c40971fe58952b32635aa5eb767a42f73108b00
-session.zh.md: d958b03fcdad91b58cc277636e599cce46a2c7fb
+session.md: 760a3042724472b5f518896b8ff0e56bcfec2799
+session.zh.md: 8c56029af5144569f1ab6df73a8fe2278f9ef5b4

+ 1 - 1
docs/subsystems/session.md

@@ -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/*`.
 
-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
 

+ 1 - 1
docs/subsystems/session.zh.md

@@ -590,7 +590,7 @@ interface TurnEndReasonMap {
 
 它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compaction/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compaction/*` 的原因。
 
-活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。
+按真人活动排序 Session 的消费方会排除该边界:接手 Session 不算工作,因此按日志尾部排序会把每个打开过的 Session 顶到最前。
 
 ## 插件贡献的仅日志事件
 

+ 1 - 1
packages/core/session/src/index.ts

@@ -26,7 +26,7 @@ export type { SessionPreparationOptions } from './preparation.ts'
 export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
 export { isJsonValue, snapshotJsonValue } from './json.ts'
 export type { JsonValue } from './json.ts'
-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 type { ChunkRow, StorageRecord } from './chunk-rows.ts'
 export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'

+ 1 - 20
packages/core/session/src/repair.ts

@@ -1,10 +1,7 @@
 /**
  * Crash-recovery repair for an interrupted session log. It preserves a fully
  * written final turn and supplies the missing tool, step, and turn boundaries
- * needed to resume with a provider-valid transcript, plus the activity-time
- * 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.
+ * needed to resume with a provider-valid transcript.
  * @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 { 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. */
 export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
 

+ 1 - 42
packages/core/session/tests/repair.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 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'
 
 /**
@@ -273,44 +273,3 @@ describe('interruptedTurnClosers', () => {
     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
packages/host/apiproxy/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/host/apiproxy/README.md
-README.md: 518a7e5640bc62b493244a3d863cfea643386f7f
-README.zh.md: 5692e9441d20d9dbec5d8a69263c875eb3f3f907
+README.md: b7dee9488c1b123172c225d4d4235bdb3c76911d
+README.zh.md: 46835a84254c4bbc45300a18d29aae5a6ac66f14

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
packages/host/apiproxy/README.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
packages/host/apiproxy/README.zh.md


+ 30 - 26
packages/host/apiproxy/src/api-proxy.ts

@@ -563,40 +563,40 @@ function summarize(session: Session, running: boolean): SessionSummary {
 }
 
 /**
- * Verify a possibly blank cold Session only when its physical artifact is
- * within the configured per-Session read bound. A stale `blank: true`, an
+ * Verify a possibly blank cold Session only when its physical artifact passes
+ * the configured per-Session size check. A stale `blank: true`, an
  * 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
  * cache hint or an unavailable optimization.
  */
-async function probeColdSessionBlank(
+async function probeColdSessionMetadata(
   ctx: Context,
   persistence: SessionPersistence,
   meta: SessionHeader,
   maxBytes: number,
   signal?: AbortSignal,
-): Promise<boolean> {
-  if (maxBytes === 0) return false
+): Promise<SessionListMetadata | undefined> {
+  if (maxBytes === 0) return undefined
   signal?.throwIfAborted()
   const location = persistence.locate(meta)
-  if (location === undefined) return false
+  if (location === undefined) return undefined
   signal?.throwIfAborted()
   let size: number
   try {
     size = (await stat(location.path)).size
   } catch {
     signal?.throwIfAborted()
-    return false
+    return undefined
   }
-  if (size > maxBytes) return false
+  if (size > maxBytes) return undefined
   try {
     const { events } = await persistence.readFrom(meta.id, 0, signal)
     signal?.throwIfAborted()
-    return !events.some(event => event.type === 'turn/start')
+    return sessionListMetadata(events)
   } catch (error) {
     signal?.throwIfAborted()
     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,
   signal?: AbortSignal,
 ): Promise<SessionSummary> {
-  const blank = metadata?.blank === false
-    ? false
-    : await probeColdSessionBlank(ctx, persistence, meta, blankProbeMaxBytes, signal)
+  const probed = metadata?.blank === false
+    ? undefined
+    : await probeColdSessionMetadata(ctx, persistence, meta, blankProbeMaxBytes, signal)
   return {
     sessionId: meta.id,
-    updatedAt: sessionListUpdatedAt(meta, metadata),
+    updatedAt: sessionListUpdatedAt(meta, probed ?? metadata),
     running: false,
-    blank,
+    blank: metadata?.blank === false ? false : probed?.blank ?? false,
     // Header-only: reading the log for a blank-window preset switch would
     // defeat the same index read, and attaching the session replaces this row
     // 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[]> {
     signal?.throwIfAborted()
-    const items = ctx.sessions.list().map((session) => {
+    const summarizeAttached = (session: Session): SessionSummary => {
       const agent = ctx.agents.get(session.id)
       const projections = listProjectionsFor(ctx, session.header, session)
       return {
         ...summarize(session, agent?.status === 'running'),
         ...projections === undefined ? {} : { projections },
       }
-    })
+    }
+    const items = ctx.sessions.list().map(summarizeAttached)
     signal?.throwIfAborted()
     const attached = new Set(items.map(item => item.sessionId))
     const persistence = ctx.get('sessionPersistence')
@@ -1745,17 +1746,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         const settled = await Promise.allSettled(
           batch.map(async (meta) => {
             // 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 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 {
-              ...await summarizeCold(
-                ctx,
-                persistence,
-                meta,
-                projections?.values.sessionListMetadata,
-                coldBlankProbeMaxBytes,
-                signal,
-              ),
+              ...summary,
               ...projections === undefined ? {} : { projections },
             }
           }),

+ 10 - 10
packages/host/apiproxy/src/api/sessions.ts

@@ -173,25 +173,25 @@ export type QueueAction =
   | { kind: 'remove' }
   | { kind: 'steer' }
 
-/** Session list entry (v1 builds no index: list does readdir+stat). */
+/** One Session list entry. */
 export interface SessionSummary {
   sessionId: SessionId
   /**
-   * Last activity. Attached: the last non-`session/end-seed` event, since a
-   * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
-   * with no per-session file (README Known Limitations covers the skew).
+   * The later of creation and the latest human-authored prompt. Attached
+   * Sessions fold their live log; cold Sessions use a projection-cache hint or
+   * an exact small-artifact read, falling back to creation time.
    */
   updatedAt: number
   /** Status of the attached agent; always false for cold (unattached) sessions. */
   running: boolean
   /**
-   * Derived conversation-not-started bit: true while no turn has run (no
-   * prompt was accepted yet). Standalone plugin events — command lifecycle
+   * Derived conversation-not-started bit: true while no turn has run.
+   * Standalone plugin events — command lifecycle
    * 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
-   * New Session on the same workspace. Always false for cold sessions —
-   * lazy persistence keeps a never-appended session out of the store, and a
-   * listed cold session's log holds its turns.
+   * not clear it. Clients hide blank Sessions from lists and reuse them for
+   * New Session on the same workspace. A cold Session is true only when a
+   * small-artifact read verifies that no `turn/start` exists; unavailable
+   * or oversized artifacts conservatively report false.
    */
   blank: boolean
   /** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */

+ 65 - 2
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -67,7 +67,14 @@ describe('sessions.list cold merge', () => {
       if (id === sid('small-conversation')) {
         return {
           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')
@@ -105,7 +112,7 @@ describe('sessions.list cold merge', () => {
     const byId = Object.fromEntries(response.result.value.items.map(item => [item.sessionId, item]))
     expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
     // 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 })
     // false is monotonic, so this row skips stat/read and keeps cached recency.
     expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
@@ -149,6 +156,62 @@ describe('sessions.list cold merge', () => {
     ])
     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', () => {

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů