@karmaniverous/jeeves-watcher 0.18.8 → 0.18.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, DEFAULT_PORTS, getBindAddress } from '@karmaniverous/jeeves';
3
- import { a as normalizeSlashes, n as normalizeError, r as retry, G as GitignoreFilter, e as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, i as isPathWatched, g as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, l as loadConfig } from './index.js';
4
- export { b as startFromConfig } from './index.js';
3
+ import { d as normalizeSlashes, n as normalizeError, e as execFileAsync, r as retry, b as gitAddViaStdin, c as normalizePathCase, f as findRootForPath, G as GitignoreFilter, a as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, i as isPathWatched, g as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, l as loadConfig } from './index.js';
4
+ export { h as startFromConfig } from './index.js';
5
5
  import { mkdirSync, existsSync, readFileSync, writeFileSync, rmSync, renameSync, readdirSync, statSync } from 'node:fs';
6
6
  import { join, dirname, resolve, extname, basename, relative, sep, isAbsolute } from 'node:path';
7
7
  import Database from 'better-sqlite3';
@@ -10,11 +10,9 @@ import Ajv from 'ajv';
10
10
  import addFormats from 'ajv-formats';
11
11
  import picomatch from 'picomatch';
12
12
  import { deserialize, serialize } from 'node:v8';
13
- import { normalizeWatchPaths, extractWatchPathStrings, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
14
- import { execFile } from 'node:child_process';
15
- import { promisify } from 'node:util';
13
+ import { normalizeWatchPaths, vcsRetentionConfigSchema, extractWatchPathStrings, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
16
14
  import { homedir } from 'node:os';
17
- import { access, rm, readFile, writeFile, appendFile, mkdir, stat } from 'node:fs/promises';
15
+ import { access, rm, stat, readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
18
16
  import { pathToFileURL, fileURLToPath } from 'node:url';
19
17
  import { packageDirectorySync } from 'package-directory';
20
18
  import { JsonMap } from '@karmaniverous/jsonmap';
@@ -40,6 +38,8 @@ import mammoth from 'mammoth';
40
38
  import { MarkdownTextSplitter, RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
41
39
  import { QdrantClient } from '@qdrant/js-client-rest';
42
40
  import 'cosmiconfig';
41
+ import 'node:child_process';
42
+ import 'node:util';
43
43
 
44
44
  /**
45
45
  * @module api/InitialScanTracker
@@ -928,77 +928,6 @@ class CommitMessageGenerator {
928
928
  }
929
929
  }
930
930
 
931
- /**
932
- * @module vcs/gitExec
933
- * Shared git execution utilities.
934
- */
935
- /** Promisified execFile for git commands. */
936
- const execFileAsync = promisify(execFile);
937
- /**
938
- * Stage files via stdin to avoid ENAMETOOLONG on Windows.
939
- *
940
- * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
941
- * through stdin (NUL-delimited) instead of passed as command-line
942
- * arguments. This sidesteps the Windows CreateProcessW 32 767-char
943
- * limit that triggers ENAMETOOLONG when batches contain many files
944
- * with long absolute paths.
945
- *
946
- * @param files - Absolute paths of files to stage.
947
- * @param cwd - Repository root (working directory for git).
948
- */
949
- function gitAddViaStdin(files, cwd) {
950
- return new Promise((resolve, reject) => {
951
- const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
952
- if (error)
953
- reject(error);
954
- else
955
- resolve();
956
- });
957
- if (!child.stdin) {
958
- reject(new Error('Failed to open stdin for git add'));
959
- return;
960
- }
961
- // Suppress EPIPE — expected if git exits before we finish writing
962
- child.stdin.on('error', (err) => {
963
- if (err.code !== 'EPIPE') {
964
- reject(err);
965
- }
966
- });
967
- child.stdin.end(files.join('\0'));
968
- });
969
- }
970
- /**
971
- * Normalize a path for case-insensitive comparison on Windows.
972
- * On Windows, lowercases the entire path; on other platforms, returns as-is.
973
- *
974
- * @param p - The path string to normalize.
975
- * @param platform - The platform to check against (default: `process.platform`).
976
- * @returns The normalized path.
977
- */
978
- function normalizePathCase(p, platform = process.platform) {
979
- return platform === 'win32' ? p.toLowerCase() : p;
980
- }
981
- /**
982
- * Find the longest-prefix-match root for a normalized path.
983
- * Roots must be sorted longest-first for correct nested matching.
984
- *
985
- * @param roots - Root paths sorted longest-first.
986
- * @param normalizedPath - Normalized absolute path (forward slashes).
987
- * @param platform - The platform to check against (default: `process.platform`).
988
- * @returns The matching root, or undefined.
989
- */
990
- function findRootForPath(roots, normalizedPath, platform = process.platform) {
991
- const comparePath = normalizePathCase(normalizedPath, platform);
992
- for (const root of roots) {
993
- const compareRoot = normalizePathCase(root, platform);
994
- if (comparePath === compareRoot ||
995
- comparePath.startsWith(compareRoot + '/')) {
996
- return root;
997
- }
998
- }
999
- return undefined;
1000
- }
1001
-
1002
931
  /**
1003
932
  * @module vcs/resolveCommitMessageApiKey
1004
933
  * Resolves the API key for AI commit message generation.
@@ -1479,6 +1408,7 @@ class VcsManager {
1479
1408
  squashManager;
1480
1409
  debounceTimer;
1481
1410
  commitInFlight = Promise.resolve();
1411
+ consecutiveCommitFailures = 0;
1482
1412
  started = false;
1483
1413
  isBaseline = true;
1484
1414
  _lastPushTime = null;
@@ -1527,6 +1457,11 @@ class VcsManager {
1527
1457
  fileChanged(filePath) {
1528
1458
  if (!this.started)
1529
1459
  return;
1460
+ // Reset circuit breaker on new file change so the system can recover
1461
+ if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
1462
+ this.logger.info({ root: this.rootPath }, 'VCS circuit breaker reset — new file change received, retrying commits');
1463
+ this.consecutiveCommitFailures = 0;
1464
+ }
1530
1465
  this.pending.add(filePath);
1531
1466
  if (this.pending.size >= this.config.maxBatchSize) {
1532
1467
  this.clearDebounce();
@@ -1614,12 +1549,44 @@ class VcsManager {
1614
1549
  }, this.config.commitDebounceMs);
1615
1550
  }
1616
1551
  /**
1617
- * Commit a batch of files to the git repo with index.lock retry.
1552
+ * Check for and remove stale index.lock files before retrying.
1553
+ */
1554
+ async removeStaleLock() {
1555
+ const lockPath = join(this.rootPath, '.git', 'index.lock');
1556
+ try {
1557
+ const lockStat = await stat(lockPath);
1558
+ const ageMs = Date.now() - lockStat.mtimeMs;
1559
+ if (ageMs > this.config.staleLockThresholdMs) {
1560
+ await rm(lockPath, { force: true });
1561
+ this.logger.warn({ root: this.rootPath, ageMs }, 'Removed stale index.lock');
1562
+ }
1563
+ }
1564
+ catch (error) {
1565
+ const code = error.code;
1566
+ if (code !== 'ENOENT') {
1567
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
1568
+ }
1569
+ }
1570
+ }
1571
+ /**
1572
+ * Commit a batch of files to the git repo with index.lock retry,
1573
+ * stale lock detection, circuit breaker, and re-queue cap.
1618
1574
  */
1619
1575
  async commitBatch(files) {
1620
1576
  if (files.length === 0)
1621
1577
  return;
1578
+ // Circuit breaker: skip if too many consecutive failures
1579
+ if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
1580
+ this.logger.error({
1581
+ root: this.rootPath,
1582
+ consecutiveFailures: this.consecutiveCommitFailures,
1583
+ discardedFiles: files.length,
1584
+ }, 'VCS circuit breaker tripped — pending files discarded until next successful commit');
1585
+ return;
1586
+ }
1622
1587
  try {
1588
+ // Check for stale lock before first attempt
1589
+ await this.removeStaleLock();
1623
1590
  await retry(async (attempt) => {
1624
1591
  await gitAddViaStdin(files, this.rootPath);
1625
1592
  // Build message after staging so getStagedDiff can see the changes.
@@ -1645,12 +1612,22 @@ class VcsManager {
1645
1612
  this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
1646
1613
  },
1647
1614
  });
1615
+ // Success — reset circuit breaker
1616
+ this.consecutiveCommitFailures = 0;
1648
1617
  }
1649
1618
  catch (error) {
1650
- for (const f of files) {
1619
+ this.consecutiveCommitFailures++;
1620
+ // Re-queue cap: only re-queue up to maxBatchSize files
1621
+ const cap = this.config.maxBatchSize;
1622
+ const toRequeue = files.slice(0, cap);
1623
+ const overflow = files.length - toRequeue.length;
1624
+ for (const f of toRequeue) {
1651
1625
  this.pending.add(f);
1652
1626
  }
1653
- this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
1627
+ if (overflow > 0) {
1628
+ this.logger.warn({ root: this.rootPath, overflow, cap }, 'Re-queue cap exceeded — discarded overflow files');
1629
+ }
1630
+ this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
1654
1631
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
1655
1632
  }
1656
1633
  }
@@ -1682,10 +1659,14 @@ class VcsCoordinator {
1682
1659
  // Deduplicate: skip if another glob already resolved to this root.
1683
1660
  if (this.managers.has(rootKey))
1684
1661
  continue;
1662
+ const mergedRetention = entry.vcs?.retention ??
1663
+ config.vcs.retention ??
1664
+ vcsRetentionConfigSchema.parse({});
1685
1665
  const mergedConfig = {
1686
1666
  ...config.vcs,
1687
1667
  ...entry.vcs,
1688
1668
  enabled: true,
1669
+ retention: mergedRetention,
1689
1670
  };
1690
1671
  // Create CommitMessageGenerator if AI commit messages are configured
1691
1672
  const cmConfig = mergedConfig.commitMessage;
@@ -3179,10 +3160,10 @@ function createWatcher(config, factories, queue, processor, logger, runtimeOptio
3179
3160
  * Tear down and rebuild the filesystem watcher with new config.
3180
3161
  * Falls back to the old watcher if the new one fails to start.
3181
3162
  */
3182
- async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache) {
3163
+ async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
3183
3164
  try {
3184
3165
  await oldState.watcher.stop();
3185
- const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache);
3166
+ const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete);
3186
3167
  newWatcher.start();
3187
3168
  logger.info('Filesystem watcher rebuilt successfully');
3188
3169
  return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
@@ -3377,7 +3358,7 @@ async function reloadConfig(state, deps) {
3377
3358
  // Rebuild filesystem watcher if watch config changed
3378
3359
  if (watchConfigChanged(oldConfig, newConfig)) {
3379
3360
  logger.info('Watch config changed, rebuilding filesystem watcher...');
3380
- const newState = await rebuildWatcher(newConfig, deps.factories, deps.queue, processor, logger, deps.runtimeOptions, { watcher: state.watcher, gitignoreFilter: state.gitignoreFilter }, deps.initialScanTracker, deps.contentHashCache);
3361
+ const newState = await rebuildWatcher(newConfig, deps.factories, deps.queue, processor, logger, deps.runtimeOptions, { watcher: state.watcher, gitignoreFilter: state.gitignoreFilter }, deps.initialScanTracker, deps.contentHashCache, deps.onVcsFileChange, deps.onInitialScanComplete);
3381
3362
  state.watcher = newState.watcher;
3382
3363
  state.gitignoreFilter = newState.gitignoreFilter;
3383
3364
  }
@@ -7989,6 +7970,8 @@ class JeevesWatcher {
7989
7970
  contentHashCache: this.contentHashCache,
7990
7971
  valuesManager: this.valuesManager,
7991
7972
  issuesManager: this.issuesManager,
7973
+ onVcsFileChange: (filePath, event) => this.vcsCoordinator?.onFileChange(filePath, event),
7974
+ onInitialScanComplete: () => this.vcsCoordinator?.onInitialScanComplete(),
7992
7975
  });
7993
7976
  this.config = state.config;
7994
7977
  this.watcher = state.watcher;
@@ -7,10 +7,12 @@ import { packageDirectorySync } from 'package-directory';
7
7
  import { readFileSync, existsSync, mkdirSync, renameSync, statSync, readdirSync } from 'node:fs';
8
8
  import { cosmiconfig } from 'cosmiconfig';
9
9
  import { ZodError } from 'zod';
10
- import { readdir, stat } from 'node:fs/promises';
10
+ import { readdir, stat, access } from 'node:fs/promises';
11
11
  import { parallel } from 'radash';
12
12
  import ignore from 'ignore';
13
13
  import picomatch from 'picomatch';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
14
16
 
15
17
  /**
16
18
  * @module api/handlers/configMerge
@@ -224,7 +226,7 @@ async function startFromConfig(configPath, descriptor) {
224
226
  const config = await loadConfig(resolvedPath);
225
227
  // Dynamic import breaks the circular dependency between this module
226
228
  // and app/index.ts (which defines JeevesWatcher and re-exports startFromConfig).
227
- const { JeevesWatcher } = await import('./index-BjghvUnc.js');
229
+ const { JeevesWatcher } = await import('./index-xP2XrV4W.js');
228
230
  const app = new JeevesWatcher(config, resolvedPath, descriptor);
229
231
  installShutdownHandlers(() => app.stop());
230
232
  await app.start();
@@ -613,6 +615,77 @@ async function retry(fn, options) {
613
615
  throw lastError;
614
616
  }
615
617
 
618
+ /**
619
+ * @module vcs/gitExec
620
+ * Shared git execution utilities.
621
+ */
622
+ /** Promisified execFile for git commands. */
623
+ const execFileAsync = promisify(execFile);
624
+ /**
625
+ * Stage files via stdin to avoid ENAMETOOLONG on Windows.
626
+ *
627
+ * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
628
+ * through stdin (NUL-delimited) instead of passed as command-line
629
+ * arguments. This sidesteps the Windows CreateProcessW 32 767-char
630
+ * limit that triggers ENAMETOOLONG when batches contain many files
631
+ * with long absolute paths.
632
+ *
633
+ * @param files - Absolute paths of files to stage.
634
+ * @param cwd - Repository root (working directory for git).
635
+ */
636
+ function gitAddViaStdin(files, cwd) {
637
+ return new Promise((resolve, reject) => {
638
+ const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
639
+ if (error)
640
+ reject(error);
641
+ else
642
+ resolve();
643
+ });
644
+ if (!child.stdin) {
645
+ reject(new Error('Failed to open stdin for git add'));
646
+ return;
647
+ }
648
+ // Suppress EPIPE — expected if git exits before we finish writing
649
+ child.stdin.on('error', (err) => {
650
+ if (err.code !== 'EPIPE') {
651
+ reject(err);
652
+ }
653
+ });
654
+ child.stdin.end(files.join('\0'));
655
+ });
656
+ }
657
+ /**
658
+ * Normalize a path for case-insensitive comparison on Windows.
659
+ * On Windows, lowercases the entire path; on other platforms, returns as-is.
660
+ *
661
+ * @param p - The path string to normalize.
662
+ * @param platform - The platform to check against (default: `process.platform`).
663
+ * @returns The normalized path.
664
+ */
665
+ function normalizePathCase(p, platform = process.platform) {
666
+ return platform === 'win32' ? p.toLowerCase() : p;
667
+ }
668
+ /**
669
+ * Find the longest-prefix-match root for a normalized path.
670
+ * Roots must be sorted longest-first for correct nested matching.
671
+ *
672
+ * @param roots - Root paths sorted longest-first.
673
+ * @param normalizedPath - Normalized absolute path (forward slashes).
674
+ * @param platform - The platform to check against (default: `process.platform`).
675
+ * @returns The matching root, or undefined.
676
+ */
677
+ function findRootForPath(roots, normalizedPath, platform = process.platform) {
678
+ const comparePath = normalizePathCase(normalizedPath, platform);
679
+ for (const root of roots) {
680
+ const compareRoot = normalizePathCase(root, platform);
681
+ if (comparePath === compareRoot ||
682
+ comparePath.startsWith(compareRoot + '/')) {
683
+ return root;
684
+ }
685
+ }
686
+ return undefined;
687
+ }
688
+
616
689
  /**
617
690
  * Best-effort base directory inference for a glob pattern.
618
691
  *
@@ -830,7 +903,7 @@ function groupByRoot(filePaths, watchPaths) {
830
903
  /** Default page size for prune scroll (smaller than search to reduce connection strain). */
831
904
  const PRUNE_SCROLL_PAGE_SIZE = 500;
832
905
  /** Max retry attempts for scroll page failures. */
833
- const SCROLL_RETRY_ATTEMPTS = 3;
906
+ const SCROLL_RETRY_ATTEMPTS = 5;
834
907
  /** Base delay for scroll retry backoff (ms). */
835
908
  const SCROLL_RETRY_BASE_DELAY_MS = 1000;
836
909
  /**
@@ -856,26 +929,42 @@ async function scrollPageWithRetry(vectorStore, cursor, logger) {
856
929
  }
857
930
  throw lastError;
858
931
  }
932
+ /** Concurrency limit for fs.access() disk-existence checks. */
933
+ const FS_ACCESS_CONCURRENCY = 100;
859
934
  /**
860
- * Compute the plan for a prune operation.
861
- * Scrolls all Qdrant points page-by-page with retry/resume,
862
- * checks each file_path against watch scope + gitignore,
863
- * returns the list of orphaned point IDs and the plan.
935
+ * Compute the plan for a prune operation using a filter-first approach.
936
+ *
937
+ * 1. Enumerate all watched files from the filesystem → `watchedFiles` set.
938
+ * 2. Scroll Qdrant points. For each point:
939
+ * - No `file_path` → orphan.
940
+ * - `file_path` not in `watchedFiles` → orphan (out of watch scope).
941
+ * - `file_path` in `watchedFiles` → check disk existence via `fs.access()`.
942
+ * If missing from disk → orphan (deleted file still in index).
943
+ * 3. Batch `fs.access()` checks with bounded concurrency.
864
944
  */
865
945
  async function computePrunePlan(deps) {
866
946
  const { config, vectorStore, gitignoreFilter, logger } = deps;
867
947
  if (!vectorStore) {
868
948
  throw new Error('vectorStore is required for prune scope');
869
949
  }
950
+ // Step 1: Build set of watched files from filesystem
951
+ const isGitignored = createIsGitignored(gitignoreFilter);
952
+ const watchedFileList = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
953
+ const watchedFiles = new Set(watchedFileList.map((f) => normalizePathCase(normalizeSlashes(f))));
954
+ logger.info({ watchedFileCount: watchedFiles.size }, 'Prune: enumerated watched files from filesystem');
955
+ // Step 2: Scroll Qdrant and classify points
870
956
  const seenFiles = new Set();
871
957
  const orphanedFiles = new Set();
958
+ const diskCheckFiles = new Set();
872
959
  const orphanedIds = [];
960
+ // Map from file_path → point IDs, for points needing disk-existence check
961
+ const diskCheckPointIds = new Map();
873
962
  let totalPoints = 0;
874
963
  let incomplete = false;
875
964
  let cursor;
876
965
  try {
877
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cursor is updated each iteration
878
- while (true) {
966
+ let hasMore = true;
967
+ while (hasMore) {
879
968
  const page = await scrollPageWithRetry(vectorStore, cursor, logger);
880
969
  for (const point of page.points) {
881
970
  totalPoints++;
@@ -884,32 +973,62 @@ async function computePrunePlan(deps) {
884
973
  orphanedIds.push(point.id);
885
974
  continue;
886
975
  }
887
- if (seenFiles.has(filePath)) {
888
- if (orphanedFiles.has(filePath)) {
976
+ const normalized = normalizePathCase(normalizeSlashes(filePath));
977
+ if (seenFiles.has(normalized)) {
978
+ if (orphanedFiles.has(normalized)) {
889
979
  orphanedIds.push(point.id);
890
980
  }
981
+ else if (diskCheckFiles.has(normalized)) {
982
+ // Already queued for disk check — track its ID
983
+ const ids = diskCheckPointIds.get(normalized);
984
+ if (ids)
985
+ ids.push(point.id);
986
+ }
891
987
  continue;
892
988
  }
893
- seenFiles.add(filePath);
894
- const watched = isPathWatched(filePath, extractWatchPathStrings(config.watch.paths), config.watch.ignored);
895
- const gitignored = gitignoreFilter
896
- ? gitignoreFilter.isIgnored(filePath)
897
- : false;
898
- if (!watched || gitignored) {
899
- orphanedFiles.add(filePath);
989
+ seenFiles.add(normalized);
990
+ if (!watchedFiles.has(normalized)) {
991
+ // Not in watch scope → definitely orphaned
992
+ orphanedFiles.add(normalized);
900
993
  orphanedIds.push(point.id);
901
994
  }
995
+ else {
996
+ // In watch scope → needs disk-existence check
997
+ diskCheckFiles.add(normalized);
998
+ diskCheckPointIds.set(normalized, [point.id]);
999
+ }
1000
+ }
1001
+ // Progress logging every 100K points
1002
+ if (totalPoints > 0 && totalPoints % 100000 === 0) {
1003
+ logger.info({ totalPoints, orphanedCount: orphanedIds.length }, 'Prune scroll progress');
1004
+ }
1005
+ if (!page.nextCursor) {
1006
+ hasMore = false;
1007
+ }
1008
+ else {
1009
+ cursor = page.nextCursor;
902
1010
  }
903
- if (!page.nextCursor)
904
- break;
905
- cursor = page.nextCursor;
906
1011
  }
907
1012
  }
908
1013
  catch (error) {
909
- // All retries exhausted - return partial results
910
1014
  logger.error({ err: normalizeError(error), totalPoints, cursor }, 'Prune scroll failed after retries; returning partial plan');
911
1015
  incomplete = true;
912
1016
  }
1017
+ // Step 3: Batch fs.access() checks for files in watch scope
1018
+ const filesToCheck = [...diskCheckFiles];
1019
+ await parallel(FS_ACCESS_CONCURRENCY, filesToCheck, async (filePath) => {
1020
+ try {
1021
+ await access(filePath);
1022
+ }
1023
+ catch {
1024
+ // File doesn't exist on disk → orphaned
1025
+ orphanedFiles.add(filePath);
1026
+ const pointIds = diskCheckPointIds.get(filePath);
1027
+ if (pointIds) {
1028
+ orphanedIds.push(...pointIds);
1029
+ }
1030
+ }
1031
+ });
913
1032
  const orphanedFileList = [...orphanedFiles];
914
1033
  const byRoot = groupByRoot(orphanedFileList, extractWatchPathStrings(config.watch.paths));
915
1034
  return {
@@ -967,6 +1086,12 @@ async function executeReindex(deps, scope, path, dryRun = false) {
967
1086
  deps.queue?.resume();
968
1087
  return { filesProcessed: 0, durationMs: 0, errors: 0, plan };
969
1088
  }
1089
+ // Abort live prune when plan is incomplete — never silently delete partial data
1090
+ if (plan.incomplete) {
1091
+ deps.queue?.resume();
1092
+ logger.error({ totalPoints: plan.total, pointsToDelete: plan.toDelete }, 'Aborting live prune — scroll was incomplete; partial deletes are unsafe');
1093
+ return { filesProcessed: 0, durationMs: 0, errors: 1, plan };
1094
+ }
970
1095
  // Execute prune
971
1096
  reindexTracker?.start(scope);
972
1097
  const startTime = Date.now();
@@ -1456,4 +1581,4 @@ const watcherDescriptor = {
1456
1581
  const program = createServiceCli(watcherDescriptor);
1457
1582
  program.parse();
1458
1583
 
1459
- export { CONFIG_WATCH_VALID_SCOPES as C, GitignoreFilter as G, VALID_REINDEX_SCOPES as V, normalizeSlashes as a, startFromConfig as b, executeReindex as e, getWatchRootBases as g, isPathWatched as i, loadConfig as l, mergeInferenceRules as m, normalizeError as n, retry as r, sleep as s };
1584
+ export { CONFIG_WATCH_VALID_SCOPES as C, GitignoreFilter as G, VALID_REINDEX_SCOPES as V, executeReindex as a, gitAddViaStdin as b, normalizePathCase as c, normalizeSlashes as d, execFileAsync as e, findRootForPath as f, getWatchRootBases as g, startFromConfig as h, isPathWatched as i, loadConfig as l, mergeInferenceRules as m, normalizeError as n, retry as r, sleep as s };
package/dist/index.d.ts CHANGED
@@ -1295,6 +1295,7 @@ declare class VcsManager {
1295
1295
  private readonly squashManager;
1296
1296
  private debounceTimer;
1297
1297
  private commitInFlight;
1298
+ private consecutiveCommitFailures;
1298
1299
  private started;
1299
1300
  private isBaseline;
1300
1301
  private _lastPushTime;
@@ -1355,7 +1356,12 @@ declare class VcsManager {
1355
1356
  */
1356
1357
  private resetDebounce;
1357
1358
  /**
1358
- * Commit a batch of files to the git repo with index.lock retry.
1359
+ * Check for and remove stale index.lock files before retrying.
1360
+ */
1361
+ private removeStaleLock;
1362
+ /**
1363
+ * Commit a batch of files to the git repo with index.lock retry,
1364
+ * stale lock detection, circuit breaker, and re-queue cap.
1359
1365
  */
1360
1366
  private commitBatch;
1361
1367
  }