@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/README.md +3 -2
- package/dist/adapters/opencode/tui.js +5 -1
- package/dist/cli/copies.d.ts +28 -0
- package/dist/cli/copies.js +207 -0
- package/dist/cli/install.js +218 -1
- package/dist/cli/main.js +264 -16
- package/dist/core/daemon.d.ts +18 -0
- package/dist/core/daemon.js +84 -3
- package/dist/core/persist.js +5 -3
- package/dist/core/state.d.ts +7 -4
- package/dist/core/state.js +64 -10
- package/dist/dashboard/frame.js +5 -1
- package/dist/dashboard/view.d.ts +4 -0
- package/dist/dashboard/view.js +5 -0
- package/dist/shared/client.js +4 -1
- package/dist/shared/protocol.d.ts +40 -2
- package/dist/shared/protocol.js +5 -0
- package/dist/shared/version.d.ts +10 -0
- package/dist/shared/version.js +57 -0
- package/dist/test/install.test.js +181 -1
- package/dist/test/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/docs/ARCHITECTURE.md +10 -6
- package/docs/COMPATIBILITY.md +79 -4
- package/docs/OPERATIONS.md +171 -56
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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);
|
|
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 |
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -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:
|
|
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)
|
|
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.
|