@aron98/load-lock 1.2.1 → 1.4.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/dist/adapters/opencode/plugin.d.ts +2 -1
- package/dist/adapters/opencode/plugin.js +85 -5
- 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/main.js +298 -25
- package/dist/core/daemon.d.ts +42 -0
- package/dist/core/daemon.js +305 -3
- package/dist/core/persist.js +5 -3
- package/dist/core/state.d.ts +12 -4
- package/dist/core/state.js +80 -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/mcp/server.js +107 -7
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +6 -0
- package/dist/shared/client.js +4 -1
- package/dist/shared/protocol.d.ts +72 -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/shared/wait-completion.d.ts +67 -0
- package/dist/shared/wait-completion.js +505 -0
- package/dist/test/mcp.test.js +115 -1
- package/dist/test/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/dist/test/wait-completion.test.d.ts +1 -0
- package/dist/test/wait-completion.test.js +418 -0
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +49 -3
- package/docs/ARCHITECTURE.md +25 -6
- package/docs/COMPATIBILITY.md +32 -0
- package/docs/OPERATIONS.md +54 -6
- package/package.json +1 -1
package/dist/cli/main.js
CHANGED
|
@@ -6,13 +6,18 @@
|
|
|
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";
|
|
16
|
+
import { waitForTrackedJob } from "../shared/wait-completion.js";
|
|
14
17
|
import { stateDir, socketPath } from "../core/persist.js";
|
|
15
18
|
import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
|
|
19
|
+
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
20
|
+
import { findInstalledCopies } from "./copies.js";
|
|
16
21
|
import { installCommand, uninstallCommand } from "./install.js";
|
|
17
22
|
import { hookCommand } from "./hook.js";
|
|
18
23
|
import { topCommand } from "./top.js";
|
|
@@ -61,6 +66,87 @@ function defaultAgent() {
|
|
|
61
66
|
return (process.env.LOAD_LOCK_AGENT ||
|
|
62
67
|
"cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
|
|
63
68
|
}
|
|
69
|
+
// ------------------------------------------------- daemon service helpers
|
|
70
|
+
/** daemon.json contents, or null when missing/unreadable. */
|
|
71
|
+
function readDaemonInfo(dir) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Version-less hello over a raw socket connection: deliberately does NOT
|
|
81
|
+
* send clientVersion, so asking a daemon for its identity never orders it
|
|
82
|
+
* to self-restart (used by `service stop` and post-restart confirmation).
|
|
83
|
+
* Best-effort: resolves null on any failure.
|
|
84
|
+
*/
|
|
85
|
+
function rawHello(sockPath, timeoutMs = 2000) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
let settled = false;
|
|
88
|
+
let buf = "";
|
|
89
|
+
const done = (v) => {
|
|
90
|
+
if (settled)
|
|
91
|
+
return;
|
|
92
|
+
settled = true;
|
|
93
|
+
try {
|
|
94
|
+
sock.destroy();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* ignore */
|
|
98
|
+
}
|
|
99
|
+
resolve(v);
|
|
100
|
+
};
|
|
101
|
+
const sock = connect(sockPath, () => {
|
|
102
|
+
sock.write(JSON.stringify({ id: 1, method: "hello", params: {} }) + "\n");
|
|
103
|
+
});
|
|
104
|
+
sock.on("data", (chunk) => {
|
|
105
|
+
buf += chunk.toString("utf8");
|
|
106
|
+
const idx = buf.indexOf("\n");
|
|
107
|
+
if (idx === -1)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
const msg = JSON.parse(buf.slice(0, idx));
|
|
111
|
+
done(msg.ok ? msg.result : null);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
done(null);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
sock.on("error", () => done(null));
|
|
118
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
119
|
+
timer.unref?.();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Poll the socket until nothing answers there (bounded). */
|
|
123
|
+
async function waitSocketFree(sockPath, timeoutMs) {
|
|
124
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
125
|
+
for (;;) {
|
|
126
|
+
if (!(await probeTransport(sockPath)))
|
|
127
|
+
return true;
|
|
128
|
+
if (Date.now() >= deadline)
|
|
129
|
+
return false;
|
|
130
|
+
await delay(100);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* After ordering a self-restart, wait for the daemon to come back running
|
|
135
|
+
* at least `installed` (the version of this CLI copy). Resolves its hello
|
|
136
|
+
* result, or null if it did not come back (or an older/pre-handshake
|
|
137
|
+
* daemon answered again — e.g. the daemon's copy is not actually updated).
|
|
138
|
+
*/
|
|
139
|
+
async function awaitDaemonUpgraded(sockPath, timeoutMs, installed) {
|
|
140
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
141
|
+
for (;;) {
|
|
142
|
+
const h = await rawHello(sockPath, 1500);
|
|
143
|
+
if (h && typeof h.daemonVersion === "string" && compareVersions(h.daemonVersion, installed) >= 0)
|
|
144
|
+
return h;
|
|
145
|
+
if (Date.now() >= deadline)
|
|
146
|
+
return null;
|
|
147
|
+
await delay(250);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
64
150
|
const HELP_GROUPS = [
|
|
65
151
|
{
|
|
66
152
|
title: "permit operations",
|
|
@@ -77,7 +163,7 @@ const HELP_GROUPS = [
|
|
|
77
163
|
{
|
|
78
164
|
title: "waiting & running",
|
|
79
165
|
items: [
|
|
80
|
-
{ names: ["wait"], desc: "sleep until a queued request's turn,
|
|
166
|
+
{ names: ["wait"], desc: "sleep until a queued request's turn (--id <request-id>), auto-claiming the offer — or until a heavy job finishes (--job <request-id>): [--timeout <ms>]" },
|
|
81
167
|
{ names: ["run"], desc: "acquire (or reuse) a permit and run a command under it: run [--permit <token> | acquire flags] -- <cmd>" },
|
|
82
168
|
],
|
|
83
169
|
},
|
|
@@ -96,8 +182,8 @@ const HELP_GROUPS = [
|
|
|
96
182
|
{
|
|
97
183
|
title: "service & diagnostics",
|
|
98
184
|
items: [
|
|
99
|
-
{ names: ["service"], desc: "daemon lifecycle: start | stop | status" },
|
|
100
|
-
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, machine" },
|
|
185
|
+
{ names: ["service"], desc: "daemon lifecycle: start | stop | status (stop/start take --timeout <ms>)" },
|
|
186
|
+
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, versions + copies, machine" },
|
|
101
187
|
{ names: ["recover"], desc: "list held permits with diagnostics; recover force-release --id <request-id>" },
|
|
102
188
|
],
|
|
103
189
|
},
|
|
@@ -157,6 +243,10 @@ async function main() {
|
|
|
157
243
|
lines.push(` offer: ${o.agentLabel || o.agentId} — ${o.reason} (expires in ${Math.max(0, Math.round(((o.offerExpiresAt ?? 0) - Date.now()) / 1000))}s)`);
|
|
158
244
|
p.queue.forEach((q, i) => lines.push(` queued #${i + 1}: ${q.agentLabel || q.agentId} — ${q.reason}`));
|
|
159
245
|
}
|
|
246
|
+
const dv = r.daemon?.daemonVersion;
|
|
247
|
+
if (typeof dv === "string" && compareVersions(dv, packageVersion()) < 0) {
|
|
248
|
+
lines.push(`note: daemon v${dv} is older than this CLI v${packageVersion()} — it self-restarts on contact; run again to confirm`);
|
|
249
|
+
}
|
|
160
250
|
return lines.join("\n");
|
|
161
251
|
});
|
|
162
252
|
return;
|
|
@@ -299,19 +389,43 @@ async function main() {
|
|
|
299
389
|
out(await withClient((c) => c.request("attach_pid", { permitToken: token, pid })));
|
|
300
390
|
return;
|
|
301
391
|
}
|
|
302
|
-
// ------------------------------------------------------- wait (offer -> permit)
|
|
392
|
+
// ------------------------------------------------------- wait (offer -> permit / job -> done)
|
|
303
393
|
case "wait": {
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
394
|
+
// Two honest bounded sleeps sharing one command:
|
|
395
|
+
// --id : queue phase — watches a queued request, auto-claims the
|
|
396
|
+
// offer when capacity reaches it, prints the permit token.
|
|
397
|
+
// --job : execution phase — sleeps until the daemon-tracked job for
|
|
398
|
+
// that request-id finishes (liveness sweep wake; the permit
|
|
399
|
+
// is released before you are woken). Structured result on
|
|
400
|
+
// every path; still_running = checkpoint, call again.
|
|
401
|
+
// Both share the exact implementations with the MCP tools and the
|
|
402
|
+
// OpenCode plugin (src/shared/wait-turn.ts / wait-completion.ts).
|
|
310
403
|
const timeoutMs = N("timeout", 600_000);
|
|
311
|
-
const agent = S("agent", defaultAgent());
|
|
312
404
|
const c = new LLClient();
|
|
313
405
|
await c.connect();
|
|
314
406
|
try {
|
|
407
|
+
const job = S("job");
|
|
408
|
+
if (job) {
|
|
409
|
+
const r = await waitForTrackedJob({ client: c, requestId: job, timeoutMs });
|
|
410
|
+
out({
|
|
411
|
+
state: r.state,
|
|
412
|
+
request_id: r.requestId ?? job,
|
|
413
|
+
exit_code: r.exitCode,
|
|
414
|
+
duration_ms: r.durationMs,
|
|
415
|
+
permit_released: r.permitReleased,
|
|
416
|
+
waited_ms: r.waitedMs,
|
|
417
|
+
effective_wait_ms: r.effectiveWaitMs,
|
|
418
|
+
started_at: r.startedAt,
|
|
419
|
+
detail: r.detail ?? r.nextAction,
|
|
420
|
+
});
|
|
421
|
+
if (r.state === "finished")
|
|
422
|
+
return;
|
|
423
|
+
if (r.state === "still_running")
|
|
424
|
+
fail(`wait: job still running after ${r.waitedMs}ms (checkpoint — call again)`, 68);
|
|
425
|
+
fail(`wait: job is ${r.state.replace(/_/g, " ")} — ${r.nextAction}`, 69);
|
|
426
|
+
}
|
|
427
|
+
const id = S("id") || fail("wait: --id <request-id> (queue wait) or --job <request-id> (job wait) required");
|
|
428
|
+
const agent = S("agent", defaultAgent());
|
|
315
429
|
const r = await waitForTurn({ client: c, agentId: agent, requestId: id, timeoutMs });
|
|
316
430
|
if (r.state === "granted" && r.entry?.permitToken) {
|
|
317
431
|
out({ permit_token: r.entry.permitToken, state: r.state, request_id: r.requestId, auto_claimed: r.autoClaimed ?? false });
|
|
@@ -339,28 +453,149 @@ async function main() {
|
|
|
339
453
|
case "service": {
|
|
340
454
|
const sub = rest[0] ?? "status";
|
|
341
455
|
if (sub === "stop") {
|
|
342
|
-
//
|
|
456
|
+
// Honest stop: signal the daemon, wait bounded for its socket to go
|
|
457
|
+
// silent, escalate to SIGKILL, and only report success once the
|
|
458
|
+
// endpoint is verifiably free. A leftover silent socket file or
|
|
459
|
+
// daemon.json (crash leftovers) is cleaned up either way.
|
|
343
460
|
const dir = stateDir();
|
|
461
|
+
const sock = socketPath();
|
|
462
|
+
const timeoutMs = N("timeout", 5000);
|
|
463
|
+
const info = readDaemonInfo(dir);
|
|
464
|
+
const answering = await probeTransport(sock);
|
|
465
|
+
if (!answering) {
|
|
466
|
+
// Nothing is listening: no daemon to stop. Clean crash leftovers.
|
|
467
|
+
try {
|
|
468
|
+
if (!isWindows() && existsSync(sock))
|
|
469
|
+
unlinkSync(sock);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
/* ignore */
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
rmSync(join(dir, "daemon.json"), { force: true });
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
/* ignore */
|
|
479
|
+
}
|
|
480
|
+
process.stdout.write("load-lock: no running daemon found (stale files cleaned)\n");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// Identify the daemon: prefer daemon.json, else ask it directly
|
|
484
|
+
// (version-less hello — stopping must not order a self-restart).
|
|
485
|
+
let pid = typeof info?.pid === "number" ? info.pid : undefined;
|
|
486
|
+
if (!pid) {
|
|
487
|
+
const hello = await rawHello(sock, 1500);
|
|
488
|
+
if (hello && typeof hello.pid === "number")
|
|
489
|
+
pid = hello.pid;
|
|
490
|
+
}
|
|
491
|
+
if (!pid) {
|
|
492
|
+
fail(`the socket ${sock} answers but no pid could be identified (daemon did not respond and daemon.json names none) ` +
|
|
493
|
+
`— cannot stop safely; run \`load-lock doctor\``, 70);
|
|
494
|
+
}
|
|
495
|
+
let how = "SIGTERM";
|
|
496
|
+
let stopped = false;
|
|
497
|
+
try {
|
|
498
|
+
process.kill(pid, "SIGTERM");
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
if (e?.code === "ESRCH")
|
|
502
|
+
stopped = true; // already gone
|
|
503
|
+
else
|
|
504
|
+
fail(`cannot signal daemon pid ${pid}: ${e?.message ?? e}`, 70);
|
|
505
|
+
}
|
|
506
|
+
if (!stopped)
|
|
507
|
+
stopped = await waitSocketFree(sock, timeoutMs);
|
|
508
|
+
if (!stopped) {
|
|
509
|
+
how = "SIGKILL";
|
|
510
|
+
try {
|
|
511
|
+
process.kill(pid, "SIGKILL");
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
/* already dead */
|
|
515
|
+
}
|
|
516
|
+
stopped = await waitSocketFree(sock, timeoutMs);
|
|
517
|
+
}
|
|
518
|
+
if (!stopped) {
|
|
519
|
+
fail(`daemon pid ${pid} did not stop within ${timeoutMs}ms even after SIGKILL — run \`load-lock doctor\``, 70);
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
if (!isWindows() && existsSync(sock))
|
|
523
|
+
unlinkSync(sock);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
/* ignore */
|
|
527
|
+
}
|
|
344
528
|
try {
|
|
345
|
-
|
|
346
|
-
process.kill(info.pid, "SIGTERM");
|
|
347
|
-
process.stdout.write(`load-lock: daemon (pid ${info.pid}) stopping\n`);
|
|
529
|
+
rmSync(join(dir, "daemon.json"), { force: true });
|
|
348
530
|
}
|
|
349
531
|
catch {
|
|
350
|
-
|
|
532
|
+
/* ignore */
|
|
351
533
|
}
|
|
534
|
+
process.stdout.write(`load-lock: daemon (pid ${pid}) stopped${how === "SIGKILL" ? " (required SIGKILL after SIGTERM)" : ""}\n`);
|
|
352
535
|
return;
|
|
353
536
|
}
|
|
354
537
|
if (sub === "start") {
|
|
355
538
|
// Connecting auto-spawns a detached daemon (see shared/client.ts);
|
|
356
|
-
// hello confirms it is up.
|
|
357
|
-
const
|
|
358
|
-
|
|
539
|
+
// hello confirms it is up. Refusals name the conflicting pid.
|
|
540
|
+
const timeoutMs = N("timeout", 10_000);
|
|
541
|
+
const sock = socketPath();
|
|
542
|
+
const installed = packageVersion();
|
|
543
|
+
try {
|
|
544
|
+
const r = (await withClient((c) => c.request("hello", {}, timeoutMs)));
|
|
545
|
+
const dv = typeof r.daemonVersion === "string" ? r.daemonVersion : undefined;
|
|
546
|
+
if (dv && compareVersions(dv, installed) < 0) {
|
|
547
|
+
// The handshake just ordered this old daemon to self-restart;
|
|
548
|
+
// confirm it comes back on updated code.
|
|
549
|
+
const up = await awaitDaemonUpgraded(sock, 8000, installed);
|
|
550
|
+
if (up) {
|
|
551
|
+
process.stdout.write(`load-lock: daemon self-restarted ${r.pid} -> ${up.pid} (v${dv} -> v${up.daemonVersion}), running on ${sock}\n`);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock}), daemon v${dv} — older than this CLI v${installed}; ` +
|
|
555
|
+
`self-restart ordered but not confirmed (its copy may not be updated in place — see docs/OPERATIONS.md)\n`);
|
|
556
|
+
}
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock})${dv ? `, daemon v${dv}` : ""}\n`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
if (await probeTransport(sock)) {
|
|
564
|
+
// Something owns the endpoint. Name it.
|
|
565
|
+
const hello = await rawHello(sock, 1500);
|
|
566
|
+
if (hello && typeof hello.pid === "number") {
|
|
567
|
+
fail(`start refused: socket answered from pid ${hello.pid} — another daemon owns ${sock}; ` +
|
|
568
|
+
`if it is stale, run \`load-lock service stop\` first`, 70);
|
|
569
|
+
}
|
|
570
|
+
const info = readDaemonInfo(stateDir());
|
|
571
|
+
if (info && typeof info.pid === "number") {
|
|
572
|
+
fail(`socket ${sock} answers but the daemon did not respond (daemon.json names pid ${info.pid}) — likely hung; ` +
|
|
573
|
+
`run \`load-lock service stop\``, 70);
|
|
574
|
+
}
|
|
575
|
+
fail(`start refused: ${sock} is in use but nothing responsive owns it — run \`load-lock doctor\``, 70);
|
|
576
|
+
}
|
|
577
|
+
throw e;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (sub === "status") {
|
|
581
|
+
const r = (await withClient((c) => c.request("hello", {})));
|
|
582
|
+
out(r, () => {
|
|
583
|
+
let line = `load-lock daemon: pid ${r.pid}, protocol v${r.protocolVersion}, config version ${r.configVersion}`;
|
|
584
|
+
if (typeof r.daemonVersion === "string") {
|
|
585
|
+
line += `, daemon v${r.daemonVersion}`;
|
|
586
|
+
const installed = packageVersion();
|
|
587
|
+
if (compareVersions(r.daemonVersion, installed) < 0) {
|
|
588
|
+
line += ` — older than this CLI v${installed}; self-restarting on contact`;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
line += ` — pre-handshake daemon (no version report); \`load-lock service stop\` then any command restarts it on current code`;
|
|
593
|
+
}
|
|
594
|
+
return line;
|
|
595
|
+
});
|
|
359
596
|
return;
|
|
360
597
|
}
|
|
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}`);
|
|
598
|
+
fail(`service: unknown subcommand "${sub}" (use start | stop | status)`);
|
|
364
599
|
return;
|
|
365
600
|
}
|
|
366
601
|
case "doctor": {
|
|
@@ -376,16 +611,54 @@ async function main() {
|
|
|
376
611
|
report.socketExists = isWindows() ? await probeTransport(sock) : existsSync(sock);
|
|
377
612
|
report.configExists = existsSync(join(dir, "config.json"));
|
|
378
613
|
report.journalExists = existsSync(join(dir, "journal.json"));
|
|
614
|
+
report.installedVersion = packageVersion();
|
|
615
|
+
const warnings = [];
|
|
379
616
|
try {
|
|
380
|
-
|
|
381
|
-
|
|
617
|
+
// One connection for hello + inspect: if the handshake orders a
|
|
618
|
+
// self-restart (daemon older than this CLI), both requests must
|
|
619
|
+
// finish before the daemon drains and exits.
|
|
620
|
+
const { hello, insp } = await withClient(async (c) => ({
|
|
621
|
+
hello: await c.request("hello", {}),
|
|
622
|
+
insp: await c.request("inspect", {}),
|
|
623
|
+
}));
|
|
624
|
+
report.hello = hello;
|
|
382
625
|
report.effectiveCores = insp.cpu.effectiveCores;
|
|
383
626
|
report.pressure = insp.pressure.note;
|
|
384
627
|
report.procs = insp.cpu.cgroupVersion;
|
|
628
|
+
const dv = hello.daemonVersion;
|
|
629
|
+
if (typeof dv === "string") {
|
|
630
|
+
const installed = packageVersion();
|
|
631
|
+
if (compareVersions(dv, installed) < 0) {
|
|
632
|
+
warnings.push(`daemon v${dv} is older than this CLI v${installed}; the version handshake ordered it to self-restart`);
|
|
633
|
+
const up = await awaitDaemonUpgraded(sock, 5000, installed);
|
|
634
|
+
if (up) {
|
|
635
|
+
report.daemonUpgradedTo = up.daemonVersion;
|
|
636
|
+
warnings.push(`daemon self-restarted to v${up.daemonVersion} (pid ${up.pid}) — now up to date`);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
warnings.push(`daemon did not come back on v${installed} within 5s (its on-disk copy may not be updated in place — ` +
|
|
640
|
+
`see docs/OPERATIONS.md "Update lifecycle"); the next client contact retries the restart`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
warnings.push(`pre-handshake daemon (no version in hello): restart it onto current code — \`load-lock service stop\`, then any load-lock command`);
|
|
646
|
+
}
|
|
385
647
|
}
|
|
386
648
|
catch (e) {
|
|
387
649
|
report.daemon = `unreachable: ${e.message}`;
|
|
388
650
|
}
|
|
651
|
+
// Multi-copy reality (issue #34): list every detectable copy and warn
|
|
652
|
+
// when their versions drift apart.
|
|
653
|
+
const copies = findInstalledCopies();
|
|
654
|
+
report.copies = copies;
|
|
655
|
+
const versions = new Set(copies.map((c) => c.version).filter((v) => v !== null));
|
|
656
|
+
if (versions.size > 1) {
|
|
657
|
+
warnings.push(`multiple load-lock versions installed (${[...versions].sort().join(", ")}): ` +
|
|
658
|
+
copies.map((c) => `${c.source}=${c.version ?? "?"} (${c.path})`).join("; ") +
|
|
659
|
+
` — see docs/COMPATIBILITY.md "Multiple copies of load-lock" for which copy is authoritative for what`);
|
|
660
|
+
}
|
|
661
|
+
report.warnings = warnings;
|
|
389
662
|
out(report, () => JSON.stringify(report, null, 2));
|
|
390
663
|
return;
|
|
391
664
|
}
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -27,11 +27,14 @@ export declare class Daemon {
|
|
|
27
27
|
* restart via the event cursor) and the process can exit promptly. */
|
|
28
28
|
private conns;
|
|
29
29
|
private waiters;
|
|
30
|
+
/** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
|
|
31
|
+
private completionWaiters;
|
|
30
32
|
private sentEventId;
|
|
31
33
|
private stateDirPath;
|
|
32
34
|
private socketPath;
|
|
33
35
|
private sweepTimer;
|
|
34
36
|
private expiryTimer;
|
|
37
|
+
private restartTimer;
|
|
35
38
|
private stopped;
|
|
36
39
|
constructor(opts?: {
|
|
37
40
|
stateDir?: string;
|
|
@@ -50,6 +53,23 @@ export declare class Daemon {
|
|
|
50
53
|
private onConnection;
|
|
51
54
|
private send;
|
|
52
55
|
private handleLine;
|
|
56
|
+
/**
|
|
57
|
+
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
58
|
+
* package version than this daemon runs, schedule a respawn so the daemon
|
|
59
|
+
* picks up the updated on-disk code (e.g. after `npm i -g
|
|
60
|
+
* @aron98/load-lock` while the daemon kept running). Skew detection is
|
|
61
|
+
* entirely daemon-side and best-effort: pre-handshake clients that send
|
|
62
|
+
* no version never trigger a restart.
|
|
63
|
+
*/
|
|
64
|
+
private maybeScheduleSelfRestart;
|
|
65
|
+
/**
|
|
66
|
+
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
67
|
+
* journal FIRST, then stops (destroying all client connections — they
|
|
68
|
+
* reconnect to the fresh daemon), then spawns the replacement with the
|
|
69
|
+
* exact same argv so a daemon started via the old copy runs the new copy's
|
|
70
|
+
* code, and exits.
|
|
71
|
+
*/
|
|
72
|
+
private selfRestart;
|
|
53
73
|
/** Returns [result, agentIdForPendingAttachment]. */
|
|
54
74
|
private dispatch;
|
|
55
75
|
/**
|
|
@@ -59,6 +79,28 @@ export declare class Daemon {
|
|
|
59
79
|
* push notifications — no model-authored busy-polling required.
|
|
60
80
|
*/
|
|
61
81
|
private waitForAdmission;
|
|
82
|
+
/**
|
|
83
|
+
* Bounded blocking wait for a daemon-tracked job's completion. Mirrors
|
|
84
|
+
* waitForAdmission: the call is held server-side and woken by
|
|
85
|
+
* resolveCompletionWaiters() when the job is observed done — which always
|
|
86
|
+
* happens AFTER the permit has been released (finish() frees capacity
|
|
87
|
+
* before the event is emitted, and waiters are resolved after that).
|
|
88
|
+
* timeoutMs 0 is check-only. Never blocks unbounded.
|
|
89
|
+
*/
|
|
90
|
+
private waitForJobCompletion;
|
|
91
|
+
/**
|
|
92
|
+
* Classify a request whose entry is no longer held (released, reclaimed,
|
|
93
|
+
* cancelled, or never started) into a structured completion result, using
|
|
94
|
+
* the most recent event for the request.
|
|
95
|
+
*/
|
|
96
|
+
private classifyEntryCompletion;
|
|
97
|
+
/**
|
|
98
|
+
* Wake blocked wait_for_completion calls whose tracked job has ended.
|
|
99
|
+
* Called after every machine transition (afterOp, sweep, expiry timer) —
|
|
100
|
+
* always AFTER finish() has freed capacity and after the event flush, so
|
|
101
|
+
* a woken agent observes the pool as free. Entries still held are skipped.
|
|
102
|
+
*/
|
|
103
|
+
private resolveCompletionWaiters;
|
|
62
104
|
/** Runs after every dispatch: sweep deadlines, auto-claim for waiters, flush events, journal. */
|
|
63
105
|
private afterOp;
|
|
64
106
|
private armExpiryTimer;
|