@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/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, then auto-claim the offer: --id <request-id> [--timeout <ms>]" },
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
- // Watches a queued request; auto-claims the offer when capacity is
305
- // handed to it and prints the permit token. Honest bounded blocking
306
- // for scriptable flows (aider, plain shell scripts). Shares the exact
307
- // implementation with the MCP wait_for_turn tool and the OpenCode
308
- // plugin (src/shared/wait-turn.ts).
309
- const id = S("id") || fail("wait: --id <request-id> required");
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
- // Connect and ask... simplest: SIGTERM via daemon.json pid.
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
- 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`);
529
+ rmSync(join(dir, "daemon.json"), { force: true });
348
530
  }
349
531
  catch {
350
- process.stdout.write("load-lock: no running daemon found\n");
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 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`);
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
- // 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}`);
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
- report.hello = await withClient((c) => c.request("hello", {}));
381
- const insp = await withClient((c) => c.request("inspect", {}));
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
  }
@@ -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;