@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.
- 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/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/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/docs/ARCHITECTURE.md +10 -6
- package/docs/COMPATIBILITY.md +32 -0
- package/docs/OPERATIONS.md +52 -5
- 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
|
@@ -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
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -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;
|
|
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.
|
|
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",
|