Просмотр исходного кода

Merge pull request #4209 from deepseek-harness/fix/issue-3479-linear-cache

fix(session-controller): reconcile list cache in linear time
imccyu 3 дней назад
Родитель
Сommit
b51eb87125

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.i18n.yaml

@@ -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-09-15-linear-session-list-cache.md
+2026-09-15-linear-session-list-cache.md: fb09950f77c224d2b85bb76792bdf8c3c87e33f5
+2026-09-15-linear-session-list-cache.zh.md: b877f0659fff4e1b3d48aa4250e634120ddc365f

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.md

@@ -0,0 +1,25 @@
+# Agent Note: Linear Session list cache reconciliation
+
+Status: implemented
+
+English | [中文](2026-09-15-linear-session-list-cache.zh.md)
+
+## Problem
+
+The Client Session list retains row objects for React reference stability. Scanning the entire new list for every cached ID makes snapshot rebuilds quadratic, including first hydration because new rows enter the cache before cleanup. Thousands of Sessions can occupy the browser thread during list updates.
+
+## Decision
+
+SessionManager builds one ID set from its reconciled rows and uses it for cache eviction and selected-row membership. Field comparisons, row identities, array reuse, lineage order, and retained subagent addresses keep their existing semantics.
+
+## Alternatives considered
+
+**Replace the row cache on every rebuild.** A new Map can also remove missing entries linearly, but requires changing the row-reuse path. A temporary membership set confines the change to membership checks.
+
+**Enforce unit-test wall-clock limits.** Shared CI load makes tight time budgets unreliable. An instance-local ID accessor counts membership reads during repeated refreshes; the original implementation exceeds the linear bound without depending on machine speed.
+
+## Consequences
+
+Reconciliation uses O(n + c) time and O(n) temporary membership storage for n current rows and c cached rows. Missing rows lose cached identity; unchanged rows and their array retain identity, and an off-list selection candidate can become current again when its row returns.
+
+Local macOS arm64 Node 26 measurements compile the production manager to JavaScript and time subscribed refreshes through the resulting list snapshot, with fresh synthetic Remote response objects. After three warmups, nine samples give median refresh times of 0.67, 1.40, 2.78, and 5.46 ms for 1,000, 2,600, 5,000, and 10,000 rows. The corresponding original medians are 4.86, 29.01, 30.62, and 459.89 ms. These measurements include list reconstruction and exclude server scanning, transport, DOM rendering, browser input, and memory measurement; they do not establish end-to-end reconnect latency. The focused manager tests cover eviction, empty lists, selection recovery, identity reuse, and the linear read bound.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Session 列表缓存的线性对账
+
+Status: implemented
+
+[English](2026-09-15-linear-session-list-cache.md) | 中文
+
+## 问题
+
+Client Session 列表保留行对象,以维持 React 引用稳定性。为每个缓存 ID 扫描整个新列表,会使快照重建成本按平方增长;首次加载也受影响,因为新行在清理之前就已进入缓存。数千个 Session 可能在列表更新时占用浏览器线程。
+
+## 决策
+
+SessionManager 从对账后的行构建一个 ID 集合,用于缓存淘汰和选中行的成员检查。字段比较、行对象标识、数组复用、派生顺序和保留的子智能体地址维持现有语义。
+
+## 考虑过的替代方案
+
+**每次重建都替换行缓存。** 新 Map 同样可以在线性时间内移除缺失条目,但需要修改行复用路径。临时成员集合把改动限定在成员检查。
+
+**在单元测试中限制实际耗时。** 共享 CI 负载会使严格时间预算不可靠。实例局部的 ID 访问器统计重复刷新时的成员读取次数;原实现超过线性上限,无需依赖机器速度。
+
+## 影响
+
+对于 n 个当前行和 c 个缓存行,对账使用 O(n + c) 时间和 O(n) 临时成员存储。缺失行失去缓存标识;未变化的行及其数组保留标识,离开列表的候选选中项在行返回后可以再次成为当前项。
+
+本地 macOS arm64 Node 26 测量将生产 manager 编译为 JavaScript,并在存在订阅时,从刷新开始计时到结果列表快照可读,Remote 响应使用新建的合成对象。预热三次后采集九个样本,1,000、2,600、5,000 和 10,000 行的刷新耗时中位数分别为 0.67、1.40、2.78 和 5.46 ms。原实现对应中位数为 4.86、29.01、30.62 和 459.89 ms。测量包含列表重建,排除服务端扫描、传输、DOM 渲染、浏览器输入和内存测量,因此不能证明端到端重连延迟。聚焦 manager 的测试覆盖淘汰、空列表、选择恢复、标识复用和线性读取上限。

+ 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: 9b2cdeed600cd597ecfab076fe98a3a334533ba8
-README.zh.md: 04ed7808dc7ea581b489921f88349479d46d8037
+README.md: 1e8089b19c2dcba4691345b44498a37b33670b64
+README.zh.md: ce406f16edea8b47bd584adfa68efea7987510c4

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/api/session-controller/README.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/api/session-controller/README.zh.md


+ 3 - 2
packages/api/session-controller/src/client/sessions/manager.ts

@@ -934,14 +934,15 @@ export class SessionManager {
       this.entryCache.set(entry.sessionId, entry)
       return entry
     })
+    const itemIds = new Set(items.map(entry => entry.sessionId))
     for (const id of this.entryCache.keys()) {
-      if (!items.some(e => e.sessionId === id)) this.entryCache.delete(id)
+      if (!itemIds.has(id)) this.entryCache.delete(id)
     }
     const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
     if (!sameOrder) this.itemsCache = items
     const selected = this.selected
     const current = selected !== undefined
-      && (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
+      && (itemIds.has(selected) || this.addresses.has(selected))
       ? selected
       : undefined
     return {

+ 58 - 0
packages/api/session-controller/tests/manager.client.spec.ts

@@ -721,6 +721,64 @@ describe('remaining branches', () => {
     expect(manager.getListSnapshot().items).toBe(after.items)
   })
 
+  it('reuses refreshed rows and evicts missing rows while retaining the selection candidate', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote, S2)
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
+    await manager.refreshList()
+    const first = manager.getListSnapshot()
+    expect(first.current).toBe(S2)
+
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
+    await manager.refreshList()
+    expect(manager.getListSnapshot().items).toBe(first.items)
+    expect(manager.getListSnapshot().current).toBe(S2)
+
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
+    await manager.refreshList()
+    expect(manager.getListSnapshot().items).toEqual([first.items[0]])
+    expect(manager.getListSnapshot().items[0]).toBe(first.items[0])
+    expect(manager.getListSnapshot().current).toBeUndefined()
+
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
+    await manager.refreshList()
+    expect(manager.getListSnapshot().items[0]).toBe(first.items[0])
+    expect(manager.getListSnapshot().items[1]).not.toBe(first.items[1])
+    expect(manager.getListSnapshot().current).toBe(S2)
+
+    remote.session.list.mockResolvedValue(ok({ items: [] as never[] }))
+    await manager.refreshList()
+    expect(manager.getListSnapshot().items).toEqual([])
+    expect(manager.getListSnapshot().current).toBeUndefined()
+
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
+    await manager.refreshList()
+    expect(manager.getListSnapshot().items[0]).not.toBe(first.items[0])
+  })
+
+  it('bounds cached-row ID reads linearly during repeated list refreshes', async ({ mock, remote }) => {
+    const count = 1_000
+    const summaries = Array.from({ length: count }, (_, i) => summary(`list-${i}` as SessionId))
+    const manager = makeManager(mock, remote, summaries[count - 1]!.sessionId)
+    remote.session.list.mockResolvedValue(ok({ items: summaries as never[] }))
+    await manager.refreshList()
+    const first = manager.getListSnapshot()
+    let reads = 0
+    // Instance-local accessors count membership work without a machine-dependent timing budget.
+    for (const entry of first.items) {
+      const id = entry.sessionId
+      Object.defineProperty(entry, 'sessionId', { get: () => { reads++; return id }, configurable: true })
+    }
+    for (let refresh = 0; refresh < 2; refresh++) {
+      reads = 0
+      remote.session.list.mockResolvedValue(ok({ items: summaries.map(item => ({ ...item })) as never[] }))
+      await manager.refreshList()
+      const snapshot = manager.getListSnapshot()
+      expect(snapshot.items).toBe(first.items)
+      expect(snapshot.current).toBe(summaries[count - 1]!.sessionId)
+      expect(reads).toBeLessThanOrEqual(count * 3)
+    }
+  })
+
   it('carries parentSessionId from the added event into the lineage row', ({ mock, remote }) => {
     const manager = makeManager(mock, remote)
     manager.handleSessionAdded(summary(S1, { blank: true }))

Некоторые файлы не были показаны из-за большого количества измененных файлов