diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index b24a816d64..b5e5703c2e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.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. -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 schedule_create → Session create event → persistence diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index 78939119b6..02d16a55aa 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.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 证明该前缀后才会显示。 -浏览器 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 schedule_create → Session create event → persistence diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index edf7b58555..8a4f62f939 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/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 +} + /** * Owns a session's event window, derived conversation state, and observable * 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 * passes drop all writes once the generation moves on. */ private openGeneration = 0 - private loadingOlder = false + private loadingOlder: OlderPageLoad | null = null private readonly transcript = new TranscriptAdapter() private partial: PartialAccumulator | null = null private openCalls = new Map() @@ -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). */ async loadOlder(): Promise { - 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() 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) const older = result.value.events if (older.length === 0) { @@ -405,26 +410,39 @@ export class Session implements SessionFace { return } 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. - 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 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. */ this.baseSeq = older[0]?.event.seq ?? this.baseSeq this.hasMore = result.value.hasMore this.transcript.reset(this.events, this.views) this.rebuildDerivedFromWindow() } catch (error) { - if (generation === this.openGeneration) { + if (this.loadingOlder === loading) { console.error('[web-runtime] loadOlder failed:', error) } } finally { - if (generation === this.openGeneration) { - this.loadingOlder = false + if (this.loadingOlder === loading) { + this.loadingOlder = null if (this.liveBuffer.length > 0) void this.repairGap() this.notifier.markDirty() } @@ -455,7 +473,7 @@ export class Session implements SessionFace { this.pendingRev++ this.subscribedLastSeq = null this.liveBuffer = [] - this.loadingOlder = false + this.loadingOlder = null this.stitching = false this.notifier.markDirty() await this.open() @@ -836,11 +854,12 @@ export class Session implements SessionFace { 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 { if (this.openState === 'loading' || this.stitching) { this.liveBuffer.push({ event, view }) @@ -850,6 +869,15 @@ export class Session implements SessionFace { const tailSeq = this.windowTailSeq() if (tailSeq !== null && event.seq <= tailSeq) { 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) if (changed) this.notifier.markDirty() } catch (error) { @@ -860,12 +888,12 @@ export class Session implements SessionFace { } if (tailSeq !== null && event.seq > tailSeq + 1) { this.liveBuffer.push({ event, view }) - if (!this.loadingOlder) void this.repairGap() + if (this.loadingOlder === null) void this.repairGap() return } if (tailSeq === null && event.seq !== 0) { this.liveBuffer.push({ event, view }) - if (!this.loadingOlder) void this.repairGap() + if (this.loadingOlder === null) void this.repairGap() return } this.appendLive(event, view) @@ -1148,7 +1176,7 @@ export class Session implements SessionFace { openState: this.openState, openError: this.openError, hasMore: this.hasMore, - loadingOlder: this.loadingOlder, + loadingOlder: this.loadingOlder !== null, promptError: this.promptError, blank: this.blankBit, lastAgentError: this.lastAgentError, diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 9635f08515..5c6c493f07 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/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>>() - 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>>() + 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>>() + 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>>() + 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() + } + }) }) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 524c9c7bd2..5865c3588d 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/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: 7a37baf92c6e050e628d97d5926b08bae295137b -README.zh.md: 8acc33cc397f8e0ac4bee016007d635d0021c309 +README.md: c7b1934cd5a8e6ac3e3cc022ec67a948ec538786 +README.zh.md: 8ba09550e1506ad9233827ec4e95fc35fa8d7c0d diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 165a29e11a..8ba09550e1 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -2,7 +2,7 @@ [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。