Răsfoiți Sursa

fix(web): discard projection state across host generations

_Kerman 1 săptămână în urmă
părinte
comite
f3332af2a4

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.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-17-durable-web-queue-recovery.md
-2026-08-17-durable-web-queue-recovery.md: ffc699c7bc793da10e1f261be9ff6f7cfe079c59
-2026-08-17-durable-web-queue-recovery.zh.md: 789131c3ddfa944e43fccd71ac2ad4c39a418882
+2026-08-17-durable-web-queue-recovery.md: 360be105a9ce702d97c9254e341eeb13cd228f61
+2026-08-17-durable-web-queue-recovery.zh.md: 221ae628f02ee63a99aeb608721b6a94beffc199

+ 3 - 1
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md

@@ -18,6 +18,8 @@ The projection registry owns both durable reconstruction and live state. Its exi
 
 The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block on history tail pages, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration.
 
+Each Host connection reset discards every retained projection value and watermark before refreshing queries, including cold Sessions absent from the process-local control baseline. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Clearing at connection reset also preserves fresh list values when the control baseline arrives later.
+
 The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection.
 
 `session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before mutating its Inbox. A restored pending row therefore remains editable, removable, or steerable after restart, while subagent ownership keeps the same fence as other Agent operations.
@@ -28,7 +30,7 @@ No new session event or on-disk format is introduced. The existing splice stream
 
 Inbox tests prove that service creation restores both lists through the registered projection, a direct durable append is immediately visible through the same live cell, a fork projects pending input inherited in its seed, and Inbox mutations reject duplicate pending identities before append. Host projection coverage reads a detached persisted Session with a pending splice, returns `values.inbox` through `session.history`, and proves that no live Agent is required. A separate cold-operation test proves `session.updateQueue` resumes the Session and appends the durable removal splice.
 
-Client coverage pins generic Inbox projection delivery, reconnect truncation, higher-seq retention before Session materialization, and the absence of queue state from `SessionSnapshot`. UI coverage pins direct `next-turn` QueueDock rendering and user-origin `next-step` ChatView rendering. The keyless Web fixture opens a cold persisted Session and observes its pending row after restart.
+Client coverage pins generic Inbox projection delivery, reconnect invalidation for omitted cold Sessions, both baseline arrival orders, obsolete list request outcomes, higher-seq retention before Session materialization, and the absence of queue state from `SessionSnapshot`. UI coverage pins direct `next-turn` QueueDock rendering and user-origin `next-step` ChatView rendering. The keyless Web fixture opens a cold persisted Session and observes its pending row after restart.
 
 ## Alternatives considered
 

+ 3 - 1
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md

@@ -18,6 +18,8 @@ Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 We
 
 通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在历史尾页中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。
 
+每次 Host 连接重置都会先丢弃所有保留的投影值及其水位,再刷新查询,其中也包括进程本地 control baseline 中没有列出的冷 Session。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。在连接重置时清理,也能在 control baseline 较晚到达时保留新 list 值。
+
 客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。
 
 `session.updateQueue` 在修改 Inbox 前通过共享 Agent 解析器解析普通冷 Session。因此,恢复出的待处理行在重启后仍可编辑、移除或 steering,而 subagent ownership 保持与其他 Agent 操作相同的 fence。
@@ -28,7 +30,7 @@ Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 We
 
 Inbox 测试证明服务创建会通过已注册投影恢复两份列表、直接追加的持久事件会立即通过同一 live cell 可见、fork 会投影其 seed 中继承的待处理输入,并且 Inbox 变更会在 append 前拒绝重复的待处理标识。Host 投影覆盖会读取包含待处理 splice 的已分离持久 Session,经 `session.history` 返回 `values.inbox`,并证明不需要 live Agent。另一项冷操作测试证明 `session.updateQueue` 会恢复 Session 并追加持久删除 splice。
 
-客户端覆盖固定通用 Inbox 投影投递、重连截断、Session 实例化前保留 seq 更高的值,以及 `SessionSnapshot` 不含 queue 状态。UI 覆盖固定 QueueDock 直接渲染 `next-turn`,以及 ChatView 渲染用户来源的 `next-step`。无密钥 Web fixture 会打开一份冷持久 Session,并在重启后观察其待处理行。
+客户端覆盖固定通用 Inbox 投影投递、重连时清理遗漏冷 Session 的旧值、基线的两种到达顺序、过期 list 请求的结果、Session 实例化前保留 seq 更高的值,以及 `SessionSnapshot` 不含 queue 状态。UI 覆盖固定 QueueDock 直接渲染 `next-turn`,以及 ChatView 渲染用户来源的 `next-step`。无密钥 Web fixture 会打开一份冷持久 Session,并在重启后观察其待处理行。
 
 ## 考虑过的替代方案
 

+ 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: dc1b2a4cf8c322e9cfd9981fd4adc29d1dc544d5
-README.zh.md: 118786581b2611f2dae19cdbb6b07dee91df48d0
+README.md: 35b00005d15086b5b9f2ba07d6af028ea6a248b7
+README.zh.md: 30013b27e03e6a65a58c5b51faf36ab7312084f4

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
packages/api/session-controller/README.md


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
packages/api/session-controller/README.zh.md


+ 14 - 5
packages/api/session-controller/src/client/sessions/manager.ts

@@ -114,7 +114,7 @@ export class SessionManager {
   private listPhase: SessionListPhase = 'pending'
   private listError: RemoteFailure | null = null
   private listInflight: Promise<void> | null = null
-  /** Mutations arriving after a list request starts are replayed over its response. */
+  /** Active list request's mutation log; its identity also fences completion after reconnect. */
   private listMutations: SessionListMutation[] | null = null
   private readonly addresses = new Map<SessionId, SubagentAddress>()
   private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
@@ -441,7 +441,7 @@ export class SessionManager {
 
   // ---- List API ----
 
-  /** Full refresh via session.list (single-flight: an in-flight call is reused). */
+  /** Full refresh via session.list (single-flight within one Host generation). */
   refreshList(): Promise<void> {
     if (this.listInflight !== null) return this.listInflight
     this.listState = 'loading'
@@ -453,6 +453,7 @@ export class SessionManager {
     this.listInflight = (async () => {
       try {
         const result = await this.remote.session.list({})
+        if (this.listMutations !== mutations) return
         if (result.ok) {
           const baseline: SessionSummary[] = this.listPhase === 'pending'
             ? [...result.value.items]
@@ -502,12 +503,15 @@ export class SessionManager {
         }
       } catch (error) {
         if (!isRemoteFailure(error)) throw error
+        if (this.listMutations !== mutations) return
         this.listState = 'error'
         this.listError = error
       } finally {
-        this.listMutations = null
-        this.listInflight = null
-        this.notifier.markDirty()
+        if (this.listMutations === mutations) {
+          this.listMutations = null
+          this.listInflight = null
+          this.notifier.markDirty()
+        }
       }
     })()
     return this.listInflight
@@ -766,9 +770,14 @@ export class SessionManager {
 
   /**
    * Repair one re-established Host-event generation with queryable baselines.
+   * Discard old projection cuts before new queries, including cold Sessions
+   * absent from the process-local control baseline.
    * Opened Session follow streams resume independently through API Gateway.
    */
   handleConnected(): void {
+    for (const store of this.projectionStores.values()) store.clear()
+    this.listMutations = null
+    this.listInflight = null
     void this.refreshList()
     const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
     if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)

+ 11 - 3
packages/api/session-controller/src/client/sessions/projection-store.ts

@@ -67,9 +67,9 @@ interface Channel {
 /**
  * One session's projection values. Framework semantics, uniform across every
  * key: a baseline seeds rows at its cut, a push frame updates one row, and in
- * both paths a lower-or-equal seq loses — a replayed frame cannot regress a
- * value, a stale baseline cannot overwrite a newer frame. A key the store has
- * never seen reads `undefined` (capability absent). Faces are identity-stable
+ * both paths a lower-or-equal seq within the Host generation loses. A replayed
+ * frame cannot regress a value; a stale baseline cannot overwrite a newer
+ * frame. A key the store has never seen reads `undefined` (capability absent). Faces are identity-stable
  * per key (create-on-demand, cached) so the React side binds each exactly
  * once; the store-level channel (`subscribeAny`) serves coarse consumers (the
  * manager's list projection reads the `title` key).
@@ -174,6 +174,14 @@ export class ProjectionValueStore {
     }
   }
 
+  /** Discard one Host generation's values and watermarks while preserving subscribed faces. */
+  clear(): void {
+    for (const key of this.rows.keys()) {
+      this.rows.delete(key)
+      this.changed(key)
+    }
+  }
+
   private changed(key: string): void {
     this.valuesCache = undefined
     this.channels.get(key)?.notifier.markDirty()

+ 45 - 1
packages/api/session-controller/tests/inbox-projection.client.spec.ts

@@ -7,7 +7,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { SessionManager } from '../src/client/sessions/manager.ts'
-import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
+import { deferred, FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
 
 const SID = 'fk-q1' as SessionId
 const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
@@ -63,6 +63,50 @@ describe('Inbox projection intake', () => {
     expect(manager.get(SID).projections.faceOf('inbox').getSnapshot()).toBeUndefined()
   })
 
+  it.each(['control-first', 'list-first'] as const)(
+    'replaces cold Session Inbox values across Host generations (%s)',
+    async (order) => {
+      const api = new FakeApiClient()
+      const list = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+      api.onList = () => list.promise
+      const manager = new SessionManager(fakeRemote(api))
+      const hiddenSessionId = 'cold-hidden-inbox' as SessionId
+      const ghost = message('ghost', 'acceptance was not persisted')
+      const pending = message('pending', 'claim was not persisted')
+      const empty = { 'next-turn': [], 'next-step': [] }
+      const restored = { 'next-turn': [pending], 'next-step': [] }
+      manager.handleControlFrame({ ...inboxFrame({ ...empty, 'next-turn': [ghost] }), seq: 20 })
+      manager.handleControlFrame({ ...inboxFrame(empty), sessionId: hiddenSessionId, seq: 20 })
+      const face = manager.get(SID).projections.faceOf('inbox')
+      const baseline = { type: 'baseline', value: { jobs: {}, projections: {} } } as const
+      const result = ok({ items: [
+        { sessionId: SID, updatedAt: 1, running: false, blank: false,
+          projections: { asOfSeq: 1, values: { inbox: empty } } },
+        { sessionId: hiddenSessionId, updatedAt: 1, running: false, blank: false,
+          projections: { asOfSeq: 1, values: { inbox: restored } } },
+      ] }) as Awaited<ReturnType<FakeApiClient['onList']>>
+      let refreshed: Promise<void> | undefined
+
+      try {
+        manager.handleConnected()
+        refreshed = manager.refreshList()
+        expect(face.getSnapshot()).toBeUndefined()
+        if (order === 'control-first') manager.handleControlFrame(baseline)
+        list.resolve(result)
+        await refreshed
+        if (order === 'list-first') manager.handleControlFrame(baseline)
+
+        expect(manager.get(SID).projections.faceOf('inbox')).toBe(face)
+        expect(face.getSnapshot()).toEqual(empty)
+        expect(manager.get(hiddenSessionId).projections.faceOf('inbox').getSnapshot()).toEqual(restored)
+      } finally {
+        list.resolve(result)
+        await refreshed
+        await manager.dispose()
+      }
+    },
+  )
+
   it('retains only the highest-seq value received before Session materialization', () => {
     const manager = new SessionManager(fakeRemote(new FakeApiClient()))
     manager.handleControlFrame(inboxFrame({

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

@@ -751,6 +751,76 @@ describe('remaining branches', () => {
 })
 
 describe('connected generation', () => {
+  it.each(['old-first', 'new-first'] as const)(
+    'ignores a previous generation list response (%s)',
+    async (order) => {
+      const api = new FakeApiClient()
+      const oldList = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+      const newList = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+      let calls = 0
+      api.onList = () => calls++ === 0 ? oldList.promise : newList.promise
+      const manager = new SessionManager(fakeRemote(api))
+      const oldResult = ok({ items: [{ ...summary(S1), projections: {
+        asOfSeq: 20, values: { title: 'Unpersisted title' },
+      } }] as never[] })
+      const newResult = ok({ items: [{ ...summary(S1), projections: {
+        asOfSeq: 1, values: { title: 'Durable title' },
+      } }] as never[] })
+      const oldPull = manager.refreshList()
+      let newPull: Promise<void> | undefined
+      try {
+        manager.handleConnected()
+        newPull = manager.refreshList()
+        expect(api.callsOf('session.list')).toHaveLength(2)
+        if (order === 'old-first') {
+          oldList.resolve(oldResult)
+          await oldPull
+          expect(manager.getListSnapshot().state).toBe('loading')
+          expect(manager.refreshList()).toBe(newPull)
+        }
+        newList.resolve(newResult)
+        await newPull
+        oldList.resolve(oldResult)
+        await oldPull
+
+        expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', error: null })
+        expect(manager.getListSnapshot().items[0]?.title).toBe('Durable title')
+      } finally {
+        oldList.resolve(oldResult)
+        newList.resolve(newResult)
+        await Promise.all([oldPull, newPull])
+        await manager.dispose()
+      }
+    },
+  )
+
+  it('ignores a previous generation request failure while the new list is loading', async () => {
+    const api = new FakeApiClient()
+    const oldList = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+    const newList = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+    let calls = 0
+    api.onList = () => calls++ === 0 ? oldList.promise : newList.promise
+    const manager = new SessionManager(fakeRemote(api))
+    const oldPull = manager.refreshList()
+    let newPull: Promise<void> | undefined
+    try {
+      manager.handleConnected()
+      newPull = manager.refreshList()
+      oldList.reject(new RemoteError('gateway/internal', 'old Host disconnected', {}))
+      await oldPull
+      expect(manager.getListSnapshot()).toMatchObject({ state: 'loading', error: null })
+      expect(manager.refreshList()).toBe(newPull)
+      newList.resolve(ok({ items: [summary(S1)] as never[] }))
+      await newPull
+      expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', error: null })
+    } finally {
+      oldList.resolve(ok({ items: [] }))
+      newList.resolve(ok({ items: [] }))
+      await Promise.all([oldPull, newPull])
+      await manager.dispose()
+    }
+  })
+
   it('refreshes query baselines without rebuilding independently resumed Session sources', async () => {
     const api = new FakeApiClient()
     api.onHistory = () => Promise.resolve(ok({

+ 26 - 0
packages/api/session-controller/tests/projection-store.client.spec.ts

@@ -68,6 +68,32 @@ describe('Session projection value semantics', () => {
     expect(store.get('other')).toBeUndefined()
   })
 
+  it('clears all generation watermarks without replacing subscribed faces', async () => {
+    const store = new ProjectionValueStore()
+    const face = store.faceOf('test/marks')
+    const observed: unknown[] = []
+    const unsubscribe = face.subscribe(() => { observed.push(face.getSnapshot()) })
+    try {
+      store.apply('test/marks', { marks: ['lost-tail'] }, SessionSeq(20))
+      store.apply('empty-session', 'old generation', -1)
+      const previous = store.values()
+      await Promise.resolve()
+      store.clear()
+      await Promise.resolve()
+
+      expect(face.getSnapshot()).toBeUndefined()
+      expect(store.get('empty-session')).toBeUndefined()
+      expect(store.faceOf('test/marks')).toBe(face)
+      expect(store.values()).toEqual({})
+      expect(store.values()).not.toBe(previous)
+      store.seed({ asOfSeq: SessionSeq(1), values: { 'test/marks': { marks: ['durable'] } } })
+      await Promise.resolve()
+      expect(observed).toEqual([{ marks: ['lost-tail'] }, undefined, { marks: ['durable'] }])
+    } finally {
+      unsubscribe()
+    }
+  })
+
   it('notifies the key face on change (batched) and not on dropped applications', async () => {
     const store = new ProjectionValueStore()
     let keyTicks = 0

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff