Browse Source

fix(schedule): preserve late views during pagination

pku-xht 2 months ago
parent
commit
acbeaf2d74

+ 1 - 1
.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md

@@ -55,7 +55,7 @@ The Host continues to send every raw event on append. It keeps one monotonic wat
 
 
 Attached history independently inspects persistence and adds views only to a stored event prefix whose header identity and every event match the live Session. Persistence canonically writes absent top-level `delegationDepth` as zero, so those two forms are identity-equivalent; cwd, lineage, origin, timestamps, version, id, and every event still match exactly. Missing, failed, divergent, or longer inspection withholds the view while returning raw history. Detached history is already a persisted prefix. A parent dispatch copied into a fork seed therefore appears in child history only after child storage proves that prefix.
 Attached history independently inspects persistence and adds views only to a stored event prefix whose header identity and every event match the live Session. Persistence canonically writes absent top-level `delegationDepth` as zero, so those two forms are identity-equivalent; cwd, lineage, origin, timestamps, version, id, and every event still match exactly. Missing, failed, divergent, or longer inspection withholds the view while returning raw history. Detached history is already a persisted prefix. A parent dispatch copied into a fork seed therefore appears in child history only after child storage proves that prefix.
 
 
-The browser Session accepts a repeated seq only when the durable event is deeply identical, then upgrades the sidecar immediately without appending another event. Tail loading and true gap repair retain uncovered events in the existing `liveBuffer`; ordinary older-page pagination keeps receiving live tail events in the current arrays and prepends its page after the await. Reconnect generations prevent stale page or repair results and `finally` blocks from touching the rebuilt window. `TranscriptAdapter` creates a generic `PresentedEventNode` keyed by the durable event type. `ui-conversation` dispatches it through `conversation.chat.eventview` and retains an expandable JSON fallback, while `ui-schedule` owns the bilingual `schedule/change` reminder row.
+The browser Session accepts a repeated seq only when the durable event is deeply identical, then upgrades the sidecar immediately without appending another event. Tail loading and true gap repair retain uncovered events in the existing `liveBuffer`; ordinary older-page pagination keeps receiving live tail events in the current arrays, while a sidecar below the current window stays with the in-flight page and attaches only when that page returns the identical event. Reconnect generations prevent stale page or repair results and `finally` blocks from touching the rebuilt window. `TranscriptAdapter` creates a generic `PresentedEventNode` keyed by the durable event type. `ui-conversation` dispatches it through `conversation.chat.eventview` and retains an expandable JSON fallback, while `ui-schedule` owns the bilingual `schedule/change` reminder row.
 
 
 ```text
 ```text
 schedule_create → Session create event → persistence
 schedule_create → Session create event → persistence

+ 1 - 1
.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md

@@ -55,7 +55,7 @@ Host 在 append 时继续发送所有 raw event。它在 `WeakMap` 中按 exact
 
 
 已附加 history 会独立 inspect persistence,只有 stored event prefix 的 header identity 与每个 event 都和 live Session 匹配时才添加 view。persistence 会把顶层缺失的 `delegationDepth` 规范写成零,因此两种形式在身份上等价;cwd、lineage、origin、时间戳、版本、id 与每个 event 仍必须精确匹配。inspect 缺失、失败、分歧或比 live 更长时,只会省略 view,raw history 仍然返回。已分离 history 本身就是持久前缀。因此复制进 fork seed 的 parent dispatch 只有在 child storage 证明该前缀后才会显示。
 已附加 history 会独立 inspect persistence,只有 stored event prefix 的 header identity 与每个 event 都和 live Session 匹配时才添加 view。persistence 会把顶层缺失的 `delegationDepth` 规范写成零,因此两种形式在身份上等价;cwd、lineage、origin、时间戳、版本、id 与每个 event 仍必须精确匹配。inspect 缺失、失败、分歧或比 live 更长时,只会省略 view,raw history 仍然返回。已分离 history 本身就是持久前缀。因此复制进 fork seed 的 parent dispatch 只有在 child storage 证明该前缀后才会显示。
 
 
-浏览器 Session 只有在 durable event 深度一致时才接受重复 seq,随后立即升级 sidecar,不再追加 event。只有尾部加载与真正的 gap repair 才会将尚未覆盖的事件保留在既有 `liveBuffer` 中;普通旧页分页会让当前数组继续接收 live tail 事件,并在 await 后再前插该页。重连 generation 会阻止陈旧的 page 或 repair 结果以及 `finally` 块触碰重建后的 window。`TranscriptAdapter` 创建按持久事件类型键控的通用 `PresentedEventNode`。`ui-conversation` 通过 `conversation.chat.eventview` 分发,并保留可展开 JSON fallback;`ui-schedule` 则拥有双语 `schedule/change` 提醒行。
+浏览器 Session 只有在 durable event 深度一致时才接受重复 seq,随后立即升级 sidecar,不再追加 event。只有尾部加载与真正的 gap repair 才会将尚未覆盖的事件保留在既有 `liveBuffer` 中;普通旧页分页会让当前数组继续接收 live tail 事件,当前 window 以下的 sidecar 则由 in-flight page 自身暂存,只有该页返回身份完全相同的事件时才附着。重连 generation 会阻止陈旧的 page 或 repair 结果以及 `finally` 块触碰重建后的 window。`TranscriptAdapter` 创建按持久事件类型键控的通用 `PresentedEventNode`。`ui-conversation` 通过 `conversation.chat.eventview` 分发,并保留可展开 JSON fallback;`ui-schedule` 则拥有双语 `schedule/change` 提醒行。
 
 
 ```text
 ```text
 schedule_create → Session create event → persistence
 schedule_create → Session create event → persistence

+ 51 - 23
packages/client/runtime/src/client/sessions/session.ts

@@ -98,6 +98,12 @@ function assertSameEvent(left: SessionEvent, right: SessionEvent): void {
   }
   }
 }
 }
 
 
+/** One in-flight older-page request and the late sidecars that may belong to its result. */
+interface OlderPageLoad {
+  readonly beforeSeq: number
+  readonly views: Map<number, { event: SessionEvent; view: SessionEventView }>
+}
+
 /**
 /**
  * Owns a session's event window, derived conversation state, and observable
  * Owns a session's event window, derived conversation state, and observable
  * snapshot. React bindings remain outside this data layer. Features see only
  * snapshot. React bindings remain outside this data layer. Features see only
@@ -119,7 +125,7 @@ export class Session implements SessionFace {
    *  a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
    *  a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
    *  passes drop all writes once the generation moves on. */
    *  passes drop all writes once the generation moves on. */
   private openGeneration = 0
   private openGeneration = 0
-  private loadingOlder = false
+  private loadingOlder: OlderPageLoad | null = null
   private readonly transcript = new TranscriptAdapter()
   private readonly transcript = new TranscriptAdapter()
   private partial: PartialAccumulator | null = null
   private partial: PartialAccumulator | null = null
   private openCalls = new Map<string, RunningToolCall>()
   private openCalls = new Map<string, RunningToolCall>()
@@ -390,14 +396,13 @@ export class Session implements SessionFace {
 
 
   /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
   /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
   async loadOlder(): Promise<void> {
   async loadOlder(): Promise<void> {
-    if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
-    const generation = this.openGeneration
-    const requestedBaseSeq = this.baseSeq
-    this.loadingOlder = true
+    if (this.openState !== 'open' || !this.hasMore || this.loadingOlder !== null) return
+    const loading: OlderPageLoad = { beforeSeq: this.baseSeq, views: new Map() }
+    this.loadingOlder = loading
     this.notifier.markDirty()
     this.notifier.markDirty()
     try {
     try {
-      const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
-      if (generation !== this.openGeneration) return
+      const { result } = await this.history({ beforeSeq: loading.beforeSeq, maxMessages: PAGE_MESSAGES })
+      if (this.loadingOlder !== loading) return
       if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
       if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
       const older = result.value.events
       const older = result.value.events
       if (older.length === 0) {
       if (older.length === 0) {
@@ -405,26 +410,39 @@ export class Session implements SessionFace {
         return
         return
       }
       }
       const tail = older[older.length - 1]
       const tail = older[older.length - 1]
-      if (tail === undefined || tail.event.seq + 1 !== requestedBaseSeq) {
+      if (tail === undefined || tail.event.seq + 1 !== loading.beforeSeq) {
         // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
         // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
-        console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${requestedBaseSeq}`)
+        console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${loading.beforeSeq}`)
         this.hasMore = false
         this.hasMore = false
         return
         return
       }
       }
-      this.events = [...older.map(entry => entry.event), ...this.events]
-      this.views = [...older.map(entry => entry.view), ...this.views]
+      let settled: HistoryEntry[]
+      try {
+        settled = older.map((entry): HistoryEntry => {
+          const late = loading.views.get(entry.event.seq)
+          if (late === undefined) return entry
+          assertSameEvent(entry.event, late.event)
+          return { ...entry, view: late.view }
+        })
+      } catch (error) {
+        console.error('[web-runtime] older-page session event failed identity validation:', error)
+        void this.resync()
+        return
+      }
+      this.events = [...settled.map(entry => entry.event), ...this.events]
+      this.views = [...settled.map(entry => entry.view), ...this.views]
       /* v8 ignore next -- the empty-page branch returned above. */
       /* v8 ignore next -- the empty-page branch returned above. */
       this.baseSeq = older[0]?.event.seq ?? this.baseSeq
       this.baseSeq = older[0]?.event.seq ?? this.baseSeq
       this.hasMore = result.value.hasMore
       this.hasMore = result.value.hasMore
       this.transcript.reset(this.events, this.views)
       this.transcript.reset(this.events, this.views)
       this.rebuildDerivedFromWindow()
       this.rebuildDerivedFromWindow()
     } catch (error) {
     } catch (error) {
-      if (generation === this.openGeneration) {
+      if (this.loadingOlder === loading) {
         console.error('[web-runtime] loadOlder failed:', error)
         console.error('[web-runtime] loadOlder failed:', error)
       }
       }
     } finally {
     } finally {
-      if (generation === this.openGeneration) {
-        this.loadingOlder = false
+      if (this.loadingOlder === loading) {
+        this.loadingOlder = null
         if (this.liveBuffer.length > 0) void this.repairGap()
         if (this.liveBuffer.length > 0) void this.repairGap()
         this.notifier.markDirty()
         this.notifier.markDirty()
       }
       }
@@ -455,7 +473,7 @@ export class Session implements SessionFace {
     this.pendingRev++
     this.pendingRev++
     this.subscribedLastSeq = null
     this.subscribedLastSeq = null
     this.liveBuffer = []
     this.liveBuffer = []
-    this.loadingOlder = false
+    this.loadingOlder = null
     this.stitching = false
     this.stitching = false
     this.notifier.markDirty()
     this.notifier.markDirty()
     await this.open()
     await this.open()
@@ -836,11 +854,12 @@ export class Session implements SessionFace {
     this.queueRev++
     this.queueRev++
   }
   }
 
 
-  /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
-   *  a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
-   *  expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
-   *  raw range, which is what lets the transcript render every event between its ends and lets a
-   *  compaction checkpoint find its cited summary event. */
+  /** Land a live session/event (open/repair in flight -> buffer; retained overlap -> validate
+   *  and upgrade; an overlap below the window waits only for its in-flight older page). A seq gap
+   *  buffers and repulls the tail instead of appending a hole (audit S3: a gap is an expected
+   *  reconnect-window artifact, repaired by refetch). The window stays one contiguous raw range,
+   *  which lets the transcript render every event between its ends and a compaction checkpoint
+   *  find its cited summary event. */
   private acceptLiveEvent(event: SessionEvent, view?: SessionEventView): void {
   private acceptLiveEvent(event: SessionEvent, view?: SessionEventView): void {
     if (this.openState === 'loading' || this.stitching) {
     if (this.openState === 'loading' || this.stitching) {
       this.liveBuffer.push({ event, view })
       this.liveBuffer.push({ event, view })
@@ -850,6 +869,15 @@ export class Session implements SessionFace {
     const tailSeq = this.windowTailSeq()
     const tailSeq = this.windowTailSeq()
     if (tailSeq !== null && event.seq <= tailSeq) {
     if (tailSeq !== null && event.seq <= tailSeq) {
       try {
       try {
+        if (event.seq < this.baseSeq) {
+          const loading = this.loadingOlder
+          if (loading !== null && view !== undefined && event.seq < loading.beforeSeq) {
+            const retained = loading.views.get(event.seq)
+            if (retained !== undefined) assertSameEvent(retained.event, event)
+            loading.views.set(event.seq, { event, view })
+          }
+          return
+        }
         const changed = this.upgradeLiveView(event, view)
         const changed = this.upgradeLiveView(event, view)
         if (changed) this.notifier.markDirty()
         if (changed) this.notifier.markDirty()
       } catch (error) {
       } catch (error) {
@@ -860,12 +888,12 @@ export class Session implements SessionFace {
     }
     }
     if (tailSeq !== null && event.seq > tailSeq + 1) {
     if (tailSeq !== null && event.seq > tailSeq + 1) {
       this.liveBuffer.push({ event, view })
       this.liveBuffer.push({ event, view })
-      if (!this.loadingOlder) void this.repairGap()
+      if (this.loadingOlder === null) void this.repairGap()
       return
       return
     }
     }
     if (tailSeq === null && event.seq !== 0) {
     if (tailSeq === null && event.seq !== 0) {
       this.liveBuffer.push({ event, view })
       this.liveBuffer.push({ event, view })
-      if (!this.loadingOlder) void this.repairGap()
+      if (this.loadingOlder === null) void this.repairGap()
       return
       return
     }
     }
     this.appendLive(event, view)
     this.appendLive(event, view)
@@ -1148,7 +1176,7 @@ export class Session implements SessionFace {
       openState: this.openState,
       openState: this.openState,
       openError: this.openError,
       openError: this.openError,
       hasMore: this.hasMore,
       hasMore: this.hasMore,
-      loadingOlder: this.loadingOlder,
+      loadingOlder: this.loadingOlder !== null,
       promptError: this.promptError,
       promptError: this.promptError,
       blank: this.blankBit,
       blank: this.blankBit,
       lastAgentError: this.lastAgentError,
       lastAgentError: this.lastAgentError,

+ 87 - 36
packages/client/runtime/tests/session.spec.ts

@@ -186,43 +186,94 @@ describe('late event views', () => {
     })
     })
   })
   })
 
 
-  it.each(['success', 'rejection', 'empty', 'discontinuous'] as const)(
-    'settles a late overlap after loadOlder %s',
-    async (outcome) => {
-      const { api, session } = makeSession()
-      const newer = logRange(6, 12)
-      const target = newer[3]!
-      api.onHistory = () => histResponse(newer, true)
-      await session.open()
+  it('upgrades a retained view during loadOlder and preserves it across prepend', async () => {
+    const { api, session } = makeSession()
+    const target = reminderEvent(9, 'schedule-loading-older')
+    const newer = [...logRange(6, 9), target, ...logRange(10, 12)]
+    api.onHistory = () => histResponse(newer, true)
+    await session.open()
 
 
-      const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
-      api.onHistory = () => gate.promise
-      const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
-      try {
-        const loading = session.loadOlder()
-        session.handleMuxEnvelope('late' as never, {
-          type: 'session/event', sessionId: SID, event: target,
-          view: reminderView(`schedule-${outcome}`),
-        })
-        if (outcome === 'success') {
-          gate.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
-        } else if (outcome === 'rejection') {
-          gate.resolve(err({ code: 'internal', message: 'page rejected', details: {} }))
-        } else if (outcome === 'empty') {
-          gate.resolve(ok({ events: [], hasMore: false }))
-        } else {
-          gate.resolve(ok({ events: entries(logRange(0, 2)) as never[], hasMore: true }))
-        }
-        await loading
-        expect(session.getSnapshot().nodes).toMatchObject([{
-          kind: 'presented-event', seq: target.seq,
-          view: { id: `schedule-${outcome}` },
-        }])
-      } finally {
-        errorSpy.mockRestore()
-      }
-    },
-  )
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => gate.promise
+    const loading = session.loadOlder()
+    session.handleMuxEnvelope('late' as never, {
+      type: 'session/event', sessionId: SID, event: target,
+      view: reminderView('schedule-loading-older'),
+    })
+    expect(session.getSnapshot().nodes).toMatchObject([{
+      kind: 'presented-event', seq: target.seq,
+      view: { id: 'schedule-loading-older' },
+    }])
+
+    gate.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
+    await loading
+    expect(session.getSnapshot().nodes).toMatchObject([{
+      kind: 'presented-event', seq: target.seq,
+      view: { id: 'schedule-loading-older' },
+    }])
+  })
+
+  it('keeps a late view for the raw event returned by an in-flight older page', async () => {
+    const { api, session } = makeSession()
+    const target = reminderEvent(3, 'schedule-older-page')
+    const older = [...logRange(0, 3), target, ...logRange(4, 6)]
+    api.onHistory = () => histResponse(logRange(6, 12), true)
+    await session.open()
+
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => gate.promise
+    const loading = session.loadOlder()
+    session.handleMuxEnvelope('late' as never, {
+      type: 'session/event', sessionId: SID, event: target,
+      view: reminderView('schedule-older-page'),
+    })
+    session.handleMuxEnvelope('later' as never, {
+      type: 'session/event', sessionId: SID, event: target,
+      view: reminderView('schedule-older-page', '检查更新'),
+    })
+    expect(session.getSnapshot().nodes).toEqual([])
+
+    gate.resolve(ok({ events: entries(older) as never[], hasMore: false }))
+    await loading
+    expect(session.getSnapshot().nodes).toMatchObject([{
+      kind: 'presented-event', seq: target.seq,
+      view: { id: 'schedule-older-page', prompt: '检查更新' },
+    }])
+  })
+
+  it('resyncs when an older page disagrees with its buffered late event identity', async () => {
+    const { api, session } = makeSession()
+    const newer = logRange(6, 12)
+    const pageEvent = reminderEvent(3, 'schedule-page')
+    const delivered = reminderEvent(3, 'schedule-delivered')
+    const older = [...logRange(0, 3), pageEvent, ...logRange(4, 6)]
+    api.onHistory = () => histResponse(newer, true)
+    await session.open()
+
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => gate.promise
+    const loading = session.loadOlder()
+    session.handleMuxEnvelope('late' as never, {
+      type: 'session/event', sessionId: SID, event: delivered,
+      view: reminderView('schedule-delivered'),
+    })
+    api.onHistory = () => histResponse(newer)
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      gate.resolve(ok({ events: entries(older) as never[], hasMore: false }))
+      await loading
+      await vi.waitFor(() => {
+        expect(api.callsOf('session.history')).toHaveLength(3)
+        expect(session.getSnapshot().openState).toBe('open')
+      })
+      expect(errorSpy).toHaveBeenCalledWith(
+        '[web-runtime] older-page session event failed identity validation:',
+        expect.objectContaining({ message: 'session event identity mismatch at seq 3' }),
+      )
+    } finally {
+      errorSpy.mockRestore()
+    }
+  })
 })
 })
 
 
 
 

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md
-README.md: 7a37baf92c6e050e628d97d5926b08bae295137b
-README.zh.md: 8acc33cc397f8e0ac4bee016007d635d0021c309
+README.md: c7b1934cd5a8e6ac3e3cc022ec67a948ec538786
+README.zh.md: 8ba09550e1506ad9233827ec4e95fc35fa8d7c0d

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

@@ -2,7 +2,7 @@
 
 
 [English](README.md) | 中文
 [English](README.md) | 中文
 
 
-用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册持久事件类型 `schedule/change`。通用 runtime 继续携带持久事件身份与 Host 计算的 JSON sidecar;本包只拥有 Schedule 卡片。
+用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册持久事件类型 `schedule/change`。通用运行时继续携带持久事件身份与 Host 计算的 JSON sidecar;本包只拥有 Schedule 卡片。
 
 
 卡片显示提醒原文、Session 内的 Schedule ID、精确 UTC 发生时刻,以及 `session-local` 交付边界。若 sidecar 损坏或版本不兼容,组件会显示受控的不可用回执,而不会让会话崩溃。卸载插件只会移除该键控 renderer;`ui-conversation` 随后仍会为同一个持久事件显示通用且可见的 JSON fallback。
 卡片显示提醒原文、Session 内的 Schedule ID、精确 UTC 发生时刻,以及 `session-local` 交付边界。若 sidecar 损坏或版本不兼容,组件会显示受控的不可用回执,而不会让会话崩溃。卸载插件只会移除该键控 renderer;`ui-conversation` 随后仍会为同一个持久事件显示通用且可见的 JSON fallback。