fix(watcher): keep a full scan owed after a failed sync (#1964) (#1977)

A directory removal adds no pending file, only needsFullScan. The retry
after a failed sync (lock contention or a generic failure) was gated on
pending files alone, so the owed full reconcile was dropped and the
removed directory's files kept their nodes. Reschedule while needsFullScan
is set as well. A regression test also pins the mid-sync scope-change case,
which already follows a scoped pass with a full scan.

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniil
2026-09-27 15:22:18 +03:00
committed by GitHub
parent c3faef260b
commit a9c8808d72
3 changed files with 77 additions and 2 deletions
+1
View File
@@ -163,6 +163,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### MCP / indexing
- File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964)
- Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.
- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.
- Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829)
+72
View File
@@ -968,6 +968,78 @@ describe('FileWatcher', () => {
expect(calls[0]).toBeUndefined();
});
it.each([
['lock contention', () => new LockUnavailableError()],
['sync failure', () => new Error('injected sync failure')],
])('retries a full-only directory removal after %s (#1964)', async (_case, failure) => {
const syncFn = vi.fn()
.mockRejectedValueOnce(failure())
.mockResolvedValue({ filesChanged: 0, durationMs: 5 });
const watcher = newWatcher(syncFn, { debounceMs: 25 });
watcher.start();
try {
await watcher.waitUntilReady();
__emitWatchEventForTests(testDir, 'src/removed-dir');
await waitFor(() => syncFn.mock.calls.length >= 2, 4000);
expect(syncFn.mock.calls.map(call => call[0])).toEqual([undefined, undefined]);
} finally {
watcher.stop();
}
});
it('follows a scoped sync with a full scan when a directory is removed mid-sync (#1964)', async () => {
let release!: () => void;
const firstRun = new Promise<void>(resolve => { release = resolve; });
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async paths => {
calls.push(paths);
if (calls.length === 1) await firstRun;
return { filesChanged: 0, durationMs: 5 };
};
const watcher = newWatcher(syncFn, { debounceMs: 25 });
watcher.start();
try {
await watcher.waitUntilReady();
fs.writeFileSync(path.join(testDir, 'src', 'a.ts'), 'export const a = 1;');
__emitWatchEventForTests(testDir, 'src/a.ts');
await waitFor(() => calls.length === 1, 4000);
__emitWatchEventForTests(testDir, 'src/removed-dir');
release();
await waitFor(() => calls.length >= 2, 4000);
expect(calls).toEqual([['src/a.ts'], undefined]);
} finally {
release();
watcher.stop();
}
});
it('follows a scoped sync with a full scan when scope changes mid-sync (#1964)', async () => {
let release!: () => void;
const firstRun = new Promise<void>(resolve => { release = resolve; });
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async paths => {
calls.push(paths);
if (calls.length === 1) await firstRun;
return { filesChanged: 0, durationMs: 5 };
};
const watcher = newWatcher(syncFn, { debounceMs: 25 });
watcher.start();
try {
await watcher.waitUntilReady();
fs.writeFileSync(path.join(testDir, 'src', 'a.ts'), 'export const a = 1;');
__emitWatchEventForTests(testDir, 'src/a.ts');
await waitFor(() => calls.length === 1, 4000);
fs.writeFileSync(path.join(testDir, '.gitignore'), 'src/ignored/\n');
__emitWatchEventForTests(testDir, '.gitignore');
release();
await waitFor(() => calls.length >= 2, 4000);
expect(calls).toEqual([['src/a.ts'], undefined]);
} finally {
release();
watcher.stop();
}
});
it('a lone file event fires on the quick window, well before the full debounce', async () => {
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async (paths?: string[]) => {
+4 -2
View File
@@ -986,8 +986,10 @@ export class FileWatcher {
// sync resets both counters so normal edits keep the fast debounce. Use
// the larger streak so interleaved failures still back off. A degrade()
// above already set `stopped`, so this won't reschedule a watcher that
// has given up.
if (this.pendingFiles.size > 0 && !this.stopped) {
// has given up. A directory removal whose full sync failed adds no
// pending file, only `needsFullScan` — it still owes the full reconcile
// it asked for (#1964).
if ((this.pendingFiles.size > 0 || this.needsFullScan) && !this.stopped) {
const retryCount = Math.max(this.lockRetryCount, this.syncFailureRetryCount);
if (retryCount > 0) {
const retryDelayMs = Math.min(