fix(watcher): retain pending files on zero-result sync (#450)

* fix(watcher): retain pending files on zero-result sync

* refactor(watcher): detect lock-unavailable at the wrapper

Replace the heuristic `(filesChanged === 0 && durationMs === 0)` check
inside `FileWatcher.flush()` with a typed `LockUnavailableError` thrown
by `CodeGraph.watch()`'s sync wrapper. The wrapper has access to the
full `SyncResult`, including `filesChecked` — which is **only** zero
when `sync()` failed to acquire the cross-process file lock (a real
empty sync always has `filesChecked > 0` because `scanDirectory` ran).
That eliminates the heuristic's edge case where a fast no-op sync
returns `durationMs === 0` by `Date.now()` rounding and gets mistaken
for a lock failure on tiny projects.

The watcher's `catch` block now distinguishes `LockUnavailableError`
from real errors: it logs at `logDebug` (not `logWarn`) and does NOT
call `onSyncError` — so a long-running external indexer holding the
lock doesn't spam stderr every debounce cycle via the MCP daemon's
`Auto-sync error` handler. The existing post-catch path already
preserves `pendingFiles` and reschedules, so no new control flow is
needed.

A/B validated end-to-end against the built dist on macOS with a
three-scenario repro (lock held, lock released mid-flight, real sync
error):

- main:           lock-held silently clears pendingFiles (BUG);
                  lock-released never recovers (no real sync runs).
- PR-as-is:       lock-held preserves pendingFiles; lock-released
                  drains. Same observable behavior as wrapper-level.
- wrapper-level:  same outcomes; lock-failure goes through the catch
                  path silently (logDebug only, no onSyncError noise);
                  real errors still surface via onSyncError.

Updates the regression test to throw `LockUnavailableError` (the real
contract surfaced to `FileWatcher` by `CodeGraph.watch()`), and
asserts `onSyncError` stays quiet during the lock-held cycle.

Closes #449.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thismilktea
2026-05-26 13:47:04 -05:00
committed by GitHub
co-authored by Claude Opus 4.7 Colby McHenry
parent 6015e4fdd2
commit 72c08c2bef
5 changed files with 80 additions and 12 deletions
+10 -2
View File
@@ -46,7 +46,7 @@ import {
import { GraphTraverser, GraphQueryManager } from './graph';
import { ContextBuilder, createContextBuilder } from './context';
import { Mutex, FileLock } from './utils';
import { FileWatcher, WatchOptions, PendingFile } from './sync';
import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
// Re-export types for consumers
export * from './types';
@@ -75,7 +75,7 @@ export {
defaultLogger,
} from './errors';
export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
export { FileWatcher, WatchOptions, PendingFile } from './sync';
export { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
export { MCPServer } from './mcp';
/**
@@ -495,6 +495,14 @@ export class CodeGraph {
this.projectRoot,
async () => {
const result = await this.sync();
// sync() returns this exact zero-shape iff it failed to acquire the
// file lock (a real empty sync always has filesChecked > 0 because
// scanDirectory ran). Surface that to the watcher as a typed error
// so it keeps pendingFiles + reschedules instead of clearing them
// (#449).
if (result.filesChecked === 0 && result.durationMs === 0) {
throw new LockUnavailableError();
}
const filesChanged = result.filesAdded + result.filesModified + result.filesRemoved;
return { filesChanged, durationMs: result.durationMs };
},