@indigoai-us/hq-cloud 6.15.80 → 6.15.81

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.
Files changed (49) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +15 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-company.test.js +65 -0
  5. package/dist/bin/sync-runner-company.test.js.map +1 -1
  6. package/dist/bin/sync-runner-events.test.js +20 -0
  7. package/dist/bin/sync-runner-events.test.js.map +1 -1
  8. package/dist/bin/sync-runner-watch-loop.d.ts +7 -0
  9. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.js +308 -20
  11. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  12. package/dist/bin/sync-runner.d.ts +5 -0
  13. package/dist/bin/sync-runner.d.ts.map +1 -1
  14. package/dist/bin/sync-runner.js.map +1 -1
  15. package/dist/bin/sync-runner.test.js +144 -1
  16. package/dist/bin/sync-runner.test.js.map +1 -1
  17. package/dist/cli/share.d.ts.map +1 -1
  18. package/dist/cli/share.js +29 -5
  19. package/dist/cli/share.js.map +1 -1
  20. package/dist/cli/share.test.js +131 -24
  21. package/dist/cli/share.test.js.map +1 -1
  22. package/dist/cli/sync.d.ts +23 -0
  23. package/dist/cli/sync.d.ts.map +1 -1
  24. package/dist/cli/sync.js +72 -7
  25. package/dist/cli/sync.js.map +1 -1
  26. package/dist/cli/sync.test.js +199 -0
  27. package/dist/cli/sync.test.js.map +1 -1
  28. package/dist/cli/watch-event-push-conflict.test.js +22 -11
  29. package/dist/cli/watch-event-push-conflict.test.js.map +1 -1
  30. package/dist/s3.d.ts +62 -0
  31. package/dist/s3.d.ts.map +1 -1
  32. package/dist/s3.js +91 -1
  33. package/dist/s3.js.map +1 -1
  34. package/dist/s3.rename-retry.test.d.ts +2 -0
  35. package/dist/s3.rename-retry.test.d.ts.map +1 -0
  36. package/dist/s3.rename-retry.test.js +153 -0
  37. package/dist/s3.rename-retry.test.js.map +1 -0
  38. package/dist/sync/feature-flags.d.ts +9 -0
  39. package/dist/sync/feature-flags.d.ts.map +1 -1
  40. package/dist/sync/feature-flags.js +19 -0
  41. package/dist/sync/feature-flags.js.map +1 -1
  42. package/dist/sync/feature-flags.test.js +11 -1
  43. package/dist/sync/feature-flags.test.js.map +1 -1
  44. package/dist/sync/parallel-sync-coordinator.d.ts.map +1 -1
  45. package/dist/sync/parallel-sync-coordinator.js +0 -3
  46. package/dist/sync/parallel-sync-coordinator.js.map +1 -1
  47. package/dist/sync/parallel-sync-coordinator.test.js +14 -0
  48. package/dist/sync/parallel-sync-coordinator.test.js.map +1 -1
  49. package/package.json +1 -1
@@ -15,6 +15,9 @@ import { TreeWatcher, resolveEventDebounceConfig, systemClock, } from "../watche
15
15
  import { clearLocalDeleteIntent, getStateDir, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, openJournalStoreSession, openJournalStoreSessionAsync, 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
+ import { isParallelCoordinatorEnabled } from "../sync/feature-flags.js";
19
+ import { DefaultParallelSyncCoordinator } from "../sync/parallel-sync-coordinator.js";
20
+ import { InlineSyncWorkerAdapter, } from "../sync/sync-work.js";
18
21
  import { buildRoutePullArgv, buildScopedPushArgv, buildScopedDrainPullArgv, buildTargetedPullArgv, isWatchRouteSelected, routeChangeToTarget, routeKey, } from "./sync-runner-watch-routes.js";
19
22
  import { resolveSkipCompanies } from "./sync-runner-planning.js";
20
23
  const FULL_RECONCILE_EVERY_TICKS = 10;
@@ -62,6 +65,16 @@ export const DEFAULT_FAST_LANE_MAX_BYTES = 4 * 1024 * 1024;
62
65
  * leaving session logs and other transfer-heavy files in the slow lane.
63
66
  */
64
67
  export const DEFAULT_FAST_LANE_MAX_FILE_BYTES = 256 * 1024;
68
+ /**
69
+ * A coordinator submission must not contain both a directory and one of its
70
+ * descendants: they are one mutation scope, and the lease manager correctly
71
+ * rejects internally-conflicting submissions. Prefer the widest scope so an
72
+ * unlinkDir following child unlink events remains admissible.
73
+ */
74
+ export function normalizeCoordinatorPaths(paths) {
75
+ const ordered = [...new Set(paths)].sort((left, right) => left.length - right.length || left.localeCompare(right));
76
+ return ordered.filter((candidate, index) => !ordered.slice(0, index).some((ancestor) => candidate.startsWith(`${ancestor}/`)));
77
+ }
65
78
  function positiveEnvInt(name, fallback) {
66
79
  const value = Number.parseInt(process.env[name] ?? "", 10);
67
80
  return Number.isFinite(value) && value > 0 ? value : fallback;
@@ -443,6 +456,10 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
443
456
  // Fast work may bypass the lock only while this process *actually owns* it;
444
457
  // otherwise it must participate in normal lock acquisition.
445
458
  let processOwnsOperationLock = false;
459
+ // Compatibility chores still mutate whole routes/vaults. While one is
460
+ // executing, a coordinator realtime task must not take the normal fast-path
461
+ // lock carve-out: it has no bounded chore lease to prove independence.
462
+ let coordinatorWholeMutationChores = 0;
446
463
  let operationLockUsers = 0;
447
464
  let resolveNoOperationLockUsers = null;
448
465
  const acquireOperationLockUser = () => {
@@ -469,7 +486,9 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
469
486
  // its keyed journal delta and per-object mutation are independent from the
470
487
  // slow pass's bulk paths, and the slow pass already owns the root lock.
471
488
  // Never bypass the lock when no slow pass is active.
472
- if (lane === "fast" && processOwnsOperationLock) {
489
+ if (lane === "fast" &&
490
+ processOwnsOperationLock &&
491
+ coordinatorWholeMutationChores === 0) {
473
492
  acquireOperationLockUser();
474
493
  try {
475
494
  prepare?.();
@@ -731,6 +750,109 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
731
750
  });
732
751
  };
733
752
  const runGuarded = (passArgvForRun, prepare, lane = "slow") => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare, lane), lane);
753
+ /*
754
+ * The bridge is intentionally opt-in. The legacy guarded pass arrays stay
755
+ * exactly as they are until an outpost enables HQ_SYNC_PARALLEL_COORDINATOR.
756
+ * Once enabled, the coordinator owns admission and path fencing; the small
757
+ * inline adapters are only the compatibility execution seam while the
758
+ * runner's real slice executors are being moved into child workers.
759
+ */
760
+ const parallelCoordinatorEnabled = isParallelCoordinatorEnabled(process.env.HQ_SYNC_PARALLEL_COORDINATOR);
761
+ const coordinatorExecutions = new WeakMap();
762
+ const coordinatorWorker = async (slice) => {
763
+ const execution = coordinatorExecutions.get(slice.work);
764
+ if (!execution) {
765
+ return {
766
+ paths: slice.paths.map((item) => ({
767
+ path: item.canonicalPath,
768
+ epoch: item.epoch,
769
+ status: "retryable",
770
+ reason: "coordinator execution was not registered",
771
+ })),
772
+ };
773
+ }
774
+ if (slice.work.class === "chore")
775
+ coordinatorWholeMutationChores += 1;
776
+ try {
777
+ execution.result = await execution.run(slice);
778
+ }
779
+ finally {
780
+ if (slice.work.class === "chore")
781
+ coordinatorWholeMutationChores -= 1;
782
+ }
783
+ const exitCode = passExitCode(execution.result);
784
+ return {
785
+ paths: slice.paths.map((item) => ({
786
+ path: item.canonicalPath,
787
+ epoch: item.epoch,
788
+ status: exitCode === 0 ? "durable" : "retryable",
789
+ ...(exitCode === 0
790
+ ? {}
791
+ : { reason: `sync pass exited ${exitCode}` }),
792
+ })),
793
+ };
794
+ };
795
+ const coordinator = parallelCoordinatorEnabled
796
+ ? new DefaultParallelSyncCoordinator({
797
+ realtimeWorker: new InlineSyncWorkerAdapter(coordinatorWorker),
798
+ choreWorker: new InlineSyncWorkerAdapter(coordinatorWorker),
799
+ identifyPath: (canonicalPath) => {
800
+ const route = routeChangeToTarget(canonicalPath);
801
+ if (!route)
802
+ throw new Error(`coordinator cannot route ${canonicalPath}`);
803
+ return route.kind === "company"
804
+ ? { targetUid: `company:${route.slug}`, areaId: `company:${route.slug}` }
805
+ : { targetUid: "personal", areaId: "personal" };
806
+ },
807
+ // Compatibility chores still use whole-vault/whole-route executors,
808
+ // which cannot yet name sound bounded mutation paths. Their execution
809
+ // therefore remains behind the runner's guarded operation queue until
810
+ // those executors can provide concrete scopes.
811
+ planChorePaths: () => [],
812
+ })
813
+ : null;
814
+ const runCoordinatorWork = async (work, task) => {
815
+ if (!coordinator) {
816
+ return {
817
+ result: await task({
818
+ id: "legacy-direct",
819
+ work,
820
+ paths: [],
821
+ signal: new AbortController().signal,
822
+ }),
823
+ accepted: true,
824
+ };
825
+ }
826
+ const execution = { run: task };
827
+ coordinatorExecutions.set(work, execution);
828
+ try {
829
+ const outcome = await coordinator.submit(work);
830
+ const accepted = outcome.paths.every((path) => path.status === "durable");
831
+ return {
832
+ result: execution.result ?? 1,
833
+ accepted,
834
+ };
835
+ }
836
+ finally {
837
+ coordinatorExecutions.delete(work);
838
+ }
839
+ };
840
+ let nextCoordinatorCausality = 0;
841
+ const realtimeWork = (source, direction, paths) => ({
842
+ class: "realtime",
843
+ source,
844
+ direction,
845
+ paths: normalizeCoordinatorPaths(paths),
846
+ causality: `${source}-${++nextCoordinatorCausality}`,
847
+ });
848
+ const choreWork = (kind) => ({ class: "chore", kind, areas: [] });
849
+ const runRealtimePass = (source, direction, paths, task) => runCoordinatorWork(realtimeWork(source, direction, paths), task);
850
+ const enqueueChore = (kind, task, onSettled) => {
851
+ void runCoordinatorWork(choreWork(kind), task).then(onSettled).catch((error) => {
852
+ process.stderr.write(`hq-sync-runner: coordinator ${kind} chore failed: ${describeError(error)}\n`);
853
+ onSettled({ result: 1, accepted: false });
854
+ });
855
+ };
734
856
  let watcher = null;
735
857
  let eventPushSurfacesActivated = false;
736
858
  let watcherUnavailableWarned = false;
@@ -1417,6 +1539,10 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1417
1539
  const skipCompanies = grouped ? resolveSkipCompanies() : null;
1418
1540
  const isPausedRoute = (route) => route.kind === "company" &&
1419
1541
  (skipCompanies?.has(route.slug.toLowerCase()) ?? false);
1542
+ const changesForAdmittedPaths = (group, admittedPaths) => new Map([...group.changes.entries()].filter(([absolutePath]) => {
1543
+ const relativePath = group.paths.get(absolutePath);
1544
+ return relativePath !== undefined && admittedPaths.some((scope) => relativePath === scope || relativePath.startsWith(`${scope}/`));
1545
+ }));
1420
1546
  if (watcherDrainRunsPush && grouped) {
1421
1547
  for (const group of grouped.values()) {
1422
1548
  // Paused workspaces: drop the scoped push without uploading or
@@ -1424,15 +1550,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1424
1550
  if (isPausedRoute(group.route)) {
1425
1551
  continue;
1426
1552
  }
1427
- const scopedArgv = buildScopedPushArgv(group.route, group.relPaths, passArgv, group.deleteScopeRoots);
1428
1553
  try {
1429
1554
  const lane = isFastLocalBatch(group.paths.keys(), group.changes)
1430
1555
  ? "fast"
1431
1556
  : "slow";
1432
- const result = await runGuarded(scopedArgv, group.changes.size > 0
1433
- ? () => prepareWatcherChanges(group.changes)
1434
- : undefined, lane);
1435
- if (passExitCode(result) === 0) {
1557
+ const execution = parallelCoordinatorEnabled
1558
+ ? await runRealtimePass("watcher", "push", group.relPaths, async (slice) => {
1559
+ const admittedPaths = slice.paths.map((path) => path.canonicalPath);
1560
+ const admittedChanges = changesForAdmittedPaths(group, admittedPaths);
1561
+ const scopedArgv = buildScopedPushArgv(group.route, admittedPaths, passArgv, group.deleteScopeRoots);
1562
+ return runGuarded(scopedArgv, admittedChanges.size > 0
1563
+ ? () => prepareWatcherChanges(admittedChanges)
1564
+ : undefined, lane);
1565
+ })
1566
+ : { result: await runGuarded(buildScopedPushArgv(group.route, group.relPaths, passArgv, group.deleteScopeRoots), group.changes.size > 0
1567
+ ? () => prepareWatcherChanges(group.changes)
1568
+ : undefined, lane), accepted: true };
1569
+ const result = execution.result;
1570
+ if (execution.accepted && passExitCode(result) === 0) {
1436
1571
  const refusedPaths = new Set(typeof result === "number"
1437
1572
  ? []
1438
1573
  : (result.result?.pushPathResults ?? [])
@@ -1492,7 +1627,10 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1492
1627
  continue;
1493
1628
  ranPersonalDrain = true;
1494
1629
  }
1495
- const exit = passExitCode(await runGuarded(pullArgv, undefined, isFastLocalBatch(group.paths.keys(), group.changes) ? "fast" : "slow"));
1630
+ const execution = parallelCoordinatorEnabled
1631
+ ? await runRealtimePass("watcher", "pull", group.relPaths, (slice) => runGuarded(buildTargetedPullArgv(group.route, passArgv, slice.paths.map((path) => path.canonicalPath)), undefined, isFastLocalBatch(group.paths.keys(), group.changes) ? "fast" : "slow"))
1632
+ : { result: await runGuarded(pullArgv, undefined, isFastLocalBatch(group.paths.keys(), group.changes) ? "fast" : "slow"), accepted: true };
1633
+ const exit = execution.accepted ? passExitCode(execution.result) : 1;
1496
1634
  if (exit !== 0 && worstExit === 0)
1497
1635
  worstExit = exit;
1498
1636
  }
@@ -1527,6 +1665,30 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1527
1665
  for (const hint of batch.droppedRouteHints ?? [])
1528
1666
  addRoute(hint);
1529
1667
  const skipCompanies = resolveSkipCompanies();
1668
+ if (parallelCoordinatorEnabled) {
1669
+ for (const route of routes.values()) {
1670
+ if (route.kind === "company" &&
1671
+ skipCompanies.has(route.slug.toLowerCase())) {
1672
+ continue;
1673
+ }
1674
+ enqueueChore("backlog", async () => {
1675
+ if (watcherDrainRunsPush) {
1676
+ const push = await runGuarded(buildScopedPushArgv(route, [], passArgv));
1677
+ if (passExitCode(push) !== 0)
1678
+ return push;
1679
+ }
1680
+ return drainRunsPull
1681
+ ? runGuarded(buildRoutePullArgv(route, passArgv))
1682
+ : 0;
1683
+ }, (execution) => {
1684
+ if (!execution.accepted || passExitCode(execution.result) !== 0) {
1685
+ process.stderr.write(`hq-sync-runner: backlog chore for ${routeKey(route)} failed; will retry on cadence\n`);
1686
+ interruptWait();
1687
+ }
1688
+ });
1689
+ }
1690
+ return 0;
1691
+ }
1530
1692
  let worstExit = 0;
1531
1693
  for (const route of routes.values()) {
1532
1694
  // Paused workspaces sync in NO direction while Off.
@@ -1647,6 +1809,29 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1647
1809
  const lanes = selectedBatch && !selectedBatch.overflowed
1648
1810
  ? partitionLocalBatch(selectedBatch)
1649
1811
  : null;
1812
+ if (parallelCoordinatorEnabled && lanes) {
1813
+ // Preserve the ingress partition/debounce, but both partitions now
1814
+ // submit semantic realtime work. In particular a fresh large file
1815
+ // never waits for a whole-backlog chore merely because it exceeded
1816
+ // the old fast byte cap.
1817
+ for (const lane of [lanes.fast, lanes.slow]) {
1818
+ if (!lane)
1819
+ continue;
1820
+ const generation = ++nextWatcherBatchGeneration;
1821
+ void runScopedDrain(lane, generation, true).then((exit) => {
1822
+ if (exit === 0)
1823
+ return;
1824
+ restorePendingWatcherPaths(lane.paths, generation);
1825
+ interruptWait();
1826
+ }).catch((err) => {
1827
+ restorePendingWatcherPaths(lane.paths, generation);
1828
+ process.stderr.write(`watch coordinator scoped push failed, will retry next tick: ${describeError(err)}\n`);
1829
+ interruptWait();
1830
+ });
1831
+ }
1832
+ realtimeState.scheduler?.signal("watcher");
1833
+ return;
1834
+ }
1650
1835
  if (lanes?.fast &&
1651
1836
  activeSlowPass !== null) {
1652
1837
  const generation = ++nextWatcherBatchGeneration;
@@ -1768,6 +1953,28 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1768
1953
  // Apply the caps once across the whole delivery, then retain the residue
1769
1954
  // in the normal lane. The receiver already coalesces to one latest event
1770
1955
  // per path, so the partition cannot split revisions of one path.
1956
+ if (parallelCoordinatorEnabled) {
1957
+ // #423's coalescing/caps remain ingress protection only. A newly
1958
+ // delivered single large path is realtime work, not a slow-pass item;
1959
+ // byte size is handled by the transfer budget below the coordinator.
1960
+ const pathsByRoute = new Map();
1961
+ for (const event of events) {
1962
+ const route = routeChangeToTarget(event.relativePath);
1963
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection))
1964
+ continue;
1965
+ const key = routeKey(route);
1966
+ const group = pathsByRoute.get(key) ?? { route, paths: [] };
1967
+ group.paths.push(event.relativePath);
1968
+ pathsByRoute.set(key, group);
1969
+ }
1970
+ for (const { route, paths } of pathsByRoute.values()) {
1971
+ const execution = await runRealtimePass("receiver", "pull", paths, (slice) => runGuarded(buildTargetedPullArgv(route, passArgv, slice.paths.map((path) => path.canonicalPath)), undefined, "slow"));
1972
+ if (!execution.accepted || passExitCode(execution.result) !== 0) {
1973
+ throw new Error(`targeted pull failed with exit code ${passExitCode(execution.result)}`);
1974
+ }
1975
+ }
1976
+ return;
1977
+ }
1771
1978
  const lanes = partitionByKnownSizes(events, receiverEventSize);
1772
1979
  const drainLane = async (lane, laneEvents) => {
1773
1980
  const pathsByRoute = new Map();
@@ -1800,6 +2007,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1800
2007
  await drainLane("slow", lanes.slow);
1801
2008
  };
1802
2009
  receiverSyncBatchFn.partition = (events) => {
2010
+ if (parallelCoordinatorEnabled)
2011
+ return events.length > 0 ? [events] : [];
1803
2012
  const lanes = partitionByKnownSizes(events, receiverEventSize);
1804
2013
  return [lanes.fast, lanes.slow].filter((lane) => lane.length > 0);
1805
2014
  };
@@ -1821,10 +2030,19 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1821
2030
  runGuarded: async (drain) => {
1822
2031
  let outcome;
1823
2032
  try {
1824
- outcome = await runGuardedTask(() => runTaskWithLock(async () => {
1825
- await drain();
1826
- return 0;
1827
- }));
2033
+ if (parallelCoordinatorEnabled) {
2034
+ const execution = await runRealtimePass("wake", "pull", [], async () => runGuardedTask(() => runTaskWithLock(async () => {
2035
+ await drain();
2036
+ return 0;
2037
+ })));
2038
+ outcome = execution.accepted ? execution.result : 1;
2039
+ }
2040
+ else {
2041
+ outcome = await runGuardedTask(() => runTaskWithLock(async () => {
2042
+ await drain();
2043
+ return 0;
2044
+ }));
2045
+ }
1828
2046
  }
1829
2047
  catch (err) {
1830
2048
  // Scheduler clients may have already been disposed when a queued
@@ -1899,11 +2117,16 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1899
2117
  });
1900
2118
  }
1901
2119
  }
2120
+ let coordinatorShutdown = null;
1902
2121
  const shutdown = () => {
1903
2122
  if (stopped)
1904
- return;
2123
+ return coordinatorShutdown ?? Promise.resolve();
1905
2124
  stopped = true;
1906
2125
  resolveStoppedQueue();
2126
+ // Reject queued coordinator work before tearing down ingress. Active
2127
+ // work receives its abort signal; receiver receipts remain unacknowledged
2128
+ // unless their durable pull has already completed.
2129
+ coordinatorShutdown = coordinator?.shutdown() ?? Promise.resolve();
1907
2130
  try {
1908
2131
  void receiver?.dispose();
1909
2132
  }
@@ -1931,10 +2154,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1931
2154
  /* ignore */
1932
2155
  }
1933
2156
  resolveStopped?.();
2157
+ return coordinatorShutdown;
1934
2158
  };
1935
2159
  const onShutdownSignal = deps.onShutdownSignal ??
1936
2160
  ((handler) => {
1937
- const wrapped = () => handler();
2161
+ const wrapped = () => { void handler(); };
1938
2162
  process.on("SIGTERM", wrapped);
1939
2163
  process.on("SIGINT", wrapped);
1940
2164
  return () => {
@@ -1948,8 +2172,13 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1948
2172
  let nextDueAt = monotonicNow();
1949
2173
  let pollTick = 0;
1950
2174
  let lastCompletedFullReconcileAt = null;
2175
+ let fullChorePending = false;
2176
+ let fullChoreFollowupPending = false;
2177
+ let coordinatorAuthRequired = false;
1951
2178
  try {
1952
2179
  while (!stopped) {
2180
+ if (coordinatorAuthRequired)
2181
+ return 0;
1953
2182
  pollTick++;
1954
2183
  const pending = takePendingWatcherChange();
1955
2184
  // The quarantine is scoped to the delete episode it refused, and only
@@ -2020,10 +2249,58 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2020
2249
  };
2021
2250
  let result;
2022
2251
  let ranFullReconcile = false;
2252
+ let enqueuedFullChore = false;
2023
2253
  if (shouldRunFull) {
2024
- ranFullReconcile = true;
2025
2254
  emitDecision("full", reasons);
2026
- result = await runGuarded(passArgv);
2255
+ if (parallelCoordinatorEnabled) {
2256
+ // Cadence/full/backlog work is admitted to the reserved chore worker
2257
+ // and deliberately never awaited by the watch loop. The completion
2258
+ // callback, rather than submission, advances the full-reconcile
2259
+ // clock and turns on event surfaces.
2260
+ if (!fullChorePending) {
2261
+ fullChorePending = true;
2262
+ enqueueChore("full-reconcile", () => runGuarded(passArgv), (execution) => {
2263
+ fullChorePending = false;
2264
+ const exit = passExitCode(execution.result);
2265
+ if (exit === runtime.authRequiredPassExit) {
2266
+ coordinatorAuthRequired = true;
2267
+ interruptWait();
2268
+ return;
2269
+ }
2270
+ if (execution.accepted && exit === 0) {
2271
+ lastCompletedFullReconcileAt = monotonicNow();
2272
+ activateEventPushSurfaces();
2273
+ if (fullChoreFollowupPending) {
2274
+ fullChoreFollowupPending = false;
2275
+ interruptWait();
2276
+ }
2277
+ return;
2278
+ }
2279
+ process.stderr.write(`hq-sync-runner: full reconcile chore failed (exit=${exit}); will retry on cadence\n`);
2280
+ if (fullChoreFollowupPending) {
2281
+ fullChoreFollowupPending = false;
2282
+ interruptWait();
2283
+ }
2284
+ });
2285
+ }
2286
+ else {
2287
+ // A full pass can have already scanned this route. Preserve any
2288
+ // incoming evidence and make the completion schedule one more
2289
+ // reconcile rather than dropping the taken batch.
2290
+ if (pending.batch) {
2291
+ restorePendingWatcherPaths(pending.batch.paths, pending.generation);
2292
+ }
2293
+ if (pending.bare)
2294
+ pendingWatcherBareChange = true;
2295
+ fullChoreFollowupPending ||= pending.batch !== null || pending.bare;
2296
+ }
2297
+ enqueuedFullChore = true;
2298
+ result = 0;
2299
+ }
2300
+ else {
2301
+ ranFullReconcile = true;
2302
+ result = await runGuarded(passArgv);
2303
+ }
2027
2304
  }
2028
2305
  else if (pending.batch === null) {
2029
2306
  // There is no watcher batch and no full-reconcile trigger. Do not
@@ -2104,9 +2381,9 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2104
2381
  else if (exitCode !== 0) {
2105
2382
  return exitCode;
2106
2383
  }
2107
- const completedPostRepairPass = typeof result === "number"
2384
+ const completedPostRepairPass = !enqueuedFullChore && (typeof result === "number"
2108
2385
  ? exitCode === 0
2109
- : result.result !== undefined;
2386
+ : result.result !== undefined);
2110
2387
  if (completedPostRepairPass) {
2111
2388
  // The production adapter returns RunnerPassResult only after fanout,
2112
2389
  // which follows journal repair. Its numeric seam has the same
@@ -2118,9 +2395,20 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2118
2395
  if (Date.now() >= nextBackupPruneAt) {
2119
2396
  // Advance before invoking: a failed/unavailable backstop cannot become
2120
2397
  // an every-tick retry loop. It is diagnostic only and never restarts
2121
- // the sync daemon. Keep it in the same deferred lock queue as every
2122
- // other watch task so an already-waiting rescue owns this handoff too.
2398
+ // the sync daemon. Under coordinator mode it is a reserved-worker
2399
+ // chore, never a dependency of realtime admission.
2123
2400
  nextBackupPruneAt = Date.now() + 24 * 60 * 60 * 1000;
2401
+ if (parallelCoordinatorEnabled) {
2402
+ enqueueChore("rotation", async () => runGuardedTask(() => runTaskWithLock(async () => {
2403
+ await runBackupPrune(hqRoot);
2404
+ return 0;
2405
+ })), (execution) => {
2406
+ if (!execution.accepted || passExitCode(execution.result) !== 0) {
2407
+ process.stderr.write("hq-sync-runner: backup retention chore failed; will retry tomorrow\n");
2408
+ }
2409
+ });
2410
+ continue;
2411
+ }
2124
2412
  try {
2125
2413
  await runGuardedTask(() => runTaskWithLock(async () => {
2126
2414
  await runBackupPrune(hqRoot);
@@ -2160,7 +2448,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2160
2448
  return 0;
2161
2449
  }
2162
2450
  finally {
2163
- shutdown();
2451
+ await shutdown();
2164
2452
  detachSignal?.();
2165
2453
  }
2166
2454
  }