@indigoai-us/hq-cli 5.342.11 → 5.342.12
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 +1 -1
- package/dist/command-catalog.generated.js +1 -1
- package/dist/commands/lanes-keep-alive-audit.js +1 -1
- package/dist/lib/lanes/keep-alive-audit.d.ts +2 -1
- package/dist/lib/lanes/keep-alive-audit.js +73 -8
- package/dist/lib/lanes/spawn.js +79 -0
- package/dist/utils/package-use-lease.d.ts +15 -0
- package/dist/utils/package-use-lease.js +94 -1
- package/dist/utils/self-update.d.ts +5 -0
- package/dist/utils/self-update.js +100 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.342.12] — 2026-10-04
|
|
6
|
+
|
|
7
|
+
- Record every lane worker resume with its launch time and trigger, and classify watcher, monitor-event, and pending-work wakes separately from keep-alive timer accuracy.
|
|
8
|
+
|
|
9
|
+
- Hand off the package-use lease through a non-JSON marker after a re-executed CLI publishes its matching lease, and forward parent-only stop signals to the child.
|
|
10
|
+
|
|
5
11
|
## [5.342.11] — 2026-10-04
|
|
6
12
|
- Release the CLI package-use lease before interactive login waits after resident modules preload.
|
|
7
13
|
- Show the cached version update nudge after interactive commands when a newer cached release remains and its feature flag is enabled; avoid waiting for registry readiness when there is no nudge to show or repeating guidance after the updater already reported its result.
|
|
@@ -11147,7 +11147,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
11147
11147
|
readonly subcommands: readonly [];
|
|
11148
11148
|
}, {
|
|
11149
11149
|
readonly name: "keep-alive-audit";
|
|
11150
|
-
readonly description: "Audit lead lane keep-alive timing.
|
|
11150
|
+
readonly description: "Audit lead lane keep-alive timing. Durable worker-resume events retain launch time and trigger after bounded lane history rolls over; late_restarts_after_window counts known launches after the grace window.";
|
|
11151
11151
|
readonly aliases: readonly [];
|
|
11152
11152
|
readonly hidden: false;
|
|
11153
11153
|
readonly helpOption: true;
|
|
@@ -13537,7 +13537,7 @@ export const COMMAND_CATALOG = [
|
|
|
13537
13537
|
},
|
|
13538
13538
|
{
|
|
13539
13539
|
"name": "keep-alive-audit",
|
|
13540
|
-
"description": "Audit lead lane keep-alive timing.
|
|
13540
|
+
"description": "Audit lead lane keep-alive timing. Durable worker-resume events retain launch time and trigger after bounded lane history rolls over; late_restarts_after_window counts known launches after the grace window.",
|
|
13541
13541
|
"aliases": [],
|
|
13542
13542
|
"hidden": false,
|
|
13543
13543
|
"helpOption": true,
|
|
@@ -2,7 +2,7 @@ import { buildKeepAliveAudit, formatKeepAliveAuditText } from "../lib/lanes/keep
|
|
|
2
2
|
export function registerLaneKeepAliveAuditCommand(parent, deps) {
|
|
3
3
|
parent
|
|
4
4
|
.command("keep-alive-audit")
|
|
5
|
-
.description("Audit lead lane keep-alive timing.
|
|
5
|
+
.description("Audit lead lane keep-alive timing. Durable worker-resume events retain launch time and trigger after bounded lane history rolls over; late_restarts_after_window counts known launches after the grace window.")
|
|
6
6
|
.option("--since <iso>", "Include stopped turns at or after this ISO date-time")
|
|
7
7
|
.option("--lane <id>", "Audit one exact lane id")
|
|
8
8
|
.option("--json", "Print the report as JSON")
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "self_wake" | "early_by_keep_alive" | "restarted_untimed" | "not_restarted" | "keep_alive_off" | "waiting";
|
|
1
|
+
type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "work_triggered" | "self_wake" | "early_by_keep_alive" | "restarted_untimed" | "not_restarted" | "keep_alive_off" | "waiting";
|
|
2
2
|
interface KeepAliveAuditRestart {
|
|
3
3
|
at: string | null;
|
|
4
4
|
trigger: string;
|
|
5
5
|
caller: string | null;
|
|
6
|
+
workerEvent?: boolean;
|
|
6
7
|
}
|
|
7
8
|
interface KeepAliveAuditRow {
|
|
8
9
|
lane: string;
|
|
@@ -55,6 +55,7 @@ const VERDICTS = [
|
|
|
55
55
|
"late",
|
|
56
56
|
"early_wake",
|
|
57
57
|
"message_wake",
|
|
58
|
+
"work_triggered",
|
|
58
59
|
"self_wake",
|
|
59
60
|
"early_by_keep_alive",
|
|
60
61
|
"restarted_untimed",
|
|
@@ -68,6 +69,50 @@ function parseTime(value) {
|
|
|
68
69
|
const parsed = Date.parse(value);
|
|
69
70
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
70
71
|
}
|
|
72
|
+
function readLaneWorkerResumeEvents(hqRoot, laneId) {
|
|
73
|
+
const root = fs.realpathSync(hqRoot);
|
|
74
|
+
const eventsPath = path.join(laneRunDir(root, laneId), "watcher", "events.jsonl");
|
|
75
|
+
if (!withinRoot(root, eventsPath)) {
|
|
76
|
+
throw new LanesError("keep_alive_audit_path_invalid", `Lane watcher event path escapes the HQ root for ${laneId}`, { lane_id: laneId });
|
|
77
|
+
}
|
|
78
|
+
let text;
|
|
79
|
+
try {
|
|
80
|
+
const stat = fs.lstatSync(eventsPath);
|
|
81
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
82
|
+
throw new LanesError("keep_alive_audit_event_invalid", `Lane watcher events must be a regular file: ${eventsPath}`, { lane_id: laneId, path: eventsPath });
|
|
83
|
+
}
|
|
84
|
+
const realPath = fs.realpathSync(eventsPath);
|
|
85
|
+
if (!withinRoot(root, realPath)) {
|
|
86
|
+
throw new LanesError("keep_alive_audit_path_invalid", `Lane watcher event file escapes the HQ root for ${laneId}`, { lane_id: laneId, path: eventsPath });
|
|
87
|
+
}
|
|
88
|
+
text = fs.readFileSync(realPath, "utf8");
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err.code === "ENOENT")
|
|
92
|
+
return [];
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
const events = [];
|
|
96
|
+
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
|
97
|
+
if (!line.trim())
|
|
98
|
+
continue;
|
|
99
|
+
let value;
|
|
100
|
+
try {
|
|
101
|
+
value = JSON.parse(line);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!isRecord(value) || value.event !== "lane_worker_resumed")
|
|
107
|
+
continue;
|
|
108
|
+
const at = parseTime(value.resumed_at);
|
|
109
|
+
if (at === undefined || typeof value.reason !== "string" || !value.reason.trim()) {
|
|
110
|
+
throw new LanesError("keep_alive_audit_event_invalid", `Lane worker resume event has invalid timing or reason: ${eventsPath}:${index + 1}`, { lane_id: laneId, path: eventsPath, line: index + 1 });
|
|
111
|
+
}
|
|
112
|
+
events.push({ at, reason: value.reason, caller: typeof value.caller === "string" ? value.caller : null });
|
|
113
|
+
}
|
|
114
|
+
return events.sort((a, b) => a.at - b.at);
|
|
115
|
+
}
|
|
71
116
|
function iso(ms) {
|
|
72
117
|
return new Date(ms).toISOString();
|
|
73
118
|
}
|
|
@@ -176,9 +221,7 @@ function readKeepAliveResumeEvents(hqRoot, laneId) {
|
|
|
176
221
|
if (nextEligibleAt === undefined) {
|
|
177
222
|
throw new LanesError("keep_alive_audit_event_invalid", `Keep-alive resume event has invalid timing fields: ${eventsPath}:${index + 1}`, { lane_id: laneId, path: eventsPath, line: index + 1 });
|
|
178
223
|
}
|
|
179
|
-
const reason =
|
|
180
|
-
? value.reason
|
|
181
|
-
: undefined;
|
|
224
|
+
const reason = typeof value.reason === "string" ? value.reason : undefined;
|
|
182
225
|
events.push({ at, nextEligibleAt, ...(reason === undefined ? {} : { reason }) });
|
|
183
226
|
}
|
|
184
227
|
return events.sort((a, b) => a.at - b.at);
|
|
@@ -286,7 +329,18 @@ function laneWasKeepAliveOffAtStop(lane, stoppedAt) {
|
|
|
286
329
|
const optedInAt = parseTime(lane.keep_alive_opted_in_at);
|
|
287
330
|
return optedInAt === undefined || optedInAt > stoppedAt;
|
|
288
331
|
}
|
|
289
|
-
function firstRestartAfter(lane, stop, nextEnvelopeMs) {
|
|
332
|
+
function firstRestartAfter(lane, stop, nextEnvelopeMs, workerResumeEvents) {
|
|
333
|
+
const recordedWorkerResume = workerResumeEvents
|
|
334
|
+
.filter((event) => event.at > stop.mtimeMs && (nextEnvelopeMs === undefined || event.at < nextEnvelopeMs))
|
|
335
|
+
.sort((a, b) => a.at - b.at)[0];
|
|
336
|
+
if (recordedWorkerResume !== undefined) {
|
|
337
|
+
return {
|
|
338
|
+
at: iso(recordedWorkerResume.at),
|
|
339
|
+
trigger: recordedWorkerResume.reason,
|
|
340
|
+
caller: recordedWorkerResume.caller,
|
|
341
|
+
workerEvent: true,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
290
344
|
const restart = (lane.resume_history ?? [])
|
|
291
345
|
.map((entry) => ({ entry, at: parseTime(entry.at) }))
|
|
292
346
|
.filter(({ entry, at }) => at !== undefined && at > stop.mtimeMs && (nextEnvelopeMs === undefined || at < nextEnvelopeMs) && typeof entry.trigger === "string")
|
|
@@ -329,6 +383,14 @@ function classifyVerdict(input) {
|
|
|
329
383
|
if (restart?.trigger === "unknown" && laneWasKeepAliveOffAtStop(lane, input.stoppedAt))
|
|
330
384
|
return "keep_alive_off";
|
|
331
385
|
if (restart) {
|
|
386
|
+
if (restart.trigger === "pending_work" || restart.trigger === "watcher" || restart.trigger === "monitor_event")
|
|
387
|
+
return "work_triggered";
|
|
388
|
+
const workEvidenceEnd = input.nextEnvelopeAt ?? Number.POSITIVE_INFINITY;
|
|
389
|
+
const triggeredWork = restart.workerEvent !== true && input.resumeEvents
|
|
390
|
+
.filter((event) => event.at > input.stoppedAt && event.at < workEvidenceEnd && event.at <= (restart.at === null ? Number.POSITIVE_INFINITY : Date.parse(restart.at)))
|
|
391
|
+
.some((event) => event.reason === "pending_work" || event.reason === "watcher" || event.reason === "monitor_event");
|
|
392
|
+
if (triggeredWork)
|
|
393
|
+
return "work_triggered";
|
|
332
394
|
if (restart.at === null)
|
|
333
395
|
return "restarted_untimed";
|
|
334
396
|
const restartAt = Date.parse(restart.at);
|
|
@@ -336,12 +398,14 @@ function classifyVerdict(input) {
|
|
|
336
398
|
return "self_wake";
|
|
337
399
|
if (restart.trigger === "message" || restart.trigger === "answered_questions")
|
|
338
400
|
return "message_wake";
|
|
339
|
-
if (restart.trigger === "keep_alive") {
|
|
401
|
+
if (restart.trigger === "keep_alive" || restart.trigger === "next_check_at_due" || restart.trigger === "backoff") {
|
|
340
402
|
const event = input.resumeEvents
|
|
341
403
|
.filter((candidate) => candidate.at > input.stoppedAt && candidate.at <= restartAt + 2 * 60_000)
|
|
342
404
|
.sort((left, right) => Math.abs(left.at - restartAt) - Math.abs(right.at - restartAt))[0];
|
|
343
405
|
if (event?.reason === "pending_work")
|
|
344
|
-
return "
|
|
406
|
+
return "work_triggered";
|
|
407
|
+
if (event?.reason === "watcher" || event?.reason === "monitor_event")
|
|
408
|
+
return "work_triggered";
|
|
345
409
|
if (event?.reason === undefined &&
|
|
346
410
|
(hasDeliveredDropboxEvidence(input.hqRoot, lane.lane_id, input.stoppedAt, restartAt) ||
|
|
347
411
|
hasMessageResumeEvidence(lane, input.stoppedAt, restartAt) ||
|
|
@@ -384,6 +448,7 @@ export function buildKeepAliveAudit(input) {
|
|
|
384
448
|
try {
|
|
385
449
|
const { allMtimes, stops } = readStopEnvelopes(input.hqRoot, lane.lane_id);
|
|
386
450
|
const keepAliveResumeEvents = readKeepAliveResumeEvents(input.hqRoot, lane.lane_id);
|
|
451
|
+
const laneWorkerResumeEvents = readLaneWorkerResumeEvents(input.hqRoot, lane.lane_id);
|
|
387
452
|
const doneClassifications = readDoneClassifications(input.hqRoot, lane.lane_id);
|
|
388
453
|
for (const stop of stops) {
|
|
389
454
|
if (stop.mtimeMs < sinceMs || stop.mtimeMs > nowMs)
|
|
@@ -391,7 +456,7 @@ export function buildKeepAliveAudit(input) {
|
|
|
391
456
|
if (!doneClassifications.some((at) => at >= stop.mtimeMs && at - stop.mtimeMs <= 10 * 60_000))
|
|
392
457
|
continue;
|
|
393
458
|
const nextEnvelopeMs = allMtimes.find((mtime) => mtime > stop.mtimeMs);
|
|
394
|
-
const restart = firstRestartAfter(lane, stop, nextEnvelopeMs);
|
|
459
|
+
const restart = firstRestartAfter(lane, stop, nextEnvelopeMs, laneWorkerResumeEvents);
|
|
395
460
|
const due = dueTime(stop, keepAliveResumeEvents);
|
|
396
461
|
rows.push({
|
|
397
462
|
lane: lane.lane_id,
|
|
@@ -402,7 +467,7 @@ export function buildKeepAliveAudit(input) {
|
|
|
402
467
|
due_at: iso(due.at),
|
|
403
468
|
due_reason: due.reason,
|
|
404
469
|
restart,
|
|
405
|
-
verdict: classifyVerdict({ dueAt: due.at, stoppedAt: stop.mtimeMs, nextCheckAt: stop.nextCheckAt, restart, lane, nowMs, resumeEvents: keepAliveResumeEvents, hqRoot: input.hqRoot }),
|
|
470
|
+
verdict: classifyVerdict({ dueAt: due.at, stoppedAt: stop.mtimeMs, nextCheckAt: stop.nextCheckAt, restart, lane, nowMs, resumeEvents: keepAliveResumeEvents, ...(nextEnvelopeMs === undefined ? {} : { nextEnvelopeAt: nextEnvelopeMs }), hqRoot: input.hqRoot }),
|
|
406
471
|
});
|
|
407
472
|
}
|
|
408
473
|
}
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -56,6 +56,70 @@ const PROVIDER_COMMAND = {
|
|
|
56
56
|
codex: "codex",
|
|
57
57
|
grok: "grok",
|
|
58
58
|
};
|
|
59
|
+
function keepAliveResumeReason(runDir, pendingResume) {
|
|
60
|
+
if (pendingResume?.keep_alive !== true || typeof pendingResume.at !== "string")
|
|
61
|
+
return undefined;
|
|
62
|
+
const eventsPath = path.join(runDir, "watcher", "events.jsonl");
|
|
63
|
+
let content;
|
|
64
|
+
try {
|
|
65
|
+
content = fs.readFileSync(eventsPath, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err.code === "ENOENT")
|
|
69
|
+
return "keep_alive";
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
for (const line of content.split(/\r?\n/).reverse()) {
|
|
73
|
+
if (!line.trim())
|
|
74
|
+
continue;
|
|
75
|
+
let value;
|
|
76
|
+
try {
|
|
77
|
+
value = JSON.parse(line);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
83
|
+
const event = value;
|
|
84
|
+
if (event.event === "keep_alive_resume" && event.at === pendingResume.at && typeof event.reason === "string") {
|
|
85
|
+
return event.reason;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return "keep_alive";
|
|
90
|
+
}
|
|
91
|
+
function appendLaneWorkerResumeEvent(input) {
|
|
92
|
+
const eventsPath = path.join(laneRunDir(input.hqRoot, input.laneId), "watcher", "events.jsonl");
|
|
93
|
+
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
94
|
+
let separator = "";
|
|
95
|
+
try {
|
|
96
|
+
const fd = fs.openSync(eventsPath, "r");
|
|
97
|
+
try {
|
|
98
|
+
const size = fs.fstatSync(fd).size;
|
|
99
|
+
if (size > 0) {
|
|
100
|
+
const lastByte = Buffer.alloc(1);
|
|
101
|
+
fs.readSync(fd, lastByte, 0, 1, size - 1);
|
|
102
|
+
if (lastByte[0] !== 0x0a)
|
|
103
|
+
separator = "\n";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
fs.closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (err.code !== "ENOENT")
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
fs.appendFileSync(eventsPath, `${separator}${JSON.stringify({
|
|
115
|
+
event: "lane_worker_resumed",
|
|
116
|
+
lane_id: input.laneId,
|
|
117
|
+
resumed_at: input.resumedAt,
|
|
118
|
+
reason: input.reason,
|
|
119
|
+
...(input.caller === undefined ? {} : { caller: input.caller }),
|
|
120
|
+
...(input.turnId === undefined ? {} : { turn_id: input.turnId }),
|
|
121
|
+
})}\n`, "utf8");
|
|
122
|
+
}
|
|
59
123
|
export function providerCommandOnPath(command, pathEnv) {
|
|
60
124
|
// Test and embedded callers can intentionally omit PATH and delegate lookup
|
|
61
125
|
// to their spawn implementation. A present PATH is authoritative.
|
|
@@ -2081,6 +2145,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2081
2145
|
trigger.kind === "resume" || trigger.kind === "message"
|
|
2082
2146
|
? "user"
|
|
2083
2147
|
: undefined);
|
|
2148
|
+
const resumeReason = keepAliveResumeReason(runDir, pendingResume) ?? resumeHistoryTrigger;
|
|
2084
2149
|
let statusGateHeartbeat;
|
|
2085
2150
|
try {
|
|
2086
2151
|
statusGateHeartbeat = managerStatusGateHeartbeat(hqRoot, existing, laneTurnId, nowIso);
|
|
@@ -2356,6 +2421,14 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2356
2421
|
...(resumeHistoryCaller ? { caller: resumeHistoryCaller } : {}),
|
|
2357
2422
|
}),
|
|
2358
2423
|
}));
|
|
2424
|
+
appendLaneWorkerResumeEvent({
|
|
2425
|
+
hqRoot,
|
|
2426
|
+
laneId: lane.lane_id,
|
|
2427
|
+
resumedAt: nowIso,
|
|
2428
|
+
reason: resumeReason,
|
|
2429
|
+
...(resumeHistoryCaller === undefined ? {} : { caller: resumeHistoryCaller }),
|
|
2430
|
+
turnId: laneTurnId,
|
|
2431
|
+
});
|
|
2359
2432
|
}
|
|
2360
2433
|
return laneLaunchResult(readLane(hqRoot, lane.lane_id));
|
|
2361
2434
|
}
|
|
@@ -2670,6 +2743,12 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
2670
2743
|
...(callerLaneId ? { caller: callerLaneId } : {}),
|
|
2671
2744
|
}),
|
|
2672
2745
|
}));
|
|
2746
|
+
appendLaneWorkerResumeEvent({
|
|
2747
|
+
hqRoot,
|
|
2748
|
+
laneId: lane.lane_id,
|
|
2749
|
+
resumedAt: startedAt,
|
|
2750
|
+
reason: "envelope_repair",
|
|
2751
|
+
});
|
|
2673
2752
|
return workerPid;
|
|
2674
2753
|
}
|
|
2675
2754
|
export { PROVIDER_HOME_ENV, PROVIDER_COMMAND };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
|
|
1
2
|
export declare class PackageUseUpdatePendingError extends Error {
|
|
2
3
|
constructor(message: string);
|
|
3
4
|
}
|
|
@@ -10,11 +11,24 @@ export interface PackageUseLeaseRecord {
|
|
|
10
11
|
start_time_ms: number;
|
|
11
12
|
hq_version: string;
|
|
12
13
|
}
|
|
14
|
+
export interface PackageUseLeaseHandoff {
|
|
15
|
+
readonly envValue: string;
|
|
16
|
+
waitForChild(pid: number, childAlive: () => boolean): Promise<boolean>;
|
|
17
|
+
cleanup(): void;
|
|
18
|
+
}
|
|
13
19
|
export declare function npmPrefix(root: string, platform?: NodeJS.Platform): string | null;
|
|
14
20
|
export declare function packageUseLeaseDirectory(prefix: string, stateDirectory?: string): string;
|
|
15
21
|
export declare function packageUseUpdateRequestPath(prefix: string, stateDirectory?: string): string;
|
|
16
22
|
declare function waitForUpdateRequest(requestPath: string, timeoutMs: number): void;
|
|
17
23
|
export declare function acquirePackageUseLease(prefix: string, hqVersion: string, stateDirectory?: string): PackageUseLease;
|
|
24
|
+
/**
|
|
25
|
+
* Create a bounded, per-reexec rendezvous in the current lease directory.
|
|
26
|
+
* The child confirms readiness only after publishing its own atomic lease.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createPackageUseLeaseHandoff(lease: PackageUseLease, timeoutMs?: number): PackageUseLeaseHandoff;
|
|
29
|
+
export declare function createCurrentPackageUseLeaseHandoff(timeoutMs?: number): PackageUseLeaseHandoff | null;
|
|
30
|
+
/** Publish this process's lease identity to a parent waiting in the same prefix. */
|
|
31
|
+
export declare function publishPackageUseLeaseHandoff(lease: PackageUseLease, env?: NodeJS.ProcessEnv): boolean;
|
|
18
32
|
export declare function acquireCurrentPackageUseLease(): PackageUseLease | null;
|
|
19
33
|
/** Release this process's package-use lease after a long-running command has loaded its package modules. */
|
|
20
34
|
export declare function releaseCurrentPackageUseLease(): void;
|
|
@@ -30,6 +44,7 @@ export declare function currentPackageUseUpdatePending(): boolean;
|
|
|
30
44
|
export declare function currentPackageUseUpdateRequested(): boolean;
|
|
31
45
|
export declare const __test__: {
|
|
32
46
|
waitForUpdateRequest: typeof waitForUpdateRequest;
|
|
47
|
+
setCurrentPackageUseLease(lease: PackageUseLease | null): void;
|
|
33
48
|
};
|
|
34
49
|
export {};
|
|
35
50
|
//# sourceMappingURL=package-use-lease.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
7
|
const CLI_NAME = "@indigoai-us/hq-cli";
|
|
8
8
|
const UPDATE_REQUEST = "update.pending.json";
|
|
9
|
+
export const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
|
|
10
|
+
const HANDOFF_TIMEOUT_MS = 2_000;
|
|
9
11
|
export class PackageUseUpdatePendingError extends Error {
|
|
10
12
|
constructor(message) {
|
|
11
13
|
super(message);
|
|
@@ -172,6 +174,93 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory) {
|
|
|
172
174
|
}
|
|
173
175
|
throw new PackageUseUpdatePendingError("An HQ CLI update is waiting for the package lease; retry when it finishes");
|
|
174
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Create a bounded, per-reexec rendezvous in the current lease directory.
|
|
179
|
+
* The child confirms readiness only after publishing its own atomic lease.
|
|
180
|
+
*/
|
|
181
|
+
export function createPackageUseLeaseHandoff(lease, timeoutMs = HANDOFF_TIMEOUT_MS) {
|
|
182
|
+
const directory = path.dirname(lease.leasePath);
|
|
183
|
+
const markerPath = path.join(directory, `.reexec-${process.pid}-${randomBytes(12).toString("hex")}.handoff`);
|
|
184
|
+
let cleaned = false;
|
|
185
|
+
const cleanup = () => {
|
|
186
|
+
if (cleaned)
|
|
187
|
+
return;
|
|
188
|
+
cleaned = true;
|
|
189
|
+
process.removeListener("exit", cleanup);
|
|
190
|
+
try {
|
|
191
|
+
rmSync(markerPath, { force: true });
|
|
192
|
+
rmSync(`${markerPath}.tmp`, { force: true });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
process.stderr.write(`hq-cli package-use lease handoff cleanup failed (${errorLabel(error)})\n`);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
process.once("exit", cleanup);
|
|
199
|
+
return {
|
|
200
|
+
envValue: markerPath,
|
|
201
|
+
async waitForChild(pid, childAlive) {
|
|
202
|
+
const deadline = Date.now() + timeoutMs;
|
|
203
|
+
while (childAlive() && Date.now() <= deadline) {
|
|
204
|
+
try {
|
|
205
|
+
const marker = JSON.parse(readFileSync(markerPath, "utf8"));
|
|
206
|
+
if (marker.pid === pid && Number.isSafeInteger(marker.start_time_ms) && typeof marker.lease_name === "string") {
|
|
207
|
+
const leasePath = path.join(directory, marker.lease_name);
|
|
208
|
+
if (path.basename(leasePath) === marker.lease_name) {
|
|
209
|
+
const record = JSON.parse(readFileSync(leasePath, "utf8"));
|
|
210
|
+
if (record.pid === pid && record.start_time_ms === marker.start_time_ms)
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) {
|
|
217
|
+
process.stderr.write(`hq-cli package-use lease handoff check failed (${errorLabel(error)})\n`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
},
|
|
224
|
+
cleanup,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export function createCurrentPackageUseLeaseHandoff(timeoutMs = HANDOFF_TIMEOUT_MS) {
|
|
228
|
+
return currentPackageUseLease ? createPackageUseLeaseHandoff(currentPackageUseLease, timeoutMs) : null;
|
|
229
|
+
}
|
|
230
|
+
/** Publish this process's lease identity to a parent waiting in the same prefix. */
|
|
231
|
+
export function publishPackageUseLeaseHandoff(lease, env = process.env) {
|
|
232
|
+
const markerPath = env[PACKAGE_USE_LEASE_HANDOFF_ENV];
|
|
233
|
+
if (!markerPath)
|
|
234
|
+
return false;
|
|
235
|
+
const directory = path.dirname(lease.leasePath);
|
|
236
|
+
if (path.dirname(path.resolve(markerPath)) !== path.resolve(directory)) {
|
|
237
|
+
delete env[PACKAGE_USE_LEASE_HANDOFF_ENV];
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
const match = /^(\d+)-(\d+)\.json$/.exec(path.basename(lease.leasePath));
|
|
241
|
+
if (!match || Number(match[1]) !== process.pid) {
|
|
242
|
+
delete env[PACKAGE_USE_LEASE_HANDOFF_ENV];
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const record = {
|
|
246
|
+
pid: process.pid,
|
|
247
|
+
start_time_ms: Number(match[2]),
|
|
248
|
+
lease_name: path.basename(lease.leasePath),
|
|
249
|
+
};
|
|
250
|
+
const tempPath = `${markerPath}.tmp`;
|
|
251
|
+
try {
|
|
252
|
+
writeFileSync(tempPath, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 });
|
|
253
|
+
renameSync(tempPath, markerPath);
|
|
254
|
+
delete env[PACKAGE_USE_LEASE_HANDOFF_ENV];
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
rmSync(tempPath, { force: true });
|
|
259
|
+
delete env[PACKAGE_USE_LEASE_HANDOFF_ENV];
|
|
260
|
+
process.stderr.write(`hq-cli package-use lease handoff publish failed (${errorLabel(error)})\n`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
175
264
|
function exists(file) {
|
|
176
265
|
try {
|
|
177
266
|
readFileSync(file);
|
|
@@ -198,6 +287,7 @@ export function acquireCurrentPackageUseLease() {
|
|
|
198
287
|
}
|
|
199
288
|
const lease = acquirePackageUseLease(prefix, pkg.version);
|
|
200
289
|
currentPackageUseLease = lease;
|
|
290
|
+
publishPackageUseLeaseHandoff(lease);
|
|
201
291
|
return lease;
|
|
202
292
|
}
|
|
203
293
|
/** Release this process's package-use lease after a long-running command has loaded its package modules. */
|
|
@@ -260,5 +350,8 @@ export function currentPackageUseUpdateRequested() {
|
|
|
260
350
|
}
|
|
261
351
|
export const __test__ = {
|
|
262
352
|
waitForUpdateRequest,
|
|
353
|
+
setCurrentPackageUseLease(lease) {
|
|
354
|
+
currentPackageUseLease = lease;
|
|
355
|
+
},
|
|
263
356
|
};
|
|
264
357
|
//# sourceMappingURL=package-use-lease.js.map
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
* Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
|
|
57
57
|
* version-check), `hq rescue --no-self-update`, and the re-exec guard env.
|
|
58
58
|
*/
|
|
59
|
+
import { spawn } from "node:child_process";
|
|
59
60
|
import * as fs from "node:fs";
|
|
60
61
|
import { type RunningInstall, type UpdateResult } from "./version-gate.js";
|
|
61
62
|
import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
|
|
@@ -145,6 +146,10 @@ export interface SelfUpdateDeps {
|
|
|
145
146
|
renamePath?: typeof fs.renameSync;
|
|
146
147
|
runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
|
|
147
148
|
reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
|
|
149
|
+
/** Async process-spawn seam for lease handoff tests. */
|
|
150
|
+
spawnReexec?: typeof spawn;
|
|
151
|
+
/** stdin TTY probe for re-exec signal forwarding tests. */
|
|
152
|
+
stdinIsTTY?: () => boolean;
|
|
148
153
|
acquireLock?: () => (() => void) | null;
|
|
149
154
|
/**
|
|
150
155
|
* Read-your-writes convergence check run after a "successful" install:
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
* Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
|
|
57
57
|
* version-check), `hq rescue --no-self-update`, and the re-exec guard env.
|
|
58
58
|
*/
|
|
59
|
-
import {
|
|
59
|
+
import { spawn } from "node:child_process";
|
|
60
60
|
import { randomBytes } from "node:crypto";
|
|
61
61
|
import * as fs from "node:fs";
|
|
62
62
|
import * as path from "node:path";
|
|
@@ -70,6 +70,7 @@ import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
|
|
|
70
70
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
71
71
|
import { markLatestIneffective } from "./version-check.js";
|
|
72
72
|
import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
|
|
73
|
+
import { createCurrentPackageUseLeaseHandoff, PACKAGE_USE_LEASE_HANDOFF_ENV, releaseCurrentPackageUseLease, } from "./package-use-lease.js";
|
|
73
74
|
/**
|
|
74
75
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
75
76
|
* One update + one re-exec per user invocation, ever.
|
|
@@ -596,18 +597,101 @@ export function acquireUpdateLock(now = Date.now()) {
|
|
|
596
597
|
* command. Returns the child's exit status, or null when the child could not be
|
|
597
598
|
* started at all (e.g. `hq` not on PATH in a minimal-PATH parent).
|
|
598
599
|
*/
|
|
599
|
-
function reexecHq(argv, env) {
|
|
600
|
+
async function reexecHq(argv, env, spawnProcess = spawn, stdinIsTTY = () => process.stdin.isTTY === true) {
|
|
600
601
|
const plan = buildSpawnPlan("hq", argv);
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
env
|
|
605
|
-
|
|
606
|
-
|
|
602
|
+
const handoff = createCurrentPackageUseLeaseHandoff();
|
|
603
|
+
const childEnv = handoff
|
|
604
|
+
? { ...env, [PACKAGE_USE_LEASE_HANDOFF_ENV]: handoff.envValue }
|
|
605
|
+
: env;
|
|
606
|
+
let childForSignals;
|
|
607
|
+
let childSpawned = false;
|
|
608
|
+
let childClosed = false;
|
|
609
|
+
let spawnedChild;
|
|
610
|
+
const pendingSignals = [];
|
|
611
|
+
const reexecSignals = process.platform === "win32"
|
|
612
|
+
? ["SIGINT", "SIGTERM"]
|
|
613
|
+
: ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
614
|
+
const forwardSignal = (signal) => {
|
|
615
|
+
if (signal === "SIGINT" && stdinIsTTY())
|
|
616
|
+
return;
|
|
617
|
+
if (childClosed)
|
|
618
|
+
return;
|
|
619
|
+
if (!childForSignals || !childSpawned) {
|
|
620
|
+
pendingSignals.push(signal);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (childForSignals.exitCode === null && childForSignals.signalCode === null) {
|
|
624
|
+
childForSignals.kill(signal);
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const signalHandlers = new Map(reexecSignals.map((signal) => [signal, () => forwardSignal(signal)]));
|
|
628
|
+
for (const [signal, handler] of signalHandlers)
|
|
629
|
+
process.on(signal, handler);
|
|
630
|
+
const removeSignalHandlers = () => {
|
|
631
|
+
for (const [signal, handler] of signalHandlers)
|
|
632
|
+
process.removeListener(signal, handler);
|
|
633
|
+
};
|
|
634
|
+
// spawnSync left default parent signal handling in place, so a parent-only
|
|
635
|
+
// SIGTERM or SIGHUP could terminate it. Forward stop signals to the async
|
|
636
|
+
// child while keeping this process waiting for the child's exit status.
|
|
637
|
+
try {
|
|
638
|
+
spawnedChild = spawnProcess(plan.cmd, plan.args, {
|
|
639
|
+
stdio: "inherit",
|
|
640
|
+
shell: plan.shell,
|
|
641
|
+
env: childEnv,
|
|
642
|
+
});
|
|
643
|
+
childForSignals = spawnedChild;
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
removeSignalHandlers();
|
|
647
|
+
handoff?.cleanup();
|
|
648
|
+
process.stderr.write(`hq-cli re-exec spawn failed (${reexecErrorClass(error)})\n`);
|
|
607
649
|
return null;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
650
|
+
}
|
|
651
|
+
return new Promise((resolve) => {
|
|
652
|
+
let alive = true;
|
|
653
|
+
let spawnFailed = false;
|
|
654
|
+
spawnedChild.once("error", (error) => {
|
|
655
|
+
spawnFailed = true;
|
|
656
|
+
process.stderr.write(`hq-cli re-exec spawn failed (${reexecErrorClass(error)})\n`);
|
|
657
|
+
});
|
|
658
|
+
spawnedChild.once("spawn", () => {
|
|
659
|
+
childSpawned = true;
|
|
660
|
+
for (const signal of pendingSignals.splice(0)) {
|
|
661
|
+
if (spawnedChild.exitCode === null && spawnedChild.signalCode === null)
|
|
662
|
+
spawnedChild.kill(signal);
|
|
663
|
+
}
|
|
664
|
+
if (!handoff || spawnedChild.pid === undefined)
|
|
665
|
+
return;
|
|
666
|
+
void handoff.waitForChild(spawnedChild.pid, () => alive).then((matched) => {
|
|
667
|
+
if (matched && alive) {
|
|
668
|
+
releaseCurrentPackageUseLease();
|
|
669
|
+
handoff.cleanup();
|
|
670
|
+
}
|
|
671
|
+
}).catch((error) => {
|
|
672
|
+
process.stderr.write(`hq-cli package-use lease handoff wait failed (${error instanceof Error ? error.name : "unknown"})\n`);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
spawnedChild.once("close", (code, _signal) => {
|
|
676
|
+
alive = false;
|
|
677
|
+
childClosed = true;
|
|
678
|
+
pendingSignals.length = 0;
|
|
679
|
+
removeSignalHandlers();
|
|
680
|
+
handoff?.cleanup();
|
|
681
|
+
// The former spawnSync path returned null on spawn failure and 1 for a signal.
|
|
682
|
+
resolve(spawnFailed ? null : code ?? 1);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
function reexecErrorClass(error) {
|
|
687
|
+
if (error && typeof error === "object") {
|
|
688
|
+
const code = error.code;
|
|
689
|
+
if (typeof code === "string" && /^[A-Z0-9_]{1,32}$/.test(code))
|
|
690
|
+
return code;
|
|
691
|
+
if (error instanceof Error)
|
|
692
|
+
return error.name;
|
|
693
|
+
}
|
|
694
|
+
return "unknown";
|
|
611
695
|
}
|
|
612
696
|
async function updateAndReexec(argv, flavor, known, deps) {
|
|
613
697
|
const env = deps.env ?? process.env;
|
|
@@ -829,7 +913,11 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
829
913
|
return { action: "updated", latest };
|
|
830
914
|
}
|
|
831
915
|
const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
|
|
832
|
-
const status =
|
|
916
|
+
const status = deps.spawnReexec
|
|
917
|
+
? await reexecHq([...argv.slice(2)], childEnv, deps.spawnReexec, deps.stdinIsTTY)
|
|
918
|
+
: deps.reexec
|
|
919
|
+
? deps.reexec([...argv.slice(2)], childEnv)
|
|
920
|
+
: await reexecHq([...argv.slice(2)], childEnv, spawn, deps.stdinIsTTY);
|
|
833
921
|
if (status === null) {
|
|
834
922
|
console.error(chalk.yellow(`⚠ Updated hq-cli to ${latest}, but couldn't re-launch \`hq\` from PATH; ` +
|
|
835
923
|
`continuing the ${flavor.noun} on ${current}.`));
|