mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
* fix(native-chat): add reconciliation to catch missed transcript updates Periodic reconciliation catches file changes that fs.watch may miss on remote filesystems. Event-driven drains use a bounded max-wait timer to ensure responsiveness. Extracts timer logic into a scheduler module for testability and maintainability. * fix(native-chat): add reconciliation to catch missed transcript updates Detects same-size file rewrites and native watcher failures by reconciling transcript state even when fs.watch events don't fire. Extracts watcher binding into a dedicated module that gracefully degrades to reconciliation-only mode when native watch is unavailable. Adds test coverage for watcher rebind failures, parent-directory replacements, and unsubscribe races. * Document transcript watcher reconciliation contract Clarify that bind() is best-effort with reconciliation fallback, invalidate() detaches from stale identities, and the caller's polling loop owns transcript liveness. Supports resilience on remote filesystems.
102 lines
2.7 KiB
TypeScript
102 lines
2.7 KiB
TypeScript
const DEFAULT_DEBOUNCE_MS = 40
|
|
const EVENT_DRAIN_MAX_WAIT_MS = 250
|
|
const DEFAULT_RECONCILIATION_MS = 1_000
|
|
|
|
type TranscriptWatchSchedulerOptions = {
|
|
debounceMs?: number
|
|
reconciliationIntervalMs?: number
|
|
drain: () => void
|
|
reconcile: () => Promise<void>
|
|
}
|
|
|
|
export type TranscriptWatchScheduler = {
|
|
scheduleEventDrain: () => void
|
|
scheduleRetry: (delayMs: number) => boolean
|
|
startReconciliation: () => void
|
|
dispose: () => void
|
|
}
|
|
|
|
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
|
timer.unref?.()
|
|
}
|
|
|
|
export function createTranscriptWatchScheduler(
|
|
options: TranscriptWatchSchedulerOptions
|
|
): TranscriptWatchScheduler {
|
|
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
|
const maxEventWaitMs = Math.max(debounceMs, EVENT_DRAIN_MAX_WAIT_MS)
|
|
const reconciliationIntervalMs = options.reconciliationIntervalMs ?? DEFAULT_RECONCILIATION_MS
|
|
let disposed = false
|
|
let drainTimer: ReturnType<typeof setTimeout> | null = null
|
|
let firstEventAt: number | null = null
|
|
let reconciliationTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function clearDrainTimer(): void {
|
|
if (!drainTimer) {
|
|
return
|
|
}
|
|
clearTimeout(drainTimer)
|
|
drainTimer = null
|
|
}
|
|
|
|
function fireDrain(): void {
|
|
clearDrainTimer()
|
|
firstEventAt = null
|
|
options.drain()
|
|
}
|
|
|
|
function armDrain(delayMs: number): void {
|
|
drainTimer = setTimeout(fireDrain, delayMs)
|
|
unrefTimer(drainTimer)
|
|
}
|
|
|
|
function armReconciliation(): void {
|
|
if (disposed || reconciliationTimer) {
|
|
return
|
|
}
|
|
reconciliationTimer = setTimeout(() => {
|
|
reconciliationTimer = null
|
|
// Why: wait for the host-side stat/drain check before rearming so a slow
|
|
// remote filesystem cannot accumulate overlapping reconciliation work.
|
|
void options.reconcile().then(armReconciliation, armReconciliation)
|
|
}, reconciliationIntervalMs)
|
|
unrefTimer(reconciliationTimer)
|
|
}
|
|
|
|
return {
|
|
scheduleEventDrain(): void {
|
|
if (disposed) {
|
|
return
|
|
}
|
|
const now = Date.now()
|
|
firstEventAt ??= now
|
|
const remainingMaxWait = Math.max(0, maxEventWaitMs - (now - firstEventAt))
|
|
clearDrainTimer()
|
|
armDrain(Math.min(debounceMs, remainingMaxWait))
|
|
},
|
|
scheduleRetry(delayMs: number): boolean {
|
|
if (disposed || drainTimer) {
|
|
return false
|
|
}
|
|
firstEventAt = null
|
|
armDrain(delayMs)
|
|
return true
|
|
},
|
|
startReconciliation(): void {
|
|
armReconciliation()
|
|
},
|
|
dispose(): void {
|
|
if (disposed) {
|
|
return
|
|
}
|
|
disposed = true
|
|
clearDrainTimer()
|
|
if (reconciliationTimer) {
|
|
clearTimeout(reconciliationTimer)
|
|
reconciliationTimer = null
|
|
}
|
|
firstEventAt = null
|
|
}
|
|
}
|
|
}
|