* 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
()
| 836 | * unindexed, and the rescheduled sync will absorb the same set next time. |
| 837 | */ |
| 838 | private async flush(): Promise<void> { |
| 839 | // If already syncing, the post-sync check will re-trigger |
| 840 | if (this.syncing || this.stopped) return; |
| 841 | |
| 842 | this.syncStartedMs = Date.now(); |
| 843 | this.syncing = true; |
| 844 | |
| 845 | // Scoped fast path: when every pending change is a known file event, hand |
| 846 | // the exact paths to sync and skip its O(repo) scan-diff. Anything the |
| 847 | // events can't fully describe — a directory removal, an empty pending set |
| 848 | // (retry paths), or an event storm past the ceiling — runs the full |
| 849 | // scan-diff, which remains the ground truth. |
| 850 | const scoped = |
| 851 | !this.needsFullScan && this.pendingFiles.size > 0 && this.pendingFiles.size <= SCOPED_SYNC_MAX_PENDING |
| 852 | ? [...this.pendingFiles.keys()] |
| 853 | : undefined; |
| 854 | |
| 855 | try { |
| 856 | const result = await this.syncFn(scoped); |
| 857 | if (!scoped) this.needsFullScan = false; |
| 858 | this.lockRetryCount = 0; // a clean sync clears any contention backoff |
| 859 | this.syncFailureRetryCount = 0; // ...and any generic-failure backoff |
| 860 | // Remove entries whose most recent event predates this sync — those |
| 861 | // edits are now in the DB. Entries with lastSeenMs > syncStartedMs |
| 862 | // arrived mid-sync; whether the in-flight sync captured them depends |
| 863 | // on when sync read that file, so we keep them as pending and let |
| 864 | // the follow-up sync handle them. We prefer false positives ("shown |
| 865 | // stale, actually fresh" → at worst one extra Read) over false |
| 866 | // negatives ("shown fresh, actually stale" → misleads the agent). |
| 867 | for (const [filePath, info] of this.pendingFiles) { |
| 868 | if (info.lastSeenMs <= this.syncStartedMs) { |
| 869 | this.pendingFiles.delete(filePath); |
| 870 | } |
| 871 | } |
| 872 | this.onSyncComplete?.(result); |
| 873 | } catch (err) { |
| 874 | if (err instanceof LockUnavailableError) { |
| 875 | this.lockRetryCount += 1; |
| 876 | // Lock-failure no-op (another writer holds the lock). pendingFiles |
| 877 | // stays intact and the `finally` block reschedules with backoff. Keep |
| 878 | // brief contention quiet (debug-only — a long external index would |
| 879 | // otherwise spam stderr every cycle), but stop retrying forever: once a |
| 880 | // writer holds the lock past the budget, degrade auto-sync explicitly. |
| 881 | logDebug('Watch sync skipped: file lock unavailable', { |
| 882 | pendingFiles: this.pendingFiles.size, |
| 883 | retryCount: this.lockRetryCount, |
| 884 | }); |
| 885 | if (this.lockRetryCount > MAX_LOCK_RETRIES) { |
| 886 | this.degrade( |
| 887 | 'CodeGraph file lock held by another process past the retry budget; ' + |
| 888 | 'auto-sync disabled. Run `codegraph sync` once the other writer finishes ' + |
| 889 | '(or install git sync hooks) to refresh the graph.', |
| 890 | { pendingFiles: this.pendingFiles.size, retryCount: this.lockRetryCount } |
| 891 | ); |
| 892 | } |
| 893 | } else { |
| 894 | this.lockRetryCount = 0; // a non-lock failure isn't contention; reset that streak |
| 895 | this.syncFailureRetryCount += 1; |
no test coverage detected