grepmax 0.26.62 → 0.26.64

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.
@@ -10,7 +10,7 @@
10
10
  "name": "grepmax",
11
11
  "source": "./plugins/grepmax",
12
12
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
13
- "version": "0.26.62",
13
+ "version": "0.26.64",
14
14
  "author": {
15
15
  "name": "Robert Owens",
16
16
  "email": "robowens@me.com"
package/README.md CHANGED
@@ -227,6 +227,8 @@ The CLI and MCP output include score, max similarity, pair count, representative
227
227
 
228
228
  A single daemon watches your projects via native OS file events (FSEvents/inotify). Changes are detected in sub-second and incrementally reindexed. All writes to LanceDB are routed through the daemon via IPC, eliminating lock contention.
229
229
 
230
+ Filesystem watching belongs to gmax, in both daemon and standalone watch modes. Hetchy Developer is an optional monitor/controller that reads gmax status and invokes gmax commands. Watching, indexing and search must work with the dev app and its helper absent or stopped. Any future shared watcher extraction must be an independent component or package with an interface and lifecycle usable without Hetchy Developer; the dev app cannot become a gmax runtime dependency.
231
+
230
232
  The daemon watches only projects that are in use: each Claude Code session (its MCP server and session hooks) holds a lease on its project, and `gmax add` / `gmax index` take a short one. When the last lease ends the project is unwatched; its index stays searchable, and the next session's catch-up scan picks up whatever changed in between. Set `GMAX_WATCH_ALL=1` to watch every registered project instead.
231
233
 
232
234
  ```bash
@@ -238,7 +240,11 @@ gmax status # See all projects + watcher status
238
240
 
239
241
  Adding or indexing a project can start the daemon automatically. MCP acquires primary-project watch leases lazily when an index-reading tool is used; discovery and catalog listing start no background work. It shuts down after 4 hours of inactivity, and hands off to a fresh daemon after 24 hours or once its memory footprint passes 2.5 GB. File-change batches are processed on up to four worker processes by default, added only when work backs up, with one kept free for searches; full-table compaction and new FTS/ANN index builds are disabled by containment. Existing indexes remain readable. Host safety stops persist across restart and block heavy operations.
240
242
 
241
- `gmax watch status` reports failed files, native watcher recovery or polling, dropped-event counts, and the last complete filesystem reconciliation. Polling scans run every five minutes, so recent edits can lag. Search and MCP responses retain health warnings even after the file queue drains. A complete reconciliation means the filesystem scan completed; queued embedding work can still be pending.
243
+ `gmax watch status` reports failed files, native watcher recovery or polling, dropped-event counts, and the last complete filesystem reconciliation. FSEvents dropped-event warnings keep the native stream attached and preserve the accompanying events. A reconciliation covers the gap; repeated warnings coalesce into one later scan, with a 30-second scan interval. Recovery stays visible until a complete scan covers the latest gap. Other backend errors still trigger subscription recovery; repeated terminal failures fall back to five-minute polling, where recent edits can lag. Search and MCP responses retain health warnings even after the file queue drains. A complete reconciliation means the filesystem scan completed; queued embedding work can still be pending.
244
+
245
+ Live filesystem edits take priority over background catchup and cleanup. Background batches dispatch one file at a time, with at most four catchup files or 50 cleanup paths. They yield between files when a live edit arrives or the two-second dispatch window is reached; an active file or store commit finishes before yielding. Catchup and cleanup alternate, and one background file is admitted after three consecutive live batches so cleanup continues during sustained editing. Existing retry backoff and worker limits remain in effect.
246
+
247
+ `gmax status --json` exposes `projects[].health.queue`; daemon IPC and MCP `index_status` expose the same `indexState.queue`. Counts `live`, `catchup` and `cleanup` cover waiting paths; `activeFiles` covers the selected current batch, including files not yet dispatched. `oldestLiveEditAgeMs` includes both waiting and current-batch live edits until commit, and is null when none remain. Human/agent status, `gmax watch status` and MCP health text show these measures separately. Initial full indexing uses its existing progress counters rather than these watcher queues. Older daemons may omit queue diagnostics.
242
248
 
243
249
  Temporary embedding backend outages preserve existing indexed rows and retry with backoff from five seconds up to one minute, without exhausting individual files' retry budgets. MLX failures report bounded transport/protocol details in the daemon log. `gmax status --json` includes the most recent resource snapshot when a recycle threshold has been reached; these counters help investigate memory growth, whose cause requires measurement over time.
244
250
 
@@ -1842,7 +1842,8 @@ exports.mcp = new commander_1.Command("mcp")
1842
1842
  }
1843
1843
  function handleIndexStatus() {
1844
1844
  return __awaiter(this, void 0, void 0, function* () {
1845
- var _a, _b, _c, _d, _e;
1845
+ var _a;
1846
+ var _b, _c, _d, _e, _f;
1846
1847
  try {
1847
1848
  const globalConfig = storeConfig();
1848
1849
  const projects = storeProjects();
@@ -1860,7 +1861,7 @@ exports.mcp = new commander_1.Command("mcp")
1860
1861
  : (0, watcher_store_1.getWatcherCoveringPath)(currentRoot());
1861
1862
  let watcherLine = "Watcher: not running";
1862
1863
  if (watcher) {
1863
- const status = (_a = watcher.status) !== null && _a !== void 0 ? _a : "unknown";
1864
+ const status = (_b = watcher.status) !== null && _b !== void 0 ? _b : "unknown";
1864
1865
  const root = path.basename(watcher.projectRoot);
1865
1866
  const reindex = watcher.lastReindex
1866
1867
  ? `last reindex: ${Math.round((Date.now() - watcher.lastReindex) / 60000)}m ago`
@@ -1871,12 +1872,14 @@ exports.mcp = new commander_1.Command("mcp")
1871
1872
  }
1872
1873
  }
1873
1874
  if (daemonProject) {
1874
- watcherLine = `Watcher: ${(_b = daemonProject.status) !== null && _b !== void 0 ? _b : "unknown"} (${path.basename(daemonProject.root)}/)`;
1875
+ watcherLine = `Watcher: ${(_c = daemonProject.status) !== null && _c !== void 0 ? _c : "unknown"} (${path.basename(daemonProject.root)}/)`;
1875
1876
  const health = (0, index_state_footer_1.formatIndexStateFooter)(daemonProject.indexState, {
1876
1877
  agent: true,
1877
1878
  });
1878
1879
  if (health)
1879
1880
  watcherLine += `\n${health}`;
1881
+ if ((_a = daemonProject.indexState) === null || _a === void 0 ? void 0 : _a.queue)
1882
+ watcherLine += `\nQueue: ${(0, index_state_footer_1.formatWatchQueue)(daemonProject.indexState.queue)}`;
1880
1883
  }
1881
1884
  // Health overview only. The per-project listing (names, roots,
1882
1885
  // per-project chunk counts) lives in `list_projects` — keep this tool
@@ -1919,8 +1922,8 @@ exports.mcp = new commander_1.Command("mcp")
1919
1922
  : null,
1920
1923
  },
1921
1924
  watcher: {
1922
- status: (_d = (_c = daemonProject === null || daemonProject === void 0 ? void 0 : daemonProject.status) !== null && _c !== void 0 ? _c : watcher === null || watcher === void 0 ? void 0 : watcher.status) !== null && _d !== void 0 ? _d : (secondary() || !daemonStatus.ok ? "unobserved" : "not_running"),
1923
- indexState: (_e = daemonProject === null || daemonProject === void 0 ? void 0 : daemonProject.indexState) !== null && _e !== void 0 ? _e : null,
1925
+ status: (_e = (_d = daemonProject === null || daemonProject === void 0 ? void 0 : daemonProject.status) !== null && _d !== void 0 ? _d : watcher === null || watcher === void 0 ? void 0 : watcher.status) !== null && _e !== void 0 ? _e : (secondary() || !daemonStatus.ok ? "unobserved" : "not_running"),
1926
+ indexState: (_f = daemonProject === null || daemonProject === void 0 ? void 0 : daemonProject.indexState) !== null && _f !== void 0 ? _f : null,
1924
1927
  },
1925
1928
  compaction: compaction.success ? compaction.data : null,
1926
1929
  });
@@ -273,8 +273,8 @@ Examples:
273
273
  gmax status --json The same, plus daemon and settings, as JSON
274
274
  `)
275
275
  .action((opts) => __awaiter(void 0, void 0, void 0, function* () {
276
- var _a, _b, _c, _d;
277
- var _e, _f, _g, _h, _j, _k, _l, _m;
276
+ var _a, _b, _c, _d, _e, _f, _g, _h;
277
+ var _j, _k, _l, _m, _o, _p, _q, _r;
278
278
  const globalConfig = (0, index_config_1.readGlobalConfig)();
279
279
  const projects = (0, project_registry_1.listProjects)();
280
280
  const indexing = (0, lock_1.isLocked)(config_1.PATHS.globalRoot);
@@ -316,7 +316,7 @@ Examples:
316
316
  console.log(`\n${style.bold("gmax")} · ${globalConfig.modelTier} (${globalConfig.vectorDim}d, ${globalConfig.embedMode})${indexing ? style.yellow(" · indexing...") : ""}`);
317
317
  }
318
318
  if (((_b = (_a = view.daemon) === null || _a === void 0 ? void 0 : _a.service) === null || _b === void 0 ? void 0 : _b.mode) === "paused") {
319
- const reason = ((_e = view.daemon.service.reason) !== null && _e !== void 0 ? _e : "host pressure").replace(/[\r\n\t]/g, " ");
319
+ const reason = ((_j = view.daemon.service.reason) !== null && _j !== void 0 ? _j : "host pressure").replace(/[\r\n\t]/g, " ");
320
320
  console.log(opts.agent
321
321
  ? `daemon_service\tmode=paused\treason=${reason}`
322
322
  : style.yellow(`Bounded reads available; indexing and embeddings paused: ${reason}`));
@@ -331,14 +331,14 @@ Examples:
331
331
  if (opts.agent) {
332
332
  console.log(`store\t${store.home}\t${store.state}\tprefixes=${store.prefixes.join(",")}${store.error ? `\terror=${store.error}` : ""}`);
333
333
  for (const project of store.projects)
334
- console.log(`store_project\t${store.home}\t${project.name}\t${(_f = project.chunkCount) !== null && _f !== void 0 ? _f : 0}\t${(_g = project.status) !== null && _g !== void 0 ? _g : "indexed"}\tcounts=cached`);
334
+ console.log(`store_project\t${store.home}\t${project.name}\t${(_k = project.chunkCount) !== null && _k !== void 0 ? _k : 0}\t${(_l = project.status) !== null && _l !== void 0 ? _l : "indexed"}\tcounts=cached`);
335
335
  }
336
336
  else {
337
337
  console.log(`\nStore: ${shortenPath(store.home)} — ${store.state}${store.error ? ` (${store.error})` : ""}`);
338
338
  if (store.state === "offline")
339
339
  console.log(" Connect the drive to list its projects.");
340
340
  for (const project of store.projects)
341
- console.log(` ${project.name} ${formatChunks(project.chunkCount)} chunks (cached) ${(_h = project.status) !== null && _h !== void 0 ? _h : "indexed"}`);
341
+ console.log(` ${project.name} ${formatChunks(project.chunkCount)} chunks (cached) ${(_m = project.status) !== null && _m !== void 0 ? _m : "indexed"}`);
342
342
  }
343
343
  }
344
344
  if (projects.length === 0) {
@@ -355,9 +355,10 @@ Examples:
355
355
  for (const project of projects) {
356
356
  const st = projectState(project, watchers.get(project.root));
357
357
  const isCurrent = project.root === currentRoot;
358
- const count = (_j = chunkCounts.get(project.root)) !== null && _j !== void 0 ? _j : project.chunkCount;
358
+ const count = (_o = chunkCounts.get(project.root)) !== null && _o !== void 0 ? _o : project.chunkCount;
359
359
  const identity = (0, embedding_status_1.projectEmbeddingStatus)(project, globalConfig);
360
- console.log(`${project.name}\t${formatChunks(count)}\t${formatAge(project.lastIndexed)}\t${st}\tembedding=${identity.state}${isCurrent ? "\tcurrent" : ""}${((_c = view.health) === null || _c === void 0 ? void 0 : _c.get(project.root)) ? `\t${(_k = (0, index_state_footer_1.formatIndexStateFooter)(view.health.get(project.root), { agent: true })) !== null && _k !== void 0 ? _k : ""}` : ""}`);
360
+ const queue = (_d = (_c = view.health) === null || _c === void 0 ? void 0 : _c.get(project.root)) === null || _d === void 0 ? void 0 : _d.queue;
361
+ console.log(`${project.name}\t${formatChunks(count)}\t${formatAge(project.lastIndexed)}\t${st}\tembedding=${identity.state}${isCurrent ? "\tcurrent" : ""}${((_e = view.health) === null || _e === void 0 ? void 0 : _e.get(project.root)) ? `\t${(_p = (0, index_state_footer_1.formatIndexStateFooter)(view.health.get(project.root), { agent: true })) !== null && _p !== void 0 ? _p : ""}` : ""}${queue ? `\t${(0, index_state_footer_1.formatWatchQueue)(queue)}` : ""}`);
361
362
  }
362
363
  const legacyNotice = (0, embedding_status_1.formatLegacyEmbeddingNotice)((0, embedding_status_1.countLegacyEmbeddingProjects)(projects, globalConfig), { agent: true });
363
364
  if (legacyNotice)
@@ -373,7 +374,7 @@ Examples:
373
374
  const watcher = watchers.get(project.root);
374
375
  // Status column
375
376
  let statusStr;
376
- const projectStatus = (_l = project.status) !== null && _l !== void 0 ? _l : "indexed";
377
+ const projectStatus = (_q = project.status) !== null && _q !== void 0 ? _q : "indexed";
377
378
  if (projectStatus === "pending") {
378
379
  statusStr = style.yellow("pending");
379
380
  }
@@ -393,7 +394,7 @@ Examples:
393
394
  statusStr = style.dim("idle");
394
395
  }
395
396
  // Chunks column
396
- const count = (_m = chunkCounts.get(project.root)) !== null && _m !== void 0 ? _m : project.chunkCount;
397
+ const count = (_r = chunkCounts.get(project.root)) !== null && _r !== void 0 ? _r : project.chunkCount;
397
398
  const chunks = `${formatChunks(count)} chunks`;
398
399
  // Age column
399
400
  const age = formatAge(project.lastIndexed);
@@ -402,11 +403,14 @@ Examples:
402
403
  const marker = isCurrent ? style.cyan(" ←") : "";
403
404
  const name = project.name.padEnd(nameWidth);
404
405
  console.log(` ${name} ${chunks.padEnd(12)} ${age.padEnd(10)} ${statusStr} embedding:${embedding}${marker}`);
405
- const health = (0, index_state_footer_1.formatIndexStateFooter)((_d = view.health) === null || _d === void 0 ? void 0 : _d.get(project.root), {
406
+ const health = (0, index_state_footer_1.formatIndexStateFooter)((_f = view.health) === null || _f === void 0 ? void 0 : _f.get(project.root), {
406
407
  agent: false,
407
408
  });
408
409
  if (health)
409
410
  console.log(` ${health}`);
411
+ const queue = (_h = (_g = view.health) === null || _g === void 0 ? void 0 : _g.get(project.root)) === null || _h === void 0 ? void 0 : _h.queue;
412
+ if (queue)
413
+ console.log(` queue: ${(0, index_state_footer_1.formatWatchQueue)(queue)}`);
410
414
  }
411
415
  const legacyNotice = (0, embedding_status_1.formatLegacyEmbeddingNotice)((0, embedding_status_1.countLegacyEmbeddingProjects)(projects, globalConfig));
412
416
  if (legacyNotice)
@@ -401,7 +401,8 @@ exports.watch
401
401
  .command("status")
402
402
  .description("Show running watchers")
403
403
  .action(() => __awaiter(void 0, void 0, void 0, function* () {
404
- var _a, _b, _c;
404
+ var _a;
405
+ var _b, _c, _d;
405
406
  const { sendDaemonCommand } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/daemon-client")));
406
407
  const resp = yield sendDaemonCommand({ cmd: "status" });
407
408
  if (resp.ok) {
@@ -417,8 +418,10 @@ exports.watch
417
418
  const health = (0, index_state_footer_1.formatIndexStateFooter)(p.indexState, { agent: true });
418
419
  if (health)
419
420
  console.log(` ${health}`);
421
+ if ((_a = p.indexState) === null || _a === void 0 ? void 0 : _a.queue)
422
+ console.log(` queue: ${(0, index_state_footer_1.formatWatchQueue)(p.indexState.queue)}`);
420
423
  if (p.indexState)
421
- console.log(` watcher=${(_a = p.indexState.watcherMode) !== null && _a !== void 0 ? _a : "unknown"} overflows=${(_b = p.indexState.overflowCount) !== null && _b !== void 0 ? _b : 0} lastReconciled=${p.indexState.lastReconciledAt ? new Date(p.indexState.lastReconciledAt).toISOString() : "unknown"} catchupMs=${(_c = p.indexState.catchupMs) !== null && _c !== void 0 ? _c : "unknown"}`);
424
+ console.log(` watcher=${(_b = p.indexState.watcherMode) !== null && _b !== void 0 ? _b : "unknown"} overflows=${(_c = p.indexState.overflowCount) !== null && _c !== void 0 ? _c : 0} lastReconciled=${p.indexState.lastReconciledAt ? new Date(p.indexState.lastReconciledAt).toISOString() : "unknown"} catchupMs=${(_d = p.indexState.catchupMs) !== null && _d !== void 0 ? _d : "unknown"}`);
422
425
  }
423
426
  if (projects.length === 0) {
424
427
  console.log(" (no projects)");
@@ -980,13 +980,14 @@ class Daemon {
980
980
  * on every search to annotate partial-result responses (Phase 6).
981
981
  */
982
982
  indexState(root) {
983
- var _a, _b, _c, _d;
984
- var _e, _f, _g, _h, _j;
983
+ var _a;
984
+ var _b, _c, _d, _e;
985
985
  const processor = this.processors.get(root);
986
- const batchPending = (_e = (_a = processor === null || processor === void 0 ? void 0 : processor.progress) === null || _a === void 0 ? void 0 : _a.pendingFiles) !== null && _e !== void 0 ? _e : 0;
987
- const processing = (_f = (_b = processor === null || processor === void 0 ? void 0 : processor.progress) === null || _b === void 0 ? void 0 : _b.processing) !== null && _f !== void 0 ? _f : false;
986
+ const progress = processor === null || processor === void 0 ? void 0 : processor.progress;
987
+ const batchPending = (_b = progress === null || progress === void 0 ? void 0 : progress.pendingFiles) !== null && _b !== void 0 ? _b : 0;
988
+ const processing = (_c = progress === null || progress === void 0 ? void 0 : progress.processing) !== null && _c !== void 0 ? _c : false;
988
989
  // status === "pending" means the initial full index hasn't completed.
989
- const initialPending = ((_c = (0, project_registry_1.getProject)(root)) === null || _c === void 0 ? void 0 : _c.status) === "pending";
990
+ const initialPending = ((_a = (0, project_registry_1.getProject)(root)) === null || _a === void 0 ? void 0 : _a.status) === "pending";
990
991
  // A full index (--reset / initial) bypasses the batch processor; its
991
992
  // onProgress feeds indexProgress, giving a real remaining count.
992
993
  const fullIdx = this.indexProgress.get(root);
@@ -998,7 +999,7 @@ class Daemon {
998
999
  // index genuinely has work outstanding; a post-FSEvents-drop catchup mostly
999
1000
  // re-verifies unchanged files. Only claim "verifying" with a real sample and
1000
1001
  // no initial-index work pending.
1001
- const { recentFiles, recentReindexed } = (_g = processor === null || processor === void 0 ? void 0 : processor.progress) !== null && _g !== void 0 ? _g : {
1002
+ const { recentFiles, recentReindexed } = progress !== null && progress !== void 0 ? progress : {
1002
1003
  recentFiles: 0,
1003
1004
  recentReindexed: 0,
1004
1005
  };
@@ -1008,8 +1009,10 @@ class Daemon {
1008
1009
  recentReindexed === 0 &&
1009
1010
  pendingFiles > 0;
1010
1011
  const health = this.watcherManager.health(root);
1011
- return Object.assign(Object.assign({ indexing: this.pausedReason == null &&
1012
- (!!fullIdx || processing || batchPending > 0 || initialPending), pendingFiles: this.pausedReason == null ? pendingFiles : 0, verifying: this.pausedReason == null && verifying }, health), { failedFiles: Math.max((_h = (_d = processor === null || processor === void 0 ? void 0 : processor.progress) === null || _d === void 0 ? void 0 : _d.failedFiles) !== null && _h !== void 0 ? _h : 0, (_j = health.failedFiles) !== null && _j !== void 0 ? _j : 0) });
1012
+ return Object.assign(Object.assign(Object.assign({ indexing: this.pausedReason == null &&
1013
+ (!!fullIdx || processing || batchPending > 0 || initialPending), pendingFiles: this.pausedReason == null ? pendingFiles : 0, verifying: this.pausedReason == null && verifying }, health), (this.pausedReason == null && (progress === null || progress === void 0 ? void 0 : progress.queue)
1014
+ ? { queue: progress.queue }
1015
+ : {})), { failedFiles: Math.max((_d = progress === null || progress === void 0 ? void 0 : progress.failedFiles) !== null && _d !== void 0 ? _d : 0, (_e = health.failedFiles) !== null && _e !== void 0 ? _e : 0) });
1013
1016
  }
1014
1017
  search(payload, signal) {
1015
1018
  return __awaiter(this, void 0, void 0, function* () {
@@ -57,6 +57,7 @@ const cache_coherence_1 = require("../index/cache-coherence");
57
57
  const file_policy_1 = require("../index/file-policy");
58
58
  const walker_1 = require("../index/walker");
59
59
  const watcher_1 = require("../index/watcher");
60
+ const watcher_errors_1 = require("../index/watcher-errors");
60
61
  const watcher_ignore_1 = require("../index/watcher-ignore");
61
62
  const file_utils_1 = require("../utils/file-utils");
62
63
  const logger_1 = require("../utils/logger");
@@ -68,7 +69,7 @@ const watcher_store_1 = require("../utils/watcher-store");
68
69
  // overflows has passed. Probe often; a failed probe just drops back to polling.
69
70
  const FSEVENTS_RECOVERY_INTERVAL_MS = 15 * 60 * 1000;
70
71
  const FSEVENTS_HEALTH_WINDOW_MS = 5 * 60 * 1000; // 5 min of quiet = "healthy"
71
- // Overflows arrive in bursts (a build, a checkout, a venv install). Every
72
+ // Terminal subscription failures can arrive in bursts. Every
72
73
  // recovery inside this window folds into one deferred catchup scan instead of
73
74
  // each rescanning the project (~25s on a 26k-file tree) or skipping outright
74
75
  // and leaving its drop window uncovered.
@@ -104,18 +105,21 @@ class WatcherManager {
104
105
  this.pollIntervals = new Map();
105
106
  this.pollRecoveryTimers = new Map();
106
107
  this.recoveryTimeouts = new Map();
107
- this.lastOverflowMs = new Map();
108
+ this.lastWatcherFailureMs = new Map();
108
109
  this.lastWatcherErrorLogMs = new Map();
109
110
  this.overflowCounts = new Map();
110
111
  this.reconciledAt = new Map();
111
112
  this.catchupDurations = new Map();
112
113
  this.lastCatchupEndMs = new Map();
114
+ this.lastCatchupStartMs = new Map();
115
+ this.overflowPendingRoots = new Set();
113
116
  this.deferredCatchups = new Map();
114
117
  this.catchups = new Map();
115
118
  this.degradedRoots = new Set();
116
119
  this.terminalFailures = new Map();
117
120
  this.watchLifecycles = new Map();
118
121
  this.subscriptionUpdates = new Map();
122
+ this.watcherGenerations = new Map();
119
123
  }
120
124
  health(root) {
121
125
  var _a;
@@ -126,7 +130,8 @@ class WatcherManager {
126
130
  watcherMode: this.pollIntervals.has(root)
127
131
  ? "polling"
128
132
  : this.pendingOps.has(`recover:${root}`) ||
129
- this.deferredCatchups.has(root)
133
+ this.deferredCatchups.has(root) ||
134
+ this.overflowPendingRoots.has(root)
130
135
  ? "recovering"
131
136
  : "native",
132
137
  catchupRunning: this.catchups.has(root),
@@ -250,7 +255,7 @@ class WatcherManager {
250
255
  const name = path.basename(root);
251
256
  console.error(`[daemon:${name}] Subscribe failed at startup (${err instanceof Error ? err.message : err}) — switching to poll mode`);
252
257
  this.watcherFailCount.set(root, 1000); // > MAX_WATCHER_RETRIES
253
- this.lastOverflowMs.set(root, Date.now());
258
+ this.lastWatcherFailureMs.set(root, Date.now());
254
259
  this.recoverWatcher(root, processor);
255
260
  }
256
261
  if (!ownsLifecycle())
@@ -311,6 +316,8 @@ class WatcherManager {
311
316
  if (this.deps.processors.get(root) !== processor ||
312
317
  ((_b = (_a = this.deps).getShuttingDown) === null || _b === void 0 ? void 0 : _b.call(_a)))
313
318
  return;
319
+ const generation = Symbol(root);
320
+ this.watcherGenerations.set(root, generation);
314
321
  // Unsubscribe existing watcher if any (e.g. during recovery)
315
322
  const existingSub = this.deps.subscriptions.get(root);
316
323
  if (existingSub) {
@@ -322,21 +329,30 @@ class WatcherManager {
322
329
  }
323
330
  const sub = yield watcher.subscribe(root, (err, events) => {
324
331
  var _a, _b;
325
- if (this.deps.processors.get(root) !== processor)
332
+ var _c, _d;
333
+ if (this.deps.processors.get(root) !== processor ||
334
+ this.watcherGenerations.get(root) !== generation ||
335
+ ((_b = (_a = this.deps).getShuttingDown) === null || _b === void 0 ? void 0 : _b.call(_a)))
326
336
  return;
327
337
  if (err) {
328
- this.overflowCounts.set(root, ((_a = this.overflowCounts.get(root)) !== null && _a !== void 0 ? _a : 0) + 1);
338
+ this.overflowCounts.set(root, ((_c = this.overflowCounts.get(root)) !== null && _c !== void 0 ? _c : 0) + 1);
329
339
  const now = Date.now();
330
340
  if (!this.lastWatcherErrorLogMs.has(root) ||
331
341
  now - this.lastWatcherErrorLogMs.get(root) >= 60000) {
332
342
  this.lastWatcherErrorLogMs.set(root, now);
333
343
  console.error(`[daemon:${name}] Watcher error (total=${this.overflowCounts.get(root)}):`, err);
334
344
  }
335
- this.recoverWatcher(root, processor);
336
- return;
345
+ if ((0, watcher_errors_1.isFSEventsGap)(err)) {
346
+ this.overflowPendingRoots.add(root);
347
+ this.requestOverflowCatchup(root, processor);
348
+ }
349
+ else {
350
+ this.recoverWatcher(root, processor);
351
+ }
337
352
  }
338
- // Only reset fail counter after sustained health (5min since last overflow)
339
- const lastOverflow = (_b = this.lastOverflowMs.get(root)) !== null && _b !== void 0 ? _b : 0;
353
+ // Gaps do not kill Parcel's stream. Only a terminal failure should
354
+ // reset the subscription health window or prevent leaving poll mode.
355
+ const lastOverflow = (_d = this.lastWatcherFailureMs.get(root)) !== null && _d !== void 0 ? _d : 0;
340
356
  if (Date.now() - lastOverflow > 5 * 60 * 1000) {
341
357
  this.watcherFailCount.delete(root);
342
358
  }
@@ -350,13 +366,30 @@ class WatcherManager {
350
366
  ...(yield (0, watcher_ignore_1.readProjectWatcherIgnores)(root)),
351
367
  ],
352
368
  });
353
- if (this.deps.processors.get(root) !== processor) {
369
+ if (this.deps.processors.get(root) !== processor ||
370
+ this.watcherGenerations.get(root) !== generation) {
354
371
  yield sub.unsubscribe();
355
372
  return;
356
373
  }
357
374
  this.deps.subscriptions.set(root, sub);
358
375
  });
359
376
  }
377
+ requestOverflowCatchup(root, processor) {
378
+ const started = this.lastCatchupStartMs.get(root);
379
+ const remaining = started === undefined
380
+ ? 0
381
+ : watcher_errors_1.FSEVENTS_GAP_SCAN_INTERVAL_MS - (Date.now() - started);
382
+ // A gap during a scan may concern a path already visited. Keep one later
383
+ // scan rather than treating the current walk as coverage of that gap.
384
+ if (this.catchups.has(root) || remaining > 0) {
385
+ this.deferCatchup(root, processor, Math.max(remaining, watcher_errors_1.FSEVENTS_GAP_SCAN_INTERVAL_MS));
386
+ return;
387
+ }
388
+ this.cancelDeferredCatchup(root);
389
+ this.runCatchup(root, processor).catch((err) => {
390
+ console.error(`[daemon:${path.basename(root)}] Overflow catchup failed:`, err);
391
+ });
392
+ }
360
393
  recoverWatcher(root, processor) {
361
394
  var _a;
362
395
  const name = path.basename(root);
@@ -370,11 +403,11 @@ class WatcherManager {
370
403
  this.pendingOps.add(recoveryKey);
371
404
  const fails = ((_a = this.watcherFailCount.get(root)) !== null && _a !== void 0 ? _a : 0) + 1;
372
405
  this.watcherFailCount.set(root, fails);
373
- this.lastOverflowMs.set(root, Date.now());
406
+ this.lastWatcherFailureMs.set(root, Date.now());
374
407
  const MAX_WATCHER_RETRIES = 3;
375
408
  const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
376
409
  if (fails > MAX_WATCHER_RETRIES) {
377
- // FSEvents can't handle this project — degrade to periodic catchup scans
410
+ // Repeated terminal subscription failures require periodic catchup scans.
378
411
  // Always tear down the broken sub, even if poll mode is already active —
379
412
  // this can happen if a recovery attempt resubscribed successfully then
380
413
  // re-overflowed during the 5-min health window.
@@ -384,7 +417,7 @@ class WatcherManager {
384
417
  this.deps.subscriptions.delete(root);
385
418
  }
386
419
  if (!this.pollIntervals.has(root)) {
387
- console.error(`[daemon:${name}] FSEvents unreliable after ${fails} failures — switching to poll mode (${POLL_INTERVAL_MS / 60000}min interval)`);
420
+ console.error(`[daemon:${name}] Native watcher failed after ${fails} terminal errors — switching to poll mode (${POLL_INTERVAL_MS / 60000}min interval)`);
388
421
  // Run an immediate catchup, then schedule periodic ones
389
422
  this.runCatchup(root, processor).catch((err) => {
390
423
  console.error(`[daemon:${name}] Poll catchup failed:`, err);
@@ -416,7 +449,7 @@ class WatcherManager {
416
449
  }
417
450
  // Backoff: wait before re-subscribing (3s, 6s, 12s)
418
451
  const delayMs = 3000 * Math.pow(2, (fails - 1));
419
- console.error(`[daemon:${name}] Recovering watcher (attempt ${fails}/${MAX_WATCHER_RETRIES}, backoff ${delayMs}ms)...`);
452
+ console.error(`[daemon:${name}] Recovering failed watcher (attempt ${fails}/${MAX_WATCHER_RETRIES}, backoff ${delayMs}ms)...`);
420
453
  const timeout = setTimeout(() => {
421
454
  this.recoveryTimeouts.delete(root);
422
455
  if (this.deps.getShuttingDown() ||
@@ -520,14 +553,14 @@ class WatcherManager {
520
553
  if (this.deps.processors.get(root) !== processor)
521
554
  return;
522
555
  // Wait one health window — if the new subscription survives without
523
- // another overflow, we consider it recovered and tear down poll mode.
556
+ // another terminal failure, we consider it recovered and tear down poll mode.
524
557
  yield abortableDelay(FSEVENTS_HEALTH_WINDOW_MS, lifecycle.signal);
525
558
  if (this.deps.getShuttingDown() ||
526
559
  this.deps.processors.get(root) !== processor)
527
560
  return;
528
- const lastOverflow = (_a = this.lastOverflowMs.get(root)) !== null && _a !== void 0 ? _a : 0;
561
+ const lastOverflow = (_a = this.lastWatcherFailureMs.get(root)) !== null && _a !== void 0 ? _a : 0;
529
562
  if (Date.now() - lastOverflow < FSEVENTS_HEALTH_WINDOW_MS) {
530
- console.log(`[daemon:${name}] FSEvents recovery aborted — fresh overflow within health window, staying in poll mode`);
563
+ console.log(`[daemon:${name}] FSEvents recovery aborted — terminal failure within health window, staying in poll mode`);
531
564
  return; // recoverWatcher will have re-armed poll mode if needed
532
565
  }
533
566
  // Healthy — drop poll mode.
@@ -555,8 +588,9 @@ class WatcherManager {
555
588
  return __awaiter(this, void 0, void 0, function* () {
556
589
  var _a, e_1, _b, _c;
557
590
  var _d;
558
- var _e;
591
+ var _e, _f, _g;
559
592
  const scanStart = Date.now();
593
+ const overflowCountAtStart = (_e = this.overflowCounts.get(root)) !== null && _e !== void 0 ? _e : 0;
560
594
  const { isFileCached } = yield Promise.resolve().then(() => __importStar(require("../utils/cache-check")));
561
595
  const metaCache = this.deps.getMetaCache();
562
596
  const vectorDb = this.deps.getVectorDb();
@@ -573,12 +607,12 @@ class WatcherManager {
573
607
  let skipped = 0;
574
608
  let debugSamples = 0;
575
609
  try {
576
- for (var _f = true, _g = __asyncValues((0, walker_1.walk)(root, {
610
+ for (var _h = true, _j = __asyncValues((0, walker_1.walk)(root, {
577
611
  policy: processor.filePolicy,
578
612
  state: walkState,
579
- })), _h; _h = yield _g.next(), _a = _h.done, !_a; _f = true) {
580
- _c = _h.value;
581
- _f = false;
613
+ })), _k; _k = yield _j.next(), _a = _k.done, !_a; _h = true) {
614
+ _c = _k.value;
615
+ _h = false;
582
616
  const relPath = _c;
583
617
  if (signal.aborted)
584
618
  return false;
@@ -606,6 +640,7 @@ class WatcherManager {
606
640
  else if (reconciliation.action === "reprocess") {
607
641
  processor.handleFileEvent("change", absPath, {
608
642
  forceReprocess: true,
643
+ workKind: "catchup",
609
644
  });
610
645
  queued++;
611
646
  if (queued % 500 === 0) {
@@ -623,7 +658,9 @@ class WatcherManager {
623
658
  });
624
659
  if (snapshot.size !== stats.size ||
625
660
  snapshot.mtimeMs !== stats.mtimeMs) {
626
- processor.handleFileEvent("change", absPath);
661
+ processor.handleFileEvent("change", absPath, {
662
+ workKind: "catchup",
663
+ });
627
664
  continue;
628
665
  }
629
666
  const hash = (0, file_utils_1.computeContentHash)(snapshot.buffer, absPath);
@@ -643,7 +680,7 @@ class WatcherManager {
643
680
  }
644
681
  if (signal.aborted)
645
682
  return false;
646
- processor.handleFileEvent("change", absPath);
683
+ processor.handleFileEvent("change", absPath, { workKind: "catchup" });
647
684
  queued++;
648
685
  // Throttle: pause periodically during large catchup scans to let the
649
686
  // batch processor drain and compaction run between bursts.
@@ -665,7 +702,7 @@ class WatcherManager {
665
702
  catch (e_1_1) { e_1 = { error: e_1_1 }; }
666
703
  finally {
667
704
  try {
668
- if (!_f && !_a && (_b = _g.return)) yield _b.call(_g);
705
+ if (!_h && !_a && (_b = _j.return)) yield _b.call(_j);
669
706
  }
670
707
  finally { if (e_1) throw e_1.error; }
671
708
  }
@@ -677,7 +714,10 @@ class WatcherManager {
677
714
  return false;
678
715
  if (!seenPaths.has(cachedPath) &&
679
716
  !(0, walker_1.isPathProtectedByWalkState)(cachedPath, walkState)) {
680
- processor.handleFileEvent("unlink", cachedPath, { forceDelete: true });
717
+ processor.handleFileEvent("unlink", cachedPath, {
718
+ forceDelete: true,
719
+ workKind: "cleanup",
720
+ });
681
721
  purged++;
682
722
  }
683
723
  }
@@ -695,7 +735,7 @@ class WatcherManager {
695
735
  }
696
736
  else {
697
737
  this.degradedRoots.delete(root);
698
- const failedFiles = (_e = (_d = this.terminalFailures.get(root)) === null || _d === void 0 ? void 0 : _d.size) !== null && _e !== void 0 ? _e : 0;
738
+ const failedFiles = (_f = (_d = this.terminalFailures.get(root)) === null || _d === void 0 ? void 0 : _d.size) !== null && _f !== void 0 ? _f : 0;
699
739
  (0, watcher_store_1.registerWatcher)(Object.assign({ pid: process.pid, projectRoot: root, startTime: Date.now(), status: this.isRootDegraded(root) ? "degraded" : "watching", lastHeartbeat: Date.now() }, (failedFiles > 0
700
740
  ? { lastError: `${failedFiles} file(s) exhausted automatic retries` }
701
741
  : {})));
@@ -711,8 +751,12 @@ class WatcherManager {
711
751
  const ended = Date.now();
712
752
  this.lastCatchupEndMs.set(root, ended);
713
753
  this.catchupDurations.set(root, ended - scanStart);
714
- if (complete)
754
+ if (complete) {
715
755
  this.reconciledAt.set(root, ended);
756
+ // A later gap still needs its deferred scan, even if this walk succeeded.
757
+ if (((_g = this.overflowCounts.get(root)) !== null && _g !== void 0 ? _g : 0) === overflowCountAtStart)
758
+ this.overflowPendingRoots.delete(root);
759
+ }
716
760
  return complete;
717
761
  });
718
762
  }
@@ -731,6 +775,7 @@ class WatcherManager {
731
775
  let incompleteRetries = 0;
732
776
  do {
733
777
  active.dirty = false;
778
+ this.lastCatchupStartMs.set(root, Date.now());
734
779
  const complete = yield this.catchupScan(root, processor, signal);
735
780
  if (!complete && !signal.aborted && incompleteRetries < 3) {
736
781
  incompleteRetries++;
@@ -769,6 +814,7 @@ class WatcherManager {
769
814
  // independently of the processor, so clear them even on the early return.
770
815
  (_a = this.watchLifecycles.get(root)) === null || _a === void 0 ? void 0 : _a.abort();
771
816
  this.watchLifecycles.delete(root);
817
+ this.watcherGenerations.delete(root);
772
818
  const pollInterval = this.pollIntervals.get(root);
773
819
  if (pollInterval) {
774
820
  clearInterval(pollInterval);
@@ -798,8 +844,10 @@ class WatcherManager {
798
844
  this.deps.subscriptions.delete(root);
799
845
  }
800
846
  this.deps.evictSearcher(root);
801
- this.lastOverflowMs.delete(root);
847
+ this.lastWatcherFailureMs.delete(root);
802
848
  this.lastCatchupEndMs.delete(root);
849
+ this.lastCatchupStartMs.delete(root);
850
+ this.overflowPendingRoots.delete(root);
803
851
  this.degradedRoots.delete(root);
804
852
  this.terminalFailures.delete(root);
805
853
  (0, watcher_store_1.unregisterWatcherByRoot)(root);
@@ -58,6 +58,10 @@ const watcher_batch_1 = require("./watcher-batch");
58
58
  const DEBOUNCE_MS = 2000;
59
59
  const MAX_RETRIES = 5;
60
60
  const MAX_BATCH_SIZE = 50;
61
+ const MAX_CATCHUP_BATCH_SIZE = 4;
62
+ const BACKGROUND_DISPATCH_WINDOW_MS = 2000;
63
+ const MAX_CONSECUTIVE_LIVE_BATCHES = 3;
64
+ const WORK_PRIORITY = { live: 0, catchup: 1, cleanup: 2 };
61
65
  // Pool priority cannot preempt an active processFile, so keep one worker free
62
66
  // for latency-sensitive encodeQuery/rerank work during indexing bursts.
63
67
  const DEFAULT_BATCH_CONCURRENCY = Math.max(1, config_1.CONFIG.WORKER_THREADS - 1);
@@ -70,6 +74,10 @@ class ProjectBatchProcessor {
70
74
  constructor(opts) {
71
75
  var _a, _b, _c;
72
76
  this.pending = new Map();
77
+ this.pendingWork = new Map();
78
+ this.activeWork = new Map();
79
+ this.liveBatches = 0;
80
+ this.nextBackground = "catchup";
73
81
  this.retryCount = new Map();
74
82
  this.retryAt = new Map();
75
83
  this.terminalFailures = new Set();
@@ -110,6 +118,7 @@ class ProjectBatchProcessor {
110
118
  }
111
119
  handleFileEvent(event, absPath, options) {
112
120
  var _a, _b;
121
+ var _c;
113
122
  if (this.closed)
114
123
  return;
115
124
  const normalize = this.filePolicy.normalizeEventPath;
@@ -149,10 +158,35 @@ class ProjectBatchProcessor {
149
158
  this.forcedReprocess.set(normalized, ++this.forceGeneration);
150
159
  }
151
160
  this.retryAt.delete(normalized);
152
- this.pending.set(normalized, event);
161
+ this.enqueueWork(normalized, event, {
162
+ kind: (_c = options === null || options === void 0 ? void 0 : options.workKind) !== null && _c !== void 0 ? _c : "live",
163
+ queuedAt: Date.now(),
164
+ });
153
165
  (_b = this.onActivity) === null || _b === void 0 ? void 0 : _b.call(this);
154
166
  this.scheduleBatch();
155
167
  }
168
+ enqueueWork(absPath, event, work) {
169
+ var _a;
170
+ const previous = (_a = this.pendingWork.get(absPath)) !== null && _a !== void 0 ? _a : this.activeWork.get(absPath);
171
+ this.pending.set(absPath, event);
172
+ this.pendingWork.set(absPath, !previous || WORK_PRIORITY[work.kind] < WORK_PRIORITY[previous.kind]
173
+ ? work
174
+ : {
175
+ kind: previous.kind,
176
+ queuedAt: previous.kind === work.kind
177
+ ? Math.min(previous.queuedAt, work.queuedAt)
178
+ : previous.queuedAt,
179
+ });
180
+ }
181
+ hasReadyLiveWork() {
182
+ var _a;
183
+ const now = Date.now();
184
+ for (const [file, work] of this.pendingWork) {
185
+ if (work.kind === "live" && ((_a = this.retryAt.get(file)) !== null && _a !== void 0 ? _a : 0) <= now)
186
+ return true;
187
+ }
188
+ return false;
189
+ }
156
190
  /** Live (re)index progress: files queued + whether a batch is running.
157
191
  *
158
192
  * recentFiles/recentReindexed summarize the last few batches so callers can
@@ -162,18 +196,40 @@ class ProjectBatchProcessor {
162
196
  * not pending work. Without this, a large pendingFiles alone reads as "the
163
197
  * index is far behind" and search callers caveat correct results. */
164
198
  get progress() {
199
+ var _a;
165
200
  let recentFiles = 0;
166
201
  let recentReindexed = 0;
167
202
  for (const b of this.recentBatches) {
168
203
  recentFiles += b.files;
169
204
  recentReindexed += b.reindexed;
170
205
  }
206
+ const queue = {
207
+ live: 0,
208
+ catchup: 0,
209
+ cleanup: 0,
210
+ activeFiles: this.activeWork.size,
211
+ oldestLiveEditAgeMs: null,
212
+ };
213
+ let oldest = Number.POSITIVE_INFINITY;
214
+ for (const file of this.pending.keys()) {
215
+ const work = this.pendingWork.get(file);
216
+ queue[(_a = work === null || work === void 0 ? void 0 : work.kind) !== null && _a !== void 0 ? _a : "live"]++;
217
+ if ((work === null || work === void 0 ? void 0 : work.kind) === "live")
218
+ oldest = Math.min(oldest, work.queuedAt);
219
+ }
220
+ for (const work of this.activeWork.values()) {
221
+ if (work.kind === "live")
222
+ oldest = Math.min(oldest, work.queuedAt);
223
+ }
224
+ if (Number.isFinite(oldest))
225
+ queue.oldestLiveEditAgeMs = Math.max(0, Date.now() - oldest);
171
226
  return {
172
227
  pendingFiles: this.pending.size,
173
228
  processing: this.processing,
174
229
  failedFiles: this.terminalFailures.size,
175
230
  recentFiles,
176
231
  recentReindexed,
232
+ queue,
177
233
  };
178
234
  }
179
235
  loadReusableEmbeddings(absPath) {
@@ -253,8 +309,8 @@ class ProjectBatchProcessor {
253
309
  }
254
310
  processBatch(operationSignal) {
255
311
  return __awaiter(this, void 0, void 0, function* () {
256
- var _a, _b, _c, _d;
257
- var _e, _f;
312
+ var _a, _b, _c, _d, _e;
313
+ var _f, _g, _h, _j;
258
314
  if (this.closed || this.processing || this.pending.size === 0)
259
315
  return;
260
316
  const diskPressure = this.vectorDb.checkDiskPressure();
@@ -263,25 +319,55 @@ class ProjectBatchProcessor {
263
319
  }
264
320
  const batch = new Map();
265
321
  const batchForceGenerations = new Map();
322
+ const batchWork = new Map();
266
323
  const now = Date.now();
267
- let taken = 0;
268
- for (const [absPath, event] of this.pending) {
269
- if (((_e = this.retryAt.get(absPath)) !== null && _e !== void 0 ? _e : 0) > now)
324
+ const ready = {
325
+ live: [],
326
+ catchup: [],
327
+ cleanup: [],
328
+ };
329
+ for (const absPath of this.pending.keys()) {
330
+ if (((_f = this.retryAt.get(absPath)) !== null && _f !== void 0 ? _f : 0) > now)
270
331
  continue;
332
+ const kind = (_g = (_a = this.pendingWork.get(absPath)) === null || _a === void 0 ? void 0 : _a.kind) !== null && _g !== void 0 ? _g : "live";
333
+ if (ready[kind].length < MAX_BATCH_SIZE)
334
+ ready[kind].push(absPath);
335
+ }
336
+ const background = ready[this.nextBackground].length
337
+ ? this.nextBackground
338
+ : this.nextBackground === "catchup"
339
+ ? "cleanup"
340
+ : "catchup";
341
+ const fairBackground = ready.live.length > 0 &&
342
+ ready[background].length > 0 &&
343
+ this.liveBatches >= MAX_CONSECUTIVE_LIVE_BATCHES;
344
+ const kind = ready.live.length > 0 && !fairBackground ? "live" : background;
345
+ const limit = fairBackground
346
+ ? 1
347
+ : kind === "catchup"
348
+ ? MAX_CATCHUP_BATCH_SIZE
349
+ : MAX_BATCH_SIZE;
350
+ for (const absPath of ready[kind].slice(0, limit)) {
351
+ const event = this.pending.get(absPath);
271
352
  batch.set(absPath, event);
353
+ batchWork.set(absPath, (_h = this.pendingWork.get(absPath)) !== null && _h !== void 0 ? _h : { kind, queuedAt: now });
272
354
  const forceGeneration = this.forcedReprocess.get(absPath);
273
355
  if (forceGeneration !== undefined) {
274
356
  batchForceGenerations.set(absPath, forceGeneration);
275
357
  }
276
- taken++;
277
- if (taken >= MAX_BATCH_SIZE)
278
- break;
279
358
  }
280
359
  if (batch.size === 0) {
281
360
  this.schedulePendingBatch(30000);
282
361
  return;
283
362
  }
284
363
  this.processing = true;
364
+ this.activeWork = batchWork;
365
+ if (kind === "live")
366
+ this.liveBatches++;
367
+ else {
368
+ this.liveBatches = 0;
369
+ this.nextBackground = kind === "catchup" ? "cleanup" : "catchup";
370
+ }
285
371
  const batchAc = new AbortController();
286
372
  const abortBatch = () => batchAc.abort(operationSignal.reason);
287
373
  if (operationSignal.aborted)
@@ -295,6 +381,7 @@ class ProjectBatchProcessor {
295
381
  }, this.batchTimeoutMs);
296
382
  for (const key of batch.keys()) {
297
383
  this.pending.delete(key);
384
+ this.pendingWork.delete(key);
298
385
  this.retryAt.delete(key);
299
386
  }
300
387
  const filenames = [...batch.keys()].map((p) => path.basename(p));
@@ -331,7 +418,7 @@ class ProjectBatchProcessor {
331
418
  this.retryCount.set(absPath, retry.failures);
332
419
  this.retryAt.set(absPath, Date.now() + retry.backoffMs);
333
420
  }
334
- this.pending.set(absPath, event);
421
+ this.enqueueWork(absPath, event, batchWork.get(absPath));
335
422
  };
336
423
  let stopDispatch = false;
337
424
  const processOne = (absPath, event) => __awaiter(this, void 0, void 0, function* () {
@@ -473,7 +560,7 @@ class ProjectBatchProcessor {
473
560
  }
474
561
  }
475
562
  });
476
- const batchConcurrency = Math.min(this.concurrency, Math.max(1, Math.ceil(batch.size / FILES_PER_BATCH_SLOT)));
563
+ const batchConcurrency = Math.min(kind === "live" ? this.concurrency : 1, Math.max(1, Math.ceil(batch.size / FILES_PER_BATCH_SLOT)));
477
564
  const activeTasks = [];
478
565
  const schedule = (task) => __awaiter(this, void 0, void 0, function* () {
479
566
  const taskPromise = task();
@@ -491,6 +578,14 @@ class ProjectBatchProcessor {
491
578
  for (const [absPath, event] of batch) {
492
579
  if (batchAc.signal.aborted || stopDispatch)
493
580
  break;
581
+ // Do not enqueue a whole background batch into the worker pool. Finish
582
+ // the current file, commit its results, then admit waiting live edits.
583
+ // A single fairness file still runs during continuous live traffic.
584
+ if (kind !== "live" &&
585
+ settled > 0 &&
586
+ (Date.now() - start >= BACKGROUND_DISPATCH_WINDOW_MS ||
587
+ (!fairBackground && this.hasReadyLiveWork())))
588
+ break;
494
589
  yield schedule(() => processOne(absPath, event));
495
590
  }
496
591
  yield Promise.allSettled(activeTasks);
@@ -561,9 +656,9 @@ class ProjectBatchProcessor {
561
656
  }
562
657
  const duration = Date.now() - start;
563
658
  if (reindexed > 0) {
564
- (_a = this.onReindex) === null || _a === void 0 ? void 0 : _a.call(this, reindexed, duration);
659
+ (_b = this.onReindex) === null || _b === void 0 ? void 0 : _b.call(this, reindexed, duration);
565
660
  }
566
- this.recentBatches.push({ files: batch.size, reindexed });
661
+ this.recentBatches.push({ files: settled, reindexed });
567
662
  if (this.recentBatches.length > ProjectBatchProcessor.RECENT_BATCH_WINDOW) {
568
663
  this.recentBatches.shift();
569
664
  }
@@ -578,7 +673,7 @@ class ProjectBatchProcessor {
578
673
  this.retryCount.delete(absPath);
579
674
  this.retryAt.delete(absPath);
580
675
  this.terminalFailures.delete(absPath);
581
- (_b = this.onPathSuccess) === null || _b === void 0 ? void 0 : _b.call(this, absPath);
676
+ (_c = this.onPathSuccess) === null || _c === void 0 ? void 0 : _c.call(this, absPath);
582
677
  }
583
678
  // Trigger compaction if fragments are accumulating
584
679
  if (reindexed > 0 && diskPressure !== "critical") {
@@ -599,7 +694,7 @@ class ProjectBatchProcessor {
599
694
  if (this.terminalFailures.has(absPath))
600
695
  continue;
601
696
  if (!this.pending.has(absPath)) {
602
- this.pending.set(absPath, event);
697
+ this.enqueueWork(absPath, event, batchWork.get(absPath));
603
698
  }
604
699
  }
605
700
  (0, logger_1.log)(this.wtag, "Disk pressure — requeued batch, will retry in 60s");
@@ -622,7 +717,7 @@ class ProjectBatchProcessor {
622
717
  if (this.terminalFailures.has(absPath))
623
718
  continue;
624
719
  if (!this.pending.has(absPath))
625
- this.pending.set(absPath, event);
720
+ this.enqueueWork(absPath, event, batchWork.get(absPath));
626
721
  }
627
722
  backoffOverrideMs = 30 * 60 * 1000;
628
723
  }
@@ -635,19 +730,19 @@ class ProjectBatchProcessor {
635
730
  continue;
636
731
  if (this.pending.has(absPath))
637
732
  continue;
638
- const retry = (0, watcher_batch_1.computePathRetry)((_f = this.retryCount.get(absPath)) !== null && _f !== void 0 ? _f : 0, MAX_RETRIES, DEBOUNCE_MS);
733
+ const retry = (0, watcher_batch_1.computePathRetry)((_j = this.retryCount.get(absPath)) !== null && _j !== void 0 ? _j : 0, MAX_RETRIES, DEBOUNCE_MS);
639
734
  if (!retry.retry) {
640
735
  this.retryCount.delete(absPath);
641
736
  this.retryAt.delete(absPath);
642
737
  this.terminalFailures.add(absPath);
643
- (_c = this.onTerminalFailure) === null || _c === void 0 ? void 0 : _c.call(this, absPath);
738
+ (_d = this.onTerminalFailure) === null || _d === void 0 ? void 0 : _d.call(this, absPath);
644
739
  dropped++;
645
740
  droppedPaths.push(absPath);
646
741
  continue;
647
742
  }
648
743
  this.retryCount.set(absPath, retry.failures);
649
744
  this.retryAt.set(absPath, Date.now() + retry.backoffMs);
650
- this.pending.set(absPath, event);
745
+ this.enqueueWork(absPath, event, batchWork.get(absPath));
651
746
  }
652
747
  if (dropped > 0) {
653
748
  (0, logger_1.log)(this.wtag, `Dropped ${dropped} file(s) after ${MAX_RETRIES} retries: ${droppedPaths.map((p) => path.basename(p)).join(", ")}`);
@@ -666,10 +761,11 @@ class ProjectBatchProcessor {
666
761
  if (!this.pending.has(absPath)) {
667
762
  this.retryCount.delete(absPath);
668
763
  this.retryAt.delete(absPath);
669
- this.pending.set(absPath, "change");
764
+ this.enqueueWork(absPath, "change", batchWork.get(absPath));
670
765
  }
671
766
  }
672
767
  }
768
+ this.activeWork = new Map();
673
769
  if (!this.closed && this.pending.size > 0) {
674
770
  if (backoffOverrideMs > 0) {
675
771
  if (this.debounceTimer)
@@ -686,7 +782,7 @@ class ProjectBatchProcessor {
686
782
  }
687
783
  }
688
784
  if (!this.closed)
689
- (_d = this.onBatchSettled) === null || _d === void 0 ? void 0 : _d.call(this);
785
+ (_e = this.onBatchSettled) === null || _e === void 0 ? void 0 : _e.call(this);
690
786
  }
691
787
  });
692
788
  }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FSEVENTS_GAP_SCAN_INTERVAL_MS = void 0;
4
+ exports.isFSEventsGap = isFSEventsGap;
5
+ /** Parcel reports these gaps without closing its FSEvents stream. Other errors
6
+ * may clear the native callbacks and still require subscription recovery. */
7
+ function isFSEventsGap(error) {
8
+ return (error.message ===
9
+ "Events were dropped by the FSEvents client. File system must be re-scanned." ||
10
+ error.message ===
11
+ "Events were dropped by the kernel. File system must be re-scanned." ||
12
+ error.message === "Too many events. File system must be re-scanned.");
13
+ }
14
+ // Bound reconciliation under sustained churn without abandoning native edits
15
+ // for the five-minute polling fallback used by a failed subscription.
16
+ exports.FSEVENTS_GAP_SCAN_INTERVAL_MS = 30000;
@@ -58,6 +58,7 @@ const cache_coherence_1 = require("./cache-coherence");
58
58
  const file_policy_1 = require("./file-policy");
59
59
  const ignore_patterns_1 = require("./ignore-patterns");
60
60
  const walker_1 = require("./walker");
61
+ const watcher_errors_1 = require("./watcher-errors");
61
62
  const watcher_ignore_1 = require("./watcher-ignore");
62
63
  // Directory names ignored at ANY depth. @parcel/watcher treats a non-glob
63
64
  // ignore entry as a literal path resolved against the watch root, so a bare
@@ -125,6 +126,11 @@ function startWatcher(opts) {
125
126
  let subscription = null;
126
127
  let subscriptionUpdate = Promise.resolve();
127
128
  let subscriptionPolicy = "";
129
+ let subscriptionFailed = false;
130
+ let subscriptionGeneration = 0;
131
+ let gapScanTimer;
132
+ let lastReconcileStart;
133
+ let gapGeneration = 0;
128
134
  const updateSubscription = () => {
129
135
  const update = subscriptionUpdate
130
136
  .catch(() => { })
@@ -134,16 +140,31 @@ function startWatcher(opts) {
134
140
  ...(yield (0, watcher_ignore_1.readProjectWatcherIgnores)(projectRoot)),
135
141
  ];
136
142
  const policy = JSON.stringify(ignores);
137
- if (closing || (subscription && subscriptionPolicy === policy))
143
+ if (closing ||
144
+ (subscription && !subscriptionFailed && subscriptionPolicy === policy))
138
145
  return;
146
+ const generation = ++subscriptionGeneration;
147
+ subscriptionFailed = false;
139
148
  yield subscription === null || subscription === void 0 ? void 0 : subscription.unsubscribe();
140
149
  subscription = null;
141
150
  if (closing)
142
151
  return;
143
152
  const next = yield watcher.subscribe(projectRoot, (err, events) => {
153
+ var _a;
154
+ if (closing || generation !== subscriptionGeneration)
155
+ return;
144
156
  if (err) {
145
157
  console.error(`[${wtag}] Watcher error:`, err);
146
- return;
158
+ scanHealthy = false;
159
+ (_a = opts.onHealthChange) === null || _a === void 0 ? void 0 : _a.call(opts, false, 1);
160
+ gapGeneration++;
161
+ if ((0, watcher_errors_1.isFSEventsGap)(err))
162
+ requestGapScan();
163
+ else {
164
+ // Terminal backend errors can clear Parcel's callbacks.
165
+ subscriptionFailed = true;
166
+ reconcile();
167
+ }
147
168
  }
148
169
  for (const event of events)
149
170
  processor.handleFileEvent(event.type === "delete" ? "unlink" : "change", event.path);
@@ -180,6 +201,8 @@ function startWatcher(opts) {
180
201
  let incompleteRetries = 0;
181
202
  do {
182
203
  reconcileRequested = false;
204
+ lastReconcileStart = Date.now();
205
+ const observedGapGeneration = gapGeneration;
183
206
  filePolicy.invalidateIgnoreCache();
184
207
  yield updateSubscription();
185
208
  if (closing)
@@ -210,6 +233,7 @@ function startWatcher(opts) {
210
233
  }
211
234
  processor.handleFileEvent("change", absolute, {
212
235
  forceReprocess: reconciliation.action === "reprocess",
236
+ workKind: "catchup",
213
237
  });
214
238
  }
215
239
  }
@@ -227,6 +251,7 @@ function startWatcher(opts) {
227
251
  !(0, walker_1.isPathProtectedByWalkState)(cachedPath, state)) {
228
252
  processor.handleFileEvent("unlink", cachedPath, {
229
253
  forceDelete: true,
254
+ workKind: "cleanup",
230
255
  });
231
256
  }
232
257
  }
@@ -241,7 +266,7 @@ function startWatcher(opts) {
241
266
  }
242
267
  }
243
268
  else {
244
- scanHealthy = true;
269
+ scanHealthy = observedGapGeneration === gapGeneration;
245
270
  if (ingestionDegraded && processor.progress.pendingFiles > 0) {
246
271
  degradedRepairQueued = true;
247
272
  }
@@ -255,6 +280,21 @@ function startWatcher(opts) {
255
280
  reconciliation = null;
256
281
  });
257
282
  };
283
+ const requestGapScan = () => {
284
+ if (closing || gapScanTimer)
285
+ return;
286
+ const remaining = lastReconcileStart === undefined
287
+ ? 0
288
+ : watcher_errors_1.FSEVENTS_GAP_SCAN_INTERVAL_MS - (Date.now() - lastReconcileStart);
289
+ if (reconciliation || remaining > 0) {
290
+ gapScanTimer = setTimeout(() => {
291
+ gapScanTimer = undefined;
292
+ reconcile();
293
+ }, Math.max(remaining, watcher_errors_1.FSEVENTS_GAP_SCAN_INTERVAL_MS));
294
+ return;
295
+ }
296
+ reconcile();
297
+ };
258
298
  processor = new batch_processor_1.ProjectBatchProcessor(Object.assign(Object.assign({}, opts), { filePolicy, onPolicyChange: reconcile, onReindex: (files, durationMs) => {
259
299
  var _a;
260
300
  (_a = opts.onReindex) === null || _a === void 0 ? void 0 : _a.call(opts, files, durationMs);
@@ -282,6 +322,8 @@ function startWatcher(opts) {
282
322
  },
283
323
  close: () => __awaiter(this, void 0, void 0, function* () {
284
324
  closing = true;
325
+ subscriptionGeneration++;
326
+ clearTimeout(gapScanTimer);
285
327
  yield subscriptionUpdate.catch(() => { });
286
328
  yield subscription === null || subscription === void 0 ? void 0 : subscription.unsubscribe();
287
329
  yield reconciliation;
@@ -6,7 +6,14 @@
6
6
  // historically did not. This formats a single machine-readable footer so an
7
7
  // agent can decide to caveat its answer or retry once indexing settles.
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.formatWatchQueue = formatWatchQueue;
9
10
  exports.formatIndexStateFooter = formatIndexStateFooter;
11
+ function formatWatchQueue(queue) {
12
+ const age = queue.oldestLiveEditAgeMs === null
13
+ ? "none"
14
+ : `${Math.ceil(queue.oldestLiveEditAgeMs / 1000)}s`;
15
+ return `live=${queue.live} catchup=${queue.catchup} cleanup=${queue.cleanup} active=${queue.activeFiles} oldestLiveEdit=${age}`;
16
+ }
10
17
  /**
11
18
  * One-line footer describing an in-progress index, or null when there's
12
19
  * nothing to say (no state, or the index is settled). Suppressing the
@@ -111,6 +111,15 @@ exports.MCP_READ_OUTPUT_SCHEMAS = {
111
111
  .object({
112
112
  indexing: zod_1.z.boolean(),
113
113
  pendingFiles: zod_1.z.number(),
114
+ queue: zod_1.z
115
+ .object({
116
+ live: zod_1.z.number().int().nonnegative(),
117
+ catchup: zod_1.z.number().int().nonnegative(),
118
+ cleanup: zod_1.z.number().int().nonnegative(),
119
+ activeFiles: zod_1.z.number().int().nonnegative(),
120
+ oldestLiveEditAgeMs: zod_1.z.number().nonnegative().nullable(),
121
+ })
122
+ .optional(),
114
123
  verifying: zod_1.z.boolean().optional(),
115
124
  failedFiles: zod_1.z.number().optional(),
116
125
  degraded: zod_1.z.boolean().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.62",
3
+ "version": "0.26.64",
4
4
  "author": "Robert Owens <78518764+reowens@users.noreply.github.com>",
5
5
  "homepage": "https://github.com/reowens/grepmax",
6
6
  "bugs": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.62",
3
+ "version": "0.26.64",
4
4
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
5
5
  "author": {
6
6
  "name": "Robert Owens",