@indigoai-us/hq-cli 5.285.0 → 5.287.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 +33 -0
- package/assets/scaffold/core/scripts/clean-worktree-reconcile-handoff.sh +171 -0
- package/assets/scaffold/core/scripts/resume-thread-lock.sh +285 -0
- package/assets/scaffold/core/scripts/verify-story-deliverables.sh +148 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +362 -0
- package/dist/command-catalog.generated.d.ts +52 -0
- package/dist/command-catalog.generated.js +64 -0
- package/dist/commands/cloud-provision.js +99 -78
- package/dist/commands/core-native-handoff-flag.d.ts +4 -0
- package/dist/commands/core-native-handoff-flag.js +7 -0
- package/dist/commands/core.js +45 -0
- package/dist/commands/lanes.js +1 -28
- package/dist/commands/team-sync.js +71 -10
- package/dist/fast-lanes-list-route.js +2 -0
- package/dist/fast-lanes-list.js +1 -29
- package/dist/lib/core-utils/sentry-report.js +5 -32
- package/dist/lib/core-utils/worktree-resume.d.ts +18 -0
- package/dist/lib/core-utils/worktree-resume.js +1182 -0
- package/dist/lib/daemon/paths.d.ts +2 -0
- package/dist/lib/daemon/paths.js +1 -0
- package/dist/lib/daemon/protocol-tail.d.ts +33 -0
- package/dist/lib/daemon/protocol-tail.js +131 -0
- package/dist/lib/daemon/run.js +79 -5
- package/dist/lib/daemon/succession.d.ts +2 -0
- package/dist/lib/daemon/succession.js +1 -0
- package/dist/lib/doctor/report.js +1 -1
- package/dist/lib/lanes/outpost-snapshot-refresh.d.ts +21 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.js +329 -0
- package/package.json +1 -1
|
@@ -12,6 +12,8 @@ export interface DaemonPaths {
|
|
|
12
12
|
requestsDir: string;
|
|
13
13
|
supervisorLogPath: string;
|
|
14
14
|
unitLogPath: (unitId: string) => string;
|
|
15
|
+
/** The FIFO a unit that speaks the sync protocol writes its stdout to (see protocol-tail.ts); kept out of logs/ so nothing reading logs drains it. */
|
|
16
|
+
unitProtocolPath: (unitId: string) => string;
|
|
15
17
|
}
|
|
16
18
|
export declare function daemonPaths(home?: string, env?: NodeJS.ProcessEnv): DaemonPaths;
|
|
17
19
|
/** Create `dir` (and any missing parents) readable only by its owner, and tighten it if it already exists. */
|
package/dist/lib/daemon/paths.js
CHANGED
|
@@ -18,6 +18,7 @@ export function daemonPaths(home = os.homedir(), env = process.env) {
|
|
|
18
18
|
requestsDir: path.join(root, "requests"),
|
|
19
19
|
supervisorLogPath: path.join(logsDir, "daemon.log"),
|
|
20
20
|
unitLogPath: (unitId) => path.join(logsDir, `${unitId.replace(/[^A-Za-z0-9_.-]/g, "-")}.log`),
|
|
21
|
+
unitProtocolPath: (unitId) => path.join(root, "protocol", `${unitId.replace(/[^A-Za-z0-9_.-]/g, "-")}.fifo`),
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
/** Create `dir` (and any missing parents) readable only by its owner, and tighten it if it already exists. */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A service's protocol output read from a named pipe instead of an anonymous
|
|
3
|
+
* one.
|
|
4
|
+
*
|
|
5
|
+
* An anonymous pipe loses its reader when the daemon exits, so a service
|
|
6
|
+
* writing to one cannot be handed to the next daemon. Here the service's
|
|
7
|
+
* stdout is a FIFO it holds open for reading and writing, so it always has a
|
|
8
|
+
* reader and never gets EPIPE. Each daemon polls the FIFO without blocking and
|
|
9
|
+
* feeds what it reads to the supervisor as the child's stdout. On a handoff
|
|
10
|
+
* the daemon stops reading without draining, so output it has not read stays
|
|
11
|
+
* in the kernel buffer for the next daemon, and nothing accumulates on disk.
|
|
12
|
+
*/
|
|
13
|
+
export interface ProtocolTail {
|
|
14
|
+
/** "data" carries each chunk read; "end" fires once, after close(). */
|
|
15
|
+
on(event: "data" | "end", handler: (chunk?: Buffer | string) => void): ProtocolTail;
|
|
16
|
+
/** The service exited: read what is left, emit "end" and stop polling. */
|
|
17
|
+
close(): void;
|
|
18
|
+
/** Handing over: stop polling without reading, leaving the rest for the next daemon. */
|
|
19
|
+
detach(): void;
|
|
20
|
+
}
|
|
21
|
+
/** Make `file` a FIFO, replacing anything else found there. */
|
|
22
|
+
export declare function ensureProtocolFifo(file: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* The service's stdout. Opened for reading and writing, so the FIFO keeps a
|
|
25
|
+
* reader while no daemon has it open and the service's writes never fail.
|
|
26
|
+
*/
|
|
27
|
+
export declare function openProtocolWriter(file: string): number;
|
|
28
|
+
export declare function createProtocolTail(file: string, opts?: {
|
|
29
|
+
pollMs?: number;
|
|
30
|
+
setInterval?: (fn: () => void, ms: number) => unknown;
|
|
31
|
+
clearInterval?: (handle: unknown) => void;
|
|
32
|
+
}): ProtocolTail;
|
|
33
|
+
//# sourceMappingURL=protocol-tail.d.ts.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A service's protocol output read from a named pipe instead of an anonymous
|
|
3
|
+
* one.
|
|
4
|
+
*
|
|
5
|
+
* An anonymous pipe loses its reader when the daemon exits, so a service
|
|
6
|
+
* writing to one cannot be handed to the next daemon. Here the service's
|
|
7
|
+
* stdout is a FIFO it holds open for reading and writing, so it always has a
|
|
8
|
+
* reader and never gets EPIPE. Each daemon polls the FIFO without blocking and
|
|
9
|
+
* feeds what it reads to the supervisor as the child's stdout. On a handoff
|
|
10
|
+
* the daemon stops reading without draining, so output it has not read stays
|
|
11
|
+
* in the kernel buffer for the next daemon, and nothing accumulates on disk.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
18
|
+
const POLL_MS = 1_000;
|
|
19
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
20
|
+
/** Make `file` a FIFO, replacing anything else found there. */
|
|
21
|
+
export function ensureProtocolFifo(file) {
|
|
22
|
+
ensurePrivateDir(path.dirname(file));
|
|
23
|
+
try {
|
|
24
|
+
if (fs.lstatSync(file).isFIFO())
|
|
25
|
+
return;
|
|
26
|
+
fs.rmSync(file, { force: true });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Missing: created below.
|
|
30
|
+
}
|
|
31
|
+
const r = spawnSync("mkfifo", ["-m", "600", file], { encoding: "utf8" });
|
|
32
|
+
if (r.error)
|
|
33
|
+
throw r.error;
|
|
34
|
+
if (r.status !== 0)
|
|
35
|
+
throw new Error(`mkfifo ${file} failed: ${(r.stderr ?? "").trim() || `exit ${r.status}`}`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The service's stdout. Opened for reading and writing, so the FIFO keeps a
|
|
39
|
+
* reader while no daemon has it open and the service's writes never fail.
|
|
40
|
+
*/
|
|
41
|
+
export function openProtocolWriter(file) {
|
|
42
|
+
return fs.openSync(file, fs.constants.O_RDWR);
|
|
43
|
+
}
|
|
44
|
+
export function createProtocolTail(file, opts = {}) {
|
|
45
|
+
const events = new EventEmitter();
|
|
46
|
+
const setI = opts.setInterval ?? ((fn, ms) => setInterval(fn, ms));
|
|
47
|
+
const clearI = opts.clearInterval ?? ((h) => clearInterval(h));
|
|
48
|
+
let fd = null;
|
|
49
|
+
let done = false;
|
|
50
|
+
const open = () => {
|
|
51
|
+
if (fd !== null)
|
|
52
|
+
return fd;
|
|
53
|
+
try {
|
|
54
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
fd = null;
|
|
58
|
+
}
|
|
59
|
+
return fd;
|
|
60
|
+
};
|
|
61
|
+
const read = () => {
|
|
62
|
+
const handle = open();
|
|
63
|
+
if (handle === null)
|
|
64
|
+
return;
|
|
65
|
+
const buf = Buffer.alloc(READ_CHUNK_BYTES);
|
|
66
|
+
for (;;) {
|
|
67
|
+
let n;
|
|
68
|
+
try {
|
|
69
|
+
n = fs.readSync(handle, buf, 0, buf.length, null);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// EAGAIN: nothing written since the last read.
|
|
73
|
+
if (err.code === "EAGAIN")
|
|
74
|
+
return;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
if (n <= 0)
|
|
78
|
+
return;
|
|
79
|
+
events.emit("data", Buffer.from(buf.subarray(0, n)));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const stop = () => {
|
|
83
|
+
done = true;
|
|
84
|
+
clearI(timer);
|
|
85
|
+
if (fd !== null) {
|
|
86
|
+
try {
|
|
87
|
+
fs.closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// already closed
|
|
91
|
+
}
|
|
92
|
+
fd = null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const timer = setI(() => {
|
|
96
|
+
try {
|
|
97
|
+
read();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// An unreadable FIFO reads as silence; the heartbeat watchdog restarts the service.
|
|
101
|
+
}
|
|
102
|
+
}, opts.pollMs ?? POLL_MS);
|
|
103
|
+
timer.unref?.();
|
|
104
|
+
// Open now, so the FIFO has a reader before the service starts writing.
|
|
105
|
+
open();
|
|
106
|
+
const tail = {
|
|
107
|
+
on(event, handler) {
|
|
108
|
+
events.on(event, handler);
|
|
109
|
+
return tail;
|
|
110
|
+
},
|
|
111
|
+
close() {
|
|
112
|
+
if (done)
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
read();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// nothing more to read
|
|
119
|
+
}
|
|
120
|
+
stop();
|
|
121
|
+
events.emit("end");
|
|
122
|
+
},
|
|
123
|
+
detach() {
|
|
124
|
+
if (done)
|
|
125
|
+
return;
|
|
126
|
+
stop();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
return tail;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=protocol-tail.js.map
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -25,6 +25,7 @@ import { acquireDaemonLock, releaseDaemonLock } from "./lock.js";
|
|
|
25
25
|
import { daemonRootRefusal } from "./install.js";
|
|
26
26
|
import { detectMachineType } from "./machine-type.js";
|
|
27
27
|
import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
|
|
28
|
+
import { createProtocolTail, ensureProtocolFifo, openProtocolWriter } from "./protocol-tail.js";
|
|
28
29
|
import { takeControlRequests } from "./requests.js";
|
|
29
30
|
import { currentHqCloud, hqCloudRunners } from "./hq-cloud.js";
|
|
30
31
|
import { runnerSessionName } from "./remote-control.js";
|
|
@@ -224,6 +225,14 @@ export function unitProcessEnv(base, unit) {
|
|
|
224
225
|
env.NODE_OPTIONS = syncRunnerNodeOptions(env);
|
|
225
226
|
return env;
|
|
226
227
|
}
|
|
228
|
+
function isFifo(file) {
|
|
229
|
+
try {
|
|
230
|
+
return fs.lstatSync(file).isFIFO();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
227
236
|
export async function runDaemon(opts) {
|
|
228
237
|
const env = opts.env ?? process.env;
|
|
229
238
|
const home = opts.home ?? os.homedir();
|
|
@@ -263,6 +272,8 @@ export async function runDaemon(opts) {
|
|
|
263
272
|
process.kill(pid, sig);
|
|
264
273
|
};
|
|
265
274
|
let adopt = [];
|
|
275
|
+
/** Readers of units' protocol FIFOs; a handoff detaches them so the next daemon reads what is left. */
|
|
276
|
+
const protocolTails = new Set();
|
|
266
277
|
if (succession) {
|
|
267
278
|
const taken = takeSuccession(succession, { now: Date.now, readStart: readProcStart, log });
|
|
268
279
|
await stopOrphans(taken.orphans, {
|
|
@@ -271,7 +282,20 @@ export async function runDaemon(opts) {
|
|
|
271
282
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
272
283
|
log,
|
|
273
284
|
});
|
|
274
|
-
adopt = taken.adopt.map((u) =>
|
|
285
|
+
adopt = taken.adopt.map((u) => {
|
|
286
|
+
// A unit that speaks the sync protocol writes it to a FIFO; read what the previous daemon left.
|
|
287
|
+
const protocolPath = paths.unitProtocolPath(u.id);
|
|
288
|
+
const stdout = isFifo(protocolPath) ? createProtocolTail(protocolPath) : undefined;
|
|
289
|
+
const child = adoptChild(u, { readStart: readProcStart, signal: signalPid, ...(stdout ? { stdout } : {}) });
|
|
290
|
+
if (stdout) {
|
|
291
|
+
protocolTails.add(stdout);
|
|
292
|
+
child.on("exit", () => {
|
|
293
|
+
protocolTails.delete(stdout);
|
|
294
|
+
stdout.close();
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return { id: u.id, key: u.key, child };
|
|
298
|
+
});
|
|
275
299
|
}
|
|
276
300
|
const tracker = succession ? createChildTracker(succession.childrenPath, { readStart: readProcStart, log }) : null;
|
|
277
301
|
for (const unit of adopt)
|
|
@@ -381,11 +405,57 @@ export async function runDaemon(opts) {
|
|
|
381
405
|
: [command.file, command.args];
|
|
382
406
|
const logPath = paths.unitLogPath(unit.id);
|
|
383
407
|
const cwd = unit.env.HQ_ROOT && fs.existsSync(unit.env.HQ_ROOT) ? unit.env.HQ_ROOT : home;
|
|
408
|
+
if (unit.heartbeatTimeoutMs && succession) {
|
|
409
|
+
// The supervisor reads this unit's protocol heartbeats from stdout. Where
|
|
410
|
+
// services can outlive the daemon, stdout is a FIFO (see protocol-tail.ts)
|
|
411
|
+
// rather than a pipe, so the unit can be handed to the next daemon.
|
|
412
|
+
const protocolPath = paths.unitProtocolPath(unit.id);
|
|
413
|
+
ensureProtocolFifo(protocolPath);
|
|
414
|
+
const stdout = createProtocolTail(protocolPath);
|
|
415
|
+
const outFd = openProtocolWriter(protocolPath);
|
|
416
|
+
const errFd = openPrivateAppend(logPath);
|
|
417
|
+
try {
|
|
418
|
+
const child = spawn(file, args, {
|
|
419
|
+
cwd,
|
|
420
|
+
env: unitProcessEnv(env, unit),
|
|
421
|
+
stdio: ["ignore", outFd, errFd],
|
|
422
|
+
windowsHide: true,
|
|
423
|
+
});
|
|
424
|
+
spawned.add(child);
|
|
425
|
+
if (unit.service === "sync" && child.pid !== undefined)
|
|
426
|
+
daemonSyncRunnerPids.add(child.pid);
|
|
427
|
+
protocolTails.add(stdout);
|
|
428
|
+
const finish = () => {
|
|
429
|
+
protocolTails.delete(stdout);
|
|
430
|
+
stdout.close();
|
|
431
|
+
};
|
|
432
|
+
child.once("exit", () => {
|
|
433
|
+
spawned.delete(child);
|
|
434
|
+
if (child.pid !== undefined)
|
|
435
|
+
daemonSyncRunnerPids.delete(child.pid);
|
|
436
|
+
finish();
|
|
437
|
+
});
|
|
438
|
+
child.once("error", finish);
|
|
439
|
+
const supervised = {
|
|
440
|
+
pid: child.pid,
|
|
441
|
+
stdout,
|
|
442
|
+
on(event, handler) {
|
|
443
|
+
child.on(event, handler);
|
|
444
|
+
return supervised;
|
|
445
|
+
},
|
|
446
|
+
kill: (signal) => child.kill(signal),
|
|
447
|
+
};
|
|
448
|
+
if (unit.kind === "long-running")
|
|
449
|
+
tracker?.track(unit.id, supervised);
|
|
450
|
+
return supervised;
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
fs.closeSync(outFd);
|
|
454
|
+
fs.closeSync(errFd);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
384
457
|
if (unit.heartbeatTimeoutMs) {
|
|
385
|
-
//
|
|
386
|
-
// output goes through the daemon. A piped child cannot outlive the daemon,
|
|
387
|
-
// so it is never handed over: a handoff stops it and the next daemon
|
|
388
|
-
// starts it again.
|
|
458
|
+
// Elsewhere the service stops with the daemon, so its output can go through a pipe.
|
|
389
459
|
const logFd = openPrivateAppend(logPath);
|
|
390
460
|
const logStream = fs.createWriteStream(logPath, { fd: logFd, autoClose: true });
|
|
391
461
|
logStream.on("error", (err) => log("error", `could not write ${unit.id} log ${logPath}: ${err.message}`));
|
|
@@ -525,6 +595,10 @@ export async function runDaemon(opts) {
|
|
|
525
595
|
return;
|
|
526
596
|
stopping = true;
|
|
527
597
|
const released = supervisor.release();
|
|
598
|
+
// What these readers have not read stays in the FIFO for the next daemon.
|
|
599
|
+
for (const tail of protocolTails)
|
|
600
|
+
tail.detach();
|
|
601
|
+
protocolTails.clear();
|
|
528
602
|
const starts = new Map((tracker?.records() ?? []).map((r) => [r.pid, r.start]));
|
|
529
603
|
const units = released.flatMap((u) => {
|
|
530
604
|
const start = starts.get(u.pid);
|
|
@@ -95,6 +95,8 @@ export declare function adoptChild(record: ChildRecord, deps: {
|
|
|
95
95
|
setInterval?: (fn: () => void, ms: number) => unknown;
|
|
96
96
|
clearInterval?: (handle: unknown) => void;
|
|
97
97
|
pollMs?: number;
|
|
98
|
+
/** Its protocol output, read from the file it writes to (see protocol-tail.ts). */
|
|
99
|
+
stdout?: SupervisedChild["stdout"];
|
|
98
100
|
}): SupervisedChild;
|
|
99
101
|
/** The loaded daemon unit's KillMode, or null when systemctl cannot tell. */
|
|
100
102
|
export declare function systemdKillMode(run: (file: string, args: string[]) => {
|
|
@@ -103,7 +103,7 @@ export function renderText(options) {
|
|
|
103
103
|
const pad = " ".repeat(STATUS_LABEL_WIDTH);
|
|
104
104
|
const lines = [];
|
|
105
105
|
lines.push(`hq doctor — HQ root: ${hqRoot}`);
|
|
106
|
-
lines.push(`
|
|
106
|
+
lines.push(`Agent platform: ${platform.id}`);
|
|
107
107
|
for (const run of families) {
|
|
108
108
|
lines.push("");
|
|
109
109
|
lines.push(`${run.family.title} (${run.family.id})`);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export declare const OUTPOST_SNAPSHOT_REFRESH_LOCK_STALE_AFTER_MS: number;
|
|
3
|
+
export interface SnapshotRefreshChildRuntime {
|
|
4
|
+
pid: number;
|
|
5
|
+
once: (event: "exit", listener: () => void) => unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface StartSnapshotRefreshDeps {
|
|
8
|
+
spawn?: typeof spawn;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
pid?: number;
|
|
11
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
12
|
+
entryPath?: string;
|
|
13
|
+
beforeStaleLockClaim?: () => void;
|
|
14
|
+
writeError?: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function outpostSnapshotRefreshLockPath(root: string, env?: NodeJS.ProcessEnv): string;
|
|
17
|
+
/** Start the detached remote snapshot refresh unless this HQ root is refreshing already. */
|
|
18
|
+
export declare function startOutpostLaneSnapshotRefresh(root: string, env?: NodeJS.ProcessEnv, deps?: StartSnapshotRefreshDeps): void;
|
|
19
|
+
/** Adopt the launcher lock in the detached child and remove it on normal exit. */
|
|
20
|
+
export declare function registerOutpostLaneSnapshotRefreshChild(env?: NodeJS.ProcessEnv, runtime?: SnapshotRefreshChildRuntime): boolean;
|
|
21
|
+
//# sourceMappingURL=outpost-snapshot-refresh.d.ts.map
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
const STATE_DIR_ENV = "HQ_LANES_SNAPSHOT_REFRESH_STATE_DIR";
|
|
7
|
+
const CHILD_LOCK_PATH_ENV = "HQ_INTERNAL_LANES_SNAPSHOT_REFRESH_LOCK_PATH";
|
|
8
|
+
const CHILD_LOCK_TOKEN_ENV = "HQ_INTERNAL_LANES_SNAPSHOT_REFRESH_LOCK_TOKEN";
|
|
9
|
+
// One Outpost summary pickup has a 20-second timeout. Thirty minutes leaves
|
|
10
|
+
// ample time for lists spanning many Outposts while bounding abandoned locks.
|
|
11
|
+
export const OUTPOST_SNAPSHOT_REFRESH_LOCK_STALE_AFTER_MS = 30 * 60 * 1_000;
|
|
12
|
+
function errorMessage(err) {
|
|
13
|
+
return err instanceof Error ? err.message : String(err);
|
|
14
|
+
}
|
|
15
|
+
function writeError(message) {
|
|
16
|
+
process.stderr.write(`${message}\n`);
|
|
17
|
+
}
|
|
18
|
+
export function outpostSnapshotRefreshLockPath(root, env = process.env) {
|
|
19
|
+
const stateBase = env[STATE_DIR_ENV]?.trim() || path.join(env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state"), "hq-cli");
|
|
20
|
+
if (!path.isAbsolute(stateBase))
|
|
21
|
+
throw new Error(`${STATE_DIR_ENV} must resolve to an absolute path`);
|
|
22
|
+
const canonicalRoot = fs.realpathSync(path.resolve(root));
|
|
23
|
+
const rootHash = createHash("sha256").update(canonicalRoot).digest("hex");
|
|
24
|
+
return path.join(stateBase, "lanes-snapshot-refresh", `${rootHash}.lock`);
|
|
25
|
+
}
|
|
26
|
+
function parseLock(filePath, content) {
|
|
27
|
+
let value;
|
|
28
|
+
try {
|
|
29
|
+
value = JSON.parse(content);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
throw new Error(`could not read lock ${filePath}: ${errorMessage(err)}`);
|
|
33
|
+
}
|
|
34
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
35
|
+
throw new Error(`invalid lock data in ${filePath}`);
|
|
36
|
+
}
|
|
37
|
+
const lock = value;
|
|
38
|
+
if (!Number.isSafeInteger(lock.pid) || (lock.pid ?? 0) <= 0 ||
|
|
39
|
+
!Number.isFinite(lock.startedAt) || (lock.startedAt ?? 0) < 0 ||
|
|
40
|
+
typeof lock.token !== "string" || lock.token.length === 0) {
|
|
41
|
+
throw new Error(`invalid lock fields in ${filePath}`);
|
|
42
|
+
}
|
|
43
|
+
return lock;
|
|
44
|
+
}
|
|
45
|
+
function readLock(filePath) {
|
|
46
|
+
let content;
|
|
47
|
+
try {
|
|
48
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new Error(`could not read lock ${filePath}: ${errorMessage(err)}`);
|
|
52
|
+
}
|
|
53
|
+
return parseLock(filePath, content);
|
|
54
|
+
}
|
|
55
|
+
function observeLock(filePath) {
|
|
56
|
+
const descriptor = fs.openSync(filePath, "r");
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.fstatSync(descriptor);
|
|
59
|
+
const content = fs.readFileSync(descriptor, "utf8");
|
|
60
|
+
try {
|
|
61
|
+
return { lock: parseLock(filePath, content), content, stat };
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
return { lock: null, content, stat, invalidReason: errorMessage(err) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
fs.closeSync(descriptor);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function processIsAlive(pid) {
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const code = err.code;
|
|
78
|
+
if (code === "ESRCH")
|
|
79
|
+
return false;
|
|
80
|
+
if (code === "EPERM")
|
|
81
|
+
return true;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function writeLockExclusive(filePath, lock) {
|
|
86
|
+
const descriptor = fs.openSync(filePath, "wx", 0o600);
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(lock)}\n`, "utf8");
|
|
89
|
+
fs.fsyncSync(descriptor);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(filePath);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Keep the original write error as the actionable cause.
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
fs.closeSync(descriptor);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function writeLockReplacement(filePath, lock) {
|
|
105
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
106
|
+
const descriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(lock)}\n`, "utf8");
|
|
109
|
+
fs.fsyncSync(descriptor);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
try {
|
|
113
|
+
fs.unlinkSync(temporaryPath);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Keep the original write error as the actionable cause.
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
fs.closeSync(descriptor);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
fs.renameSync(temporaryPath, filePath);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
try {
|
|
128
|
+
fs.unlinkSync(temporaryPath);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Keep the rename error as the actionable cause.
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function releaseLock(filePath, token) {
|
|
137
|
+
try {
|
|
138
|
+
if (readLock(filePath).token === token)
|
|
139
|
+
fs.unlinkSync(filePath);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err.code !== "ENOENT") {
|
|
143
|
+
writeError(`lanes snapshot refresh could not release its lock: ${errorMessage(err)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function updateLockOwner(filePath, token, pid) {
|
|
148
|
+
const lock = readLock(filePath);
|
|
149
|
+
if (lock.token !== token)
|
|
150
|
+
throw new Error(`lock ownership changed at ${filePath}`);
|
|
151
|
+
writeLockReplacement(filePath, { ...lock, pid });
|
|
152
|
+
}
|
|
153
|
+
function sameFile(left, right) {
|
|
154
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Claim the exact stale inode before unlinking it. Claim paths are stable for
|
|
158
|
+
* one inode, so two contenders cannot both remove the same expired lock.
|
|
159
|
+
*/
|
|
160
|
+
function claimStaleLock(filePath, observed) {
|
|
161
|
+
const claimPath = `${filePath}.claim-${observed.stat.dev}-${observed.stat.ino}`;
|
|
162
|
+
try {
|
|
163
|
+
fs.linkSync(filePath, claimPath);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const code = err.code;
|
|
167
|
+
if (code === "EEXIST" || code === "ENOENT")
|
|
168
|
+
return false;
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
let claimedStaleLock = false;
|
|
172
|
+
let claimError;
|
|
173
|
+
try {
|
|
174
|
+
const claimStat = fs.statSync(claimPath);
|
|
175
|
+
const currentStat = fs.statSync(filePath);
|
|
176
|
+
if (sameFile(observed.stat, claimStat) && sameFile(observed.stat, currentStat)) {
|
|
177
|
+
const claimContent = fs.readFileSync(claimPath, "utf8");
|
|
178
|
+
const currentContent = fs.readFileSync(filePath, "utf8");
|
|
179
|
+
if (claimContent === observed.content && currentContent === observed.content) {
|
|
180
|
+
fs.unlinkSync(filePath);
|
|
181
|
+
claimedStaleLock = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (err.code !== "ENOENT")
|
|
187
|
+
claimError = err;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
fs.unlinkSync(claimPath);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (err.code !== "ENOENT")
|
|
194
|
+
claimError ??= err;
|
|
195
|
+
}
|
|
196
|
+
if (claimError)
|
|
197
|
+
throw claimError;
|
|
198
|
+
return claimedStaleLock;
|
|
199
|
+
}
|
|
200
|
+
function acquireLock(root, env, deps) {
|
|
201
|
+
const filePath = outpostSnapshotRefreshLockPath(root, env);
|
|
202
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
203
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
204
|
+
const lock = {
|
|
205
|
+
pid: deps.pid ?? process.pid,
|
|
206
|
+
startedAt: (deps.now ?? Date.now)(),
|
|
207
|
+
token: randomUUID(),
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
writeLockExclusive(filePath, lock);
|
|
211
|
+
return lock;
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (err.code !== "EEXIST")
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
let existing;
|
|
218
|
+
try {
|
|
219
|
+
existing = observeLock(filePath);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (err.code === "ENOENT")
|
|
223
|
+
continue;
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
const staleSince = existing.lock?.startedAt ?? existing.stat.mtimeMs;
|
|
227
|
+
const ageMs = (deps.now ?? Date.now)() - staleSince;
|
|
228
|
+
const oldEnough = ageMs >= OUTPOST_SNAPSHOT_REFRESH_LOCK_STALE_AFTER_MS;
|
|
229
|
+
if (!existing.lock) {
|
|
230
|
+
if (!oldEnough) {
|
|
231
|
+
throw new Error(`malformed lock ${filePath} is not old enough to recover: ${existing.invalidReason}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const alive = (deps.isProcessAlive ?? processIsAlive)(existing.lock.pid);
|
|
236
|
+
if (!oldEnough || alive)
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
deps.beforeStaleLockClaim?.();
|
|
240
|
+
if (!claimStaleLock(filePath, existing))
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
// Another caller may have replaced a stale lock during recovery. Fail closed
|
|
244
|
+
// and let the next list call retry rather than risking a second refresh.
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
/** Start the detached remote snapshot refresh unless this HQ root is refreshing already. */
|
|
248
|
+
export function startOutpostLaneSnapshotRefresh(root, env = process.env, deps = {}) {
|
|
249
|
+
const report = deps.writeError ?? writeError;
|
|
250
|
+
let entry;
|
|
251
|
+
try {
|
|
252
|
+
const currentEntry = deps.entryPath ?? process.argv[1];
|
|
253
|
+
if (!currentEntry)
|
|
254
|
+
throw new Error("process.argv[1] is empty");
|
|
255
|
+
entry = fs.realpathSync(currentEntry);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
report(`lanes snapshot refresh could not resolve the running CLI entry: ${errorMessage(err)}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let lock;
|
|
262
|
+
let filePath;
|
|
263
|
+
try {
|
|
264
|
+
filePath = outpostSnapshotRefreshLockPath(root, env);
|
|
265
|
+
lock = acquireLock(root, env, deps);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
report(`lanes snapshot refresh could not acquire its lock: ${errorMessage(err)}`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!lock)
|
|
272
|
+
return;
|
|
273
|
+
let child;
|
|
274
|
+
try {
|
|
275
|
+
const childEnv = {
|
|
276
|
+
...env,
|
|
277
|
+
HQ_ROOT: root,
|
|
278
|
+
HQ_NO_UPDATE_CHECK: "1",
|
|
279
|
+
HQ_INTERNAL_LANES_REFRESH_REMOTE_SNAPSHOT: "1",
|
|
280
|
+
[CHILD_LOCK_PATH_ENV]: filePath,
|
|
281
|
+
[CHILD_LOCK_TOKEN_ENV]: lock.token,
|
|
282
|
+
};
|
|
283
|
+
const spawnChild = deps.spawn ?? spawn;
|
|
284
|
+
child = spawnChild(process.execPath, [...process.execArgv, entry, "lanes", "list", "--json"], {
|
|
285
|
+
cwd: root,
|
|
286
|
+
detached: true,
|
|
287
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
288
|
+
env: childEnv,
|
|
289
|
+
});
|
|
290
|
+
child.once("error", (err) => {
|
|
291
|
+
releaseLock(filePath, lock.token);
|
|
292
|
+
report(`lanes snapshot refresh could not start: ${errorMessage(err)}`);
|
|
293
|
+
});
|
|
294
|
+
if (child.pid)
|
|
295
|
+
updateLockOwner(filePath, lock.token, child.pid);
|
|
296
|
+
child.unref();
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
try {
|
|
300
|
+
child?.kill();
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// Spawn failure is already reported below; the lock is still released.
|
|
304
|
+
}
|
|
305
|
+
releaseLock(filePath, lock.token);
|
|
306
|
+
report(`lanes snapshot refresh could not start: ${errorMessage(err)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Adopt the launcher lock in the detached child and remove it on normal exit. */
|
|
310
|
+
export function registerOutpostLaneSnapshotRefreshChild(env = process.env, runtime = process) {
|
|
311
|
+
const filePath = env[CHILD_LOCK_PATH_ENV];
|
|
312
|
+
const token = env[CHILD_LOCK_TOKEN_ENV];
|
|
313
|
+
if (!filePath && !token)
|
|
314
|
+
return false;
|
|
315
|
+
if (!filePath || !token) {
|
|
316
|
+
writeError("lanes snapshot refresh child has incomplete lock metadata");
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
updateLockOwner(filePath, token, runtime.pid);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
writeError(`lanes snapshot refresh child could not register its lock: ${errorMessage(err)}`);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
runtime.once("exit", () => releaseLock(filePath, token));
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
//# sourceMappingURL=outpost-snapshot-refresh.js.map
|