@aron98/load-lock 0.0.0-stage → 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/demo/demo.sh +112 -0
  4. package/demo/sim-agent.mjs +149 -0
  5. package/dist/adapters/opencode/plugin.d.ts +18 -0
  6. package/dist/adapters/opencode/plugin.js +301 -0
  7. package/dist/cli/hook.d.ts +1 -0
  8. package/dist/cli/hook.js +141 -0
  9. package/dist/cli/install.d.ts +14 -0
  10. package/dist/cli/install.js +377 -0
  11. package/dist/cli/main.d.ts +2 -0
  12. package/dist/cli/main.js +461 -0
  13. package/dist/core/daemon-main.d.ts +2 -0
  14. package/dist/core/daemon-main.js +27 -0
  15. package/dist/core/daemon.d.ts +79 -0
  16. package/dist/core/daemon.js +526 -0
  17. package/dist/core/persist.d.ts +11 -0
  18. package/dist/core/persist.js +66 -0
  19. package/dist/core/state.d.ts +203 -0
  20. package/dist/core/state.js +909 -0
  21. package/dist/core/sysinfo.d.ts +2 -0
  22. package/dist/core/sysinfo.js +123 -0
  23. package/dist/mcp/main.d.ts +2 -0
  24. package/dist/mcp/main.js +4 -0
  25. package/dist/mcp/server.d.ts +27 -0
  26. package/dist/mcp/server.js +352 -0
  27. package/dist/policy/text.d.ts +30 -0
  28. package/dist/policy/text.js +58 -0
  29. package/dist/shared/client.d.ts +30 -0
  30. package/dist/shared/client.js +227 -0
  31. package/dist/shared/protocol.d.ts +333 -0
  32. package/dist/shared/protocol.js +22 -0
  33. package/dist/test/core.test.d.ts +1 -0
  34. package/dist/test/core.test.js +281 -0
  35. package/dist/test/daemon.test.d.ts +1 -0
  36. package/dist/test/daemon.test.js +384 -0
  37. package/dist/test/hook.test.d.ts +1 -0
  38. package/dist/test/hook.test.js +126 -0
  39. package/dist/test/mcp.test.d.ts +1 -0
  40. package/dist/test/mcp.test.js +139 -0
  41. package/dist/test/run-helper.test.d.ts +1 -0
  42. package/dist/test/run-helper.test.js +84 -0
  43. package/dist/test/testutil.d.ts +27 -0
  44. package/dist/test/testutil.js +111 -0
  45. package/docs/ADAPTERS.md +118 -0
  46. package/docs/AGENT_GUIDE.md +142 -0
  47. package/docs/ARCHITECTURE.md +195 -0
  48. package/docs/COMPATIBILITY.md +116 -0
  49. package/docs/OPERATIONS.md +126 -0
  50. package/package.json +102 -4
  51. package/skills/load-lock/SKILL.md +73 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Unit tests for the pure state machine (no I/O, injectable clock).
3
+ * Compile + run: npm test (node --test dist/test/)
4
+ */
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { Machine, defaultConfig } from "../core/state.js";
8
+ import { ProtocolError } from "../shared/protocol.js";
9
+ function mk(config = defaultConfig()) {
10
+ let t = 1_000_000; // fixed base for deterministic math
11
+ const clock = () => t;
12
+ return {
13
+ m: new Machine(clock, { ...config }),
14
+ advance: (ms) => {
15
+ t += ms;
16
+ },
17
+ now: () => t,
18
+ };
19
+ }
20
+ const A = "agentA", B = "agentB", C = "agentC";
21
+ /** Current state of a request (snapshots returned by acquire() are frozen). */
22
+ const stateOf = (m, id) => m.entryById(id).state;
23
+ /** Current offer token of an agent's offered request. */
24
+ const offerOf = (m, agentId) => {
25
+ const o = m.pending(agentId).offers[0];
26
+ assert.ok(o, `${agentId} should have an outstanding offer`);
27
+ return o.offerToken;
28
+ };
29
+ function grant(m, agent = A, op = "op1", extra = {}) {
30
+ const r = m.acquire({ agentId: agent, opKey: op, reason: "test", pool: "heavy", ...extra });
31
+ assert.equal(r.state, "granted", `expected grant for ${agent}`);
32
+ return r.entry;
33
+ }
34
+ test("immediate grant then FIFO queueing with positions", () => {
35
+ const { m } = mk();
36
+ const a = grant(m);
37
+ const b = m.acquire({ agentId: B, opKey: "op1", reason: "b" });
38
+ const c = m.acquire({ agentId: C, opKey: "op1", reason: "c" });
39
+ assert.equal(b.state, "queued");
40
+ assert.equal(c.state, "queued");
41
+ assert.equal(b.entry.position, 1);
42
+ assert.equal(c.entry.position, 2);
43
+ assert.equal(m.used("heavy"), 1);
44
+ // owner sees the permit token; the public snapshot never does
45
+ assert.ok(a.permitToken.startsWith("prm_"));
46
+ const st = m.status();
47
+ assert.equal(st.pools[0].holders[0].state, "held");
48
+ assert.equal(st.pools[0].holders[0].permitToken, undefined);
49
+ assert.equal(a.requestId, st.pools[0].holders[0].requestId);
50
+ });
51
+ test("idempotent acquire returns the same live request (tokens included)", () => {
52
+ const { m } = mk();
53
+ const a = grant(m, A, "op1");
54
+ const again = m.acquire({ agentId: A, opKey: "op1", reason: "again" });
55
+ assert.equal(again.state, "granted");
56
+ assert.equal(again.entry.requestId, a.requestId);
57
+ assert.equal(again.entry.permitToken, a.permitToken);
58
+ // distinct op keys are distinct requests
59
+ const other = m.acquire({ agentId: A, opKey: "op2", reason: "other" });
60
+ assert.equal(other.state, "queued");
61
+ });
62
+ test("strict FIFO offers on release; claim converts offer to permit", () => {
63
+ const { m } = mk();
64
+ const a = grant(m, A, "op1");
65
+ const b = m.acquire({ agentId: B, opKey: "op1", reason: "b" });
66
+ const c = m.acquire({ agentId: C, opKey: "op1", reason: "c" });
67
+ const bId = b.entry.requestId, cId = c.entry.requestId;
68
+ m.release(a.permitToken);
69
+ // B (head of queue) is offered, C stays queued
70
+ assert.equal(stateOf(m, bId), "offered");
71
+ assert.equal(stateOf(m, cId), "queued");
72
+ // outstanding offers consume capacity
73
+ assert.equal(m.used("heavy"), 1);
74
+ const bTok = offerOf(m, B);
75
+ assert.ok(bTok.startsWith("off_"));
76
+ const claimed = m.claim(bTok);
77
+ assert.equal(claimed.entry.state, "held");
78
+ assert.ok(claimed.entry.permitToken.startsWith("prm_"));
79
+ // duplicate claim of a consumed offer token is rejected
80
+ assert.throws(() => m.claim(bTok), (e) => e instanceof ProtocolError && e.code === "stale_token");
81
+ });
82
+ test("offer expiry pauses the request and passes capacity to the next waiter", () => {
83
+ const { m, advance } = mk();
84
+ const a = grant(m, A, "op1");
85
+ const b = m.acquire({ agentId: B, opKey: "op1", reason: "b" });
86
+ const c = m.acquire({ agentId: C, opKey: "op1", reason: "c" });
87
+ const bId = b.entry.requestId, cId = c.entry.requestId;
88
+ m.release(a.permitToken);
89
+ assert.equal(stateOf(m, bId), "offered");
90
+ const bTok = offerOf(m, B);
91
+ advance(30_001);
92
+ m.tick(m.now());
93
+ assert.equal(stateOf(m, bId), "paused");
94
+ assert.equal(stateOf(m, cId), "offered"); // capacity passed on
95
+ // claiming the expired offer is deterministically rejected
96
+ assert.throws(() => m.claim(bTok), (e) => e instanceof ProtocolError && e.code === "offer_expired");
97
+ assert.equal(stateOf(m, bId), "paused"); // never auto-recycled
98
+ // claim C's fresh offer, release -> free capacity
99
+ m.release(m.claim(offerOf(m, C)).entry.permitToken);
100
+ // rejoin goes to the tail of the FIFO (here: it's the only waiter)
101
+ const rejoined = m.rejoin({ requestId: bId });
102
+ assert.equal(rejoined.state, "offered");
103
+ assert.ok(rejoined.offerToken);
104
+ });
105
+ test("explicit decline frees the reserved capacity", () => {
106
+ const { m } = mk();
107
+ const a = grant(m, A, "op1");
108
+ const b = m.acquire({ agentId: B, opKey: "op1", reason: "b" });
109
+ const bId = b.entry.requestId;
110
+ m.release(a.permitToken);
111
+ m.decline(offerOf(m, B));
112
+ assert.equal(stateOf(m, bId), "paused");
113
+ const again = m.acquire({ agentId: A, opKey: "op2", reason: "again" });
114
+ assert.equal(again.state, "granted");
115
+ });
116
+ test("start deadline reclaims a claimed-but-never-started permit", () => {
117
+ const { m, advance } = mk();
118
+ const a = grant(m, A, "op1");
119
+ advance(120_001);
120
+ m.tick(m.now());
121
+ assert.equal(stateOf(m, a.requestId), "released");
122
+ // capacity freed: a fresh request grants directly
123
+ const b = m.acquire({ agentId: B, opKey: "op1", reason: "b" });
124
+ assert.equal(b.state, "granted");
125
+ // late explicit release of the reclaimed permit is idempotent, not an error
126
+ m.release(a.permitToken);
127
+ });
128
+ test("start_job is atomic against reclamation (race: tick first)", () => {
129
+ const { m, advance } = mk();
130
+ const a = grant(m, A, "op1");
131
+ advance(120_001);
132
+ m.tick(m.now()); // reclaims the never-started permit
133
+ assert.throws(() => m.startJob(a.permitToken, 12345, 12345), (e) => e instanceof ProtocolError && (e.code === "stale_token" || e.code === "wrong_state"));
134
+ });
135
+ test("start_job transitions to tracked; mark_started to untracked lease", () => {
136
+ const cfg = defaultConfig();
137
+ cfg.pools = { heavy: { capacity: 2 } };
138
+ const { m, advance } = mk(cfg);
139
+ const a = grant(m, A, "op1");
140
+ m.startJob(a.permitToken, 4242, 4242, "100");
141
+ assert.equal(m.entryById(a.requestId).phase, "running_tracked");
142
+ // tracked permits have no automatic deadline at all
143
+ advance(10 * 60_000);
144
+ m.tick(m.now());
145
+ assert.equal(stateOf(m, a.requestId), "held", "running tracked job is never revoked on duration");
146
+ const b = grant(m, B, "op1");
147
+ m.markStarted(b.permitToken);
148
+ assert.equal(m.entryById(b.requestId).phase, "running_untracked");
149
+ advance(14 * 60_000);
150
+ m.heartbeat(B); // adapter heartbeat renews the lease
151
+ advance(14 * 60_000);
152
+ m.tick(m.now());
153
+ assert.equal(stateOf(m, b.requestId), "held", "heartbeat renews the untracked lease");
154
+ advance(16 * 60_000);
155
+ m.tick(m.now());
156
+ assert.equal(stateOf(m, b.requestId), "released", "lease expires without heartbeats");
157
+ });
158
+ test("release is idempotent; cancel paths behave", () => {
159
+ const { m } = mk();
160
+ const a = grant(m, A, "op1");
161
+ // cancel a queued request
162
+ const b = m.acquire({ agentId: B, opKey: "op1", reason: "b" });
163
+ assert.equal(b.state, "queued");
164
+ const c = m.cancel({ requestId: b.entry.requestId });
165
+ assert.equal(c.entry.state, "cancelled");
166
+ // cancelling a held permit without admin fails
167
+ assert.throws(() => m.cancel({ requestId: a.requestId }), (e) => e instanceof ProtocolError);
168
+ // release: idempotent
169
+ const r1 = m.release(a.permitToken);
170
+ assert.equal(r1.releasedNow, true);
171
+ const r2 = m.release(a.permitToken);
172
+ assert.equal(r2.releasedNow, false);
173
+ assert.equal(r2.entry.state, "released");
174
+ // admin force-release works and frees capacity
175
+ const d = grant(m, C, "op1");
176
+ m.adminRelease(d.requestId);
177
+ assert.equal(stateOf(m, d.requestId), "released");
178
+ });
179
+ test("weighted requests: the FIFO head blocks lighter requests behind it", () => {
180
+ const cfg = defaultConfig();
181
+ cfg.pools = { heavy: { capacity: 3 } };
182
+ const { m } = mk(cfg);
183
+ const a = grant(m, A, "op1", { weight: 2 });
184
+ const b = m.acquire({ agentId: B, opKey: "op1", reason: "b", weight: 2 });
185
+ const c = m.acquire({ agentId: C, opKey: "op1", reason: "c", weight: 2 });
186
+ const bId = b.entry.requestId, cId = c.entry.requestId;
187
+ assert.equal(b.state, "queued");
188
+ assert.equal(c.state, "queued"); // weight-2 head cannot be bypassed
189
+ m.release(a.permitToken);
190
+ assert.equal(stateOf(m, bId), "offered", "the heavy head is admitted first");
191
+ assert.equal(stateOf(m, cId), "queued", "the next heavy request still does not fit");
192
+ assert.equal(m.used("heavy"), 2);
193
+ });
194
+ test("configure: CAS versioning, capacity up admits, down never revokes", () => {
195
+ const { m } = mk();
196
+ const a = grant(m, A, "op1");
197
+ const b = m.acquire({ agentId: B, opKey: "op1", reason: "b" });
198
+ const bId = b.entry.requestId;
199
+ assert.equal(b.state, "queued");
200
+ // stale expected_version -> conflict
201
+ assert.throws(() => m.configure({ expectedVersion: 99, changes: {}, reason: "stale", author: "t" }), (e) => e instanceof ProtocolError && e.code === "version_conflict");
202
+ // capacity increase: queued request is admitted immediately
203
+ const r = m.configure({ expectedVersion: 1, changes: { pools: { heavy: { capacity: 2 } } }, reason: "more", author: "t" });
204
+ assert.equal(r.config.version, 2);
205
+ assert.equal(stateOf(m, bId), "offered");
206
+ // capacity decrease: holders keep their permits
207
+ m.configure({ expectedVersion: 2, changes: { pools: { heavy: { capacity: 1 } } }, reason: "less", author: "t" });
208
+ assert.equal(stateOf(m, a.requestId), "held");
209
+ // history persisted with author + reason
210
+ const hist = m.get_config().history;
211
+ assert.equal(hist.length, 2);
212
+ assert.equal(hist[1].author, "t");
213
+ assert.equal(hist[1].reason, "less");
214
+ });
215
+ test("events: durable identities, owner filtering, token stripping", () => {
216
+ const { m } = mk();
217
+ const a = grant(m, A, "op1");
218
+ m.acquire({ agentId: B, opKey: "op1", reason: "b" });
219
+ const before = m.latestEventId();
220
+ m.release(a.permitToken);
221
+ const evsB = m.eventsSince(before, B);
222
+ const offerB = evsB.find((e) => e.type === "offered");
223
+ assert.ok(offerB, "owner sees its own offered event");
224
+ const evsC = m.eventsSince(before, C);
225
+ assert.equal(evsC.find((e) => e.type === "offered"), undefined, "other agents do not see B's offered event");
226
+ const all = m.eventsSince(before, "*");
227
+ const offeredAll = all.filter((e) => e.type === "offered");
228
+ assert.ok(offeredAll.every((e) => e.entry.offerToken === undefined), "public event snapshots strip tokens");
229
+ assert.ok(all.every((e) => e.id > before && e.ts > 0), "durable event identities");
230
+ // claim tokens are attached by the daemon layer (owner-filtered pulls and
231
+ // watch pushes) — covered by the daemon integration tests.
232
+ });
233
+ test("journal: tracked-alive kept, tracked-dead released, claimed keeps deadline, untracked fresh lease", () => {
234
+ const cfg = defaultConfig();
235
+ cfg.pools = { heavy: { capacity: 4 } };
236
+ const { m, advance } = mk(cfg);
237
+ const a = grant(m, A, "alive");
238
+ m.startJob(a.permitToken, 111, 111, "start-111");
239
+ const b = grant(m, B, "dead");
240
+ m.startJob(b.permitToken, 222, 222, "start-222");
241
+ const c = grant(m, C, "claimed"); // never started
242
+ const d = grant(m, A, "untracked");
243
+ m.markStarted(d.permitToken);
244
+ const journal = m.journalEntries();
245
+ assert.equal(journal.length, 4);
246
+ const m2 = new Machine(() => m.now(), { ...defaultConfig() });
247
+ const res = m2.restore(journal, (e) => {
248
+ if (e.pid === 111)
249
+ return true; // alive
250
+ if (e.pid === 222)
251
+ return false; // dead
252
+ return null; // unknown
253
+ });
254
+ assert.equal(res.kept, 3);
255
+ assert.equal(res.releasedDead, 1);
256
+ // the restored permit keeps its token: a late explicit release works
257
+ const rel = m2.release(a.permitToken);
258
+ assert.equal(rel.releasedNow, true);
259
+ // restored claimed permit keeps the original start deadline
260
+ const before = m.now();
261
+ m2.tick(before + 1);
262
+ assert.equal(m2.entryById(c.requestId).state, "held");
263
+ m2.tick(before + 200_000);
264
+ assert.equal(m2.entryById(c.requestId).state, "released", "restored claimed permit still reclaims at its deadline");
265
+ // restored untracked permit got a fresh lease window
266
+ assert.equal(m2.entryById(d.requestId).phase, "running_untracked");
267
+ assert.ok(m2.entryById(d.requestId).leaseDeadline > before);
268
+ });
269
+ test("diagnose reports holder info and flags long holds without revoking", () => {
270
+ const { m, advance } = mk();
271
+ const a = grant(m, A, "op1");
272
+ m.startJob(a.permitToken, 333, 333, "start-333");
273
+ advance(31 * 60_000);
274
+ const diags = m.diagnose((e) => (e.pid === 333 ? true : null));
275
+ assert.equal(diags.length, 1);
276
+ assert.equal(diags[0].diagnostics.tracked, true);
277
+ assert.equal(diags[0].diagnostics.processAlive, true);
278
+ assert.equal(diags[0].diagnostics.longHold, true);
279
+ assert.equal(stateOf(m, a.requestId), "held", "long hold alone never revokes a running job");
280
+ assert.ok(diags[0].diagnostics.note.includes("pid 333"));
281
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Integration tests: a REAL daemon process over a real Unix socket, with
3
+ * multiple independent client connections (and separate CLI processes via
4
+ * run-helper/mcp tests). State dirs live in /tmp/opencode, never the user's.
5
+ */
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { spawn } from "node:child_process";
9
+ import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join, dirname } from "node:path";
12
+ import { connect } from "node:net";
13
+ import { fileURLToPath } from "node:url";
14
+ import { LLClient } from "../shared/client.js";
15
+ import { isProcessAlive, processStartTime } from "../core/daemon.js";
16
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
17
+ mkdirSync(TMP_BASE, { recursive: true });
18
+ const daemonMain = join(dirname(fileURLToPath(import.meta.url)), "..", "core", "daemon-main.js");
19
+ async function startDaemon() {
20
+ const dir = mkdtempSync(join(TMP_BASE, "ll-daemon-"));
21
+ const sock = join(dir, "daemon.sock");
22
+ const child = spawn(process.execPath, [daemonMain, "--state-dir", dir, "--socket", sock], {
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ env: { ...process.env, LOAD_LOCK_STATE_DIR: dir, LOAD_LOCK_SOCKET: sock },
25
+ });
26
+ const deadline = Date.now() + 10_000;
27
+ for (;;) {
28
+ if (await probe(sock))
29
+ break;
30
+ if (Date.now() > deadline) {
31
+ child.kill("SIGKILL");
32
+ throw new Error("daemon did not come up");
33
+ }
34
+ await delay(100);
35
+ }
36
+ const handle = {
37
+ child,
38
+ dir,
39
+ sock,
40
+ client: () => new LLClient({ socket: sock, stateDir: dir, spawnDaemon: false }),
41
+ stop: async () => {
42
+ if (child.exitCode === null && !child.killed) {
43
+ child.kill("SIGTERM");
44
+ await new Promise((resolve) => {
45
+ const t = setTimeout(() => {
46
+ child.kill("SIGKILL");
47
+ resolve();
48
+ }, 3000);
49
+ child.once("exit", () => {
50
+ clearTimeout(t);
51
+ resolve();
52
+ });
53
+ });
54
+ }
55
+ rmSync(dir, { recursive: true, force: true });
56
+ },
57
+ };
58
+ return handle;
59
+ }
60
+ function probe(sock) {
61
+ return new Promise((resolve) => {
62
+ const s = connect(sock);
63
+ s.once("connect", () => {
64
+ s.destroy();
65
+ resolve(true);
66
+ });
67
+ s.once("error", () => resolve(false));
68
+ });
69
+ }
70
+ function delay(ms) {
71
+ return new Promise((r) => setTimeout(r, ms));
72
+ }
73
+ async function until(desc, fn, timeoutMs = 15_000, step = 200) {
74
+ const deadline = Date.now() + timeoutMs;
75
+ for (;;) {
76
+ if (await fn())
77
+ return;
78
+ if (Date.now() > deadline)
79
+ throw new Error(`timeout waiting for: ${desc}`);
80
+ await delay(step);
81
+ }
82
+ }
83
+ const ACQ = (agentId, opKey, extra = {}) => ({
84
+ agentId,
85
+ opKey,
86
+ reason: "integration test",
87
+ ...extra,
88
+ });
89
+ test("concurrent burst: exactly one grant, FIFO queue, no missed wakeup", async () => {
90
+ const d = await startDaemon();
91
+ try {
92
+ const c = d.client();
93
+ await c.connect();
94
+ // tight timings for the test
95
+ await c.request("configure", {
96
+ expectedVersion: 1,
97
+ changes: { offerTimeoutMs: 60_000, maxWaitMs: 0 },
98
+ reason: "test timings",
99
+ author: "test",
100
+ });
101
+ const clients = [];
102
+ for (let i = 0; i < 5; i++) {
103
+ const cl = d.client();
104
+ await cl.connect();
105
+ clients.push(cl);
106
+ }
107
+ const results = await Promise.all(clients.map((cl, i) => cl.request("acquire", ACQ(`agent${i}`, `op${i}`))));
108
+ const granted = results.filter((r) => r.state === "granted");
109
+ const queued = results.filter((r) => r.state === "queued");
110
+ assert.equal(granted.length, 1, "exactly one concurrent grant");
111
+ assert.equal(queued.length, 4);
112
+ assert.deepEqual(queued.map((r) => r.entry.position).sort(), [1, 2, 3, 4], "queue positions assigned exactly once");
113
+ // capacity invariant
114
+ const st = await c.request("status", {});
115
+ assert.ok(st.pools[0].used <= st.pools[0].capacity);
116
+ // release: the FIFO head must see an offer; nobody else
117
+ const winner = granted[0];
118
+ const headIdx = results.findIndex((r) => r.entry?.position === 1);
119
+ const headAgent = `agent${headIdx}`;
120
+ assert.ok(headIdx >= 0);
121
+ const events = [];
122
+ const watcher = d.client();
123
+ await watcher.connect();
124
+ await watcher.watch(headAgent, (ev) => events.push(ev));
125
+ // Regression (live opencode verification found this): a "*" multiplexer
126
+ // watcher (e.g. the OpenCode plugin serving all sessions) must also
127
+ // receive the claim token on offered events.
128
+ const starEvents = [];
129
+ const starWatcher = d.client();
130
+ await starWatcher.connect();
131
+ await starWatcher.watch("*", (ev) => starEvents.push(ev));
132
+ await c.request("release", { permitToken: winner.entry.permitToken });
133
+ const p = (await clients[headIdx].request("pending", { agentId: headAgent }));
134
+ assert.equal(p.offers.length, 1, "queued head receives the offer");
135
+ assert.ok(p.offers[0].offerToken);
136
+ await until("watcher got offered event", () => events.some((e) => e.type === "offered"));
137
+ const offeredEv = events.find((e) => e.type === "offered");
138
+ assert.ok(offeredEv.offerToken, "watch push for the owner carries the claim token");
139
+ await until("star watcher got offered event", () => starEvents.some((e) => e.type === "offered"));
140
+ const starEv = starEvents.find((e) => e.type === "offered");
141
+ assert.ok(starEv.offerToken, 'watch push for a "*" multiplexer carries the claim token');
142
+ const claimed = await clients[headIdx].request("claim", { offerToken: p.offers[0].offerToken });
143
+ assert.equal(claimed.entry.state, "held");
144
+ // capacity again: claim consumed the offer, no double admission
145
+ const st2 = (await c.request("status", {}));
146
+ assert.equal(st2.pools[0].used, 1);
147
+ assert.equal(st2.pools[0].capacity, 1);
148
+ for (const cl of clients)
149
+ cl.close();
150
+ watcher.close();
151
+ starWatcher.close();
152
+ c.close();
153
+ }
154
+ finally {
155
+ await d.stop();
156
+ }
157
+ });
158
+ test("bounded blocking wait auto-claims when capacity frees in time", async () => {
159
+ const d = await startDaemon();
160
+ try {
161
+ const a = d.client();
162
+ const b = d.client();
163
+ await a.connect();
164
+ await b.connect();
165
+ const g = (await a.request("acquire", ACQ("A", "op1")));
166
+ assert.equal(g.state, "granted");
167
+ const t0 = Date.now();
168
+ const bPromise = b.request("acquire", ACQ("B", "op1", { waitMs: 8000 }));
169
+ await delay(500);
170
+ await a.request("release", { permitToken: g.entry.permitToken });
171
+ const r = (await bPromise);
172
+ assert.equal(r.state, "granted", "waiter is auto-claimed on admission");
173
+ assert.ok(r.entry.permitToken, "waiter receives the permit token");
174
+ assert.ok(Date.now() - t0 < 5000, "woke promptly after the release");
175
+ a.close();
176
+ b.close();
177
+ }
178
+ finally {
179
+ await d.stop();
180
+ }
181
+ });
182
+ test("offer expiry pauses and hands capacity to the next waiter; rejoin works", async () => {
183
+ const d = await startDaemon();
184
+ try {
185
+ const c = d.client();
186
+ await c.connect();
187
+ await c.request("configure", {
188
+ expectedVersion: 1,
189
+ changes: { offerTimeoutMs: 1000, maxWaitMs: 0 },
190
+ reason: "fast expiry",
191
+ author: "test",
192
+ });
193
+ const a = d.client();
194
+ const b = d.client();
195
+ await a.connect();
196
+ await b.connect();
197
+ const g = (await a.request("acquire", ACQ("A", "op1")));
198
+ const q = (await b.request("acquire", ACQ("B", "op1")));
199
+ assert.equal(q.state, "queued");
200
+ await a.request("release", { permitToken: g.entry.permitToken });
201
+ const pb = (await b.request("pending", { agentId: "B" }));
202
+ assert.equal(pb.offers.length, 1);
203
+ // do NOT claim; let it expire
204
+ await until("offer expired", async () => {
205
+ const st = (await c.request("status", {}));
206
+ return st.pools[0].paused.length === 1;
207
+ });
208
+ const st = (await c.request("status", {}));
209
+ assert.equal(st.pools[0].offers.length, 0, "expired offer does not keep consuming capacity");
210
+ // claim after expiry is rejected; rejoin re-queues
211
+ await assert.rejects(b.request("claim", { offerToken: pb.offers[0].offerToken }), (e) => e.code === "offer_expired");
212
+ const rj = (await b.request("rejoin", { requestId: q.entry.requestId }));
213
+ assert.equal(rj.state, "offered", "rejoined request is admitted into the free capacity");
214
+ a.close();
215
+ b.close();
216
+ c.close();
217
+ }
218
+ finally {
219
+ await d.stop();
220
+ }
221
+ });
222
+ test("tracked process: auto-release on death; PID identity checked via /proc start time", async () => {
223
+ const d = await startDaemon();
224
+ try {
225
+ const c = d.client();
226
+ await c.connect();
227
+ const g = (await c.request("acquire", ACQ("A", "op1")));
228
+ const sleeper = spawn("sleep", ["60"], { stdio: "ignore" });
229
+ const st0 = processStartTime(sleeper.pid);
230
+ assert.ok(st0, "reads /proc start time on Linux");
231
+ // PID-reuse defense: wrong start time => treated as a different (dead) process
232
+ assert.equal(isProcessAlive(sleeper.pid, "9999"), false);
233
+ assert.equal(isProcessAlive(sleeper.pid, st0), true);
234
+ const att = (await c.request("attach_pid", { permitToken: g.entry.permitToken, pid: sleeper.pid }));
235
+ assert.equal(att.entry.phase, "running_tracked");
236
+ // kill the tracked process; the daemon sweep must auto-release
237
+ sleeper.kill("SIGKILL");
238
+ await until("auto-release after tracked process death", async () => {
239
+ const st = (await c.request("status", {}));
240
+ return st.pools[0].holders.length === 0;
241
+ });
242
+ const st = (await c.request("status", {}));
243
+ assert.equal(st.pools[0].used, 0, "capacity is freed, not leaked");
244
+ c.close();
245
+ }
246
+ finally {
247
+ await d.stop();
248
+ }
249
+ });
250
+ test("subprocess survival: parent exits, child keeps the permit until it really ends", async () => {
251
+ const d = await startDaemon();
252
+ try {
253
+ const c = d.client();
254
+ await c.connect();
255
+ const g = (await c.request("acquire", ACQ("A", "op1")));
256
+ // parent exits immediately; a child (sleep 4) keeps the process group alive
257
+ const parent = spawn("bash", ["-c", "sleep 4 & exit 0"], { stdio: "ignore", detached: true });
258
+ const pgid = parent.pid;
259
+ await delay(300); // let bash exit, leave the orphan sleep in the pgid
260
+ const att = (await c.request("start_job", { permitToken: g.entry.permitToken, pid: parent.pid, pgid }));
261
+ assert.equal(att.entry.phase, "running_tracked");
262
+ // parent is gone but the process group still runs: permit must be retained
263
+ await delay(1200);
264
+ let st = (await c.request("status", {}));
265
+ assert.equal(st.pools[0].holders.length, 1, "permit retained while a subprocess of the job lives");
266
+ // once the whole group is gone, the daemon releases
267
+ await until("auto-release when the process group fully exits", async () => {
268
+ st = (await c.request("status", {}));
269
+ return st.pools[0].holders.length === 0;
270
+ }, 15_000);
271
+ assert.equal(st.pools[0].used, 0);
272
+ c.close();
273
+ }
274
+ finally {
275
+ await d.stop();
276
+ }
277
+ });
278
+ test("daemon restart: journal restores a running tracked permit; token still releases", async () => {
279
+ const d = await startDaemon();
280
+ try {
281
+ const c = d.client();
282
+ await c.connect();
283
+ const g = (await c.request("acquire", ACQ("A", "op1")));
284
+ const permitToken = g.entry.permitToken;
285
+ const sleeper = spawn("sleep", ["60"], { stdio: "ignore" });
286
+ await c.request("attach_pid", { permitToken: permitToken, pid: sleeper.pid });
287
+ c.close();
288
+ // ---- restart
289
+ d.child.kill("SIGTERM");
290
+ await new Promise((r) => d.child.once("exit", r));
291
+ const child2 = spawn(process.execPath, [daemonMain, "--state-dir", d.dir, "--socket", d.sock], {
292
+ stdio: ["ignore", "pipe", "pipe"],
293
+ env: { ...process.env, LOAD_LOCK_STATE_DIR: d.dir, LOAD_LOCK_SOCKET: d.sock },
294
+ });
295
+ await until("restarted daemon listens", async () => await probe(d.sock));
296
+ const c2 = new LLClient({ socket: d.sock, stateDir: d.dir, spawnDaemon: false });
297
+ await c2.connect();
298
+ const st = (await c2.request("status", {}));
299
+ assert.equal(st.pools[0].holders.length, 1, "running permit survives the restart");
300
+ assert.equal(st.pools[0].holders[0].phase, "running_tracked");
301
+ assert.equal(st.pools[0].holders[0].pid, sleeper.pid);
302
+ // the SAME permit token still releases (idempotency across restart)
303
+ const rel = (await c2.request("release", { permitToken: permitToken }));
304
+ assert.equal(rel.releasedNow, true);
305
+ // ---- restart with a job that died while we were down
306
+ const g2 = (await c2.request("acquire", ACQ("B", "op1")));
307
+ const short = spawn("sleep", ["30"], { stdio: "ignore" });
308
+ await c2.request("attach_pid", { permitToken: g2.entry.permitToken, pid: short.pid });
309
+ short.kill("SIGKILL");
310
+ await delay(300); // dies before the restart
311
+ c2.close();
312
+ child2.kill("SIGTERM");
313
+ await new Promise((r) => child2.once("exit", r));
314
+ const child3 = spawn(process.execPath, [daemonMain, "--state-dir", d.dir, "--socket", d.sock], {
315
+ stdio: ["ignore", "pipe", "pipe"],
316
+ env: { ...process.env, LOAD_LOCK_STATE_DIR: d.dir, LOAD_LOCK_SOCKET: d.sock },
317
+ });
318
+ await until("third daemon listens", async () => await probe(d.sock));
319
+ const c3 = new LLClient({ socket: d.sock, stateDir: d.dir, spawnDaemon: false });
320
+ await c3.connect();
321
+ const st3 = (await c3.request("status", {}));
322
+ assert.equal(st3.pools[0].holders.length, 0, "confirmed-dead job is released at restore, capacity free");
323
+ // duplicate release of the already-released permit is a clean idempotent no-op
324
+ const rel2 = (await c3.request("release", { permitToken: g2.entry.permitToken }));
325
+ assert.equal(rel2.releasedNow, false);
326
+ c3.close();
327
+ child3.kill("SIGTERM");
328
+ await new Promise((r) => child3.once("exit", r));
329
+ sleeper.kill("SIGKILL");
330
+ }
331
+ finally {
332
+ await d.stop();
333
+ }
334
+ });
335
+ test("forgotten untracked permit: lease reclaims it (heartbeat would renew)", async () => {
336
+ const d = await startDaemon();
337
+ try {
338
+ const c = d.client();
339
+ await c.connect();
340
+ await c.request("configure", {
341
+ expectedVersion: 1,
342
+ changes: { untrackedLeaseMs: 10_000 },
343
+ reason: "short lease",
344
+ author: "test",
345
+ });
346
+ const g = (await c.request("acquire", ACQ("A", "op1")));
347
+ await c.request("mark_started", { permitToken: g.entry.permitToken });
348
+ // no heartbeats: the lease must reclaim the permit
349
+ await until("untracked lease reclaims the permit", async () => {
350
+ const st = (await c.request("status", {}));
351
+ return st.pools[0].holders.length === 0;
352
+ }, 25_000);
353
+ const st = (await c.request("status", {}));
354
+ assert.equal(st.pools[0].used, 0);
355
+ c.close();
356
+ }
357
+ finally {
358
+ await d.stop();
359
+ }
360
+ });
361
+ test("offers_for_cwd returns cwd-matched offers with tokens (hook support)", async () => {
362
+ const d = await startDaemon();
363
+ try {
364
+ const a = d.client();
365
+ const b = d.client();
366
+ await a.connect();
367
+ await b.connect();
368
+ const cwd = mkdtempSync(join(TMP_BASE, "ll-cwd-"));
369
+ const g = (await a.request("acquire", ACQ("A", "op1", { cwd })));
370
+ await b.request("acquire", ACQ("B", "op1", { cwd }));
371
+ await a.request("release", { permitToken: g.entry.permitToken });
372
+ const r = (await b.request("offers_for_cwd", { cwd }));
373
+ assert.equal(r.offers.length, 1);
374
+ assert.ok(r.offers[0].offerToken);
375
+ const r2 = (await b.request("offers_for_cwd", { cwd: "/nonexistent" }));
376
+ assert.equal(r2.offers.length, 0);
377
+ a.close();
378
+ b.close();
379
+ rmSync(cwd, { recursive: true, force: true });
380
+ }
381
+ finally {
382
+ await d.stop();
383
+ }
384
+ });
@@ -0,0 +1 @@
1
+ export {};