@indigoai-us/hq-cloud 6.16.64 → 6.16.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.
Files changed (142) hide show
  1. package/dist/bin/sync-runner-company.d.ts +14 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +42 -8
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-company.test.js +62 -7
  6. package/dist/bin/sync-runner-company.test.js.map +1 -1
  7. package/dist/bin/sync-runner-planning.d.ts +12 -3
  8. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  9. package/dist/bin/sync-runner-planning.js +65 -3
  10. package/dist/bin/sync-runner-planning.js.map +1 -1
  11. package/dist/bin/sync-runner-planning.test.js +172 -1
  12. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  13. package/dist/bin/sync-runner-watch-loop.d.ts +52 -3
  14. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  15. package/dist/bin/sync-runner-watch-loop.js +443 -167
  16. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  17. package/dist/bin/sync-runner-watch-routes.d.ts +19 -2
  18. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  19. package/dist/bin/sync-runner-watch-routes.js +40 -5
  20. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  21. package/dist/bin/sync-runner-watch-routes.test.js +47 -0
  22. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
  23. package/dist/bin/sync-runner.d.ts +64 -6
  24. package/dist/bin/sync-runner.d.ts.map +1 -1
  25. package/dist/bin/sync-runner.js +48 -4
  26. package/dist/bin/sync-runner.js.map +1 -1
  27. package/dist/bin/sync-runner.test.js +1577 -169
  28. package/dist/bin/sync-runner.test.js.map +1 -1
  29. package/dist/cli/onepass-pull-planner-regressions.test.d.ts +2 -0
  30. package/dist/cli/onepass-pull-planner-regressions.test.d.ts.map +1 -0
  31. package/dist/cli/onepass-pull-planner-regressions.test.js +300 -0
  32. package/dist/cli/onepass-pull-planner-regressions.test.js.map +1 -0
  33. package/dist/cli/onepass-pull-planner.test.d.ts +2 -0
  34. package/dist/cli/onepass-pull-planner.test.d.ts.map +1 -0
  35. package/dist/cli/onepass-pull-planner.test.js +266 -0
  36. package/dist/cli/onepass-pull-planner.test.js.map +1 -0
  37. package/dist/cli/push-scan-exclusions.test.js +4 -4
  38. package/dist/cli/push-scan-exclusions.test.js.map +1 -1
  39. package/dist/cli/share.d.ts +31 -1
  40. package/dist/cli/share.d.ts.map +1 -1
  41. package/dist/cli/share.js +136 -6
  42. package/dist/cli/share.js.map +1 -1
  43. package/dist/cli/share.test.js +295 -5
  44. package/dist/cli/share.test.js.map +1 -1
  45. package/dist/cli/streaming-pull-planner.bench.test.d.ts +15 -0
  46. package/dist/cli/streaming-pull-planner.bench.test.d.ts.map +1 -0
  47. package/dist/cli/streaming-pull-planner.bench.test.js +273 -0
  48. package/dist/cli/streaming-pull-planner.bench.test.js.map +1 -0
  49. package/dist/cli/sync-scope.test.js +8 -0
  50. package/dist/cli/sync-scope.test.js.map +1 -1
  51. package/dist/cli/sync.d.ts +12 -0
  52. package/dist/cli/sync.d.ts.map +1 -1
  53. package/dist/cli/sync.js +302 -123
  54. package/dist/cli/sync.js.map +1 -1
  55. package/dist/cli/sync.test.js +255 -2
  56. package/dist/cli/sync.test.js.map +1 -1
  57. package/dist/hq-root-recovery.d.ts +30 -0
  58. package/dist/hq-root-recovery.d.ts.map +1 -0
  59. package/dist/hq-root-recovery.js +131 -0
  60. package/dist/hq-root-recovery.js.map +1 -0
  61. package/dist/hq-root-recovery.test.d.ts +2 -0
  62. package/dist/hq-root-recovery.test.d.ts.map +1 -0
  63. package/dist/hq-root-recovery.test.js +65 -0
  64. package/dist/hq-root-recovery.test.js.map +1 -0
  65. package/dist/journal-row-store.d.ts +40 -5
  66. package/dist/journal-row-store.d.ts.map +1 -1
  67. package/dist/journal-row-store.js +240 -21
  68. package/dist/journal-row-store.js.map +1 -1
  69. package/dist/journal-row-store.test.js +54 -0
  70. package/dist/journal-row-store.test.js.map +1 -1
  71. package/dist/journal-snapshot-v4.d.ts.map +1 -1
  72. package/dist/journal-snapshot-v4.js +11 -6
  73. package/dist/journal-snapshot-v4.js.map +1 -1
  74. package/dist/journal-store.test.js +324 -7
  75. package/dist/journal-store.test.js.map +1 -1
  76. package/dist/journal.d.ts +51 -16
  77. package/dist/journal.d.ts.map +1 -1
  78. package/dist/journal.js +303 -79
  79. package/dist/journal.js.map +1 -1
  80. package/dist/lib/exit-codes.d.ts +15 -0
  81. package/dist/lib/exit-codes.d.ts.map +1 -1
  82. package/dist/lib/exit-codes.js +15 -0
  83. package/dist/lib/exit-codes.js.map +1 -1
  84. package/dist/personal-vault.d.ts.map +1 -1
  85. package/dist/personal-vault.js +10 -3
  86. package/dist/personal-vault.js.map +1 -1
  87. package/dist/personal-vault.test.js +35 -0
  88. package/dist/personal-vault.test.js.map +1 -1
  89. package/dist/remote-pull.d.ts.map +1 -1
  90. package/dist/remote-pull.js +9 -2
  91. package/dist/remote-pull.js.map +1 -1
  92. package/dist/remote-pull.test.js +59 -2
  93. package/dist/remote-pull.test.js.map +1 -1
  94. package/dist/s3.d.ts +1 -0
  95. package/dist/s3.d.ts.map +1 -1
  96. package/dist/s3.js +20 -2
  97. package/dist/s3.js.map +1 -1
  98. package/dist/s3.list-cost.test.d.ts +2 -0
  99. package/dist/s3.list-cost.test.d.ts.map +1 -0
  100. package/dist/s3.list-cost.test.js +51 -0
  101. package/dist/s3.list-cost.test.js.map +1 -0
  102. package/dist/sync/area-ledger.d.ts +27 -3
  103. package/dist/sync/area-ledger.d.ts.map +1 -1
  104. package/dist/sync/area-ledger.js +149 -39
  105. package/dist/sync/area-ledger.js.map +1 -1
  106. package/dist/sync/area-ledger.test.js +192 -5
  107. package/dist/sync/area-ledger.test.js.map +1 -1
  108. package/dist/sync/parallel-sync-coordinator.test.js +29 -0
  109. package/dist/sync/parallel-sync-coordinator.test.js.map +1 -1
  110. package/dist/sync/realtime-telemetry.d.ts +1 -1
  111. package/dist/sync/realtime-telemetry.d.ts.map +1 -1
  112. package/dist/sync/realtime-telemetry.js +14 -6
  113. package/dist/sync/realtime-telemetry.js.map +1 -1
  114. package/dist/sync/realtime-telemetry.test.js +136 -16
  115. package/dist/sync/realtime-telemetry.test.js.map +1 -1
  116. package/dist/sync/remote-file-spool.d.ts +16 -0
  117. package/dist/sync/remote-file-spool.d.ts.map +1 -0
  118. package/dist/sync/remote-file-spool.js +91 -0
  119. package/dist/sync/remote-file-spool.js.map +1 -0
  120. package/dist/sync/remote-file-spool.test.d.ts +2 -0
  121. package/dist/sync/remote-file-spool.test.d.ts.map +1 -0
  122. package/dist/sync/remote-file-spool.test.js +77 -0
  123. package/dist/sync/remote-file-spool.test.js.map +1 -0
  124. package/dist/sync/state-store.d.ts +6 -0
  125. package/dist/sync/state-store.d.ts.map +1 -1
  126. package/dist/sync/state-store.js +14 -2
  127. package/dist/sync/state-store.js.map +1 -1
  128. package/dist/watch-coverage.d.ts +24 -0
  129. package/dist/watch-coverage.d.ts.map +1 -0
  130. package/dist/watch-coverage.js +46 -0
  131. package/dist/watch-coverage.js.map +1 -0
  132. package/dist/watch-coverage.test.d.ts +2 -0
  133. package/dist/watch-coverage.test.d.ts.map +1 -0
  134. package/dist/watch-coverage.test.js +56 -0
  135. package/dist/watch-coverage.test.js.map +1 -0
  136. package/dist/watcher.d.ts +31 -1
  137. package/dist/watcher.d.ts.map +1 -1
  138. package/dist/watcher.js +172 -0
  139. package/dist/watcher.js.map +1 -1
  140. package/dist/watcher.test.js +78 -0
  141. package/dist/watcher.test.js.map +1 -1
  142. package/package.json +2 -1
@@ -10,11 +10,12 @@ import { pickCanonicalPersonEntity } from "../vault-client.js";
10
10
  import { acquireOperationLock, withOperationLock, withOperationLockSync, OperationLockedError, OPERATION_LOCKED_EXIT, } from "../operation-lock.js";
11
11
  import { describeError } from "../lib/describe-error.js";
12
12
  import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
13
- import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
13
+ import { PARTIAL_SYNC_EXIT, UNRESOLVED_COMPANY_EXIT, } from "../lib/exit-codes.js";
14
14
  import { getOrCreateMachineId } from "../lib/machine-id.js";
15
15
  import { localPathForVaultKey } from "../local-path-codec.js";
16
16
  import { TreeWatcher, resolveEventDebounceConfig, systemClock, } from "../watcher.js";
17
- import { clearLocalDeleteIntent, getStateDir, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, openJournalStoreSession, openJournalStoreSessionAsync, listJournalSlugs, listJournals, maintainAreaJournalSlice, readJournal, releaseCachedAreaJournalMigrations, journalCacheCensusSizes, journalRows, readJournalScoped, writeJournal, } from "../journal.js";
17
+ import { recoverHqRootLocalState } from "../hq-root-recovery.js";
18
+ import { clearLocalDeleteIntent, getStateDir, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, openJournalStoreSession, openJournalStoreSessionAsync, listJournalSlugs, listJournals, maintainAreaJournalSlice, disposeScopedJournalStores, releaseScopedJournalStore, releaseCachedAreaJournalMigrations, journalCacheCensusSizes, journalRows, readJournalScoped, writeJournal, } from "../journal.js";
18
19
  import { isPersonalVaultDeletionExcluded } from "../personal-vault.js";
19
20
  import { NoopPushReceiver, } from "../sync/push-receiver.js";
20
21
  import { resolveEventSync, startEventSync as defaultStartEventSync, } from "../sync/event-sync.js";
@@ -30,6 +31,219 @@ import { InlineSyncWorkerAdapter, } from "../sync/sync-work.js";
30
31
  import { buildRoutePullArgv, buildScopedPushArgv, buildScopedDrainPullArgv, buildTargetedPullArgv, isWatchRouteSelected, routeChangeToTarget, routeKey, } from "./sync-runner-watch-routes.js";
31
32
  import { resolveSkipCompanies } from "./sync-runner-planning.js";
32
33
  const FULL_RECONCILE_EVERY_TICKS = 10;
34
+ /** A deferred full chore must get the next admission after this long. */
35
+ export const CHORE_STARVATION_CAP_MS = 15 * 60 * 1000;
36
+ /**
37
+ * One durable-state owner at a time for a full chore. Realtime work never runs
38
+ * beside that chore: it explicitly awaits `checkpoint()` after a completed
39
+ * durable unit, then resumes once the hand-off is finished. A separately
40
+ * tracked, explicitly opted-in watcher fast pass may use the pre-existing
41
+ * scoped fast-over-slow exception, but never while a chore owns durable state.
42
+ */
43
+ export class CooperativePassScheduler {
44
+ now;
45
+ starvationCapMs;
46
+ pending = [];
47
+ active = null;
48
+ // Scoped fast realtime work has the established in-process lock exception
49
+ // while a scoped slow realtime upload is in flight. Keep that slot separate
50
+ // from `active`: a chore still has exactly one durable-state owner.
51
+ activeFastRealtime = null;
52
+ stopped = false;
53
+ _handoffActive = false;
54
+ constructor(now, starvationCapMs = CHORE_STARVATION_CAP_MS) {
55
+ this.now = now;
56
+ this.starvationCapMs = starvationCapMs;
57
+ }
58
+ get handoffActive() {
59
+ return this._handoffActive;
60
+ }
61
+ get hasActiveChore() {
62
+ return this.active?.workClass === "chore";
63
+ }
64
+ get hasActiveSlowRealtime() {
65
+ return this.active?.workClass === "realtime" && this.active.lane === "slow";
66
+ }
67
+ pendingLaneCount(lane) {
68
+ return this.pending.filter((entry) => entry.lane === lane).length;
69
+ }
70
+ submit(task, workClass, lane = "slow", allowFastRealtimeOvertake = false) {
71
+ if (this.stopped)
72
+ return Promise.resolve(0);
73
+ const directEntry = {
74
+ task,
75
+ workClass,
76
+ lane,
77
+ allowFastRealtimeOvertake,
78
+ // An immediately admitted task cannot starve, so avoid sampling the
79
+ // cadence clock merely to stamp it. This preserves the loop's deadline
80
+ // accounting and keeps the timestamp meaningful only for real queues.
81
+ enqueuedAt: 0,
82
+ resolve: () => undefined,
83
+ reject: () => undefined,
84
+ };
85
+ if (this.canStartFastRealtime(directEntry)) {
86
+ return this.startFastRealtime(directEntry);
87
+ }
88
+ if (this.active === null &&
89
+ this.activeFastRealtime === null &&
90
+ this.pending.length === 0) {
91
+ return this.start(directEntry);
92
+ }
93
+ return new Promise((resolve, reject) => {
94
+ const entry = { ...directEntry, enqueuedAt: this.now(), resolve, reject };
95
+ this.pending.push(entry);
96
+ this.drain();
97
+ });
98
+ }
99
+ stop() {
100
+ this.stopped = true;
101
+ while (this.pending.length > 0)
102
+ this.pending.shift()?.resolve(0);
103
+ }
104
+ drain() {
105
+ if (this.stopped || this.pending.length === 0)
106
+ return;
107
+ if (this.active !== null) {
108
+ const fastRealtime = this.nextFastRealtime();
109
+ if (fastRealtime && this.canStartFastRealtime(fastRealtime)) {
110
+ this.pending.splice(this.pending.indexOf(fastRealtime), 1);
111
+ this.startFastRealtime(fastRealtime);
112
+ }
113
+ return;
114
+ }
115
+ if (this.activeFastRealtime !== null)
116
+ return;
117
+ const next = this.takeNext();
118
+ if (next)
119
+ this.start(next);
120
+ }
121
+ takeNext() {
122
+ const now = this.now();
123
+ const starvedChore = this.pending.findIndex((entry) => entry.workClass === "chore" && now - entry.enqueuedAt >= this.starvationCapMs);
124
+ if (starvedChore >= 0)
125
+ return this.pending.splice(starvedChore, 1)[0];
126
+ // A receiver's bulk drain is still realtime work, but its slow lane is the
127
+ // progress boundary for that delivery. Preserve its established turn
128
+ // before refilling later fast realtime arrivals; this does not let a
129
+ // chore overtake any realtime work.
130
+ const realtime = this.nextRealtimeIndex();
131
+ if (realtime >= 0)
132
+ return this.pending.splice(realtime, 1)[0];
133
+ return this.pending.shift();
134
+ }
135
+ nextRealtimeIndex() {
136
+ const slowRealtime = this.pending.findIndex((entry) => entry.workClass === "realtime" && entry.lane === "slow");
137
+ return slowRealtime >= 0
138
+ ? slowRealtime
139
+ : this.pending.findIndex((entry) => entry.workClass === "realtime");
140
+ }
141
+ nextFastRealtime() {
142
+ return this.pending.find((entry) => entry.workClass === "realtime" && entry.lane === "fast");
143
+ }
144
+ hasStarvedChore() {
145
+ const now = this.now();
146
+ return this.pending.some((entry) => entry.workClass === "chore" && now - entry.enqueuedAt >= this.starvationCapMs);
147
+ }
148
+ canStartFastRealtime(entry) {
149
+ return (!this._handoffActive &&
150
+ this.activeFastRealtime === null &&
151
+ entry.workClass === "realtime" &&
152
+ entry.lane === "fast" &&
153
+ entry.allowFastRealtimeOvertake &&
154
+ this.hasActiveSlowRealtime &&
155
+ !this.hasStarvedChore());
156
+ }
157
+ start(entry) {
158
+ this.active = entry;
159
+ let current;
160
+ try {
161
+ current = this.execute(entry);
162
+ }
163
+ catch (error) {
164
+ current = Promise.reject(error);
165
+ }
166
+ void current.then((outcome) => {
167
+ entry.resolve(outcome);
168
+ this.complete(entry);
169
+ }, (error) => {
170
+ entry.reject(error);
171
+ this.complete(entry);
172
+ }).catch(() => undefined);
173
+ return current;
174
+ }
175
+ complete(entry) {
176
+ if (this.active === entry)
177
+ this.active = null;
178
+ this.drain();
179
+ }
180
+ startFastRealtime(entry) {
181
+ this.activeFastRealtime = entry;
182
+ let current;
183
+ try {
184
+ current = this.execute(entry);
185
+ }
186
+ catch (error) {
187
+ current = Promise.reject(error);
188
+ }
189
+ void current.then((outcome) => {
190
+ entry.resolve(outcome);
191
+ this.completeFastRealtime(entry);
192
+ }, (error) => {
193
+ entry.reject(error);
194
+ this.completeFastRealtime(entry);
195
+ });
196
+ return current;
197
+ }
198
+ completeFastRealtime(entry) {
199
+ if (this.activeFastRealtime === entry)
200
+ this.activeFastRealtime = null;
201
+ this.drain();
202
+ }
203
+ execute(entry) {
204
+ return entry.task({ checkpoint: () => this.checkpoint(entry) });
205
+ }
206
+ async checkpoint(entry) {
207
+ if (this.stopped ||
208
+ entry.workClass !== "chore" ||
209
+ this.active !== entry ||
210
+ !this.pending.some((pending) => pending.workClass === "realtime")) {
211
+ return false;
212
+ }
213
+ // `entry` is suspended at a caller-declared durable boundary. It remains
214
+ // the sole full-pass continuation and will resume after this bounded burst.
215
+ const deferredAt = this.now();
216
+ this.active = null;
217
+ this._handoffActive = true;
218
+ let handedOff = false;
219
+ try {
220
+ while (!this.stopped && this.now() - deferredAt < this.starvationCapMs) {
221
+ const realtimeIndex = this.nextRealtimeIndex();
222
+ if (realtimeIndex < 0)
223
+ break;
224
+ const realtime = this.pending.splice(realtimeIndex, 1)[0];
225
+ handedOff = true;
226
+ this.active = realtime;
227
+ try {
228
+ realtime.resolve(await this.execute(realtime));
229
+ }
230
+ catch (error) {
231
+ realtime.reject(error);
232
+ }
233
+ finally {
234
+ if (this.active === realtime)
235
+ this.active = null;
236
+ }
237
+ }
238
+ }
239
+ finally {
240
+ this._handoffActive = false;
241
+ if (!this.stopped)
242
+ this.active = entry;
243
+ }
244
+ return handedOff;
245
+ }
246
+ }
33
247
  const MAX_FULL_RECONCILE_REASONS = 7;
34
248
  /**
35
249
  * Area storage is AUTO by default and only applies after durable cutover.
@@ -62,7 +276,7 @@ function emitFullReconcileDecision({ mode, reasons, triggers, pollTick, configur
62
276
  * is drained in bounded chunks across successive ticks instead of one giant
63
277
  * scoped pass. Size is not a full-reconcile signal — unknown scope is
64
278
  * (`overflowed` with no route hints, warm-up catch-up failure, first tick,
65
- * scheduled interval, bare wake). The crossover for a *chunk* is the number
279
+ * scheduled interval, bare wake, vanished HQ-root coverage). The crossover for a *chunk* is the number
66
280
  * of `--scope-path` arguments a targeted pass can carry without becoming a
67
281
  * de-facto whole-tree walk. `HQ_SYNC_EVENT_BATCH_LIMIT` overrides; 0/invalid
68
282
  * = default. Do not raise this to "fix" a large warm-up catch-up: that only
@@ -509,6 +723,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
509
723
  const idleHeartbeatIntervalMs = deps.idleHeartbeatIntervalMs ?? WATCH_IDLE_HEARTBEAT_INTERVAL_MS;
510
724
  const monotonicNow = deps.monotonicNow ?? (() => performance.now());
511
725
  const runPass = deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
726
+ const runPassSupportsCooperativeCheckpoint = deps.runPass === undefined || deps.runPassSupportsCooperativeCheckpoint === true;
512
727
  const pollIdx = argv.indexOf("--poll-remote-ms");
513
728
  // An explicit --poll-remote-ms pins a fixed interval (menubar/CLI still set
514
729
  // one). When it is omitted, the interval is resolved fresh each cycle from
@@ -519,7 +734,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
519
734
  const eventPush = argv.includes("--event-push");
520
735
  const fullReconcileIntervalMs = resolveFullReconcileMs();
521
736
  const companiesMode = argv.includes("--companies");
522
- const companyMode = argv.includes("--company");
737
+ const companyIdx = argv.indexOf("--company");
738
+ const companyMode = companyIdx >= 0;
739
+ // The tenant a single-company run owns. Carried into route selection so a
740
+ // watcher or receiver event under another company cannot queue a pass for it.
741
+ const companySlug = companyMode ? argv[companyIdx + 1] : undefined;
523
742
  const personalMode = argv.includes("--personal");
524
743
  // Keep this at the watcher ingress rather than importing the runner's
525
744
  // resolver: sync-runner imports this loop, and a reverse value import would
@@ -528,6 +747,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
528
747
  const watchRouteSelection = {
529
748
  companies: companiesMode,
530
749
  company: companyMode,
750
+ ...(companySlug !== undefined ? { companySlug } : {}),
531
751
  personal: personalMode,
532
752
  skipPersonal: parsed.skipPersonal === true ||
533
753
  skipPersonalEnv === "1" ||
@@ -672,7 +892,27 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
672
892
  resolveNoOperationLockUsers = resolve;
673
893
  });
674
894
  };
675
- const runPassWithLock = async (passArgvForRun, prepare, lane = "slow") => {
895
+ const passScheduler = new CooperativePassScheduler(monotonicNow);
896
+ const runPassWithLock = async (passArgvForRun, prepare, lane = "slow", workClass = "chore", checkpoint) => {
897
+ const reportedAppliedUpserts = new Set();
898
+ const recordDurableAppliedUpserts = (upserts) => {
899
+ for (const applied of upserts) {
900
+ const key = `${applied.relativePath}\u0000${applied.contentHash}`;
901
+ if (reportedAppliedUpserts.has(key))
902
+ continue;
903
+ reportedAppliedUpserts.add(key);
904
+ rememberAppliedUpsert(applied.relativePath, applied.contentHash);
905
+ }
906
+ };
907
+ const invokePass = () => {
908
+ if (!runPassSupportsCooperativeCheckpoint)
909
+ return runPass(passArgvForRun);
910
+ // This callback is emitted by the production runner after journal
911
+ // repair/seed and before fanout. It starts ingress early, but never
912
+ // admits a second durable writer: queued realtime still waits for the
913
+ // existing scheduler's cooperative hand-off checkpoint.
914
+ return runPass(passArgvForRun, checkpoint, () => activateEventPushSurfaces(), recordDurableAppliedUpserts);
915
+ };
676
916
  // Observe the heap at pass boundaries — a gated checkpoint immediately before
677
917
  // admitting a pass and immediately after it returns. A pass that runs a long
678
918
  // synchronous stretch starves the interval timer, so these boundary
@@ -684,9 +924,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
684
924
  heapCheckpoint?.();
685
925
  if (typeof outcome === "number")
686
926
  return;
687
- for (const applied of outcome.result?.appliedUpserts ?? []) {
688
- rememberAppliedUpsert(applied.relativePath, applied.contentHash);
689
- }
927
+ recordDurableAppliedUpserts(outcome.result?.appliedUpserts ?? []);
690
928
  };
691
929
  // Pre-admit boundary observation.
692
930
  heapCheckpoint?.();
@@ -695,13 +933,31 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
695
933
  // its keyed journal delta and per-object mutation are independent from the
696
934
  // slow pass's bulk paths, and the slow pass already owns the root lock.
697
935
  // Never bypass the lock when no slow pass is active.
936
+ if (
937
+ // A full chore is paused at a caller-declared durable checkpoint. Keep
938
+ // the root lock held by this process, but lend it to exactly one
939
+ // realtime pass; the suspended chore cannot mutate until this returns.
940
+ workClass === "realtime" &&
941
+ passScheduler.handoffActive &&
942
+ processOwnsOperationLock) {
943
+ acquireOperationLockUser();
944
+ try {
945
+ prepare?.();
946
+ const outcome = await invokePass();
947
+ recordAppliedUpserts(outcome);
948
+ return outcome;
949
+ }
950
+ finally {
951
+ releaseOperationLockUser();
952
+ }
953
+ }
698
954
  if (lane === "fast" &&
699
955
  processOwnsOperationLock &&
700
956
  coordinatorWholeMutationChores === 0) {
701
957
  acquireOperationLockUser();
702
958
  try {
703
959
  prepare?.();
704
- const outcome = await runPass(passArgvForRun);
960
+ const outcome = await invokePass();
705
961
  recordAppliedUpserts(outcome);
706
962
  return outcome;
707
963
  }
@@ -721,7 +977,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
721
977
  acquireOperationLockUser();
722
978
  }
723
979
  prepare?.();
724
- const outcome = await runPass(passArgvForRun);
980
+ const outcome = await invokePass();
725
981
  recordAppliedUpserts(outcome);
726
982
  return outcome;
727
983
  }
@@ -755,7 +1011,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
755
1011
  }
756
1012
  try {
757
1013
  prepare?.();
758
- const outcome = await runPass(passArgvForRun);
1014
+ const outcome = await invokePass();
759
1015
  recordAppliedUpserts(outcome);
760
1016
  return outcome;
761
1017
  }
@@ -778,7 +1034,18 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
778
1034
  throw err;
779
1035
  }
780
1036
  };
781
- const runTaskWithLock = async (task) => {
1037
+ const runTaskWithLock = async (task, workClass = "chore") => {
1038
+ if (workClass === "realtime" &&
1039
+ passScheduler.handoffActive &&
1040
+ processOwnsOperationLock) {
1041
+ acquireOperationLockUser();
1042
+ try {
1043
+ return await task();
1044
+ }
1045
+ finally {
1046
+ releaseOperationLockUser();
1047
+ }
1048
+ }
782
1049
  try {
783
1050
  const handle = acquireOperationLock(hqRoot, "sync", {
784
1051
  timeoutSec: 0,
@@ -872,102 +1139,15 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
872
1139
  heapCheckpoint?.();
873
1140
  }
874
1141
  };
875
- let activeSlowPass = null;
876
- let activeFastPass = null;
877
- const pendingSlowPasses = [];
878
- const pendingFastPasses = [];
879
1142
  const resolveStoppedQueue = () => {
880
- for (const queue of [pendingFastPasses, pendingSlowPasses]) {
881
- while (queue.length > 0)
882
- queue.shift()?.resolve(0);
883
- }
884
- };
885
- const drainQueuedPasses = () => {
886
- if (stopped) {
887
- resolveStoppedQueue();
888
- return;
889
- }
890
- // A queued slow pass is the bulk-drain progress boundary. Admit it before
891
- // refilling the realtime queue; otherwise a steady stream of fast work
892
- // perpetually leapfrogs a receiver's bulk batch and its SQS poll loop
893
- // cannot issue another receive.
894
- if (activeSlowPass === null &&
895
- activeFastPass === null &&
896
- pendingSlowPasses.length > 0) {
897
- const slow = pendingSlowPasses.shift();
898
- if (slow) {
899
- const current = startGuardedTask(slow.task, "slow");
900
- void current.then(slow.resolve, slow.reject);
901
- }
902
- }
903
- if (activeFastPass === null) {
904
- const fast = pendingFastPasses.shift();
905
- if (fast) {
906
- const current = startGuardedTask(fast.task, "fast");
907
- void current.then(fast.resolve, fast.reject);
908
- }
909
- }
910
- // A fast pass that acquired the operation lock normally (rather than
911
- // bypassing an in-process slow holder) still owns the cross-process
912
- // boundary. Slow work must queue behind it; only fast work is allowed to
913
- // overlap an already-lock-owning slow pass.
914
- if (activeSlowPass === null && activeFastPass === null) {
915
- const slow = pendingSlowPasses.shift();
916
- if (slow) {
917
- const current = startGuardedTask(slow.task, "slow");
918
- void current.then(slow.resolve, slow.reject);
919
- }
920
- }
921
- };
922
- const startGuardedTask = (task, lane) => {
923
- // Claim the lane before invoking `task`: a task can synchronously activate
924
- // a watcher/scheduler callback before returning its promise. Without this
925
- // placeholder that re-entrant callback sees an idle lane and starts a
926
- // second lock contender.
927
- const placeholder = Promise.resolve(0);
928
- if (lane === "fast")
929
- activeFastPass = placeholder;
930
- else
931
- activeSlowPass = placeholder;
932
- let current;
933
- try {
934
- current = stopped ? placeholder : task();
935
- }
936
- catch (err) {
937
- current = Promise.reject(err);
938
- }
939
- if (lane === "fast")
940
- activeFastPass = current;
941
- else
942
- activeSlowPass = current;
943
- void current
944
- .finally(() => {
945
- if (lane === "fast" && activeFastPass === current) {
946
- activeFastPass = null;
947
- drainQueuedPasses();
948
- }
949
- else if (lane === "slow" && activeSlowPass === current) {
950
- activeSlowPass = null;
951
- drainQueuedPasses();
952
- }
953
- })
954
- .catch(() => undefined);
955
- return current;
1143
+ passScheduler.stop();
956
1144
  };
957
- const runGuardedTask = (task, lane = "slow") => {
958
- const active = lane === "fast"
959
- ? activeFastPass
960
- : activeSlowPass ?? activeFastPass;
961
- const queued = lane === "fast" ? pendingFastPasses : pendingSlowPasses;
962
- if (active === null && queued.length === 0) {
963
- return startGuardedTask(task, lane);
964
- }
965
- return new Promise((resolve, reject) => {
966
- queued.push({ task, lane, resolve, reject });
967
- drainQueuedPasses();
968
- });
969
- };
970
- const runGuarded = (passArgvForRun, prepare, lane = "slow") => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare, lane), lane);
1145
+ const runGuardedTask = (task, lane = "slow", workClass = "chore", allowFastRealtimeOvertake = false) =>
1146
+ // Lanes still choose the pass execution profile, but no longer decide
1147
+ // chore ownership. Realtime normally waits for a chore hand-off; the
1148
+ // watcher fast path explicitly opts into its established scoped exception.
1149
+ passScheduler.submit(task, workClass, lane, allowFastRealtimeOvertake);
1150
+ const runGuarded = (passArgvForRun, prepare, lane = "slow", workClass = "chore", allowFastRealtimeOvertake = false) => runGuardedTask((context) => runPassWithLock(passArgvForRun, prepare, lane, workClass, workClass === "chore" ? context.checkpoint : undefined), lane, workClass, allowFastRealtimeOvertake);
971
1151
  /*
972
1152
  * Area storage and the parallel coordinator default to AUTO. AUTO turns
973
1153
  * them on after durable personal-vault cutover; explicit off is the kill
@@ -1159,6 +1339,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1159
1339
  });
1160
1340
  },
1161
1341
  onCutOver: () => {
1342
+ releaseScopedJournalStore(PERSONAL_VAULT_JOURNAL_SLUG);
1162
1343
  parallelCoordinatorEnabled = refreshCoordinatorEnabled();
1163
1344
  if (parallelCoordinatorEnabled && coordinator === null) {
1164
1345
  coordinator = createCoordinator();
@@ -1167,6 +1348,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1167
1348
  })
1168
1349
  : { onTick() { } };
1169
1350
  let watcher = null;
1351
+ let rootCoverageStale = false;
1170
1352
  let eventPushSurfacesActivated = false;
1171
1353
  let watcherUnavailableWarned = false;
1172
1354
  let watcherRetryAt = null;
@@ -1240,11 +1422,36 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1240
1422
  `watched-paths=${watchedPaths ?? "unknown"}, ` +
1241
1423
  `dropped-untracked-deletes=${watcher.untrackedDeletesDropped?.() ?? "unknown"}, ` +
1242
1424
  `drained-untracked-deletes=${drainedUntrackedDeletes})\n`);
1425
+ const probe = watcher.probeCoverage?.();
1426
+ if (probe && !probe.ok) {
1427
+ process.stderr.write("hq-sync-runner: WARN event-push watcher coverage stale; " +
1428
+ `reason=${probe.reason} stale-watches=${probe.staleWatches} ` +
1429
+ `watched-paths=${watchedPaths ?? "unknown"}; restoring from vault, not pushing deletes\n`);
1430
+ try {
1431
+ recoverHqRootLocalState(hqRoot);
1432
+ }
1433
+ catch (err) {
1434
+ process.stderr.write(`hq-sync-runner: WARN local HQ recovery failed: ${describeError(err)}\n`);
1435
+ }
1436
+ try {
1437
+ watcher.rebuildCoverage?.();
1438
+ }
1439
+ catch (err) {
1440
+ process.stderr.write(`hq-sync-runner: WARN watcher coverage rebuild failed: ${describeError(err)}\n`);
1441
+ }
1442
+ rootCoverageStale = true;
1443
+ interruptWait();
1444
+ }
1243
1445
  };
1244
1446
  /** Vanished never-journaled file deletes removed from batches at drain time. */
1245
1447
  let drainedUntrackedDeletes = 0;
1246
1448
  const pendingWatcherPaths = new Map();
1247
1449
  const pendingWatcherChanges = new Map();
1450
+ // A warm-up scan observes filesystem state over time. Keep an ingress
1451
+ // sequence so its older observations cannot overwrite a live watcher event
1452
+ // that arrived while the scan was yielding.
1453
+ const pendingWatcherChangeSequences = new Map();
1454
+ let nextPendingWatcherChangeSequence = 0;
1248
1455
  let pendingWatcherOriginalBatch = null;
1249
1456
  // Every coalesced watcher batch gets one durable in-process generation.
1250
1457
  // A wake can cause the scheduler to revisit a drained batch, but an event
@@ -1268,11 +1475,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1268
1475
  const paths = new Map();
1269
1476
  const changes = new Map();
1270
1477
  for (const [absolutePath, relativePath] of batch.paths.entries()) {
1271
- const route = routeChangeToTarget(relativePath);
1478
+ const change = batch.changes?.get(absolutePath);
1479
+ const route = routeChangeToTarget(relativePath, change?.kind);
1272
1480
  if (!route || !isWatchRouteSelected(route, watchRouteSelection))
1273
1481
  continue;
1274
1482
  paths.set(absolutePath, relativePath);
1275
- const change = batch.changes?.get(absolutePath);
1276
1483
  if (change)
1277
1484
  changes.set(absolutePath, change);
1278
1485
  }
@@ -1280,7 +1487,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1280
1487
  return null;
1281
1488
  return { ...batch, paths, changes };
1282
1489
  };
1283
- const addPendingWatcherChange = (changedRelPath, batch) => {
1490
+ const addPendingWatcherChange = (changedRelPath, batch, options) => {
1284
1491
  if (batch) {
1285
1492
  const selectedBatch = selectWatchBatch(batch);
1286
1493
  if (!selectedBatch)
@@ -1293,20 +1500,30 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1293
1500
  return false;
1294
1501
  }
1295
1502
  }
1296
- pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
1297
1503
  if (batch) {
1504
+ const entries = [...batch.paths.entries()].filter(([absolutePath]) => {
1505
+ const previousSequence = pendingWatcherChangeSequences.get(absolutePath);
1506
+ return previousSequence === undefined ||
1507
+ options?.preserveChangesAfter === undefined ||
1508
+ previousSequence <= options.preserveChangesAfter;
1509
+ });
1510
+ if (entries.length === 0)
1511
+ return false;
1512
+ pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
1298
1513
  pendingWatcherOriginalBatch =
1299
1514
  pendingWatcherPaths.size === 0 &&
1300
1515
  !pendingWatcherBareChange &&
1301
1516
  !pendingWatcherOverflowed &&
1302
- !batch.overflowed
1517
+ !batch.overflowed &&
1518
+ entries.length === batch.paths.size
1303
1519
  ? batch
1304
1520
  : null;
1305
- for (const [absolutePath, relativePath] of batch.paths.entries()) {
1521
+ for (const [absolutePath, relativePath] of entries) {
1306
1522
  pendingWatcherPaths.set(absolutePath, relativePath);
1307
1523
  const change = batch.changes?.get(absolutePath);
1308
1524
  if (change)
1309
1525
  pendingWatcherChanges.set(absolutePath, change);
1526
+ pendingWatcherChangeSequences.set(absolutePath, ++nextPendingWatcherChangeSequence);
1310
1527
  }
1311
1528
  if (batch.overflowed) {
1312
1529
  pendingWatcherOverflowed = true;
@@ -1323,6 +1540,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1323
1540
  }
1324
1541
  return true;
1325
1542
  }
1543
+ pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
1326
1544
  if (changedRelPath) {
1327
1545
  pendingWatcherOriginalBatch = null;
1328
1546
  // Codec boundary: `changedRelPath` is a canonical vault key (the
@@ -1331,6 +1549,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1331
1549
  // point at the impossible colon path on Windows.
1332
1550
  const absolutePath = localPathForVaultKey(hqRoot, changedRelPath);
1333
1551
  pendingWatcherPaths.set(absolutePath, changedRelPath);
1552
+ pendingWatcherChangeSequences.set(absolutePath, ++nextPendingWatcherChangeSequence);
1334
1553
  // A bare path signal carries no delete intent; drop any change record
1335
1554
  // a prior batch left for this path so the stale intent cannot leak
1336
1555
  // into the coalesced batch (#227).
@@ -1372,6 +1591,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1372
1591
  const generation = pendingWatcherBatchGeneration;
1373
1592
  pendingWatcherPaths.clear();
1374
1593
  pendingWatcherChanges.clear();
1594
+ pendingWatcherChangeSequences.clear();
1375
1595
  pendingWatcherOriginalBatch = null;
1376
1596
  pendingWatcherBareChange = false;
1377
1597
  pendingWatcherWarmupCatchup = false;
@@ -1390,6 +1610,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1390
1610
  pendingWatcherOriginalBatch = null;
1391
1611
  for (const [absolutePath, relativePath] of paths.entries()) {
1392
1612
  pendingWatcherPaths.set(absolutePath, relativePath);
1613
+ pendingWatcherChangeSequences.set(absolutePath, ++nextPendingWatcherChangeSequence);
1393
1614
  }
1394
1615
  };
1395
1616
  /**
@@ -1470,8 +1691,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1470
1691
  }
1471
1692
  return null;
1472
1693
  };
1473
- const journalCoordinates = (relativePath) => {
1474
- const route = routeChangeToTarget(relativePath);
1694
+ const journalCoordinates = (relativePath, kind) => {
1695
+ const route = routeChangeToTarget(relativePath, kind);
1475
1696
  if (!route || !isWatchRouteSelected(route, watchRouteSelection))
1476
1697
  return null;
1477
1698
  if (route.kind === "personal") {
@@ -1506,7 +1727,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1506
1727
  declaredSize >= 0) {
1507
1728
  return declaredSize;
1508
1729
  }
1509
- const size = journalRows(readJournal(coordinates.slug)).get(coordinates.key)?.size;
1730
+ const size = journalRows(readJournalScoped(coordinates.slug, [coordinates.key]))
1731
+ .get(coordinates.key)?.size;
1510
1732
  return typeof size === "number" && Number.isSafeInteger(size) && size >= 0 ? size : null;
1511
1733
  };
1512
1734
  /**
@@ -1553,8 +1775,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1553
1775
  return session.get(coordinates.key) === undefined;
1554
1776
  };
1555
1777
  const captureLocalDeleteSnapshots = (relativePath, kind) => {
1556
- const coordinates = journalCoordinates(relativePath);
1557
- const route = routeChangeToTarget(relativePath);
1778
+ const coordinates = journalCoordinates(relativePath, kind);
1779
+ const route = routeChangeToTarget(relativePath, kind);
1558
1780
  if (!coordinates || !route)
1559
1781
  return [];
1560
1782
  // Desktop workspace Off toggles are authoritative before journal I/O.
@@ -1675,8 +1897,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1675
1897
  * it scopes a push or pull for the affected batch.
1676
1898
  */
1677
1899
  const captureQueuedLocalDeleteSnapshots = async (relativePath, kind, isCurrent) => {
1678
- const coordinates = journalCoordinates(relativePath);
1679
- const route = routeChangeToTarget(relativePath);
1900
+ const coordinates = journalCoordinates(relativePath, kind);
1901
+ const route = routeChangeToTarget(relativePath, kind);
1680
1902
  if (!coordinates || !route)
1681
1903
  return [];
1682
1904
  if (route.kind === "company" &&
@@ -1770,8 +1992,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1770
1992
  }
1771
1993
  return snapshots;
1772
1994
  };
1773
- const warmupJournalPaths = () => {
1774
- const paths = [];
1995
+ const warmupJournalPaths = function* () {
1775
1996
  const journals = listJournals();
1776
1997
  const vaultJournalPresent = journals.some(({ slug }) => slug === PERSONAL_VAULT_JOURNAL_SLUG);
1777
1998
  for (const { slug, journal } of journals) {
@@ -1804,10 +2025,9 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1804
2025
  // ~12.8k of them per runner start after a transcript prune.
1805
2026
  if (route.kind === "personal" && isPersonalVaultDeletionExcluded(key))
1806
2027
  continue;
1807
- paths.push(route.kind === "personal" ? key : `companies/${route.slug}/${key}`);
2028
+ yield route.kind === "personal" ? key : `companies/${route.slug}/${key}`;
1808
2029
  }
1809
2030
  }
1810
- return paths;
1811
2031
  };
1812
2032
  const publishedContentHashLookup = (relativePath) => {
1813
2033
  const coordinates = journalCoordinates(relativePath);
@@ -1820,13 +2040,34 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1820
2040
  return publishedContentHashFromJournalEntryBeforeSession(entry, serviceStartedAt);
1821
2041
  };
1822
2042
  const prepareWatcherChanges = (changes) => {
2043
+ const keysByJournal = new Map();
2044
+ const rememberKey = (slug, key) => {
2045
+ const keys = keysByJournal.get(slug) ?? new Set();
2046
+ keys.add(key);
2047
+ keysByJournal.set(slug, keys);
2048
+ };
2049
+ for (const [absolutePath, change] of changes.entries()) {
2050
+ if (change.kind === "unlink" || change.kind === "unlinkDir") {
2051
+ for (const snapshot of change.deleteSnapshots ?? []) {
2052
+ rememberKey(snapshot.journalSlug, snapshot.journalPath);
2053
+ }
2054
+ continue;
2055
+ }
2056
+ const relativePath = path
2057
+ .relative(hqRoot, absolutePath)
2058
+ .split(path.sep)
2059
+ .join("/");
2060
+ const coordinates = journalCoordinates(relativePath);
2061
+ if (coordinates)
2062
+ rememberKey(coordinates.slug, coordinates.key);
2063
+ }
1823
2064
  const journals = new Map();
1824
2065
  const dirty = new Set();
1825
2066
  const journalFor = (slug) => {
1826
2067
  const loaded = journals.get(slug);
1827
2068
  if (loaded)
1828
2069
  return loaded;
1829
- const journal = readJournal(slug);
2070
+ const journal = readJournalScoped(slug, [...(keysByJournal.get(slug) ?? [])]);
1830
2071
  journals.set(slug, journal);
1831
2072
  return journal;
1832
2073
  };
@@ -1884,7 +2125,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1884
2125
  const groupBatchByRoute = (batch) => {
1885
2126
  const grouped = new Map();
1886
2127
  for (const [absolutePath, relativePath] of batch.paths.entries()) {
1887
- const route = routeChangeToTarget(relativePath);
2128
+ const change = batch.changes?.get(absolutePath);
2129
+ const route = routeChangeToTarget(relativePath, change?.kind);
1888
2130
  if (!route)
1889
2131
  continue;
1890
2132
  const key = routeKey(route);
@@ -1897,7 +2139,6 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1897
2139
  deleteScopeRoots: [],
1898
2140
  };
1899
2141
  existing.paths.set(absolutePath, relativePath);
1900
- const change = batch.changes?.get(absolutePath);
1901
2142
  if (change)
1902
2143
  existing.changes.set(absolutePath, change);
1903
2144
  existing.relPaths = [...new Set([...existing.paths.values()])];
@@ -1981,7 +2222,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
1981
2222
  }
1982
2223
  return false;
1983
2224
  };
1984
- const runScopedDrain = async (batch, watcherBatchGeneration, wakeOnPushFailure = false) => {
2225
+ const runScopedDrain = async (batch, watcherBatchGeneration, wakeOnPushFailure = false, allowFastRealtimeOvertake = false) => {
1985
2226
  if (batch)
1986
2227
  await resolveDeleteSnapshotCaptures(batch);
1987
2228
  const grouped = batch && batch.paths.size > 0 ? groupBatchByRoute(batch) : null;
@@ -2010,11 +2251,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2010
2251
  const scopedArgv = buildScopedPushArgv(group.route, admittedPaths, passArgv, group.deleteScopeRoots);
2011
2252
  return runGuarded(scopedArgv, admittedChanges.size > 0
2012
2253
  ? () => prepareWatcherChanges(admittedChanges)
2013
- : undefined, lane);
2254
+ : undefined, lane, "realtime", allowFastRealtimeOvertake);
2014
2255
  })
2015
2256
  : { result: await runGuarded(buildScopedPushArgv(group.route, group.relPaths, passArgv, group.deleteScopeRoots), group.changes.size > 0
2016
2257
  ? () => prepareWatcherChanges(group.changes)
2017
- : undefined, lane), accepted: true, coordinatorRefusedPaths: [] };
2258
+ : undefined, lane, "realtime", allowFastRealtimeOvertake), accepted: true, coordinatorRefusedPaths: [] };
2018
2259
  const result = execution.result;
2019
2260
  if (execution.accepted && passExitCode(result) === 0) {
2020
2261
  const refusedPaths = new Set(typeof result === "number"
@@ -2084,8 +2325,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2084
2325
  ? "fast"
2085
2326
  : "slow";
2086
2327
  const execution = parallelCoordinatorEnabled
2087
- ? await runRealtimePass("watcher", "pull", group.relPaths, lane, (slice) => runGuarded(buildTargetedPullArgv(group.route, passArgv, slice.paths.map((path) => path.canonicalPath)), undefined, lane))
2088
- : { result: await runGuarded(pullArgv, undefined, lane), accepted: true };
2328
+ ? await runRealtimePass("watcher", "pull", group.relPaths, lane, (slice) => runGuarded(buildTargetedPullArgv(group.route, passArgv, slice.paths.map((path) => path.canonicalPath)), undefined, lane, "realtime"))
2329
+ : { result: await runGuarded(pullArgv, undefined, lane, "realtime"), accepted: true };
2089
2330
  const exit = execution.accepted ? passExitCode(execution.result) : 1;
2090
2331
  if (exit !== 0 && worstExit === 0)
2091
2332
  worstExit = exit;
@@ -2290,13 +2531,13 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2290
2531
  return;
2291
2532
  }
2292
2533
  if (lanes?.fast &&
2293
- activeSlowPass !== null) {
2534
+ (passScheduler.hasActiveChore || passScheduler.hasActiveSlowRealtime)) {
2294
2535
  const generation = ++nextWatcherBatchGeneration;
2295
2536
  // Fast dispatch bypasses the pending-batch ingress, so preserve both
2296
2537
  // of its contracts here: only selected routes may run, and V2 sees
2297
2538
  // the same dirty signal as the compatibility drain.
2298
2539
  realtimeState.scheduler?.signal("watcher");
2299
- void runScopedDrain(lanes.fast, generation, true).then((exit) => {
2540
+ void runScopedDrain(lanes.fast, generation, true, true).then((exit) => {
2300
2541
  if (exit === 0)
2301
2542
  return;
2302
2543
  restorePendingWatcherPaths(lanes.fast.paths, generation);
@@ -2320,6 +2561,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2320
2561
  interruptWait();
2321
2562
  }
2322
2563
  });
2564
+ try {
2565
+ recoverHqRootLocalState(hqRoot);
2566
+ }
2567
+ catch (err) {
2568
+ process.stderr.write(`hq-sync-runner: WARN local HQ recovery failed: ${describeError(err)}\n`);
2569
+ }
2323
2570
  watcher.start();
2324
2571
  const reportWatcherReady = () => {
2325
2572
  if (stopped || watcher === null)
@@ -2345,29 +2592,36 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2345
2592
  watcherRediscoveryRetries = 0;
2346
2593
  process.stderr.write(`hq-sync-runner: event-push watcher active (watched-paths=${watchedPaths ?? "unknown"})\n`);
2347
2594
  const startedAt = Date.now();
2348
- try {
2349
- const catchup = watcher.collectWarmupCatchup?.(serviceStartedAt - WATCHER_WARMUP_SAFETY_MARGIN_MS, warmupJournalPaths());
2350
- if (!catchup)
2351
- return;
2352
- const queued = addPendingWatcherChange(undefined, catchup.batch);
2353
- pendingWatcherWarmupCatchup ||= queued;
2354
- process.stderr.write(JSON.stringify({
2355
- type: "watcher-warmup-catchup",
2356
- reason: "watch-set-ready",
2357
- pathsFound: catchup.batch.paths.size,
2358
- scannedPaths: catchup.scannedPaths,
2359
- elapsedMs: Date.now() - startedAt,
2360
- queued,
2361
- }) + "\n");
2362
- if (queued)
2595
+ const liveChangeSequenceAtWarmupStart = nextPendingWatcherChangeSequence;
2596
+ void (async () => {
2597
+ try {
2598
+ const catchup = watcher?.collectWarmupCatchupAsync
2599
+ ? await watcher.collectWarmupCatchupAsync(serviceStartedAt - WATCHER_WARMUP_SAFETY_MARGIN_MS, warmupJournalPaths())
2600
+ : watcher?.collectWarmupCatchup?.(serviceStartedAt - WATCHER_WARMUP_SAFETY_MARGIN_MS, warmupJournalPaths());
2601
+ if (!catchup || catchup.cancelled || stopped || watcher === null)
2602
+ return;
2603
+ const queued = addPendingWatcherChange(undefined, catchup.batch, {
2604
+ preserveChangesAfter: liveChangeSequenceAtWarmupStart,
2605
+ });
2606
+ pendingWatcherWarmupCatchup ||= queued;
2607
+ process.stderr.write(JSON.stringify({
2608
+ type: "watcher-warmup-catchup",
2609
+ reason: "watch-set-ready",
2610
+ pathsFound: catchup.batch.paths.size,
2611
+ scannedPaths: catchup.scannedPaths,
2612
+ elapsedMs: Date.now() - startedAt,
2613
+ queued,
2614
+ }) + "\n");
2615
+ if (queued)
2616
+ interruptWait();
2617
+ }
2618
+ catch (err) {
2619
+ process.stderr.write(`hq-sync-runner: watcher warm-up catch-up failed: ${describeError(err)}\n`);
2620
+ // A scan failure must not silently extend the warm-up blind spot.
2621
+ pendingWatcherWarmupCatchupFailed = true;
2363
2622
  interruptWait();
2364
- }
2365
- catch (err) {
2366
- process.stderr.write(`hq-sync-runner: watcher warm-up catch-up failed: ${describeError(err)}\n`);
2367
- // A scan failure must not silently extend the warm-up blind spot.
2368
- pendingWatcherWarmupCatchupFailed = true;
2369
- interruptWait();
2370
- }
2623
+ }
2624
+ })();
2371
2625
  };
2372
2626
  if (watcher.onReady)
2373
2627
  watcher.onReady(reportWatcherReady);
@@ -2415,7 +2669,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2415
2669
  // Submit every route in this lane before awaiting any result. In
2416
2670
  // particular, a later route's fast receipt must never wait for an
2417
2671
  // earlier route's slow pull to finish.
2418
- const completed = await Promise.all([...eventsByRoute.values()].map(({ route, events }) => runRealtimePass("receiver", "pull", events.map((event) => event.relativePath), lane, (slice) => runGuarded(buildTargetedPullArgv(route, passArgv, slice.paths.map((path) => path.canonicalPath)), undefined, lane))));
2672
+ const completed = await Promise.all([...eventsByRoute.values()].map(({ route, events }) => runRealtimePass("receiver", "pull", events.map((event) => event.relativePath), lane, (slice) => runGuarded(buildTargetedPullArgv(route, passArgv, slice.paths.map((path) => path.canonicalPath)), undefined, lane, "realtime"))));
2419
2673
  for (const execution of completed) {
2420
2674
  if (!execution.accepted || passExitCode(execution.result) !== 0) {
2421
2675
  throw new Error(`targeted pull failed with exit code ${passExitCode(execution.result)}`);
@@ -2469,7 +2723,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2469
2723
  }
2470
2724
  for (const { route, paths } of pathsByRoute.values()) {
2471
2725
  const targetedArgv = buildTargetedPullArgv(route, passArgv, paths);
2472
- const result = await runGuarded(targetedArgv, undefined, lane);
2726
+ const result = await runGuarded(targetedArgv, undefined, lane, "realtime");
2473
2727
  if (passExitCode(result) !== 0) {
2474
2728
  throw new Error(`targeted pull failed with exit code ${passExitCode(result)}`);
2475
2729
  }
@@ -2517,14 +2771,14 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2517
2771
  const execution = await runRealtimePass("wake", "pull", [], "slow", async () => runGuardedTask(() => runTaskWithLock(async () => {
2518
2772
  await drain();
2519
2773
  return 0;
2520
- })));
2774
+ }, "realtime"), "slow", "realtime"));
2521
2775
  outcome = execution.accepted ? execution.result : 1;
2522
2776
  }
2523
2777
  else {
2524
2778
  outcome = await runGuardedTask(() => runTaskWithLock(async () => {
2525
2779
  await drain();
2526
2780
  return 0;
2527
- }));
2781
+ }, "realtime"), "slow", "realtime");
2528
2782
  }
2529
2783
  }
2530
2784
  catch (err) {
@@ -2720,8 +2974,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2720
2974
  targetedJournalSessions: () => targetedJournalSessionCensusSizes().targetedJournalSessions,
2721
2975
  targetedJournalRows: () => targetedJournalSessionCensusSizes().targetedJournalRows,
2722
2976
  cachedAreaJournalMigrations: () => journalCacheCensusSizes().cachedAreaJournalMigrations,
2723
- pendingSlowPasses: () => pendingSlowPasses.length,
2724
- pendingFastPasses: () => pendingFastPasses.length,
2977
+ pendingSlowPasses: () => passScheduler.pendingLaneCount("slow"),
2978
+ pendingFastPasses: () => passScheduler.pendingLaneCount("fast"),
2725
2979
  },
2726
2980
  eventSyncSizes: () => eventSync?.censusSizes?.(),
2727
2981
  watcherSizes: () => watcher?.censusSizes?.(),
@@ -2909,6 +3163,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2909
3163
  const watcherWarmupCatchup = pending.warmupCatchup;
2910
3164
  const watcherWarmupCatchupFailed = pending.warmupCatchupFailed;
2911
3165
  const overflowRoutesUnknown = pending.batch?.overflowed === true && !overflowWithKnownRoutes;
3166
+ const coverageStale = rootCoverageStale;
3167
+ rootCoverageStale = false;
2912
3168
  const scheduledInterval = fullReconcileIntervalMs === undefined
2913
3169
  ? pollTick % FULL_RECONCILE_EVERY_TICKS === 0
2914
3170
  : lastFullReconcileAt !== null &&
@@ -2922,6 +3178,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2922
3178
  "watcher-warmup-catch-up": watcherWarmupCatchup,
2923
3179
  "watcher-warmup-catch-up-failed": watcherWarmupCatchupFailed,
2924
3180
  "scheduled-interval": scheduledInterval,
3181
+ "root-coverage-stale": coverageStale,
2925
3182
  };
2926
3183
  const reasons = Object.entries(triggers)
2927
3184
  .filter(([, matched]) => matched)
@@ -2931,7 +3188,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
2931
3188
  bareWake ||
2932
3189
  overflowRoutesUnknown ||
2933
3190
  watcherWarmupCatchupFailed ||
2934
- scheduledInterval;
3191
+ scheduledInterval ||
3192
+ coverageStale;
2935
3193
  // Size is not unknown-scope. A fully-enumerated over-limit batch still
2936
3194
  // has every path listed, so drain it in bounded chunks and keep the
2937
3195
  // remainder queued. Skip this when a genuine full-reconcile trigger
@@ -3071,11 +3329,14 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
3071
3329
  quarantinedDeleteRoots.delete(root);
3072
3330
  }
3073
3331
  // Delete snapshot capture needs a fresh keyed view while an episode is
3074
- // being assembled, but completed watch cycles do not. Releasing it here
3075
- // prevents the watcher from retaining another full-vault projection next
3076
- // to the targeted-pull and area-ledger views.
3332
+ // being assembled, but completed watch cycles do not. Area-authoritative
3333
+ // journals are different: their bridge owns the packed aggregate that a
3334
+ // later watcher callback reuses. Dropping that bridge at every poll
3335
+ // makes the next scoped callback synchronously re-pack the whole vault.
3336
+ // Keep it for this runner's lifetime; layout/phase changes still close
3337
+ // it in openAreaMigrationIfAuthoritative(), and final shutdown releases
3338
+ // every bridge below.
3077
3339
  releaseWatcherJournalStores();
3078
- releaseCachedAreaJournalMigrations();
3079
3340
  const exitCode = passExitCode(result);
3080
3341
  if (exitCode === runtime.authRequiredPassExit) {
3081
3342
  // The pass already emitted auth-error. Hand the sentinel to the
@@ -3110,8 +3371,19 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
3110
3371
  : "") +
3111
3372
  "; staying alive, retrying next poll\n");
3112
3373
  }
3374
+ else if (exitCode === UNRESOLVED_COMPANY_EXIT) {
3375
+ // HQ-SYNC-COMPANY-ROUTE-UID: routed paths are derived from local
3376
+ // company directory names, which may not be memberships. The runner
3377
+ // has already emitted the resolution error; terminating here would
3378
+ // restart the outpost watcher every 60 seconds for each such route.
3379
+ // Keep the watcher alive while preserving the one-shot non-zero code.
3380
+ process.stderr.write("hq-sync-runner: watch pass skipped — unresolved company target; retrying next poll\n");
3381
+ }
3113
3382
  else if (exitCode !== 0) {
3114
- return exitCode;
3383
+ // A scoped company-resolution or per-pass failure is isolated to that
3384
+ // pass. The error was emitted by the runner; terminating the daemon
3385
+ // recreates the startup blind window and strands newer watcher work.
3386
+ process.stderr.write(`hq-sync-runner: watch pass failed (exit=${exitCode}); staying alive, retrying next poll\n`);
3115
3387
  }
3116
3388
  const completedPostRepairPass = !enqueuedFullChore && (typeof result === "number"
3117
3389
  ? exitCode === 0
@@ -3215,6 +3487,10 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
3215
3487
  releaseWatcherJournalStores();
3216
3488
  releaseCachedAreaJournalMigrations();
3217
3489
  await shutdown();
3490
+ // Coordinator shutdown waits for any active pass before the retained
3491
+ // projections are dropped. A shutdown must never evict a store that a
3492
+ // final scoped writer is still refreshing or committing through.
3493
+ disposeScopedJournalStores();
3218
3494
  detachSignal?.();
3219
3495
  }
3220
3496
  }