@indigoai-us/hq-cloud 6.14.44 → 6.14.45

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
@@ -12,6 +12,11 @@ import { readFile, stat } from "node:fs/promises";
12
12
  import * as path from "path";
13
13
  import { watch } from "chokidar";
14
14
  import { createIgnoreFilter } from "./ignore.js";
15
+ import {
16
+ isCoveredByRecursiveRoot,
17
+ planWatchRoots,
18
+ type WatchRootPlan,
19
+ } from "./watch-roots.js";
15
20
  import { vaultKeyForLocalPath } from "./local-path-codec.js";
16
21
  import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
17
22
  import {
@@ -340,6 +345,12 @@ export interface WatchBackend {
340
345
  close(): void;
341
346
  /** Native recursive watches need rename-kind state; chokidar does not. */
342
347
  needsKnownKinds: boolean;
348
+ /**
349
+ * True when the backend already populated those known kinds through
350
+ * `onDirectorySeen` while planning its watch roots, so {@link TreeWatcher}
351
+ * must NOT walk the tree a second time to seed them.
352
+ */
353
+ seededKnownKinds?: boolean;
343
354
  /** Directories/OS handles admitted by this backend so degradation is auditable. */
344
355
  watchedPathCount(): number;
345
356
  }
@@ -361,6 +372,11 @@ export interface TreeWatchBackendOptions {
361
372
  onError: (err: unknown) => void;
362
373
  maxWatchedPaths: number;
363
374
  onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void;
375
+ /**
376
+ * Reports every in-scope directory a backend encounters while planning its
377
+ * watch roots, so the known-kinds index can be built in that same pass.
378
+ */
379
+ onDirectorySeen?: (absolutePath: string) => void;
364
380
  }
365
381
 
366
382
  export type TreeWatchBackendFactory = (
@@ -476,88 +492,344 @@ function startChokidarTreeWatch(
476
492
  }
477
493
 
478
494
  /**
479
- * Start watching `hqRoot`, calling `onEvent(absolutePath)` for every change.
495
+ * macOS/Windows backend: recursive `fs.watch` handles placed over the in-scope
496
+ * tree ONLY, as planned by {@link planWatchRoots}.
480
497
  *
481
- * Backend selection (the whole point of this helper):
482
- * - macOS / Windows ONE recursive `fs.watch` handle for the entire tree.
483
- * chokidar 4 dropped its `fsevents` backend, so on macOS it watches via
484
- * kqueue, which costs ~1 open fd PER watched path — ~11k fds over a real
485
- * HQ tree, which EMFILEs under the default soft `ulimit -n` (256) and
486
- * silently kills the watcher (instant sync then falls back to the poll).
487
- * A single recursive watch is 1 handle regardless of tree size.
488
- * - Linux / other → chokidar (recursive `fs.watch` is unsupported there).
498
+ * The previous shape was a single recursive watch on hqRoot. That is one handle
499
+ * regardless of tree size but the OS then reports the WHOLE tree, including
500
+ * the buckets sync never uploads. On a real HQ root that is `repos/` (~26k
501
+ * directories) and `workspace/worktrees/` (~62k), where agent builds, installs,
502
+ * and git checkouts churn constantly. Each of those events woke the runner and
503
+ * was dropped only after the emit filter ran, which was enough allocation churn
504
+ * to pin the process above 100% CPU in GC.
489
505
  *
490
- * The recursive backend does NO descent-time filtering (it can't the OS
491
- * reports the whole subtree), so EVERY change is funneled to `onEvent`, which
492
- * re-applies the emit filter via {@link TreeWatcher.handleEvent}. Excluded
493
- * paths (`repos/`, `node_modules/`, out-of-scope companies) are cheaply
494
- * dropped there before they arm the debounce. This is also why the recursive
495
- * backend is immune to the `.hqinclude` ancestor-descent pruning that the
496
- * chokidar backend needs {@link toChokidarIgnored} to avoid.
506
+ * So instead: plan the minimum cover of the uploadable tree and watch that.
507
+ * A directory whose entire subtree is in scope takes one recursive watch; a
508
+ * directory containing an excluded child is watched non-recursively and its
509
+ * in-scope children are planned separately. Excluded subtrees get no watch at
510
+ * all, so the OS never reports them.
511
+ *
512
+ * Handle count stays small (tens, not thousands — well clear of the kqueue fd
513
+ * exhaustion that made chokidar 4 unusable here), and the emit filter still
514
+ * runs on every delivered event, so a depth-bounded plan admitting some residue
515
+ * is a performance concern, never a correctness one.
497
516
  */
498
- function startTreeWatch(
517
+ /**
518
+ * How long the temporary whole-tree bootstrap watch is held open after the
519
+ * scoped plan goes live, so events the OS observed during the planning walk
520
+ * still drain instead of being dropped with the handle.
521
+ */
522
+ const BOOTSTRAP_DRAIN_MS = 2000;
523
+
524
+ export interface ScopedWatchBackend extends WatchBackend {
525
+ /**
526
+ * Planned watches that could not be attached during the initial plan. Any
527
+ * failure here means the scoped plan has a permanent hole, so the caller
528
+ * discards it and falls back to chokidar rather than running with partial
529
+ * coverage.
530
+ */
531
+ attachFailures: number;
532
+ }
533
+
534
+ export function startScopedRecursiveWatch(
499
535
  hqRoot: string,
500
536
  shouldEmit: WatchPathFilter,
501
537
  onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
502
538
  onError: (err: unknown) => void,
503
- maxWatchedPaths: number,
504
- onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
505
- ): WatchBackend {
506
- if (supportsRecursiveWatch()) {
539
+ onDirectorySeen?: (absolutePath: string) => void,
540
+ ): ScopedWatchBackend {
541
+ const root = path.resolve(hqRoot);
542
+ const watchers = new Map<string, fs.FSWatcher>();
543
+ const recursiveRoots = new Set<string>();
544
+ const recursiveFlag = new Map<string, boolean>();
545
+ let closed = false;
546
+ let attachFailures = 0;
547
+ /** Retires the bootstrap watch — replaced once that watch exists. */
548
+ let closeBootstrap: () => void = () => {};
549
+
550
+ /** lstat that answers "is this a directory right now", never throwing. */
551
+ function isDirectoryNow(absolutePath: string): boolean {
507
552
  try {
508
- const native = fs.watch(
509
- hqRoot,
510
- { recursive: true, persistent: true },
553
+ return fs.lstatSync(absolutePath).isDirectory();
554
+ } catch {
555
+ return false;
556
+ }
557
+ }
558
+
559
+ function exists(absolutePath: string): boolean {
560
+ try {
561
+ fs.lstatSync(absolutePath);
562
+ return true;
563
+ } catch {
564
+ return false;
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Release the watch on `dir` AND on everything beneath it.
570
+ *
571
+ * When a directory is removed the OS reports that directory, not each of its
572
+ * descendants — so dropping only the exact path leaks every watch underneath
573
+ * it. Those handles stay open forever, and a leaked recursive root keeps
574
+ * marking its (now absent) subtree as covered, so a later recreation is never
575
+ * re-attached. These runners live for many hours, so the leak accumulates
576
+ * across every directory the tree churns through.
577
+ */
578
+ function dropWatch(dir: string): void {
579
+ const prefix = dir + path.sep;
580
+ for (const watched of [...watchers.keys()]) {
581
+ if (watched !== dir && !watched.startsWith(prefix)) continue;
582
+ const handle = watchers.get(watched);
583
+ if (handle) {
584
+ try {
585
+ handle.close();
586
+ } catch {
587
+ /* already closed */
588
+ }
589
+ }
590
+ watchers.delete(watched);
591
+ recursiveRoots.delete(watched);
592
+ recursiveFlag.delete(watched);
593
+ }
594
+ }
595
+
596
+ function addWatch(dir: string, recursive: boolean): void {
597
+ if (closed || watchers.has(dir)) return;
598
+ let handle: fs.FSWatcher;
599
+ try {
600
+ handle = fs.watch(
601
+ dir,
602
+ { recursive, persistent: true },
511
603
  (eventType, filename) => {
512
- // `filename` is relative to hqRoot (or null/empty if the platform
513
- // couldn't provide it — nothing actionable then). `String()` coerces
514
- // both the string and (older @types/node) Buffer shapes.
604
+ // `filename` is relative to THIS watch's directory (or null/empty if
605
+ // the platform couldn't provide it — nothing actionable then).
606
+ // `String()` coerces both the string and (older @types/node) Buffer
607
+ // shapes.
515
608
  if (filename == null) return;
516
609
  const rel = String(filename);
517
610
  if (rel === "") return;
518
- onEvent(
519
- path.resolve(hqRoot, rel),
520
- eventType === "change" ? "change" : "rename",
521
- );
611
+ const abs = path.resolve(dir, rel);
612
+ const kind: BackendChangeKind =
613
+ eventType === "change" ? "change" : "rename";
614
+ if (kind === "rename") reconcileCoverage(abs);
615
+ onEvent(abs, kind);
522
616
  },
523
617
  );
524
- let closed = false;
525
- let fallback: WatchBackend | null = null;
526
- native.on("error", (err) => {
527
- onError(err);
528
- if (closed || fallback !== null) return;
618
+ } catch (err) {
619
+ // A planned watch we could not attach. Report it and record the hole so
620
+ // the caller can reject the whole plan instead of running half-blind.
621
+ attachFailures += 1;
622
+ onError(err);
623
+ return;
624
+ }
625
+ handle.on("error", (err) => {
626
+ onError(err);
627
+ if (closed) return;
628
+ // Rebuild this root rather than leaving it permanently unwatched. The
629
+ // onError above has already asked TreeWatcher for a reconcile, so a gap
630
+ // between the drop and the re-attach cannot lose data.
631
+ const wasRecursive = recursiveFlag.get(dir) ?? recursive;
632
+ dropWatch(dir);
633
+ if (exists(dir) && shouldEmit(dir, true)) addWatch(dir, wasRecursive);
634
+ });
635
+ watchers.set(dir, handle);
636
+ recursiveFlag.set(dir, recursive);
637
+ if (recursive) recursiveRoots.add(dir);
638
+ }
639
+
640
+ function applyPlan(plan: WatchRootPlan): void {
641
+ for (const dir of plan.recursive) addWatch(dir, true);
642
+ for (const dir of plan.shallow) addWatch(dir, false);
643
+ }
644
+
645
+ function planAndAttach(dir: string): void {
646
+ applyPlan(planWatchRoots(dir, shouldEmit, { onDirectory: onDirectorySeen }));
647
+ }
648
+
649
+ /**
650
+ * Keep the live watch set honest about a path that just appeared, moved, or
651
+ * vanished. Three cases, all of which silently break coverage if ignored:
652
+ *
653
+ * 1. A watched directory was deleted (or deleted and recreated, which gives
654
+ * the replacement a new inode). Its stale handle would otherwise make the
655
+ * path look covered forever, so drop it and let the recreate re-plan.
656
+ * 2. An EXCLUDED directory was created under a recursive root — e.g.
657
+ * `workspace/worktrees/` or a fresh `node_modules/` that did not exist at
658
+ * plan time. The root now covers a bucket we must never watch, which is
659
+ * exactly the firehose this backend exists to prevent, so re-plan it.
660
+ * 3. An in-scope directory was created under a SHALLOW watch, so nothing
661
+ * covers its subtree yet. Attach before the event is handed on.
662
+ */
663
+ function reconcileCoverage(absolutePath: string): void {
664
+ if (closed) return;
665
+
666
+ // (1) stale handle for a path that no longer exists.
667
+ if (watchers.has(absolutePath) && !exists(absolutePath)) {
668
+ dropWatch(absolutePath);
669
+ return;
670
+ }
671
+
672
+ const inScope = shouldEmit(absolutePath, true);
673
+ const covered = isCoveredByRecursiveRoot(absolutePath, recursiveRoots);
674
+
675
+ // (2) newly-created exclusion swallowed by an existing recursive root. The
676
+ // lstat is paid only for excluded paths under a recursive root — rare, and
677
+ // strictly cheaper than the unconditional stat this backend replaced.
678
+ if (!inScope) {
679
+ if (covered && isDirectoryNow(absolutePath)) {
680
+ for (const rootDir of [...recursiveRoots]) {
681
+ if (
682
+ absolutePath === rootDir ||
683
+ absolutePath.startsWith(rootDir + path.sep)
684
+ ) {
685
+ dropWatch(rootDir);
686
+ planAndAttach(rootDir);
687
+ }
688
+ }
689
+ }
690
+ return;
691
+ }
692
+
693
+ // (3) in-scope directory with no coverage yet.
694
+ if (covered || watchers.has(absolutePath)) return;
695
+ if (!isDirectoryNow(absolutePath)) return;
696
+ onDirectorySeen?.(absolutePath);
697
+ planAndAttach(absolutePath);
698
+ }
699
+
700
+ // Cover the whole tree BEFORE planning. The plan walk below is synchronous
701
+ // and takes seconds on a real HQ root; without this, every change during that
702
+ // window would be invisible to event-driven push and would wait for the next
703
+ // cadence poll. This temporary watch is exactly the old backend's shape, so
704
+ // the bootstrap window is no worse than the previous behavior — and it is
705
+ // dropped the moment the scoped plan is live.
706
+ let bootstrap: fs.FSWatcher | null = null;
707
+ try {
708
+ bootstrap = fs.watch(
709
+ root,
710
+ { recursive: true, persistent: true },
711
+ (eventType, filename) => {
712
+ if (filename == null) return;
713
+ const rel = String(filename);
714
+ if (rel === "") return;
715
+ onEvent(
716
+ path.resolve(root, rel),
717
+ eventType === "change" ? "change" : "rename",
718
+ );
719
+ },
720
+ );
721
+ bootstrap.on("error", onError);
722
+ } catch (err) {
723
+ // Recursive watch is unavailable at all — the plan below cannot work
724
+ // either, so record the hole and let the caller fall back.
725
+ attachFailures += 1;
726
+ onError(err);
727
+ }
728
+
729
+ // Unbounded on purpose: this walk replaces TreeWatcher's separate known-kinds
730
+ // seed walk (same tree, same filter, ~3.6s on a real HQ root), so planning
731
+ // the tree exhaustively costs nothing over the status quo and leaves no
732
+ // excluded subtree covered by a recursive root.
733
+ planAndAttach(root);
734
+
735
+ // The scoped plan is live, so narrow from whole-tree to in-scope-only — but
736
+ // NOT synchronously. Events observed during the planning walk can still be
737
+ // sitting in the OS queue undelivered, and closing the handle here drops
738
+ // them, reopening the very gap the bootstrap watch exists to close. Hold it
739
+ // open for one drain window instead; the overlap only costs duplicate events
740
+ // for excluded paths, which the emit filter drops and the debounce coalesces.
741
+ if (bootstrap) {
742
+ const handle = bootstrap;
743
+ bootstrap = null;
744
+ const timer = setTimeout(() => {
745
+ try {
746
+ handle.close();
747
+ } catch {
748
+ /* already closed */
749
+ }
750
+ closeBootstrap = () => {};
751
+ }, BOOTSTRAP_DRAIN_MS);
752
+ // Never hold the process open just to retire a temporary watch.
753
+ (timer as { unref?: () => void }).unref?.();
754
+ closeBootstrap = () => {
755
+ clearTimeout(timer);
756
+ try {
757
+ handle.close();
758
+ } catch {
759
+ /* already closed */
760
+ }
761
+ closeBootstrap = () => {};
762
+ };
763
+ }
764
+
765
+ return {
766
+ close: () => {
767
+ closed = true;
768
+ closeBootstrap();
769
+ for (const handle of watchers.values()) {
529
770
  try {
530
- native.close();
771
+ handle.close();
531
772
  } catch {
532
773
  /* already closed */
533
774
  }
534
- try {
535
- fallback = startChokidarTreeWatch(
536
- hqRoot,
537
- shouldEmit,
538
- onEvent,
539
- onError,
540
- maxWatchedPaths,
541
- onWatchBudgetExceeded,
542
- );
543
- } catch (fallbackErr) {
544
- onError(fallbackErr);
545
- }
546
- });
547
- return {
548
- close: () => {
549
- closed = true;
550
- try {
551
- native.close();
552
- } catch {
553
- /* already closed */
554
- }
555
- fallback?.close();
556
- fallback = null;
557
- },
558
- needsKnownKinds: true,
559
- watchedPathCount: () => fallback?.watchedPathCount() ?? 1,
560
- };
775
+ }
776
+ watchers.clear();
777
+ recursiveRoots.clear();
778
+ recursiveFlag.clear();
779
+ },
780
+ needsKnownKinds: true,
781
+ seededKnownKinds: onDirectorySeen !== undefined,
782
+ watchedPathCount: () => watchers.size,
783
+ attachFailures,
784
+ };
785
+ }
786
+
787
+ /**
788
+ * Start watching `hqRoot`, calling `onEvent(absolutePath)` for every change.
789
+ *
790
+ * Backend selection (the whole point of this helper):
791
+ * - macOS / Windows → scoped recursive `fs.watch` handles over the in-scope
792
+ * tree only ({@link startScopedRecursiveWatch}). chokidar 4 dropped its
793
+ * `fsevents` backend, so on macOS it watches via kqueue, which costs ~1
794
+ * open fd PER watched path — ~11k fds over a real HQ tree, which EMFILEs
795
+ * under the default soft `ulimit -n` (256) and silently kills the watcher
796
+ * (instant sync then falls back to the poll).
797
+ * - Linux / other → chokidar (recursive `fs.watch` is unsupported there).
798
+ *
799
+ * The recursive backend does no descent-time filtering WITHIN a watched
800
+ * subtree, so changes there are funneled to `onEvent`, which re-applies the
801
+ * emit filter via {@link TreeWatcher.handleEvent}. That filter remains the
802
+ * authoritative gate; the watch plan is a performance optimization on top of
803
+ * it. This is also why the recursive backend is immune to the `.hqinclude`
804
+ * ancestor-descent pruning that the chokidar backend needs
805
+ * {@link toChokidarIgnored} to avoid.
806
+ */
807
+ function startTreeWatch(
808
+ hqRoot: string,
809
+ shouldEmit: WatchPathFilter,
810
+ onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
811
+ onError: (err: unknown) => void,
812
+ maxWatchedPaths: number,
813
+ onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
814
+ onDirectorySeen?: (absolutePath: string) => void,
815
+ ): WatchBackend {
816
+ if (supportsRecursiveWatch()) {
817
+ try {
818
+ const scoped = startScopedRecursiveWatch(
819
+ hqRoot,
820
+ shouldEmit,
821
+ onEvent,
822
+ onError,
823
+ onDirectorySeen,
824
+ );
825
+ // Partial coverage is worse than a different backend: a planned root we
826
+ // could not attach is silently unwatched forever, and the operator only
827
+ // sees it as "instant sync stopped working for that subtree". Accept the
828
+ // scoped plan only when EVERY planned watch attached.
829
+ if (scoped.attachFailures === 0 && scoped.watchedPathCount() > 0) {
830
+ return scoped;
831
+ }
832
+ scoped.close();
561
833
  } catch (err) {
562
834
  // Recursive watch unexpectedly unavailable — fall back to chokidar
563
835
  // rather than leaving the daemon with no watcher at all.
@@ -889,6 +1161,8 @@ export class TreeWatcher {
889
1161
  offendingPath: info.offendingPath,
890
1162
  offendingTopLevel: this.topLevelForTelemetry(info.offendingPath),
891
1163
  }),
1164
+ onDirectorySeen: (absolutePath) =>
1165
+ this.knownKinds.set(path.resolve(absolutePath), "directory"),
892
1166
  };
893
1167
  const backend = this.backendFactory
894
1168
  ? this.backendFactory(backendOpts)
@@ -899,6 +1173,7 @@ export class TreeWatcher {
899
1173
  backendOpts.onError,
900
1174
  backendOpts.maxWatchedPaths,
901
1175
  backendOpts.onWatchBudgetExceeded,
1176
+ backendOpts.onDirectorySeen,
902
1177
  );
903
1178
  // A test backend (or a future eager backend) can discover exhaustion while
904
1179
  // it is being constructed. Never retain that just-created handle after the
@@ -908,7 +1183,12 @@ export class TreeWatcher {
908
1183
  return;
909
1184
  }
910
1185
  this.backend = backend;
911
- if (backend.needsKnownKinds) this.seedKnownKinds();
1186
+ // The scoped backend already indexed every in-scope directory while
1187
+ // planning its watch roots — walking the tree again here would double a
1188
+ // multi-second startup cost for an identical result.
1189
+ if (backend.needsKnownKinds && !backend.seededKnownKinds) {
1190
+ this.seedKnownKinds();
1191
+ }
912
1192
  }
913
1193
 
914
1194
  private handleBackendError(err: unknown): void {
@@ -987,6 +1267,13 @@ export class TreeWatcher {
987
1267
  ): void {
988
1268
  if (this.disposed) return;
989
1269
  const abs = path.resolve(absolutePath);
1270
+ // Cheap pre-gate. When a path is out of scope BOTH as a file and as a
1271
+ // directory, no stat can change the answer — so drop it before paying the
1272
+ // syscall below. Scoped watch roots (see planWatchRoots) keep most excluded
1273
+ // traffic from being delivered at all; this covers the residue a
1274
+ // depth-bounded plan still admits, at the cost of two string matches
1275
+ // instead of one synchronous lstat.
1276
+ if (!this.shouldEmit(abs, false) && !this.shouldEmit(abs, true)) return;
990
1277
  let kind: TreeChangeKind;
991
1278
  if (backendKind === "rename") {
992
1279
  try {
@@ -1030,6 +1317,17 @@ export class TreeWatcher {
1030
1317
  }
1031
1318
  this.pending.set(abs, rel);
1032
1319
  if (kind === "unlink" || kind === "unlinkDir") {
1320
+ // A SECOND delete event for the same path in one window must never
1321
+ // downgrade a directory delete to a file delete. The first `unlinkDir`
1322
+ // clears this path's known-kind hint, so a duplicate event — the OS can
1323
+ // report one path through two overlapping watches — would re-classify it
1324
+ // as `unlink` and, with it, narrow the deletion scope the vault applies
1325
+ // and drop the captured descendant snapshots.
1326
+ if (kind === "unlink" && this.pendingChanges.get(abs)?.kind === "unlinkDir") {
1327
+ this.pending.set(abs, rel);
1328
+ this.arm();
1329
+ return;
1330
+ }
1033
1331
  const deleteSnapshots =
1034
1332
  this.captureLocalDeleteSnapshots?.(rel, kind) ?? [];
1035
1333
  this.pendingChanges.set(abs, {