@indigoai-us/hq-cloud 6.15.63 → 6.15.65

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, 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,63 @@ 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 DEFAULT_FAST_LANE_MAX_FILES = 32;
56
+ export const DEFAULT_FAST_LANE_MAX_BYTES = 4 * 1024 * 1024;
57
+ function positiveEnvInt(name, fallback) {
58
+ const value = Number.parseInt(process.env[name] ?? "", 10);
59
+ return Number.isFinite(value) && value > 0 ? value : fallback;
60
+ }
61
+ export function resolveFastLaneLimits() {
62
+ return {
63
+ maxFiles: positiveEnvInt(FAST_LANE_MAX_FILES_ENV, DEFAULT_FAST_LANE_MAX_FILES),
64
+ maxBytes: positiveEnvInt(FAST_LANE_MAX_BYTES_ENV, DEFAULT_FAST_LANE_MAX_BYTES),
65
+ };
66
+ }
67
+ /** A missing/stat-failed path is never allowed to bypass the slow lane. */
68
+ export function isFastLocalBatch(paths, changes) {
69
+ const { maxFiles, maxBytes } = resolveFastLaneLimits();
70
+ let files = 0;
71
+ let bytes = 0;
72
+ for (const absolutePath of paths) {
73
+ files += 1;
74
+ if (files > maxFiles)
75
+ return false;
76
+ // `unlinkDir` arrives after its path is gone. Its scoped push expands the
77
+ // journal subtree through deleteScopeRoots, so its payload cannot be
78
+ // bounded by the absent directory's lstat result.
79
+ if (changes?.get(absolutePath)?.kind === "unlinkDir")
80
+ return false;
81
+ try {
82
+ const stat = fs.lstatSync(absolutePath);
83
+ // A directory-scoped operation may expand to an arbitrary payload. Do
84
+ // not guess: it belongs in the slow lane.
85
+ if (stat.isDirectory())
86
+ return false;
87
+ bytes += stat.isFile() ? stat.size : 0;
88
+ }
89
+ catch (err) {
90
+ // Deletes are zero-byte operations; every other stat failure is unknown
91
+ // payload and must retain normal serialization.
92
+ if (err.code !== "ENOENT")
93
+ return false;
94
+ }
95
+ if (bytes > maxBytes)
96
+ return false;
97
+ }
98
+ return files > 0;
99
+ }
100
+ /** Receiver events carry no content length, so only bounded event batches qualify. */
101
+ export function isFastReceiverBatch(eventCount) {
102
+ return eventCount > 0 && eventCount <= resolveFastLaneLimits().maxFiles;
103
+ }
47
104
  export function resolveEventBatchLimit(env = process.env) {
48
105
  const raw = env[EVENT_BATCH_LIMIT_ENV];
49
106
  if (raw === undefined || raw.trim() === "")
@@ -84,6 +141,8 @@ export function resolveFullReconcileMs(env = process.env) {
84
141
  const ADAPTIVE_POLL_FLOOR_MS = 60_000;
85
142
  const ADAPTIVE_POLL_CEIL_MS = 600_000;
86
143
  const WATCH_IDLE_HEARTBEAT_INTERVAL_MS = 30_000;
144
+ /** Covers coarse timestamp filesystems and scheduling just before watcher start. */
145
+ const WATCHER_WARMUP_SAFETY_MARGIN_MS = 2_000;
87
146
  /** Initial poll-only recovery delay after a watcher conclusively stands down. */
88
147
  export const WATCHER_REDISCOVERY_RETRY_MS = 5 * 60_000;
89
148
  /** Ceiling for exponential retry delays after consecutive watcher stand-downs. */
@@ -222,6 +281,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
222
281
  skipPersonalEnv === "yes",
223
282
  };
224
283
  const hqRoot = parsed.hqRoot;
284
+ const serviceStartedAt = Date.now();
225
285
  // Delayed and daily: this must never be attached to the 15s poll cadence.
226
286
  // Stage 0 remains dry-run only; the standalone hq-backup-prune command is
227
287
  // the explicitly-gated apply surface.
@@ -260,8 +320,14 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
260
320
  // mis-defaults an omitted --direction to "both" and picks the wrong
261
321
  // occurrence on duplicates). Fall back to the parser's own default ("pull").
262
322
  const originalDirection = parsed.direction ?? "pull";
263
- const drainRunsPush = originalDirection === "both" || originalDirection === "push";
264
323
  const drainRunsPull = originalDirection === "both" || originalDirection === "pull";
324
+ // `--event-push` is an explicit local-write delivery contract. The
325
+ // one-shot/cadence direction still controls ordinary reconciliation, but a
326
+ // watcher batch must always take its bounded scoped push path even when the
327
+ // parser's legacy default direction is pull. Otherwise the drain snapshots
328
+ // and clears a local batch while running only its pull leg, leaving the
329
+ // write for a later full reconcile.
330
+ const watcherDrainRunsPush = eventPush;
265
331
  if (parsed.lockTimeoutSec === 0) {
266
332
  try {
267
333
  const handle = acquireOperationLock(hqRoot, "sync", {
@@ -279,7 +345,47 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
279
345
  throw err;
280
346
  }
281
347
  }
282
- const runPassWithLock = async (passArgvForRun, prepare) => {
348
+ // `activeSlowPass` is assigned before its task has necessarily acquired the
349
+ // cross-process lock (it may be waiting behind a rescue or a second runner).
350
+ // Fast work may bypass the lock only while this process *actually owns* it;
351
+ // otherwise it must participate in normal lock acquisition.
352
+ let processOwnsOperationLock = false;
353
+ let operationLockUsers = 0;
354
+ let resolveNoOperationLockUsers = null;
355
+ const acquireOperationLockUser = () => {
356
+ operationLockUsers += 1;
357
+ };
358
+ const releaseOperationLockUser = () => {
359
+ operationLockUsers -= 1;
360
+ if (operationLockUsers !== 0)
361
+ return;
362
+ const resolve = resolveNoOperationLockUsers;
363
+ resolveNoOperationLockUsers = null;
364
+ resolve?.();
365
+ };
366
+ const waitForOperationLockUsers = () => {
367
+ if (operationLockUsers === 0)
368
+ return null;
369
+ return new Promise((resolve) => {
370
+ resolveNoOperationLockUsers = resolve;
371
+ });
372
+ };
373
+ const runPassWithLock = async (passArgvForRun, prepare, lane = "slow") => {
374
+ // The operation lock remains the cross-process sync/rescue exclusion
375
+ // boundary. A fast scoped pass is the one deliberate in-process exception:
376
+ // its keyed journal delta and per-object mutation are independent from the
377
+ // slow pass's bulk paths, and the slow pass already owns the root lock.
378
+ // Never bypass the lock when no slow pass is active.
379
+ if (lane === "fast" && processOwnsOperationLock) {
380
+ acquireOperationLockUser();
381
+ try {
382
+ prepare?.();
383
+ return await runPass(passArgvForRun);
384
+ }
385
+ finally {
386
+ releaseOperationLockUser();
387
+ }
388
+ }
283
389
  try {
284
390
  const handle = acquireOperationLock(hqRoot, "sync", {
285
391
  timeoutSec: 0,
@@ -287,10 +393,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
287
393
  deferToWaiters: true,
288
394
  });
289
395
  try {
396
+ if (lane === "slow") {
397
+ processOwnsOperationLock = true;
398
+ acquireOperationLockUser();
399
+ }
290
400
  prepare?.();
291
401
  return await runPass(passArgvForRun);
292
402
  }
293
403
  finally {
404
+ if (lane === "slow") {
405
+ releaseOperationLockUser();
406
+ // The root lock protects a fast pass that was admitted while this
407
+ // slow pass owned it. Do not make it acquirable by rescue/another
408
+ // runner until every such in-process user has completed.
409
+ const remainingUsers = waitForOperationLockUsers();
410
+ if (remainingUsers)
411
+ await remainingUsers;
412
+ processOwnsOperationLock = false;
413
+ }
294
414
  handle.release();
295
415
  }
296
416
  }
@@ -303,9 +423,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
303
423
  }
304
424
  }
305
425
  try {
306
- return await withOperationLock(hqRoot, "sync", () => {
307
- prepare?.();
308
- return runPass(passArgvForRun);
426
+ return await withOperationLock(hqRoot, "sync", async () => {
427
+ if (lane === "slow") {
428
+ processOwnsOperationLock = true;
429
+ acquireOperationLockUser();
430
+ }
431
+ try {
432
+ prepare?.();
433
+ return await runPass(passArgvForRun);
434
+ }
435
+ finally {
436
+ if (lane === "slow") {
437
+ releaseOperationLockUser();
438
+ const remainingUsers = waitForOperationLockUsers();
439
+ if (remainingUsers)
440
+ await remainingUsers;
441
+ processOwnsOperationLock = false;
442
+ }
443
+ }
309
444
  }, { timeoutSec: parsed.lockTimeoutSec, deferToWaiters: true });
310
445
  }
311
446
  catch (err) {
@@ -407,51 +542,89 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
407
542
  observedInterruptGeneration = interruptGeneration;
408
543
  }
409
544
  };
410
- let activePass = null;
411
- const pendingPasses = [];
545
+ let activeSlowPass = null;
546
+ let activeFastPass = null;
547
+ const pendingSlowPasses = [];
548
+ const pendingFastPasses = [];
412
549
  const resolveStoppedQueue = () => {
413
- while (pendingPasses.length > 0) {
414
- pendingPasses.shift()?.resolve(0);
550
+ for (const queue of [pendingFastPasses, pendingSlowPasses]) {
551
+ while (queue.length > 0)
552
+ queue.shift()?.resolve(0);
415
553
  }
416
554
  };
417
555
  const drainQueuedPasses = () => {
418
- if (activePass !== null)
419
- return;
420
556
  if (stopped) {
421
557
  resolveStoppedQueue();
422
558
  return;
423
559
  }
424
- const next = pendingPasses.shift();
425
- if (!next)
426
- return;
427
- const current = startGuardedTask(next.task);
428
- void current.then(next.resolve, next.reject);
560
+ if (activeFastPass === null) {
561
+ const fast = pendingFastPasses.shift();
562
+ if (fast) {
563
+ const current = startGuardedTask(fast.task, "fast");
564
+ void current.then(fast.resolve, fast.reject);
565
+ }
566
+ }
567
+ // A fast pass that acquired the operation lock normally (rather than
568
+ // bypassing an in-process slow holder) still owns the cross-process
569
+ // boundary. Slow work must queue behind it; only fast work is allowed to
570
+ // overlap an already-lock-owning slow pass.
571
+ if (activeSlowPass === null && activeFastPass === null) {
572
+ const slow = pendingSlowPasses.shift();
573
+ if (slow) {
574
+ const current = startGuardedTask(slow.task, "slow");
575
+ void current.then(slow.resolve, slow.reject);
576
+ }
577
+ }
429
578
  };
430
- const startGuardedTask = (task) => {
431
- const current = stopped
432
- ? Promise.resolve(0)
433
- : task();
434
- activePass = current;
579
+ const startGuardedTask = (task, lane) => {
580
+ // Claim the lane before invoking `task`: a task can synchronously activate
581
+ // a watcher/scheduler callback before returning its promise. Without this
582
+ // placeholder that re-entrant callback sees an idle lane and starts a
583
+ // second lock contender.
584
+ const placeholder = Promise.resolve(0);
585
+ if (lane === "fast")
586
+ activeFastPass = placeholder;
587
+ else
588
+ activeSlowPass = placeholder;
589
+ let current;
590
+ try {
591
+ current = stopped ? placeholder : task();
592
+ }
593
+ catch (err) {
594
+ current = Promise.reject(err);
595
+ }
596
+ if (lane === "fast")
597
+ activeFastPass = current;
598
+ else
599
+ activeSlowPass = current;
435
600
  void current
436
601
  .finally(() => {
437
- if (activePass === current) {
438
- activePass = null;
602
+ if (lane === "fast" && activeFastPass === current) {
603
+ activeFastPass = null;
604
+ drainQueuedPasses();
605
+ }
606
+ else if (lane === "slow" && activeSlowPass === current) {
607
+ activeSlowPass = null;
439
608
  drainQueuedPasses();
440
609
  }
441
610
  })
442
611
  .catch(() => undefined);
443
612
  return current;
444
613
  };
445
- const runGuardedTask = (task) => {
446
- if (activePass === null && pendingPasses.length === 0) {
447
- return startGuardedTask(task);
614
+ const runGuardedTask = (task, lane = "slow") => {
615
+ const active = lane === "fast"
616
+ ? activeFastPass
617
+ : activeSlowPass ?? activeFastPass;
618
+ const queued = lane === "fast" ? pendingFastPasses : pendingSlowPasses;
619
+ if (active === null && queued.length === 0) {
620
+ return startGuardedTask(task, lane);
448
621
  }
449
622
  return new Promise((resolve, reject) => {
450
- pendingPasses.push({ task, resolve, reject });
623
+ queued.push({ task, lane, resolve, reject });
451
624
  drainQueuedPasses();
452
625
  });
453
626
  };
454
- const runGuarded = (passArgvForRun, prepare) => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare));
627
+ const runGuarded = (passArgvForRun, prepare, lane = "slow") => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare, lane), lane);
455
628
  let watcher = null;
456
629
  let eventPushSurfacesActivated = false;
457
630
  let watcherUnavailableWarned = false;
@@ -536,6 +709,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
536
709
  let pendingWatcherBatchGeneration = null;
537
710
  const lastPublishedWatcherBatchGenerationByRoute = new Map();
538
711
  let pendingWatcherBareChange = false;
712
+ let pendingWatcherWarmupCatchup = false;
713
+ let pendingWatcherWarmupCatchupFailed = false;
539
714
  let pendingWatcherOverflowed = false;
540
715
  let pendingWatcherDroppedPaths = 0;
541
716
  let pendingWatcherDroppedBytes = 0;
@@ -544,26 +719,28 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
544
719
  // pending state carried them; one hint-less overflow poisons the union.
545
720
  const pendingWatcherDroppedRouteHints = new Set();
546
721
  let pendingWatcherDroppedRoutesUnknown = false;
722
+ const selectWatchBatch = (batch) => {
723
+ const paths = new Map();
724
+ const changes = new Map();
725
+ for (const [absolutePath, relativePath] of batch.paths.entries()) {
726
+ const route = routeChangeToTarget(relativePath);
727
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection))
728
+ continue;
729
+ paths.set(absolutePath, relativePath);
730
+ const change = batch.changes?.get(absolutePath);
731
+ if (change)
732
+ changes.set(absolutePath, change);
733
+ }
734
+ if (paths.size === 0)
735
+ return null;
736
+ return { ...batch, paths, changes };
737
+ };
547
738
  const addPendingWatcherChange = (changedRelPath, batch) => {
548
739
  if (batch) {
549
- const paths = new Map();
550
- const changes = new Map();
551
- for (const [absolutePath, relativePath] of batch.paths.entries()) {
552
- const route = routeChangeToTarget(relativePath);
553
- if (!route || !isWatchRouteSelected(route, watchRouteSelection))
554
- continue;
555
- paths.set(absolutePath, relativePath);
556
- const change = batch.changes?.get(absolutePath);
557
- if (change)
558
- changes.set(absolutePath, change);
559
- }
560
- if (paths.size === 0)
740
+ const selectedBatch = selectWatchBatch(batch);
741
+ if (!selectedBatch)
561
742
  return false;
562
- batch = {
563
- ...batch,
564
- paths,
565
- changes,
566
- };
743
+ batch = selectedBatch;
567
744
  }
568
745
  else if (changedRelPath) {
569
746
  const route = routeChangeToTarget(changedRelPath);
@@ -645,18 +822,22 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
645
822
  }
646
823
  : null;
647
824
  const bare = pendingWatcherBareChange;
825
+ const warmupCatchup = pendingWatcherWarmupCatchup;
826
+ const warmupCatchupFailed = pendingWatcherWarmupCatchupFailed;
648
827
  const generation = pendingWatcherBatchGeneration;
649
828
  pendingWatcherPaths.clear();
650
829
  pendingWatcherChanges.clear();
651
830
  pendingWatcherOriginalBatch = null;
652
831
  pendingWatcherBareChange = false;
832
+ pendingWatcherWarmupCatchup = false;
833
+ pendingWatcherWarmupCatchupFailed = false;
653
834
  pendingWatcherOverflowed = false;
654
835
  pendingWatcherDroppedPaths = 0;
655
836
  pendingWatcherDroppedBytes = 0;
656
837
  pendingWatcherDroppedRouteHints.clear();
657
838
  pendingWatcherDroppedRoutesUnknown = false;
658
839
  pendingWatcherBatchGeneration = null;
659
- return { batch, bare, generation };
840
+ return { batch, bare, warmupCatchup, warmupCatchupFailed, generation };
660
841
  };
661
842
  const restorePendingWatcherPaths = (paths, generation) => {
662
843
  pendingWatcherBatchGeneration ??=
@@ -835,6 +1016,20 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
835
1016
  }
836
1017
  return snapshots;
837
1018
  };
1019
+ const warmupJournalPaths = () => {
1020
+ const paths = [];
1021
+ for (const { slug, journal } of listJournals()) {
1022
+ const route = slug === PERSONAL_VAULT_JOURNAL_SLUG || slug === "personal"
1023
+ ? { kind: "personal" }
1024
+ : { kind: "company", slug };
1025
+ if (!isWatchRouteSelected(route, watchRouteSelection))
1026
+ continue;
1027
+ for (const key of Object.keys(journal.files)) {
1028
+ paths.push(route.kind === "personal" ? key : `companies/${route.slug}/${key}`);
1029
+ }
1030
+ }
1031
+ return paths;
1032
+ };
838
1033
  const prepareWatcherChanges = (changes) => {
839
1034
  const journals = new Map();
840
1035
  const dirty = new Set();
@@ -933,12 +1128,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
933
1128
  }
934
1129
  return false;
935
1130
  };
936
- const runScopedDrain = async (batch, watcherBatchGeneration) => {
1131
+ const runScopedDrain = async (batch, watcherBatchGeneration, wakeOnPushFailure = false) => {
937
1132
  const grouped = batch && batch.paths.size > 0 ? groupBatchByRoute(batch) : null;
938
1133
  const skipCompanies = grouped ? resolveSkipCompanies() : null;
939
1134
  const isPausedRoute = (route) => route.kind === "company" &&
940
1135
  (skipCompanies?.has(route.slug.toLowerCase()) ?? false);
941
- if (drainRunsPush && grouped) {
1136
+ if (watcherDrainRunsPush && grouped) {
942
1137
  for (const group of grouped.values()) {
943
1138
  // Paused workspaces: drop the scoped push without uploading or
944
1139
  // publishing event-sync metadata (path/hash must not leak while Off).
@@ -947,9 +1142,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
947
1142
  }
948
1143
  const scopedArgv = buildScopedPushArgv(group.route, group.relPaths, passArgv, group.deleteScopeRoots);
949
1144
  try {
1145
+ const lane = isFastLocalBatch(group.paths.keys(), group.changes)
1146
+ ? "fast"
1147
+ : "slow";
950
1148
  const result = await runGuarded(scopedArgv, group.changes.size > 0
951
1149
  ? () => prepareWatcherChanges(group.changes)
952
- : undefined);
1150
+ : undefined, lane);
953
1151
  if (passExitCode(result) === 0) {
954
1152
  const refusedPaths = new Set(typeof result === "number"
955
1153
  ? []
@@ -974,11 +1172,15 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
974
1172
  }
975
1173
  else {
976
1174
  restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
1175
+ if (wakeOnPushFailure)
1176
+ interruptWait();
977
1177
  }
978
1178
  }
979
1179
  catch (err) {
980
1180
  restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
981
1181
  process.stderr.write(`watch scoped push failed, will retry next tick: ${describeError(err)}\n`);
1182
+ if (wakeOnPushFailure)
1183
+ interruptWait();
982
1184
  }
983
1185
  }
984
1186
  }
@@ -1006,7 +1208,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1006
1208
  continue;
1007
1209
  ranPersonalDrain = true;
1008
1210
  }
1009
- const exit = passExitCode(await runGuarded(pullArgv));
1211
+ const exit = passExitCode(await runGuarded(pullArgv, undefined, isFastLocalBatch(group.paths.keys(), group.changes) ? "fast" : "slow"));
1010
1212
  if (exit !== 0 && worstExit === 0)
1011
1213
  worstExit = exit;
1012
1214
  }
@@ -1048,7 +1250,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1048
1250
  skipCompanies.has(route.slug.toLowerCase())) {
1049
1251
  continue;
1050
1252
  }
1051
- if (drainRunsPush) {
1253
+ if (watcherDrainRunsPush) {
1052
1254
  // Whole-route push (no --scope-path): the dropped paths are unknown,
1053
1255
  // so the route's full walk is the narrowest sound scope.
1054
1256
  const exit = passExitCode(await runGuarded(buildScopedPushArgv(route, [], passArgv)));
@@ -1140,6 +1342,31 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1140
1342
  watcher.onChange((changedRelPath, batch) => {
1141
1343
  if (stopped)
1142
1344
  return;
1345
+ // A complete, small watcher batch is dispatched immediately while a
1346
+ // bulk pass is in flight. Leaving it in the next poll iteration would
1347
+ // still make it wait for that pass even though the fast lane exists.
1348
+ const selectedBatch = batch ? selectWatchBatch(batch) : null;
1349
+ if (selectedBatch &&
1350
+ !selectedBatch.overflowed &&
1351
+ activeSlowPass !== null &&
1352
+ isFastLocalBatch(selectedBatch.paths.keys(), selectedBatch.changes)) {
1353
+ const generation = ++nextWatcherBatchGeneration;
1354
+ // Fast dispatch bypasses the pending-batch ingress, so preserve both
1355
+ // of its contracts here: only selected routes may run, and V2 sees
1356
+ // the same dirty signal as the compatibility drain.
1357
+ realtimeState.scheduler?.signal("watcher");
1358
+ void runScopedDrain(selectedBatch, generation, true).then((exit) => {
1359
+ if (exit === 0)
1360
+ return;
1361
+ restorePendingWatcherPaths(selectedBatch.paths, generation);
1362
+ interruptWait();
1363
+ }).catch((err) => {
1364
+ restorePendingWatcherPaths(selectedBatch.paths, generation);
1365
+ process.stderr.write(`watch fast-lane scoped push failed, will retry next tick: ${describeError(err)}\n`);
1366
+ interruptWait();
1367
+ });
1368
+ return;
1369
+ }
1143
1370
  if (addPendingWatcherChange(changedRelPath, batch)) {
1144
1371
  // The V1 watcher batch keeps its existing compatibility behavior. A
1145
1372
  // V2-enrolled scope observes the same dirty hint through its own
@@ -1172,6 +1399,30 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1172
1399
  // initial walk will finish.
1173
1400
  watcherRediscoveryRetries = 0;
1174
1401
  process.stderr.write(`hq-sync-runner: event-push watcher active (watched-paths=${watchedPaths ?? "unknown"})\n`);
1402
+ const startedAt = Date.now();
1403
+ try {
1404
+ const catchup = watcher.collectWarmupCatchup?.(serviceStartedAt - WATCHER_WARMUP_SAFETY_MARGIN_MS, warmupJournalPaths());
1405
+ if (!catchup)
1406
+ return;
1407
+ const queued = addPendingWatcherChange(undefined, catchup.batch);
1408
+ pendingWatcherWarmupCatchup ||= queued;
1409
+ process.stderr.write(JSON.stringify({
1410
+ type: "watcher-warmup-catchup",
1411
+ reason: "watch-set-ready",
1412
+ pathsFound: catchup.batch.paths.size,
1413
+ scannedPaths: catchup.scannedPaths,
1414
+ elapsedMs: Date.now() - startedAt,
1415
+ queued,
1416
+ }) + "\n");
1417
+ if (queued)
1418
+ interruptWait();
1419
+ }
1420
+ catch (err) {
1421
+ process.stderr.write(`hq-sync-runner: watcher warm-up catch-up failed: ${describeError(err)}\n`);
1422
+ // A scan failure must not silently extend the warm-up blind spot.
1423
+ pendingWatcherWarmupCatchupFailed = true;
1424
+ interruptWait();
1425
+ }
1175
1426
  };
1176
1427
  if (watcher.onReady)
1177
1428
  watcher.onReady(reportWatcherReady);
@@ -1209,20 +1460,27 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1209
1460
  // lock.
1210
1461
  interruptWait();
1211
1462
  const pathsByRoute = new Map();
1463
+ let eligibleEventCount = 0;
1212
1464
  for (const event of events) {
1213
1465
  const route = routeChangeToTarget(event.relativePath);
1214
1466
  if (!route)
1215
1467
  continue;
1468
+ eligibleEventCount += 1;
1216
1469
  const key = routeKey(route);
1217
1470
  const group = pathsByRoute.get(key) ?? { route, paths: [] };
1218
1471
  group.paths.push(event.relativePath);
1219
1472
  pathsByRoute.set(key, group);
1220
1473
  }
1474
+ // One receiver delivery can fan out to many routes. Apply the advertised
1475
+ // cap to its whole eligible payload, not independently to each route.
1476
+ const lane = isFastReceiverBatch(eligibleEventCount)
1477
+ ? "fast"
1478
+ : "slow";
1221
1479
  for (const { route, paths } of pathsByRoute.values()) {
1222
1480
  // One guarded targeted pass per affected route, with every coalesced
1223
1481
  // path for that route expressed through the watcher's existing argv seam.
1224
1482
  const targetedArgv = buildTargetedPullArgv(route, passArgv, paths);
1225
- const result = await runGuarded(targetedArgv);
1483
+ const result = await runGuarded(targetedArgv, undefined, lane);
1226
1484
  if (passExitCode(result) !== 0) {
1227
1485
  throw new Error(`targeted pull failed with exit code ${passExitCode(result)}`);
1228
1486
  }
@@ -1240,10 +1498,23 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1240
1498
  void deps.startRealtimeScheduler({
1241
1499
  hqRoot,
1242
1500
  runGuarded: async (drain) => {
1243
- const outcome = await runGuardedTask(() => runTaskWithLock(async () => {
1244
- await drain();
1245
- return 0;
1246
- }));
1501
+ let outcome;
1502
+ try {
1503
+ outcome = await runGuardedTask(() => runTaskWithLock(async () => {
1504
+ await drain();
1505
+ return 0;
1506
+ }));
1507
+ }
1508
+ catch (err) {
1509
+ // Scheduler clients may have already been disposed when a queued
1510
+ // drain finally reaches the lock. Surface the late failure, but do
1511
+ // not leak an unhandled rejection after shutdown has completed.
1512
+ if (stopped) {
1513
+ process.stderr.write("realtime scheduler: cancelled drain failed after shutdown\n");
1514
+ return;
1515
+ }
1516
+ throw err;
1517
+ }
1247
1518
  if (passExitCode(outcome) !== 0) {
1248
1519
  throw new Error(`realtime scheduler drain exited ${passExitCode(outcome)}`);
1249
1520
  }
@@ -1385,6 +1656,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1385
1656
  const eventPushSurfacesInactive = eventPush && !eventPushSurfacesActivated;
1386
1657
  const firstTick = pollTick === 1;
1387
1658
  const bareWake = pending.bare;
1659
+ const watcherWarmupCatchup = pending.warmupCatchup;
1660
+ const watcherWarmupCatchupFailed = pending.warmupCatchupFailed;
1388
1661
  const overflowRoutesUnknown = pending.batch?.overflowed === true && !overflowWithKnownRoutes;
1389
1662
  const scheduledInterval = fullReconcileIntervalMs === undefined
1390
1663
  ? pollTick % FULL_RECONCILE_EVERY_TICKS === 0
@@ -1396,6 +1669,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1396
1669
  "bare-wake": bareWake,
1397
1670
  "batch-over-limit": batchOverLimit,
1398
1671
  "overflow-routes-unknown": overflowRoutesUnknown,
1672
+ "watcher-warmup-catch-up": watcherWarmupCatchup,
1673
+ "watcher-warmup-catch-up-failed": watcherWarmupCatchupFailed,
1399
1674
  "scheduled-interval": scheduledInterval,
1400
1675
  };
1401
1676
  const reasons = Object.entries(triggers)
@@ -1406,6 +1681,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1406
1681
  bareWake ||
1407
1682
  batchOverLimit ||
1408
1683
  overflowRoutesUnknown ||
1684
+ watcherWarmupCatchupFailed ||
1409
1685
  scheduledInterval;
1410
1686
  const emitDecision = (mode, decisionReasons) => {
1411
1687
  emitFullReconcileDecision({
@@ -1441,7 +1717,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1441
1717
  // were dropped); ordinary batches remain path-scoped. Label those
1442
1718
  // distinct scopes so operators can distinguish them from a full
1443
1719
  // all-vault reconcile.
1444
- emitDecision(overflowWithKnownRoutes ? "route" : "scoped", reasons);
1720
+ emitDecision(watcherWarmupCatchup
1721
+ ? "catch-up"
1722
+ : overflowWithKnownRoutes
1723
+ ? "route"
1724
+ : "scoped", reasons);
1445
1725
  }
1446
1726
  catch (err) {
1447
1727
  // Never silently drop a change: any failure inside the targeted