Files
goodbuddy/src/main/remote-event-batcher.ts
T
mesalogo 805acc3fce fix: restore the jump-to-bottom glide and keep user state, selections and scroll anchors in windowed lists
Pause scroll writes while the jump-to-bottom smooth scroll runs; keep rows with pending questions, approvals, recent interactions and selection ends mounted; cache Mermaid results; copy text spanning unmounted rows; anchor the sidebar on id changes; disable native overflow-anchor for windowed lists; write terminal output on the leading edge and before clear; forward remote events every 16 ms; flush streaming text before copy/export; add performance principle P10 (no UX regressions).
2026-10-03 16:19:56 +08:00

163 lines
5.0 KiB
TypeScript

import type { RemoteSemanticEventProvenance } from './agent/runtime'
export type RemoteEventBatchEntry<TEvent> = {
provenance: RemoteSemanticEventProvenance
event: TEvent
}
export type RemoteEventCommitted<TEvent> = (
event: TEvent,
inserted: boolean
) => void
export type RemoteEventBatcherOptions<TEvent> = {
/**
* Durably stores every entry in ONE transaction and returns one flag per
* entry: true when it was newly stored, false for an already stored replay.
*/
persist(entries: readonly RemoteEventBatchEntry<TEvent>[]): readonly boolean[]
/** Called when a timer-driven flush fails (for example to abort the run). */
onError?(error: unknown): void
/**
* Called after a timer-driven flush committed (and its `onCommitted`
* callbacks ran), e.g. to forward the committed events without waiting for
* a second coalescing timer downstream.
*/
onTimerFlushed?(): void
/** Safety flush for buffered events that are not followed by a boundary. */
flushIntervalMs?: number
maximumEvents?: number
}
type PendingEntry<TEvent> = RemoteEventBatchEntry<TEvent> & {
onCommitted?: RemoteEventCommitted<TEvent>
}
/**
* One frame: committed events are forwarded to the renderer only after their
* batch commits, so this bounds how long streamed remote text is held back.
*/
export const REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS = 16
export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256
/**
* Buffers remote semantic events so one SQLite transaction covers a whole
* remote transcript sequence instead of one transaction per event.
*
* Callers must flush before any point where the producer may acknowledge the
* buffered events (the remote semantic checkpoint), and before publishing any
* unrelated event so renderer order is preserved. `onCommitted` callbacks run
* after the commit, in the original order, with `inserted = false` for
* replayed events that were already stored.
*
* A failed commit stores nothing from that batch and poisons the batcher:
* every later `add` or `flush` rethrows the same error, so a later checkpoint
* can never be committed (and acknowledged) after an earlier batch was lost.
*/
export class RemoteEventBatcher<TEvent> {
private readonly persist: RemoteEventBatcherOptions<TEvent>['persist']
private readonly onError: RemoteEventBatcherOptions<TEvent>['onError']
private readonly onTimerFlushed: RemoteEventBatcherOptions<TEvent>['onTimerFlushed']
private readonly flushIntervalMs: number
private readonly maximumEvents: number
private pending: PendingEntry<TEvent>[] = []
private timer: ReturnType<typeof setTimeout> | undefined
private failure: { error: unknown } | undefined
constructor(options: RemoteEventBatcherOptions<TEvent>) {
this.persist = options.persist
this.onError = options.onError
this.onTimerFlushed = options.onTimerFlushed
this.flushIntervalMs =
options.flushIntervalMs ?? REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS
this.maximumEvents =
options.maximumEvents ?? REMOTE_EVENT_BATCH_MAXIMUM_EVENTS
if (this.flushIntervalMs <= 0) {
throw new Error('flushIntervalMs must be positive')
}
if (!Number.isSafeInteger(this.maximumEvents) || this.maximumEvents < 1) {
throw new Error('maximumEvents must be a positive integer')
}
}
get size(): number {
return this.pending.length
}
add(
provenance: RemoteSemanticEventProvenance,
event: TEvent,
onCommitted?: RemoteEventCommitted<TEvent>
): void {
this.throwIfFailed()
this.pending.push({ provenance, event, onCommitted })
if (this.pending.length >= this.maximumEvents) {
this.flush()
return
}
this.scheduleFlush()
}
flush(): void {
this.clearTimer()
this.throwIfFailed()
if (this.pending.length === 0) {
return
}
const entries = this.pending
this.pending = []
let inserted: readonly boolean[]
try {
inserted = this.persist(
entries.map(({ provenance, event }) => ({ provenance, event }))
)
if (inserted.length !== entries.length) {
throw new Error('远程事件批量写入结果数量不匹配')
}
} catch (error) {
this.failure = { error }
throw error
}
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index]!
entry.onCommitted?.(entry.event, inserted[index] === true)
}
}
/** Stops the safety timer and drops uncommitted events. */
dispose(): void {
this.clearTimer()
this.pending = []
}
private throwIfFailed(): void {
if (this.failure) {
throw this.failure.error
}
}
private scheduleFlush(): void {
if (this.timer) {
return
}
this.timer = setTimeout(() => {
this.timer = undefined
try {
this.flush()
this.onTimerFlushed?.()
} catch (error) {
this.onError?.(error)
}
}, this.flushIntervalMs)
this.timer.unref?.()
}
private clearTimer(): void {
if (!this.timer) {
return
}
clearTimeout(this.timer)
this.timer = undefined
}
}