@indigoai-us/hq-cloud 6.14.13 → 6.14.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.13",
3
+ "version": "6.14.14",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,3 +1,4 @@
1
+ import * as os from "os";
1
2
  import * as path from "path";
2
3
  import { inventoryManagedSnapshots } from "../backup-prune.js";
3
4
  import { VaultClient } from "../index.js";
@@ -40,6 +41,39 @@ import type { Direction, RunnerLoopDeps, WatcherSurface } from "./sync-runner.js
40
41
 
41
42
  const FULL_RECONCILE_EVERY_TICKS = 10;
42
43
 
44
+ /**
45
+ * Adaptive remote-poll backoff bounds (whole-box CPU aware). When
46
+ * `--poll-remote-ms` is NOT passed, the watch loop scales its poll cadence to
47
+ * the host's overall load instead of using a fixed interval: ~60s when the box
48
+ * is idle, stretching toward ~10min under sustained full load, so the periodic
49
+ * pull yields CPU/I-O to whatever else is running (e.g. the user's live agent
50
+ * session). The poll is only a correctness backstop — event-push handles
51
+ * real-time — so a stale interval under load is safe. An explicit
52
+ * `--poll-remote-ms <n>` overrides this entirely and pins a fixed interval.
53
+ */
54
+ const ADAPTIVE_POLL_FLOOR_MS = 60_000;
55
+ const ADAPTIVE_POLL_CEIL_MS = 600_000;
56
+
57
+ /**
58
+ * Map whole-box load to a poll interval in the [floor, ceil] range.
59
+ *
60
+ * `load1` is the 1-minute load average ({@link os.loadavg}[0]); dividing by the
61
+ * CPU count yields per-core saturation. ratio 0 (idle) → floor; ratio ≥ 1
62
+ * (fully saturated) → ceil; linear in between. Pure + injectable so the loop
63
+ * can be unit-tested without a real host or a 10-minute wait.
64
+ */
65
+ export function adaptivePollMs(
66
+ load1: number,
67
+ cpuCount: number,
68
+ floorMs: number = ADAPTIVE_POLL_FLOOR_MS,
69
+ ceilMs: number = ADAPTIVE_POLL_CEIL_MS,
70
+ ): number {
71
+ const cores = cpuCount > 0 ? cpuCount : 1;
72
+ const saturation = load1 > 0 ? load1 / cores : 0;
73
+ const ratio = Math.max(0, Math.min(1, saturation));
74
+ return Math.round(floorMs + ratio * (ceilMs - floorMs));
75
+ }
76
+
43
77
  export interface ParsedLoopArgs {
44
78
  hqRoot: string;
45
79
  lockTimeoutSec?: number;
@@ -95,8 +129,13 @@ export async function runWatchLoop(
95
129
  const runPass =
96
130
  deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
97
131
  const pollIdx = argv.indexOf("--poll-remote-ms");
98
- const pollMs =
99
- pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
132
+ // An explicit --poll-remote-ms pins a fixed interval (menubar/CLI still set
133
+ // one). When it is omitted, the interval is resolved fresh each cycle from
134
+ // whole-box load via adaptivePollMs() — see the sleep at the loop's tail.
135
+ const explicitPollMs =
136
+ pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : undefined;
137
+ const sampleLoadAvg = deps.sampleLoadAvg ?? (() => os.loadavg()[0]);
138
+ const cpuCount = os.cpus().length;
100
139
  const eventPush = argv.includes("--event-push");
101
140
  const companiesMode = argv.includes("--companies");
102
141
  const hqRoot = parsed.hqRoot;
@@ -534,7 +573,9 @@ export async function runWatchLoop(
534
573
  try { await runBackupPrune(hqRoot); }
535
574
  catch (error) { process.stderr.write(`hq-sync-runner: backup retention dry-run failed — ${error instanceof Error ? error.message : String(error)}\n`); }
536
575
  }
537
- await Promise.race([sleep(pollMs), stoppedSignal]);
576
+ const nextPollMs =
577
+ explicitPollMs ?? adaptivePollMs(sampleLoadAvg(), cpuCount);
578
+ await Promise.race([sleep(nextPollMs), stoppedSignal]);
538
579
  }
539
580
  return 0;
540
581
  } finally {
@@ -34,6 +34,7 @@ import type {
34
34
  WatcherSurface,
35
35
  } from "./sync-runner.js";
36
36
  import { FakeClock } from "../watcher.js";
37
+ import { adaptivePollMs } from "./sync-runner-watch-loop.js";
37
38
  import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
38
39
  import { HQ_CLOUD_VERSION } from "../version.js";
39
40
  import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
@@ -3577,6 +3578,127 @@ function makeSteppableSleep() {
3577
3578
  };
3578
3579
  }
3579
3580
 
3581
+ // ---------------------------------------------------------------------------
3582
+ // Adaptive remote-poll backoff — whole-box CPU aware (no --poll-remote-ms)
3583
+ // ---------------------------------------------------------------------------
3584
+
3585
+ describe("adaptivePollMs (whole-box CPU backoff)", () => {
3586
+ const FLOOR = 60_000;
3587
+ const CEIL = 600_000;
3588
+
3589
+ it("returns the floor when the box is idle (load 0)", () => {
3590
+ expect(adaptivePollMs(0, 8)).toBe(FLOOR);
3591
+ });
3592
+
3593
+ it("returns the ceiling when load equals the core count (fully saturated)", () => {
3594
+ expect(adaptivePollMs(8, 8)).toBe(CEIL);
3595
+ });
3596
+
3597
+ it("clamps to the ceiling when load exceeds the core count", () => {
3598
+ expect(adaptivePollMs(64, 8)).toBe(CEIL);
3599
+ });
3600
+
3601
+ it("scales linearly between floor and ceiling at half saturation", () => {
3602
+ // load 4 on 8 cores → ratio 0.5 → midpoint of [60s, 600s] = 330s.
3603
+ expect(adaptivePollMs(4, 8)).toBe(330_000);
3604
+ });
3605
+
3606
+ it("treats a negative load sample as idle (defensive)", () => {
3607
+ expect(adaptivePollMs(-1, 8)).toBe(FLOOR);
3608
+ });
3609
+
3610
+ it("guards a zero/absent core count without dividing by zero", () => {
3611
+ // cores coerced to 1; load 1 → fully saturated → ceil.
3612
+ expect(adaptivePollMs(1, 0)).toBe(CEIL);
3613
+ });
3614
+
3615
+ it("honors custom floor/ceil bounds", () => {
3616
+ expect(adaptivePollMs(0, 4, 30_000, 300_000)).toBe(30_000);
3617
+ expect(adaptivePollMs(4, 4, 30_000, 300_000)).toBe(300_000);
3618
+ });
3619
+ });
3620
+
3621
+ describe("runRunnerWithLoop — adaptive poll interval (no --poll-remote-ms)", () => {
3622
+ // Capture the ms handed to sleep on the FIRST poll, then hang so the loop
3623
+ // parks until shutdown (mirrors the never-resolving sleep the other loop
3624
+ // tests use).
3625
+ function makeCapturingSleep() {
3626
+ const calls: number[] = [];
3627
+ return {
3628
+ calls,
3629
+ sleep: (ms: number) => {
3630
+ calls.push(ms);
3631
+ return new Promise<void>(() => {});
3632
+ },
3633
+ };
3634
+ }
3635
+
3636
+ it("sleeps the floor interval when the box is idle", async () => {
3637
+ const { calls, sleep } = makeCapturingSleep();
3638
+ let triggerShutdown = () => {};
3639
+ const loop = runRunnerWithLoop(
3640
+ ["--companies", "--watch", "--hq-root", "/tmp/hq"],
3641
+ {
3642
+ runPass: vi.fn().mockResolvedValue(0),
3643
+ sleep,
3644
+ sampleLoadAvg: () => 0,
3645
+ onShutdownSignal: (handler) => {
3646
+ triggerShutdown = handler;
3647
+ return () => {};
3648
+ },
3649
+ },
3650
+ );
3651
+ await flushLoopMicrotasks();
3652
+ expect(calls[0]).toBe(60_000);
3653
+ triggerShutdown();
3654
+ await loop;
3655
+ });
3656
+
3657
+ it("backs off to the ceiling interval when the box is fully loaded", async () => {
3658
+ const { calls, sleep } = makeCapturingSleep();
3659
+ let triggerShutdown = () => {};
3660
+ const loop = runRunnerWithLoop(
3661
+ ["--companies", "--watch", "--hq-root", "/tmp/hq"],
3662
+ {
3663
+ runPass: vi.fn().mockResolvedValue(0),
3664
+ sleep,
3665
+ sampleLoadAvg: () => 1_000, // far above any core count → clamps to ceil
3666
+ onShutdownSignal: (handler) => {
3667
+ triggerShutdown = handler;
3668
+ return () => {};
3669
+ },
3670
+ },
3671
+ );
3672
+ await flushLoopMicrotasks();
3673
+ expect(calls[0]).toBe(600_000);
3674
+ triggerShutdown();
3675
+ await loop;
3676
+ });
3677
+
3678
+ it("an explicit --poll-remote-ms pins a fixed interval and ignores load", async () => {
3679
+ const { calls, sleep } = makeCapturingSleep();
3680
+ const sampleLoadAvg = vi.fn(() => 1_000);
3681
+ let triggerShutdown = () => {};
3682
+ const loop = runRunnerWithLoop(
3683
+ ["--companies", "--watch", "--poll-remote-ms", "123456", "--hq-root", "/tmp/hq"],
3684
+ {
3685
+ runPass: vi.fn().mockResolvedValue(0),
3686
+ sleep,
3687
+ sampleLoadAvg,
3688
+ onShutdownSignal: (handler) => {
3689
+ triggerShutdown = handler;
3690
+ return () => {};
3691
+ },
3692
+ },
3693
+ );
3694
+ await flushLoopMicrotasks();
3695
+ expect(calls[0]).toBe(123456);
3696
+ expect(sampleLoadAvg).not.toHaveBeenCalled();
3697
+ triggerShutdown();
3698
+ await loop;
3699
+ });
3700
+ });
3701
+
3580
3702
  describe("runRunnerWithLoop — event-push wiring", () => {
3581
3703
  it("starts the watcher alongside the poll loop when --event-push is on", async () => {
3582
3704
  const watcher = makeWatcherStub();
@@ -1741,6 +1741,13 @@ const isDirectInvocation = (() => {
1741
1741
  export interface RunnerLoopDeps {
1742
1742
  /** Sleep `ms` between passes. Default: host setTimeout. */
1743
1743
  sleep?: (ms: number) => Promise<void>;
1744
+ /**
1745
+ * 1-minute whole-box load average source for the adaptive remote-poll
1746
+ * backoff (used only when `--poll-remote-ms` is omitted). Defaults to
1747
+ * `os.loadavg()[0]`. Tests inject a fixed sample to assert the interval
1748
+ * scales with load without depending on the host's real load.
1749
+ */
1750
+ sampleLoadAvg?: () => number;
1744
1751
  /**
1745
1752
  * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
1746
1753
  * (and the event-push wiring) so the poll loop and the watcher-triggered