Преглед изворни кода

fix(web): harden schedule catalog state handling

pku-xht пре 1 месец
родитељ
комит
c3b694312f

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
-2026-08-25-session-observations-and-projection-owned-client-state.md: 9bf4bb7c720c4fccbfc162ca5a3250c9a6fdb178
-2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 0043ab237096f1533de383eb7d63bde02fe6cf3b
+2026-08-25-session-observations-and-projection-owned-client-state.md: 99dd23343b94aed192694d360d63096c89e17c73
+2026-08-25-session-observations-and-projection-owned-client-state.zh.md: a43a067474b32a1d626f753b2a82fa4445299209

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md

@@ -108,11 +108,11 @@ These distinctions prevent one overloaded `undefined` from representing cache mi
 | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent |
 | Projection frame | One whole key | Event sequence carried by the frame | Not applicable |
 
-The Client projection store records source provenance and arrival revision beside each `{ value, seq }` row. A list hint updates only tentative rows before the store has accepted a complete authoritative cut. The first authoritative frame replaces a tentative row regardless of sequence, while later frames require a strictly higher sequence. Follow and control baselines are complete replacements, so they replace equal-sequence values and remove omitted keys.
+The Client projection store records source provenance and arrival revision beside each `{ value, seq }` row. The latest arriving list hint replaces a tentative row even when crash repair lowered its watermark. A complete authoritative cut blocks later hints. The first authoritative frame replaces a tentative row regardless of sequence, while later frames require a strictly higher sequence. Follow baselines replace the complete store; control baselines exactly replace included Sessions and remove omitted keys.
 
 Each Session retains one opaque token for initial open, explicit resync, or carrier reconnection and cancels it if that opening fails or is superseded. When a follow baseline completes the token, the store discards pre-token state and retains only authoritative frames that arrived after the token and are newer than the opening cut. A control baseline received after the token at an equal or newer cut remains authoritative. Session does not capture, buffer, or replay projection operations.
 
-The list view and opened Session read the same per-Session store. Hints can populate title, preset, and other list presentation before the first complete authoritative cut; afterward, late hints are ignored so tentative cache data cannot re-enter the opened value. The store never folds Session events: it owns hint/frame provenance, frame ordering, exact baseline replacement, and opening reconciliation; `Session` owns only token lifetime, and `SessionManager` routes list hints, control frames, and control baselines to that resident store.
+The list view and opened Session read the same per-Session store. Hints can populate title, preset, and other list presentation before the first complete authoritative cut. When a later control generation omits that Session, `SessionManager` invalidates the prior authoritative rows and reinstalls the latest retained list block as tentative. A list pull folds later add and remove mutations over its pull-time hints so a delayed response cannot reverse arrival order. The store never folds Session events: it owns hint/frame provenance, frame ordering, exact baseline replacement, and opening reconciliation; `Session` owns only token lifetime, and `SessionManager` routes list hints, control frames, and control baselines to that resident store.
 
 Data that is not derived from one Session remains outside projections. `llm.models` owns the Host-generation model catalog, and `agentPreset.list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict.
 
@@ -174,7 +174,7 @@ These rules apply to new Session-derived Client state even when a direct event s
 
 Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery.
 
-Client tests pin tentative-hint ordering, first-authoritative-frame takeover, exact opening and equal-cut control replacement, post-token frame retention, stale or canceled opening tokens, manager/Session shared-store routing, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin corpus enumeration, cache and observation fallback, lifecycle witnesses, bounded cold reads, and no Agent activation during listing.
+Client tests pin arrival-ordered tentative hints, first-authoritative-frame takeover, exact opening and equal-cut control replacement, cold-Session omission across both list/control arrival orders, in-flight list-mutation replay, post-token frame retention, stale or canceled opening tokens, manager/Session shared-store routing, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin corpus enumeration, cache and observation fallback, lifecycle witnesses, bounded cold reads, and no Agent activation during listing.
 
 ## Alternatives considered
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md

@@ -108,11 +108,11 @@ Projection 的三种交付状态含义不同:
 | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 |
 | Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 |
 
-Client projection store 在每条 `{ value, seq }` row 旁记录来源类别与到达 revision。list hint 只会在 store 尚未接收完整权威 cut 时更新暂定 row。首个权威 frame 无论 sequence 如何都会替换暂定 row,后续 frame 则必须具有严格更高的 sequence。follow 与 control baseline 都是完整替换,因此会覆盖等 sequence 值并移除缺失 key。
+Client projection store 在每条 `{ value, seq }` row 旁记录来源类别与到达 revision。最新到达的 list hint 会替换暂定 row,即使崩溃修复降低了其 watermark;完整权威 cut 会阻止之后的 hint。首个权威 frame 无论 sequence 如何都会替换暂定 row,后续 frame 则必须具有严格更高的 sequence。follow baseline 会替换整个 store;control baseline 会精确替换已包含的 Session,并移除其中缺失的 key。
 
 每个 Session 为初次打开、显式 resync 或 carrier 重连保留一个不透明 token,并在该 opening 失败或被取代时取消它。当 follow baseline 完成该 token 时,store 丢弃 token 之前的状态,只保留 token 之后到达且 seq 新于 opening cut 的权威 frame。若 control baseline 在 token 之后到达且 cut 等于或新于 opening cut,它保持权威。Session 不捕获、缓冲或重放 projection operation。
 
-List view 与已打开 Session 读取同一个 per-Session store。首次完整权威 cut 之前,hint 可以填充 title、preset 和其他 list presentation;之后迟到的 hint 会被忽略,避免暂定 cache 数据重新进入已打开值。store 从不折叠 Session event:它拥有 hint/frame 来源、frame 排序、完整 baseline 的精确替换和 opening reconciliation;`Session` 只拥有 token 生命周期,`SessionManager` 则把 list hint、control frame 与 control baseline 路由到这份 resident store。
+List view 与已打开 Session 读取同一个 per-Session store。首次完整权威 cut 之前,hint 可以填充 title、preset 和其他 list presentation。若后续 control generation 缺失该 Session,`SessionManager` 会使上一代权威 row 失效,并把最近保留的 list block 重新安装为暂定值。list pull 会把稍后到达的 add 与 remove mutation 折叠到请求时的 hint 上,因此延迟响应无法逆转到达顺序。store 从不折叠 Session event:它拥有 hint/frame 来源、frame 排序、完整 baseline 的精确替换和 opening reconciliation;`Session` 只拥有 token 生命周期,`SessionManager` 则把 list hint、control frame 与 control baseline 路由到这份 resident store。
 
 不由单个 Session 派生的数据不进入 projection。`llm.models` 拥有当前 Host generation 的 model catalog,`agentPreset.list` 拥有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。
 
@@ -174,7 +174,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打
 
 Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。
 
-Client 测试固定暂定 hint 排序、首个权威 frame 接管、opening 与等 cut control 精确替换、token 后 frame 保留、陈旧或已取消的 opening token、manager/Session 共用 store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定 corpus 枚举、cache 与 observation fallback、lifecycle witness、有界冷读,以及 listing 期间不激活 Agent。
+Client 测试固定按到达顺序处理暂定 hint、首个权威 frame 接管、opening 与等 cut control 精确替换、list/control 两种到达顺序下的 cold Session 缺失、进行中 list mutation 重放、token 后 frame 保留、陈旧或已取消的 opening token、manager/Session 共用 store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定 corpus 枚举、cache 与 observation fallback、lifecycle witness、有界冷读,以及 listing 期间不激活 Agent。
 
 ## 考虑过的替代方案
 

+ 43 - 1
apps/web/tests/schedule-after.e2e.ts

@@ -66,6 +66,9 @@ const CATALOG_SESSION_ID = SessionId('schedule-catalog-web-e2e')
 const CATALOG_TITLE = 'Active schedule catalog'
 const REMINDER_TRIGGER_NAME = /^\d+ reminders?$/
 const ACTIVE_SCHEDULE_LABEL = 'Has active scheduled task'
+const LARGE_INTERVAL_SECONDS = 200_000_000_001
+const LARGE_INTERVAL_PROMPT = 'Keep every large-interval metadata field visible'
+const LARGE_INTERVAL_ID = ScheduleId('catalog-large-interval')
 const CATALOG_IDS = {
   after: ScheduleId('catalog-after'),
   at: ScheduleId('catalog-at'),
@@ -717,9 +720,48 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
       MODE,
     )
 
+    parentAgent.session.append('schedule/change', {
+      version: 1,
+      operation: 'create',
+      schedule: createEveryScheduleRecord(
+        LARGE_INTERVAL_ID,
+        LARGE_INTERVAL_PROMPT,
+        LARGE_INTERVAL_SECONDS,
+        CATALOG_NOW,
+      ),
+    })
+    await expect(scaffold.ctx.sessions.flush(parentAgent.session)).resolves.toBe(true)
+    await page.getByRole('button', { name: '4 reminders' }).waitFor({ timeout: 15_000 })
+    const largeRow = catalog.getByRole('listitem').filter({ hasText: LARGE_INTERVAL_PROMPT })
+    await largeRow.waitFor({ timeout: 15_000 })
+    const metadataLayout = await largeRow.locator(':scope > span').nth(2).evaluate((element) => {
+      const box = element.getBoundingClientRect()
+      return {
+        text: element.textContent,
+        height: box.height,
+        left: box.left,
+        right: box.right,
+        clientWidth: element.clientWidth,
+        scrollWidth: element.scrollWidth,
+        fields: [...element.children].map((child) => {
+          const field = child.getBoundingClientRect()
+          return { width: field.width, height: field.height, left: field.left, right: field.right }
+        }),
+      }
+    })
+    expect(metadataLayout.text).toContain(`Every ${LARGE_INTERVAL_SECONDS} seconds`)
+    expect(metadataLayout.height).toBeGreaterThan(16)
+    expect(metadataLayout.scrollWidth).toBeLessThanOrEqual(metadataLayout.clientWidth)
+    for (const field of metadataLayout.fields) {
+      expect(field.width).toBeGreaterThan(0)
+      expect(field.height).toBeGreaterThan(0)
+      expect(field.left).toBeGreaterThanOrEqual(metadataLayout.left)
+      expect(field.right).toBeLessThanOrEqual(metadataLayout.right)
+    }
+
     const sessionRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) })
     expect(await sessionRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1)
-    for (const id of Object.values(CATALOG_IDS)) {
+    for (const id of [...Object.values(CATALOG_IDS), LARGE_INTERVAL_ID]) {
       parentAgent.session.append('schedule/change', { version: 1, operation: 'delete', id })
     }
     await expect(scaffold.ctx.sessions.flush(parentAgent.session)).resolves.toBe(true)

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 815276847f538f99352e0f0e55fc19af5da67470
-README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b
+README.md: d3c139f6c093fb77bd0d387793ae6823e780c599
+README.zh.md: 10f062e49e2241aa04fa3636122f08738d3c8ce1

+ 1 - 1
packages/api/session-controller/README.md

@@ -27,7 +27,7 @@ History pages and follow opening snapshots carry a discriminated `SessionHistory
 
 Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
 
-The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
+The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For projections, a Session omitted from that process-local roster invalidates its prior authoritative Client rows; the Client may immediately show its latest retained list-cache hint as tentative until a follow opening or later frame supplies authoritative state.
 
 -----
 

+ 1 - 1
packages/api/session-controller/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
 
-Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
+Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。对于 projection,若某个 Session 未出现在该进程本地 roster 中,它在 Client 上一代的权威 row 就会失效;在 follow opening 或后续 frame 提供权威状态前,Client 可以立即把最近保留的 list-cache hint 作为暂定值展示。
 
 -----
 

+ 37 - 8
packages/api/session-controller/src/client/sessions/manager.ts

@@ -10,6 +10,7 @@ import type {
   SessionControlFrame,
   SessionQueuedItem,
   SessionError,
+  SessionProjectionHints,
   SessionSummary,
   SessionJob as JobView,
 } from '../../types.ts'
@@ -488,14 +489,7 @@ export class SessionManager {
             session.handleBlank(s.blank)
             session.handleRunning(s.running)
           }
-          // Apply each row's tentative projection values (cold values surface
-          // without opening the session). The store owns hint precedence and
-          // ignores them after a complete authoritative baseline.
-          for (const s of result.value.items) {
-            const block = s.projections
-            if (block === undefined) continue
-            this.projectionStore(s.sessionId).prewarm(block)
-          }
+          this.reconcileListProjectionHints(result.value.items, mutations)
         } else {
           this.listState = 'error'
           this.listError = result.error
@@ -695,6 +689,13 @@ export class SessionManager {
       if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs)
     }
 
+    const projected = new Set(Object.keys(baseline.projections))
+    const summaries = new Map(this.summaries.map(summary => [summary.sessionId, summary]))
+    for (const [sessionId, store] of this.projectionStores) {
+      if (!projected.has(sessionId)) {
+        store.replaceControlOmission(summaries.get(sessionId)?.projections)
+      }
+    }
     for (const [sessionId, block] of Object.entries(baseline.projections)) {
       this.projectionStore(sessionId as SessionId).replaceControlBaseline(block)
     }
@@ -704,6 +705,31 @@ export class SessionManager {
     this.notifier.markDirty()
   }
 
+  /**
+   * Apply pull-time hints before later list mutations without letting a stale
+   * in-flight response overwrite a newer session-added hint or recreate a
+   * Session removed while the request was pending.
+   */
+  private reconcileListProjectionHints(
+    items: readonly SessionSummary[],
+    mutations: readonly SessionListMutation[],
+  ): void {
+    const hints = new Map<SessionId, SessionProjectionHints>()
+    for (const summary of items) {
+      if (summary.projections !== undefined) hints.set(summary.sessionId, summary.projections)
+    }
+    for (const mutation of mutations) {
+      if (mutation.kind === 'remove') {
+        hints.delete(mutation.sessionId)
+      } else if (mutation.kind === 'upsert' && mutation.summary.projections !== undefined) {
+        hints.set(mutation.summary.sessionId, mutation.summary.projections)
+      }
+    }
+    for (const [sessionId, hint] of hints) {
+      this.projectionStore(sessionId).prewarm(hint)
+    }
+  }
+
   /**
    * Apply one Session-list addition forwarded through `ctx.remote.$on`.
    * @param summary - current Host summary for the added Session.
@@ -970,9 +996,12 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
           ? { parentSessionId: mutation.summary.parentSessionId } : {}),
         ...(existing.origin === undefined && mutation.summary.origin !== undefined
           ? { origin: mutation.summary.origin } : {}),
+        ...(mutation.summary.projections === undefined
+          ? {} : { projections: mutation.summary.projections }),
       }
       if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
         && filled.origin === existing.origin && filled.blank === existing.blank
+        && filled.projections === existing.projections
       ) return [...summaries]
       return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
     }

+ 38 - 4
packages/api/session-controller/src/client/sessions/projection-store.ts

@@ -136,7 +136,9 @@ export class ProjectionValueStore {
   }
 
   /**
-   * Apply a partial cache-backed hint while no complete authoritative cut exists.
+   * Apply the latest partial cache-backed hint while no complete authoritative
+   * cut exists. Arrival order, not the cached watermark, orders tentative rows:
+   * crash repair may legitimately lower the durable sequence.
    * @param hint - partial projection values from the Session list cache.
    */
   prewarm(hint: ProjectionsBaseline): void {
@@ -145,14 +147,12 @@ export class ProjectionValueStore {
     for (const key of Object.keys(values)) {
       const previous = this.rows.get(key)
       if (previous?.provenance === 'authoritative') continue
-      if (previous !== undefined && hint.asOfSeq <= previous.seq) continue
-      this.rows.set(key, {
+      this.installRow(key, {
         value: values[key],
         seq: hint.asOfSeq,
         provenance: 'tentative',
         revision: ++this.revision,
       })
-      this.changed(key)
     }
   }
 
@@ -234,6 +234,40 @@ export class ProjectionValueStore {
     this.installCompleteBaseline(baseline, revision)
   }
 
+  /**
+   * Replace state for a Session omitted from a new control generation. The
+   * Host cut invalidates prior authoritative rows, while the latest retained
+   * list block may immediately repopulate tentative sidebar values.
+   * @param hint - latest partial list-cache block retained for the Session.
+   */
+  replaceControlOmission(hint?: ProjectionsBaseline): void {
+    const revision = ++this.revision
+    this.completeBaselineInstalled = false
+    this.latestControlBaseline = undefined
+    if (hint === undefined) {
+      for (const key of this.rows.keys()) {
+        this.rows.delete(key)
+        this.changed(key)
+      }
+      return
+    }
+    const values = hint.values as Record<string, unknown>
+    const keys = new Set([...this.rows.keys(), ...Object.keys(values)])
+    for (const key of keys) {
+      if (!Object.hasOwn(values, key)) {
+        this.rows.delete(key)
+        this.changed(key)
+        continue
+      }
+      this.installRow(key, {
+        value: values[key],
+        seq: hint.asOfSeq,
+        provenance: 'tentative',
+        revision,
+      })
+    }
+  }
+
   private changed(key: string): void {
     this.valuesCache = undefined
     this.channels.get(key)?.notifier.markDirty()

+ 96 - 1
packages/api/session-controller/tests/manager.client.spec.ts

@@ -5,7 +5,10 @@
 
 import { describe, expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
-import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
+import type {
+  SessionControlFrame,
+  SessionProjectionHints,
+} from '@deepseek-ai/dsh-api-session-controller/types'
 import type {} from '@deepseek-ai/dsh-session-title/client'
 import { SessionManager } from '../src/client/sessions/manager.ts'
 import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts'
@@ -21,6 +24,7 @@ type SummaryOver = Partial<{
   cwd: string
   parentSessionId: SessionId
   origin: 'subagent'
+  projections: SessionProjectionHints
 }>
 
 function summary(sessionId: SessionId, over: SummaryOver = {}) {
@@ -213,6 +217,97 @@ describe('list lifecycle', () => {
     expect(manager.getListSnapshot().items[0]?.title).toBe('Recovered')
     expect(session.projections.get('title')).toBe('Recovered')
   })
+
+  it('uses the latest list hint when a later control generation omits the cold Session', async () => {
+    const api = new FakeApiClient()
+    api.onList = () => Promise.resolve(ok({
+      items: [summary(S1, {
+        projections: { asOfSeq: 4, values: { title: 'Repaired cache' } },
+      })] as never[],
+    }))
+    const manager = new SessionManager(fakeRemote(api))
+    manager.handleControlFrame({
+      type: 'projection', sessionId: S1, key: 'title', value: 'Stale live', seq: 9,
+    })
+
+    await manager.refreshList()
+    expect(manager.getListSnapshot().items[0]?.title).toBe('Stale live')
+    manager.handleControlFrame({
+      type: 'baseline', value: { queues: {}, jobs: {}, projections: {} },
+    })
+
+    expect(manager.getListSnapshot().items[0]?.title).toBe('Repaired cache')
+  })
+
+  it('accepts a repaired lower-sequence list hint after an omitted control baseline', async () => {
+    const api = new FakeApiClient()
+    api.onList = () => Promise.resolve(ok({
+      items: [summary(S1, {
+        projections: { asOfSeq: 12, values: { title: 'Older cache' } },
+      })] as never[],
+    }))
+    const manager = new SessionManager(fakeRemote(api))
+    await manager.refreshList()
+    manager.handleControlFrame({
+      type: 'projection', sessionId: S1, key: 'title', value: 'Stale live', seq: 20,
+    })
+
+    const response = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+    api.onList = () => response.promise
+    const refresh = manager.refreshList()
+    manager.handleControlFrame({
+      type: 'baseline', value: { queues: {}, jobs: {}, projections: {} },
+    })
+    expect(manager.getListSnapshot().items[0]?.title).toBe('Older cache')
+
+    response.resolve(ok({
+      items: [summary(S1, {
+        projections: { asOfSeq: 3, values: { title: 'Repaired lower cache' } },
+      })] as never[],
+    }))
+    await refresh
+    expect(manager.getListSnapshot().items[0]?.title).toBe('Repaired lower cache')
+  })
+
+  it('replays a newer session-added hint over an in-flight list response', async () => {
+    const api = new FakeApiClient()
+    const response = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+    api.onList = () => response.promise
+    const manager = new SessionManager(fakeRemote(api))
+    const refresh = manager.refreshList()
+
+    manager.handleSessionAdded(summary(S1, {
+      projections: { asOfSeq: 8, values: { title: 'Later added hint' } },
+    }))
+    response.resolve(ok({
+      items: [summary(S1, {
+        projections: { asOfSeq: 9, values: { title: 'Earlier pull hint' } },
+      })] as never[],
+    }))
+    await refresh
+
+    expect(manager.getListSnapshot().items[0]?.title).toBe('Later added hint')
+  })
+
+  it('does not recreate a projection store for a Session removed during a list response', async () => {
+    const api = new FakeApiClient()
+    const response = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+    api.onList = () => response.promise
+    const manager = new SessionManager(fakeRemote(api))
+    const refresh = manager.refreshList()
+
+    manager.handleSessionRemoved(S1)
+    response.resolve(ok({
+      items: [summary(S1, {
+        projections: { asOfSeq: 9, values: { title: 'Removed pull hint' } },
+      })] as never[],
+    }))
+    await refresh
+
+    expect(manager.getListSnapshot().items).toEqual([])
+    manager.handleSessionAdded(summary(S1, { blank: true }))
+    expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
+  })
 })
 
 describe('search', () => {

+ 23 - 1
packages/api/session-controller/tests/projection-store.client.spec.ts

@@ -26,7 +26,7 @@ describe('Session projection value semantics', () => {
     expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
   })
 
-  it('lets the first authoritative frame replace a newer hint, then uses higher-seq-wins', () => {
+  it('orders tentative hints by arrival, then lets authoritative frames win by sequence', () => {
     const store = new ProjectionValueStore()
     store.prewarm({
       asOfSeq: 9,
@@ -36,6 +36,7 @@ describe('Session projection value semantics', () => {
       asOfSeq: 8,
       values: { 'test/marks': { marks: ['older-hint'] } },
     })
+    expect(store.get('test/marks')).toEqual({ marks: ['older-hint'] })
 
     store.apply('test/marks', { marks: ['frame-3'] }, 3)
     store.prewarm({
@@ -52,6 +53,27 @@ describe('Session projection value semantics', () => {
     })
   })
 
+  it('resets a Session omitted from a control generation to its retained list hint', () => {
+    const store = new ProjectionValueStore()
+    store.replaceControlBaseline({
+      asOfSeq: 9,
+      values: { 'test/marks': { marks: ['old-control'] }, 'control-only': true },
+    })
+
+    store.replaceControlOmission({
+      asOfSeq: 4,
+      values: { 'test/marks': { marks: ['repaired-cache'] } },
+    })
+    expect(store.values()).toEqual({ 'test/marks': { marks: ['repaired-cache'] } })
+
+    store.apply('test/marks', { marks: ['authoritative'] }, 3)
+    store.prewarm({ asOfSeq: 99, values: { 'test/marks': { marks: ['late-cache'] } } })
+    expect(store.get('test/marks')).toEqual({ marks: ['authoritative'] })
+
+    store.replaceControlOmission()
+    expect(store.values()).toEqual({})
+  })
+
   it('opening replaces pre-opening state and retains only newer post-token frames', () => {
     const store = new ProjectionValueStore()
     store.prewarm({ asOfSeq: 20, values: { 'test/marks': { marks: ['hint'] } } })

+ 2 - 2
packages/client/ui-schedule/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/client/ui-schedule/README.md
-README.md: 318645914b1764bd9eea85f7972ace562dc41814
-README.zh.md: de1180f63c5c045018ab3527e4252d8e14aa46b8
+README.md: fa7908c73edb428d2489e34978b3566791ef112b
+README.zh.md: f8b4395f9a923cfc566a61c799e4be809818b9ae

+ 2 - 2
packages/client/ui-schedule/README.md

@@ -35,9 +35,9 @@ The shipped Web graph already resolves `@deepseek-ai/dsh-client-ui-schedule` thr
 
 ### Read and dismiss the catalog
 
-Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded. The 336px popover scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls.
+Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The 336px popover scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls.
 
-Only the native trigger button enters the tab order. Enter and Space use normal button activation; Escape closes the popover and restores trigger focus; an outside pointer press dismisses it. If a live update removes the final record, the component closes and unmounts without moving focus to another header action. A failed Session open hides the trigger even when a tentative cached projection exists.
+Only the native trigger button enters the tab order. Enter and Space use normal button activation; while the popover is open, Escape closes it and restores trigger focus even after native Tab moves focus to another header action; an outside pointer press dismisses it. If a live update removes the final record, the component closes and unmounts without moving focus to another header action. A failed Session open hides the trigger even when a tentative cached projection exists.
 
 -----
 

+ 2 - 2
packages/client/ui-schedule/README.zh.md

@@ -35,9 +35,9 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml
 
 ### 阅读和关闭目录
 
-每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入。336px 宽的弹层在需要时纵向滚动,不显示 Schedule id、原始 UTC 值、详情或操作控件。
+每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。336px 宽的弹层在需要时纵向滚动,不显示 Schedule id、原始 UTC 值、详情或操作控件。
 
-只有原生触发按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;Escape 关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live 更新移除最后一条记录,组件会关闭并卸载,但不会把焦点移到另一个会话头部动作。Session 打开失败时,即使存在暂定的缓存 projection,也会隐藏触发器。
+只有原生触发按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;弹层打开期间,即使原生 Tab 已把焦点移到另一个会话头部动作,Escape 仍会关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live 更新移除最后一条记录,组件会关闭并卸载,但不会把焦点移到另一个会话头部动作。Session 打开失败时,即使存在暂定的缓存 projection,也会隐藏触发器。
 
 -----
 

+ 1 - 8
packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css

@@ -112,20 +112,13 @@
 
 .metadata {
   display: flex;
+  flex-wrap: wrap;
   align-items: center;
   gap: 5px;
   min-width: 0;
   color: var(--dsw-alias-label-tertiary);
   font-size: 11px;
   line-height: 16px;
-  white-space: nowrap;
-}
-
-.relative,
-.relativeOverdue {
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
 }
 
 .relativeOverdue {

+ 15 - 9
packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
 import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
 import { IconChevronDownOutline14, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -121,6 +121,18 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule
     return () => { clearInterval(timer) }
   }, [open])
 
+  useEffect(() => {
+    if (!open) return
+    const dismissOnEscape = (event: KeyboardEvent): void => {
+      if (event.key !== 'Escape') return
+      event.preventDefault()
+      setOpen(false)
+      triggerRef.current?.focus()
+    }
+    document.addEventListener('keydown', dismissOnEscape)
+    return () => { document.removeEventListener('keydown', dismissOnEscape) }
+  }, [open])
+
   useEffect(() => {
     if (visible || !open) return
     setOpen(false)
@@ -132,12 +144,6 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule
 
   const countKey = records.length === 1 ? 'trigger.one' : 'trigger.other'
   const countLabel = t(countKey, { count: records.length })
-  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
-    if (event.key !== 'Escape' || !open) return
-    event.preventDefault()
-    setOpen(false)
-    triggerRef.current?.focus()
-  }
   const toggleCatalog = (): void => {
     setNow(Date.now())
     setOpen(current => !current)
@@ -176,7 +182,7 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule
                 <span aria-hidden="true">·</span>
                 <span>{formatScheduleLocalTime(record.scheduledAt, document.documentElement.lang)}</span>
                 <span aria-hidden="true">·</span>
-                <span className={overdue ? css.relativeOverdue : css.relative}>
+                <span className={overdue ? css.relativeOverdue : undefined}>
                   {formatScheduleRelative(record.scheduledAt, now, t)}
                 </span>
               </span>
@@ -188,7 +194,7 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule
     : null
 
   return (
-    <div ref={rootRef} className={css.root} onKeyDown={onKeyDown}>
+    <div ref={rootRef} className={css.root}>
       {trigger}
       {catalog}
     </div>

+ 21 - 4
packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx

@@ -162,6 +162,7 @@ describe('ScheduleCatalogAction rows', () => {
       [7_200, 'Every 2 hours', '2小时一次'],
       [300, 'Every 5 minutes', '5分钟一次'],
       [301, 'Every 301 seconds', '301秒一次'],
+      [200_000_000_001, 'Every 200000000001 seconds', '200000000001秒一次'],
     ] as const
     for (const [seconds, english, chinese] of samples) {
       const item = record(String(seconds), 'every', START + 1_000, { everySeconds: seconds })
@@ -173,6 +174,20 @@ describe('ScheduleCatalogAction rows', () => {
     expect(tZh('status.overdue')).toBe('已逾期')
   })
 
+  it('renders every required metadata value for a valid large recurrence', () => {
+    const item = record('large', 'every', START + 200_000_000_001_000, {
+      everySeconds: 200_000_000_001,
+    })
+    const t = makeTranslate(en)
+    render(<ScheduleCatalogAction {...props([item])} />)
+    fireEvent.click(screen.getByRole('button'))
+
+    const row = screen.getByRole('listitem')
+    expect(row.textContent).toContain(formatScheduleFrequency(item, t))
+    expect(row.textContent).toContain(formatScheduleLocalTime(item.scheduledAt, 'en'))
+    expect(row.textContent).toContain(formatScheduleRelative(item.scheduledAt, START, t))
+  })
+
   it('formats absolute time with the active document locale instead of the runtime default', () => {
     document.documentElement.lang = 'de-DE'
     const item = record('localized', 'at', START + 3_600_000)
@@ -216,14 +231,16 @@ describe('ScheduleCatalogAction rows', () => {
 describe('ScheduleCatalogAction dismissal', () => {
   const active = [record('active', 'after', START + 60_000)]
 
-  it('closes on Escape, restores trigger focus, and ignores unrelated or closed keys', () => {
-    render(<ScheduleCatalogAction {...props(active)} />)
-    const trigger = screen.getByRole('button')
+  it('closes on Escape after focus leaves the catalog and restores trigger focus', () => {
+    render(<><ScheduleCatalogAction {...props(active)} /><button type="button">Sibling</button></>)
+    const trigger = screen.getByRole('button', { name: '1 reminder' })
+    const sibling = screen.getByRole('button', { name: 'Sibling' })
     fireEvent.keyDown(trigger, { key: 'Escape' })
     fireEvent.click(trigger)
     fireEvent.keyDown(trigger, { key: 'ArrowDown' })
     expect(trigger.getAttribute('aria-expanded')).toBe('true')
-    fireEvent.keyDown(trigger, { key: 'Escape' })
+    sibling.focus()
+    fireEvent.keyDown(sibling, { key: 'Escape' })
     expect(trigger.getAttribute('aria-expanded')).toBe('false')
     expect(document.activeElement).toBe(trigger)
   })