@indigoai-us/hq-cli 5.345.202 → 5.345.204
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/CHANGELOG.md +18 -0
- package/dist/command-catalog.generated.d.ts +24 -0
- package/dist/command-catalog.generated.js +26 -0
- package/dist/commands/bot.js +7 -2
- package/dist/commands/capacity.js +10 -2
- package/dist/commands/cloud.d.ts +14 -2
- package/dist/commands/cloud.js +25 -16
- package/dist/commands/daemon.d.ts +2 -1
- package/dist/commands/daemon.js +225 -4
- package/dist/commands/integrations-core.d.ts +3 -0
- package/dist/commands/integrations-core.js +11 -0
- package/dist/commands/integrations-manage.js +2 -2
- package/dist/commands/integrations.js +5 -5
- package/dist/commands/sync-core.js +4 -1
- package/dist/lib/bot/claude-login.d.ts +7 -1
- package/dist/lib/bot/claude-login.js +56 -16
- package/dist/lib/bot/executive-settings.d.ts +1 -1
- package/dist/lib/bot/executive-settings.js +8 -6
- package/dist/lib/bot/jobs/meeting-detect.js +2 -2
- package/dist/lib/bot/jobs/meeting-rider.js +2 -2
- package/dist/lib/bot/run.js +3 -3
- package/dist/lib/capacity/codex-availability.d.ts +6 -0
- package/dist/lib/capacity/codex-availability.js +33 -0
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +53 -5
- package/dist/lib/capacity/collectors/claude-oauth.js +251 -71
- package/dist/lib/capacity/ledger.d.ts +0 -11
- package/dist/lib/capacity/ledger.js +30 -3
- package/dist/lib/capacity/route.js +11 -5
- package/dist/lib/daemon/catalog.d.ts +3 -0
- package/dist/lib/daemon/catalog.js +17 -4
- package/dist/lib/daemon/handoff.d.ts +2 -0
- package/dist/lib/daemon/handoff.js +9 -2
- package/dist/lib/daemon/install.d.ts +99 -0
- package/dist/lib/daemon/install.js +316 -7
- package/dist/lib/daemon/migrate.d.ts +3 -1
- package/dist/lib/daemon/migrate.js +14 -2
- package/dist/lib/daemon/plist-refresh.d.ts +34 -0
- package/dist/lib/daemon/plist-refresh.js +62 -0
- package/dist/lib/daemon/run.js +27 -2
- package/dist/lib/daemon/selfcheck.d.ts +71 -0
- package/dist/lib/daemon/selfcheck.js +150 -0
- package/dist/lib/daemon/supervisor.d.ts +31 -0
- package/dist/lib/daemon/supervisor.js +130 -6
- package/dist/lib/daemon/unit-holds.js +2 -1
- package/dist/lib/lanes/config.js +13 -0
- package/dist/lib/lanes/throttle.d.ts +12 -1
- package/dist/lib/lanes/throttle.js +27 -8
- package/dist/lib/lanes/types.d.ts +5 -0
- package/dist/lib/service-manager/launchd.d.ts +25 -0
- package/dist/lib/service-manager/launchd.js +51 -3
- package/dist/lib/service-manager/migration.d.ts +14 -0
- package/dist/lib/service-manager/migration.js +34 -0
- package/dist/lib/service-manager/systemd.d.ts +13 -0
- package/dist/lib/service-manager/systemd.js +19 -0
- package/dist/lib/service-manager/types.d.ts +8 -0
- package/dist/lib/signals/steps/extract.d.ts +110 -4
- package/dist/lib/signals/steps/extract.js +520 -39
- package/dist/lib/sync-core/engine-home.d.ts +2 -1
- package/dist/lib/sync-core/engine-home.js +25 -8
- package/dist/lib/sync-core/reconcile.d.ts +7 -2
- package/dist/lib/sync-core/reconcile.js +32 -9
- package/dist/lib/sync-core/release.d.ts +35 -3
- package/dist/lib/sync-core/release.js +95 -12
- package/package.json +7 -7
package/dist/lib/daemon/run.js
CHANGED
|
@@ -36,6 +36,7 @@ import { createLanesReconcileWatcher } from "./lanes-reconcile-watcher.js";
|
|
|
36
36
|
import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup, } from "./legacy.js";
|
|
37
37
|
import { readLegacySyncEnv } from "./legacy-sync-env.js";
|
|
38
38
|
import { readVaultSyncSwitches, unreadableSwitchesWarning, writeVaultSyncSwitches } from "../vault-sync-switches.js";
|
|
39
|
+
import { scheduleDaemonPlistRefresh } from "./plist-refresh.js";
|
|
39
40
|
import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
|
|
40
41
|
import { retireLegacyMeshUnits } from "./migrate.js";
|
|
41
42
|
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
@@ -81,7 +82,7 @@ export function syncCoreVaultPlans(home, hqRoot, platform) {
|
|
|
81
82
|
release: state.release,
|
|
82
83
|
...(vault.legacyOffSince ? { legacyOffSince: vault.legacyOffSince } : {}),
|
|
83
84
|
}, platform);
|
|
84
|
-
const releaseHold = vault.phase === "full" ? fullModeHold(vault, state.release) : undefined;
|
|
85
|
+
const releaseHold = vault.phase === "full" ? fullModeHold(vault, state.release, platform) : undefined;
|
|
85
86
|
return releaseHold ? { ...plan, blockedReason: releaseHold } : plan;
|
|
86
87
|
});
|
|
87
88
|
}
|
|
@@ -114,7 +115,7 @@ export function planUnitsWithSyncCoreInterlock(services, context, supervisor) {
|
|
|
114
115
|
let restorePersonal = false;
|
|
115
116
|
const restoreCompanies = {};
|
|
116
117
|
for (const vault of context.syncCoreVaults ?? []) {
|
|
117
|
-
if (vault.phase !== "full" || runningVaults.has(vault.uid) || !fullModeHold(vault, vault.release))
|
|
118
|
+
if (vault.phase !== "full" || runningVaults.has(vault.uid) || !fullModeHold(vault, vault.release, context.platform))
|
|
118
119
|
continue;
|
|
119
120
|
if (vault.kind === "personal") {
|
|
120
121
|
restorePersonal = personalOff;
|
|
@@ -658,6 +659,21 @@ export async function runDaemon(opts) {
|
|
|
658
659
|
}
|
|
659
660
|
const hostNote = opts.host ? `, hosted by the ${opts.host} app` : "";
|
|
660
661
|
log("info", `starting hq daemon (pid ${process.pid}) as ${machineType} (${detected.detail})${hostNote}`);
|
|
662
|
+
try {
|
|
663
|
+
scheduleDaemonPlistRefresh({
|
|
664
|
+
platform,
|
|
665
|
+
managedByLaunchd: opts.managed === true && opts.host === undefined,
|
|
666
|
+
home,
|
|
667
|
+
daemonRoot: paths.root,
|
|
668
|
+
nodeBinary: opts.nodeBinary,
|
|
669
|
+
hqBinary: opts.hqBinary,
|
|
670
|
+
env,
|
|
671
|
+
log,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
log("warn", `could not check the daemon LaunchAgent: ${err instanceof Error ? err.message : String(err)}`);
|
|
676
|
+
}
|
|
661
677
|
// On Linux, services can outlive a daemon (KillMode=process). Adopt the ones
|
|
662
678
|
// the previous daemon handed over and stop any others it left behind before
|
|
663
679
|
// starting anything, so a service never runs twice.
|
|
@@ -1264,6 +1280,15 @@ export async function runDaemon(opts) {
|
|
|
1264
1280
|
read: () => readUnitHolds(paths.holdsPath),
|
|
1265
1281
|
write: (holds) => writeUnitHolds(paths.holdsPath, holds),
|
|
1266
1282
|
},
|
|
1283
|
+
pidAlive: (pid) => {
|
|
1284
|
+
try {
|
|
1285
|
+
process.kill(pid, 0);
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
catch (err) {
|
|
1289
|
+
return err.code === "EPERM";
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1267
1292
|
exitOutputTail: (unitId) => {
|
|
1268
1293
|
// An adopted unit was started by an earlier daemon; its output start is unknown.
|
|
1269
1294
|
const offset = unitLogOffsets.get(unitId);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's sync-engine self-check (`hq daemon service sync-core-selfcheck`).
|
|
3
|
+
*
|
|
4
|
+
* It reads the supervisor's state and holds and finds engine units
|
|
5
|
+
* (`sync-core:<vault>`) that have been held, or failing without a heartbeat,
|
|
6
|
+
* for more than ATTENTION_AFTER_MS. When there are any, it sends one DM to the
|
|
7
|
+
* person signed in on this machine, at most once a day per machine whichever
|
|
8
|
+
* units are affected, naming every affected vault in that one DM. The 16-hour outage of 2026-11-03 was
|
|
9
|
+
* found by hand; this makes the next one reach the owner.
|
|
10
|
+
*/
|
|
11
|
+
import type { DaemonState, PersistedHolds } from "./supervisor.js";
|
|
12
|
+
/** An engine unit that has been held or failing this long needs a person. */
|
|
13
|
+
export declare const ATTENTION_AFTER_MS: number;
|
|
14
|
+
/** This machine sends at most one self-check DM per this long. */
|
|
15
|
+
export declare const REPORT_INTERVAL_MS: number;
|
|
16
|
+
export interface EngineUnitProblem {
|
|
17
|
+
unitId: string;
|
|
18
|
+
/** Epoch ms since when the unit has been held or failing. */
|
|
19
|
+
since: number;
|
|
20
|
+
/** One line for the DM. */
|
|
21
|
+
what: string;
|
|
22
|
+
}
|
|
23
|
+
/** Engine units held, or failing without a heartbeat, for more than ATTENTION_AFTER_MS at `now`. */
|
|
24
|
+
export declare function engineUnitsNeedingAttention(state: DaemonState | null, holds: PersistedHolds, now: number,
|
|
25
|
+
/** False for a vault that is skipped on this machine or not on the rollout. */
|
|
26
|
+
wanted?: (vaultUid: string) => boolean): EngineUnitProblem[];
|
|
27
|
+
export interface SelfCheckRecord {
|
|
28
|
+
lastSentAt: number;
|
|
29
|
+
/** Sorted unit ids last reported (for the log; the rate limit ignores them). */
|
|
30
|
+
units: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare function readSelfCheckRecord(file: string): SelfCheckRecord | null;
|
|
33
|
+
export declare function writeSelfCheckRecord(file: string, record: SelfCheckRecord): void;
|
|
34
|
+
/**
|
|
35
|
+
* Send now: something is wrong and this machine has not sent a self-check DM
|
|
36
|
+
* in the last day. Which units are affected does not matter, so units that
|
|
37
|
+
* take turns failing cannot send a DM on every change.
|
|
38
|
+
*/
|
|
39
|
+
export declare function shouldReport(problems: EngineUnitProblem[], last: SelfCheckRecord | null, now: number): boolean;
|
|
40
|
+
/** The DM text for the owner. Plain, with the commands that fix each case. */
|
|
41
|
+
export declare function selfCheckMessage(problems: EngineUnitProblem[], machine: string,
|
|
42
|
+
/** A name for a vault (company slug, "personal"), when known. */
|
|
43
|
+
vaultName?: (vaultUid: string) => string | undefined): {
|
|
44
|
+
body: string;
|
|
45
|
+
details: string;
|
|
46
|
+
};
|
|
47
|
+
export interface SelfCheckDeps {
|
|
48
|
+
statePath: string;
|
|
49
|
+
holdsPath: string;
|
|
50
|
+
recordPath: string;
|
|
51
|
+
now: () => number;
|
|
52
|
+
machine: string;
|
|
53
|
+
readState: () => DaemonState | null;
|
|
54
|
+
readHolds: () => PersistedHolds;
|
|
55
|
+
/** False for a vault that is skipped here or not on the rollout. Default: every vault. */
|
|
56
|
+
wanted?: (vaultUid: string) => boolean;
|
|
57
|
+
/** A name for a vault, for the DM. */
|
|
58
|
+
vaultName?: (vaultUid: string) => string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Send the DM to the person signed in on this machine; resolves to who it
|
|
61
|
+
* went to, or null when no person is signed in (an agent identity).
|
|
62
|
+
*/
|
|
63
|
+
sendToOwner: (message: {
|
|
64
|
+
body: string;
|
|
65
|
+
details: string;
|
|
66
|
+
}) => Promise<string | null>;
|
|
67
|
+
log: (line: string) => void;
|
|
68
|
+
}
|
|
69
|
+
export type SelfCheckOutcome = "healthy" | "reported" | "already-reported" | "no-owner";
|
|
70
|
+
export declare function runSelfCheck(deps: SelfCheckDeps): Promise<SelfCheckOutcome>;
|
|
71
|
+
//# sourceMappingURL=selfcheck.d.ts.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's sync-engine self-check (`hq daemon service sync-core-selfcheck`).
|
|
3
|
+
*
|
|
4
|
+
* It reads the supervisor's state and holds and finds engine units
|
|
5
|
+
* (`sync-core:<vault>`) that have been held, or failing without a heartbeat,
|
|
6
|
+
* for more than ATTENTION_AFTER_MS. When there are any, it sends one DM to the
|
|
7
|
+
* person signed in on this machine, at most once a day per machine whichever
|
|
8
|
+
* units are affected, naming every affected vault in that one DM. The 16-hour outage of 2026-11-03 was
|
|
9
|
+
* found by hand; this makes the next one reach the owner.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { syncCoreUnitId } from "../sync-core/engine-home.js";
|
|
14
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
15
|
+
/** An engine unit that has been held or failing this long needs a person. */
|
|
16
|
+
export const ATTENTION_AFTER_MS = 30 * 60_000;
|
|
17
|
+
/** This machine sends at most one self-check DM per this long. */
|
|
18
|
+
export const REPORT_INTERVAL_MS = 24 * 60 * 60_000;
|
|
19
|
+
const ENGINE_UNIT_PREFIX = syncCoreUnitId("");
|
|
20
|
+
/** Engine units held, or failing without a heartbeat, for more than ATTENTION_AFTER_MS at `now`. */
|
|
21
|
+
export function engineUnitsNeedingAttention(state, holds, now,
|
|
22
|
+
/** False for a vault that is skipped on this machine or not on the rollout. */
|
|
23
|
+
wanted = () => true) {
|
|
24
|
+
const problems = [];
|
|
25
|
+
const units = state?.units ?? [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const unit of units) {
|
|
28
|
+
if (!unit.id.startsWith(ENGINE_UNIT_PREFIX))
|
|
29
|
+
continue;
|
|
30
|
+
if (!wanted(unit.id.slice(ENGINE_UNIT_PREFIX.length)))
|
|
31
|
+
continue;
|
|
32
|
+
seen.add(unit.id);
|
|
33
|
+
const problem = unitProblem(unit, holds[unit.id], state, now);
|
|
34
|
+
if (problem && now - problem.since > ATTENTION_AFTER_MS)
|
|
35
|
+
problems.push(problem);
|
|
36
|
+
}
|
|
37
|
+
// With no daemon state, the persisted holds are all there is to go on. With
|
|
38
|
+
// state, a hold for a unit the daemon does not supervise is left over from a
|
|
39
|
+
// vault that is no longer on the engine: it stops nothing, so it is not reported.
|
|
40
|
+
if (state)
|
|
41
|
+
return problems.sort((a, b) => a.unitId.localeCompare(b.unitId));
|
|
42
|
+
for (const [unitId, hold] of Object.entries(holds)) {
|
|
43
|
+
if (seen.has(unitId) || !unitId.startsWith(ENGINE_UNIT_PREFIX) || hold.retryStopped === true)
|
|
44
|
+
continue;
|
|
45
|
+
if (!wanted(unitId.slice(ENGINE_UNIT_PREFIX.length)))
|
|
46
|
+
continue;
|
|
47
|
+
if (now - hold.since > ATTENTION_AFTER_MS) {
|
|
48
|
+
problems.push({ unitId, since: hold.since, what: `held since ${iso(hold.since)}: ${hold.detail}` });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return problems.sort((a, b) => a.unitId.localeCompare(b.unitId));
|
|
52
|
+
}
|
|
53
|
+
function unitProblem(unit, hold, state, now) {
|
|
54
|
+
// Stopped on request (`hq daemon stop`, or a stopped recovery probe) is not failing.
|
|
55
|
+
if (unit.hold?.retryStopped === true || hold?.retryStopped === true)
|
|
56
|
+
return null;
|
|
57
|
+
if (unit.status === "held") {
|
|
58
|
+
const since = unit.hold?.since ?? hold?.since ?? now;
|
|
59
|
+
const detail = unit.hold?.detail ?? hold?.detail ?? unit.reason ?? "held";
|
|
60
|
+
return { unitId: unit.id, since, what: `held since ${iso(since)}: ${detail}` };
|
|
61
|
+
}
|
|
62
|
+
// Paused on purpose (sync paused, waiting for the legacy sync to hand over) is not failing.
|
|
63
|
+
if (unit.status !== "backoff" && unit.status !== "exited" && unit.status !== "running")
|
|
64
|
+
return null;
|
|
65
|
+
const last = unit.lastHeartbeatAt ? Date.parse(unit.lastHeartbeatAt) : Number.NaN;
|
|
66
|
+
// No heartbeat yet: count from when the daemon started supervising it.
|
|
67
|
+
const since = Number.isFinite(last) ? last : state.startedAt;
|
|
68
|
+
if (unit.status === "running" && unit.restarts === 0)
|
|
69
|
+
return null;
|
|
70
|
+
if (unit.status === "running" && Number.isFinite(last) && now - last <= ATTENTION_AFTER_MS)
|
|
71
|
+
return null;
|
|
72
|
+
const exit = unit.lastExit ? ` (last exit code ${unit.lastExit.code ?? "none"}${unit.lastExit.signal ? `, signal ${unit.lastExit.signal}` : ""})` : "";
|
|
73
|
+
return {
|
|
74
|
+
unitId: unit.id,
|
|
75
|
+
since,
|
|
76
|
+
what: `${unit.status === "running" ? "restarting" : "failing"} with no heartbeat since ${iso(since)}, restarted ${unit.restarts} times${exit}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function iso(ms) {
|
|
80
|
+
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
81
|
+
}
|
|
82
|
+
export function readSelfCheckRecord(file) {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
85
|
+
if (typeof parsed.lastSentAt !== "number" || !Array.isArray(parsed.units))
|
|
86
|
+
return null;
|
|
87
|
+
return { lastSentAt: parsed.lastSentAt, units: parsed.units.filter((u) => typeof u === "string") };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function writeSelfCheckRecord(file, record) {
|
|
94
|
+
ensurePrivateDir(path.dirname(file));
|
|
95
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
96
|
+
fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
97
|
+
fs.renameSync(tmp, file);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Send now: something is wrong and this machine has not sent a self-check DM
|
|
101
|
+
* in the last day. Which units are affected does not matter, so units that
|
|
102
|
+
* take turns failing cannot send a DM on every change.
|
|
103
|
+
*/
|
|
104
|
+
export function shouldReport(problems, last, now) {
|
|
105
|
+
if (problems.length === 0)
|
|
106
|
+
return false;
|
|
107
|
+
if (!last)
|
|
108
|
+
return true;
|
|
109
|
+
return now - last.lastSentAt >= REPORT_INTERVAL_MS;
|
|
110
|
+
}
|
|
111
|
+
/** The DM text for the owner. Plain, with the commands that fix each case. */
|
|
112
|
+
export function selfCheckMessage(problems, machine,
|
|
113
|
+
/** A name for a vault (company slug, "personal"), when known. */
|
|
114
|
+
vaultName = () => undefined) {
|
|
115
|
+
const named = (p) => {
|
|
116
|
+
const uid = p.unitId.slice(ENGINE_UNIT_PREFIX.length);
|
|
117
|
+
const name = vaultName(uid);
|
|
118
|
+
return name ? `${name} (${uid})` : uid;
|
|
119
|
+
};
|
|
120
|
+
const names = problems.map(named).join(", ");
|
|
121
|
+
const body = `HQ sync on ${machine}: the sync engine for ${names} has not synced for over ${ATTENTION_AFTER_MS / 60_000} minutes. ` +
|
|
122
|
+
"Run `hq daemon status` on that machine.";
|
|
123
|
+
const lines = problems.map((p) => `- ${named(p)}: ${p.what}. Retry with: hq daemon restart ${p.unitId}`);
|
|
124
|
+
lines.push("", "Logs: hq daemon logs <unit>. This check runs in hq daemon and sends at most one DM a day from this machine.");
|
|
125
|
+
return { body, details: lines.join("\n") };
|
|
126
|
+
}
|
|
127
|
+
export async function runSelfCheck(deps) {
|
|
128
|
+
const now = deps.now();
|
|
129
|
+
const problems = engineUnitsNeedingAttention(deps.readState(), deps.readHolds(), now, deps.wanted);
|
|
130
|
+
if (problems.length === 0) {
|
|
131
|
+
deps.log("sync engine self-check: every engine unit is healthy");
|
|
132
|
+
return "healthy";
|
|
133
|
+
}
|
|
134
|
+
for (const p of problems)
|
|
135
|
+
deps.log(`sync engine self-check: ${p.unitId} ${p.what}`);
|
|
136
|
+
const last = readSelfCheckRecord(deps.recordPath);
|
|
137
|
+
if (!shouldReport(problems, last, now)) {
|
|
138
|
+
deps.log(`sync engine self-check: already reported at ${iso(last.lastSentAt)}; next report after ${iso(last.lastSentAt + REPORT_INTERVAL_MS)}`);
|
|
139
|
+
return "already-reported";
|
|
140
|
+
}
|
|
141
|
+
const to = await deps.sendToOwner(selfCheckMessage(problems, deps.machine, deps.vaultName));
|
|
142
|
+
if (to === null) {
|
|
143
|
+
deps.log("sync engine self-check: this machine is signed in as an agent, so there is no owner to DM; nothing was sent");
|
|
144
|
+
return "no-owner";
|
|
145
|
+
}
|
|
146
|
+
writeSelfCheckRecord(deps.recordPath, { lastSentAt: now, units: problems.map((p) => p.unitId) });
|
|
147
|
+
deps.log(`sync engine self-check: sent a DM to ${to}`);
|
|
148
|
+
return "reported";
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=selfcheck.js.map
|
|
@@ -46,6 +46,30 @@ export declare const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60000;
|
|
|
46
46
|
export declare const HANDOFF_STAGGER_MS = 15000;
|
|
47
47
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
48
48
|
export declare const HANDOFF_DRAIN_MAX_MS: number;
|
|
49
|
+
/** hq-syncd's exit code when another engine holds its engine lease or the HQ operation lock. */
|
|
50
|
+
export declare const SYNC_CORE_LOCK_HELD_EXIT = 17;
|
|
51
|
+
/**
|
|
52
|
+
* Heartbeat-timeout restarts of an engine that reported growing progress are
|
|
53
|
+
* not counted toward the hold, up to this many per failure episode. After
|
|
54
|
+
* that they count, so an engine that moves a few bytes each run and then hangs
|
|
55
|
+
* is still held (and reported by the self-check) instead of restarting forever.
|
|
56
|
+
*/
|
|
57
|
+
export declare const SYNC_CORE_PROGRESS_EXEMPT_MAX = 6;
|
|
58
|
+
/** How often a unit held for exit 17 checks whether the lock holder is still alive. */
|
|
59
|
+
export declare const LOCK_HOLDER_POLL_MS = 5000;
|
|
60
|
+
/**
|
|
61
|
+
* The pid hq-syncd names when it exits 17: "local sync engine is already
|
|
62
|
+
* running (pid N)" or "HQ operation lock for ... is held by "cmd" (pid N)".
|
|
63
|
+
* Null when the output names no live-looking pid.
|
|
64
|
+
*/
|
|
65
|
+
export declare function lockHolderPidFromOutput(output: string | undefined): number | null;
|
|
66
|
+
/**
|
|
67
|
+
* Bytes the engine reports having moved, from a protocol heartbeat that
|
|
68
|
+
* carries `progress` (see the engine contract in the hq-sync-core 0.1.24
|
|
69
|
+
* notes): `{"type":"heartbeat","progress":{"bytesReceived":N,"bytesSent":M}}`,
|
|
70
|
+
* both cumulative over the engine process. Null for a heartbeat without it.
|
|
71
|
+
*/
|
|
72
|
+
export declare function heartbeatProgressBytes(event: Record<string, unknown>): number | null;
|
|
49
73
|
export interface SupervisedChild {
|
|
50
74
|
pid?: number;
|
|
51
75
|
stdout?: {
|
|
@@ -84,6 +108,11 @@ export interface UnitHold {
|
|
|
84
108
|
probing?: boolean;
|
|
85
109
|
/** Explicit stop of a recovering unit; only an explicit restart clears it. */
|
|
86
110
|
retryStopped?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Exit 17 holds: the pid hq-syncd said holds its lock. The hold is released
|
|
113
|
+
* as soon as that process is gone instead of waiting out the cooldown.
|
|
114
|
+
*/
|
|
115
|
+
lockHolderPid?: number;
|
|
87
116
|
}
|
|
88
117
|
/** The holds file: unit id to hold. */
|
|
89
118
|
export type PersistedHolds = Record<string, UnitHold>;
|
|
@@ -172,6 +201,8 @@ export interface SupervisorDeps {
|
|
|
172
201
|
read(): PersistedHolds;
|
|
173
202
|
write(holds: PersistedHolds): void;
|
|
174
203
|
};
|
|
204
|
+
/** Whether a process is alive (signal 0). Enables releasing exit-17 holds when the lock holder exits. */
|
|
205
|
+
pidAlive?: (pid: number) => boolean;
|
|
175
206
|
}
|
|
176
207
|
export interface Supervisor {
|
|
177
208
|
start(): void;
|
|
@@ -51,6 +51,47 @@ const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
|
|
|
51
51
|
export const HANDOFF_STAGGER_MS = 15_000;
|
|
52
52
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
53
53
|
export const HANDOFF_DRAIN_MAX_MS = 15 * 60_000;
|
|
54
|
+
/** hq-syncd's exit code when another engine holds its engine lease or the HQ operation lock. */
|
|
55
|
+
export const SYNC_CORE_LOCK_HELD_EXIT = 17;
|
|
56
|
+
/**
|
|
57
|
+
* Heartbeat-timeout restarts of an engine that reported growing progress are
|
|
58
|
+
* not counted toward the hold, up to this many per failure episode. After
|
|
59
|
+
* that they count, so an engine that moves a few bytes each run and then hangs
|
|
60
|
+
* is still held (and reported by the self-check) instead of restarting forever.
|
|
61
|
+
*/
|
|
62
|
+
export const SYNC_CORE_PROGRESS_EXEMPT_MAX = 6;
|
|
63
|
+
/** How often a unit held for exit 17 checks whether the lock holder is still alive. */
|
|
64
|
+
export const LOCK_HOLDER_POLL_MS = 5_000;
|
|
65
|
+
/**
|
|
66
|
+
* The pid hq-syncd names when it exits 17: "local sync engine is already
|
|
67
|
+
* running (pid N)" or "HQ operation lock for ... is held by "cmd" (pid N)".
|
|
68
|
+
* Null when the output names no live-looking pid.
|
|
69
|
+
*/
|
|
70
|
+
export function lockHolderPidFromOutput(output) {
|
|
71
|
+
if (!output)
|
|
72
|
+
return null;
|
|
73
|
+
const matches = [...output.matchAll(/\(pid (\d+)\)/g)];
|
|
74
|
+
const pid = Number(matches.at(-1)?.[1]);
|
|
75
|
+
return Number.isSafeInteger(pid) && pid > 1 ? pid : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Bytes the engine reports having moved, from a protocol heartbeat that
|
|
79
|
+
* carries `progress` (see the engine contract in the hq-sync-core 0.1.24
|
|
80
|
+
* notes): `{"type":"heartbeat","progress":{"bytesReceived":N,"bytesSent":M}}`,
|
|
81
|
+
* both cumulative over the engine process. Null for a heartbeat without it.
|
|
82
|
+
*/
|
|
83
|
+
export function heartbeatProgressBytes(event) {
|
|
84
|
+
if (event.type !== "heartbeat")
|
|
85
|
+
return null;
|
|
86
|
+
const progress = event.progress;
|
|
87
|
+
if (!progress || typeof progress !== "object" || Array.isArray(progress))
|
|
88
|
+
return null;
|
|
89
|
+
const { bytesReceived, bytesSent } = progress;
|
|
90
|
+
const count = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
91
|
+
if (typeof bytesReceived !== "number" && typeof bytesSent !== "number")
|
|
92
|
+
return null;
|
|
93
|
+
return count(bytesReceived) + count(bytesSent);
|
|
94
|
+
}
|
|
54
95
|
const PASS_REQUEST_DIR_ENV = "HQ_SYNC_PASS_REQUEST_DIR";
|
|
55
96
|
function passRequestDirOf(env) {
|
|
56
97
|
const dir = env?.[PASS_REQUEST_DIR_ENV];
|
|
@@ -182,6 +223,9 @@ export function createSupervisor(deps) {
|
|
|
182
223
|
if (slot.recoveryHealthyTimer !== undefined)
|
|
183
224
|
clearT(slot.recoveryHealthyTimer);
|
|
184
225
|
slot.recoveryHealthyTimer = undefined;
|
|
226
|
+
if (slot.lockHolderTimer !== undefined)
|
|
227
|
+
clearT(slot.lockHolderTimer);
|
|
228
|
+
slot.lockHolderTimer = undefined;
|
|
185
229
|
};
|
|
186
230
|
const clearTimer = (slot) => {
|
|
187
231
|
clearRecoveryWindow(slot);
|
|
@@ -363,16 +407,60 @@ export function createSupervisor(deps) {
|
|
|
363
407
|
if (deps.holds && recoverableHold(slot.plan, slot.state.hold)) {
|
|
364
408
|
slot.state.hold.retryAt = now() + EPISODE_HEALTHY_RESET_MS;
|
|
365
409
|
}
|
|
410
|
+
if (kind === "exhausted" && slot.plan.service === "sync-core"
|
|
411
|
+
&& slot.state.lastExit?.code === SYNC_CORE_LOCK_HELD_EXIT && slot.lastLockHolderPid !== undefined) {
|
|
412
|
+
slot.state.hold.lockHolderPid = slot.lastLockHolderPid;
|
|
413
|
+
slot.state.hold.detail += `; the lock is held by pid ${slot.lastLockHolderPid}, released when it exits`;
|
|
414
|
+
}
|
|
366
415
|
persistedHolds[slot.plan.id] = slot.state.hold;
|
|
367
416
|
showHeld(slot);
|
|
368
417
|
deps.log("error", `holding ${slot.plan.id}: ${slot.state.reason}`);
|
|
369
418
|
writeHolds();
|
|
370
419
|
armRecoveryProbe(slot);
|
|
420
|
+
armLockHolderWatch(slot);
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* A unit held because its lock was taken (exit 17) starts again as soon as
|
|
424
|
+
* the process holding the lock is gone, instead of after the cooldown.
|
|
425
|
+
*/
|
|
426
|
+
const armLockHolderWatch = (slot) => {
|
|
427
|
+
const hold = slot.state.hold;
|
|
428
|
+
const pid = hold?.lockHolderPid;
|
|
429
|
+
if (!hold || pid === undefined || !deps.pidAlive || hold.retryStopped === true || stopped || slot.child)
|
|
430
|
+
return;
|
|
431
|
+
if (slot.lockHolderTimer !== undefined)
|
|
432
|
+
clearT(slot.lockHolderTimer);
|
|
433
|
+
const check = () => {
|
|
434
|
+
slot.lockHolderTimer = undefined;
|
|
435
|
+
if (stopped || slots.get(slot.plan.id) !== slot || slot.child || slot.state.hold !== hold || hold.retryStopped === true)
|
|
436
|
+
return;
|
|
437
|
+
let alive;
|
|
438
|
+
try {
|
|
439
|
+
alive = deps.pidAlive(pid);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
alive = true;
|
|
443
|
+
}
|
|
444
|
+
if (alive) {
|
|
445
|
+
slot.lockHolderTimer = setT(check, LOCK_HOLDER_POLL_MS);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
deps.log("info", `releasing the hold on ${slot.plan.id}: pid ${pid}, which held its lock, has exited`);
|
|
449
|
+
clearTimer(slot);
|
|
450
|
+
clearHold(slot);
|
|
451
|
+
if (slot.plan.blocked)
|
|
452
|
+
setBlocked(slot);
|
|
453
|
+
else
|
|
454
|
+
startLong(slot);
|
|
455
|
+
emit();
|
|
456
|
+
};
|
|
457
|
+
slot.lockHolderTimer = setT(check, 0);
|
|
371
458
|
};
|
|
372
459
|
const clearHold = (slot) => {
|
|
373
460
|
clearRecoveryWindow(slot);
|
|
374
461
|
slot.attempts = 0;
|
|
375
462
|
slot.failures = 0;
|
|
463
|
+
slot.progressExemptRestarts = 0;
|
|
376
464
|
if (!slot.state.hold && !persistedHolds[slot.plan.id])
|
|
377
465
|
return;
|
|
378
466
|
delete slot.state.hold;
|
|
@@ -466,10 +554,11 @@ export function createSupervisor(deps) {
|
|
|
466
554
|
escalateToKill();
|
|
467
555
|
signalChild(slot, child, "SIGTERM");
|
|
468
556
|
};
|
|
469
|
-
const scheduleRestart = (slot) => {
|
|
557
|
+
const scheduleRestart = (slot, countFailure = true) => {
|
|
470
558
|
if (stopped)
|
|
471
559
|
return;
|
|
472
|
-
|
|
560
|
+
if (countFailure)
|
|
561
|
+
slot.failures += 1;
|
|
473
562
|
if (slot.failures >= EPISODE_MAX_FAILURES) {
|
|
474
563
|
holdUnit(slot, "exhausted", "transient", `${slot.failures} failures without staying up for ${EPISODE_HEALTHY_RESET_MS / 60_000} min`);
|
|
475
564
|
return;
|
|
@@ -502,8 +591,20 @@ export function createSupervisor(deps) {
|
|
|
502
591
|
slot.heartbeatTimer = undefined;
|
|
503
592
|
if (stopped || slot.child !== child || slot.expectingExit)
|
|
504
593
|
return;
|
|
505
|
-
|
|
506
|
-
|
|
594
|
+
// An engine that reported bytes moving during this run is slow, not
|
|
595
|
+
// hung: restart it, but do not count it toward the exhausted hold.
|
|
596
|
+
// Bounded per episode: past SYNC_CORE_PROGRESS_EXEMPT_MAX these count again.
|
|
597
|
+
const grew = slot.plan.service === "sync-core" && slot.progressedThisRun === true;
|
|
598
|
+
const progressed = grew && (slot.progressExemptRestarts ?? 0) < SYNC_CORE_PROGRESS_EXEMPT_MAX;
|
|
599
|
+
if (progressed)
|
|
600
|
+
slot.progressExemptRestarts = (slot.progressExemptRestarts ?? 0) + 1;
|
|
601
|
+
deps.log("warn", `${slot.plan.id} emitted no sync protocol heartbeat for ${timeoutMs}ms; restarting it` +
|
|
602
|
+
(progressed
|
|
603
|
+
? ` (it reported transfer progress this run, so this does not count toward holding it; ${slot.progressExemptRestarts}/${SYNC_CORE_PROGRESS_EXEMPT_MAX})`
|
|
604
|
+
: grew
|
|
605
|
+
? ` (it reported progress, but ${SYNC_CORE_PROGRESS_EXEMPT_MAX} such restarts already did not count, so this one does)`
|
|
606
|
+
: ""));
|
|
607
|
+
terminate(slot, () => scheduleRestart(slot, !progressed));
|
|
507
608
|
emit();
|
|
508
609
|
}, timeoutMs);
|
|
509
610
|
};
|
|
@@ -543,6 +644,16 @@ export function createSupervisor(deps) {
|
|
|
543
644
|
runDeferredCliRestart();
|
|
544
645
|
}
|
|
545
646
|
}
|
|
647
|
+
if (slot.plan.service === "sync-core") {
|
|
648
|
+
const bytes = heartbeatProgressBytes(event);
|
|
649
|
+
if (bytes !== null) {
|
|
650
|
+
// The first progress heartbeat of a run is the baseline: counters are
|
|
651
|
+
// cumulative per process, and a handshake alone moves some bytes.
|
|
652
|
+
if (slot.progressBytes !== undefined && bytes > slot.progressBytes)
|
|
653
|
+
slot.progressedThisRun = true;
|
|
654
|
+
slot.progressBytes = Math.max(bytes, slot.progressBytes ?? 0);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
546
657
|
const at = now();
|
|
547
658
|
slot.state.lastHeartbeatAt = new Date(at).toISOString();
|
|
548
659
|
const pass = syncPassResult(event, slot.state.lastHeartbeatAt);
|
|
@@ -610,8 +721,10 @@ export function createSupervisor(deps) {
|
|
|
610
721
|
// run starts a new failure episode, as an unexpected exit would.
|
|
611
722
|
if (ranFor >= HEALTHY_RUN_MS)
|
|
612
723
|
slot.attempts = 0;
|
|
613
|
-
if (ranFor >= EPISODE_HEALTHY_RESET_MS)
|
|
724
|
+
if (ranFor >= EPISODE_HEALTHY_RESET_MS) {
|
|
614
725
|
slot.failures = 0;
|
|
726
|
+
slot.progressExemptRestarts = 0;
|
|
727
|
+
}
|
|
615
728
|
const then = slot.afterExit;
|
|
616
729
|
slot.afterExit = undefined;
|
|
617
730
|
then?.();
|
|
@@ -688,8 +801,10 @@ export function createSupervisor(deps) {
|
|
|
688
801
|
deps.log("warn", `${slot.plan.id} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
|
|
689
802
|
if (ranFor >= HEALTHY_RUN_MS)
|
|
690
803
|
slot.attempts = 0;
|
|
691
|
-
if (ranFor >= EPISODE_HEALTHY_RESET_MS)
|
|
804
|
+
if (ranFor >= EPISODE_HEALTHY_RESET_MS) {
|
|
692
805
|
slot.failures = 0;
|
|
806
|
+
slot.progressExemptRestarts = 0;
|
|
807
|
+
}
|
|
693
808
|
const stoppedFromOutside = signal === "SIGTERM" || signal === "SIGINT";
|
|
694
809
|
if (slot.plan.restart === "on-failure" && (code === 0 || stoppedFromOutside)) {
|
|
695
810
|
slot.state.status = "exited";
|
|
@@ -702,6 +817,12 @@ export function createSupervisor(deps) {
|
|
|
702
817
|
const spawnError = slot.lastSpawnError;
|
|
703
818
|
delete slot.lastSpawnError;
|
|
704
819
|
const outputTail = spawnError === undefined ? readExitOutputTail(slot) : undefined;
|
|
820
|
+
delete slot.lastLockHolderPid;
|
|
821
|
+
if (slot.plan.service === "sync-core" && code === SYNC_CORE_LOCK_HELD_EXIT) {
|
|
822
|
+
const holder = lockHolderPidFromOutput(outputTail);
|
|
823
|
+
if (holder !== null)
|
|
824
|
+
slot.lastLockHolderPid = holder;
|
|
825
|
+
}
|
|
705
826
|
if (!holdIfPermanent(slot, { code, signal, spawnError, outputTail }))
|
|
706
827
|
scheduleRestart(slot);
|
|
707
828
|
}
|
|
@@ -740,6 +861,8 @@ export function createSupervisor(deps) {
|
|
|
740
861
|
delete slot.lastProtocolLine;
|
|
741
862
|
delete slot.lastProtocolStatus;
|
|
742
863
|
delete slot.syncPassActive;
|
|
864
|
+
delete slot.progressBytes;
|
|
865
|
+
delete slot.progressedThisRun;
|
|
743
866
|
let child;
|
|
744
867
|
try {
|
|
745
868
|
child = deps.spawn(slot.plan);
|
|
@@ -923,6 +1046,7 @@ export function createSupervisor(deps) {
|
|
|
923
1046
|
if (slot.state.hold) {
|
|
924
1047
|
showHeld(slot);
|
|
925
1048
|
armRecoveryProbe(slot);
|
|
1049
|
+
armLockHolderWatch(slot);
|
|
926
1050
|
return;
|
|
927
1051
|
}
|
|
928
1052
|
startLong(slot);
|
|
@@ -20,7 +20,8 @@ function isHold(value) {
|
|
|
20
20
|
&& typeof h.detail === "string"
|
|
21
21
|
&& typeof h.since === "number"
|
|
22
22
|
&& typeof h.failures === "number"
|
|
23
|
-
&& typeof h.keyHash === "string"
|
|
23
|
+
&& typeof h.keyHash === "string"
|
|
24
|
+
&& (h.lockHolderPid === undefined || (typeof h.lockHolderPid === "number" && Number.isSafeInteger(h.lockHolderPid) && h.lockHolderPid > 1)));
|
|
24
25
|
}
|
|
25
26
|
export function readUnitHolds(file) {
|
|
26
27
|
let raw;
|
package/dist/lib/lanes/config.js
CHANGED
|
@@ -172,6 +172,14 @@ function optionalUnitInterval(value, field) {
|
|
|
172
172
|
}
|
|
173
173
|
return value;
|
|
174
174
|
}
|
|
175
|
+
function optionalPercent(value, field) {
|
|
176
|
+
if (value === undefined)
|
|
177
|
+
return undefined;
|
|
178
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
|
|
179
|
+
throw new LanesConfigError("invalid_config", `${field} must be a number in [0, 100]`, { field, value });
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
175
183
|
function parseAccount(provider, id, raw, deps) {
|
|
176
184
|
if (!isPlainObject(raw)) {
|
|
177
185
|
throw new LanesConfigError("invalid_config", `providers.${provider}.accounts.${id} must be an object`, { provider, account: id });
|
|
@@ -407,6 +415,11 @@ function parseThrottle(raw) {
|
|
|
407
415
|
}
|
|
408
416
|
result.min_available_mem_mb = obj.min_available_mem_mb;
|
|
409
417
|
}
|
|
418
|
+
// darwin: a warn pressure level holds only at or below this free
|
|
419
|
+
// percentage. Unset means DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT (20).
|
|
420
|
+
const warnMinFree = optionalPercent(obj.memory_warn_min_free_percent, "throttle.memory_warn_min_free_percent");
|
|
421
|
+
if (warnMinFree !== undefined)
|
|
422
|
+
result.memory_warn_min_free_percent = warnMinFree;
|
|
410
423
|
return result;
|
|
411
424
|
}
|
|
412
425
|
function parseLiveness(raw) {
|
|
@@ -25,6 +25,17 @@ export declare const SYSCTL_BIN = "/usr/sbin/sysctl";
|
|
|
25
25
|
* hw.memsize turns the percentage into MiB for min_available_mem_mb.
|
|
26
26
|
*/
|
|
27
27
|
export declare const MEMORY_PRESSURE_SYSCTLS: readonly ["kern.memorystatus_level", "kern.memorystatus_vm_pressure_level", "hw.memsize"];
|
|
28
|
+
/**
|
|
29
|
+
* Default for throttle.memory_warn_min_free_percent. On darwin a warn pressure
|
|
30
|
+
* level holds admission only while the free percentage is at or below this.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT = 20;
|
|
33
|
+
/**
|
|
34
|
+
* Whether a darwin memory_pressure level holds admission on its own. Critical
|
|
35
|
+
* always holds. Warn holds only at or below warnMinFreePercent free, or when
|
|
36
|
+
* the free percentage is unknown.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pressureLevelHolds(signal: MemorySignal, warnMinFreePercent?: number): boolean;
|
|
28
39
|
export declare const LANES_CPU_BUSY_OVERRIDE = "HQ_LANES_CPU_BUSY_OVERRIDE";
|
|
29
40
|
export declare const LANES_CPU_CHECK = "HQ_LANES_CPU_CHECK";
|
|
30
41
|
export interface CpuTimes {
|
|
@@ -129,7 +140,7 @@ export declare function readMemorySignal(deps?: ThrottleDeps): MemorySignal | nu
|
|
|
129
140
|
/** Available memory in MiB from readMemorySignal; null skips the memory gate. */
|
|
130
141
|
export declare function readAvailableMemMb(deps?: ThrottleDeps): number | null;
|
|
131
142
|
/** Plain explanation of a memory hold: which signal, its value, and the threshold. */
|
|
132
|
-
export declare function formatMemoryHoldMessage(signal: MemorySignal, thresholdMb: number): string;
|
|
143
|
+
export declare function formatMemoryHoldMessage(signal: MemorySignal, thresholdMb: number, warnMinFreePercent?: number): string;
|
|
133
144
|
export interface ThrottleState {
|
|
134
145
|
consecutiveDeferrals: number;
|
|
135
146
|
lastAdmissionAt: string | null;
|