@indigoai-us/hq-cloud 6.14.26 → 6.14.28

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.
package/src/watcher.ts CHANGED
@@ -31,6 +31,28 @@ import {
31
31
 
32
32
  const DEBOUNCE_MS = 2000;
33
33
 
34
+ /**
35
+ * A single Linux chokidar watcher must leave room for editors, dev servers,
36
+ * and other HQ runners on the same uid. This is deliberately far below the
37
+ * common 250k kernel maximum. The cap is on distinct directories admitted over
38
+ * this watcher's lifetime, which also prevents a high-churn tree from slowly
39
+ * accumulating an unbounded set of kernel watches.
40
+ */
41
+ export const DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS = 20_000;
42
+ export const TREE_WATCHER_MAX_WATCHED_PATHS_ENV = "HQ_SYNC_MAX_WATCHED_PATHS";
43
+
44
+ function resolveMaxWatchedPaths(override: number | undefined): number {
45
+ const configured = override ?? Number(process.env[TREE_WATCHER_MAX_WATCHED_PATHS_ENV]);
46
+ if (override === undefined && process.env[TREE_WATCHER_MAX_WATCHED_PATHS_ENV] === undefined) {
47
+ return DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS;
48
+ }
49
+ if (Number.isFinite(configured) && configured > 0) return Math.trunc(configured);
50
+ console.warn(
51
+ `TreeWatcher ignoring invalid ${TREE_WATCHER_MAX_WATCHED_PATHS_ENV}; using ${DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS}`,
52
+ );
53
+ return DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS;
54
+ }
55
+
34
56
  /**
35
57
  * Injectable clock seam (US-001).
36
58
  *
@@ -224,44 +246,146 @@ export class WatchPushDriver {
224
246
  /** Decision for a single path: emit a change for it, or ignore it. */
225
247
  export type WatchPathFilter = (absolutePath: string, isDir?: boolean) => boolean;
226
248
 
249
+ export interface TreeWatcherWatchBudgetExceeded {
250
+ maxWatchedPaths: number;
251
+ watchedPaths: number;
252
+ /** Absolute directory whose admission would exceed the cap. */
253
+ offendingPath: string;
254
+ }
255
+
256
+ /**
257
+ * Tracks directories admitted to chokidar. Chokidar asks its ignored callback
258
+ * twice: before stat (the descent gate) and after stat (where we can safely
259
+ * account only for directories). This class is intentionally lifetime-bounded:
260
+ * a directory that is deleted and recreated cannot turn one long-running
261
+ * runner into an unbounded consumer.
262
+ */
263
+ export class ChokidarWatchBudget {
264
+ private readonly admitted = new Set<string>();
265
+ private exhausted = false;
266
+
267
+ constructor(
268
+ private readonly maxWatchedPaths: number,
269
+ private readonly onExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
270
+ ) {}
271
+
272
+ admit(absolutePath: string): boolean {
273
+ const resolved = path.resolve(absolutePath);
274
+ if (this.admitted.has(resolved)) return true;
275
+ if (this.exhausted || this.admitted.size >= this.maxWatchedPaths) {
276
+ if (!this.exhausted) {
277
+ this.exhausted = true;
278
+ this.onExceeded({
279
+ maxWatchedPaths: this.maxWatchedPaths,
280
+ watchedPaths: this.admitted.size,
281
+ offendingPath: resolved,
282
+ });
283
+ }
284
+ return false;
285
+ }
286
+ this.admitted.add(resolved);
287
+ return true;
288
+ }
289
+
290
+ count(): number {
291
+ return this.admitted.size;
292
+ }
293
+ }
294
+
227
295
  /**
228
- * Translate an emit-filter into chokidar's `ignored` predicate (true = SKIP).
296
+ * Translate the emit filter into chokidar's descent predicate.
229
297
  *
230
- * The subtlety this encodes: chokidar probes a candidate path for the descent
231
- * decision BEFORE it has stat()ed it, so `stats` is `undefined` on that first
232
- * call — and if we say "ignore" there, chokidar prunes the subtree and never
233
- * descends. In allowlist mode (`.hqinclude`) the in-scope leaves
234
- * (`companies/*\/knowledge/`) live UNDER intermediate dirs (`companies/`,
235
- * `companies/<slug>/`) that only survive the filter when queried as a
236
- * directory (the ancestor matcher fires for `isDir=true` only). With no stats
237
- * hint we don't know if the candidate is that ancestor dir or an in-scope
238
- * file, so we must NOT prune when EITHER reading would keep it. The real
239
- * isDir-accurate verdict is reapplied at event time (TreeWatcher.handleEvent
240
- * re-checks with the known kind), so this only widens descent, never emit.
298
+ * Chokidar first calls this with no stats, before it has chosen whether to
299
+ * recurse. That call is a DIRECTORY question, not an event-emission question:
300
+ * consulting `shouldEmit(path, false)` here lets directory-only ignores such
301
+ * as `node_modules/` masquerade as files and opens their whole subtree. The
302
+ * directory branch of createIgnoreFilter already contains the exact
303
+ * `.hqinclude` ancestor matcher, so it is both safe and sufficient to consult
304
+ * `shouldEmit(path, true)` at this gate.
241
305
  */
242
- function toChokidarIgnored(
306
+ export function toChokidarIgnored(
243
307
  shouldEmit: WatchPathFilter,
244
308
  hqRoot: string,
309
+ budget?: ChokidarWatchBudget,
245
310
  ): (filePath: string, stats?: fs.Stats) => boolean {
246
311
  const root = path.resolve(hqRoot);
247
312
  return (filePath: string, stats?: fs.Stats): boolean => {
248
313
  // The watched root itself must NEVER be ignored, or chokidar tears down
249
314
  // the whole watch. (shouldEmit returns false for it — it's not a valid
250
315
  // emit target — so we special-case it here.)
251
- if (path.resolve(filePath) === root) return false;
316
+ if (path.resolve(filePath) === root) {
317
+ return stats?.isDirectory() && budget !== undefined
318
+ ? !budget.admit(filePath)
319
+ : false;
320
+ }
252
321
  const isDir = stats?.isDirectory();
253
322
  if (isDir === undefined) {
254
- // Pre-stat descent probe: keep the path if it could be an in-scope file
255
- // OR an ancestor directory of one.
256
- return !(shouldEmit(filePath, false) || shouldEmit(filePath, true));
323
+ // This is chokidar's descent decision. The `isDir=true` branch includes
324
+ // only precise .hqinclude ancestors, rather than widening descent for
325
+ // every path that could happen to be an allowed file.
326
+ return !shouldEmit(filePath, true);
327
+ }
328
+ if (isDir) {
329
+ return (
330
+ !shouldEmit(filePath, true) ||
331
+ (budget !== undefined && !budget.admit(filePath))
332
+ );
257
333
  }
258
334
  return !shouldEmit(filePath, isDir);
259
335
  };
260
336
  }
261
337
 
262
338
  /** A started watch backend. `close()` releases its OS handle(s); idempotent. */
263
- interface WatchBackend {
339
+ export interface WatchBackend {
264
340
  close(): void;
341
+ /** Native recursive watches need rename-kind state; chokidar does not. */
342
+ needsKnownKinds: boolean;
343
+ /** Directories/OS handles admitted by this backend so degradation is auditable. */
344
+ watchedPathCount(): number;
345
+ }
346
+
347
+ export interface TreeWatcherDegradation {
348
+ reason: "watch_budget_exhausted" | "inotify_enospc";
349
+ maxWatchedPaths: number;
350
+ watchedPaths: number;
351
+ /** Full local path is deliberately log-only; telemetry receives its top level. */
352
+ offendingPath?: string;
353
+ offendingTopLevel?: string;
354
+ errorCode?: string;
355
+ }
356
+
357
+ export interface TreeWatchBackendOptions {
358
+ hqRoot: string;
359
+ shouldEmit: WatchPathFilter;
360
+ onEvent: (absolutePath: string, kind: BackendChangeKind) => void;
361
+ onError: (err: unknown) => void;
362
+ maxWatchedPaths: number;
363
+ onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void;
364
+ }
365
+
366
+ export type TreeWatchBackendFactory = (
367
+ opts: TreeWatchBackendOptions,
368
+ ) => WatchBackend;
369
+
370
+ function isInotifyLimitError(err: unknown): boolean {
371
+ return (
372
+ typeof err === "object" &&
373
+ err !== null &&
374
+ "code" in err &&
375
+ (err as { code?: unknown }).code === "ENOSPC"
376
+ );
377
+ }
378
+
379
+ function errorPath(err: unknown): string | undefined {
380
+ if (
381
+ typeof err === "object" &&
382
+ err !== null &&
383
+ "path" in err &&
384
+ typeof (err as { path?: unknown }).path === "string"
385
+ ) {
386
+ return (err as { path: string }).path;
387
+ }
388
+ return undefined;
265
389
  }
266
390
 
267
391
  export type TreeChangeKind =
@@ -306,18 +430,36 @@ function startChokidarTreeWatch(
306
430
  shouldEmit: WatchPathFilter,
307
431
  onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
308
432
  onError: (err: unknown) => void,
433
+ maxWatchedPaths: number,
434
+ onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
309
435
  ): WatchBackend {
310
- const cw = watch(hqRoot, {
311
- // chokidar fallback (Linux): see toChokidarIgnored for why the descent
312
- // probe must not prune ancestor dirs of allowlisted leaves.
313
- ignored: toChokidarIgnored(shouldEmit, hqRoot),
314
- persistent: true,
315
- ignoreInitial: true,
316
- awaitWriteFinish: {
317
- stabilityThreshold: 500,
318
- pollInterval: 100,
319
- },
320
- });
436
+ const budget = new ChokidarWatchBudget(
437
+ maxWatchedPaths,
438
+ onWatchBudgetExceeded,
439
+ );
440
+ let cw: ReturnType<typeof watch>;
441
+ try {
442
+ cw = watch(hqRoot, {
443
+ // On Linux chokidar adds one fs.watch/inotify watch per descended dir.
444
+ // The predicate therefore enforces both scope and the hard per-runner cap.
445
+ ignored: toChokidarIgnored(shouldEmit, hqRoot, budget),
446
+ persistent: true,
447
+ ignoreInitial: true,
448
+ awaitWriteFinish: {
449
+ stabilityThreshold: 500,
450
+ pollInterval: 100,
451
+ },
452
+ });
453
+ } catch (err) {
454
+ // Some chokidar/fs.watch versions surface ENOSPC synchronously. Route it
455
+ // through the same degradation path as an asynchronous backend error.
456
+ onError(err);
457
+ return {
458
+ close: () => {},
459
+ needsKnownKinds: false,
460
+ watchedPathCount: () => budget.count(),
461
+ };
462
+ }
321
463
  cw.on("add", (p) => onEvent(p, "add"))
322
464
  .on("change", (p) => onEvent(p, "change"))
323
465
  .on("unlink", (p) => onEvent(p, "unlink"))
@@ -328,6 +470,8 @@ function startChokidarTreeWatch(
328
470
  close: () => {
329
471
  void cw.close();
330
472
  },
473
+ needsKnownKinds: false,
474
+ watchedPathCount: () => budget.count(),
331
475
  };
332
476
  }
333
477
 
@@ -356,6 +500,8 @@ function startTreeWatch(
356
500
  shouldEmit: WatchPathFilter,
357
501
  onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
358
502
  onError: (err: unknown) => void,
503
+ maxWatchedPaths: number,
504
+ onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
359
505
  ): WatchBackend {
360
506
  if (supportsRecursiveWatch()) {
361
507
  try {
@@ -391,6 +537,8 @@ function startTreeWatch(
391
537
  shouldEmit,
392
538
  onEvent,
393
539
  onError,
540
+ maxWatchedPaths,
541
+ onWatchBudgetExceeded,
394
542
  );
395
543
  } catch (fallbackErr) {
396
544
  onError(fallbackErr);
@@ -407,6 +555,8 @@ function startTreeWatch(
407
555
  fallback?.close();
408
556
  fallback = null;
409
557
  },
558
+ needsKnownKinds: true,
559
+ watchedPathCount: () => fallback?.watchedPathCount() ?? 1,
410
560
  };
411
561
  } catch (err) {
412
562
  // Recursive watch unexpectedly unavailable — fall back to chokidar
@@ -415,7 +565,14 @@ function startTreeWatch(
415
565
  }
416
566
  }
417
567
 
418
- return startChokidarTreeWatch(hqRoot, shouldEmit, onEvent, onError);
568
+ return startChokidarTreeWatch(
569
+ hqRoot,
570
+ shouldEmit,
571
+ onEvent,
572
+ onError,
573
+ maxWatchedPaths,
574
+ onWatchBudgetExceeded,
575
+ );
419
576
  }
420
577
 
421
578
  /**
@@ -562,8 +719,25 @@ export interface TreeWatcherOptions {
562
719
  maxPendingPaths?: number;
563
720
  /** Approximate maximum path-string bytes retained in one debounce window. */
564
721
  maxPendingBytes?: number;
722
+ /**
723
+ * Hard cap on distinct directories admitted to the chokidar backend for the
724
+ * lifetime of this instance. Linux uses one inotify watch per such directory.
725
+ * Overrides `HQ_SYNC_MAX_WATCHED_PATHS` for callers that construct a watcher.
726
+ */
727
+ maxWatchedPaths?: number;
565
728
  /** Backlog overflow signal. Defaults to a console warning. */
566
729
  onBacklogOverflow?: (info: TreeWatcherBacklogOverflow) => void;
730
+ /**
731
+ * Structured degradation/metric seam. It fires exactly once when the watch
732
+ * cap or the kernel's inotify limit disables event watching; cadence polling
733
+ * remains the correctness path.
734
+ */
735
+ onDegraded?: (info: TreeWatcherDegradation) => void;
736
+ /** Optional cloud telemetry sink for the same degradation signal. */
737
+ telemetryClient?: CloudTelemetryClient | null;
738
+ telemetryClaims?: TelemetryClaims | null;
739
+ /** Test seam for a watch backend; production uses native/chokidar selection. */
740
+ backendFactory?: TreeWatchBackendFactory;
567
741
  captureLocalDeleteSnapshots?: (
568
742
  relativePath: string,
569
743
  kind: "unlink" | "unlinkDir",
@@ -622,7 +796,12 @@ export class TreeWatcher {
622
796
  private readonly shouldEmit: WatchPathFilter;
623
797
  private readonly maxPendingPaths: number;
624
798
  private readonly maxPendingBytes: number;
799
+ private readonly maxWatchedPaths: number;
625
800
  private readonly onBacklogOverflow: (info: TreeWatcherBacklogOverflow) => void;
801
+ private readonly onDegraded: (info: TreeWatcherDegradation) => void;
802
+ private readonly telemetryClient: CloudTelemetryClient | null | undefined;
803
+ private readonly telemetryClaims: TelemetryClaims | null | undefined;
804
+ private readonly backendFactory?: TreeWatchBackendFactory;
626
805
  private readonly captureLocalDeleteSnapshots?: TreeWatcherOptions["captureLocalDeleteSnapshots"];
627
806
  private backend: WatchBackend | null = null;
628
807
  private timer: unknown = null;
@@ -636,6 +815,7 @@ export class TreeWatcher {
636
815
  private overflowLogged = false;
637
816
  private droppedPaths = 0;
638
817
  private droppedBytes = 0;
818
+ private degraded = false;
639
819
  private disposed = false;
640
820
 
641
821
  constructor(opts: TreeWatcherOptions) {
@@ -652,6 +832,7 @@ export class TreeWatcher {
652
832
  1,
653
833
  opts.maxPendingBytes ?? DEFAULT_TREE_WATCHER_MAX_PENDING_BYTES,
654
834
  );
835
+ this.maxWatchedPaths = resolveMaxWatchedPaths(opts.maxWatchedPaths);
655
836
  this.onBacklogOverflow =
656
837
  opts.onBacklogOverflow ??
657
838
  ((info) => {
@@ -659,6 +840,16 @@ export class TreeWatcher {
659
840
  `TreeWatcher backlog cap exceeded; dropping ${info.droppedPaths} path(s) until the next resync`,
660
841
  );
661
842
  });
843
+ this.onDegraded =
844
+ opts.onDegraded ??
845
+ ((info) => {
846
+ console.error(
847
+ JSON.stringify({ event: "watcher.degraded", ...info }),
848
+ );
849
+ });
850
+ this.telemetryClient = opts.telemetryClient;
851
+ this.telemetryClaims = opts.telemetryClaims;
852
+ this.backendFactory = opts.backendFactory;
662
853
  this.captureLocalDeleteSnapshots = opts.captureLocalDeleteSnapshots;
663
854
  }
664
855
 
@@ -684,17 +875,98 @@ export class TreeWatcher {
684
875
  * for why per-path watching is avoided (kqueue fd exhaustion → EMFILE).
685
876
  */
686
877
  start(): void {
687
- if (this.disposed || this.backend) return;
688
- this.backend = startTreeWatch(
689
- this.hqRoot,
690
- this.shouldEmit,
691
- (absolutePath, kind) => this.handleEvent(absolutePath, kind),
692
- (err) => {
693
- console.error("TreeWatcher error:", err);
694
- this.signalBackendResync();
878
+ if (this.disposed || this.degraded || this.backend) return;
879
+ const backendOpts: TreeWatchBackendOptions = {
880
+ hqRoot: this.hqRoot,
881
+ shouldEmit: this.shouldEmit,
882
+ onEvent: (absolutePath, kind) => this.handleEvent(absolutePath, kind),
883
+ onError: (err) => this.handleBackendError(err),
884
+ maxWatchedPaths: this.maxWatchedPaths,
885
+ onWatchBudgetExceeded: (info) => this.degrade({
886
+ reason: "watch_budget_exhausted",
887
+ maxWatchedPaths: info.maxWatchedPaths,
888
+ watchedPaths: info.watchedPaths,
889
+ offendingPath: info.offendingPath,
890
+ offendingTopLevel: this.topLevelForTelemetry(info.offendingPath),
891
+ }),
892
+ };
893
+ const backend = this.backendFactory
894
+ ? this.backendFactory(backendOpts)
895
+ : startTreeWatch(
896
+ backendOpts.hqRoot,
897
+ backendOpts.shouldEmit,
898
+ backendOpts.onEvent,
899
+ backendOpts.onError,
900
+ backendOpts.maxWatchedPaths,
901
+ backendOpts.onWatchBudgetExceeded,
902
+ );
903
+ // A test backend (or a future eager backend) can discover exhaustion while
904
+ // it is being constructed. Never retain that just-created handle after the
905
+ // degradation path has deliberately disabled event watching.
906
+ if (this.degraded) {
907
+ backend.close();
908
+ return;
909
+ }
910
+ this.backend = backend;
911
+ if (backend.needsKnownKinds) this.seedKnownKinds();
912
+ }
913
+
914
+ private handleBackendError(err: unknown): void {
915
+ if (isInotifyLimitError(err)) {
916
+ const offendingPath = errorPath(err);
917
+ this.degrade({
918
+ reason: "inotify_enospc",
919
+ maxWatchedPaths: this.maxWatchedPaths,
920
+ watchedPaths: this.backend?.watchedPathCount() ?? 0,
921
+ offendingPath,
922
+ offendingTopLevel: offendingPath
923
+ ? this.topLevelForTelemetry(offendingPath)
924
+ : undefined,
925
+ errorCode: "ENOSPC",
926
+ });
927
+ return;
928
+ }
929
+ console.error("TreeWatcher error:", err);
930
+ this.signalBackendResync();
931
+ }
932
+
933
+ private topLevelForTelemetry(absolutePath: string): string | undefined {
934
+ const rel = path.relative(this.hqRoot, absolutePath).split(path.sep);
935
+ const topLevel = rel[0];
936
+ return topLevel && topLevel !== ".." ? topLevel : undefined;
937
+ }
938
+
939
+ private degrade(info: TreeWatcherDegradation): void {
940
+ if (this.disposed || this.degraded) return;
941
+ this.degraded = true;
942
+ // Closing synchronously prevents further directory admissions while
943
+ // chokidar's initial walk is still in flight.
944
+ this.backend?.close();
945
+ this.backend = null;
946
+ try {
947
+ this.onDegraded(info);
948
+ } catch (err) {
949
+ console.error("TreeWatcher degradation reporter error:", err);
950
+ }
951
+ void emitCloudTelemetry(
952
+ this.telemetryClient,
953
+ {
954
+ eventName: "watcher_degraded",
955
+ source: "watcher",
956
+ properties: {
957
+ reason: info.reason,
958
+ maxWatchedPaths: info.maxWatchedPaths,
959
+ watchedPaths: info.watchedPaths,
960
+ ...(info.offendingTopLevel
961
+ ? { offendingTopLevel: info.offendingTopLevel }
962
+ : {}),
963
+ },
695
964
  },
965
+ { claims: this.telemetryClaims },
696
966
  );
697
- this.seedKnownKinds();
967
+ // The existing overflow/resync batch causes the runner to use its scoped
968
+ // drain while the cadence poll continues covering everything else.
969
+ this.signalBackendResync();
698
970
  }
699
971
 
700
972
  private signalBackendResync(): void {
@@ -767,7 +1039,12 @@ export class TreeWatcher {
767
1039
  this.knownKinds.delete(abs);
768
1040
  } else {
769
1041
  this.pendingChanges.set(abs, { kind });
770
- this.knownKinds.set(abs, isDir ? "directory" : "file");
1042
+ // Only directories are needed to disambiguate a native recursive
1043
+ // rename-after-delete. Unknown paths already correctly default to an
1044
+ // unlink file, so retaining every file ever touched made this map grow
1045
+ // without bound in a long-lived runner.
1046
+ if (isDir) this.knownKinds.set(abs, "directory");
1047
+ else this.knownKinds.delete(abs);
771
1048
  }
772
1049
  this.arm();
773
1050
  }
@@ -786,8 +1063,6 @@ export class TreeWatcher {
786
1063
  if (!this.shouldEmit(absolutePath, true)) continue;
787
1064
  this.knownKinds.set(path.resolve(absolutePath), "directory");
788
1065
  visit(absolutePath);
789
- } else if (this.shouldEmit(absolutePath, false)) {
790
- this.knownKinds.set(path.resolve(absolutePath), "file");
791
1066
  }
792
1067
  }
793
1068
  };
@@ -872,6 +1147,11 @@ export class TreeWatcher {
872
1147
  return this.timer === null ? 0 : 1;
873
1148
  }
874
1149
 
1150
+ /** Number of native-rename directory hints retained for deletion handling. */
1151
+ knownDirectoryCount(): number {
1152
+ return this.knownKinds.size;
1153
+ }
1154
+
875
1155
  /**
876
1156
  * Stop watching: close the watch backend (releasing its OS handle) and
877
1157
  * cancel any pending debounce timer. Idempotent. The instance can be