* 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
()
| 753 | * unindexed, and the rescheduled sync will absorb the same set next time. |
| 754 | */ |
| 755 | private async flush(): Promise<void> { |
| 756 | // If already syncing, the post-sync check will re-trigger |
| 757 | if (this.syncing || this.stopped) return; |
| 758 | |
| 759 | this.syncStartedMs = Date.now(); |
| 760 | this.syncing = true; |
| 761 | |
| 762 | try { |
| 763 | const result = await this.syncFn(); |
| 764 | this.lockRetryCount = 0; // a clean sync clears any contention backoff |
| 765 | // Remove entries whose most recent event predates this sync — those |
| 766 | // edits are now in the DB. Entries with lastSeenMs > syncStartedMs |
| 767 | // arrived mid-sync; whether the in-flight sync captured them depends |
| 768 | // on when sync read that file, so we keep them as pending and let |
| 769 | // the follow-up sync handle them. We prefer false positives ("shown |
| 770 | // stale, actually fresh" → at worst one extra Read) over false |
| 771 | // negatives ("shown fresh, actually stale" → misleads the agent). |
| 772 | for (const [filePath, info] of this.pendingFiles) { |
| 773 | if (info.lastSeenMs <= this.syncStartedMs) { |
| 774 | this.pendingFiles.delete(filePath); |
| 775 | } |
| 776 | } |
| 777 | this.onSyncComplete?.(result); |
| 778 | } catch (err) { |
| 779 | if (err instanceof LockUnavailableError) { |
| 780 | this.lockRetryCount += 1; |
| 781 | // Lock-failure no-op (another writer holds the lock). pendingFiles |
| 782 | // stays intact and the `finally` block reschedules with backoff. Keep |
| 783 | // brief contention quiet (debug-only — a long external index would |
| 784 | // otherwise spam stderr every cycle), but stop retrying forever: once a |
| 785 | // writer holds the lock past the budget, degrade auto-sync explicitly. |
| 786 | logDebug('Watch sync skipped: file lock unavailable', { |
| 787 | pendingFiles: this.pendingFiles.size, |
| 788 | retryCount: this.lockRetryCount, |
| 789 | }); |
| 790 | if (this.lockRetryCount > MAX_LOCK_RETRIES) { |
| 791 | this.degrade( |
| 792 | 'CodeGraph file lock held by another process past the retry budget; ' + |
| 793 | 'auto-sync disabled. Run `codegraph sync` once the other writer finishes ' + |
| 794 | '(or install git sync hooks) to refresh the graph.', |
| 795 | { pendingFiles: this.pendingFiles.size, retryCount: this.lockRetryCount } |
| 796 | ); |
| 797 | } |
| 798 | } else { |
| 799 | this.lockRetryCount = 0; // a non-lock failure isn't contention; reset backoff |
| 800 | const error = err instanceof Error ? err : new Error(String(err)); |
| 801 | logWarn('Watch sync failed', { error: error.message }); |
| 802 | this.onSyncError?.(error); |
| 803 | } |
| 804 | // Failure: leave pendingFiles untouched. Every edit it tracks is |
| 805 | // still unindexed; the rescheduled sync sees the same set. |
| 806 | } finally { |
| 807 | this.syncing = false; |
| 808 | |
| 809 | // If pending files remain (mid-sync events, or this sync failed), |
| 810 | // schedule another pass. After lock contention, back off exponentially |
| 811 | // (debounceMs · 2^(n-1), capped) instead of retrying at the normal |
| 812 | // debounce cadence; a clean sync resets lockRetryCount so normal edits |
no test coverage detected