@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.
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Integration tests for the update story (issue #34):
3
+ * - version handshake: hello/status carry daemonVersion; clients stamp
4
+ * their envelope with clientVersion
5
+ * - daemon self-restart: a NEWER client orders the daemon to persist
6
+ * state, drain, and respawn with the same argv (state survives)
7
+ * - same/older clients never restart it; no restart loops per version
8
+ * - offered entries restart as queued and are re-offered with fresh tokens
9
+ * - `service stop` waits bounded, escalates to SIGKILL, verifies the
10
+ * socket is free, and reports honestly
11
+ * - `service start` refusals name the conflicting pid
12
+ * - `doctor` reports installed copies and warns on drift
13
+ *
14
+ * Modeled after daemon.test.ts: real daemons over real sockets in temp
15
+ * state dirs (LL_TEST_TMP / $TMPDIR/opencode — never the user's real dir).
16
+ */
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { spawn } from "node:child_process";
20
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { connect, createServer } from "node:net";
24
+ import { LLClient } from "../shared/client.js";
25
+ import { packageVersion } from "../shared/version.js";
26
+ import { startDaemon, probe, delay, until, cliMainPath } from "./testutil.js";
27
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
28
+ mkdirSync(TMP_BASE, { recursive: true });
29
+ const ACQ = (agentId, opKey, extra = {}) => ({
30
+ agentId,
31
+ opKey,
32
+ reason: "update-story test",
33
+ ...extra,
34
+ });
35
+ /** Send a raw (handcrafted) envelope line; resolve the first response. */
36
+ function rawRequest(sockPath, envelope, timeoutMs = 3000) {
37
+ return new Promise((resolve) => {
38
+ let settled = false;
39
+ let buf = "";
40
+ const done = (v) => {
41
+ if (settled)
42
+ return;
43
+ settled = true;
44
+ try {
45
+ s.destroy();
46
+ }
47
+ catch {
48
+ /* ignore */
49
+ }
50
+ resolve(v);
51
+ };
52
+ const s = connect(sockPath, () => {
53
+ s.write(JSON.stringify(envelope) + "\n");
54
+ });
55
+ s.on("data", (chunk) => {
56
+ buf += chunk.toString("utf8");
57
+ const idx = buf.indexOf("\n");
58
+ if (idx === -1)
59
+ return;
60
+ try {
61
+ done(JSON.parse(buf.slice(0, idx)));
62
+ }
63
+ catch {
64
+ done(null);
65
+ }
66
+ });
67
+ s.on("error", () => done(null));
68
+ setTimeout(() => done(null), timeoutMs).unref?.();
69
+ });
70
+ }
71
+ /** hello envelope with an explicit client version (the handshake trigger). */
72
+ const helloFrom = (clientVersion) => ({ id: 1, method: "hello", params: {}, ...(clientVersion ? { clientVersion } : {}) });
73
+ /** Run the CLI with an explicit env (for fake-daemon fixtures). */
74
+ function runCliEnv(env, args, timeoutMs = 60_000) {
75
+ return new Promise((resolve, reject) => {
76
+ const child = spawn(process.execPath, [cliMainPath, ...args], { env });
77
+ let stdout = "";
78
+ let stderr = "";
79
+ child.stdout.on("data", (b) => (stdout += b));
80
+ child.stderr.on("data", (b) => (stderr += b));
81
+ child.stdin.end();
82
+ const timer = setTimeout(() => {
83
+ child.kill("SIGKILL");
84
+ reject(new Error(`CLI timed out: load-lock ${args.join(" ")}`));
85
+ }, timeoutMs);
86
+ child.once("exit", (code) => {
87
+ clearTimeout(timer);
88
+ resolve({ code, stdout, stderr });
89
+ });
90
+ child.once("error", (e) => {
91
+ clearTimeout(timer);
92
+ reject(e);
93
+ });
94
+ });
95
+ }
96
+ /** Kill whatever daemon currently owns the state dir (respawned daemons are not our child). */
97
+ function killDaemonFromJson(dir) {
98
+ try {
99
+ const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
100
+ if (typeof info.pid === "number") {
101
+ try {
102
+ process.kill(info.pid, "SIGKILL");
103
+ }
104
+ catch {
105
+ /* already gone */
106
+ }
107
+ }
108
+ }
109
+ catch {
110
+ /* no daemon.json — nothing to kill */
111
+ }
112
+ }
113
+ /** Wait for the daemon on dir/sock to have a pid different from `oldPid`. */
114
+ async function waitRespawned(d, oldPid, timeoutMs = 15_000) {
115
+ let pid = 0;
116
+ await until("daemon respawned with a new pid", async () => {
117
+ if (!(await probe(d.sock)))
118
+ return false;
119
+ const r = await rawRequest(d.sock, helloFrom(), 2000);
120
+ if (r && r.ok && r.result && r.result.pid !== oldPid) {
121
+ pid = r.result.pid;
122
+ return true;
123
+ }
124
+ return false;
125
+ }, timeoutMs);
126
+ return pid;
127
+ }
128
+ // ------------------------------------------------------------------ tests
129
+ test("handshake: hello/status carry daemonVersion; client envelope carries clientVersion", async () => {
130
+ const d = await startDaemon();
131
+ try {
132
+ const c = d.client();
133
+ await c.connect();
134
+ const h = (await c.request("hello", {}));
135
+ assert.equal(h.daemonVersion, packageVersion(), "hello reports the daemon package version");
136
+ const st = (await c.request("status", {}));
137
+ assert.equal(st.daemon.daemonVersion, packageVersion(), "status reports the daemon package version");
138
+ c.close();
139
+ // A client with no version (pre-handshake) gets a normal response — the
140
+ // field is additive both ways.
141
+ const r = await rawRequest(d.sock, helloFrom());
142
+ assert.ok(r.ok);
143
+ assert.equal(r.result.daemonVersion, packageVersion());
144
+ // The shared client stamps its envelope: a fake server captures the line.
145
+ const capDir = mkdtempSync(join(TMP_BASE, "ll-capture-"));
146
+ const capSock = join(capDir, "cap.sock");
147
+ let captured = "";
148
+ const server = createServer((s) => {
149
+ s.on("data", (chunk) => {
150
+ captured += chunk.toString("utf8");
151
+ const idx = captured.indexOf("\n");
152
+ if (idx !== -1) {
153
+ try {
154
+ const msg = JSON.parse(captured.slice(0, idx));
155
+ s.write(JSON.stringify({ id: msg.id, ok: true, result: { echo: true } }) + "\n");
156
+ }
157
+ catch {
158
+ /* ignore */
159
+ }
160
+ }
161
+ });
162
+ });
163
+ await new Promise((r) => server.listen(capSock, r));
164
+ const cc = new LLClient({ socket: capSock, stateDir: capDir, spawnDaemon: false });
165
+ await cc.connect();
166
+ await cc.request("hello", {});
167
+ cc.close();
168
+ await new Promise((r) => server.close(() => r()));
169
+ const envelope = JSON.parse(captured.trim());
170
+ assert.equal(envelope.clientVersion, packageVersion(), "client envelope carries clientVersion");
171
+ rmSync(capDir, { recursive: true, force: true });
172
+ // `service status` surfaces the version too.
173
+ const svc = await runCliEnv(d.env, ["service", "status"]);
174
+ assert.equal(svc.code, 0);
175
+ assert.ok(svc.stdout.includes(`daemon v${packageVersion()}`), `service status shows daemon version: ${svc.stdout}`);
176
+ }
177
+ finally {
178
+ await d.stop();
179
+ }
180
+ });
181
+ test("self-restart: newer client respawns the daemon; held permits and queued requests survive", async () => {
182
+ const d = await startDaemon();
183
+ try {
184
+ const pid1 = d.child.pid;
185
+ const c = d.client();
186
+ await c.connect();
187
+ // A: held, tracked on a live sleeper process.
188
+ const g = (await c.request("acquire", ACQ("A", "op1")));
189
+ const permitToken = g.entry.permitToken;
190
+ const sleeper = spawn("sleep", ["60"], { stdio: "ignore" });
191
+ await c.request("attach_pid", { permitToken, pid: sleeper.pid });
192
+ // B: queued behind A (capacity 1).
193
+ const q = (await c.request("acquire", ACQ("B", "op2")));
194
+ assert.equal(q.state, "queued");
195
+ const bRequestId = q.entry.requestId;
196
+ c.close();
197
+ // A NEWER client shows up -> the daemon answers first, then restarts.
198
+ const r = await rawRequest(d.sock, helloFrom("999.0.0"));
199
+ assert.ok(r.ok, "hello response still delivered before the restart");
200
+ const pid2 = await waitRespawned(d, pid1);
201
+ assert.notEqual(pid2, pid1);
202
+ // Held permit survived with the same token and tracking.
203
+ const c2 = d.client();
204
+ await c2.connect();
205
+ const st = (await c2.request("status", {}));
206
+ assert.equal(st.daemon.daemonVersion, packageVersion());
207
+ assert.equal(st.pools[0].holders.length, 1, "held permit survives the self-restart");
208
+ assert.equal(st.pools[0].holders[0].phase, "running_tracked");
209
+ assert.equal(st.pools[0].holders[0].pid, sleeper.pid);
210
+ // Queued request survived, same id (idempotent re-acquire keeps working).
211
+ assert.equal(st.pools[0].queue.length, 1, "queued request survives the self-restart");
212
+ assert.equal(st.pools[0].queue[0].requestId, bRequestId);
213
+ // Same permit token still releases.
214
+ const rel = (await c2.request("release", { permitToken }));
215
+ assert.equal(rel.releasedNow, true);
216
+ // Idempotent re-acquire of the queued request resolves to the same id.
217
+ const again = (await c2.request("acquire", ACQ("B", "op2")));
218
+ assert.equal(again.entry.requestId, bRequestId);
219
+ c2.close();
220
+ sleeper.kill("SIGKILL");
221
+ await new Promise((r) => sleeper.once("exit", r));
222
+ }
223
+ finally {
224
+ killDaemonFromJson(d.dir);
225
+ await d.stop();
226
+ }
227
+ });
228
+ test("no restart for same/older clients, and no restart loop after a respawn", async () => {
229
+ const d = await startDaemon();
230
+ try {
231
+ const pid1 = d.child.pid;
232
+ // Same version: no restart.
233
+ await rawRequest(d.sock, helloFrom(packageVersion()));
234
+ await delay(600);
235
+ const s1 = await rawRequest(d.sock, helloFrom());
236
+ assert.equal(s1.result.pid, pid1, "same-version client does not restart the daemon");
237
+ // Older client: no restart.
238
+ await rawRequest(d.sock, helloFrom("0.0.1"));
239
+ await delay(600);
240
+ const s2 = await rawRequest(d.sock, helloFrom());
241
+ assert.equal(s2.result.pid, pid1, "older client does not restart the daemon");
242
+ // No clientVersion at all (pre-handshake client): no restart.
243
+ await rawRequest(d.sock, helloFrom());
244
+ await delay(600);
245
+ const s3 = await rawRequest(d.sock, helloFrom());
246
+ assert.equal(s3.result.pid, pid1);
247
+ // Now restart for 999.0.0 ...
248
+ await rawRequest(d.sock, helloFrom("999.0.0"));
249
+ const pid2 = await waitRespawned(d, pid1);
250
+ // ... and confirm neither 999.0.0 again nor an older version restarts
251
+ // it a second time (LOAD_LOCK_RESTARTED_FOR marker prevents loops).
252
+ await rawRequest(d.sock, helloFrom("999.0.0"));
253
+ await delay(600);
254
+ await rawRequest(d.sock, helloFrom("500.0.0"));
255
+ await delay(600);
256
+ const s4 = await rawRequest(d.sock, helloFrom());
257
+ assert.equal(s4.result.pid, pid2, "respawned daemon is stable (no restart loop)");
258
+ }
259
+ finally {
260
+ killDaemonFromJson(d.dir);
261
+ await d.stop();
262
+ }
263
+ });
264
+ test("offered entry restarts as queued and is re-offered with a fresh token", async () => {
265
+ const d = await startDaemon();
266
+ try {
267
+ const pid1 = d.child.pid;
268
+ const cA = d.client();
269
+ await cA.connect();
270
+ const gA = (await cA.request("acquire", ACQ("A", "op1")));
271
+ const cB = d.client();
272
+ await cB.connect();
273
+ const qB = (await cB.request("acquire", ACQ("B", "op1")));
274
+ assert.equal(qB.state, "queued");
275
+ // Free capacity -> B is offered (token t1).
276
+ await cA.request("release", { permitToken: gA.entry.permitToken });
277
+ const pend1 = (await cB.request("pending", { agentId: "B" }));
278
+ assert.equal(pend1.offers.length, 1);
279
+ const t1 = pend1.offers[0].offerToken;
280
+ assert.ok(t1);
281
+ cA.close();
282
+ cB.close();
283
+ // Self-restart while the offer is outstanding.
284
+ await rawRequest(d.sock, helloFrom("999.0.0"));
285
+ await waitRespawned(d, pid1);
286
+ // After the restart B is offered again — with a FRESH token.
287
+ const c2 = d.client();
288
+ await c2.connect();
289
+ const pend2 = (await c2.request("pending", { agentId: "B" }));
290
+ assert.equal(pend2.offers.length, 1, "queued-then-offered request is re-offered after restart");
291
+ const t2 = pend2.offers[0].offerToken;
292
+ assert.ok(t2);
293
+ assert.notEqual(t2, t1, "offer token is fresh after the restart");
294
+ // The fresh token claims; the pre-restart token is stale.
295
+ const cl = (await c2.request("claim", { offerToken: t2 }));
296
+ assert.equal(cl.entry.state, "held", "fresh offer token claims after the restart");
297
+ await assert.rejects(c2.request("claim", { offerToken: t1 }), (e) => e.code === "stale_token");
298
+ await c2.request("release", { permitToken: cl.entry.permitToken });
299
+ c2.close();
300
+ }
301
+ finally {
302
+ killDaemonFromJson(d.dir);
303
+ await d.stop();
304
+ }
305
+ });
306
+ test("service stop: graceful stop verified socket-free, daemon.json cleaned", async () => {
307
+ const d = await startDaemon();
308
+ try {
309
+ const r = await runCliEnv(d.env, ["service", "stop"]);
310
+ assert.equal(r.code, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
311
+ assert.ok(r.stdout.includes("stopped"), `honest success message: ${r.stdout}`);
312
+ assert.ok(!(await probe(d.sock)), "socket is verifiably free");
313
+ assert.ok(!existsSync(join(d.dir, "daemon.json")), "daemon.json removed");
314
+ }
315
+ finally {
316
+ await d.stop();
317
+ }
318
+ });
319
+ test("service stop: escalates to SIGKILL for a hung daemon and cleans the stale socket", async () => {
320
+ const dir = mkdtempSync(join(TMP_BASE, "ll-hung-"));
321
+ const sock = join(dir, "daemon.sock");
322
+ // A fake daemon: owns the socket, writes daemon.json, ignores SIGTERM.
323
+ const script = `
324
+ const net = require("net");
325
+ const fs = require("fs");
326
+ const dir = process.argv[1], sock = process.argv[2];
327
+ fs.writeFileSync(require("path").join(dir, "daemon.json"),
328
+ JSON.stringify({ pid: process.pid, startedAt: Date.now(), socket: sock }));
329
+ process.on("SIGTERM", () => {});
330
+ const srv = net.createServer((s) => { s.on("data", () => {}); });
331
+ srv.listen(sock, () => { setInterval(() => {}, 1000); });
332
+ `;
333
+ const child = spawn(process.execPath, ["-e", script, dir, sock], {
334
+ stdio: "ignore",
335
+ env: { ...process.env, LOAD_LOCK_STATE_DIR: dir, LOAD_LOCK_SOCKET: sock },
336
+ });
337
+ await until("fake hung daemon listens", () => probe(sock));
338
+ const fakePid = child.pid;
339
+ try {
340
+ const r = await runCliEnv({ ...process.env, LOAD_LOCK_STATE_DIR: dir, LOAD_LOCK_SOCKET: sock }, ["service", "stop", "--timeout", "700"]);
341
+ assert.equal(r.code, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
342
+ assert.ok(/SIGKILL/i.test(r.stdout), `reports the escalation honestly: ${r.stdout}`);
343
+ // The hung process is really dead and the endpoint is really free.
344
+ await until("hung daemon exits", () => !isAlive(fakePid));
345
+ assert.ok(!existsSync(sock), "stale socket file cleaned");
346
+ assert.ok(!existsSync(join(dir, "daemon.json")), "stale daemon.json cleaned");
347
+ }
348
+ finally {
349
+ try {
350
+ child.kill("SIGKILL");
351
+ }
352
+ catch {
353
+ /* ignore */
354
+ }
355
+ rmSync(dir, { recursive: true, force: true });
356
+ }
357
+ });
358
+ function isAlive(pid) {
359
+ try {
360
+ process.kill(pid, 0);
361
+ return true;
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }
367
+ test("service start: refusal names the conflicting pid when the daemon hangs", async () => {
368
+ const dir = mkdtempSync(join(TMP_BASE, "ll-refuse-"));
369
+ const sock = join(dir, "daemon.sock");
370
+ // Accepts connections but never answers hello; writes daemon.json.
371
+ const script = `
372
+ const net = require("net");
373
+ const fs = require("fs");
374
+ const path = require("path");
375
+ const dir = process.argv[1], sock = process.argv[2];
376
+ fs.writeFileSync(path.join(dir, "daemon.json"),
377
+ JSON.stringify({ pid: process.pid, startedAt: Date.now(), socket: sock }));
378
+ const srv = net.createServer((s) => { s.on("data", () => {}); });
379
+ srv.listen(sock, () => { setInterval(() => {}, 1000); });
380
+ `;
381
+ const child = spawn(process.execPath, ["-e", script, dir, sock], {
382
+ stdio: "ignore",
383
+ env: { ...process.env, LOAD_LOCK_STATE_DIR: dir, LOAD_LOCK_SOCKET: sock },
384
+ });
385
+ await until("hung listener accepts", () => probe(sock));
386
+ const fakePid = child.pid;
387
+ try {
388
+ const r = await runCliEnv({ ...process.env, LOAD_LOCK_STATE_DIR: dir, LOAD_LOCK_SOCKET: sock }, ["service", "start", "--timeout", "1500"], 30_000);
389
+ assert.notEqual(r.code, 0, "start must fail honestly");
390
+ const combined = r.stdout + r.stderr;
391
+ assert.ok(combined.includes(String(fakePid)), `refusal names the conflicting pid: ${combined}`);
392
+ assert.ok(/daemon\.json names pid|answered from pid/.test(combined), `refusal points at the hang: ${combined}`);
393
+ }
394
+ finally {
395
+ try {
396
+ child.kill("SIGKILL");
397
+ }
398
+ catch {
399
+ /* ignore */
400
+ }
401
+ rmSync(dir, { recursive: true, force: true });
402
+ }
403
+ });
404
+ test("service start: happy path reports pid + version", async () => {
405
+ const d = await startDaemon();
406
+ try {
407
+ const r = await runCliEnv(d.env, ["service", "start"]);
408
+ assert.equal(r.code, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
409
+ assert.ok(r.stdout.includes(`running on pid ${d.child.pid}`), `names the daemon pid: ${r.stdout}`);
410
+ assert.ok(r.stdout.includes(`daemon v${packageVersion()}`), `names the daemon version: ${r.stdout}`);
411
+ }
412
+ finally {
413
+ await d.stop();
414
+ }
415
+ });
416
+ test("doctor: reports installed copies, versions, daemon skew, and copy drift", async () => {
417
+ const d = await startDaemon();
418
+ const cacheHome = mkdtempSync(join(TMP_BASE, "ll-cachehome-"));
419
+ const fakePkg = mkdtempSync(join(TMP_BASE, "ll-fakepkg-"));
420
+ try {
421
+ // A stale copy in opencode's npm cache (issue #34 multi-copy reality).
422
+ // Real layout: <cache>/opencode/npm/@aron98/load-lock@latest/<ts>/...
423
+ const cacheCopy = join(cacheHome, "opencode", "npm", "@aron98", "load-lock@latest", "1700000000000", "node_modules", "@aron98", "load-lock");
424
+ mkdirSync(cacheCopy, { recursive: true });
425
+ writeFileSync(join(cacheCopy, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version: "0.1.2" }));
426
+ // A drift copy on PATH.
427
+ mkdirSync(join(fakePkg, "bin"), { recursive: true });
428
+ writeFileSync(join(fakePkg, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version: "9.9.9" }));
429
+ writeFileSync(join(fakePkg, "bin", "load-lock"), "#!/bin/sh\ntrue\n");
430
+ const env = {
431
+ ...d.env,
432
+ XDG_CACHE_HOME: cacheHome,
433
+ PATH: join(fakePkg, "bin") + ":" + (process.env.PATH ?? ""),
434
+ };
435
+ const r = await runCliEnv(env, ["doctor", "--json"]);
436
+ assert.equal(r.code, 0, `stderr: ${r.stderr}`);
437
+ const report = JSON.parse(r.stdout);
438
+ assert.equal(report.installedVersion, packageVersion());
439
+ // Daemon reached and versioned.
440
+ assert.equal(report.hello.daemonVersion, packageVersion());
441
+ // Both planted copies are detected...
442
+ const versions = report.copies.map((c) => `${c.source}:${c.version}`);
443
+ assert.ok(versions.includes("opencode-cache:0.1.2"), `opencode cache copy found: ${versions.join(", ")}`);
444
+ assert.ok(versions.includes("path:9.9.9"), `PATH copy found: ${versions.join(", ")}`);
445
+ assert.ok(versions.includes(`cli:${packageVersion()}`), `running copy reported: ${versions.join(", ")}`);
446
+ // ...and the drift is warned about.
447
+ assert.ok(Array.isArray(report.warnings) && report.warnings.length > 0);
448
+ assert.ok(report.warnings.some((w) => w.includes("multiple load-lock versions") && w.includes("COMPATIBILITY")), `drift warning: ${JSON.stringify(report.warnings)}`);
449
+ }
450
+ finally {
451
+ await d.stop();
452
+ rmSync(cacheHome, { recursive: true, force: true });
453
+ rmSync(fakePkg, { recursive: true, force: true });
454
+ }
455
+ });
@@ -112,11 +112,15 @@ permits are journaled: on daemon restart, tracked permits are reconciled
112
112
  against real process liveness (dead ⇒ released, alive ⇒ kept, same tokens
113
113
  still valid), claimed permits keep their original start deadline, untracked
114
114
  permits get a fresh lease window, and a release arriving late for an entry
115
- that was reconciled is idempotent. Queues and offers are *not* journaled:
116
- they are ephemeral by design — queued agents are still running, notice the
117
- missed offer via their next tool call, and re-acquire; the daemon
118
- auto-spawns on the next client connection, so an agent never needs to know
119
- the daemon restarted. This is documented, deliberate behavior, not a bug.
115
+ that was reconciled is idempotent. Queued *and offered* requests are journaled
116
+ too (issue #34): they are re-queued FIFO by original enqueue time with the
117
+ same request ids (idempotent re-acquire keeps working), entries offered at
118
+ crash time restart as queued and are re-offered with fresh tokens — so a
119
+ restart no longer silently drops a queue position. The daemon auto-spawns on
120
+ the next client connection, and a *self-ordered* restart (version handshake:
121
+ a newer client than the daemon) respawns with the same argv after persisting
122
+ state — see the update lifecycle in
123
+ [OPERATIONS.md](OPERATIONS.md#update-lifecycle).
120
124
 
121
125
  ## Notification honesty
122
126
 
@@ -191,7 +195,7 @@ prompts.
191
195
  | Agent disconnects mid-heavy-work | Permit retained; tracked job keeps capacity until the real process finishes; untracked job's lease survives until heartbeats stop |
192
196
  | Agent process crashes | Tracked permit auto-releases on confirmed process death; untracked reclaims after lease expiry |
193
197
  | Background job outlives the tool call | pgid tracking retains the permit until the whole group exits |
194
- | Daemon crash/restart | Auto-respawn on next connect; journal reconciliation of held permits (conservative: dead ⇒ released, alive ⇒ kept); queues/offers ephemeral by design |
198
+ | Daemon crash/restart | Auto-respawn on next connect; journal reconciliation of held permits (conservative: dead ⇒ released, alive ⇒ kept); queued/offered requests re-queued FIFO with same ids, offered entries re-offered with fresh tokens |
195
199
  | Stale/forged token | Opaque tokens; wrong-owner claim/release rejected; expired offers rejected with a precise error |
196
200
  | Forgotten release | start deadline (claimed), process death (tracked), lease expiry (untracked), plus human `recover force-release` |
197
201
  | PID reuse | `/proc` start-time comparison |
@@ -73,6 +73,38 @@ tool round-trip (acquire → queued → wakeup → claim → release), execution
73
73
  boundary delivery without interrupting a running `sleep`, skill visible,
74
74
  and graceful handling of a deliberately missed offer (paused → cancel).
75
75
 
76
+ #### Multiple copies of load-lock (opencode)
77
+
78
+ `install --harness opencode` registers the **bare package name** in
79
+ `~/.config/opencode/opencode.json`, so opencode maintains its own private
80
+ copy of load-lock under
81
+ `~/.cache/opencode/npm/@aron98/load-lock@latest/<timestamp>/node_modules/@aron98/load-lock/`,
82
+ refreshed on opencode's schedule. That copy is *separate* from a globally
83
+ installed CLI on PATH, and the two can drift (this was observed on a real
84
+ machine with cache copies at 0.1.2, 1.0.0, 1.1.0 and 1.2.1 — issue #34).
85
+
86
+ Which copy is authoritative for what:
87
+
88
+ - **opencode plugin behavior** — the cache copy. Updated by opencode itself
89
+ (it refreshes the cache entry); you cannot reliably update it by hand.
90
+ - **CLI commands** (`load-lock status`, `service`, `doctor`, …) — the copy on
91
+ PATH (typically a global install via npm/bun). Update it with your usual
92
+ global install command.
93
+ - **The daemon's code** — whichever copy spawned it (usually the CLI copy,
94
+ since auto-spawn and `service start` run from the CLI client code). The
95
+ version handshake makes a daemon self-restart onto updated code when a
96
+ newer client contacts it — but the respawn reuses the daemon's original
97
+ argv, so it only picks up an update when its spawning copy was updated
98
+ *in place* (the normal global-upgrade case). A daemon spawned from a
99
+ different copy than the one you updated keeps running the old code until
100
+ `load-lock service stop` + any command re-spawns it from the new one.
101
+
102
+ `load-lock doctor` lists every copy it can find (PATH-resolved CLI, this
103
+ running copy, opencode cache copies) with versions and warns when they
104
+ drift. Detection is best-effort (never crashes on missing paths) and was
105
+ contract-tested with planted copies; the drift scenario itself was
106
+ observed live on this machine before the feature existed.
107
+
76
108
  ### Tier 2 — Claude Code / Codex CLI (hooks + MCP, live-verified)
77
109
 
78
110
  `load-lock install --harness claude-code|codex` configures the MCP server
@@ -95,15 +95,62 @@ uninstall — see issue #19); not live-verified inside the real CLIs.
95
95
  [Sandboxes](#sandboxes)).
96
96
  - Also honored by the shared client: `LOAD_LOCK_LABEL_PREFIX` (prefix for
97
97
  holder labels; see Sandboxes).
98
- - Files: `config.json` (settings + history), `journal.json` (held permits),
99
- `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
98
+ - Files: `config.json` (settings + history), `journal.json` (held permits +
99
+ queued requests), `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
100
100
 
101
101
  ```sh
102
- load-lock service status # hello + pid
103
- load-lock service start # ensure running (idempotent)
104
- load-lock service stop # SIGTERM; next tool call respawns
102
+ load-lock service status # hello + pid + daemon version
103
+ load-lock service start # ensure running (idempotent); refusals name the conflicting pid
104
+ load-lock service stop # SIGTERM → bounded wait → SIGKILL; verifies the socket is free
105
105
  ```
106
106
 
107
+ `service stop` is honest, not fire-and-forget: it signals the daemon
108
+ (`--timeout <ms>` bounds the wait, default 5s), escalates to SIGKILL if the
109
+ socket still answers, and only reports success once the endpoint is
110
+ verifiably free (stale `daemon.json`/socket files are cleaned up either way).
111
+ `service start` refuses loudly when something else owns the endpoint —
112
+ naming the conflicting pid — instead of a bare "did not come up within 10s".
113
+
114
+ ## Update lifecycle
115
+
116
+ The daemon is long-lived, so an update (`npm i -g @aron98/load-lock@latest`,
117
+ or opencode refreshing its plugin copy) leaves the running daemon on stale
118
+ code. The **version handshake** closes that gap (v1.3.0+):
119
+
120
+ - Every client envelope carries `clientVersion`; `hello`/`status` replies
121
+ carry `daemonVersion` (the package version of the code the daemon runs).
122
+ - When a **newer** client contacts an older daemon, the daemon finishes
123
+ answering, then **self-restarts**: it persists state + journal, drains all
124
+ connections, respawns itself with the same argv (`process.execPath` +
125
+ original arguments, so it runs whatever is now on disk at the same path),
126
+ and exits. The respawn inherits a `LOAD_LOCK_RESTARTED_FOR` marker so
127
+ repeated contact with the same version cannot cause a restart loop — one
128
+ respawn attempt per version.
129
+ - Clients that were connected see a disconnect and simply reconnect to the
130
+ fresh daemon.
131
+ - **What survives a restart:** held permits are journaled and reconciled
132
+ conservatively (tracked jobs re-checked for liveness, same permit tokens
133
+ valid; dead jobs released); queued *and offered* requests are journaled and
134
+ re-queued FIFO with the same request ids (idempotent re-acquire keeps
135
+ working). Offered entries restart as queued and are re-offered with fresh
136
+ tokens — pre-restart offer tokens are invalid, exactly as for any restart.
137
+ - Skew is visible before it heals: `load-lock status`, `service status`,
138
+ `doctor`, and the dashboards show "daemon vX, installed vY" and note the
139
+ pending self-restart; `doctor` actively waits a few seconds to confirm the
140
+ daemon came back on the new version.
141
+
142
+ Limits, stated honestly: the respawn re-runs the **same argv** — it picks up
143
+ an updated copy only if the daemon's launching copy was updated *in place*
144
+ (the normal `npm i -g` case). A daemon spawned from a different copy (e.g.
145
+ opencode's private cache copy) keeps running that copy's code after a
146
+ restart; see the multi-copy notes in
147
+ [COMPATIBILITY.md](COMPATIBILITY.md#multiple-copies-of-load-lock-opencode).
148
+ Pre-1.3.0 daemons do not answer the handshake and never self-restart —
149
+ `service stop` followed by any load-lock command moves them onto current
150
+ code. The restart behavior itself is live-verified on Linux by the test
151
+ suite (`src/test/update.test.ts`); the in-place-update path beyond
152
+ same-argv respawn is inspected, not live-tested.
153
+
107
154
  ## Configuration
108
155
 
109
156
  All configuration is agent-editable through tools (no file editing for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aron98/load-lock",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Machine-wide counting semaphore coordinating resource-intensive work across parallel coding agents (OpenCode, Codex CLI, Claude Code, ...)",
5
5
  "type": "module",
6
6
  "license": "MIT",