mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +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.
88 lines
2.4 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|