@indigoai-us/hq-cloud 6.15.64 → 6.15.66

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.
@@ -12,7 +12,7 @@ import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
12
12
  import { getOrCreateMachineId } from "../lib/machine-id.js";
13
13
  import { localPathForVaultKey } from "../local-path-codec.js";
14
14
  import { TreeWatcher, resolveEventDebounceConfig, systemClock, } from "../watcher.js";
15
- import { clearLocalDeleteIntent, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, openJournalStoreSession, readJournal, writeJournal, } from "../journal.js";
15
+ import { clearLocalDeleteIntent, getStateDir, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, openJournalStoreSession, listJournals, readJournal, writeJournal, } from "../journal.js";
16
16
  import { NoopPushReceiver, } from "../sync/push-receiver.js";
17
17
  import { resolveEventSync, startEventSync as defaultStartEventSync, } from "../sync/event-sync.js";
18
18
  import { buildRoutePullArgv, buildScopedPushArgv, buildScopedDrainPullArgv, buildTargetedPullArgv, isWatchRouteSelected, routeChangeToTarget, routeKey, } from "./sync-runner-watch-routes.js";
@@ -44,6 +44,132 @@ function emitFullReconcileDecision({ mode, reasons, triggers, pollTick, configur
44
44
  */
45
45
  export const DEFAULT_EVENT_BATCH_LIMIT = 10_000;
46
46
  export const EVENT_BATCH_LIMIT_ENV = "HQ_SYNC_EVENT_BATCH_LIMIT";
47
+ /**
48
+ * Fast-lane admission is deliberately conservative: a normal editor save is
49
+ * a handful of files and well below 4 MiB, whereas session-log catch-up is
50
+ * neither. Operators may tune both limits without changing scheduling
51
+ * semantics. Invalid values retain the safe defaults.
52
+ */
53
+ export const FAST_LANE_MAX_FILES_ENV = "HQ_SYNC_FAST_LANE_MAX_FILES";
54
+ export const FAST_LANE_MAX_BYTES_ENV = "HQ_SYNC_FAST_LANE_MAX_BYTES";
55
+ export const FAST_LANE_MAX_FILE_BYTES_ENV = "HQ_SYNC_FAST_LANE_MAX_FILE_BYTES";
56
+ export const DEFAULT_FAST_LANE_MAX_FILES = 32;
57
+ export const DEFAULT_FAST_LANE_MAX_BYTES = 4 * 1024 * 1024;
58
+ /**
59
+ * A per-file ceiling keeps a single medium-sized transfer from consuming the
60
+ * realtime lane even when the batch aggregate is otherwise below its cap.
61
+ * 256 KiB comfortably covers ordinary notes and control-plane artifacts while
62
+ * leaving session logs and other transfer-heavy files in the slow lane.
63
+ */
64
+ export const DEFAULT_FAST_LANE_MAX_FILE_BYTES = 256 * 1024;
65
+ function positiveEnvInt(name, fallback) {
66
+ const value = Number.parseInt(process.env[name] ?? "", 10);
67
+ return Number.isFinite(value) && value > 0 ? value : fallback;
68
+ }
69
+ export function resolveFastLaneLimits() {
70
+ return {
71
+ maxFiles: positiveEnvInt(FAST_LANE_MAX_FILES_ENV, DEFAULT_FAST_LANE_MAX_FILES),
72
+ maxBytes: positiveEnvInt(FAST_LANE_MAX_BYTES_ENV, DEFAULT_FAST_LANE_MAX_BYTES),
73
+ maxFileBytes: positiveEnvInt(FAST_LANE_MAX_FILE_BYTES_ENV, DEFAULT_FAST_LANE_MAX_FILE_BYTES),
74
+ };
75
+ }
76
+ /**
77
+ * Select a bounded realtime prefix from already coalesced work. A path appears
78
+ * exactly once in this input, so a later change to the same path has already
79
+ * replaced its earlier revision and cannot straddle lanes.
80
+ */
81
+ function partitionByKnownSizes(items, sizeOf) {
82
+ const { maxFiles, maxBytes, maxFileBytes } = resolveFastLaneLimits();
83
+ const fast = [];
84
+ const slow = [];
85
+ let bytes = 0;
86
+ for (const item of items) {
87
+ const size = sizeOf(item);
88
+ if (size === null ||
89
+ size > maxFileBytes ||
90
+ fast.length >= maxFiles ||
91
+ bytes + size > maxBytes) {
92
+ slow.push(item);
93
+ continue;
94
+ }
95
+ fast.push(item);
96
+ bytes += size;
97
+ }
98
+ return { fast, slow };
99
+ }
100
+ function localPathSize(absolutePath, changes) {
101
+ // `unlinkDir` has an unbounded journal expansion; never guess its payload.
102
+ if (changes?.get(absolutePath)?.kind === "unlinkDir")
103
+ return null;
104
+ try {
105
+ const stat = fs.lstatSync(absolutePath);
106
+ if (stat.isDirectory())
107
+ return null;
108
+ return stat.isFile() ? stat.size : 0;
109
+ }
110
+ catch (err) {
111
+ // A missing file is an exact delete and has no content transfer. Any other
112
+ // stat error leaves its payload unknown and therefore slow.
113
+ return err.code === "ENOENT" ? 0 : null;
114
+ }
115
+ }
116
+ /**
117
+ * Partition one selected watcher batch. The returned batches retain the same
118
+ * route/filter metadata, but no latest path can appear in both lane maps.
119
+ */
120
+ export function partitionLocalBatch(batch) {
121
+ const partition = partitionByKnownSizes([...batch.paths.entries()], ([absolutePath]) => localPathSize(absolutePath, batch.changes));
122
+ const makeBatch = (entries) => {
123
+ if (entries.length === 0)
124
+ return null;
125
+ const paths = new Map(entries);
126
+ const changes = new Map();
127
+ for (const [absolutePath] of entries) {
128
+ const change = batch.changes?.get(absolutePath);
129
+ if (change)
130
+ changes.set(absolutePath, change);
131
+ }
132
+ return { ...batch, paths, ...(changes.size > 0 ? { changes } : {}) };
133
+ };
134
+ return { fast: makeBatch(partition.fast), slow: makeBatch(partition.slow) };
135
+ }
136
+ /** A missing/stat-failed path is never allowed to bypass the slow lane. */
137
+ export function isFastLocalBatch(paths, changes) {
138
+ const { maxFiles, maxBytes } = resolveFastLaneLimits();
139
+ let files = 0;
140
+ let bytes = 0;
141
+ for (const absolutePath of paths) {
142
+ files += 1;
143
+ if (files > maxFiles)
144
+ return false;
145
+ // `unlinkDir` arrives after its path is gone. Its scoped push expands the
146
+ // journal subtree through deleteScopeRoots, so its payload cannot be
147
+ // bounded by the absent directory's lstat result.
148
+ if (changes?.get(absolutePath)?.kind === "unlinkDir")
149
+ return false;
150
+ try {
151
+ const stat = fs.lstatSync(absolutePath);
152
+ // A directory-scoped operation may expand to an arbitrary payload. Do
153
+ // not guess: it belongs in the slow lane.
154
+ if (stat.isDirectory())
155
+ return false;
156
+ bytes += stat.isFile() ? stat.size : 0;
157
+ }
158
+ catch (err) {
159
+ // Deletes are zero-byte operations; every other stat failure is unknown
160
+ // payload and must retain normal serialization.
161
+ if (err.code !== "ENOENT")
162
+ return false;
163
+ }
164
+ if (bytes > maxBytes)
165
+ return false;
166
+ }
167
+ return files > 0;
168
+ }
169
+ /** A receiver event needs a known small upsert size before it can go realtime. */
170
+ export function isFastReceiverBatch(eventCount) {
171
+ return eventCount > 0 && eventCount <= resolveFastLaneLimits().maxFiles;
172
+ }
47
173
  export function resolveEventBatchLimit(env = process.env) {
48
174
  const raw = env[EVENT_BATCH_LIMIT_ENV];
49
175
  if (raw === undefined || raw.trim() === "")
@@ -84,6 +210,8 @@ export function resolveFullReconcileMs(env = process.env) {
84
210
  const ADAPTIVE_POLL_FLOOR_MS = 60_000;
85
211
  const ADAPTIVE_POLL_CEIL_MS = 600_000;
86
212
  const WATCH_IDLE_HEARTBEAT_INTERVAL_MS = 30_000;
213
+ /** Covers coarse timestamp filesystems and scheduling just before watcher start. */
214
+ const WATCHER_WARMUP_SAFETY_MARGIN_MS = 2_000;
87
215
  /** Initial poll-only recovery delay after a watcher conclusively stands down. */
88
216
  export const WATCHER_REDISCOVERY_RETRY_MS = 5 * 60_000;
89
217
  /** Ceiling for exponential retry delays after consecutive watcher stand-downs. */
@@ -222,6 +350,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
222
350
  skipPersonalEnv === "yes",
223
351
  };
224
352
  const hqRoot = parsed.hqRoot;
353
+ const serviceStartedAt = Date.now();
225
354
  // Delayed and daily: this must never be attached to the 15s poll cadence.
226
355
  // Stage 0 remains dry-run only; the standalone hq-backup-prune command is
227
356
  // the explicitly-gated apply surface.
@@ -285,7 +414,47 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
285
414
  throw err;
286
415
  }
287
416
  }
288
- const runPassWithLock = async (passArgvForRun, prepare) => {
417
+ // `activeSlowPass` is assigned before its task has necessarily acquired the
418
+ // cross-process lock (it may be waiting behind a rescue or a second runner).
419
+ // Fast work may bypass the lock only while this process *actually owns* it;
420
+ // otherwise it must participate in normal lock acquisition.
421
+ let processOwnsOperationLock = false;
422
+ let operationLockUsers = 0;
423
+ let resolveNoOperationLockUsers = null;
424
+ const acquireOperationLockUser = () => {
425
+ operationLockUsers += 1;
426
+ };
427
+ const releaseOperationLockUser = () => {
428
+ operationLockUsers -= 1;
429
+ if (operationLockUsers !== 0)
430
+ return;
431
+ const resolve = resolveNoOperationLockUsers;
432
+ resolveNoOperationLockUsers = null;
433
+ resolve?.();
434
+ };
435
+ const waitForOperationLockUsers = () => {
436
+ if (operationLockUsers === 0)
437
+ return null;
438
+ return new Promise((resolve) => {
439
+ resolveNoOperationLockUsers = resolve;
440
+ });
441
+ };
442
+ const runPassWithLock = async (passArgvForRun, prepare, lane = "slow") => {
443
+ // The operation lock remains the cross-process sync/rescue exclusion
444
+ // boundary. A fast scoped pass is the one deliberate in-process exception:
445
+ // its keyed journal delta and per-object mutation are independent from the
446
+ // slow pass's bulk paths, and the slow pass already owns the root lock.
447
+ // Never bypass the lock when no slow pass is active.
448
+ if (lane === "fast" && processOwnsOperationLock) {
449
+ acquireOperationLockUser();
450
+ try {
451
+ prepare?.();
452
+ return await runPass(passArgvForRun);
453
+ }
454
+ finally {
455
+ releaseOperationLockUser();
456
+ }
457
+ }
289
458
  try {
290
459
  const handle = acquireOperationLock(hqRoot, "sync", {
291
460
  timeoutSec: 0,
@@ -293,10 +462,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
293
462
  deferToWaiters: true,
294
463
  });
295
464
  try {
465
+ if (lane === "slow") {
466
+ processOwnsOperationLock = true;
467
+ acquireOperationLockUser();
468
+ }
296
469
  prepare?.();
297
470
  return await runPass(passArgvForRun);
298
471
  }
299
472
  finally {
473
+ if (lane === "slow") {
474
+ releaseOperationLockUser();
475
+ // The root lock protects a fast pass that was admitted while this
476
+ // slow pass owned it. Do not make it acquirable by rescue/another
477
+ // runner until every such in-process user has completed.
478
+ const remainingUsers = waitForOperationLockUsers();
479
+ if (remainingUsers)
480
+ await remainingUsers;
481
+ processOwnsOperationLock = false;
482
+ }
300
483
  handle.release();
301
484
  }
302
485
  }
@@ -309,9 +492,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
309
492
  }
310
493
  }
311
494
  try {
312
- return await withOperationLock(hqRoot, "sync", () => {
313
- prepare?.();
314
- return runPass(passArgvForRun);
495
+ return await withOperationLock(hqRoot, "sync", async () => {
496
+ if (lane === "slow") {
497
+ processOwnsOperationLock = true;
498
+ acquireOperationLockUser();
499
+ }
500
+ try {
501
+ prepare?.();
502
+ return await runPass(passArgvForRun);
503
+ }
504
+ finally {
505
+ if (lane === "slow") {
506
+ releaseOperationLockUser();
507
+ const remainingUsers = waitForOperationLockUsers();
508
+ if (remainingUsers)
509
+ await remainingUsers;
510
+ processOwnsOperationLock = false;
511
+ }
512
+ }
315
513
  }, { timeoutSec: parsed.lockTimeoutSec, deferToWaiters: true });
316
514
  }
317
515
  catch (err) {
@@ -413,51 +611,89 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
413
611
  observedInterruptGeneration = interruptGeneration;
414
612
  }
415
613
  };
416
- let activePass = null;
417
- const pendingPasses = [];
614
+ let activeSlowPass = null;
615
+ let activeFastPass = null;
616
+ const pendingSlowPasses = [];
617
+ const pendingFastPasses = [];
418
618
  const resolveStoppedQueue = () => {
419
- while (pendingPasses.length > 0) {
420
- pendingPasses.shift()?.resolve(0);
619
+ for (const queue of [pendingFastPasses, pendingSlowPasses]) {
620
+ while (queue.length > 0)
621
+ queue.shift()?.resolve(0);
421
622
  }
422
623
  };
423
624
  const drainQueuedPasses = () => {
424
- if (activePass !== null)
425
- return;
426
625
  if (stopped) {
427
626
  resolveStoppedQueue();
428
627
  return;
429
628
  }
430
- const next = pendingPasses.shift();
431
- if (!next)
432
- return;
433
- const current = startGuardedTask(next.task);
434
- void current.then(next.resolve, next.reject);
629
+ if (activeFastPass === null) {
630
+ const fast = pendingFastPasses.shift();
631
+ if (fast) {
632
+ const current = startGuardedTask(fast.task, "fast");
633
+ void current.then(fast.resolve, fast.reject);
634
+ }
635
+ }
636
+ // A fast pass that acquired the operation lock normally (rather than
637
+ // bypassing an in-process slow holder) still owns the cross-process
638
+ // boundary. Slow work must queue behind it; only fast work is allowed to
639
+ // overlap an already-lock-owning slow pass.
640
+ if (activeSlowPass === null && activeFastPass === null) {
641
+ const slow = pendingSlowPasses.shift();
642
+ if (slow) {
643
+ const current = startGuardedTask(slow.task, "slow");
644
+ void current.then(slow.resolve, slow.reject);
645
+ }
646
+ }
435
647
  };
436
- const startGuardedTask = (task) => {
437
- const current = stopped
438
- ? Promise.resolve(0)
439
- : task();
440
- activePass = current;
648
+ const startGuardedTask = (task, lane) => {
649
+ // Claim the lane before invoking `task`: a task can synchronously activate
650
+ // a watcher/scheduler callback before returning its promise. Without this
651
+ // placeholder that re-entrant callback sees an idle lane and starts a
652
+ // second lock contender.
653
+ const placeholder = Promise.resolve(0);
654
+ if (lane === "fast")
655
+ activeFastPass = placeholder;
656
+ else
657
+ activeSlowPass = placeholder;
658
+ let current;
659
+ try {
660
+ current = stopped ? placeholder : task();
661
+ }
662
+ catch (err) {
663
+ current = Promise.reject(err);
664
+ }
665
+ if (lane === "fast")
666
+ activeFastPass = current;
667
+ else
668
+ activeSlowPass = current;
441
669
  void current
442
670
  .finally(() => {
443
- if (activePass === current) {
444
- activePass = null;
671
+ if (lane === "fast" && activeFastPass === current) {
672
+ activeFastPass = null;
673
+ drainQueuedPasses();
674
+ }
675
+ else if (lane === "slow" && activeSlowPass === current) {
676
+ activeSlowPass = null;
445
677
  drainQueuedPasses();
446
678
  }
447
679
  })
448
680
  .catch(() => undefined);
449
681
  return current;
450
682
  };
451
- const runGuardedTask = (task) => {
452
- if (activePass === null && pendingPasses.length === 0) {
453
- return startGuardedTask(task);
683
+ const runGuardedTask = (task, lane = "slow") => {
684
+ const active = lane === "fast"
685
+ ? activeFastPass
686
+ : activeSlowPass ?? activeFastPass;
687
+ const queued = lane === "fast" ? pendingFastPasses : pendingSlowPasses;
688
+ if (active === null && queued.length === 0) {
689
+ return startGuardedTask(task, lane);
454
690
  }
455
691
  return new Promise((resolve, reject) => {
456
- pendingPasses.push({ task, resolve, reject });
692
+ queued.push({ task, lane, resolve, reject });
457
693
  drainQueuedPasses();
458
694
  });
459
695
  };
460
- const runGuarded = (passArgvForRun, prepare) => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare));
696
+ const runGuarded = (passArgvForRun, prepare, lane = "slow") => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare, lane), lane);
461
697
  let watcher = null;
462
698
  let eventPushSurfacesActivated = false;
463
699
  let watcherUnavailableWarned = false;
@@ -542,6 +778,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
542
778
  let pendingWatcherBatchGeneration = null;
543
779
  const lastPublishedWatcherBatchGenerationByRoute = new Map();
544
780
  let pendingWatcherBareChange = false;
781
+ let pendingWatcherWarmupCatchup = false;
782
+ let pendingWatcherWarmupCatchupFailed = false;
545
783
  let pendingWatcherOverflowed = false;
546
784
  let pendingWatcherDroppedPaths = 0;
547
785
  let pendingWatcherDroppedBytes = 0;
@@ -550,26 +788,28 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
550
788
  // pending state carried them; one hint-less overflow poisons the union.
551
789
  const pendingWatcherDroppedRouteHints = new Set();
552
790
  let pendingWatcherDroppedRoutesUnknown = false;
791
+ const selectWatchBatch = (batch) => {
792
+ const paths = new Map();
793
+ const changes = new Map();
794
+ for (const [absolutePath, relativePath] of batch.paths.entries()) {
795
+ const route = routeChangeToTarget(relativePath);
796
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection))
797
+ continue;
798
+ paths.set(absolutePath, relativePath);
799
+ const change = batch.changes?.get(absolutePath);
800
+ if (change)
801
+ changes.set(absolutePath, change);
802
+ }
803
+ if (paths.size === 0)
804
+ return null;
805
+ return { ...batch, paths, changes };
806
+ };
553
807
  const addPendingWatcherChange = (changedRelPath, batch) => {
554
808
  if (batch) {
555
- const paths = new Map();
556
- const changes = new Map();
557
- for (const [absolutePath, relativePath] of batch.paths.entries()) {
558
- const route = routeChangeToTarget(relativePath);
559
- if (!route || !isWatchRouteSelected(route, watchRouteSelection))
560
- continue;
561
- paths.set(absolutePath, relativePath);
562
- const change = batch.changes?.get(absolutePath);
563
- if (change)
564
- changes.set(absolutePath, change);
565
- }
566
- if (paths.size === 0)
809
+ const selectedBatch = selectWatchBatch(batch);
810
+ if (!selectedBatch)
567
811
  return false;
568
- batch = {
569
- ...batch,
570
- paths,
571
- changes,
572
- };
812
+ batch = selectedBatch;
573
813
  }
574
814
  else if (changedRelPath) {
575
815
  const route = routeChangeToTarget(changedRelPath);
@@ -651,18 +891,22 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
651
891
  }
652
892
  : null;
653
893
  const bare = pendingWatcherBareChange;
894
+ const warmupCatchup = pendingWatcherWarmupCatchup;
895
+ const warmupCatchupFailed = pendingWatcherWarmupCatchupFailed;
654
896
  const generation = pendingWatcherBatchGeneration;
655
897
  pendingWatcherPaths.clear();
656
898
  pendingWatcherChanges.clear();
657
899
  pendingWatcherOriginalBatch = null;
658
900
  pendingWatcherBareChange = false;
901
+ pendingWatcherWarmupCatchup = false;
902
+ pendingWatcherWarmupCatchupFailed = false;
659
903
  pendingWatcherOverflowed = false;
660
904
  pendingWatcherDroppedPaths = 0;
661
905
  pendingWatcherDroppedBytes = 0;
662
906
  pendingWatcherDroppedRouteHints.clear();
663
907
  pendingWatcherDroppedRoutesUnknown = false;
664
908
  pendingWatcherBatchGeneration = null;
665
- return { batch, bare, generation };
909
+ return { batch, bare, warmupCatchup, warmupCatchupFailed, generation };
666
910
  };
667
911
  const restorePendingWatcherPaths = (paths, generation) => {
668
912
  pendingWatcherBatchGeneration ??=
@@ -730,6 +974,22 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
730
974
  : relativePath.slice(prefix.length + 1),
731
975
  };
732
976
  };
977
+ /**
978
+ * A receiver event is eligible only when its upsert bytes are known small.
979
+ * Current publishers do not require a size field, so a locally journaled
980
+ * version is the backwards-compatible HEAD-size source. An event with no
981
+ * declared or journal-known size is bulk: guessing would let a large legacy
982
+ * transfer consume the realtime lane.
983
+ */
984
+ const receiverEventSize = (event) => {
985
+ if (event.kind !== "upsert")
986
+ return null;
987
+ const coordinates = journalCoordinates(event.relativePath);
988
+ if (!coordinates)
989
+ return null;
990
+ const size = readJournal(coordinates.slug).files[coordinates.key]?.size;
991
+ return Number.isSafeInteger(size) && size >= 0 ? size : null;
992
+ };
733
993
  const captureLocalDeleteSnapshots = (relativePath, kind) => {
734
994
  const coordinates = journalCoordinates(relativePath);
735
995
  const route = routeChangeToTarget(relativePath);
@@ -841,6 +1101,41 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
841
1101
  }
842
1102
  return snapshots;
843
1103
  };
1104
+ const warmupJournalPaths = () => {
1105
+ const paths = [];
1106
+ for (const { slug, journal } of listJournals()) {
1107
+ const route = slug === PERSONAL_VAULT_JOURNAL_SLUG || slug === "personal"
1108
+ ? { kind: "personal" }
1109
+ : { kind: "company", slug };
1110
+ if (!isWatchRouteSelected(route, watchRouteSelection))
1111
+ continue;
1112
+ for (const key of Object.keys(journal.files)) {
1113
+ paths.push(route.kind === "personal" ? key : `companies/${route.slug}/${key}`);
1114
+ }
1115
+ }
1116
+ return paths;
1117
+ };
1118
+ const publishedContentHashBaseline = () => {
1119
+ const hashes = {};
1120
+ for (const { slug, journal } of listJournals()) {
1121
+ const isPersonal = slug === PERSONAL_VAULT_JOURNAL_SLUG || slug === "personal";
1122
+ const route = isPersonal
1123
+ ? { kind: "personal" }
1124
+ : { kind: "company", slug };
1125
+ if (!isWatchRouteSelected(route, watchRouteSelection))
1126
+ continue;
1127
+ for (const [key, entry] of Object.entries(journal.files)) {
1128
+ // Tombstones and link records have no live regular-file body for the
1129
+ // PushEventEmitter's SHA-256 comparison, so they must not seed it.
1130
+ if (entry.removedAt !== undefined ||
1131
+ entry.kind === "symlink" ||
1132
+ entry.localDiverges)
1133
+ continue;
1134
+ hashes[isPersonal ? key : `companies/${slug}/${key}`] = `sha256:${entry.hash}`;
1135
+ }
1136
+ }
1137
+ return hashes;
1138
+ };
844
1139
  const prepareWatcherChanges = (changes) => {
845
1140
  const journals = new Map();
846
1141
  const dirty = new Set();
@@ -939,7 +1234,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
939
1234
  }
940
1235
  return false;
941
1236
  };
942
- const runScopedDrain = async (batch, watcherBatchGeneration) => {
1237
+ const runScopedDrain = async (batch, watcherBatchGeneration, wakeOnPushFailure = false) => {
943
1238
  const grouped = batch && batch.paths.size > 0 ? groupBatchByRoute(batch) : null;
944
1239
  const skipCompanies = grouped ? resolveSkipCompanies() : null;
945
1240
  const isPausedRoute = (route) => route.kind === "company" &&
@@ -953,9 +1248,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
953
1248
  }
954
1249
  const scopedArgv = buildScopedPushArgv(group.route, group.relPaths, passArgv, group.deleteScopeRoots);
955
1250
  try {
1251
+ const lane = isFastLocalBatch(group.paths.keys(), group.changes)
1252
+ ? "fast"
1253
+ : "slow";
956
1254
  const result = await runGuarded(scopedArgv, group.changes.size > 0
957
1255
  ? () => prepareWatcherChanges(group.changes)
958
- : undefined);
1256
+ : undefined, lane);
959
1257
  if (passExitCode(result) === 0) {
960
1258
  const refusedPaths = new Set(typeof result === "number"
961
1259
  ? []
@@ -980,11 +1278,15 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
980
1278
  }
981
1279
  else {
982
1280
  restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
1281
+ if (wakeOnPushFailure)
1282
+ interruptWait();
983
1283
  }
984
1284
  }
985
1285
  catch (err) {
986
1286
  restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
987
1287
  process.stderr.write(`watch scoped push failed, will retry next tick: ${describeError(err)}\n`);
1288
+ if (wakeOnPushFailure)
1289
+ interruptWait();
988
1290
  }
989
1291
  }
990
1292
  }
@@ -1012,7 +1314,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1012
1314
  continue;
1013
1315
  ranPersonalDrain = true;
1014
1316
  }
1015
- const exit = passExitCode(await runGuarded(pullArgv));
1317
+ const exit = passExitCode(await runGuarded(pullArgv, undefined, isFastLocalBatch(group.paths.keys(), group.changes) ? "fast" : "slow"));
1016
1318
  if (exit !== 0 && worstExit === 0)
1017
1319
  worstExit = exit;
1018
1320
  }
@@ -1146,6 +1448,36 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1146
1448
  watcher.onChange((changedRelPath, batch) => {
1147
1449
  if (stopped)
1148
1450
  return;
1451
+ // Peel a bounded realtime sub-batch from a mixed watcher batch while a
1452
+ // bulk pass is in flight. The residue enters the ordinary pending drain
1453
+ // immediately; a latest path appears in exactly one of these maps.
1454
+ const selectedBatch = batch ? selectWatchBatch(batch) : null;
1455
+ const lanes = selectedBatch && !selectedBatch.overflowed
1456
+ ? partitionLocalBatch(selectedBatch)
1457
+ : null;
1458
+ if (lanes?.fast &&
1459
+ activeSlowPass !== null) {
1460
+ const generation = ++nextWatcherBatchGeneration;
1461
+ // Fast dispatch bypasses the pending-batch ingress, so preserve both
1462
+ // of its contracts here: only selected routes may run, and V2 sees
1463
+ // the same dirty signal as the compatibility drain.
1464
+ realtimeState.scheduler?.signal("watcher");
1465
+ void runScopedDrain(lanes.fast, generation, true).then((exit) => {
1466
+ if (exit === 0)
1467
+ return;
1468
+ restorePendingWatcherPaths(lanes.fast.paths, generation);
1469
+ interruptWait();
1470
+ }).catch((err) => {
1471
+ restorePendingWatcherPaths(lanes.fast.paths, generation);
1472
+ process.stderr.write(`watch fast-lane scoped push failed, will retry next tick: ${describeError(err)}\n`);
1473
+ interruptWait();
1474
+ });
1475
+ if (lanes.slow && addPendingWatcherChange(undefined, lanes.slow)) {
1476
+ realtimeState.scheduler?.signal("watcher");
1477
+ interruptWait();
1478
+ }
1479
+ return;
1480
+ }
1149
1481
  if (addPendingWatcherChange(changedRelPath, batch)) {
1150
1482
  // The V1 watcher batch keeps its existing compatibility behavior. A
1151
1483
  // V2-enrolled scope observes the same dirty hint through its own
@@ -1178,6 +1510,30 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1178
1510
  // initial walk will finish.
1179
1511
  watcherRediscoveryRetries = 0;
1180
1512
  process.stderr.write(`hq-sync-runner: event-push watcher active (watched-paths=${watchedPaths ?? "unknown"})\n`);
1513
+ const startedAt = Date.now();
1514
+ try {
1515
+ const catchup = watcher.collectWarmupCatchup?.(serviceStartedAt - WATCHER_WARMUP_SAFETY_MARGIN_MS, warmupJournalPaths());
1516
+ if (!catchup)
1517
+ return;
1518
+ const queued = addPendingWatcherChange(undefined, catchup.batch);
1519
+ pendingWatcherWarmupCatchup ||= queued;
1520
+ process.stderr.write(JSON.stringify({
1521
+ type: "watcher-warmup-catchup",
1522
+ reason: "watch-set-ready",
1523
+ pathsFound: catchup.batch.paths.size,
1524
+ scannedPaths: catchup.scannedPaths,
1525
+ elapsedMs: Date.now() - startedAt,
1526
+ queued,
1527
+ }) + "\n");
1528
+ if (queued)
1529
+ interruptWait();
1530
+ }
1531
+ catch (err) {
1532
+ process.stderr.write(`hq-sync-runner: watcher warm-up catch-up failed: ${describeError(err)}\n`);
1533
+ // A scan failure must not silently extend the warm-up blind spot.
1534
+ pendingWatcherWarmupCatchupFailed = true;
1535
+ interruptWait();
1536
+ }
1181
1537
  };
1182
1538
  if (watcher.onReady)
1183
1539
  watcher.onReady(reportWatcherReady);
@@ -1214,25 +1570,39 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1214
1570
  // so it cannot overlap the pass that may currently own the operation
1215
1571
  // lock.
1216
1572
  interruptWait();
1217
- const pathsByRoute = new Map();
1218
- for (const event of events) {
1219
- const route = routeChangeToTarget(event.relativePath);
1220
- if (!route)
1221
- continue;
1222
- const key = routeKey(route);
1223
- const group = pathsByRoute.get(key) ?? { route, paths: [] };
1224
- group.paths.push(event.relativePath);
1225
- pathsByRoute.set(key, group);
1226
- }
1227
- for (const { route, paths } of pathsByRoute.values()) {
1228
- // One guarded targeted pass per affected route, with every coalesced
1229
- // path for that route expressed through the watcher's existing argv seam.
1230
- const targetedArgv = buildTargetedPullArgv(route, passArgv, paths);
1231
- const result = await runGuarded(targetedArgv);
1232
- if (passExitCode(result) !== 0) {
1233
- throw new Error(`targeted pull failed with exit code ${passExitCode(result)}`);
1573
+ // Apply the caps once across the whole delivery, then retain the residue
1574
+ // in the normal lane. The receiver already coalesces to one latest event
1575
+ // per path, so the partition cannot split revisions of one path.
1576
+ const lanes = partitionByKnownSizes(events, receiverEventSize);
1577
+ const drainLane = async (lane, laneEvents) => {
1578
+ const pathsByRoute = new Map();
1579
+ for (const event of laneEvents) {
1580
+ const route = routeChangeToTarget(event.relativePath);
1581
+ if (!route)
1582
+ continue;
1583
+ const key = routeKey(route);
1584
+ const group = pathsByRoute.get(key) ?? { route, paths: [] };
1585
+ group.paths.push(event.relativePath);
1586
+ pathsByRoute.set(key, group);
1234
1587
  }
1235
- }
1588
+ for (const { route, paths } of pathsByRoute.values()) {
1589
+ const targetedArgv = buildTargetedPullArgv(route, passArgv, paths);
1590
+ const result = await runGuarded(targetedArgv, undefined, lane);
1591
+ if (passExitCode(result) !== 0) {
1592
+ throw new Error(`targeted pull failed with exit code ${passExitCode(result)}`);
1593
+ }
1594
+ }
1595
+ };
1596
+ // Dispatch realtime work before awaiting its bulk residue. This lets an
1597
+ // established slow pass continue while the small scoped pull uses its
1598
+ // ref-counted lock carve-out; the residue remains serialized and cannot
1599
+ // starve the slow lane.
1600
+ await drainLane("fast", lanes.fast);
1601
+ await drainLane("slow", lanes.slow);
1602
+ };
1603
+ receiverSyncBatchFn.partition = (events) => {
1604
+ const lanes = partitionByKnownSizes(events, receiverEventSize);
1605
+ return [lanes.fast, lanes.slow].filter((lane) => lane.length > 0);
1236
1606
  };
1237
1607
  const createReceiver = deps.createReceiver ?? (() => new NoopPushReceiver());
1238
1608
  receiver = createReceiver({ syncBatchFn: receiverSyncBatchFn, hqRoot });
@@ -1246,10 +1616,23 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1246
1616
  void deps.startRealtimeScheduler({
1247
1617
  hqRoot,
1248
1618
  runGuarded: async (drain) => {
1249
- const outcome = await runGuardedTask(() => runTaskWithLock(async () => {
1250
- await drain();
1251
- return 0;
1252
- }));
1619
+ let outcome;
1620
+ try {
1621
+ outcome = await runGuardedTask(() => runTaskWithLock(async () => {
1622
+ await drain();
1623
+ return 0;
1624
+ }));
1625
+ }
1626
+ catch (err) {
1627
+ // Scheduler clients may have already been disposed when a queued
1628
+ // drain finally reaches the lock. Surface the late failure, but do
1629
+ // not leak an unhandled rejection after shutdown has completed.
1630
+ if (stopped) {
1631
+ process.stderr.write("realtime scheduler: cancelled drain failed after shutdown\n");
1632
+ return;
1633
+ }
1634
+ throw err;
1635
+ }
1253
1636
  if (passExitCode(outcome) !== 0) {
1254
1637
  throw new Error(`realtime scheduler drain exited ${passExitCode(outcome)}`);
1255
1638
  }
@@ -1297,6 +1680,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1297
1680
  log: (m) => process.stderr.write(`${m}\n`),
1298
1681
  telemetryClient: eventSyncClient,
1299
1682
  telemetryClaims: claims,
1683
+ publishedContentHashStateDir: getStateDir(),
1684
+ initialPublishedContentHashes: publishedContentHashBaseline(),
1300
1685
  });
1301
1686
  if (!handles)
1302
1687
  return;
@@ -1391,6 +1776,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1391
1776
  const eventPushSurfacesInactive = eventPush && !eventPushSurfacesActivated;
1392
1777
  const firstTick = pollTick === 1;
1393
1778
  const bareWake = pending.bare;
1779
+ const watcherWarmupCatchup = pending.warmupCatchup;
1780
+ const watcherWarmupCatchupFailed = pending.warmupCatchupFailed;
1394
1781
  const overflowRoutesUnknown = pending.batch?.overflowed === true && !overflowWithKnownRoutes;
1395
1782
  const scheduledInterval = fullReconcileIntervalMs === undefined
1396
1783
  ? pollTick % FULL_RECONCILE_EVERY_TICKS === 0
@@ -1402,6 +1789,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1402
1789
  "bare-wake": bareWake,
1403
1790
  "batch-over-limit": batchOverLimit,
1404
1791
  "overflow-routes-unknown": overflowRoutesUnknown,
1792
+ "watcher-warmup-catch-up": watcherWarmupCatchup,
1793
+ "watcher-warmup-catch-up-failed": watcherWarmupCatchupFailed,
1405
1794
  "scheduled-interval": scheduledInterval,
1406
1795
  };
1407
1796
  const reasons = Object.entries(triggers)
@@ -1412,6 +1801,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1412
1801
  bareWake ||
1413
1802
  batchOverLimit ||
1414
1803
  overflowRoutesUnknown ||
1804
+ watcherWarmupCatchupFailed ||
1415
1805
  scheduledInterval;
1416
1806
  const emitDecision = (mode, decisionReasons) => {
1417
1807
  emitFullReconcileDecision({
@@ -1447,7 +1837,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1447
1837
  // were dropped); ordinary batches remain path-scoped. Label those
1448
1838
  // distinct scopes so operators can distinguish them from a full
1449
1839
  // all-vault reconcile.
1450
- emitDecision(overflowWithKnownRoutes ? "route" : "scoped", reasons);
1840
+ emitDecision(watcherWarmupCatchup
1841
+ ? "catch-up"
1842
+ : overflowWithKnownRoutes
1843
+ ? "route"
1844
+ : "scoped", reasons);
1451
1845
  }
1452
1846
  catch (err) {
1453
1847
  // Never silently drop a change: any failure inside the targeted