@indigoai-us/hq-cli 5.272.0 → 5.273.0-alpha.0
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 +6 -0
- package/dist/command-catalog.generated.d.ts +114 -0
- package/dist/command-catalog.generated.js +144 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +173 -1
- package/dist/commands/sync-mode.d.ts +11 -0
- package/dist/commands/sync-mode.js +30 -0
- package/dist/lib/daemon/catalog.d.ts +18 -2
- package/dist/lib/daemon/catalog.js +18 -2
- package/dist/lib/daemon/config.d.ts +6 -0
- package/dist/lib/daemon/config.js +15 -2
- package/dist/lib/daemon/requests.d.ts +4 -0
- package/dist/lib/daemon/requests.js +2 -0
- package/dist/lib/daemon/run.js +78 -19
- package/dist/lib/daemon/supervisor.d.ts +8 -0
- package/dist/lib/daemon/supervisor.js +101 -1
- package/dist/lib/daemon/sync-runner.d.ts +58 -0
- package/dist/lib/daemon/sync-runner.js +277 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/package.json +2 -2
|
@@ -12,11 +12,17 @@ export interface DaemonConfig {
|
|
|
12
12
|
hqRoot?: string;
|
|
13
13
|
/** Exact hq-cloud package version preferred by this daemon, including prereleases. */
|
|
14
14
|
hqCloudVersion?: string;
|
|
15
|
+
/** Persisted machine-local sync pause. */
|
|
16
|
+
syncPaused?: boolean;
|
|
17
|
+
/** Explicit desktop handoff; absent values preserve the legacy per-machine owner. */
|
|
18
|
+
syncOwner?: SyncOwner;
|
|
15
19
|
/** Services turned on here beyond the machine type's defaults. */
|
|
16
20
|
enable: string[];
|
|
17
21
|
/** Default services turned off here. */
|
|
18
22
|
disable: string[];
|
|
19
23
|
}
|
|
24
|
+
export type SyncOwner = "daemon" | "desktop-app";
|
|
25
|
+
export declare function effectiveSyncOwner(config: Pick<DaemonConfig, "syncOwner">, machineType: MachineType): SyncOwner;
|
|
20
26
|
export declare function emptyDaemonConfig(): DaemonConfig;
|
|
21
27
|
export declare function readDaemonConfig(file: string): DaemonConfig;
|
|
22
28
|
export declare function writeDaemonConfig(file: string, config: DaemonConfig): void;
|
|
@@ -10,8 +10,11 @@ import { valid as validSemver } from "semver";
|
|
|
10
10
|
import { isServiceName } from "./catalog.js";
|
|
11
11
|
import { isMachineType } from "./machine-type.js";
|
|
12
12
|
import { ensurePrivateDir } from "./paths.js";
|
|
13
|
+
export function effectiveSyncOwner(config, machineType) {
|
|
14
|
+
return config.syncOwner ?? (machineType === "desktop" ? "desktop-app" : "daemon");
|
|
15
|
+
}
|
|
13
16
|
export function emptyDaemonConfig() {
|
|
14
|
-
return { version: 1, enable: [], disable: [] };
|
|
17
|
+
return { version: 1, syncPaused: false, enable: [], disable: [] };
|
|
15
18
|
}
|
|
16
19
|
function stringList(value) {
|
|
17
20
|
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
@@ -36,9 +39,16 @@ export function readDaemonConfig(file) {
|
|
|
36
39
|
if (!raw || typeof raw !== "object")
|
|
37
40
|
throw new Error(`hq daemon config at ${file} is not a JSON object`);
|
|
38
41
|
const obj = raw;
|
|
39
|
-
const config = {
|
|
42
|
+
const config = {
|
|
43
|
+
version: 1,
|
|
44
|
+
syncPaused: obj.syncPaused === true,
|
|
45
|
+
enable: stringList(obj.enable),
|
|
46
|
+
disable: stringList(obj.disable),
|
|
47
|
+
};
|
|
40
48
|
if (isMachineType(obj.machineType))
|
|
41
49
|
config.machineType = obj.machineType;
|
|
50
|
+
if (obj.syncOwner === "daemon" || obj.syncOwner === "desktop-app")
|
|
51
|
+
config.syncOwner = obj.syncOwner;
|
|
42
52
|
if (typeof obj.hqRoot === "string" && obj.hqRoot.trim())
|
|
43
53
|
config.hqRoot = obj.hqRoot;
|
|
44
54
|
if (typeof obj.hqCloudVersion === "string" && validSemver(obj.hqCloudVersion) === obj.hqCloudVersion) {
|
|
@@ -56,6 +66,9 @@ export function writeDaemonConfig(file, config) {
|
|
|
56
66
|
ordered.hqRoot = config.hqRoot;
|
|
57
67
|
if (config.hqCloudVersion)
|
|
58
68
|
ordered.hqCloudVersion = config.hqCloudVersion;
|
|
69
|
+
ordered.syncPaused = config.syncPaused === true;
|
|
70
|
+
if (config.syncOwner)
|
|
71
|
+
ordered.syncOwner = config.syncOwner;
|
|
59
72
|
ordered.enable = config.enable;
|
|
60
73
|
ordered.disable = config.disable;
|
|
61
74
|
fs.writeFileSync(tmp, `${JSON.stringify(ordered, null, 2)}\n`, { mode: 0o600 });
|
|
@@ -15,6 +15,10 @@ export type ControlRequest = {
|
|
|
15
15
|
action: "stop";
|
|
16
16
|
unit: string;
|
|
17
17
|
}
|
|
18
|
+
/** Re-read persisted configuration and apply any changed blocks immediately. */
|
|
19
|
+
| {
|
|
20
|
+
action: "reconcile";
|
|
21
|
+
}
|
|
18
22
|
/** Restart the whole daemon, for example after the updater installed a new CLI. */
|
|
19
23
|
| {
|
|
20
24
|
action: "restart-daemon";
|
|
@@ -19,6 +19,8 @@ function isControlRequest(value) {
|
|
|
19
19
|
return typeof v.unit === "string";
|
|
20
20
|
if (v.action === "restart-daemon")
|
|
21
21
|
return true;
|
|
22
|
+
if (v.action === "reconcile")
|
|
23
|
+
return true;
|
|
22
24
|
return false;
|
|
23
25
|
}
|
|
24
26
|
// Orders requests written by one process within the same millisecond; the
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -16,7 +16,7 @@ import { agentKitPaths } from "../agent-kit/paths.js";
|
|
|
16
16
|
import { readBotConfig } from "../bot/config.js";
|
|
17
17
|
import { botsRoot } from "../bot/paths.js";
|
|
18
18
|
import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
|
|
19
|
-
import { readDaemonConfig } from "./config.js";
|
|
19
|
+
import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
|
|
20
20
|
import { findLegacyUnits, legacyOwnerLookup } from "./legacy.js";
|
|
21
21
|
import { readLegacySyncEnv } from "./legacy-sync-env.js";
|
|
22
22
|
import { acquireDaemonLock, releaseDaemonLock } from "./lock.js";
|
|
@@ -26,6 +26,7 @@ import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from
|
|
|
26
26
|
import { takeControlRequests } from "./requests.js";
|
|
27
27
|
import { currentHqCloud, hqCloudRunners } from "./hq-cloud.js";
|
|
28
28
|
import { runnerSessionName } from "./remote-control.js";
|
|
29
|
+
import { listSyncRunnerProcesses, readWatchRunnerOwnerStatus, syncHeartbeatTimeoutMs, syncRunnerNodeOptions, watchRunnerOwnerStatusPath, } from "./sync-runner.js";
|
|
29
30
|
import { STOP_ESCALATION_MS, createSupervisor } from "./supervisor.js";
|
|
30
31
|
import { userToolDirs } from "./tools.js";
|
|
31
32
|
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
@@ -213,7 +214,10 @@ function makeLogger(paths, foreground) {
|
|
|
213
214
|
* an on-failure unit down. The daemon's updater handles CLI updates.
|
|
214
215
|
*/
|
|
215
216
|
export function unitProcessEnv(base, unit) {
|
|
216
|
-
|
|
217
|
+
const env = { ...base, ...unit.env, HQ_DAEMON_UNIT: unit.id, HQ_NO_UPDATE_CHECK: "1" };
|
|
218
|
+
if (unit.service === "sync")
|
|
219
|
+
env.NODE_OPTIONS = syncRunnerNodeOptions(env);
|
|
220
|
+
return env;
|
|
217
221
|
}
|
|
218
222
|
export async function runDaemon(opts) {
|
|
219
223
|
const env = opts.env ?? process.env;
|
|
@@ -258,10 +262,11 @@ export async function runDaemon(opts) {
|
|
|
258
262
|
return legacyCache.units;
|
|
259
263
|
};
|
|
260
264
|
let warnedMissingPin = null;
|
|
265
|
+
const daemonSyncRunnerPids = new Set();
|
|
261
266
|
const plan = () => {
|
|
262
267
|
const config = readDaemonConfig(paths.configPath);
|
|
263
268
|
const legacy = legacyUnits();
|
|
264
|
-
const configured = config.hqRoot ?? env.HQ_ROOT?.trim()
|
|
269
|
+
const configured = config.hqRoot ?? (env.HQ_ROOT?.trim() || undefined);
|
|
265
270
|
const hqRoot = configured && fs.existsSync(configured) ? configured : undefined;
|
|
266
271
|
// Re-picked every reconcile, so a newer hq-cloud from the updater is used as soon as it lands.
|
|
267
272
|
const hqCloud = currentHqCloud(paths.root);
|
|
@@ -273,7 +278,33 @@ export async function runDaemon(opts) {
|
|
|
273
278
|
}
|
|
274
279
|
}
|
|
275
280
|
const runners = hqCloud ? hqCloudRunners(hqCloud.root) : { syncRunner: null, heartbeat: null, sessionHost: null };
|
|
276
|
-
|
|
281
|
+
const services = enabledServices(machineType, config);
|
|
282
|
+
const isSignedIn = signedIn(env, home, log);
|
|
283
|
+
const syncOwner = effectiveSyncOwner(config, machineType);
|
|
284
|
+
let syncRunnerProcesses = [];
|
|
285
|
+
let syncRunnerOwnerPid = null;
|
|
286
|
+
if (machineType === "desktop" &&
|
|
287
|
+
syncOwner === "daemon" &&
|
|
288
|
+
services.includes("sync") &&
|
|
289
|
+
!config.syncPaused &&
|
|
290
|
+
hqRoot &&
|
|
291
|
+
isSignedIn) {
|
|
292
|
+
const processList = listSyncRunnerProcesses(platform);
|
|
293
|
+
if (!processList.ok) {
|
|
294
|
+
syncRunnerProcesses = null;
|
|
295
|
+
log("warn", `${processList.error}; sync will wait before taking ownership`);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
syncRunnerProcesses = processList.processes;
|
|
299
|
+
try {
|
|
300
|
+
syncRunnerOwnerPid = readWatchRunnerOwnerStatus(watchRunnerOwnerStatusPath(hqRoot, home, env))?.pid ?? null;
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
log("warn", `could not verify the sync watch-owner status: ${error instanceof Error ? error.message : String(error)}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return planUnits(services, {
|
|
277
308
|
machineType,
|
|
278
309
|
platform,
|
|
279
310
|
home,
|
|
@@ -282,14 +313,20 @@ export async function runDaemon(opts) {
|
|
|
282
313
|
heartbeatRunnerPath: runners.heartbeat,
|
|
283
314
|
sessionHostRunnerPath: runners.sessionHost,
|
|
284
315
|
legacySyncEnv: readLegacySyncEnv(platform),
|
|
285
|
-
signedIn:
|
|
316
|
+
signedIn: isSignedIn,
|
|
286
317
|
kitConfigured: fs.existsSync(agentKitPaths(home, env).kitConfigPath),
|
|
318
|
+
syncPaused: config.syncPaused === true,
|
|
319
|
+
syncOwner,
|
|
287
320
|
desktopAppManagesSync: desktopAppManagesSync({
|
|
288
321
|
machineType,
|
|
289
322
|
host: opts.host,
|
|
290
323
|
legacy,
|
|
291
324
|
appRunning: () => desktopAppRunning(platform),
|
|
292
325
|
}),
|
|
326
|
+
syncRunnerProcesses,
|
|
327
|
+
syncRunnerOwnerPid,
|
|
328
|
+
daemonSyncRunnerPids,
|
|
329
|
+
syncHeartbeatTimeoutMs: syncHeartbeatTimeoutMs(env),
|
|
293
330
|
bots: listLocalBots(),
|
|
294
331
|
legacyOwner: legacyOwnerLookup(legacy),
|
|
295
332
|
cliSelfUpdatable: updatable,
|
|
@@ -311,20 +348,27 @@ export async function runDaemon(opts) {
|
|
|
311
348
|
: command.type === "node"
|
|
312
349
|
? [opts.nodeBinary, [command.script, ...command.args]]
|
|
313
350
|
: [command.file, command.args];
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
351
|
+
const logPath = paths.unitLogPath(unit.id);
|
|
352
|
+
const logFd = openPrivateAppend(logPath);
|
|
353
|
+
const logStream = fs.createWriteStream(logPath, { fd: logFd, autoClose: true });
|
|
354
|
+
logStream.on("error", (err) => log("error", `could not write ${unit.id} log ${logPath}: ${err.message}`));
|
|
355
|
+
const cwd = unit.env.HQ_ROOT && fs.existsSync(unit.env.HQ_ROOT) ? unit.env.HQ_ROOT : home;
|
|
356
|
+
const child = spawn(file, args, {
|
|
357
|
+
cwd,
|
|
358
|
+
env: unitProcessEnv(env, unit),
|
|
359
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
360
|
+
windowsHide: true,
|
|
361
|
+
});
|
|
362
|
+
if (unit.service === "sync" && child.pid !== undefined)
|
|
363
|
+
daemonSyncRunnerPids.add(child.pid);
|
|
364
|
+
child.stdout?.pipe(logStream, { end: false });
|
|
365
|
+
child.stderr?.pipe(logStream, { end: false });
|
|
366
|
+
child.on("close", () => {
|
|
367
|
+
if (child.pid !== undefined)
|
|
368
|
+
daemonSyncRunnerPids.delete(child.pid);
|
|
369
|
+
logStream.end();
|
|
370
|
+
});
|
|
371
|
+
return child;
|
|
328
372
|
};
|
|
329
373
|
// Set once the shutdown path exists below; a restart request before then is dropped.
|
|
330
374
|
let restartDaemon = () => { };
|
|
@@ -346,6 +390,21 @@ export async function runDaemon(opts) {
|
|
|
346
390
|
restartDaemon: () => restartDaemon(),
|
|
347
391
|
writeState: (state) => writeJsonAtomic(paths.statePath, state),
|
|
348
392
|
log,
|
|
393
|
+
syncOwnerBlockReason: () => {
|
|
394
|
+
const config = readDaemonConfig(paths.configPath);
|
|
395
|
+
const hqRoot = config.hqRoot ?? env.HQ_ROOT?.trim();
|
|
396
|
+
if (!hqRoot)
|
|
397
|
+
return undefined;
|
|
398
|
+
const statusPath = watchRunnerOwnerStatusPath(hqRoot, home, env);
|
|
399
|
+
try {
|
|
400
|
+
const owner = readWatchRunnerOwnerStatus(statusPath);
|
|
401
|
+
return owner ? `sync owned by ${owner.owner} pid ${owner.pid}` : undefined;
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
log("error", `could not read sync owner status at ${statusPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
},
|
|
349
408
|
});
|
|
350
409
|
return await new Promise((resolve) => {
|
|
351
410
|
let stopping = false;
|
|
@@ -20,12 +20,16 @@
|
|
|
20
20
|
import type { UnitPlan } from "./catalog.js";
|
|
21
21
|
import type { MachineType } from "./machine-type.js";
|
|
22
22
|
import type { ControlRequest } from "./requests.js";
|
|
23
|
+
import { syncPassResult } from "./sync-runner.js";
|
|
23
24
|
export declare const BACKOFF_BASE_MS = 1000;
|
|
24
25
|
export declare const BACKOFF_MAX_MS = 60000;
|
|
25
26
|
export declare const HEALTHY_RUN_MS = 60000;
|
|
26
27
|
export declare const STOP_ESCALATION_MS = 10000;
|
|
27
28
|
export interface SupervisedChild {
|
|
28
29
|
pid?: number;
|
|
30
|
+
stdout?: {
|
|
31
|
+
on(event: "data" | "end", handler: (chunk?: Buffer | string) => void): unknown;
|
|
32
|
+
};
|
|
29
33
|
on(event: "exit", handler: (code: number | null, signal: string | null) => void): unknown;
|
|
30
34
|
on(event: "error", handler: (err: Error) => void): unknown;
|
|
31
35
|
kill(signal?: NodeJS.Signals | number): boolean;
|
|
@@ -49,6 +53,8 @@ export interface UnitState {
|
|
|
49
53
|
lastRunAt?: number;
|
|
50
54
|
nextRunAt?: number;
|
|
51
55
|
lastResult?: "ok" | "failed";
|
|
56
|
+
lastHeartbeatAt?: string;
|
|
57
|
+
lastPassResult?: ReturnType<typeof syncPassResult>;
|
|
52
58
|
}
|
|
53
59
|
/** An app that runs the daemon as its own child process instead of an OS unit. */
|
|
54
60
|
export type HostApp = "desktop";
|
|
@@ -79,6 +85,8 @@ export interface SupervisorDeps {
|
|
|
79
85
|
clearTimeout?: (handle: unknown) => void;
|
|
80
86
|
reconcileMs?: number;
|
|
81
87
|
requestPollMs?: number;
|
|
88
|
+
/** Read hq-cloud's lease status after it exits with the lease-conflict code. */
|
|
89
|
+
syncOwnerBlockReason?: () => string | undefined;
|
|
82
90
|
}
|
|
83
91
|
export interface Supervisor {
|
|
84
92
|
start(): void;
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Processes, time, and state output are injected; `run.ts` wires the real ones.
|
|
19
19
|
*/
|
|
20
|
+
import { StringDecoder } from "node:string_decoder";
|
|
21
|
+
import { SYNC_RUNNER_LEASE_CONFLICT_EXIT, SYNC_RUNNER_LEASE_RETRY_MS, parseSyncRunnerProtocolLine, syncPassResult, } from "./sync-runner.js";
|
|
20
22
|
export const BACKOFF_BASE_MS = 1_000;
|
|
21
23
|
export const BACKOFF_MAX_MS = 60_000;
|
|
22
24
|
export const HEALTHY_RUN_MS = 60_000;
|
|
@@ -25,7 +27,15 @@ export function backoffMs(attempt) {
|
|
|
25
27
|
return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
|
|
26
28
|
}
|
|
27
29
|
function planKey(plan) {
|
|
28
|
-
return JSON.stringify([
|
|
30
|
+
return JSON.stringify([
|
|
31
|
+
plan.kind,
|
|
32
|
+
plan.restart,
|
|
33
|
+
plan.intervalSec,
|
|
34
|
+
plan.initialDelaySec,
|
|
35
|
+
plan.heartbeatTimeoutMs,
|
|
36
|
+
plan.command,
|
|
37
|
+
plan.env,
|
|
38
|
+
]);
|
|
29
39
|
}
|
|
30
40
|
function errorMessage(err) {
|
|
31
41
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -68,6 +78,11 @@ export function createSupervisor(deps) {
|
|
|
68
78
|
clearT(slot.timer);
|
|
69
79
|
slot.timer = undefined;
|
|
70
80
|
};
|
|
81
|
+
const clearHeartbeatTimer = (slot) => {
|
|
82
|
+
if (slot.heartbeatTimer !== undefined)
|
|
83
|
+
clearT(slot.heartbeatTimer);
|
|
84
|
+
slot.heartbeatTimer = undefined;
|
|
85
|
+
};
|
|
71
86
|
const setBlocked = (slot) => {
|
|
72
87
|
const block = slot.plan.blocked;
|
|
73
88
|
slot.state.status = block.state;
|
|
@@ -78,6 +93,7 @@ export function createSupervisor(deps) {
|
|
|
78
93
|
/** Stop the slot's child (if any), then run `then` once it has exited. */
|
|
79
94
|
const terminate = (slot, then) => {
|
|
80
95
|
clearTimer(slot);
|
|
96
|
+
clearHeartbeatTimer(slot);
|
|
81
97
|
const child = slot.child;
|
|
82
98
|
if (!child) {
|
|
83
99
|
then?.();
|
|
@@ -122,6 +138,69 @@ export function createSupervisor(deps) {
|
|
|
122
138
|
const watch = (slot, child) => {
|
|
123
139
|
live.add(child);
|
|
124
140
|
let ended = false;
|
|
141
|
+
const timeoutMs = slot.plan.heartbeatTimeoutMs;
|
|
142
|
+
const decoder = timeoutMs ? new StringDecoder("utf8") : null;
|
|
143
|
+
let stdoutRemainder = "";
|
|
144
|
+
let lastHeartbeatPersistedAt = now() - 15_000;
|
|
145
|
+
const armHeartbeatWatchdog = () => {
|
|
146
|
+
if (!timeoutMs)
|
|
147
|
+
return;
|
|
148
|
+
clearHeartbeatTimer(slot);
|
|
149
|
+
slot.heartbeatTimer = setT(() => {
|
|
150
|
+
slot.heartbeatTimer = undefined;
|
|
151
|
+
if (stopped || slot.child !== child || slot.expectingExit)
|
|
152
|
+
return;
|
|
153
|
+
deps.log("warn", `${slot.plan.id} emitted no sync protocol heartbeat for ${timeoutMs}ms; restarting it`);
|
|
154
|
+
terminate(slot, () => scheduleRestart(slot));
|
|
155
|
+
emit();
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
};
|
|
158
|
+
const acceptProtocolLine = (line) => {
|
|
159
|
+
let event;
|
|
160
|
+
try {
|
|
161
|
+
event = parseSyncRunnerProtocolLine(line);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
deps.log("error", `${slot.plan.id} emitted invalid sync protocol output (${Buffer.byteLength(line)} bytes): ${errorMessage(err)}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!event)
|
|
168
|
+
return;
|
|
169
|
+
const at = now();
|
|
170
|
+
slot.state.lastHeartbeatAt = new Date(at).toISOString();
|
|
171
|
+
const pass = syncPassResult(event, slot.state.lastHeartbeatAt);
|
|
172
|
+
if (pass)
|
|
173
|
+
slot.state.lastPassResult = pass;
|
|
174
|
+
armHeartbeatWatchdog();
|
|
175
|
+
if (pass || at - lastHeartbeatPersistedAt >= 15_000) {
|
|
176
|
+
lastHeartbeatPersistedAt = at;
|
|
177
|
+
emit();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
if (decoder && child.stdout) {
|
|
181
|
+
child.stdout.on("data", (chunk) => {
|
|
182
|
+
if (chunk === undefined)
|
|
183
|
+
return;
|
|
184
|
+
stdoutRemainder += decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
185
|
+
let newline = stdoutRemainder.indexOf("\n");
|
|
186
|
+
while (newline >= 0) {
|
|
187
|
+
acceptProtocolLine(stdoutRemainder.slice(0, newline).trim());
|
|
188
|
+
stdoutRemainder = stdoutRemainder.slice(newline + 1);
|
|
189
|
+
newline = stdoutRemainder.indexOf("\n");
|
|
190
|
+
}
|
|
191
|
+
// A malformed or unexpectedly large line is not a heartbeat. Bound
|
|
192
|
+
// retained data so a broken child cannot grow daemon memory forever.
|
|
193
|
+
if (Buffer.byteLength(stdoutRemainder) > 1024 * 1024)
|
|
194
|
+
stdoutRemainder = "";
|
|
195
|
+
});
|
|
196
|
+
child.stdout.on("end", () => {
|
|
197
|
+
stdoutRemainder += decoder.end();
|
|
198
|
+
if (stdoutRemainder)
|
|
199
|
+
acceptProtocolLine(stdoutRemainder.trim());
|
|
200
|
+
stdoutRemainder = "";
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
armHeartbeatWatchdog();
|
|
125
204
|
const onEnd = (code, signal) => {
|
|
126
205
|
if (ended)
|
|
127
206
|
return;
|
|
@@ -132,6 +211,7 @@ export function createSupervisor(deps) {
|
|
|
132
211
|
if (slot.child !== child)
|
|
133
212
|
return;
|
|
134
213
|
slot.child = undefined;
|
|
214
|
+
clearHeartbeatTimer(slot);
|
|
135
215
|
if (slot.killTimer !== undefined) {
|
|
136
216
|
clearT(slot.killTimer);
|
|
137
217
|
slot.killTimer = undefined;
|
|
@@ -159,6 +239,20 @@ export function createSupervisor(deps) {
|
|
|
159
239
|
emit();
|
|
160
240
|
return;
|
|
161
241
|
}
|
|
242
|
+
if (slot.plan.service === "sync" && code === SYNC_RUNNER_LEASE_CONFLICT_EXIT) {
|
|
243
|
+
const reason = deps.syncOwnerBlockReason?.() ?? "sync owned by another runner";
|
|
244
|
+
slot.state.status = "waiting";
|
|
245
|
+
slot.state.reason = reason;
|
|
246
|
+
slot.state.nextRunAt = now() + SYNC_RUNNER_LEASE_RETRY_MS;
|
|
247
|
+
deps.log("info", `${reason}; retrying in ${SYNC_RUNNER_LEASE_RETRY_MS}ms`);
|
|
248
|
+
slot.timer = setT(() => {
|
|
249
|
+
slot.timer = undefined;
|
|
250
|
+
startLong(slot);
|
|
251
|
+
emit();
|
|
252
|
+
}, SYNC_RUNNER_LEASE_RETRY_MS);
|
|
253
|
+
emit();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
162
256
|
deps.log("warn", `${slot.plan.id} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
|
|
163
257
|
if (ranFor >= HEALTHY_RUN_MS)
|
|
164
258
|
slot.attempts = 0;
|
|
@@ -369,6 +463,11 @@ export function createSupervisor(deps) {
|
|
|
369
463
|
emit();
|
|
370
464
|
};
|
|
371
465
|
const handleRequest = (req) => {
|
|
466
|
+
if (req.action === "reconcile") {
|
|
467
|
+
deps.log("info", "reconciling daemon configuration (requested)");
|
|
468
|
+
reconcile();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
372
471
|
if (req.action === "restart-daemon") {
|
|
373
472
|
deps.log("info", "restarting the daemon (requested)");
|
|
374
473
|
if (deps.restartDaemon)
|
|
@@ -475,6 +574,7 @@ export function createSupervisor(deps) {
|
|
|
475
574
|
clearT(requestTimer);
|
|
476
575
|
for (const slot of slots.values()) {
|
|
477
576
|
clearTimer(slot);
|
|
577
|
+
clearHeartbeatTimer(slot);
|
|
478
578
|
slot.child = undefined;
|
|
479
579
|
if (slot.killTimer !== undefined)
|
|
480
580
|
clearT(slot.killTimer);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Local status and resource controls shared by the daemon and its sync runner. */
|
|
2
|
+
export declare const SYNC_RUNNER_OWNER = "hq-daemon";
|
|
3
|
+
export declare const SYNC_RUNNER_LEASE_CONFLICT_EXIT = 20;
|
|
4
|
+
export declare const SYNC_RUNNER_LEASE_RETRY_MS = 15000;
|
|
5
|
+
export declare const DEFAULT_SYNC_HEARTBEAT_TIMEOUT_MS = 300000;
|
|
6
|
+
export declare const DEFAULT_SYNC_RUNNER_HEAP_MB = 3584;
|
|
7
|
+
export declare const SYNC_HEARTBEAT_TIMEOUT_ENV = "HQ_DAEMON_SYNC_HEARTBEAT_TIMEOUT_SECONDS";
|
|
8
|
+
export declare const SYNC_RUNNER_HEAP_ENV = "HQ_SYNC_RUNNER_MAX_OLD_SPACE_MB";
|
|
9
|
+
export interface WatchRunnerOwnerStatus {
|
|
10
|
+
owner: string;
|
|
11
|
+
pid: number;
|
|
12
|
+
version: string;
|
|
13
|
+
startedAt: string;
|
|
14
|
+
heartbeatAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SyncRunnerProcess {
|
|
17
|
+
pid: number;
|
|
18
|
+
commandLine: string;
|
|
19
|
+
}
|
|
20
|
+
export type SyncRunnerProcessList = {
|
|
21
|
+
ok: true;
|
|
22
|
+
processes: SyncRunnerProcess[];
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
error: string;
|
|
26
|
+
};
|
|
27
|
+
export interface ProcessListCommandResult {
|
|
28
|
+
status: number | null;
|
|
29
|
+
stdout: string;
|
|
30
|
+
stderr: string;
|
|
31
|
+
error?: Error;
|
|
32
|
+
}
|
|
33
|
+
export type ProcessListRunner = (command: string, args: string[]) => ProcessListCommandResult;
|
|
34
|
+
export interface SyncPassResult {
|
|
35
|
+
status: "ok" | "partial" | "failed";
|
|
36
|
+
completedAt: string;
|
|
37
|
+
companiesAttempted?: number;
|
|
38
|
+
errors: number;
|
|
39
|
+
transient: number;
|
|
40
|
+
}
|
|
41
|
+
/** The status file path used by hq-cloud's watch-owner lease. */
|
|
42
|
+
export declare function watchRunnerOwnerStatusPath(hqRoot: string, home?: string, env?: NodeJS.ProcessEnv): string;
|
|
43
|
+
/** Read the watch-owner status JSON without taking or changing its lease. */
|
|
44
|
+
export declare function readWatchRunnerOwnerStatus(statusPath: string): WatchRunnerOwnerStatus | null;
|
|
45
|
+
/** Match a live process command line to the hq-cloud watch runner executable. */
|
|
46
|
+
export declare function isSyncRunnerCommandLine(commandLine: string): boolean;
|
|
47
|
+
/** Return a clear wait reason when an unleased runner already targets this HQ root. */
|
|
48
|
+
export declare function syncRunnerUnleasedBlockReason(hqRoot: string, processes: readonly SyncRunnerProcess[], ownerPid: number | null, platform?: NodeJS.Platform, daemonRunnerPids?: ReadonlySet<number>): string | undefined;
|
|
49
|
+
/** Read complete process command lines so root matching can include trailing arguments. */
|
|
50
|
+
export declare function listSyncRunnerProcesses(platform?: NodeJS.Platform, run?: ProcessListRunner): SyncRunnerProcessList;
|
|
51
|
+
/** The watchdog follows the runner's NDJSON protocol, the same liveness signal used by the desktop app. */
|
|
52
|
+
export declare function parseSyncRunnerProtocolLine(line: string): Record<string, unknown> | null;
|
|
53
|
+
/** Summarize the runner's all-complete event for daemon status. */
|
|
54
|
+
export declare function syncPassResult(event: Record<string, unknown>, completedAt?: string): SyncPassResult | null;
|
|
55
|
+
export declare function syncHeartbeatTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
56
|
+
/** Preserve an explicit NODE_OPTIONS heap setting; otherwise apply the desktop runner's default/override. */
|
|
57
|
+
export declare function syncRunnerNodeOptions(env: NodeJS.ProcessEnv): string | undefined;
|
|
58
|
+
//# sourceMappingURL=sync-runner.d.ts.map
|