Files
orca/src/main/native-chat/transcript-watch-scheduler.ts
T
Jinjing 42bff5e598 fix(native-chat): add reconciliation to catch missed transcript updates (#9701)
* 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.
2026-07-20 23:57:05 -07:00

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
}
}
}