@indigoai-us/hq-cloud 6.15.47 → 6.15.49

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.
@@ -14,7 +14,7 @@ import { runRunner, runRunnerWithLoop, resolveDeletePolicy, resolveSkipPersonal,
14
14
  import { PresignObjectIO, S3SdkObjectIO } from "../object-io.js";
15
15
  import { setBandwidthGovernorForTesting } from "../bandwidth.js";
16
16
  import { FakeClock, TreeWatcher, } from "../watcher.js";
17
- import { adaptivePollMs, advanceMonotonicDeadline, journalEntriesForWatcherDelete, shouldQuarantineWatcherDeleteIntents, WATCHER_REDISCOVERY_RETRY_MS, WATCHER_REDISCOVERY_RETRY_MAX_MS, } from "./sync-runner-watch-loop.js";
17
+ import { adaptivePollMs, advanceMonotonicDeadline, DEFAULT_EVENT_BATCH_LIMIT, DEFAULT_FULL_RECONCILE_MS, EVENT_BATCH_LIMIT_ENV, FULL_RECONCILE_MS_ENV, journalEntriesForWatcherDelete, resolveEventBatchLimit, resolveFullReconcileMs, shouldQuarantineWatcherDeleteIntents, WATCHER_REDISCOVERY_RETRY_MS, WATCHER_REDISCOVERY_RETRY_MAX_MS, } from "./sync-runner-watch-loop.js";
18
18
  import { PERSONAL_VAULT_JOURNAL_SLUG, readJournal, writeJournal, } from "../journal.js";
19
19
  import { HQ_CLOUD_VERSION } from "../version.js";
20
20
  import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
@@ -3709,6 +3709,23 @@ describe("advanceMonotonicDeadline", () => {
3709
3709
  });
3710
3710
  });
3711
3711
  });
3712
+ describe("watch-loop reconcile environment resolvers", () => {
3713
+ it("uses the shipped 10,000 event-batch default while allowing smaller and larger overrides", () => {
3714
+ expect(DEFAULT_EVENT_BATCH_LIMIT).toBe(10_000);
3715
+ expect(resolveEventBatchLimit({})).toBe(10_000);
3716
+ expect(resolveEventBatchLimit({ [EVENT_BATCH_LIMIT_ENV]: "5" })).toBe(5);
3717
+ expect(resolveEventBatchLimit({ [EVENT_BATCH_LIMIT_ENV]: "50000" })).toBe(50_000);
3718
+ });
3719
+ it("uses the legacy tick cadence for an unset or invalid full-reconcile interval", () => {
3720
+ expect(resolveFullReconcileMs({})).toBe(DEFAULT_FULL_RECONCILE_MS);
3721
+ for (const raw of ["", "0", "0.5", "-1", "NaN", "not-a-number", "Infinity"]) {
3722
+ expect(resolveFullReconcileMs({ [FULL_RECONCILE_MS_ENV]: raw })).toBe(DEFAULT_FULL_RECONCILE_MS);
3723
+ }
3724
+ });
3725
+ it("accepts a positive millisecond full-reconcile interval", () => {
3726
+ expect(resolveFullReconcileMs({ [FULL_RECONCILE_MS_ENV]: "10800000" })).toBe(10_800_000);
3727
+ });
3728
+ });
3712
3729
  describe("runRunnerWithLoop — adaptive poll interval (no --poll-remote-ms)", () => {
3713
3730
  // Capture the ms handed to sleep on the FIRST poll, then hang so the loop
3714
3731
  // parks until shutdown (mirrors the never-resolving sleep the other loop
@@ -3836,6 +3853,56 @@ describe("runRunnerWithLoop — adaptive poll interval (no --poll-remote-ms)", (
3836
3853
  });
3837
3854
  });
3838
3855
  describe("runRunnerWithLoop — event-push wiring", () => {
3856
+ it("keeps a healthy watcher through repeated poll cycles and the periodic full reconcile", async () => {
3857
+ const sleep = makeSteppableSleep();
3858
+ const watchers = [];
3859
+ let disposeCalls = 0;
3860
+ const createWatcher = vi.fn(() => {
3861
+ const watcher = makeWatcherStub();
3862
+ watcher.isWatching = () => true;
3863
+ watcher.watchedPathCount = () => 16_609;
3864
+ const dispose = watcher.dispose.bind(watcher);
3865
+ watcher.dispose = () => {
3866
+ disposeCalls += 1;
3867
+ dispose();
3868
+ };
3869
+ watchers.push(watcher);
3870
+ return watcher;
3871
+ });
3872
+ const runPass = vi.fn().mockResolvedValue(completedPassOutcome());
3873
+ let triggerShutdown = () => { };
3874
+ const loop = runRunnerWithLoop([
3875
+ "--companies",
3876
+ "--watch",
3877
+ "--event-push",
3878
+ "--poll-remote-ms",
3879
+ "60000",
3880
+ "--hq-root",
3881
+ "/tmp/hq",
3882
+ ], {
3883
+ runPass,
3884
+ createWatcher,
3885
+ sleep: sleep.sleep,
3886
+ onShutdownSignal: (handler) => {
3887
+ triggerShutdown = handler;
3888
+ return () => { };
3889
+ },
3890
+ });
3891
+ await flushLoopMicrotasks();
3892
+ for (let i = 0; i < 9; i++) {
3893
+ sleep.tick();
3894
+ await flushLoopMicrotasks();
3895
+ }
3896
+ // Tick 1 and tick 10 are full reconciles; all ten completed poll cycles
3897
+ // must leave the healthy watcher in place.
3898
+ expect(runPass).toHaveBeenCalledTimes(10);
3899
+ expect(createWatcher).toHaveBeenCalledTimes(1);
3900
+ expect(watchers).toHaveLength(1);
3901
+ expect(disposeCalls).toBe(0);
3902
+ triggerShutdown();
3903
+ await loop;
3904
+ expect(disposeCalls).toBe(1);
3905
+ });
3839
3906
  it("activates the watcher after a successful numeric --companies pass", async () => {
3840
3907
  const watcher = makeWatcherStub();
3841
3908
  let triggerShutdown = () => { };
@@ -4045,6 +4112,8 @@ describe("runRunnerWithLoop — event-push wiring", () => {
4045
4112
  expect(stderrWrite.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain("event-push watcher degraded; stopped; continuing cadence-only: initial_discovery_timeout");
4046
4113
  await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MS);
4047
4114
  expect(attempts).toBe(2);
4115
+ expect(stderrWrite.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain("watcher-replacement reason=initial_discovery_timeout " +
4116
+ "previous-watcher-id=1 next-watcher-id=2");
4048
4117
  await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MS * 2 - 1);
4049
4118
  expect(attempts).toBe(2);
4050
4119
  await vi.advanceTimersByTimeAsync(1);
@@ -4068,6 +4137,74 @@ describe("runRunnerWithLoop — event-push wiring", () => {
4068
4137
  vi.useRealTimers();
4069
4138
  }
4070
4139
  });
4140
+ it("re-arms exponential backoff when replacement watcher discovery throws", async () => {
4141
+ vi.useFakeTimers();
4142
+ const stderrWrite = vi
4143
+ .spyOn(process.stderr, "write")
4144
+ .mockImplementation(() => true);
4145
+ let attempts = 0;
4146
+ let triggerShutdown = () => { };
4147
+ const createWatcher = vi.fn((options) => {
4148
+ attempts += 1;
4149
+ if (attempts > 1 && attempts < 7) {
4150
+ throw new Error("transient replacement watcher failure");
4151
+ }
4152
+ const watcher = makeWatcherStub();
4153
+ watcher.isWatching = () => attempts === 7;
4154
+ watcher.watchedPathCount = () => 9_220;
4155
+ watcher.watchMode = () => "chokidar";
4156
+ if (attempts === 1) {
4157
+ watcher.start = () => {
4158
+ watcher.started = true;
4159
+ options.onDegraded?.({
4160
+ reason: "initial_discovery_timeout",
4161
+ watchedPaths: 9_220,
4162
+ maxWatchedPaths: 500_000,
4163
+ action: "stopped",
4164
+ });
4165
+ };
4166
+ }
4167
+ return watcher;
4168
+ });
4169
+ const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"], {
4170
+ runPass: vi.fn().mockResolvedValue(completedPassOutcome()),
4171
+ createWatcher,
4172
+ idleHeartbeatIntervalMs: WATCHER_REDISCOVERY_RETRY_MS,
4173
+ sleep: () => new Promise(() => { }),
4174
+ onShutdownSignal: (handler) => {
4175
+ triggerShutdown = handler;
4176
+ return () => { };
4177
+ },
4178
+ });
4179
+ try {
4180
+ await flushLoopMicrotasks();
4181
+ expect(attempts).toBe(1);
4182
+ await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MS);
4183
+ expect(attempts).toBe(2);
4184
+ await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MS * 2 - 1);
4185
+ expect(attempts).toBe(2);
4186
+ await vi.advanceTimersByTimeAsync(1);
4187
+ expect(attempts).toBe(3);
4188
+ await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MS * 4);
4189
+ expect(attempts).toBe(4);
4190
+ await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MS * 8);
4191
+ expect(attempts).toBe(5);
4192
+ await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MAX_MS);
4193
+ expect(attempts).toBe(6);
4194
+ // Repeated replacement failures cap at one hour rather than retrying at
4195
+ // a fixed short interval. The next attempt recovers event push.
4196
+ await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MAX_MS);
4197
+ expect(attempts).toBe(7);
4198
+ await vi.advanceTimersByTimeAsync(WATCHER_REDISCOVERY_RETRY_MAX_MS);
4199
+ expect(createWatcher).toHaveBeenCalledTimes(7);
4200
+ }
4201
+ finally {
4202
+ triggerShutdown();
4203
+ await loop;
4204
+ stderrWrite.mockRestore();
4205
+ vi.useRealTimers();
4206
+ }
4207
+ });
4071
4208
  it("reports current watcher liveness after startup", async () => {
4072
4209
  vi.useFakeTimers();
4073
4210
  const watcher = makeWatcherStub();
@@ -5325,6 +5462,125 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
5325
5462
  sleep.tick();
5326
5463
  await loop;
5327
5464
  });
5465
+ it("keeps the legacy every-tenth-tick full-reconcile cadence when the interval env is unset", async () => {
5466
+ const saved = process.env.HQ_SYNC_FULL_RECONCILE_MS;
5467
+ delete process.env.HQ_SYNC_FULL_RECONCILE_MS;
5468
+ try {
5469
+ const watcher = makeBatchWatcherStub();
5470
+ const sleep = makeSteppableSleep();
5471
+ let triggerShutdown = () => { };
5472
+ const runPass = vi.fn().mockResolvedValue(completedPassOutcome());
5473
+ const loop = runRunnerWithLoop(watchArgv, {
5474
+ runPass,
5475
+ clock: new FakeClock(),
5476
+ createWatcher: () => watcher,
5477
+ sleep: sleep.sleep,
5478
+ onShutdownSignal: (handler) => {
5479
+ triggerShutdown = handler;
5480
+ return () => { };
5481
+ },
5482
+ });
5483
+ await flushLoopMicrotasks();
5484
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
5485
+ runPass.mockClear();
5486
+ for (let tick = 0; tick < 8; tick++) {
5487
+ sleep.tick();
5488
+ await flushLoopMicrotasks();
5489
+ }
5490
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual(Array.from({ length: 8 }, () => fullPullArgv));
5491
+ runPass.mockClear();
5492
+ sleep.tick();
5493
+ await flushLoopMicrotasks();
5494
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
5495
+ triggerShutdown();
5496
+ await loop;
5497
+ }
5498
+ finally {
5499
+ if (saved === undefined)
5500
+ delete process.env.HQ_SYNC_FULL_RECONCILE_MS;
5501
+ else
5502
+ process.env.HQ_SYNC_FULL_RECONCILE_MS = saved;
5503
+ }
5504
+ });
5505
+ it("uses the configured elapsed-time interval instead of the tick count", async () => {
5506
+ const saved = process.env.HQ_SYNC_FULL_RECONCILE_MS;
5507
+ process.env.HQ_SYNC_FULL_RECONCILE_MS = "1000";
5508
+ try {
5509
+ const clockState = { now: 0 };
5510
+ const watcher = makeBatchWatcherStub();
5511
+ const sleep = makeSteppableSleep();
5512
+ let triggerShutdown = () => { };
5513
+ const runPass = vi.fn().mockResolvedValue(completedPassOutcome());
5514
+ const loop = runRunnerWithLoop(watchArgv, {
5515
+ runPass,
5516
+ clock: new FakeClock(),
5517
+ monotonicNow: () => clockState.now,
5518
+ createWatcher: () => watcher,
5519
+ sleep: sleep.sleep,
5520
+ onShutdownSignal: (handler) => {
5521
+ triggerShutdown = handler;
5522
+ return () => { };
5523
+ },
5524
+ });
5525
+ await flushLoopMicrotasks();
5526
+ // Startup always forces the first full reconcile, and starts the interval.
5527
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
5528
+ runPass.mockClear();
5529
+ clockState.now = 999;
5530
+ sleep.tick();
5531
+ await flushLoopMicrotasks();
5532
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullPullArgv]);
5533
+ runPass.mockClear();
5534
+ clockState.now = 1_000;
5535
+ sleep.tick();
5536
+ await flushLoopMicrotasks();
5537
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
5538
+ triggerShutdown();
5539
+ await loop;
5540
+ }
5541
+ finally {
5542
+ if (saved === undefined)
5543
+ delete process.env.HQ_SYNC_FULL_RECONCILE_MS;
5544
+ else
5545
+ process.env.HQ_SYNC_FULL_RECONCILE_MS = saved;
5546
+ }
5547
+ });
5548
+ it("does not let a full-reconcile interval suppress event-push activation", async () => {
5549
+ const saved = process.env.HQ_SYNC_FULL_RECONCILE_MS;
5550
+ process.env.HQ_SYNC_FULL_RECONCILE_MS = "10800000";
5551
+ try {
5552
+ const watcher = makeBatchWatcherStub();
5553
+ const sleep = makeSteppableSleep();
5554
+ let triggerShutdown = () => { };
5555
+ // A numeric partial result does not activate event-push surfaces, so the
5556
+ // next tick must force another full pass even though three hours have not elapsed.
5557
+ const runPass = vi.fn().mockResolvedValue(PARTIAL_SYNC_EXIT);
5558
+ const loop = runRunnerWithLoop(watchArgv, {
5559
+ runPass,
5560
+ clock: new FakeClock(),
5561
+ monotonicNow: () => 0,
5562
+ createWatcher: () => watcher,
5563
+ sleep: sleep.sleep,
5564
+ onShutdownSignal: (handler) => {
5565
+ triggerShutdown = handler;
5566
+ return () => { };
5567
+ },
5568
+ });
5569
+ await flushLoopMicrotasks();
5570
+ runPass.mockClear();
5571
+ sleep.tick();
5572
+ await flushLoopMicrotasks();
5573
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
5574
+ triggerShutdown();
5575
+ await loop;
5576
+ }
5577
+ finally {
5578
+ if (saved === undefined)
5579
+ delete process.env.HQ_SYNC_FULL_RECONCILE_MS;
5580
+ else
5581
+ process.env.HQ_SYNC_FULL_RECONCILE_MS = saved;
5582
+ }
5583
+ });
5328
5584
  it("overflow with known dropped-route hints fans out per touched route instead of a full reconcile", async () => {
5329
5585
  const watcher = makeBatchWatcherStub();
5330
5586
  const sleep = makeSteppableSleep();
@@ -5410,7 +5666,7 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
5410
5666
  process.env.HQ_SYNC_EVENT_BATCH_LIMIT = saved;
5411
5667
  }
5412
5668
  });
5413
- it("a batch under the default limit stays on the targeted path", async () => {
5669
+ it("a 5,000-path batch under the shipped default stays on the targeted path", async () => {
5414
5670
  const watcher = makeBatchWatcherStub();
5415
5671
  const sleep = makeSteppableSleep();
5416
5672
  let triggerShutdown = () => { };
@@ -5428,33 +5684,67 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
5428
5684
  await flushLoopMicrotasks();
5429
5685
  runPass.mockClear();
5430
5686
  watcher.emit("companies/indigo/a.md", {
5431
- paths: new Map([
5432
- ["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"],
5433
- ["/tmp/hq/companies/indigo/b.md", "companies/indigo/b.md"],
5434
- ["/tmp/hq/companies/indigo/c.md", "companies/indigo/c.md"],
5435
- ]),
5687
+ paths: new Map(Array.from({ length: 5_000 }, (_, index) => [
5688
+ `/tmp/hq/companies/indigo/${index}.md`,
5689
+ `companies/indigo/${index}.md`,
5690
+ ])),
5436
5691
  });
5437
5692
  sleep.tick();
5438
5693
  await flushLoopMicrotasks();
5439
5694
  const calls = runPass.mock.calls.map((call) => call[0]);
5440
5695
  expect(calls).not.toContainEqual(fullArgv);
5441
- expect(calls[0]).toEqual([
5696
+ expect(calls).toHaveLength(2);
5697
+ expect(calls[0]?.slice(0, 4)).toEqual([
5442
5698
  "--company",
5443
5699
  "indigo",
5444
5700
  "--direction",
5445
5701
  "push",
5446
- "--scope-path",
5447
- "a.md",
5448
- "--scope-path",
5449
- "b.md",
5450
- "--scope-path",
5451
- "c.md",
5452
- "--hq-root",
5453
- "/tmp/hq",
5454
5702
  ]);
5703
+ expect(calls[0]?.filter((arg) => arg === "--scope-path")).toHaveLength(5_000);
5704
+ expect(calls[1]?.filter((arg) => arg === "--scope-path")).toHaveLength(5_000);
5455
5705
  triggerShutdown();
5456
5706
  await loop;
5457
5707
  });
5708
+ it("a batch above the shipped 10,000-path default forces a full reconcile before the interval", async () => {
5709
+ const saved = process.env.HQ_SYNC_FULL_RECONCILE_MS;
5710
+ process.env.HQ_SYNC_FULL_RECONCILE_MS = "10800000";
5711
+ try {
5712
+ const watcher = makeBatchWatcherStub();
5713
+ const sleep = makeSteppableSleep();
5714
+ let triggerShutdown = () => { };
5715
+ const runPass = vi.fn().mockResolvedValue(completedPassOutcome());
5716
+ const loop = runRunnerWithLoop(watchArgv, {
5717
+ runPass,
5718
+ clock: new FakeClock(),
5719
+ monotonicNow: () => 0,
5720
+ createWatcher: () => watcher,
5721
+ sleep: sleep.sleep,
5722
+ onShutdownSignal: (handler) => {
5723
+ triggerShutdown = handler;
5724
+ return () => { };
5725
+ },
5726
+ });
5727
+ await flushLoopMicrotasks();
5728
+ runPass.mockClear();
5729
+ watcher.emit("companies/indigo/a.md", {
5730
+ paths: new Map(Array.from({ length: 10_001 }, (_, index) => [
5731
+ `/tmp/hq/companies/indigo/${index}.md`,
5732
+ `companies/indigo/${index}.md`,
5733
+ ])),
5734
+ });
5735
+ sleep.tick();
5736
+ await flushLoopMicrotasks();
5737
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
5738
+ triggerShutdown();
5739
+ await loop;
5740
+ }
5741
+ finally {
5742
+ if (saved === undefined)
5743
+ delete process.env.HQ_SYNC_FULL_RECONCILE_MS;
5744
+ else
5745
+ process.env.HQ_SYNC_FULL_RECONCILE_MS = saved;
5746
+ }
5747
+ });
5458
5748
  it("a throwing targeted pass falls back to one full reconcile instead of dropping the change", async () => {
5459
5749
  const runPass = vi.fn(async (argv) => {
5460
5750
  if (argv.includes("pull") && argv.includes("--scope-path")) {