|
@@ -45,6 +45,26 @@ export interface WatchOptions {
|
|
|
onSyncError?: (error: Error) => void;
|
|
onSyncError?: (error: Error) => void;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Per-file pending entry — tracks a source file the watcher saw an event for
|
|
|
|
|
+ * but hasn't yet synced into the index. Exposed via {@link FileWatcher.getPendingFiles}
|
|
|
|
|
+ * so MCP tool responses can mark stale results without forcing a wait.
|
|
|
|
|
+ */
|
|
|
|
|
+export interface PendingFile {
|
|
|
|
|
+ /** Project-relative POSIX path (e.g. "src/foo.ts"). */
|
|
|
|
|
+ path: string;
|
|
|
|
|
+ /** Wall-clock ms at the first event we saw for this path since the last sync. */
|
|
|
|
|
+ firstSeenMs: number;
|
|
|
|
|
+ /** Wall-clock ms at the most recent event we saw for this path. */
|
|
|
|
|
+ lastSeenMs: number;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * True when a sync is currently in flight that began AFTER this file's most
|
|
|
|
|
+ * recent event — i.e. the next successful sync will pick it up. False when
|
|
|
|
|
+ * the file is still in the debounce window (no sync running yet).
|
|
|
|
|
+ */
|
|
|
|
|
+ indexing: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* FileWatcher monitors a project directory for changes and triggers
|
|
* FileWatcher monitors a project directory for changes and triggers
|
|
|
* debounced sync operations via a provided callback.
|
|
* debounced sync operations via a provided callback.
|
|
@@ -55,13 +75,43 @@ export interface WatchOptions {
|
|
|
* - Debounced to avoid thrashing on rapid saves
|
|
* - Debounced to avoid thrashing on rapid saves
|
|
|
* - Filters to supported source files by extension
|
|
* - Filters to supported source files by extension
|
|
|
* - Ignores .codegraph/ and .git/ regardless of .gitignore
|
|
* - Ignores .codegraph/ and .git/ regardless of .gitignore
|
|
|
|
|
+ * - Tracks per-file pending state so MCP tools can flag stale results
|
|
|
|
|
+ * without blocking on a sync (issue #403)
|
|
|
*/
|
|
*/
|
|
|
export class FileWatcher {
|
|
export class FileWatcher {
|
|
|
private watcher: FSWatcher | null = null;
|
|
private watcher: FSWatcher | null = null;
|
|
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
- private hasChanges = false;
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Files seen by the watcher since the last successful sync — populated on
|
|
|
|
|
+ * every chokidar event, cleared at the start of a sync, and re-populated by
|
|
|
|
|
+ * events that arrive mid-sync (or restored on sync failure). Keyed by the
|
|
|
|
|
+ * same project-relative POSIX path the rest of the codebase uses, so a
|
|
|
|
|
+ * caller can intersect tool-response file paths against this map cheaply.
|
|
|
|
|
+ */
|
|
|
|
|
+ private pendingFiles = new Map<string, { firstSeenMs: number; lastSeenMs: number }>();
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Wall-clock ms at which the in-flight sync began. Combined with
|
|
|
|
|
+ * {@link pendingFiles}'s `lastSeenMs`, this distinguishes "still in the
|
|
|
|
|
+ * debounce window" (lastSeen > syncStarted, sync hasn't started yet for
|
|
|
|
|
+ * this edit) from "currently being indexed" (lastSeen <= syncStarted).
|
|
|
|
|
+ */
|
|
|
|
|
+ private syncStartedMs = 0;
|
|
|
private syncing = false;
|
|
private syncing = false;
|
|
|
private stopped = false;
|
|
private stopped = false;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * False until chokidar fires its `ready` event. Gates `pendingFiles`
|
|
|
|
|
+ * insertion so the initial crawl's `add` events (one per pre-existing
|
|
|
|
|
+ * source file) don't pollute the per-file staleness signal. The events
|
|
|
|
|
+ * still flow into `scheduleSync()` to preserve the previous "initial
|
|
|
|
|
+ * scan triggers a reconciling sync" behavior.
|
|
|
|
|
+ */
|
|
|
|
|
+ private chokidarReady = false;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Callbacks that resolve when chokidar fires `ready`. Used by tests (and
|
|
|
|
|
+ * any production caller that cares about a clean baseline) to deterministically
|
|
|
|
|
+ * gate on the end of the initial scan instead of guessing at a sleep duration.
|
|
|
|
|
+ */
|
|
|
|
|
+ private readyWaiters: Array<() => void> = [];
|
|
|
// The shared ignore matcher (built-in defaults + project .gitignore), built
|
|
// The shared ignore matcher (built-in defaults + project .gitignore), built
|
|
|
// once at start(). Same source of truth the indexer uses, so watcher scope
|
|
// once at start(). Same source of truth the indexer uses, so watcher scope
|
|
|
// can never diverge from index scope.
|
|
// can never diverge from index scope.
|
|
@@ -116,6 +166,26 @@ export class FileWatcher {
|
|
|
ignored: (testPath: string, stats?: Stats) => this.shouldIgnore(testPath, stats),
|
|
ignored: (testPath: string, stats?: Stats) => this.shouldIgnore(testPath, stats),
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // Chokidar emits `add` for every pre-existing source file during its
|
|
|
|
|
+ // initial scan. Those events should still trigger the post-startup
|
|
|
|
|
+ // reconciling sync (preserving prior behavior), but they must NOT land
|
|
|
|
|
+ // in pendingFiles — otherwise every file in the project shows up as
|
|
|
|
|
+ // "edited but not indexed" on startup, which is the opposite of the
|
|
|
|
|
+ // signal #403 is supposed to provide. Flip the flag on chokidar's
|
|
|
|
|
+ // `ready` event; from then on, real edits populate pendingFiles.
|
|
|
|
|
+ //
|
|
|
|
|
+ // We also clear `pendingFiles` here as defense-in-depth: chokidar can
|
|
|
|
|
+ // emit late initial-scan `add` events via setImmediate AFTER the
|
|
|
|
|
+ // `ready` callback runs (observed under test-parallelism load).
|
|
|
|
|
+ // Clearing once at ready guarantees a clean baseline; real subsequent
|
|
|
|
|
+ // edits repopulate the set normally.
|
|
|
|
|
+ this.watcher.on('ready', () => {
|
|
|
|
|
+ this.chokidarReady = true;
|
|
|
|
|
+ this.pendingFiles.clear();
|
|
|
|
|
+ for (const cb of this.readyWaiters) cb();
|
|
|
|
|
+ this.readyWaiters.length = 0;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
// chokidar emits 'all' for every event type; we only sync source files.
|
|
// chokidar emits 'all' for every event type; we only sync source files.
|
|
|
this.watcher.on('all', (_event: string, filePath: string) => {
|
|
this.watcher.on('all', (_event: string, filePath: string) => {
|
|
|
if (this.stopped) return;
|
|
if (this.stopped) return;
|
|
@@ -128,7 +198,17 @@ export class FileWatcher {
|
|
|
if (!isSourceFile(normalized)) return;
|
|
if (!isSourceFile(normalized)) return;
|
|
|
|
|
|
|
|
logDebug('File change detected', { file: normalized });
|
|
logDebug('File change detected', { file: normalized });
|
|
|
- this.hasChanges = true;
|
|
|
|
|
|
|
+ // Only track events from after chokidar's initial scan as pending
|
|
|
|
|
+ // edits — pre-existing files on disk are already represented by
|
|
|
|
|
+ // (or about to be reconciled by) the index, not a user edit.
|
|
|
|
|
+ if (this.chokidarReady) {
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ const existing = this.pendingFiles.get(normalized);
|
|
|
|
|
+ this.pendingFiles.set(normalized, {
|
|
|
|
|
+ firstSeenMs: existing?.firstSeenMs ?? now,
|
|
|
|
|
+ lastSeenMs: now,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
this.scheduleSync();
|
|
this.scheduleSync();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -187,7 +267,8 @@ export class FileWatcher {
|
|
|
this.watcher = null;
|
|
this.watcher = null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- this.hasChanges = false;
|
|
|
|
|
|
|
+ this.pendingFiles.clear();
|
|
|
|
|
+ this.chokidarReady = false;
|
|
|
this.ignoreMatcher = null;
|
|
this.ignoreMatcher = null;
|
|
|
logDebug('File watcher stopped');
|
|
logDebug('File watcher stopped');
|
|
|
}
|
|
}
|
|
@@ -199,6 +280,30 @@ export class FileWatcher {
|
|
|
return this.watcher !== null && !this.stopped;
|
|
return this.watcher !== null && !this.stopped;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Resolves once chokidar has fired its `ready` event (or immediately if
|
|
|
|
|
+ * it has already done so). Useful for tests that need a deterministic
|
|
|
|
|
+ * boundary before asserting on `pendingFiles` — guessing a sleep duration
|
|
|
|
|
+ * is flaky under load because chokidar can take longer than expected to
|
|
|
|
|
+ * finish its initial crawl on slow filesystems / parallel test runs.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Production callers don't need this: `pendingFiles` is read continuously,
|
|
|
|
|
+ * the staleness banner is always correct (empty or populated), and the
|
|
|
|
|
+ * initial-scan window is a small one-time startup cost.
|
|
|
|
|
+ */
|
|
|
|
|
+ waitUntilReady(timeoutMs = 10000): Promise<void> {
|
|
|
|
|
+ if (this.chokidarReady) return Promise.resolve();
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const t = setTimeout(() => {
|
|
|
|
|
+ const idx = this.readyWaiters.indexOf(handler);
|
|
|
|
|
+ if (idx >= 0) this.readyWaiters.splice(idx, 1);
|
|
|
|
|
+ reject(new Error(`FileWatcher.waitUntilReady timed out after ${timeoutMs}ms`));
|
|
|
|
|
+ }, timeoutMs);
|
|
|
|
|
+ const handler = () => { clearTimeout(t); resolve(); };
|
|
|
|
|
+ this.readyWaiters.push(handler);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Schedule a debounced sync.
|
|
* Schedule a debounced sync.
|
|
|
*/
|
|
*/
|
|
@@ -214,28 +319,80 @@ export class FileWatcher {
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Flush pending changes by running sync.
|
|
* Flush pending changes by running sync.
|
|
|
|
|
+ *
|
|
|
|
|
+ * pendingFiles is NOT cleared at the start of sync — entries are removed
|
|
|
|
|
+ * only after sync commits successfully, and only for entries whose
|
|
|
|
|
+ * lastSeenMs <= syncStartedMs. That way, a query that arrives mid-sync
|
|
|
|
|
+ * still sees the affected files marked stale (the DB hasn't been updated
|
|
|
|
|
+ * yet), and an event that lands mid-sync persists into the follow-up.
|
|
|
|
|
+ *
|
|
|
|
|
+ * On sync failure pendingFiles is left untouched — every edit is still
|
|
|
|
|
+ * unindexed, and the rescheduled sync will absorb the same set next time.
|
|
|
*/
|
|
*/
|
|
|
private async flush(): Promise<void> {
|
|
private async flush(): Promise<void> {
|
|
|
// If already syncing, the post-sync check will re-trigger
|
|
// If already syncing, the post-sync check will re-trigger
|
|
|
if (this.syncing || this.stopped) return;
|
|
if (this.syncing || this.stopped) return;
|
|
|
|
|
|
|
|
- this.hasChanges = false;
|
|
|
|
|
|
|
+ this.syncStartedMs = Date.now();
|
|
|
this.syncing = true;
|
|
this.syncing = true;
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
const result = await this.syncFn();
|
|
const result = await this.syncFn();
|
|
|
|
|
+ // Remove entries whose most recent event predates this sync — those
|
|
|
|
|
+ // edits are now in the DB. Entries with lastSeenMs > syncStartedMs
|
|
|
|
|
+ // arrived mid-sync; whether the in-flight sync captured them depends
|
|
|
|
|
+ // on when sync read that file, so we keep them as pending and let
|
|
|
|
|
+ // the follow-up sync handle them. We prefer false positives ("shown
|
|
|
|
|
+ // stale, actually fresh" → at worst one extra Read) over false
|
|
|
|
|
+ // negatives ("shown fresh, actually stale" → misleads the agent).
|
|
|
|
|
+ for (const [filePath, info] of this.pendingFiles) {
|
|
|
|
|
+ if (info.lastSeenMs <= this.syncStartedMs) {
|
|
|
|
|
+ this.pendingFiles.delete(filePath);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
this.onSyncComplete?.(result);
|
|
this.onSyncComplete?.(result);
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
|
logWarn('Watch sync failed', { error: error.message });
|
|
logWarn('Watch sync failed', { error: error.message });
|
|
|
|
|
+ // Failure: leave pendingFiles untouched. Every edit it tracks is
|
|
|
|
|
+ // still unindexed; the rescheduled sync sees the same set.
|
|
|
this.onSyncError?.(error);
|
|
this.onSyncError?.(error);
|
|
|
} finally {
|
|
} finally {
|
|
|
this.syncing = false;
|
|
this.syncing = false;
|
|
|
|
|
|
|
|
- // If new changes arrived during sync, schedule another
|
|
|
|
|
- if (this.hasChanges && !this.stopped) {
|
|
|
|
|
|
|
+ // If pending files remain (mid-sync events, or this sync failed),
|
|
|
|
|
+ // schedule another pass.
|
|
|
|
|
+ if (this.pendingFiles.size > 0 && !this.stopped) {
|
|
|
this.scheduleSync();
|
|
this.scheduleSync();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Snapshot of files seen by the watcher since the last successful sync.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Used by MCP tool responses to mark stale results without blocking on a
|
|
|
|
|
+ * sync: a tool that returns a hit in `src/foo.ts` while `src/foo.ts` is in
|
|
|
|
|
+ * this list tells the agent "Read this file directly, the index lags."
|
|
|
|
|
+ *
|
|
|
|
|
+ * `indexing` is true when a sync is currently in flight whose start time is
|
|
|
|
|
+ * AFTER this file's most recent event — i.e. that sync will absorb the
|
|
|
|
|
+ * edit. False means the file is still inside the debounce window and no
|
|
|
|
|
+ * sync has started yet (a follow-up call a few hundred ms later may show
|
|
|
|
|
+ * `indexing: true` or the file may have left the list entirely).
|
|
|
|
|
+ *
|
|
|
|
|
+ * Cheap: O(pendingFiles.size), no I/O, no locks.
|
|
|
|
|
+ */
|
|
|
|
|
+ getPendingFiles(): PendingFile[] {
|
|
|
|
|
+ const result: PendingFile[] = [];
|
|
|
|
|
+ for (const [filePath, info] of this.pendingFiles) {
|
|
|
|
|
+ result.push({
|
|
|
|
|
+ path: filePath,
|
|
|
|
|
+ firstSeenMs: info.firstSeenMs,
|
|
|
|
|
+ lastSeenMs: info.lastSeenMs,
|
|
|
|
|
+ indexing: this.syncing && this.syncStartedMs >= info.lastSeenMs,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|