@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,461 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * load-lock CLI: inspection, recovery, scriptable flows, execution helper.
4
+ *
5
+ * Exit codes: 0 ok; 64 usage; 65 protocol/data error; 68 not granted yet
6
+ * (queued/registered); 69 offer/claim failed; 70 internal.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { LLClient, withClient } from "../shared/client.js";
13
+ import { ProtocolError } from "../shared/protocol.js";
14
+ import { installCommand, uninstallCommand } from "./install.js";
15
+ import { hookCommand } from "./hook.js";
16
+ // ---------------------------------------------------------------- arg parsing
17
+ const argv = process.argv.slice(2);
18
+ let flags = {};
19
+ const positional = [];
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a === "--") {
23
+ positional.push(...argv.slice(i + 1));
24
+ break;
25
+ }
26
+ else if (a.startsWith("--")) {
27
+ const eq = a.indexOf("=");
28
+ if (eq !== -1)
29
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
30
+ else if (["json", "follow", "force", "admin", "quiet"].includes(a.slice(2)))
31
+ flags[a.slice(2)] = true;
32
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("--"))
33
+ flags[a.slice(2)] = argv[++i];
34
+ else
35
+ flags[a.slice(2)] = true;
36
+ }
37
+ else {
38
+ positional.push(a);
39
+ }
40
+ }
41
+ const cmd = positional[0] ?? "status";
42
+ const rest = positional.slice(1);
43
+ const f = flags;
44
+ const S = (k, d = "") => (typeof f[k] === "string" ? f[k] : d);
45
+ const N = (k, d) => (f[k] !== undefined ? Number(f[k]) : d);
46
+ const B = (k) => f[k] === true;
47
+ function fail(msg, code = 64) {
48
+ process.stderr.write(`load-lock: ${msg}\n`);
49
+ process.exit(code);
50
+ }
51
+ function out(v, human) {
52
+ if (B("json"))
53
+ process.stdout.write(JSON.stringify(v, null, 2) + "\n");
54
+ else
55
+ process.stdout.write((human ? human() : JSON.stringify(v, null, 2)) + "\n");
56
+ }
57
+ function defaultAgent() {
58
+ return (process.env.LOAD_LOCK_AGENT ||
59
+ "cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
60
+ }
61
+ async function main() {
62
+ switch (cmd) {
63
+ // ------------------------------------------------------------- read-only
64
+ case "status": {
65
+ const r = await withClient((c) => c.request("status", S("pool") ? { pool: S("pool") } : {}));
66
+ out(r, () => {
67
+ const lines = [];
68
+ for (const p of r.pools) {
69
+ lines.push(`pool "${p.pool}": ${p.used}/${p.capacity} in use (held + outstanding offers)` +
70
+ (p.queue.length ? `, ${p.queue.length} queued` : "") +
71
+ (p.paused.length ? `, ${p.paused.length} paused` : ""));
72
+ for (const h of p.holders) {
73
+ const phase = h.phase === "running_tracked" ? ` [tracked pid ${h.pid ?? "?"}${h.pgid ? "/pgid " + h.pgid : ""}]` : h.phase === "claimed" ? " [claimed, awaiting start]" : h.phase === "running_untracked" ? " [untracked, leased]" : "";
74
+ lines.push(` held: ${h.agentLabel || h.agentId} — ${h.reason} (${Math.round((h.heldFor ?? 0) / 1000)}s)${phase}`);
75
+ }
76
+ for (const o of p.offers)
77
+ lines.push(` offer: ${o.agentLabel || o.agentId} — ${o.reason} (expires in ${Math.max(0, Math.round(((o.offerExpiresAt ?? 0) - Date.now()) / 1000))}s)`);
78
+ p.queue.forEach((q, i) => lines.push(` queued #${i + 1}: ${q.agentLabel || q.agentId} — ${q.reason}`));
79
+ }
80
+ return lines.join("\n");
81
+ });
82
+ return;
83
+ }
84
+ case "inspect":
85
+ case "machine":
86
+ out(await withClient((c) => c.request("inspect", {})));
87
+ return;
88
+ case "get-config":
89
+ case "config":
90
+ out(await withClient((c) => c.request("get_config", {})));
91
+ return;
92
+ // CAS config update: --expected-version is optional for operator
93
+ // convenience (the current version is fetched and used atomically in
94
+ // the same connection); pass it explicitly to guard against concurrent
95
+ // changes. All changes require --reason; the author defaults to the
96
+ // CLI identity.
97
+ case "configure": {
98
+ const reason = S("reason") || fail("configure: --reason is required (changes are persisted with rationale)");
99
+ const author = S("author", defaultAgent());
100
+ await withClient(async (c) => {
101
+ let expected = N("expected-version", -1);
102
+ if (expected < 0)
103
+ expected = (await c.request("get_config", {})).config.version;
104
+ const changes = {};
105
+ if (S("capacity"))
106
+ changes.pools = { [S("pool", "heavy")]: { capacity: N("capacity", 1) } };
107
+ if (S("offer-timeout-ms"))
108
+ changes.offerTimeoutMs = N("offer-timeout-ms", 0);
109
+ if (S("max-wait-ms"))
110
+ changes.maxWaitMs = N("max-wait-ms", 0);
111
+ if (S("start-timeout-ms"))
112
+ changes.startTimeoutMs = N("start-timeout-ms", 0);
113
+ if (S("untracked-lease-ms"))
114
+ changes.untrackedLeaseMs = N("untracked-lease-ms", 0);
115
+ if (S("heavy-work-heuristic"))
116
+ changes.guidance = { heavyWorkHeuristic: S("heavy-work-heuristic") };
117
+ if (!Object.keys(changes).length)
118
+ fail("configure: no changes given (--capacity, --offer-timeout-ms, --max-wait-ms, --start-timeout-ms, --untracked-lease-ms, --heavy-work-heuristic)");
119
+ out(await c.request("configure", { expectedVersion: expected, changes, reason, author }));
120
+ });
121
+ return;
122
+ }
123
+ case "pending": {
124
+ const r = await withClient((c) => c.request("pending", { agentId: S("agent", defaultAgent()) }));
125
+ out(r);
126
+ return;
127
+ }
128
+ case "events": {
129
+ const since = Number(S("since", "0"));
130
+ const r = await withClient((c) => c.request("events", { sinceEventId: since, agentId: S("agent") || undefined }));
131
+ if (B("follow")) {
132
+ const c = new LLClient();
133
+ await c.connect();
134
+ await new Promise(() => {
135
+ void c.watch("*", (ev) => {
136
+ process.stdout.write(JSON.stringify(ev) + "\n");
137
+ });
138
+ });
139
+ return;
140
+ }
141
+ out(r);
142
+ return;
143
+ }
144
+ // ------------------------------------------------------ permit operations
145
+ case "acquire": {
146
+ const r = await withClient((c) => c.request("acquire", {
147
+ pool: S("pool", "heavy"),
148
+ agentId: S("agent", defaultAgent()),
149
+ agentLabel: S("label") || undefined,
150
+ cwd: S("cwd", process.cwd()),
151
+ opKey: S("op-key") || fail("acquire: --op-key is required"),
152
+ reason: S("reason") || fail("acquire: --reason is required"),
153
+ waitMs: N("wait-ms", 0),
154
+ subscribeIfBusy: !B("no-queue"),
155
+ }));
156
+ out(r);
157
+ if (r.state === "queued" || r.state === "paused")
158
+ process.exit(68);
159
+ return;
160
+ }
161
+ case "claim": {
162
+ const token = S("offer") || fail("claim: --offer <token> required");
163
+ const r = await withClient((c) => c.request("claim", { offerToken: token }));
164
+ out(r);
165
+ return;
166
+ }
167
+ case "release": {
168
+ const token = S("permit") || fail("release: --permit <token> required");
169
+ const r = await withClient((c) => c.request("release", { permitToken: token, outcome: S("outcome") || undefined }));
170
+ out(r);
171
+ return;
172
+ }
173
+ case "cancel": {
174
+ const id = S("id") || fail("cancel: --id <request-id> required");
175
+ const r = await withClient((c) => c.request("cancel", { requestId: id, admin: B("admin") }));
176
+ out(r);
177
+ return;
178
+ }
179
+ case "rejoin": {
180
+ const id = S("id") || fail("rejoin: --id <request-id> required");
181
+ out(await withClient((c) => c.request("rejoin", { requestId: id })));
182
+ return;
183
+ }
184
+ case "mark-started": {
185
+ const token = S("permit") || fail("mark-started: --permit <token> required");
186
+ out(await withClient((c) => c.request("mark_started", { permitToken: token })));
187
+ return;
188
+ }
189
+ case "attach-pid": {
190
+ const token = S("permit") || fail("attach-pid: --permit <token> required");
191
+ const pid = N("pid", 0) || fail("attach-pid: --pid required");
192
+ out(await withClient((c) => c.request("attach_pid", { permitToken: token, pid })));
193
+ return;
194
+ }
195
+ // ------------------------------------------------------- wait (offer -> permit)
196
+ case "wait": {
197
+ // Watches a queued request; auto-claims the offer when capacity is
198
+ // handed to it and prints the permit token. Honest bounded blocking
199
+ // for scriptable flows (aider, plain shell scripts).
200
+ const id = S("id") || fail("wait: --id <request-id> required");
201
+ const timeoutMs = N("timeout", 600_000);
202
+ const agent = S("agent", defaultAgent());
203
+ const c = new LLClient();
204
+ await c.connect();
205
+ try {
206
+ let last = 0;
207
+ let resolved = false;
208
+ const permit = await new Promise((resolve) => {
209
+ const finish = (v) => {
210
+ if (!resolved) {
211
+ resolved = true;
212
+ resolve(v);
213
+ }
214
+ };
215
+ const timer = timeoutMs > 0 ? setTimeout(() => finish(null), timeoutMs) : null;
216
+ void (async () => {
217
+ while (!resolved) {
218
+ try {
219
+ const r = (await c.request("events", { sinceEventId: last, agentId: agent }));
220
+ last = r.latestEventId;
221
+ for (const ev of r.events) {
222
+ if (ev.entry?.requestId !== id)
223
+ continue;
224
+ if (ev.type === "offered" && ev.offerToken) {
225
+ const claimed = (await c.request("claim", { offerToken: ev.offerToken }));
226
+ if (timer)
227
+ clearTimeout(timer);
228
+ finish(claimed.entry.permitToken);
229
+ }
230
+ else if (ev.type === "cancelled" || ev.type === "paused" || ev.type === "reclaimed") {
231
+ if (timer)
232
+ clearTimeout(timer);
233
+ finish(null);
234
+ }
235
+ }
236
+ }
237
+ catch {
238
+ /* transient; retry */
239
+ }
240
+ await new Promise((r2) => setTimeout(r2, 200));
241
+ }
242
+ })();
243
+ });
244
+ if (!permit)
245
+ fail("wait: timed out or the request was cancelled/paused/reclaimed", 69);
246
+ out({ permit_token: permit });
247
+ return;
248
+ }
249
+ finally {
250
+ c.close();
251
+ }
252
+ }
253
+ // ----------------------------------------------------------- run helper
254
+ case "run": {
255
+ const dash = process.argv.indexOf("--");
256
+ const jobArgv = dash !== -1 ? process.argv.slice(dash + 1) : rest.slice(1);
257
+ if (jobArgv.length === 0)
258
+ fail("run: missing command after --");
259
+ await runWithPermit(jobArgv);
260
+ return;
261
+ }
262
+ // --------------------------------------------------------------- service
263
+ case "service": {
264
+ const sub = rest[0] ?? "status";
265
+ if (sub === "stop") {
266
+ // Connect and ask... simplest: SIGTERM via daemon.json pid.
267
+ const dir = process.env.LOAD_LOCK_STATE_DIR || join(process.env.HOME ?? "~", ".local", "state", "load-lock");
268
+ try {
269
+ const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
270
+ process.kill(info.pid, "SIGTERM");
271
+ process.stdout.write(`load-lock: daemon (pid ${info.pid}) stopping\n`);
272
+ }
273
+ catch {
274
+ process.stdout.write("load-lock: no running daemon found\n");
275
+ }
276
+ return;
277
+ }
278
+ if (sub === "start") {
279
+ // Connecting auto-spawns a detached daemon (see shared/client.ts);
280
+ // hello confirms it is up.
281
+ const r = await withClient((c) => c.request("hello", {}));
282
+ process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${process.env.LOAD_LOCK_SOCKET || "default socket"})\n`);
283
+ return;
284
+ }
285
+ // status
286
+ const r = await withClient((c) => c.request("hello", {}));
287
+ out(r, () => `load-lock daemon: pid ${r.pid}, protocol v${r.protocolVersion}, config version ${r.configVersion}`);
288
+ return;
289
+ }
290
+ case "doctor": {
291
+ const report = {};
292
+ report.node = process.version;
293
+ report.platform = process.platform;
294
+ const dir = process.env.LOAD_LOCK_STATE_DIR || join(homedir(), ".local", "state", "load-lock");
295
+ report.stateDir = dir;
296
+ report.socketExists = existsSync(join(dir, "daemon.sock"));
297
+ report.configExists = existsSync(join(dir, "config.json"));
298
+ report.journalExists = existsSync(join(dir, "journal.json"));
299
+ try {
300
+ report.hello = await withClient((c) => c.request("hello", {}));
301
+ const insp = await withClient((c) => c.request("inspect", {}));
302
+ report.effectiveCores = insp.cpu.effectiveCores;
303
+ report.pressure = insp.pressure.note;
304
+ report.procs = insp.cpu.cgroupVersion;
305
+ }
306
+ catch (e) {
307
+ report.daemon = `unreachable: ${e.message}`;
308
+ }
309
+ out(report, () => JSON.stringify(report, null, 2));
310
+ return;
311
+ }
312
+ case "recover": {
313
+ const sub = rest[0] ?? "list";
314
+ if (sub === "list" || sub === "orphans") {
315
+ const r = await withClient((c) => c.request("admin_list", {}));
316
+ out(r, () => {
317
+ const hs = r.holders;
318
+ if (!hs.length)
319
+ return "load-lock: no held permits";
320
+ return ("load-lock: held permits (recovery diagnostics)\n" +
321
+ hs
322
+ .map((h) => ` ${h.entry.requestId} [${h.entry.phase}] ${h.entry.agentLabel || h.entry.agentId} — ${h.entry.reason}\n ${h.diagnostics.note}`)
323
+ .join("\n"));
324
+ });
325
+ return;
326
+ }
327
+ if (sub === "force-release") {
328
+ const id = S("id") || fail("recover force-release: --id required");
329
+ await withClient((c) => c.request("admin_release", { requestId: id }));
330
+ process.stdout.write(`load-lock: force-released ${id}\n`);
331
+ return;
332
+ }
333
+ fail(`recover: unknown subcommand "${sub}"`);
334
+ return;
335
+ }
336
+ // ------------------------------------------------------------- installer
337
+ case "install":
338
+ await installCommand({
339
+ harness: S("harness"),
340
+ project: S("project", process.cwd()),
341
+ json: B("json"),
342
+ global: true,
343
+ binDir: import.meta.dirname ? join(import.meta.dirname, "..") : undefined,
344
+ });
345
+ return;
346
+ case "uninstall":
347
+ await uninstallCommand({ harness: S("harness"), project: S("project", process.cwd()) });
348
+ return;
349
+ // ------------------------------------------------------------------ hooks
350
+ case "hook":
351
+ await hookCommand(rest);
352
+ return;
353
+ case "agent-guide": {
354
+ const p = join(import.meta.dirname ?? ".", "..", "..", "docs", "AGENT_GUIDE.md");
355
+ process.stdout.write(readFileSync(existsSync(p) ? p : join(import.meta.dirname ?? ".", "..", "..", "docs", "AGENT_GUIDE.md"), "utf8"));
356
+ return;
357
+ }
358
+ default:
359
+ fail(`unknown command "${cmd}". Commands: status, inspect, config, pending, events, acquire, claim, release, cancel, rejoin, mark-started, attach-pid, wait, run, service, doctor, recover, install, uninstall, hook, agent-guide`);
360
+ }
361
+ }
362
+ // ------------------------------------------------------------------ run helper
363
+ /**
364
+ * `load-lock run [--permit TOKEN | acquire flags] -- cmd...`
365
+ *
366
+ * Owns the process lifecycle: acquires (or uses a claimed permit), spawns the
367
+ * job in its own process group, atomically associates it with the permit,
368
+ * waits, and releases with the job's outcome. The job outliving the helper
369
+ * (helper killed, session gone) is safe: the daemon watches the process
370
+ * group and releases the permit when the job actually exits. The permit is
371
+ * NOT released merely because a background-task id was returned — only on
372
+ * real process exit.
373
+ */
374
+ async function runWithPermit(jobArgv) {
375
+ const command = jobArgv.join(" ");
376
+ const client = new LLClient();
377
+ await client.connect();
378
+ try {
379
+ let permitToken = S("permit");
380
+ if (!permitToken) {
381
+ const acq = (await client.request("acquire", {
382
+ pool: S("pool", "heavy"),
383
+ agentId: S("agent", defaultAgent()),
384
+ agentLabel: S("label") || "load-lock run",
385
+ cwd: process.cwd(),
386
+ opKey: S("op-key") || `run/${command}/${Date.now()}`,
387
+ reason: S("reason") || command,
388
+ waitMs: N("wait-ms", 5_000),
389
+ }));
390
+ if (acq.state === "queued") {
391
+ process.stdout.write(`load-lock: queued at position ${acq.entry.position} (request ${acq.entry.requestId}); increase --wait-ms or use load-lock wait\n`);
392
+ process.exit(68);
393
+ }
394
+ permitToken = acq.entry.permitToken;
395
+ }
396
+ // Spawn in its own process group so subprocesses/background jobs count.
397
+ // If the job was given as a single shell string, run it through a shell;
398
+ // if it was given as explicit argv (multiple args after --), exec directly
399
+ // so quoting survives.
400
+ const single = jobArgv.length === 1;
401
+ const spawnOpts = { detached: true, stdio: "inherit", cwd: S("cwd", process.cwd()), env: process.env };
402
+ const child = (single
403
+ ? spawn(jobArgv[0], { ...spawnOpts, shell: true })
404
+ : spawn(jobArgv[0], jobArgv.slice(1), spawnOpts));
405
+ const pgid = child.pid;
406
+ // Register the exit watcher BEFORE any await: if the job exits while we
407
+ // are still associating the permit, the otherwise-unobserved exit event
408
+ // would be lost and this helper would hang forever.
409
+ const exited = new Promise((resolveExit, rejectExit) => {
410
+ child.on("exit", (c, sig) => resolveExit(c ?? (sig ? 70 /* killed by signal */ : 0)));
411
+ child.on("error", (e) => rejectExit(e));
412
+ });
413
+ // Atomic association: if the permit was reclaimed in the interim, kill
414
+ // the freshly spawned job before it does meaningful work.
415
+ try {
416
+ await client.request("start_job", { permitToken, pid: child.pid, pgid });
417
+ }
418
+ catch (err) {
419
+ try {
420
+ process.kill(-pgid, "SIGKILL");
421
+ }
422
+ catch {
423
+ /* already gone */
424
+ }
425
+ fail(`permit no longer startable (${err.message}); job not started`, 69);
426
+ }
427
+ const forward = (sig) => {
428
+ try {
429
+ process.kill(-pgid, sig);
430
+ }
431
+ catch {
432
+ /* already gone */
433
+ }
434
+ };
435
+ process.on("SIGTERM", () => forward("SIGTERM"));
436
+ process.on("SIGINT", () => forward("SIGINT"));
437
+ let code;
438
+ try {
439
+ code = await exited;
440
+ }
441
+ catch (e) {
442
+ fail(`failed to run command: ${e.message}`, 65);
443
+ }
444
+ await client
445
+ .request("release", { permitToken, outcome: code === 0 ? "success" : "failure" })
446
+ .catch((e) => {
447
+ if (e instanceof ProtocolError && e.code === "stale_token")
448
+ return; // already auto-released
449
+ throw e;
450
+ });
451
+ process.exit(code === null ? 1 : code);
452
+ }
453
+ finally {
454
+ client.close();
455
+ }
456
+ }
457
+ main().catch((e) => {
458
+ if (e instanceof ProtocolError)
459
+ fail(e.message, e.code === "unavailable" ? 70 : 65);
460
+ fail(e?.stack ?? String(e), 70);
461
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * load-lock daemon entrypoint. Normally auto-spawned by the first client
4
+ * that needs it (detached, stdio ignored). Runs until SIGTERM/SIGINT or
5
+ * `load-lock service stop`.
6
+ */
7
+ import { Daemon } from "./daemon.js";
8
+ import { ProtocolError } from "../shared/protocol.js";
9
+ function arg(name) {
10
+ const i = process.argv.indexOf(name);
11
+ return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined;
12
+ }
13
+ const stateDir = arg("--state-dir");
14
+ const socket = arg("--socket");
15
+ const d = new Daemon({ stateDir, socket });
16
+ try {
17
+ await d.start();
18
+ }
19
+ catch (err) {
20
+ // E.g. another daemon already owns the socket: auto-spawn races resolve
21
+ // themselves, so exit quietly in that case.
22
+ if (err instanceof ProtocolError && err.code === "denied") {
23
+ process.exit(0);
24
+ }
25
+ process.stderr.write(`load-lock daemon failed to start: ${err.message}\n`);
26
+ process.exit(1);
27
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * load-lock daemon: single process owning all semaphore state.
3
+ *
4
+ * Ownership/lifecycle rationale (see docs/ARCHITECTURE.md): MCP tool calls
5
+ * are short-lived; a lock held by a tool call would die with the call. The
6
+ * daemon is the persistent owner: permits live in the daemon, independent of
7
+ * any agent connection, so a background build outliving its tool call or
8
+ * session keeps its authorization until released — or until its associated
9
+ * process is confirmed dead.
10
+ *
11
+ * Automatic cleanup (all sweeps run here, on real process evidence):
12
+ * - offers expire after offerTimeoutMs (claim window)
13
+ * - claimed permits are reclaimed after startTimeoutMs if execution never starts
14
+ * - tracked permits are released when their pid/process-group is confirmed dead
15
+ * - untracked permits are reclaimed when their heartbeat lease lapses
16
+ * All state mutations run on one event loop => every RPC is atomic wrt all
17
+ * others; start_job is therefore atomic wrt reclamation.
18
+ */
19
+ import { Server } from "node:net";
20
+ import { Machine } from "./state.js";
21
+ export declare class Daemon {
22
+ machine: Machine;
23
+ server: Server | null;
24
+ private watchers;
25
+ private waiters;
26
+ private sentEventId;
27
+ private stateDirPath;
28
+ private socketPath;
29
+ private sweepTimer;
30
+ private expiryTimer;
31
+ private stopped;
32
+ constructor(opts?: {
33
+ stateDir?: string;
34
+ socket?: string;
35
+ machine?: Machine;
36
+ recover?: boolean;
37
+ });
38
+ start(): Promise<void>;
39
+ /** Bind the socket, replacing it only if it is verifiably stale. */
40
+ private claimSocket;
41
+ stop(): void;
42
+ private onConnection;
43
+ private send;
44
+ private handleLine;
45
+ /** Returns [result, agentIdForPendingAttachment]. */
46
+ private dispatch;
47
+ /**
48
+ * Bounded blocking wait for a queued request. Resolves with a granted
49
+ * result if capacity is admitted within waitMs, else null (caller returns
50
+ * the queued state). This is the honest fallback for harnesses without
51
+ * push notifications — no model-authored busy-polling required.
52
+ */
53
+ private waitForAdmission;
54
+ /** Runs after every dispatch: sweep deadlines, auto-claim for waiters, flush events, journal. */
55
+ private afterOp;
56
+ private armExpiryTimer;
57
+ private broadcast;
58
+ /**
59
+ * Periodic cleanup with positive process evidence:
60
+ * - tracked permits: release when pid AND process group are confirmed dead
61
+ * - claimed/untracked deadlines: handled by machine.tick() (start
62
+ * deadline, heartbeat lease) here as well so timers alone are not load-bearing.
63
+ * Untracked permits are never released on duration alone — only when their
64
+ * lease lapses, which requires the owner's adapter to be gone.
65
+ */
66
+ private sweep;
67
+ }
68
+ /** Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse. */
69
+ export declare function isProcessAlive(pid: number, expectedStartTime?: string): boolean | null;
70
+ /**
71
+ * Job liveness: a tracked job is alive if its pid is alive (start-time
72
+ * matched) OR any process in its process group is alive — covering
73
+ * subprocesses and background jobs that outlive their parent. Returns
74
+ * true/false on Linux; on platforms without /proc the pgid scan is
75
+ * unavailable and the result may be null (unknown => conservative keep).
76
+ */
77
+ export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string): boolean | null;
78
+ /** Read the Linux start time of a pid (pid-reuse-safe association). */
79
+ export declare function processStartTime(pid: number): string | undefined;