@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/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
  /**
@@ -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 [{ protocolVersion: 1, pid: process.pid, startedAt: m.startedAt, configVersion: m.config.version }, undefined];
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
- return [m.status(p.pool ? String(p.pool) : undefined), undefined];
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": {
@@ -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
- * Only *configuration* persists across daemon restarts. Permits and queue
7
- * are ephemeral by design (see docs/ARCHITECTURE.md — restart behavior):
8
- * tokens are daemon-instance-scoped, and agents re-acquire after a restart.
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";
@@ -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 for conservative restart reconciliation. */
183
+ /** Held permits (first) and queued/offered requests to persist across restarts. */
184
184
  journalEntries(): JournalEntry[];
185
185
  /**
186
- * Restore held permits after a daemon restart (conservative reconciliation):
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 {};