Files
orca/src/main/native-chat/transcript-native-watcher.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

88 lines
2.4 KiB
TypeScript

import { watch, type FSWatcher } from 'node:fs'
import { basename, dirname } from 'node:path'
export type TranscriptNativeWatcher = {
/** Best-effort bind; false keeps the caller in reconciliation-only mode. */
bind: () => boolean
/** Detach from an identity that may no longer represent the watched path. */
invalidate: () => void
needsRebind: () => boolean
dispose: () => void
}
/**
* Optional fs.watch acceleration for transcript reconciliation. Native watches
* can fail on otherwise-readable remote filesystems, so binding is retryable
* and never owns transcript liveness; the caller's polling loop does.
*/
export function createTranscriptNativeWatcher(
filePath: string,
onEvent: () => void,
onError: () => void
): TranscriptNativeWatcher {
const watchedName = basename(filePath)
let disposed = false
let watcher: FSWatcher | null = null
let rebindNeeded = true
function invalidateCandidate(candidate: FSWatcher): void {
if (watcher !== candidate) {
return
}
watcher = null
rebindNeeded = true
candidate.close()
}
return {
bind(): boolean {
if (disposed || watcher) {
return watcher !== null
}
let nextWatcher: FSWatcher
try {
// Why: watching the parent survives target-file replacement on macOS.
nextWatcher = watch(dirname(filePath), (event, changedName) => {
if (changedName !== null && changedName.toString() !== watchedName) {
return
}
// Why: a parent replacement may emit rename without a watcher error.
if (event === 'rename') {
invalidateCandidate(nextWatcher)
}
onEvent()
})
} catch {
rebindNeeded = true
return false
}
// Why: an active tail should not keep a headless runtime alive during shutdown.
nextWatcher.unref?.()
nextWatcher.on('error', () => {
if (disposed || watcher !== nextWatcher) {
return
}
invalidateCandidate(nextWatcher)
onError()
})
watcher = nextWatcher
rebindNeeded = false
return true
},
invalidate(): void {
if (watcher) {
invalidateCandidate(watcher)
} else {
rebindNeeded = true
}
},
needsRebind: () => rebindNeeded,
dispose(): void {
disposed = true
watcher?.close()
watcher = null
rebindNeeded = false
}
}
}