fix(schedule): preserve late views during pagination

This commit is contained in:
pku-xht
2026-08-06 04:47:31 +08:00
committed by Tianyi Cui
parent 75d9676a77
commit acbeaf2d74
6 changed files with 143 additions and 64 deletions
@@ -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
@@ -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
@@ -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 if (this.openState !== 'open' || !this.hasMore || this.loadingOlder !== null) return
const generation = this.openGeneration const loading: OlderPageLoad = { beforeSeq: this.baseSeq, views: new Map() }
const requestedBaseSeq = this.baseSeq this.loadingOlder = loading
this.loadingOlder = true
this.notifier.markDirty() this.notifier.markDirty()
try { try {
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) const { result } = await this.history({ beforeSeq: loading.beforeSeq, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return 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] let settled: HistoryEntry[]
this.views = [...older.map(entry => entry.view), ...this.views] 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) { if (this.loadingOlder === loading) {
this.loadingOlder = false 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; /** Land a live session/event (open/repair in flight -> buffer; retained overlap -> validate
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an * and upgrade; an overlap below the window waits only for its in-flight older page). A seq gap
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous * buffers and repulls the tail instead of appending a hole (audit S3: a gap is an expected
* raw range, which is what lets the transcript render every event between its ends and lets a * reconnect-window artifact, repaired by refetch). The window stays one contiguous raw range,
* compaction checkpoint find its cited summary event. */ * 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
View File
@@ -186,43 +186,94 @@ describe('late event views', () => {
}) })
}) })
it.each(['success', 'rejection', 'empty', 'discontinuous'] as const)( it('upgrades a retained view during loadOlder and preserves it across prepend', async () => {
'settles a late overlap after loadOlder %s', const { api, session } = makeSession()
async (outcome) => { const target = reminderEvent(9, 'schedule-loading-older')
const { api, session } = makeSession() const newer = [...logRange(6, 9), target, ...logRange(10, 12)]
const newer = logRange(6, 12) api.onHistory = () => histResponse(newer, true)
const target = newer[3]! await session.open()
api.onHistory = () => histResponse(newer, true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>() const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise api.onHistory = () => gate.promise
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const loading = session.loadOlder()
try { session.handleMuxEnvelope('late' as never, {
const loading = session.loadOlder() type: 'session/event', sessionId: SID, event: target,
session.handleMuxEnvelope('late' as never, { view: reminderView('schedule-loading-older'),
type: 'session/event', sessionId: SID, event: target, })
view: reminderView(`schedule-${outcome}`), expect(session.getSnapshot().nodes).toMatchObject([{
}) kind: 'presented-event', seq: target.seq,
if (outcome === 'success') { view: { id: 'schedule-loading-older' },
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: {} })) gate.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
} else if (outcome === 'empty') { await loading
gate.resolve(ok({ events: [], hasMore: false })) expect(session.getSnapshot().nodes).toMatchObject([{
} else { kind: 'presented-event', seq: target.seq,
gate.resolve(ok({ events: entries(logRange(0, 2)) as never[], hasMore: true })) view: { id: 'schedule-loading-older' },
} }])
await loading })
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq, it('keeps a late view for the raw event returned by an in-flight older page', async () => {
view: { id: `schedule-${outcome}` }, const { api, session } = makeSession()
}]) const target = reminderEvent(3, 'schedule-older-page')
} finally { const older = [...logRange(0, 3), target, ...logRange(4, 6)]
errorSpy.mockRestore() 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
View File
@@ -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.md: c7b1934cd5a8e6ac3e3cc022ec67a948ec538786
README.zh.md: 8acc33cc397f8e0ac4bee016007d635d0021c309 README.zh.md: 8ba09550e1506ad9233827ec4e95fc35fa8d7c0d
+1 -1
View File
@@ -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。