@aron98/load-lock 1.2.1 → 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.
@@ -89,13 +89,17 @@ function dashboardLines(v, width, f) {
89
89
  const syncAge = v.lastSyncAt ? fmtDuration(Math.max(0, v.now - v.lastSyncAt)) : "never";
90
90
  push(`load-lock dashboard ${STATE_LABEL[v.state] ?? v.state} synced ${syncAge} ago`, stateColor(v.state, f));
91
91
  if (v.daemon) {
92
- push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion} · events through #${v.latestEventId}`, f.dim);
92
+ push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}` +
93
+ (v.daemon.daemonVersion ? ` · daemon v${v.daemon.daemonVersion}` : "") +
94
+ ` · events through #${v.latestEventId}`, f.dim);
93
95
  }
94
96
  else {
95
97
  push(`daemon unreachable — it auto-spawns on the next load-lock command`, f.dim);
96
98
  }
97
99
  if (v.daemonRestartNote)
98
100
  push(`note: ${v.daemonRestartNote}`, f.warn);
101
+ if (v.versionSkewNote)
102
+ push(`note: ${v.versionSkewNote}`, f.warn);
99
103
  if (v.machine) {
100
104
  const m = v.machine;
101
105
  const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
@@ -0,0 +1,28 @@
1
+ export interface InstalledCopy {
2
+ /** Where this copy was found. */
3
+ source: "cli" | "path" | "opencode-cache";
4
+ /** Path of the copy's package root (its package.json's directory). */
5
+ path: string;
6
+ /** Version string, or null when the package.json could not be read. */
7
+ version: string | null;
8
+ }
9
+ /**
10
+ * Find the package.json of the @aron98/load-lock copy that contains the
11
+ * given file, by climbing parent directories. Best-effort: returns null if
12
+ * no enclosing package.json matches (or is readable).
13
+ */
14
+ export declare function enclosingPackage(startDir: string, maxDepth?: number): {
15
+ path: string;
16
+ version: string | null;
17
+ } | null;
18
+ /** The copy of load-lock this process runs from (its own package root). */
19
+ export declare function thisCopy(): InstalledCopy | null;
20
+ /**
21
+ * All installed copies of load-lock detectable on this machine:
22
+ * - "cli": the copy this process runs from.
23
+ * - "path": any `load-lock` binary found on PATH (deduped by real path,
24
+ * so a symlink to the running copy is recognized and not double-listed
25
+ * — a copy is only listed under "path" when it differs from "cli").
26
+ * - "opencode-cache": copies under opencode's private npm cache.
27
+ */
28
+ export declare function findInstalledCopies(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstalledCopy[];
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Installed-copy detection (issue #34): on a given machine several copies
3
+ * of load-lock commonly coexist — the globally-installed CLI on PATH, and
4
+ * the private copy opencode maintains under its npm cache (because
5
+ * `install --harness opencode` registers the bare package name, opencode
6
+ * installs and refreshes its own copy on its own schedule).
7
+ *
8
+ * All scanning is best-effort: missing paths, unreadable files, and
9
+ * unexpected layouts yield skipped entries or null versions — never a throw.
10
+ */
11
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { fileURLToPath } from "node:url";
15
+ import { isWindows } from "../shared/transport.js";
16
+ /** Package name of this package (used to find enclosing package roots). */
17
+ const PKG_NAME = "@aron98/load-lock";
18
+ /**
19
+ * Find the package.json of the @aron98/load-lock copy that contains the
20
+ * given file, by climbing parent directories. Best-effort: returns null if
21
+ * no enclosing package.json matches (or is readable).
22
+ */
23
+ export function enclosingPackage(startDir, maxDepth = 12) {
24
+ let dir = startDir;
25
+ for (let i = 0; i < maxDepth; i++) {
26
+ const pkgPath = join(dir, "package.json");
27
+ try {
28
+ if (existsSync(pkgPath)) {
29
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
30
+ if (pkg?.name === PKG_NAME) {
31
+ return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
32
+ }
33
+ // A foreign package.json terminates the climb (e.g. a PATH bin
34
+ // inside some other tool's package).
35
+ return null;
36
+ }
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ const parent = dirname(dir);
42
+ if (parent === dir)
43
+ return null;
44
+ dir = parent;
45
+ }
46
+ return null;
47
+ }
48
+ /** The copy of load-lock this process runs from (its own package root). */
49
+ export function thisCopy() {
50
+ // dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
51
+ const startDir = dirname(dirname(fileURLToPath(import.meta.url)));
52
+ const found = enclosingPackage(startDir);
53
+ if (!found)
54
+ return null;
55
+ return { source: "cli", path: found.path, version: found.version };
56
+ }
57
+ /**
58
+ * All installed copies of load-lock detectable on this machine:
59
+ * - "cli": the copy this process runs from.
60
+ * - "path": any `load-lock` binary found on PATH (deduped by real path,
61
+ * so a symlink to the running copy is recognized and not double-listed
62
+ * — a copy is only listed under "path" when it differs from "cli").
63
+ * - "opencode-cache": copies under opencode's private npm cache.
64
+ */
65
+ export function findInstalledCopies(platform = process.platform, env = process.env) {
66
+ const copies = [];
67
+ const seen = new Set();
68
+ const add = (c) => {
69
+ if (!c)
70
+ return;
71
+ let real;
72
+ try {
73
+ real = realpathSync(c.path);
74
+ }
75
+ catch {
76
+ real = c.path;
77
+ }
78
+ if (seen.has(real))
79
+ return;
80
+ seen.add(real);
81
+ copies.push(c);
82
+ };
83
+ add(thisCopy());
84
+ // --- PATH scan ------------------------------------------------------------
85
+ try {
86
+ const binNames = isWindows(platform)
87
+ ? ["load-lock.cmd", "load-lock.exe", "load-lock"]
88
+ : ["load-lock"];
89
+ const dirs = (env.PATH ?? "").split(isWindows(platform) ? ";" : ":").filter(Boolean);
90
+ for (const dir of dirs) {
91
+ for (const bin of binNames) {
92
+ try {
93
+ const binPath = join(dir, bin);
94
+ if (!statSync(binPath).isFile())
95
+ continue;
96
+ // A shim/wrapper script is not itself the copy — resolve through
97
+ // its enclosing package (works for npm/bun global layouts where
98
+ // the bin sits in <pkg>/bin or node_modules/.bin via a symlink).
99
+ let realBin;
100
+ try {
101
+ realBin = realpathSync(binPath);
102
+ }
103
+ catch {
104
+ realBin = binPath;
105
+ }
106
+ const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
107
+ if (found)
108
+ add({ source: "path", path: found.path, version: found.version });
109
+ }
110
+ catch {
111
+ /* unreadable PATH entry — skip */
112
+ }
113
+ }
114
+ }
115
+ }
116
+ catch {
117
+ /* PATH scan is best-effort */
118
+ }
119
+ // --- opencode npm-cache scan ----------------------------------------------
120
+ // Layout (verified live): <cacheRoot>/opencode/npm/@aron98/load-lock@latest/<ts>/
121
+ // where "@aron98" and "load-lock@latest" are two nested directories (a
122
+ // normal npm scoped layout); URL-escaped single-directory variants like
123
+ // "@aron98%2Fload-lock@latest" also occur. Under each <ts>: either the
124
+ // real package at node_modules/@aron98/load-lock, or a top-level
125
+ // package.json (npm-install root).
126
+ try {
127
+ const cacheRoot = opencodeCacheRoot(platform, env);
128
+ if (cacheRoot) {
129
+ const npmDir = join(cacheRoot, "opencode", "npm");
130
+ if (existsSync(npmDir)) {
131
+ const scanTimestampDirs = (pkgBase) => {
132
+ let tsDirs = [];
133
+ try {
134
+ tsDirs = readdirSync(pkgBase).filter((d) => statSync(join(pkgBase, d)).isDirectory());
135
+ }
136
+ catch {
137
+ return;
138
+ }
139
+ for (const ts of tsDirs) {
140
+ const nested = join(pkgBase, ts, "node_modules", "@aron98", "load-lock");
141
+ const topLevel = join(pkgBase, ts);
142
+ for (const cand of [nested, topLevel]) {
143
+ try {
144
+ const pkgPath = join(cand, "package.json");
145
+ if (!existsSync(pkgPath))
146
+ continue;
147
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
148
+ if (pkg?.name !== PKG_NAME)
149
+ continue;
150
+ add({
151
+ source: "opencode-cache",
152
+ path: cand,
153
+ version: typeof pkg.version === "string" ? pkg.version : null,
154
+ });
155
+ break;
156
+ }
157
+ catch {
158
+ /* skip unreadable timestamp dir */
159
+ }
160
+ }
161
+ }
162
+ };
163
+ let scopeDirs = [];
164
+ try {
165
+ scopeDirs = readdirSync(npmDir).filter((d) => statSync(join(npmDir, d)).isDirectory());
166
+ }
167
+ catch {
168
+ scopeDirs = [];
169
+ }
170
+ for (const scopeDir of scopeDirs) {
171
+ if (scopeDir.startsWith("@aron98%2Fload-lock")) {
172
+ // URL-escaped single-directory form.
173
+ scanTimestampDirs(join(npmDir, scopeDir));
174
+ }
175
+ else if (scopeDir === "@aron98") {
176
+ // Scoped layout: @aron98/<name>@<version-range>/<ts>/...
177
+ let inner = [];
178
+ try {
179
+ inner = readdirSync(join(npmDir, scopeDir)).filter((d) => statSync(join(npmDir, scopeDir, d)).isDirectory());
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ for (const nameDir of inner) {
185
+ if (nameDir.startsWith("load-lock"))
186
+ scanTimestampDirs(join(npmDir, scopeDir, nameDir));
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ catch {
194
+ /* cache scan is best-effort */
195
+ }
196
+ return copies;
197
+ }
198
+ /** Cache root the way opencode resolves it (its npm cache lives under it). */
199
+ function opencodeCacheRoot(platform, env) {
200
+ if (env.XDG_CACHE_HOME)
201
+ return env.XDG_CACHE_HOME;
202
+ if (platform === "win32")
203
+ return env.LOCALAPPDATA ?? null;
204
+ if (platform === "darwin")
205
+ return join(env.HOME ?? homedir(), "Library", "Caches");
206
+ return join(env.HOME ?? homedir(), ".cache");
207
+ }
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
- // Connect and ask... simplest: SIGTERM via daemon.json pid.
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
- const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
346
- process.kill(info.pid, "SIGTERM");
347
- process.stdout.write(`load-lock: daemon (pid ${info.pid}) stopping\n`);
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
- process.stdout.write("load-lock: no running daemon found\n");
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 r = await withClient((c) => c.request("hello", {}));
358
- process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${process.env.LOAD_LOCK_SOCKET || "default socket"})\n`);
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
- // status
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
- report.hello = await withClient((c) => c.request("hello", {}));
381
- const insp = await withClient((c) => c.request("inspect", {}));
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
  }
@@ -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
  /**