@aron98/load-lock 1.2.0 → 1.3.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/README.md +3 -2
- package/dist/adapters/opencode/tui.js +5 -1
- package/dist/cli/copies.d.ts +28 -0
- package/dist/cli/copies.js +207 -0
- package/dist/cli/install.js +218 -1
- package/dist/cli/main.js +264 -16
- package/dist/core/daemon.d.ts +18 -0
- package/dist/core/daemon.js +84 -3
- package/dist/core/persist.js +5 -3
- package/dist/core/state.d.ts +7 -4
- package/dist/core/state.js +64 -10
- package/dist/dashboard/frame.js +5 -1
- package/dist/dashboard/view.d.ts +4 -0
- package/dist/dashboard/view.js +5 -0
- package/dist/shared/client.js +4 -1
- package/dist/shared/protocol.d.ts +40 -2
- package/dist/shared/protocol.js +5 -0
- package/dist/shared/version.d.ts +10 -0
- package/dist/shared/version.js +57 -0
- package/dist/test/install.test.js +181 -1
- package/dist/test/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/docs/ARCHITECTURE.md +10 -6
- package/docs/COMPATIBILITY.md +79 -4
- package/docs/OPERATIONS.md +171 -56
- package/package.json +1 -1
package/dist/cli/main.js
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
* (queued/registered); 69 offer/claim failed; 70 internal.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { connect } from "node:net";
|
|
12
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
11
13
|
import { LLClient, withClient } from "../shared/client.js";
|
|
12
14
|
import { ProtocolError } from "../shared/protocol.js";
|
|
13
15
|
import { waitForTurn } from "../shared/wait-turn.js";
|
|
14
16
|
import { stateDir, socketPath } from "../core/persist.js";
|
|
15
17
|
import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
|
|
18
|
+
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
19
|
+
import { findInstalledCopies } from "./copies.js";
|
|
16
20
|
import { installCommand, uninstallCommand } from "./install.js";
|
|
17
21
|
import { hookCommand } from "./hook.js";
|
|
18
22
|
import { topCommand } from "./top.js";
|
|
@@ -61,6 +65,87 @@ function defaultAgent() {
|
|
|
61
65
|
return (process.env.LOAD_LOCK_AGENT ||
|
|
62
66
|
"cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
|
|
63
67
|
}
|
|
68
|
+
// ------------------------------------------------- daemon service helpers
|
|
69
|
+
/** daemon.json contents, or null when missing/unreadable. */
|
|
70
|
+
function readDaemonInfo(dir) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Version-less hello over a raw socket connection: deliberately does NOT
|
|
80
|
+
* send clientVersion, so asking a daemon for its identity never orders it
|
|
81
|
+
* to self-restart (used by `service stop` and post-restart confirmation).
|
|
82
|
+
* Best-effort: resolves null on any failure.
|
|
83
|
+
*/
|
|
84
|
+
function rawHello(sockPath, timeoutMs = 2000) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
let settled = false;
|
|
87
|
+
let buf = "";
|
|
88
|
+
const done = (v) => {
|
|
89
|
+
if (settled)
|
|
90
|
+
return;
|
|
91
|
+
settled = true;
|
|
92
|
+
try {
|
|
93
|
+
sock.destroy();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
resolve(v);
|
|
99
|
+
};
|
|
100
|
+
const sock = connect(sockPath, () => {
|
|
101
|
+
sock.write(JSON.stringify({ id: 1, method: "hello", params: {} }) + "\n");
|
|
102
|
+
});
|
|
103
|
+
sock.on("data", (chunk) => {
|
|
104
|
+
buf += chunk.toString("utf8");
|
|
105
|
+
const idx = buf.indexOf("\n");
|
|
106
|
+
if (idx === -1)
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
const msg = JSON.parse(buf.slice(0, idx));
|
|
110
|
+
done(msg.ok ? msg.result : null);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
done(null);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
sock.on("error", () => done(null));
|
|
117
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
118
|
+
timer.unref?.();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Poll the socket until nothing answers there (bounded). */
|
|
122
|
+
async function waitSocketFree(sockPath, timeoutMs) {
|
|
123
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
124
|
+
for (;;) {
|
|
125
|
+
if (!(await probeTransport(sockPath)))
|
|
126
|
+
return true;
|
|
127
|
+
if (Date.now() >= deadline)
|
|
128
|
+
return false;
|
|
129
|
+
await delay(100);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* After ordering a self-restart, wait for the daemon to come back running
|
|
134
|
+
* at least `installed` (the version of this CLI copy). Resolves its hello
|
|
135
|
+
* result, or null if it did not come back (or an older/pre-handshake
|
|
136
|
+
* daemon answered again — e.g. the daemon's copy is not actually updated).
|
|
137
|
+
*/
|
|
138
|
+
async function awaitDaemonUpgraded(sockPath, timeoutMs, installed) {
|
|
139
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
140
|
+
for (;;) {
|
|
141
|
+
const h = await rawHello(sockPath, 1500);
|
|
142
|
+
if (h && typeof h.daemonVersion === "string" && compareVersions(h.daemonVersion, installed) >= 0)
|
|
143
|
+
return h;
|
|
144
|
+
if (Date.now() >= deadline)
|
|
145
|
+
return null;
|
|
146
|
+
await delay(250);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
64
149
|
const HELP_GROUPS = [
|
|
65
150
|
{
|
|
66
151
|
title: "permit operations",
|
|
@@ -96,8 +181,8 @@ const HELP_GROUPS = [
|
|
|
96
181
|
{
|
|
97
182
|
title: "service & diagnostics",
|
|
98
183
|
items: [
|
|
99
|
-
{ names: ["service"], desc: "daemon lifecycle: start | stop | status" },
|
|
100
|
-
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, machine" },
|
|
184
|
+
{ names: ["service"], desc: "daemon lifecycle: start | stop | status (stop/start take --timeout <ms>)" },
|
|
185
|
+
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, versions + copies, machine" },
|
|
101
186
|
{ names: ["recover"], desc: "list held permits with diagnostics; recover force-release --id <request-id>" },
|
|
102
187
|
],
|
|
103
188
|
},
|
|
@@ -157,6 +242,10 @@ async function main() {
|
|
|
157
242
|
lines.push(` offer: ${o.agentLabel || o.agentId} — ${o.reason} (expires in ${Math.max(0, Math.round(((o.offerExpiresAt ?? 0) - Date.now()) / 1000))}s)`);
|
|
158
243
|
p.queue.forEach((q, i) => lines.push(` queued #${i + 1}: ${q.agentLabel || q.agentId} — ${q.reason}`));
|
|
159
244
|
}
|
|
245
|
+
const dv = r.daemon?.daemonVersion;
|
|
246
|
+
if (typeof dv === "string" && compareVersions(dv, packageVersion()) < 0) {
|
|
247
|
+
lines.push(`note: daemon v${dv} is older than this CLI v${packageVersion()} — it self-restarts on contact; run again to confirm`);
|
|
248
|
+
}
|
|
160
249
|
return lines.join("\n");
|
|
161
250
|
});
|
|
162
251
|
return;
|
|
@@ -339,28 +428,149 @@ async function main() {
|
|
|
339
428
|
case "service": {
|
|
340
429
|
const sub = rest[0] ?? "status";
|
|
341
430
|
if (sub === "stop") {
|
|
342
|
-
//
|
|
431
|
+
// Honest stop: signal the daemon, wait bounded for its socket to go
|
|
432
|
+
// silent, escalate to SIGKILL, and only report success once the
|
|
433
|
+
// endpoint is verifiably free. A leftover silent socket file or
|
|
434
|
+
// daemon.json (crash leftovers) is cleaned up either way.
|
|
343
435
|
const dir = stateDir();
|
|
436
|
+
const sock = socketPath();
|
|
437
|
+
const timeoutMs = N("timeout", 5000);
|
|
438
|
+
const info = readDaemonInfo(dir);
|
|
439
|
+
const answering = await probeTransport(sock);
|
|
440
|
+
if (!answering) {
|
|
441
|
+
// Nothing is listening: no daemon to stop. Clean crash leftovers.
|
|
442
|
+
try {
|
|
443
|
+
if (!isWindows() && existsSync(sock))
|
|
444
|
+
unlinkSync(sock);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
/* ignore */
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
rmSync(join(dir, "daemon.json"), { force: true });
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
/* ignore */
|
|
454
|
+
}
|
|
455
|
+
process.stdout.write("load-lock: no running daemon found (stale files cleaned)\n");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Identify the daemon: prefer daemon.json, else ask it directly
|
|
459
|
+
// (version-less hello — stopping must not order a self-restart).
|
|
460
|
+
let pid = typeof info?.pid === "number" ? info.pid : undefined;
|
|
461
|
+
if (!pid) {
|
|
462
|
+
const hello = await rawHello(sock, 1500);
|
|
463
|
+
if (hello && typeof hello.pid === "number")
|
|
464
|
+
pid = hello.pid;
|
|
465
|
+
}
|
|
466
|
+
if (!pid) {
|
|
467
|
+
fail(`the socket ${sock} answers but no pid could be identified (daemon did not respond and daemon.json names none) ` +
|
|
468
|
+
`— cannot stop safely; run \`load-lock doctor\``, 70);
|
|
469
|
+
}
|
|
470
|
+
let how = "SIGTERM";
|
|
471
|
+
let stopped = false;
|
|
344
472
|
try {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
473
|
+
process.kill(pid, "SIGTERM");
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
if (e?.code === "ESRCH")
|
|
477
|
+
stopped = true; // already gone
|
|
478
|
+
else
|
|
479
|
+
fail(`cannot signal daemon pid ${pid}: ${e?.message ?? e}`, 70);
|
|
480
|
+
}
|
|
481
|
+
if (!stopped)
|
|
482
|
+
stopped = await waitSocketFree(sock, timeoutMs);
|
|
483
|
+
if (!stopped) {
|
|
484
|
+
how = "SIGKILL";
|
|
485
|
+
try {
|
|
486
|
+
process.kill(pid, "SIGKILL");
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
/* already dead */
|
|
490
|
+
}
|
|
491
|
+
stopped = await waitSocketFree(sock, timeoutMs);
|
|
492
|
+
}
|
|
493
|
+
if (!stopped) {
|
|
494
|
+
fail(`daemon pid ${pid} did not stop within ${timeoutMs}ms even after SIGKILL — run \`load-lock doctor\``, 70);
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
if (!isWindows() && existsSync(sock))
|
|
498
|
+
unlinkSync(sock);
|
|
348
499
|
}
|
|
349
500
|
catch {
|
|
350
|
-
|
|
501
|
+
/* ignore */
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
rmSync(join(dir, "daemon.json"), { force: true });
|
|
351
505
|
}
|
|
506
|
+
catch {
|
|
507
|
+
/* ignore */
|
|
508
|
+
}
|
|
509
|
+
process.stdout.write(`load-lock: daemon (pid ${pid}) stopped${how === "SIGKILL" ? " (required SIGKILL after SIGTERM)" : ""}\n`);
|
|
352
510
|
return;
|
|
353
511
|
}
|
|
354
512
|
if (sub === "start") {
|
|
355
513
|
// Connecting auto-spawns a detached daemon (see shared/client.ts);
|
|
356
|
-
// hello confirms it is up.
|
|
357
|
-
const
|
|
358
|
-
|
|
514
|
+
// hello confirms it is up. Refusals name the conflicting pid.
|
|
515
|
+
const timeoutMs = N("timeout", 10_000);
|
|
516
|
+
const sock = socketPath();
|
|
517
|
+
const installed = packageVersion();
|
|
518
|
+
try {
|
|
519
|
+
const r = (await withClient((c) => c.request("hello", {}, timeoutMs)));
|
|
520
|
+
const dv = typeof r.daemonVersion === "string" ? r.daemonVersion : undefined;
|
|
521
|
+
if (dv && compareVersions(dv, installed) < 0) {
|
|
522
|
+
// The handshake just ordered this old daemon to self-restart;
|
|
523
|
+
// confirm it comes back on updated code.
|
|
524
|
+
const up = await awaitDaemonUpgraded(sock, 8000, installed);
|
|
525
|
+
if (up) {
|
|
526
|
+
process.stdout.write(`load-lock: daemon self-restarted ${r.pid} -> ${up.pid} (v${dv} -> v${up.daemonVersion}), running on ${sock}\n`);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock}), daemon v${dv} — older than this CLI v${installed}; ` +
|
|
530
|
+
`self-restart ordered but not confirmed (its copy may not be updated in place — see docs/OPERATIONS.md)\n`);
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock})${dv ? `, daemon v${dv}` : ""}\n`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
if (await probeTransport(sock)) {
|
|
539
|
+
// Something owns the endpoint. Name it.
|
|
540
|
+
const hello = await rawHello(sock, 1500);
|
|
541
|
+
if (hello && typeof hello.pid === "number") {
|
|
542
|
+
fail(`start refused: socket answered from pid ${hello.pid} — another daemon owns ${sock}; ` +
|
|
543
|
+
`if it is stale, run \`load-lock service stop\` first`, 70);
|
|
544
|
+
}
|
|
545
|
+
const info = readDaemonInfo(stateDir());
|
|
546
|
+
if (info && typeof info.pid === "number") {
|
|
547
|
+
fail(`socket ${sock} answers but the daemon did not respond (daemon.json names pid ${info.pid}) — likely hung; ` +
|
|
548
|
+
`run \`load-lock service stop\``, 70);
|
|
549
|
+
}
|
|
550
|
+
fail(`start refused: ${sock} is in use but nothing responsive owns it — run \`load-lock doctor\``, 70);
|
|
551
|
+
}
|
|
552
|
+
throw e;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (sub === "status") {
|
|
556
|
+
const r = (await withClient((c) => c.request("hello", {})));
|
|
557
|
+
out(r, () => {
|
|
558
|
+
let line = `load-lock daemon: pid ${r.pid}, protocol v${r.protocolVersion}, config version ${r.configVersion}`;
|
|
559
|
+
if (typeof r.daemonVersion === "string") {
|
|
560
|
+
line += `, daemon v${r.daemonVersion}`;
|
|
561
|
+
const installed = packageVersion();
|
|
562
|
+
if (compareVersions(r.daemonVersion, installed) < 0) {
|
|
563
|
+
line += ` — older than this CLI v${installed}; self-restarting on contact`;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
line += ` — pre-handshake daemon (no version report); \`load-lock service stop\` then any command restarts it on current code`;
|
|
568
|
+
}
|
|
569
|
+
return line;
|
|
570
|
+
});
|
|
359
571
|
return;
|
|
360
572
|
}
|
|
361
|
-
|
|
362
|
-
const r = await withClient((c) => c.request("hello", {}));
|
|
363
|
-
out(r, () => `load-lock daemon: pid ${r.pid}, protocol v${r.protocolVersion}, config version ${r.configVersion}`);
|
|
573
|
+
fail(`service: unknown subcommand "${sub}" (use start | stop | status)`);
|
|
364
574
|
return;
|
|
365
575
|
}
|
|
366
576
|
case "doctor": {
|
|
@@ -376,16 +586,54 @@ async function main() {
|
|
|
376
586
|
report.socketExists = isWindows() ? await probeTransport(sock) : existsSync(sock);
|
|
377
587
|
report.configExists = existsSync(join(dir, "config.json"));
|
|
378
588
|
report.journalExists = existsSync(join(dir, "journal.json"));
|
|
589
|
+
report.installedVersion = packageVersion();
|
|
590
|
+
const warnings = [];
|
|
379
591
|
try {
|
|
380
|
-
|
|
381
|
-
|
|
592
|
+
// One connection for hello + inspect: if the handshake orders a
|
|
593
|
+
// self-restart (daemon older than this CLI), both requests must
|
|
594
|
+
// finish before the daemon drains and exits.
|
|
595
|
+
const { hello, insp } = await withClient(async (c) => ({
|
|
596
|
+
hello: await c.request("hello", {}),
|
|
597
|
+
insp: await c.request("inspect", {}),
|
|
598
|
+
}));
|
|
599
|
+
report.hello = hello;
|
|
382
600
|
report.effectiveCores = insp.cpu.effectiveCores;
|
|
383
601
|
report.pressure = insp.pressure.note;
|
|
384
602
|
report.procs = insp.cpu.cgroupVersion;
|
|
603
|
+
const dv = hello.daemonVersion;
|
|
604
|
+
if (typeof dv === "string") {
|
|
605
|
+
const installed = packageVersion();
|
|
606
|
+
if (compareVersions(dv, installed) < 0) {
|
|
607
|
+
warnings.push(`daemon v${dv} is older than this CLI v${installed}; the version handshake ordered it to self-restart`);
|
|
608
|
+
const up = await awaitDaemonUpgraded(sock, 5000, installed);
|
|
609
|
+
if (up) {
|
|
610
|
+
report.daemonUpgradedTo = up.daemonVersion;
|
|
611
|
+
warnings.push(`daemon self-restarted to v${up.daemonVersion} (pid ${up.pid}) — now up to date`);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
warnings.push(`daemon did not come back on v${installed} within 5s (its on-disk copy may not be updated in place — ` +
|
|
615
|
+
`see docs/OPERATIONS.md "Update lifecycle"); the next client contact retries the restart`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
warnings.push(`pre-handshake daemon (no version in hello): restart it onto current code — \`load-lock service stop\`, then any load-lock command`);
|
|
621
|
+
}
|
|
385
622
|
}
|
|
386
623
|
catch (e) {
|
|
387
624
|
report.daemon = `unreachable: ${e.message}`;
|
|
388
625
|
}
|
|
626
|
+
// Multi-copy reality (issue #34): list every detectable copy and warn
|
|
627
|
+
// when their versions drift apart.
|
|
628
|
+
const copies = findInstalledCopies();
|
|
629
|
+
report.copies = copies;
|
|
630
|
+
const versions = new Set(copies.map((c) => c.version).filter((v) => v !== null));
|
|
631
|
+
if (versions.size > 1) {
|
|
632
|
+
warnings.push(`multiple load-lock versions installed (${[...versions].sort().join(", ")}): ` +
|
|
633
|
+
copies.map((c) => `${c.source}=${c.version ?? "?"} (${c.path})`).join("; ") +
|
|
634
|
+
` — see docs/COMPATIBILITY.md "Multiple copies of load-lock" for which copy is authoritative for what`);
|
|
635
|
+
}
|
|
636
|
+
report.warnings = warnings;
|
|
389
637
|
out(report, () => JSON.stringify(report, null, 2));
|
|
390
638
|
return;
|
|
391
639
|
}
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare class Daemon {
|
|
|
32
32
|
private socketPath;
|
|
33
33
|
private sweepTimer;
|
|
34
34
|
private expiryTimer;
|
|
35
|
+
private restartTimer;
|
|
35
36
|
private stopped;
|
|
36
37
|
constructor(opts?: {
|
|
37
38
|
stateDir?: string;
|
|
@@ -50,6 +51,23 @@ export declare class Daemon {
|
|
|
50
51
|
private onConnection;
|
|
51
52
|
private send;
|
|
52
53
|
private handleLine;
|
|
54
|
+
/**
|
|
55
|
+
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
56
|
+
* package version than this daemon runs, schedule a respawn so the daemon
|
|
57
|
+
* picks up the updated on-disk code (e.g. after `npm i -g
|
|
58
|
+
* @aron98/load-lock` while the daemon kept running). Skew detection is
|
|
59
|
+
* entirely daemon-side and best-effort: pre-handshake clients that send
|
|
60
|
+
* no version never trigger a restart.
|
|
61
|
+
*/
|
|
62
|
+
private maybeScheduleSelfRestart;
|
|
63
|
+
/**
|
|
64
|
+
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
65
|
+
* journal FIRST, then stops (destroying all client connections — they
|
|
66
|
+
* reconnect to the fresh daemon), then spawns the replacement with the
|
|
67
|
+
* exact same argv so a daemon started via the old copy runs the new copy's
|
|
68
|
+
* code, and exits.
|
|
69
|
+
*/
|
|
70
|
+
private selfRestart;
|
|
53
71
|
/** Returns [result, agentIdForPendingAttachment]. */
|
|
54
72
|
private dispatch;
|
|
55
73
|
/**
|
package/dist/core/daemon.js
CHANGED
|
@@ -20,11 +20,21 @@ import { createServer } from "node:net";
|
|
|
20
20
|
import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
21
21
|
import { readdirSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
23
24
|
import { Machine } from "./state.js";
|
|
24
25
|
import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
|
|
25
26
|
import { inspectMachine } from "./sysinfo.js";
|
|
26
27
|
import { ProtocolError } from "../shared/protocol.js";
|
|
27
28
|
import { isWindows, probeTransport } from "../shared/transport.js";
|
|
29
|
+
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
30
|
+
/** Package version of the copy of load-lock this daemon process runs from. */
|
|
31
|
+
const DAEMON_VERSION = packageVersion();
|
|
32
|
+
/**
|
|
33
|
+
* Env marker set on the respawned daemon: the version this process already
|
|
34
|
+
* restarted for. Prevents restart loops when a newer client pings twice —
|
|
35
|
+
* only one respawn per version bump.
|
|
36
|
+
*/
|
|
37
|
+
const RESTARTED_FOR_ENV = "LOAD_LOCK_RESTARTED_FOR";
|
|
28
38
|
export class Daemon {
|
|
29
39
|
machine;
|
|
30
40
|
server = null;
|
|
@@ -39,6 +49,7 @@ export class Daemon {
|
|
|
39
49
|
socketPath;
|
|
40
50
|
sweepTimer = null;
|
|
41
51
|
expiryTimer = null;
|
|
52
|
+
restartTimer = null;
|
|
42
53
|
stopped = false;
|
|
43
54
|
constructor(opts = {}) {
|
|
44
55
|
this.stateDirPath = defaultStateDir(opts.stateDir);
|
|
@@ -108,6 +119,8 @@ export class Daemon {
|
|
|
108
119
|
clearInterval(this.sweepTimer);
|
|
109
120
|
if (this.expiryTimer)
|
|
110
121
|
clearTimeout(this.expiryTimer);
|
|
122
|
+
if (this.restartTimer)
|
|
123
|
+
clearTimeout(this.restartTimer);
|
|
111
124
|
for (const w of this.watchers)
|
|
112
125
|
w.socket.destroy();
|
|
113
126
|
this.watchers.clear();
|
|
@@ -171,6 +184,8 @@ export class Daemon {
|
|
|
171
184
|
this.send(sock, { id: null, ok: false, error: { code: "invalid", message: "malformed JSON line" } });
|
|
172
185
|
return;
|
|
173
186
|
}
|
|
187
|
+
// Version handshake: remember the client's version for skew detection.
|
|
188
|
+
const clientVersion = typeof msg.clientVersion === "string" ? msg.clientVersion : undefined;
|
|
174
189
|
let result;
|
|
175
190
|
let agentId;
|
|
176
191
|
try {
|
|
@@ -187,6 +202,9 @@ export class Daemon {
|
|
|
187
202
|
else {
|
|
188
203
|
this.send(sock, { id: msg.id, ok: false, error: { code: "internal", message: String(err?.message ?? err) } });
|
|
189
204
|
}
|
|
205
|
+
// Respond first, then consider a self-restart — even on errors, a
|
|
206
|
+
// newer client means this daemon is running stale on-disk code.
|
|
207
|
+
this.maybeScheduleSelfRestart(clientVersion);
|
|
190
208
|
return;
|
|
191
209
|
}
|
|
192
210
|
// Tool-boundary delivery: surface this agent's outstanding offers/queue
|
|
@@ -195,6 +213,63 @@ export class Daemon {
|
|
|
195
213
|
result.pending = this.machine.pending(agentId);
|
|
196
214
|
}
|
|
197
215
|
this.send(sock, { id: msg.id, ok: true, result });
|
|
216
|
+
this.maybeScheduleSelfRestart(clientVersion);
|
|
217
|
+
}
|
|
218
|
+
// -------------------------------------------------------- version handshake
|
|
219
|
+
/**
|
|
220
|
+
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
221
|
+
* package version than this daemon runs, schedule a respawn so the daemon
|
|
222
|
+
* picks up the updated on-disk code (e.g. after `npm i -g
|
|
223
|
+
* @aron98/load-lock` while the daemon kept running). Skew detection is
|
|
224
|
+
* entirely daemon-side and best-effort: pre-handshake clients that send
|
|
225
|
+
* no version never trigger a restart.
|
|
226
|
+
*/
|
|
227
|
+
maybeScheduleSelfRestart(clientVersion) {
|
|
228
|
+
if (this.stopped || this.restartTimer || !clientVersion)
|
|
229
|
+
return;
|
|
230
|
+
if (compareVersions(clientVersion, DAEMON_VERSION) <= 0)
|
|
231
|
+
return;
|
|
232
|
+
const restartedFor = process.env[RESTARTED_FOR_ENV];
|
|
233
|
+
if (restartedFor && compareVersions(clientVersion, restartedFor) <= 0)
|
|
234
|
+
return; // already tried
|
|
235
|
+
this.restartTimer = setTimeout(() => {
|
|
236
|
+
this.restartTimer = null;
|
|
237
|
+
this.selfRestart(clientVersion);
|
|
238
|
+
}, 250);
|
|
239
|
+
this.restartTimer.unref?.();
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
243
|
+
* journal FIRST, then stops (destroying all client connections — they
|
|
244
|
+
* reconnect to the fresh daemon), then spawns the replacement with the
|
|
245
|
+
* exact same argv so a daemon started via the old copy runs the new copy's
|
|
246
|
+
* code, and exits.
|
|
247
|
+
*/
|
|
248
|
+
selfRestart(clientVersion) {
|
|
249
|
+
if (this.stopped)
|
|
250
|
+
return;
|
|
251
|
+
// Best-effort persistence; a failed save must not block the restart.
|
|
252
|
+
try {
|
|
253
|
+
saveState(this.stateDirPath, this.machine.config, this.machine.history);
|
|
254
|
+
saveJournal(this.stateDirPath, this.machine.journalEntries());
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
/* journal is best-effort */
|
|
258
|
+
}
|
|
259
|
+
this.stop();
|
|
260
|
+
try {
|
|
261
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
262
|
+
detached: true,
|
|
263
|
+
stdio: "ignore",
|
|
264
|
+
cwd: process.cwd(),
|
|
265
|
+
env: { ...process.env, [RESTARTED_FOR_ENV]: clientVersion },
|
|
266
|
+
});
|
|
267
|
+
child.unref();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
/* if the respawn fails the next client contact re-triggers it */
|
|
271
|
+
}
|
|
272
|
+
process.exit(0);
|
|
198
273
|
}
|
|
199
274
|
// --------------------------------------------------------------- dispatch
|
|
200
275
|
/** Returns [result, agentIdForPendingAttachment]. */
|
|
@@ -202,7 +277,10 @@ export class Daemon {
|
|
|
202
277
|
const m = this.machine;
|
|
203
278
|
switch (method) {
|
|
204
279
|
case "hello":
|
|
205
|
-
return [
|
|
280
|
+
return [
|
|
281
|
+
{ protocolVersion: 1, pid: process.pid, startedAt: m.startedAt, configVersion: m.config.version, daemonVersion: DAEMON_VERSION },
|
|
282
|
+
undefined,
|
|
283
|
+
];
|
|
206
284
|
case "acquire": {
|
|
207
285
|
const params = p;
|
|
208
286
|
let res = m.acquire(params);
|
|
@@ -265,8 +343,11 @@ export class Daemon {
|
|
|
265
343
|
const res = m.pending(String(p.agentId));
|
|
266
344
|
return [res, String(p.agentId)];
|
|
267
345
|
}
|
|
268
|
-
case "status":
|
|
269
|
-
|
|
346
|
+
case "status": {
|
|
347
|
+
const res = m.status(p.pool ? String(p.pool) : undefined);
|
|
348
|
+
res.daemon.daemonVersion = DAEMON_VERSION;
|
|
349
|
+
return [res, undefined];
|
|
350
|
+
}
|
|
270
351
|
case "get_config":
|
|
271
352
|
return [m.get_config(), undefined];
|
|
272
353
|
case "configure": {
|
package/dist/core/persist.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock;
|
|
4
4
|
* Windows: %LOCALAPPDATA%\load-lock — see shared/transport.ts).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Configuration persists across daemon restarts, plus the journal
|
|
7
|
+
* (journal.json): held permits AND queued/offered requests. Held permits
|
|
8
|
+
* are restored conservatively (tracked jobs re-checked for liveness, same
|
|
9
|
+
* permit tokens so late releases stay idempotent); queued requests are
|
|
10
|
+
* re-queued FIFO with the same requestIds (see docs/ARCHITECTURE.md).
|
|
9
11
|
*/
|
|
10
12
|
import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from "node:fs";
|
|
11
13
|
import { dirname, join } from "node:path";
|
package/dist/core/state.d.ts
CHANGED
|
@@ -180,16 +180,18 @@ export declare class Machine {
|
|
|
180
180
|
get_config(): GetConfigResult;
|
|
181
181
|
configure(p: ConfigureParams): ConfigureResult;
|
|
182
182
|
history: ConfigHistoryEntry[];
|
|
183
|
-
/** Held permits to persist
|
|
183
|
+
/** Held permits (first) and queued/offered requests to persist across restarts. */
|
|
184
184
|
journalEntries(): JournalEntry[];
|
|
185
185
|
/**
|
|
186
|
-
* Restore
|
|
186
|
+
* Restore state after a daemon restart (conservative reconciliation):
|
|
187
|
+
* - queued (incl. previously offered): re-queued FIFO by original enqueue
|
|
188
|
+
* time, same requestIds so idempotent re-acquire still works; pools no
|
|
189
|
+
* longer in the config are dropped. Offered entries restart as queued
|
|
190
|
+
* and get a fresh offer (old offer tokens are invalid after restart).
|
|
187
191
|
* - tracked: re-check liveness via the supplied probe; dead => release,
|
|
188
192
|
* alive => keep (same token, so a late explicit release still works).
|
|
189
193
|
* - claimed: keep the start deadline (reclaimed by tick if already past).
|
|
190
194
|
* - untracked: keep, with a FRESH lease window from restore time.
|
|
191
|
-
* Queued/offered requests are deliberately NOT restored — queued agents
|
|
192
|
-
* re-acquire (documented restart behavior).
|
|
193
195
|
*/
|
|
194
196
|
restore(journal: JournalEntry[], liveness: (e: {
|
|
195
197
|
pid?: number;
|
|
@@ -198,6 +200,7 @@ export declare class Machine {
|
|
|
198
200
|
}) => boolean | null): {
|
|
199
201
|
kept: number;
|
|
200
202
|
releasedDead: number;
|
|
203
|
+
queued: number;
|
|
201
204
|
};
|
|
202
205
|
}
|
|
203
206
|
export {};
|