@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.
@@ -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 |
@@ -43,6 +43,7 @@ Status labels mean exactly this:
43
43
  | **Cline** | Yes (`cline_mcp_settings.json`, merge-only installer) | `~/.cline/rules/load-lock.md` (dedicated rule file) | Rules always loaded | No hooks — `wait_for_turn` bounded sleep is the primary idle mechanism | No | Via MCP `run_with_permit` only | **Implemented (fallback tier, config-tested)** |
44
44
  | **Roo Code** | Yes (project `.roo/mcp.json` + existing global `mcp_settings.json`, merge-only installer) | `~/.roo/rules/load-lock.md` (dedicated rule file) | Rules always loaded | No hooks — `wait_for_turn` bounded sleep is the primary idle mechanism | No | Via MCP `run_with_permit` only | **Implemented (fallback tier, config-tested)** |
45
45
  | **Goose** | Yes (`~/.config/goose/config.yaml` `extensions` block, merge-only installer) | `.goosehints` (global, marked section) | Hints always loaded | No hooks — `wait_for_turn` bounded sleep is the primary idle mechanism | No | Via MCP `run_with_permit` only | **Implemented (fallback tier, config-tested)** |
46
+ | **omac** (agent sandbox; verified with 0.11.0) | N/A — omac is a sandbox runtime, not a harness: the agent inside it uses whichever harness adapter applies (OpenCode, Claude Code, …) | via the inner harness | via the inner harness | via the inner harness | via the inner harness | Full for the sandbox grants: `load-lock uninstall --harness omac` removes exactly the entries install added (match-own-entries, empty sections pruned) | **Sandbox-profile grants installed + live-verified** (`install --harness omac`: `filesystem.allow_unix_dir` += state dir, `filesystem.read` += package tree when needed, `environment.allow_vars` += `LOAD_LOCK_*`) |
46
47
  | **Aider** | **No MCP support** | CONVENTIONS.md / `.aider.conf.yml` | Read each run | N/A | N/A | CLI-only: same-shell `load-lock acquire ... && cmd; load-lock release ...` (manual) | Proposed (manual CLI tier) |
47
48
 
48
49
  ## How each integration tier works
@@ -72,6 +73,38 @@ tool round-trip (acquire → queued → wakeup → claim → release), execution
72
73
  boundary delivery without interrupting a running `sleep`, skill visible,
73
74
  and graceful handling of a deliberately missed offer (paused → cancel).
74
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
+
75
108
  ### Tier 2 — Claude Code / Codex CLI (hooks + MCP, live-verified)
76
109
 
77
110
  `load-lock install --harness claude-code|codex` configures the MCP server
@@ -236,6 +269,40 @@ push channel, so a woken agent relies on tool results (which carry
236
269
  pending offers) rather than harness messages. We do not pretend these
237
270
  harnesses got push notifications.
238
271
 
272
+ ### omac — sandbox-profile grants (live-verified, issue #32)
273
+
274
+ omac runs agent harnesses inside a sandbox (bubblewrap + Landlock on
275
+ Linux, seatbelt on macOS) and strips the harness environment, allowing
276
+ through only what the user-global profile lists — so a load-lock client
277
+ inside omac cannot reach the host daemon without explicit grants.
278
+ `load-lock install --harness omac` merges three grants into
279
+ `~/.config/omac/sandbox-profiles/default.json` (merge-only, idempotent,
280
+ reversible by uninstall; omac parses profiles strictly so the installer
281
+ writes only known fields and never rewrites the user's bytes when
282
+ nothing changed):
283
+
284
+ - `filesystem.allow_unix_dir` += the load-lock state dir (AF_UNIX connect
285
+ to the host daemon socket + read/write on the state dir);
286
+ - `filesystem.read` += the installed package tree — **only when load-lock
287
+ lives outside an already-granted workdir** (a repo checkout needs
288
+ nothing: the sandbox workdir grant covers it; a global bun/npm install
289
+ does need it, see OPERATIONS.md gotcha #1);
290
+ - `environment.allow_vars` += `"LOAD_LOCK_*"` — one trailing-`*` prefix
291
+ entry that passes all four client vars (`LOAD_LOCK_SOCKET`,
292
+ `LOAD_LOCK_STATE_DIR`, `LOAD_LOCK_NO_SPAWN`, `LOAD_LOCK_LABEL_PREFIX`)
293
+ through omac's env filter.
294
+
295
+ Verified live against omac 0.11.0 (see OPERATIONS.md for the exact
296
+ procedure and the two gotchas): profile accepted by omac's strict parser,
297
+ sandboxed CLI → host daemon round-trip, prefixed holder labels
298
+ (`omac:<label>`), client-only (`LOAD_LOCK_NO_SPAWN=1`) error contract, and
299
+ the global-install read grant proven necessary and sufficient
300
+ (ERR_MODULE_NOT_FOUND without it, working round-trip with it).
301
+ Install/uninstall round-trips are contract-tested in
302
+ `src/test/install.test.ts` (temp HOME: populated profile preserved,
303
+ idempotent re-install byte-identical, missing-profile creation, global
304
+ read grant, refusal on broken profiles).
305
+
239
306
  ### Tier 4 — Aider (manual CLI)
240
307
 
241
308
  No MCP/hooks; nothing to install. Agents (or humans) use the CLI directly in
@@ -256,6 +323,7 @@ UI** — where no UI surface exists, we say so rather than claim parity.
256
323
  | **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock dashboard` (alias `top`) |
257
324
  | **Gemini CLI** | None in-app (no hooks, no UI plugin surface). `load_lock_status` MCP tool at tool boundaries. | `load-lock dashboard` (alias `top`) |
258
325
  | **Copilot CLI, Cursor, Cline, Roo Code, Goose** | None in-app. MCP `status` tool where the MCP server is configured. | `load-lock dashboard` (alias `top`) |
326
+ | **omac** | None added by the omac installer — it only edits the sandbox profile; the inner harness keeps whatever surface its own adapter provides. | `load-lock dashboard` (alias `top`) |
259
327
  | **Aider** | None (no MCP either). | `load-lock dashboard` (alias `top`) |
260
328
 
261
329
  All dashboard surfaces are **read-only viewers that never acquire a
@@ -272,6 +340,7 @@ reconnect, view-model shaping, frame rendering) is covered by
272
340
  | Claude Code | hooks get `session_id`; MCP tools take explicit `agent_id` | policy instructs agents to pass it |
273
341
  | Codex | hooks get `session_id`; MCP tools take explicit `agent_id` | same |
274
342
  | Cursor | hooks get `conversation_id` (no `cwd` — `workspace_roots[0]` identifies the request); MCP tools take explicit `agent_id` | same |
343
+ | omac (sandbox) | inner harness identity + `LOAD_LOCK_LABEL_PREFIX=omac` | dashboard shows holders as `omac:<label>` |
275
344
  | Tier 3 | explicit `agent_id` argument | falls back to per-server-process id |
276
345
 
277
346
  ## Platform matrix
@@ -289,19 +358,25 @@ reconnect, view-model shaping, frame rendering) is covered by
289
358
  asyncRewake wakeup paths, subagents, background-job cleanup); Codex CLI
290
359
  (Docker, interactive TUI: policy injection, MCP round-trip, FIFO queueing
291
360
  behind a CLI holder, Stop-hook decision-block wakeup; exec-mode MCP
292
- injection blocked upstream — see Tier 2 notes).
361
+ injection blocked upstream — see Tier 2 notes); omac 0.11.0 (sandbox
362
+ grants installer: profile accepted by omac's strict parser, sandboxed
363
+ client → host daemon round-trip, `omac:`-prefixed labels,
364
+ `LOAD_LOCK_NO_SPAWN=1` error contract, global-install read grant proven
365
+ necessary and sufficient — see the omac section above and OPERATIONS.md).
293
366
  - Contract-tested: Cursor (hooks-v1 stop-hook adapter + installer, tested
294
367
  against the officially documented hooks JSON contract; no Cursor install
295
368
  on the verification machine — see the Cursor section under Tier 2).
296
369
  - Config-tested: Gemini CLI, Copilot CLI, Cline, Roo Code, Goose (installer
297
370
  round-trips, temp HOME; real harnesses not installed on the verification
298
371
  machine).
299
- - Core daemon/protocol/CLI: 68 automated tests + scripted 3-worktree demo
372
+ - Core daemon/protocol/CLI + adapters: 125 automated tests + scripted 3-worktree demo
300
373
  (three independent agent processes, real git worktrees, one pool), including
301
374
  9 dashboard tests (view-model shaping, event-cursor resume across a daemon
302
- restart, reconnect, and a `load-lock dashboard --once` render smoke test) and
375
+ restart, reconnect, and a `load-lock dashboard --once` render smoke test),
303
376
  9 bounded-wait tests (multi-process `wait_for_turn`: FIFO wake with
304
377
  auto-claim, timeout/pause/cancel/restart/abort paths, `maxTurnWaitMs` cap,
305
- MCP snake_case round trip).
378
+ MCP snake_case round trip) and 5 omac installer contract tests
379
+ (populated-profile preservation, idempotent re-install, missing-profile
380
+ creation, global-install read grant, broken-profile refusal).
306
381
  - Proposed (researched capabilities, generic adapter only):
307
382
  Aider.