feat(schedule): add durable after reminders

This commit is contained in:
pku-xht
2026-08-06 03:47:37 +08:00
committed by Tianyi Cui
parent f7e7851e3f
commit 8b69252664
41 changed files with 354 additions and 146 deletions

View File

@@ -40,7 +40,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by its `presentationKey`. The existing `liveBuffer` is the sole rendezvous during tail loading, gap stitching, and `loadOlder`. One merge path upgrades overlaps, consumes covered entries, and attaches only a contiguous suffix on every current-generation settlement, including rejected, empty, and discontinuous page responses. Reconnect advances the generation and clears its loading ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window.
A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by the durable event type. Tail loading and true gap repair continue to use the existing `liveBuffer`. Ordinary `loadOlder` leaves live-tail appends in the current window and prepends its page after the await, while an overlapping late sidecar upgrades immediately. Reconnect advances the generation and clears page or repair ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window.
## Request inspection

View File

@@ -40,7 +40,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
一个 Session event 到达其 presentation 提交点后Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar通用 event view 会按 `presentationKey` 形成一个 `PresentedEventNode`既有 `liveBuffer` 是尾部加载、gap stitching 与 `loadOlder` 期间唯一的汇合点。每个当前 generation 的结算出口都使用同一条 merge 路径升级窗口重叠项、消费已覆盖项并只接入连续后缀RPC 拒绝、空页和不连续页同样如此。重连会推进 generation 并清除其 loading 所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
一个 Session event 到达其 presentation 提交点后Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar通用 event view 会按持久事件类型形成一个 `PresentedEventNode``liveBuffer` 仍只用于尾部加载与真正的 gap repair。普通 `loadOlder` 会将 live-tail 追加项留在当前窗口中,并在 await 后前插所取页面;重叠的迟到 sidecar 则会立即升级。重连会推进 generation 并清除 pagerepair 的所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
## 请求检查

View File

@@ -254,8 +254,8 @@ export interface CommandNode {
/**
* Host-computed presentation for one durable non-surface event. The generic
* runtime carries the keyed JSON-compatible payload without importing the
* producing domain; a client plugin owns the keyed renderer.
* runtime carries the durable event type and JSON-compatible payload without
* importing the producing domain; a client plugin owns the keyed renderer.
*/
export interface PresentedEventNode {
kind: 'presented-event'
@@ -263,8 +263,8 @@ export interface PresentedEventNode {
seq: number
/** Unix epoch ms from the source Session event. */
time: number
/** Open runtime key selecting an optional domain renderer. */
presentationKey: string
/** Durable event type selecting an optional domain renderer. */
eventType: string
/** Domain-owned JSON-compatible presentation payload. */
view: unknown
}

View File

@@ -392,6 +392,7 @@ export class Session implements SessionFace {
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
const generation = this.openGeneration
const requestedBaseSeq = this.baseSeq
this.loadingOlder = true
this.notifier.markDirty()
try {
@@ -404,34 +405,27 @@ export class Session implements SessionFace {
return
}
const tail = older[older.length - 1]
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
if (tail === undefined || tail.event.seq + 1 !== requestedBaseSeq) {
// §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 ${this.baseSeq}`)
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${requestedBaseSeq}`)
this.hasMore = false
return
}
this.installWindow([
...older,
...this.events.map((event, index): HistoryEntry => {
const view = this.views[index]
return view === undefined ? { event } : { event, view }
}),
], result.value.hasMore)
this.events = [...older.map(entry => entry.event), ...this.events]
this.views = [...older.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) {
console.error('[web-runtime] loadOlder failed:', error)
}
} finally {
if (generation === this.openGeneration) {
try {
const { hasGap } = this.mergeWindow()
// oxlint-disable-next-line typescript/no-unnecessary-condition -- resync can close the window while the page request is awaited.
if (hasGap && this.openState === 'open') void this.repairGap()
} catch (error) {
console.error('[web-runtime] loadOlder buffer merge failed:', error)
void this.resync()
}
this.loadingOlder = false
if (this.liveBuffer.length > 0) void this.repairGap()
this.notifier.markDirty()
}
}
@@ -816,6 +810,21 @@ export class Session implements SessionFace {
this.applyEventSideEffects(event, view)
}
/** Verify one retained event and apply a defined late sidecar immediately. */
private upgradeLiveView(event: SessionEvent, view?: SessionEventView): boolean {
const index = this.events.findIndex(candidate => candidate.seq === event.seq)
if (index === -1) return false
const retained = this.events[index]
/* v8 ignore next -- findIndex returned a dense-array position. */
if (retained === undefined) return false
assertSameEvent(retained, event)
if (view === undefined || sameWireValue(this.views[index], view)) return false
this.views[index] = view
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
return true
}
/** Retire the first matching live steering occurrence when its durable message takes over. */
private handoffPendingSteering(event: SessionEvent): void {
if (event.type !== 'user/message') return
@@ -840,9 +849,8 @@ export class Session implements SessionFace {
if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open)
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) {
this.liveBuffer.push({ event, view })
try {
const { changed } = this.mergeWindow()
const changed = this.upgradeLiveView(event, view)
if (changed) this.notifier.markDirty()
} catch (error) {
console.error('[web-runtime] duplicate session event failed identity validation:', error)
@@ -852,12 +860,12 @@ export class Session implements SessionFace {
}
if (tailSeq !== null && event.seq > tailSeq + 1) {
this.liveBuffer.push({ event, view })
void this.repairGap()
if (!this.loadingOlder) void this.repairGap()
return
}
if (tailSeq === null && event.seq !== 0) {
this.liveBuffer.push({ event, view })
void this.repairGap()
if (!this.loadingOlder) void this.repairGap()
return
}
this.appendLive(event, view)

View File

@@ -125,7 +125,7 @@ function materializePresented(event: SessionEvent, sidecar: PresentedEventView):
kind: 'presented-event',
seq: event.seq,
time: event.time,
presentationKey: sidecar.presentationKey,
eventType: event.type,
view: sidecar.view,
}
}

View File

@@ -45,7 +45,6 @@ function reminderEvent(seq: number, id: string): SessionEvent {
function reminderView(id: string, prompt = '检查日志') {
return {
for: 'event' as const,
presentationKey: 'schedule/reminder',
view: { id, prompt },
}
}
@@ -127,7 +126,7 @@ describe('late event views', () => {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-1'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 0, presentationKey: 'schedule/reminder',
kind: 'presented-event', seq: 0, eventType: 'schedule/change',
view: { id: 'schedule-1', prompt: '检查日志' },
}])
@@ -707,6 +706,27 @@ describe('paging', () => {
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
})
it('keeps a concurrent live tail in the current window before prepending the older page', async () => {
const older = plainTurn(0, 0, '旧问', '旧答')
const newer = plainTurn(6, 1, '新问', '新答')
const page = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(newer, true)
: page.promise
await session.open()
const loading = session.loadOlder()
session.handleMuxEnvelope('live-tail' as never, {
type: 'session/event', sessionId: SID, event: ev.user(12, '并发尾部'),
})
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9, 12])
page.resolve(await histResponse(older, false))
await loading
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9, 12])
})
it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => {
// Pagination no longer spends maxMessages quota on replacement copies, so a
// page can carry a compaction checkpoint whose surfaceOp.start lies outside

View File

@@ -440,23 +440,21 @@ describe('TranscriptAdapter', () => {
const adapter = new TranscriptAdapter()
adapter.reset([replayed], [{
for: 'event',
presentationKey: 'schedule/reminder',
view: { id: 'schedule-1', prompt: '检查日志' },
}])
adapter.append(live, {
for: 'event',
presentationKey: 'schedule/reminder',
view: { id: 'schedule-2', prompt: '检查发布' },
})
expect(adapter.nodes()).toEqual([
{
kind: 'presented-event', seq: 0, time: 1_700_000_000_000,
presentationKey: 'schedule/reminder',
eventType: 'schedule/change',
view: { id: 'schedule-1', prompt: '检查日志' },
},
{
kind: 'presented-event', seq: 1, time: 1_700_000_000_001,
presentationKey: 'schedule/reminder',
eventType: 'schedule/change',
view: { id: 'schedule-2', prompt: '检查发布' },
},
])