fix(schedule): close review gaps
This commit is contained in:
@@ -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 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.
|
||||
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`; repair continues while each accepted snapshot advances the tail and a buffered gap remains, while an identity-conflicting snapshot triggers a full resync. 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
|
||||
|
||||
|
||||
@@ -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 会按持久事件类型形成一个 `PresentedEventNode`。`liveBuffer` 仍只用于尾部加载与真正的 gap repair。普通 `loadOlder` 会将 live-tail 追加项留在当前窗口中,并在 await 后前插所取页面;重叠的迟到 sidecar 则会立即升级。重连会推进 generation 并清除 page/repair 的所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
|
||||
一个 Session event 到达其 presentation 提交点后,Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar;通用 event view 会按持久事件类型形成一个 `PresentedEventNode`。`liveBuffer` 仍只用于尾部加载与真正的 gap repair;每当已接受的快照推进 tail 后仍留有已缓冲的 gap,repair 就会继续;身份冲突的快照则会触发全量重新同步。普通 `loadOlder` 会将 live-tail 追加项留在当前窗口中,并在 await 后前插所取页面;重叠的迟到 sidecar 则会立即升级。重连会推进 generation 并清除 page/repair 的所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
|
||||
|
||||
## 请求检查
|
||||
|
||||
|
||||
@@ -700,11 +700,16 @@ export class Session implements SessionFace {
|
||||
* overwrite a newer push frame); the window events themselves are never
|
||||
* folded — the host is the only computation site.
|
||||
*/
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.mergeWindow(entries)
|
||||
private installWindow(
|
||||
entries: HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
projections?: ProjectionsBaseline,
|
||||
): { changed: boolean; hasGap: boolean } {
|
||||
const merged = this.mergeWindow(entries)
|
||||
this.hasMore = hasMore
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.notifier.markDirty()
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -918,16 +923,32 @@ export class Session implements SessionFace {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.openGeneration
|
||||
let retryGap = false
|
||||
let acceptedHistory = false
|
||||
try {
|
||||
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration || this.openState !== 'open') return
|
||||
if (result.ok) {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
acceptedHistory = true
|
||||
const previousTail = this.windowTailSeq()
|
||||
const { hasGap } = this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.projections,
|
||||
)
|
||||
const repairedTail = this.windowTailSeq()
|
||||
retryGap = hasGap && repairedTail !== null
|
||||
&& (previousTail === null || repairedTail > previousTail)
|
||||
} else {
|
||||
this.mergeWindow()
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === this.openGeneration) {
|
||||
if (acceptedHistory) {
|
||||
console.error('[web-runtime] gap repair snapshot failed validation:', error)
|
||||
void this.resync()
|
||||
return
|
||||
}
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
try {
|
||||
this.mergeWindow()
|
||||
@@ -940,6 +961,7 @@ export class Session implements SessionFace {
|
||||
if (generation === this.openGeneration) {
|
||||
this.stitching = false
|
||||
this.notifier.markDirty()
|
||||
if (retryGap) void this.repairGap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,6 +821,64 @@ describe('live event path', () => {
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
|
||||
})
|
||||
|
||||
it('continues repair when one tail snapshot leaves a later buffered gap', async () => {
|
||||
const initial = logRange(0, 6)
|
||||
const firstGap = ev.user(9, 'first repaired event')
|
||||
const laterGap = ev.user(12, 'later buffered event')
|
||||
const firstSnapshot = [...initial, ...logRange(6, 9), firstGap]
|
||||
const completeSnapshot = [...firstSnapshot, ...logRange(10, 12), laterGap]
|
||||
const firstRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const secondRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const { api, session } = await opened(initial)
|
||||
let repairs = 0
|
||||
api.onHistory = () => ++repairs === 1 ? firstRepair.promise : secondRepair.promise
|
||||
|
||||
session.handleMuxEnvelope('first-gap' as never, {
|
||||
type: 'session/event', sessionId: SID, event: firstGap,
|
||||
})
|
||||
session.handleMuxEnvelope('later-gap' as never, {
|
||||
type: 'session/event', sessionId: SID, event: laterGap,
|
||||
})
|
||||
firstRepair.resolve(ok({ events: entries(firstSnapshot) as never[], hasMore: false }))
|
||||
|
||||
await vi.waitFor(() => { expect(repairs).toBe(2) })
|
||||
secondRepair.resolve(ok({ events: entries(completeSnapshot) as never[], hasMore: false }))
|
||||
await vi.waitFor(() => {
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([9, 12])
|
||||
})
|
||||
})
|
||||
|
||||
it('resyncs when a successful gap snapshot conflicts with a buffered event identity', async () => {
|
||||
const initial = logRange(0, 6)
|
||||
const live = ev.user(9, 'live identity')
|
||||
const conflicting = ev.user(9, 'conflicting history identity')
|
||||
const consistent = [...initial, ...logRange(6, 9), live]
|
||||
const { api, session } = await opened(initial)
|
||||
let repairs = 0
|
||||
api.onHistory = () => {
|
||||
repairs++
|
||||
return repairs === 1
|
||||
? histResponse([...initial, ...logRange(6, 9), conflicting])
|
||||
: histResponse(consistent)
|
||||
}
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
session.handleMuxEnvelope('gap' as never, {
|
||||
type: 'session/event', sessionId: SID, event: live,
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(repairs).toBe(2)
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([9])
|
||||
})
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[web-runtime] gap repair snapshot failed validation:',
|
||||
expect.objectContaining({ message: 'session event identity mismatch at seq 9' }),
|
||||
)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('paging', () => {
|
||||
|
||||
Reference in New Issue
Block a user