@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,526 @@
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 { connect, createServer } from "node:net";
20
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync, rmSync } from "node:fs";
21
+ import { readdirSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { Machine } from "./state.js";
24
+ import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
25
+ import { inspectMachine } from "./sysinfo.js";
26
+ import { ProtocolError } from "../shared/protocol.js";
27
+ export class Daemon {
28
+ machine;
29
+ server = null;
30
+ watchers = new Set();
31
+ waiters = new Map();
32
+ sentEventId;
33
+ stateDirPath;
34
+ socketPath;
35
+ sweepTimer = null;
36
+ expiryTimer = null;
37
+ stopped = false;
38
+ constructor(opts = {}) {
39
+ this.stateDirPath = defaultStateDir(opts.stateDir);
40
+ this.socketPath = defaultSocket(opts.socket);
41
+ if (opts.machine) {
42
+ this.machine = opts.machine;
43
+ }
44
+ else {
45
+ mkdirSync(this.stateDirPath, { recursive: true });
46
+ const st = loadState(this.stateDirPath);
47
+ this.machine = new Machine(Date.now, st.config);
48
+ this.machine.history.push(...st.history);
49
+ // Conservative restart reconciliation: keep permits whose job still
50
+ // runs; release permits whose process is confirmed dead. Same permit
51
+ // tokens remain valid, so a late explicit release after restart works.
52
+ if (opts.recover !== false) {
53
+ this.machine.restore(loadJournal(this.stateDirPath), (e) => jobAlive(e.pid, e.pgid, e.pidStartTime));
54
+ }
55
+ }
56
+ this.sentEventId = this.machine.latestEventId(); // don't replay history to watchers
57
+ }
58
+ // -------------------------------------------------------------------- start
59
+ async start() {
60
+ await this.claimSocket();
61
+ await new Promise((resolve, reject) => {
62
+ this.server = createServer((sock) => this.onConnection(sock));
63
+ this.server.on("error", reject);
64
+ this.server.listen(this.socketPath, () => resolve());
65
+ });
66
+ writeFileSync(join(this.stateDirPath, "daemon.json"), JSON.stringify({ pid: process.pid, startedAt: Date.now(), socket: this.socketPath }, null, 2) + "\n");
67
+ process.once("SIGTERM", () => this.stop());
68
+ process.once("SIGINT", () => this.stop());
69
+ this.sweepTimer = setInterval(() => this.sweep(), 5_000);
70
+ this.sweepTimer.unref?.();
71
+ this.sweep(); // catch anything that expired while we were down
72
+ }
73
+ /** Bind the socket, replacing it only if it is verifiably stale. */
74
+ async claimSocket() {
75
+ if (existsSync(this.socketPath)) {
76
+ const live = await probeUnixSocket(this.socketPath);
77
+ if (live) {
78
+ throw new ProtocolError("denied", `another daemon is already listening on ${this.socketPath}`);
79
+ }
80
+ try {
81
+ unlinkSync(this.socketPath);
82
+ }
83
+ catch {
84
+ /* ignore */
85
+ }
86
+ }
87
+ }
88
+ stop() {
89
+ if (this.stopped)
90
+ return;
91
+ this.stopped = true;
92
+ if (this.sweepTimer)
93
+ clearInterval(this.sweepTimer);
94
+ if (this.expiryTimer)
95
+ clearTimeout(this.expiryTimer);
96
+ for (const w of this.watchers)
97
+ w.socket.destroy();
98
+ this.watchers.clear();
99
+ this.server?.close();
100
+ try {
101
+ if (existsSync(this.socketPath))
102
+ unlinkSync(this.socketPath);
103
+ }
104
+ catch {
105
+ /* ignore */
106
+ }
107
+ try {
108
+ rmSync(join(this.stateDirPath, "daemon.json"), { force: true });
109
+ }
110
+ catch {
111
+ /* ignore */
112
+ }
113
+ }
114
+ // ----------------------------------------------------------------- framing
115
+ onConnection(sock) {
116
+ let buf = "";
117
+ sock.on("data", (chunk) => {
118
+ buf += chunk.toString("utf8");
119
+ let idx;
120
+ while ((idx = buf.indexOf("\n")) !== -1) {
121
+ const line = buf.slice(0, idx);
122
+ buf = buf.slice(idx + 1);
123
+ if (line.trim())
124
+ void this.handleLine(sock, line);
125
+ }
126
+ });
127
+ sock.on("error", () => sock.destroy());
128
+ sock.on("close", () => {
129
+ for (const w of [...this.watchers])
130
+ if (w.socket === sock)
131
+ this.watchers.delete(w);
132
+ });
133
+ }
134
+ send(sock, obj) {
135
+ if (!sock.destroyed)
136
+ sock.write(JSON.stringify(obj) + "\n");
137
+ }
138
+ async handleLine(sock, lineText) {
139
+ let msg;
140
+ try {
141
+ msg = JSON.parse(lineText);
142
+ }
143
+ catch {
144
+ this.send(sock, { id: null, ok: false, error: { code: "invalid", message: "malformed JSON line" } });
145
+ return;
146
+ }
147
+ let result;
148
+ let agentId;
149
+ try {
150
+ const [res, agent] = await this.dispatch(sock, msg.method, msg.params ?? {});
151
+ result = res;
152
+ agentId = agent;
153
+ this.afterOp();
154
+ }
155
+ catch (err) {
156
+ this.afterOp();
157
+ if (err instanceof ProtocolError) {
158
+ this.send(sock, { id: msg.id, ok: false, error: { code: err.code, message: err.message, details: err.details } });
159
+ }
160
+ else {
161
+ this.send(sock, { id: msg.id, ok: false, error: { code: "internal", message: String(err?.message ?? err) } });
162
+ }
163
+ return;
164
+ }
165
+ // Tool-boundary delivery: surface this agent's outstanding offers/queue
166
+ // on every response so the next tool result always carries them.
167
+ if (agentId) {
168
+ result.pending = this.machine.pending(agentId);
169
+ }
170
+ this.send(sock, { id: msg.id, ok: true, result });
171
+ }
172
+ // --------------------------------------------------------------- dispatch
173
+ /** Returns [result, agentIdForPendingAttachment]. */
174
+ async dispatch(sock, method, p) {
175
+ const m = this.machine;
176
+ switch (method) {
177
+ case "hello":
178
+ return [{ protocolVersion: 1, pid: process.pid, startedAt: m.startedAt, configVersion: m.config.version }, undefined];
179
+ case "acquire": {
180
+ const params = p;
181
+ let res = m.acquire(params);
182
+ if (res.state === "offered") {
183
+ // An offer was already waiting for this op (idempotent re-acquire):
184
+ // auto-claim to honor "wait" semantics without a second round trip.
185
+ res = { state: "granted", entry: m.claimByRequestId(res.entry.requestId).entry };
186
+ }
187
+ else if (res.state === "queued") {
188
+ const waitMs = Math.min(Math.max(0, params.waitMs ?? 0), m.config.maxWaitMs);
189
+ if (waitMs > 0) {
190
+ const waited = await this.waitForAdmission(res.entry.requestId, waitMs);
191
+ if (waited)
192
+ res = waited;
193
+ }
194
+ }
195
+ return [res, params.agentId];
196
+ }
197
+ case "claim": {
198
+ const res = m.claim(String(p.offerToken));
199
+ return [res, res.entry.agentId];
200
+ }
201
+ case "release": {
202
+ const res = m.release(String(p.permitToken));
203
+ return [res, res.entry.agentId];
204
+ }
205
+ case "decline": {
206
+ m.decline(String(p.offerToken));
207
+ return [{ ok: true }, undefined];
208
+ }
209
+ case "cancel": {
210
+ const res = m.cancel(p);
211
+ return [res, res.entry.agentId];
212
+ }
213
+ case "rejoin": {
214
+ const snap = m.rejoin({ requestId: String(p.requestId) });
215
+ return [{ state: snap.state, entry: snap }, snap.agentId];
216
+ }
217
+ case "start_job": {
218
+ // Atomic execution association. pidStartTime read daemon-side from
219
+ // /proc (pid-reuse-safe on Linux).
220
+ const snap = m.startJob(String(p.permitToken), Number(p.pid), p.pgid !== undefined ? Number(p.pgid) : undefined, processStartTime(Number(p.pid)));
221
+ return [{ entry: snap }, snap.agentId];
222
+ }
223
+ case "mark_started": {
224
+ const snap = m.markStarted(String(p.permitToken));
225
+ return [{ entry: snap }, snap.agentId];
226
+ }
227
+ case "attach_pid": {
228
+ // Convenience alias: associate a manually-started process.
229
+ const pid = Number(p.pid);
230
+ const snap = m.startJob(String(p.permitToken), pid, undefined, processStartTime(pid));
231
+ return [{ entry: snap }, snap.agentId];
232
+ }
233
+ case "heartbeat": {
234
+ m.heartbeat(String(p.agentId));
235
+ return [{ ok: true }, String(p.agentId)];
236
+ }
237
+ case "pending": {
238
+ const res = m.pending(String(p.agentId));
239
+ return [res, String(p.agentId)];
240
+ }
241
+ case "status":
242
+ return [m.status(p.pool ? String(p.pool) : undefined), undefined];
243
+ case "get_config":
244
+ return [m.get_config(), undefined];
245
+ case "configure": {
246
+ const res = m.configure(p);
247
+ saveState(this.stateDirPath, m.config, m.history);
248
+ return [res, undefined];
249
+ }
250
+ case "inspect":
251
+ return [inspectMachine(m.config), undefined];
252
+ case "events": {
253
+ const since = Number(p.sinceEventId ?? 0);
254
+ const agentId = p.agentId ? String(p.agentId) : undefined;
255
+ const evs = m.eventsSince(since, agentId);
256
+ if (agentId) {
257
+ // Owner-filtered pulls carry claim tokens (durable identities +
258
+ // tokens survive reconnects); unfiltered pulls never do.
259
+ for (const ev of evs) {
260
+ if (ev.type === "offered" && ev.entry && ev.targetAgent === agentId) {
261
+ const t = m.offerTokenOf(ev.entry.requestId);
262
+ if (t)
263
+ ev.offerToken = t;
264
+ }
265
+ }
266
+ }
267
+ return [{ events: evs, latestEventId: m.latestEventId() }, undefined];
268
+ }
269
+ case "watch": {
270
+ const agentId = p.agentId ? String(p.agentId) : "*";
271
+ this.watchers.add({ socket: sock, agentId });
272
+ this.send(sock, { event: true, type: "watch_started", targetAgent: agentId, latestEventId: m.latestEventId() });
273
+ return [{ ok: true }, undefined];
274
+ }
275
+ case "offers_for_cwd": {
276
+ // Used by Claude Code / Codex hook scripts, which lack the MCP
277
+ // process's agent identity: match offers by the cwd recorded at
278
+ // acquire time. Claiming still requires the opaque token, so a
279
+ // match can wake a session but can never authorize work by itself.
280
+ const cwd = String(p.cwd);
281
+ return [{ offers: m.allOffers().filter((e) => e.cwd === cwd) }, undefined];
282
+ }
283
+ case "admin_list":
284
+ return [{ holders: m.diagnose((e) => jobAlive(e.pid, e.pgid, e.pidStartTime)), latestEventId: m.latestEventId() }, undefined];
285
+ case "admin_release": {
286
+ // Force-release recovery: reclaims the permit and frees capacity.
287
+ m.adminRelease(String(p.requestId));
288
+ return [{ ok: true }, undefined];
289
+ }
290
+ default:
291
+ throw new ProtocolError("invalid", `unknown method "${method}"`);
292
+ }
293
+ }
294
+ /**
295
+ * Bounded blocking wait for a queued request. Resolves with a granted
296
+ * result if capacity is admitted within waitMs, else null (caller returns
297
+ * the queued state). This is the honest fallback for harnesses without
298
+ * push notifications — no model-authored busy-polling required.
299
+ */
300
+ waitForAdmission(requestId, waitMs) {
301
+ return new Promise((resolve) => {
302
+ const timer = setTimeout(() => {
303
+ this.waiters.delete(requestId);
304
+ resolve(null);
305
+ }, waitMs);
306
+ this.waiters.set(requestId, { requestId, resolve, timer });
307
+ });
308
+ }
309
+ // ------------------------------------------------------------- post-op pass
310
+ /** Runs after every dispatch: sweep deadlines, auto-claim for waiters, flush events, journal. */
311
+ afterOp() {
312
+ const m = this.machine;
313
+ m.tick(m.now());
314
+ // Auto-claim on behalf of blocking waiters when their request is offered.
315
+ for (const [requestId, waiter] of [...this.waiters]) {
316
+ const entry = m.entryById(requestId);
317
+ if (!entry)
318
+ continue;
319
+ if (entry.state === "offered") {
320
+ const granted = m.claimByRequestId(requestId);
321
+ clearTimeout(waiter.timer);
322
+ this.waiters.delete(requestId);
323
+ waiter.resolve({ state: "granted", entry: granted.entry });
324
+ }
325
+ else if (entry.state === "held") {
326
+ clearTimeout(waiter.timer);
327
+ this.waiters.delete(requestId);
328
+ waiter.resolve({ state: "granted", entry: m.ownerSnapshotById(requestId) });
329
+ }
330
+ else if (entry.state === "paused" || entry.state === "cancelled" || entry.state === "released") {
331
+ clearTimeout(waiter.timer);
332
+ this.waiters.delete(requestId);
333
+ waiter.resolve(null);
334
+ }
335
+ }
336
+ // Flush events produced by this op to watchers.
337
+ const fresh = m.eventsSince(this.sentEventId);
338
+ if (fresh.length > 0) {
339
+ this.sentEventId = m.latestEventId();
340
+ for (const ev of fresh)
341
+ this.broadcast(ev);
342
+ }
343
+ // Persist held permits for restart reconciliation.
344
+ try {
345
+ saveJournal(this.stateDirPath, m.journalEntries());
346
+ }
347
+ catch {
348
+ /* journal is best-effort; config.json already persisted */
349
+ }
350
+ this.armExpiryTimer();
351
+ }
352
+ armExpiryTimer() {
353
+ const next = this.machine.nextExpiry();
354
+ if (this.expiryTimer) {
355
+ clearTimeout(this.expiryTimer);
356
+ this.expiryTimer = null;
357
+ }
358
+ if (next === null)
359
+ return;
360
+ const ms = Math.max(5, next - Date.now());
361
+ this.expiryTimer = setTimeout(() => {
362
+ this.machine.tick(this.machine.now());
363
+ const fresh = this.machine.eventsSince(this.sentEventId);
364
+ if (fresh.length > 0) {
365
+ this.sentEventId = this.machine.latestEventId();
366
+ for (const ev of fresh)
367
+ this.broadcast(ev);
368
+ }
369
+ try {
370
+ saveJournal(this.stateDirPath, this.machine.journalEntries());
371
+ }
372
+ catch {
373
+ /* ignore */
374
+ }
375
+ this.armExpiryTimer();
376
+ }, ms);
377
+ this.expiryTimer.unref?.();
378
+ }
379
+ broadcast(ev) {
380
+ for (const w of this.watchers) {
381
+ if (w.agentId === "*" || ev.targetAgent === "*" || ev.targetAgent === w.agentId) {
382
+ const out = { ...ev, event: true };
383
+ if (ev.type === "offered" && ev.entry && (w.agentId === ev.targetAgent || w.agentId === "*")) {
384
+ // Deliver the claim token to the owner, and to trusted "*"
385
+ // multiplexer watchers (e.g. the OpenCode plugin, which routes the
386
+ // event to the right session by targetAgent). Claiming still
387
+ // requires the token; the event itself authorizes nothing.
388
+ const tok = this.machine.offerTokenOf(ev.entry.requestId);
389
+ if (tok)
390
+ out.offerToken = tok;
391
+ }
392
+ this.send(w.socket, out);
393
+ }
394
+ }
395
+ }
396
+ // ------------------------------------------------------------------ sweeps
397
+ /**
398
+ * Periodic cleanup with positive process evidence:
399
+ * - tracked permits: release when pid AND process group are confirmed dead
400
+ * - claimed/untracked deadlines: handled by machine.tick() (start
401
+ * deadline, heartbeat lease) here as well so timers alone are not load-bearing.
402
+ * Untracked permits are never released on duration alone — only when their
403
+ * lease lapses, which requires the owner's adapter to be gone.
404
+ */
405
+ sweep() {
406
+ const m = this.machine;
407
+ for (const e of m.heldEntries()) {
408
+ if (e.phase === "running_tracked") {
409
+ const alive = jobAlive(e.pid, e.pgid, e.pidStartTime);
410
+ if (alive === false) {
411
+ m.autoRelease(e.requestId, `tracked process ${e.pid}${e.pgid ? ` / pgid ${e.pgid}` : ""} is no longer running`);
412
+ }
413
+ }
414
+ }
415
+ m.tick(m.now());
416
+ try {
417
+ saveJournal(this.stateDirPath, m.journalEntries());
418
+ }
419
+ catch {
420
+ /* ignore */
421
+ }
422
+ const fresh = m.eventsSince(this.sentEventId);
423
+ if (fresh.length > 0) {
424
+ this.sentEventId = m.latestEventId();
425
+ for (const ev of fresh)
426
+ this.broadcast(ev);
427
+ }
428
+ }
429
+ }
430
+ // --------------------------------------------------------------------- helpers
431
+ async function probeUnixSocket(path) {
432
+ return new Promise((resolve) => {
433
+ const sock = connect(path);
434
+ sock.once("connect", () => {
435
+ sock.end();
436
+ resolve(true);
437
+ });
438
+ sock.once("error", () => resolve(false));
439
+ });
440
+ }
441
+ /** Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse. */
442
+ export function isProcessAlive(pid, expectedStartTime) {
443
+ try {
444
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
445
+ if (expectedStartTime !== undefined) {
446
+ const close = stat.lastIndexOf(")");
447
+ const fields = stat.slice(close + 2).split(" ");
448
+ const starttime = fields[19]; // field 22 overall (1-based), minus pid + comm
449
+ if (starttime !== expectedStartTime)
450
+ return false; // pid was reused
451
+ }
452
+ return true;
453
+ }
454
+ catch {
455
+ // /proc unavailable (macOS): fall back to signal 0. Pid reuse is possible
456
+ // here; documented limitation — that is why adapters should prefer
457
+ // run_with_permit / process-group tracking where available.
458
+ try {
459
+ process.kill(pid, 0);
460
+ return true;
461
+ }
462
+ catch {
463
+ return false;
464
+ }
465
+ }
466
+ }
467
+ /**
468
+ * Job liveness: a tracked job is alive if its pid is alive (start-time
469
+ * matched) OR any process in its process group is alive — covering
470
+ * subprocesses and background jobs that outlive their parent. Returns
471
+ * true/false on Linux; on platforms without /proc the pgid scan is
472
+ * unavailable and the result may be null (unknown => conservative keep).
473
+ */
474
+ export function jobAlive(pid, pgid, pidStartTime) {
475
+ if (pid !== undefined) {
476
+ const alive = isProcessAlive(pid, pidStartTime);
477
+ if (alive === true)
478
+ return true;
479
+ }
480
+ if (pgid !== undefined) {
481
+ const scan = scanProcessGroup(pgid);
482
+ if (scan !== null)
483
+ return scan;
484
+ }
485
+ if (pid === undefined)
486
+ return null;
487
+ return false;
488
+ }
489
+ /** Scan /proc for any live process whose pgrp matches pgid. null = /proc unavailable. */
490
+ function scanProcessGroup(pgid) {
491
+ let names;
492
+ try {
493
+ names = readdirSync("/proc");
494
+ }
495
+ catch {
496
+ return null;
497
+ }
498
+ for (const name of names) {
499
+ if (!/^\d+$/.test(name))
500
+ continue;
501
+ try {
502
+ const stat = readFileSync(`/proc/${name}/stat`, "utf8");
503
+ const close = stat.lastIndexOf(")");
504
+ const fields = stat.slice(close + 2).split(" ");
505
+ const pgrp = Number(fields[2]); // field 5 overall, minus pid + comm
506
+ if (pgrp === pgid)
507
+ return true;
508
+ }
509
+ catch {
510
+ /* process vanished mid-scan */
511
+ }
512
+ }
513
+ return false;
514
+ }
515
+ /** Read the Linux start time of a pid (pid-reuse-safe association). */
516
+ export function processStartTime(pid) {
517
+ try {
518
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
519
+ const close = stat.lastIndexOf(")");
520
+ const fields = stat.slice(close + 2).split(" ");
521
+ return fields[19];
522
+ }
523
+ catch {
524
+ return undefined;
525
+ }
526
+ }
@@ -0,0 +1,11 @@
1
+ import { MachineConfig, ConfigHistoryEntry, JournalEntry } from "../shared/protocol.js";
2
+ export declare function stateDir(override?: string): string;
3
+ export declare function socketPath(override?: string): string;
4
+ export declare function loadState(dir: string): {
5
+ config: MachineConfig;
6
+ history: ConfigHistoryEntry[];
7
+ };
8
+ export declare function saveState(dir: string, config: MachineConfig, history: ConfigHistoryEntry[]): void;
9
+ export declare function journalPath(dir: string): string;
10
+ export declare function loadJournal(dir: string): JournalEntry[];
11
+ export declare function saveJournal(dir: string, entries: JournalEntry[]): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Atomic JSON persistence for daemon config + change history.
3
+ * State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock).
4
+ *
5
+ * Only *configuration* persists across daemon restarts. Permits and queue
6
+ * are ephemeral by design (see docs/ARCHITECTURE.md — restart behavior):
7
+ * tokens are daemon-instance-scoped, and agents re-acquire after a restart.
8
+ */
9
+ import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { defaultConfig } from "./state.js";
12
+ export function stateDir(override) {
13
+ if (override)
14
+ return override;
15
+ if (process.env.LOAD_LOCK_STATE_DIR)
16
+ return process.env.LOAD_LOCK_STATE_DIR;
17
+ const xdg = process.env.XDG_STATE_HOME || join(process.env.HOME ?? "~", ".local", "state");
18
+ return join(xdg, "load-lock");
19
+ }
20
+ export function socketPath(override) {
21
+ if (override)
22
+ return override;
23
+ if (process.env.LOAD_LOCK_SOCKET)
24
+ return process.env.LOAD_LOCK_SOCKET;
25
+ return join(stateDir(), "daemon.sock");
26
+ }
27
+ export function loadState(dir) {
28
+ const path = join(dir, "config.json");
29
+ if (!existsSync(path))
30
+ return { config: defaultConfig(), history: [] };
31
+ const raw = JSON.parse(readFileSync(path, "utf8"));
32
+ const config = { ...defaultConfig(), ...raw.config };
33
+ // Defensive: always at least the default pool.
34
+ if (!config.pools || Object.keys(config.pools).length === 0)
35
+ config.pools = { heavy: { capacity: 1 } };
36
+ return { config, history: Array.isArray(raw.history) ? raw.history : [] };
37
+ }
38
+ export function saveState(dir, config, history) {
39
+ mkdirSync(dir, { recursive: true });
40
+ const path = join(dir, "config.json");
41
+ const tmp = join(dirname(path), ".config.json.tmp-" + process.pid);
42
+ writeFileSync(tmp, JSON.stringify({ config, history }, null, 2) + "\n", { mode: 0o644 });
43
+ renameSync(tmp, path); // atomic on POSIX
44
+ }
45
+ // --------------------------------------------------------------------------
46
+ // Journal: held permits, persisted so a daemon restart can conservatively
47
+ // reconcile running ownership (tracked jobs keep their permit; confirmed-dead
48
+ // jobs release; queued/offered requests are NOT journaled — agents re-acquire).
49
+ export function journalPath(dir) {
50
+ return join(dir, "journal.json");
51
+ }
52
+ export function loadJournal(dir) {
53
+ try {
54
+ const raw = JSON.parse(readFileSync(journalPath(dir), "utf8"));
55
+ return Array.isArray(raw) ? raw : [];
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
61
+ export function saveJournal(dir, entries) {
62
+ const path = journalPath(dir);
63
+ const tmp = join(dirname(path), ".journal.json.tmp-" + process.pid);
64
+ writeFileSync(tmp, JSON.stringify(entries, null, 2) + "\n", { mode: 0o644 });
65
+ renameSync(tmp, path);
66
+ }