@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/dist/bin/sync-runner-watch-loop.d.ts +9 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +35 -2
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +7 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +100 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +44 -3
- package/src/bin/sync-runner.test.ts +122 -0
- package/src/bin/sync-runner.ts +7 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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();
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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
|