@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,909 @@
1
+ /**
2
+ * load-lock core: a pure, synchronous state machine for the machine-wide
3
+ * counting semaphore. No I/O here — the daemon drives timers, process
4
+ * liveness checks, journaling and persistence. All mutations are serialized
5
+ * by construction (single-threaded event loop of the daemon), so every
6
+ * handle() call is atomic with respect to every other call. Notably,
7
+ * execution association (start_job) is atomic with respect to reclamation:
8
+ * a start either observes a live claimed permit and transitions it, or fails.
9
+ *
10
+ * Permit lifecycle (see docs/ARCHITECTURE.md):
11
+ * acquire --grant--> held(phase=claimed, start deadline)
12
+ * offer --claim----> held(phase=claimed, start deadline)
13
+ * claimed --start_job-------> held(phase=running_tracked, pid+pgid tracked)
14
+ * claimed --mark_started----> held(phase=running_untracked, heartbeat lease)
15
+ * running_* --job end-------> released (automatic or explicit, idempotent)
16
+ * claimed past deadline ----> released (reclaimed)
17
+ * untracked lease expired --> released (reclaimed)
18
+ *
19
+ * Invariants (covered by tests):
20
+ * - used(pool) = sum(weights of held) + sum(weights of offered) <= capacity,
21
+ * except temporarily after a capacity decrease (holders always finish).
22
+ * - Queue admission is strict FIFO per pool.
23
+ * - Offers reserve capacity; an expired/declined offer passes capacity to
24
+ * the next waiter and the request becomes `paused` (never auto-recycled).
25
+ * - Tokens (offer/permit) are opaque and single-use; only owners ever see
26
+ * them; terminal states invalidate them (kept briefly for idempotency).
27
+ * - acquire() is idempotent per (agentId, opKey) while a request is live.
28
+ * - A forgotten release cannot permanently block the queue: every held
29
+ * permit has an automatic exit (start deadline, tracked process death, or
30
+ * untracked lease). A known-running tracked job is never revoked on
31
+ * duration alone.
32
+ */
33
+ import { ProtocolError, } from "../shared/protocol.js";
34
+ import { randomBytes } from "node:crypto";
35
+ const MAX_EVENTS = 2000;
36
+ const MAX_HISTORY = 50;
37
+ export const LONG_HOLD_MS = 30 * 60_000; // diagnostics threshold, never a revocation
38
+ const PRUNE_AFTER_MS = 60 * 60_000; // terminal entries kept this long for idempotent retries
39
+ export function defaultConfig() {
40
+ return {
41
+ version: 1,
42
+ pools: { heavy: { capacity: 1 } },
43
+ offerTimeoutMs: 30_000,
44
+ maxWaitMs: 5_000,
45
+ startTimeoutMs: 120_000,
46
+ untrackedLeaseMs: 15 * 60_000,
47
+ guidance: {},
48
+ };
49
+ }
50
+ const rid = () => "req_" + randomBytes(12).toString("hex");
51
+ const tok = (p) => p + "_" + randomBytes(16).toString("hex");
52
+ export class Machine {
53
+ config;
54
+ entries = new Map();
55
+ byKey = new Map(); // `${agentId}\u0000${opKey}` -> entryId (live requests only)
56
+ offerTokens = new Map(); // offerToken -> entryId
57
+ permitTokens = new Map(); // permitToken -> entryId (survives release for idempotency until prune)
58
+ queues = new Map(); // pool -> FIFO of entryIds
59
+ expiredOffers = new Map(); // recently-expired offer token -> ts (for precise claim errors)
60
+ events = [];
61
+ nextEventId = 1;
62
+ clock;
63
+ startedAt;
64
+ constructor(clock = Date.now, config) {
65
+ this.clock = clock;
66
+ this.startedAt = clock();
67
+ this.config = config ?? defaultConfig();
68
+ this.pushEvent({ type: "daemon_started", targetAgent: "*" });
69
+ }
70
+ now() {
71
+ return this.clock();
72
+ }
73
+ // ------------------------------------------------------------------ events
74
+ pushEvent(e) {
75
+ const ev = { id: this.nextEventId++, ts: this.now(), ...e };
76
+ this.events.push(ev);
77
+ if (this.events.length > MAX_EVENTS)
78
+ this.events.splice(0, this.events.length - MAX_EVENTS);
79
+ return ev;
80
+ }
81
+ /** Events with id > since, newest last. */
82
+ eventsSince(since, agentId, limit = 500) {
83
+ const out = this.events.filter((e) => e.id > since && (!agentId || e.targetAgent === agentId || e.targetAgent === "*"));
84
+ return out.slice(-limit);
85
+ }
86
+ latestEventId() {
87
+ return this.nextEventId - 1;
88
+ }
89
+ emit(type, entry, extra) {
90
+ const targetAgent = entry ? entry.agentId : "*";
91
+ const ev = {
92
+ type,
93
+ targetAgent,
94
+ pool: entry?.pool ?? extra?.pool,
95
+ entry: entry ? this.snapshot(entry) : undefined,
96
+ ...extra,
97
+ };
98
+ if (ev.entry)
99
+ delete ev.entry.offerToken;
100
+ this.pushEvent(ev);
101
+ }
102
+ // ---------------------------------------------------------------- snapshots
103
+ snapshot(e) {
104
+ const s = {
105
+ requestId: e.id,
106
+ pool: e.pool,
107
+ agentId: e.agentId,
108
+ agentLabel: e.agentLabel,
109
+ cwd: e.cwd,
110
+ opKey: e.opKey,
111
+ reason: e.reason,
112
+ weight: e.weight,
113
+ state: e.state,
114
+ since: e.since,
115
+ };
116
+ if (e.state === "queued")
117
+ s.position = this.position(e) ?? undefined;
118
+ if (e.pid !== undefined)
119
+ s.pid = e.pid;
120
+ if (e.pgid !== undefined)
121
+ s.pgid = e.pgid;
122
+ if (e.pidStartTime)
123
+ s.pidStartTime = e.pidStartTime;
124
+ if (e.state === "offered")
125
+ s.offerExpiresAt = e.offerExpiresAt;
126
+ if (e.state === "held") {
127
+ s.phase = e.phase;
128
+ s.heldFor = this.now() - e.since;
129
+ }
130
+ if (e.startDeadline)
131
+ s.startDeadline = e.startDeadline;
132
+ if (e.leaseDeadline)
133
+ s.leaseDeadline = e.leaseDeadline;
134
+ if (e.lastSeen)
135
+ s.lastSeen = e.lastSeen;
136
+ return s;
137
+ }
138
+ ownerSnapshot(e) {
139
+ const s = this.snapshot(e);
140
+ if (e.state === "offered" && e.offerToken)
141
+ s.offerToken = e.offerToken;
142
+ if (e.state === "held" && e.permitToken)
143
+ s.permitToken = e.permitToken;
144
+ return s;
145
+ }
146
+ position(e) {
147
+ const q = this.queues.get(e.pool);
148
+ if (!q)
149
+ return null;
150
+ const idx = q.indexOf(e.id);
151
+ return idx === -1 ? null : idx + 1;
152
+ }
153
+ // ------------------------------------------------------------- pool maths
154
+ poolNames() {
155
+ return Object.keys(this.config.pools);
156
+ }
157
+ capacity(pool) {
158
+ return this.config.pools[pool]?.capacity ?? 0;
159
+ }
160
+ /** Weight currently consuming capacity (held + offered). */
161
+ used(pool) {
162
+ let n = 0;
163
+ for (const e of this.entries.values()) {
164
+ if (e.pool === pool && (e.state === "held" || e.state === "offered"))
165
+ n += e.weight;
166
+ }
167
+ return n;
168
+ }
169
+ liveEntryByOpKey(agentId, opKey) {
170
+ const id = this.byKey.get(`${agentId}\u0000${opKey}`);
171
+ if (!id)
172
+ return undefined;
173
+ return this.entries.get(id);
174
+ }
175
+ /**
176
+ * Fill capacity from the FIFO queue. Strict FIFO: the head of the queue is
177
+ * admitted only if it fits; otherwise we stop (no bypassing heavier
178
+ * requests by lighter ones behind them).
179
+ */
180
+ admit(pool) {
181
+ const q = this.queues.get(pool);
182
+ if (!q)
183
+ return;
184
+ while (q.length > 0) {
185
+ const headId = q[0];
186
+ const e = this.entries.get(headId);
187
+ if (!e || e.state !== "queued") {
188
+ q.shift();
189
+ continue;
190
+ }
191
+ if (this.used(pool) + e.weight > this.capacity(pool))
192
+ return; // FIFO head blocks
193
+ q.shift();
194
+ e.state = "offered";
195
+ e.since = this.now();
196
+ e.offerToken = tok("off");
197
+ e.offerExpiresAt = this.now() + this.config.offerTimeoutMs;
198
+ this.offerTokens.set(e.offerToken, e.id);
199
+ this.emit("offered", e);
200
+ }
201
+ }
202
+ /** Remove an entry from its pool queue if present. */
203
+ dequeue(e) {
204
+ const q = this.queues.get(e.pool);
205
+ if (!q)
206
+ return;
207
+ const i = q.indexOf(e.id);
208
+ if (i !== -1)
209
+ q.splice(i, 1);
210
+ }
211
+ /** Remove op-key association and invalidate live tokens (terminal-ish). */
212
+ retire(e) {
213
+ this.byKey.delete(`${e.agentId}\u0000${e.opKey}`);
214
+ if (e.offerToken)
215
+ this.offerTokens.delete(e.offerToken);
216
+ // permitToken mapping is intentionally KEPT so that a duplicate/retried
217
+ // release() is answered idempotently; pruned later by sweep().
218
+ }
219
+ // ----------------------------------------------------------------- acquire
220
+ newPermit(e) {
221
+ e.state = "held";
222
+ e.since = this.now();
223
+ e.phase = "claimed";
224
+ e.permitToken = tok("prm");
225
+ e.startDeadline = this.now() + this.config.startTimeoutMs;
226
+ this.permitTokens.set(e.permitToken, e.id);
227
+ }
228
+ acquire(p) {
229
+ const pool = p.pool ?? "heavy";
230
+ if (!this.config.pools[pool])
231
+ throw new ProtocolError("invalid", `unknown pool "${pool}"`);
232
+ if (!p.agentId)
233
+ throw new ProtocolError("invalid", "agentId is required");
234
+ if (!p.opKey)
235
+ throw new ProtocolError("invalid", "opKey is required");
236
+ if (!p.reason)
237
+ throw new ProtocolError("invalid", "reason is required");
238
+ const weight = p.weight ?? 1;
239
+ if (!Number.isInteger(weight) || weight < 1)
240
+ throw new ProtocolError("invalid", "weight must be a positive integer");
241
+ // Idempotency: a live request for the same (agent, op) returns its state
242
+ // (including its offer/permit token for the owner).
243
+ const existing = this.liveEntryByOpKey(p.agentId, p.opKey);
244
+ if (existing) {
245
+ const s = existing.state === "held"
246
+ ? "granted"
247
+ : existing.state === "offered"
248
+ ? "offered"
249
+ : existing.state === "paused"
250
+ ? "paused"
251
+ : "queued";
252
+ return { state: s, entry: this.ownerSnapshot(existing) };
253
+ }
254
+ if (this.used(pool) + weight <= this.capacity(pool)) {
255
+ // Immediate grant: capacity is free. A permit token is issued directly;
256
+ // the start deadline still applies (execution must begin, then track).
257
+ const e = {
258
+ id: rid(),
259
+ pool,
260
+ agentId: p.agentId,
261
+ agentLabel: p.agentLabel,
262
+ cwd: p.cwd,
263
+ opKey: p.opKey,
264
+ reason: p.reason,
265
+ weight,
266
+ state: "held",
267
+ since: this.now(),
268
+ startedAt: undefined,
269
+ };
270
+ this.entries.set(e.id, e);
271
+ this.newPermit(e);
272
+ this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
273
+ this.emit("granted", e);
274
+ return { state: "granted", entry: this.ownerSnapshot(e) };
275
+ }
276
+ if (p.subscribeIfBusy === false) {
277
+ return { state: "denied", entry: null };
278
+ }
279
+ const e = {
280
+ id: rid(),
281
+ pool,
282
+ agentId: p.agentId,
283
+ agentLabel: p.agentLabel,
284
+ cwd: p.cwd,
285
+ opKey: p.opKey,
286
+ reason: p.reason,
287
+ weight,
288
+ state: "queued",
289
+ since: this.now(),
290
+ enqueuedAt: this.now(),
291
+ };
292
+ this.entries.set(e.id, e);
293
+ this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
294
+ if (!this.queues.has(pool))
295
+ this.queues.set(pool, []);
296
+ this.queues.get(pool).push(e.id);
297
+ this.emit("queued", e);
298
+ return { state: "queued", entry: this.ownerSnapshot(e) };
299
+ }
300
+ // ------------------------------------------------------------------- claim
301
+ claim(offerToken) {
302
+ const id = this.offerTokens.get(offerToken);
303
+ if (!id) {
304
+ // A recently expired/declined offer gives the precise error instead of
305
+ // a generic stale-token one (bounded; pruned with terminal entries).
306
+ if (this.expiredOffers.has(offerToken)) {
307
+ throw new ProtocolError("offer_expired", "claim window elapsed; the request is now paused — rejoin when ready");
308
+ }
309
+ throw new ProtocolError("stale_token", "unknown offer token (it may have expired, been claimed, or the daemon restarted)");
310
+ }
311
+ const e = this.entries.get(id);
312
+ if (e.state !== "offered")
313
+ throw new ProtocolError("wrong_state", `request is ${e.state}, not offered`);
314
+ if (this.now() >= e.offerExpiresAt) {
315
+ // Deterministic even if the expiry timer has not fired yet.
316
+ this.expireOffer(e);
317
+ throw new ProtocolError("offer_expired", "claim window elapsed; the request is now paused — rejoin when ready");
318
+ }
319
+ e.state = "held";
320
+ e.since = this.now();
321
+ e.offerToken = undefined;
322
+ this.offerTokens.delete(offerToken);
323
+ this.newPermit(e);
324
+ this.emit("claimed", e);
325
+ return { entry: this.ownerSnapshot(e) };
326
+ }
327
+ /** Daemon-side claim by request id (used for bounded waits). */
328
+ claimByRequestId(requestId) {
329
+ const e = this.entries.get(requestId);
330
+ if (!e)
331
+ throw new ProtocolError("not_found", "unknown request id");
332
+ if (e.state !== "offered")
333
+ throw new ProtocolError("wrong_state", `request is ${e.state}, not offered`);
334
+ return this.claim(e.offerToken);
335
+ }
336
+ // -------------------------------------------------------------- start / run
337
+ /**
338
+ * Associate a claimed permit with a real process. Atomic with respect to
339
+ * reclamation: succeeds only if the permit is held and still in a startable
340
+ * phase; afterwards the permit is protected by process tracking instead of
341
+ * the start deadline. Callers that spawn the job first must kill it if this
342
+ * call fails (see `load-lock run` / run_with_permit helpers).
343
+ */
344
+ startJob(permitToken, pid, pgid, pidStartTime) {
345
+ const e = this.heldByPermit(permitToken);
346
+ if (!e)
347
+ throw new ProtocolError("stale_token", "unknown permit token (already released or the daemon restarted without a journal entry)");
348
+ if (e.phase !== "claimed") {
349
+ throw new ProtocolError("wrong_state", `permit is ${e.phase ?? e.state}; cannot start`);
350
+ }
351
+ if (!Number.isInteger(pid) || pid <= 0)
352
+ throw new ProtocolError("invalid", "pid must be a positive integer");
353
+ e.phase = "running_tracked";
354
+ e.startDeadline = undefined;
355
+ e.pid = pid;
356
+ e.pgid = pgid;
357
+ e.pidStartTime = pidStartTime;
358
+ e.startedAt = this.now();
359
+ this.emit("started", e, { detail: `tracked pid ${pid}` });
360
+ return this.ownerSnapshot(e);
361
+ }
362
+ /**
363
+ * Explicit "I started the work, but I cannot associate a process".
364
+ * The untracked lease applies: renewed by automatic adapter heartbeats,
365
+ * reclaimed when the owning agent is gone. Documented weaker guarantee.
366
+ */
367
+ markStarted(permitToken) {
368
+ const e = this.heldByPermit(permitToken);
369
+ if (!e)
370
+ throw new ProtocolError("stale_token", "unknown permit token (already released or the daemon restarted without a journal entry)");
371
+ if (e.phase !== "claimed")
372
+ throw new ProtocolError("wrong_state", `permit is ${e.phase ?? e.state}; cannot start`);
373
+ e.phase = "running_untracked";
374
+ e.startDeadline = undefined;
375
+ e.startedAt = this.now();
376
+ e.lastSeen = this.now();
377
+ e.leaseDeadline = this.now() + this.config.untrackedLeaseMs;
378
+ this.emit("started", e, { detail: "untracked (lease applies)" });
379
+ return this.ownerSnapshot(e);
380
+ }
381
+ /** Adapter heartbeat: extends untracked leases and records agent liveness. */
382
+ heartbeat(agentId) {
383
+ const now = this.now();
384
+ for (const e of this.entries.values()) {
385
+ if (e.agentId !== agentId)
386
+ continue;
387
+ if (e.state === "held") {
388
+ e.lastSeen = now;
389
+ if (e.phase === "running_untracked")
390
+ e.leaseDeadline = now + this.config.untrackedLeaseMs;
391
+ }
392
+ }
393
+ }
394
+ // ------------------------------------------------------------------ release
395
+ /** Idempotent release: a second release returns the terminal state. */
396
+ release(permitToken) {
397
+ const id = this.permitTokens.get(permitToken);
398
+ if (!id)
399
+ throw new ProtocolError("stale_token", "unknown permit token (already released or the daemon restarted without a journal entry)");
400
+ const e = this.entries.get(id);
401
+ if (e.state === "released") {
402
+ return { entry: this.snapshot(e), releasedNow: false }; // idempotent
403
+ }
404
+ if (e.state !== "held")
405
+ throw new ProtocolError("wrong_state", `request is ${e.state}, not held`);
406
+ this.finish(e, "released");
407
+ return { entry: this.snapshot(e), releasedNow: true };
408
+ }
409
+ /**
410
+ * Daemon-internal release with positive evidence of job death (tracked).
411
+ * Never expires a permit whose process is merely unknown — callers must
412
+ * only invoke this with confirmed death of pid/pgid.
413
+ */
414
+ autoRelease(entryId, cause) {
415
+ const e = this.entries.get(entryId);
416
+ if (!e || e.state !== "held")
417
+ return;
418
+ this.finish(e, "auto_released", cause);
419
+ }
420
+ /**
421
+ * Reclaim: start deadline exceeded, untracked lease expired, or admin
422
+ * force-release. Frees capacity for the queue.
423
+ */
424
+ reclaim(e, cause) {
425
+ this.finish(e, "reclaimed", cause);
426
+ }
427
+ finish(e, eventType, detail) {
428
+ const pool = e.pool;
429
+ e.state = "released";
430
+ e.since = this.now();
431
+ this.retire(e);
432
+ this.emit(eventType, e, detail ? { detail } : undefined);
433
+ this.admit(pool);
434
+ }
435
+ // ------------------------------------------------------------------ expiry
436
+ /** Expire one offered entry (timer-driven or claim-time). */
437
+ expireOffer(e) {
438
+ e.state = "paused";
439
+ e.since = this.now();
440
+ if (e.offerToken) {
441
+ this.offerTokens.delete(e.offerToken);
442
+ this.rememberExpired(e.offerToken);
443
+ }
444
+ e.offerToken = undefined;
445
+ e.offerExpiresAt = undefined;
446
+ this.emit("offer_expired", e);
447
+ this.emit("paused", e);
448
+ this.admit(e.pool);
449
+ }
450
+ /** Nearest future deadline of any kind, or null. The daemon arms timers with this. */
451
+ nextExpiry() {
452
+ let best = null;
453
+ const consider = (t) => {
454
+ if (t !== undefined && (best === null || t < best))
455
+ best = t;
456
+ };
457
+ for (const e of this.entries.values()) {
458
+ if (e.state === "offered")
459
+ consider(e.offerExpiresAt);
460
+ if (e.state === "held") {
461
+ if (e.phase === "claimed")
462
+ consider(e.startDeadline);
463
+ if (e.phase === "running_untracked")
464
+ consider(e.leaseDeadline);
465
+ }
466
+ }
467
+ return best;
468
+ }
469
+ /** Called by the daemon at each tick: expire offers, reclaim stale permits, prune. */
470
+ tick(now) {
471
+ for (const e of [...this.entries.values()]) {
472
+ if (e.state === "offered" && e.offerExpiresAt !== undefined && now >= e.offerExpiresAt) {
473
+ this.expireOffer(e);
474
+ }
475
+ else if (e.state === "held" && e.phase === "claimed" && e.startDeadline !== undefined && now >= e.startDeadline) {
476
+ this.reclaim(e, "start deadline exceeded: permit was claimed but execution never started");
477
+ }
478
+ else if (e.state === "held" &&
479
+ e.phase === "running_untracked" &&
480
+ e.leaseDeadline !== undefined &&
481
+ now >= e.leaseDeadline) {
482
+ this.reclaim(e, "untracked lease expired: no heartbeat from the owning agent");
483
+ }
484
+ }
485
+ this.prune(now);
486
+ }
487
+ /** Drop terminal entries (and their idempotency token mappings) after 1h. */
488
+ prune(now) {
489
+ for (const e of [...this.entries.values()]) {
490
+ if ((e.state === "released" || e.state === "cancelled") && now - e.since > PRUNE_AFTER_MS) {
491
+ if (e.permitToken)
492
+ this.permitTokens.delete(e.permitToken);
493
+ if (e.offerToken)
494
+ this.offerTokens.delete(e.offerToken);
495
+ this.entries.delete(e.id);
496
+ }
497
+ }
498
+ if (this.expiredOffers.size > 500) {
499
+ for (const [tok, ts] of [...this.expiredOffers]) {
500
+ if (now - ts > 10 * 60_000)
501
+ this.expiredOffers.delete(tok);
502
+ }
503
+ }
504
+ }
505
+ rememberExpired(offerToken) {
506
+ this.expiredOffers.set(offerToken, this.now());
507
+ }
508
+ // ------------------------------------------------------------ decline/rejoin
509
+ decline(offerToken) {
510
+ const id = this.offerTokens.get(offerToken);
511
+ if (!id)
512
+ throw new ProtocolError("stale_token", "unknown offer token");
513
+ const e = this.entries.get(id);
514
+ if (e.state !== "offered")
515
+ throw new ProtocolError("wrong_state", `request is ${e.state}, not offered`);
516
+ e.state = "paused";
517
+ e.since = this.now();
518
+ if (e.offerToken) {
519
+ this.offerTokens.delete(e.offerToken);
520
+ this.rememberExpired(e.offerToken);
521
+ }
522
+ e.offerToken = undefined;
523
+ e.offerExpiresAt = undefined;
524
+ this.emit("declined", e);
525
+ this.emit("paused", e);
526
+ this.admit(e.pool);
527
+ }
528
+ rejoin(p) {
529
+ const e = this.entries.get(p.requestId);
530
+ if (!e)
531
+ throw new ProtocolError("not_found", "unknown request id");
532
+ if (e.state !== "paused")
533
+ throw new ProtocolError("wrong_state", `request is ${e.state}; only paused requests can rejoin`);
534
+ e.state = "queued";
535
+ e.since = this.now();
536
+ if (!this.queues.has(e.pool))
537
+ this.queues.set(e.pool, []);
538
+ this.queues.get(e.pool).push(e.id);
539
+ this.emit("rejoined", e);
540
+ this.emit("queued", e);
541
+ this.admit(e.pool);
542
+ return this.ownerSnapshot(e);
543
+ }
544
+ // ------------------------------------------------------------------ cancel
545
+ cancel(p) {
546
+ const e = this.entries.get(p.requestId);
547
+ if (!e)
548
+ throw new ProtocolError("not_found", "unknown request id");
549
+ if (e.state === "held") {
550
+ if (p.admin) {
551
+ this.finish(e, "reclaimed", "admin force-release");
552
+ return { entry: this.snapshot(e) };
553
+ }
554
+ throw new ProtocolError("wrong_state", "request holds a permit; release it (or admin_release via CLI) instead of cancelling");
555
+ }
556
+ if (e.state === "released" || e.state === "cancelled") {
557
+ return { entry: this.snapshot(e) }; // idempotent
558
+ }
559
+ e.state = "cancelled";
560
+ e.since = this.now();
561
+ if (e.offerToken) {
562
+ this.offerTokens.delete(e.offerToken);
563
+ this.rememberExpired(e.offerToken);
564
+ e.offerToken = undefined;
565
+ e.offerExpiresAt = undefined;
566
+ }
567
+ this.dequeue(e);
568
+ this.retire(e);
569
+ this.emit("cancelled", e);
570
+ this.admit(e.pool);
571
+ return { entry: this.snapshot(e) };
572
+ }
573
+ // ------------------------------------------------------------------ pending
574
+ pending(agentId) {
575
+ const offers = [];
576
+ const queue = [];
577
+ const paused = [];
578
+ const held = [];
579
+ for (const e of this.entries.values()) {
580
+ if (e.agentId !== agentId)
581
+ continue;
582
+ if (e.state === "offered")
583
+ offers.push(this.ownerSnapshot(e));
584
+ else if (e.state === "queued")
585
+ queue.push(this.ownerSnapshot(e));
586
+ else if (e.state === "paused")
587
+ paused.push(this.ownerSnapshot(e));
588
+ else if (e.state === "held")
589
+ held.push(this.ownerSnapshot(e));
590
+ }
591
+ offers.sort((a, b) => a.since - b.since);
592
+ return { offers, queue, paused, held };
593
+ }
594
+ /** All outstanding offers (owner-scoped snapshots) — for cwd-matched wakeup. */
595
+ allOffers() {
596
+ const out = [];
597
+ for (const e of this.entries.values())
598
+ if (e.state === "offered")
599
+ out.push(this.ownerSnapshot(e));
600
+ return out.sort((a, b) => a.since - b.since);
601
+ }
602
+ // ------------------------------------------------------------------- status
603
+ status(pool) {
604
+ const pools = [];
605
+ for (const name of pool ? [pool] : this.poolNames()) {
606
+ if (!this.config.pools[name])
607
+ throw new ProtocolError("invalid", `unknown pool "${name}"`);
608
+ const holders = [];
609
+ const offers = [];
610
+ const queue = [];
611
+ const paused = [];
612
+ const q = this.queues.get(name) ?? [];
613
+ const qIds = new Set(q);
614
+ for (const e of this.entries.values()) {
615
+ if (e.pool !== name)
616
+ continue;
617
+ if (e.state === "held")
618
+ holders.push(this.snapshot(e));
619
+ else if (e.state === "offered")
620
+ offers.push(this.snapshot(e));
621
+ else if (e.state === "queued" && qIds.has(e.id))
622
+ queue.push(this.snapshot(e));
623
+ else if (e.state === "paused")
624
+ paused.push(this.snapshot(e));
625
+ }
626
+ holders.sort((a, b) => a.since - b.since);
627
+ offers.sort((a, b) => a.since - b.since);
628
+ paused.sort((a, b) => a.since - b.since);
629
+ pools.push({ pool: name, capacity: this.capacity(name), used: this.used(name), holders, offers, queue, paused });
630
+ }
631
+ return {
632
+ daemon: { pid: process.pid, startedAt: this.startedAt, protocolVersion: 1 },
633
+ config: {
634
+ version: this.config.version,
635
+ pools: this.config.pools,
636
+ offerTimeoutMs: this.config.offerTimeoutMs,
637
+ maxWaitMs: this.config.maxWaitMs,
638
+ startTimeoutMs: this.config.startTimeoutMs,
639
+ untrackedLeaseMs: this.config.untrackedLeaseMs,
640
+ },
641
+ pools,
642
+ events: { latestEventId: this.latestEventId() },
643
+ };
644
+ }
645
+ /** Held permits — input for daemon liveness sweeps and CLI recovery. */
646
+ heldEntries() {
647
+ const out = [];
648
+ for (const e of this.entries.values())
649
+ if (e.state === "held")
650
+ out.push(this.snapshot(e));
651
+ return out;
652
+ }
653
+ entryByPermit(permitToken) {
654
+ const id = this.permitTokens.get(permitToken);
655
+ return id ? this.entries.get(id) : undefined;
656
+ }
657
+ heldByPermit(permitToken) {
658
+ const e = this.entryByPermit(permitToken);
659
+ if (e && e.state === "held")
660
+ return e;
661
+ return undefined;
662
+ }
663
+ entryById(requestId) {
664
+ const e = this.entries.get(requestId);
665
+ if (!e)
666
+ throw new ProtocolError("not_found", "unknown request id");
667
+ return e;
668
+ }
669
+ ownerSnapshotById(requestId) {
670
+ const e = this.entries.get(requestId);
671
+ return e ? this.ownerSnapshot(e) : undefined;
672
+ }
673
+ offerTokenOf(requestId) {
674
+ const e = this.entries.get(requestId);
675
+ return e?.offerToken;
676
+ }
677
+ /** Admin (CLI recovery) force-release of a held permit — reclaims capacity. */
678
+ adminRelease(requestId) {
679
+ const e = this.entries.get(requestId);
680
+ if (!e)
681
+ throw new ProtocolError("not_found", "unknown request id");
682
+ if (e.state !== "held")
683
+ throw new ProtocolError("wrong_state", `request is ${e.state}; only held permits can be force-released`);
684
+ this.finish(e, "reclaimed", "admin force-release");
685
+ }
686
+ // ------------------------------------------------------------- diagnostics
687
+ /**
688
+ * Recovery diagnostics for held permits. `liveness(pid, pgid, pidStartTime)`
689
+ * is supplied by the daemon (reads /proc). Returns holder + job info and
690
+ * which automatic cleanup applies. Long holds are flagged, never revoked
691
+ * here.
692
+ */
693
+ diagnose(liveness) {
694
+ const now = this.now();
695
+ const out = [];
696
+ for (const e of this.entries.values()) {
697
+ if (e.state !== "held")
698
+ continue;
699
+ const snap = this.snapshot(e);
700
+ const tracked = e.phase === "running_tracked";
701
+ let processAlive = null;
702
+ let note;
703
+ if (e.phase === "claimed") {
704
+ note = `claimed ${Math.round((now - e.since) / 1000)}s ago; execution must start within ${Math.round((this.config.startTimeoutMs - (now - e.since)) / 1000)}s or the permit is reclaimed`;
705
+ }
706
+ else if (tracked) {
707
+ processAlive = liveness(e);
708
+ note =
709
+ processAlive === true
710
+ ? `tracked process (pid ${e.pid}${e.pgid ? `, pgid ${e.pgid}` : ""}) is running; released automatically when it exits; never revoked on duration alone`
711
+ : processAlive === false
712
+ ? `tracked process appears dead; daemon will release on next sweep`
713
+ : `tracked liveness unknown on this platform; permit surfaces here until force-released`;
714
+ }
715
+ else {
716
+ const leaseLeft = e.leaseDeadline ? Math.round((e.leaseDeadline - now) / 1000) : 0;
717
+ note = `untracked; heartbeat lease ${leaseLeft}s remaining — reclaimed when the owning agent stops heartbeating (overlap with an unobserved survivor is possible; see docs)`;
718
+ }
719
+ out.push({
720
+ entry: snap,
721
+ diagnostics: {
722
+ tracked,
723
+ processAlive,
724
+ phaseAgeMs: now - e.since,
725
+ lastSeenAgeMs: e.lastSeen ? now - e.lastSeen : undefined,
726
+ longHold: now - e.since > LONG_HOLD_MS,
727
+ note,
728
+ },
729
+ });
730
+ }
731
+ return out;
732
+ }
733
+ // ------------------------------------------------------------------- config
734
+ get_config() {
735
+ return {
736
+ config: JSON.parse(JSON.stringify(this.config)),
737
+ history: [...this.history],
738
+ };
739
+ }
740
+ configure(p) {
741
+ if (p.expectedVersion !== this.config.version) {
742
+ throw new ProtocolError("version_conflict", `config version is ${this.config.version}, not ${p.expectedVersion}; re-read and retry`, { currentVersion: this.config.version });
743
+ }
744
+ const c = p.changes;
745
+ if (c.pools) {
746
+ for (const [name, pc] of Object.entries(c.pools)) {
747
+ if (!Number.isInteger(pc.capacity) || pc.capacity < 0) {
748
+ throw new ProtocolError("invalid", `pool "${name}": capacity must be a non-negative integer`);
749
+ }
750
+ }
751
+ }
752
+ if (c.offerTimeoutMs !== undefined && (c.offerTimeoutMs < 1000 || c.offerTimeoutMs > 600_000)) {
753
+ throw new ProtocolError("invalid", "offerTimeoutMs must be within [1000, 600000]");
754
+ }
755
+ if (c.maxWaitMs !== undefined && (c.maxWaitMs < 0 || c.maxWaitMs > 600_000)) {
756
+ throw new ProtocolError("invalid", "maxWaitMs must be within [0, 600000]");
757
+ }
758
+ if (c.startTimeoutMs !== undefined && (c.startTimeoutMs < 1000 || c.startTimeoutMs > 3_600_000)) {
759
+ throw new ProtocolError("invalid", "startTimeoutMs must be within [1000, 3600000]");
760
+ }
761
+ if (c.untrackedLeaseMs !== undefined && (c.untrackedLeaseMs < 10_000 || c.untrackedLeaseMs > 24 * 3_600_000)) {
762
+ throw new ProtocolError("invalid", "untrackedLeaseMs must be within [10000, 86400000]");
763
+ }
764
+ this.config.version += 1;
765
+ if (c.pools) {
766
+ for (const [name, pc] of Object.entries(c.pools))
767
+ this.config.pools[name] = { capacity: pc.capacity };
768
+ }
769
+ if (c.offerTimeoutMs !== undefined)
770
+ this.config.offerTimeoutMs = c.offerTimeoutMs;
771
+ if (c.maxWaitMs !== undefined)
772
+ this.config.maxWaitMs = c.maxWaitMs;
773
+ if (c.startTimeoutMs !== undefined)
774
+ this.config.startTimeoutMs = c.startTimeoutMs;
775
+ if (c.untrackedLeaseMs !== undefined)
776
+ this.config.untrackedLeaseMs = c.untrackedLeaseMs;
777
+ this.config.guidance = this.config.guidance ?? {};
778
+ if (c.guidance) {
779
+ if (c.guidance.heavyWorkHeuristic !== undefined)
780
+ this.config.guidance.heavyWorkHeuristic = c.guidance.heavyWorkHeuristic;
781
+ if (c.guidance.notes !== undefined)
782
+ this.config.guidance.notes = c.guidance.notes;
783
+ if (c.guidance.agent) {
784
+ const a = c.guidance.agent;
785
+ this.config.guidance.agents = this.config.guidance.agents ?? {};
786
+ const prev = this.config.guidance.agents[a.id] ?? {};
787
+ this.config.guidance.agents[a.id] = {
788
+ parallelism: a.parallelism ?? prev.parallelism,
789
+ note: a.note ?? prev.note,
790
+ updatedBy: p.author,
791
+ updatedAt: this.now(),
792
+ };
793
+ }
794
+ }
795
+ this.history.push({
796
+ ts: this.now(),
797
+ author: p.author,
798
+ reason: p.reason,
799
+ changes: JSON.parse(JSON.stringify(c)),
800
+ resultingVersion: this.config.version,
801
+ });
802
+ if (this.history.length > MAX_HISTORY)
803
+ this.history.splice(0, this.history.length - MAX_HISTORY);
804
+ this.pushEvent({
805
+ type: "config_changed",
806
+ targetAgent: "*",
807
+ detail: `by ${p.author}: ${p.reason}`,
808
+ });
809
+ // Capacity increase: admit queued immediately. Decrease: holders keep
810
+ // running; admissions wait until usage drops below the new capacity.
811
+ for (const name of this.poolNames())
812
+ this.admit(name);
813
+ return this.get_config();
814
+ }
815
+ history = [];
816
+ // ----------------------------------------------------------------- journal
817
+ /** Held permits to persist for conservative restart reconciliation. */
818
+ journalEntries() {
819
+ const out = [];
820
+ for (const e of this.entries.values()) {
821
+ if (e.state !== "held")
822
+ continue;
823
+ out.push({
824
+ requestId: e.id,
825
+ pool: e.pool,
826
+ agentId: e.agentId,
827
+ agentLabel: e.agentLabel,
828
+ cwd: e.cwd,
829
+ opKey: e.opKey,
830
+ reason: e.reason,
831
+ weight: e.weight,
832
+ permitToken: e.permitToken,
833
+ phase: e.phase ?? "claimed",
834
+ pid: e.pid,
835
+ pgid: e.pgid,
836
+ pidStartTime: e.pidStartTime,
837
+ claimedAt: e.since,
838
+ startedAt: e.startedAt,
839
+ leaseDeadline: e.leaseDeadline,
840
+ });
841
+ }
842
+ return out;
843
+ }
844
+ /**
845
+ * Restore held permits after a daemon restart (conservative reconciliation):
846
+ * - tracked: re-check liveness via the supplied probe; dead => release,
847
+ * alive => keep (same token, so a late explicit release still works).
848
+ * - claimed: keep the start deadline (reclaimed by tick if already past).
849
+ * - untracked: keep, with a FRESH lease window from restore time.
850
+ * Queued/offered requests are deliberately NOT restored — queued agents
851
+ * re-acquire (documented restart behavior).
852
+ */
853
+ restore(journal, liveness) {
854
+ let kept = 0;
855
+ let releasedDead = 0;
856
+ for (const j of journal) {
857
+ const e = {
858
+ id: j.requestId,
859
+ pool: j.pool,
860
+ agentId: j.agentId,
861
+ agentLabel: j.agentLabel,
862
+ cwd: j.cwd,
863
+ opKey: j.opKey,
864
+ reason: j.reason,
865
+ weight: j.weight,
866
+ state: "held",
867
+ since: j.claimedAt,
868
+ permitToken: j.permitToken,
869
+ phase: j.phase,
870
+ pid: j.pid,
871
+ pgid: j.pgid,
872
+ pidStartTime: j.pidStartTime,
873
+ startedAt: j.startedAt,
874
+ lastSeen: this.now(),
875
+ };
876
+ if (j.phase === "claimed") {
877
+ e.startDeadline = j.claimedAt + this.config.startTimeoutMs;
878
+ }
879
+ else if (j.phase === "running_untracked") {
880
+ e.leaseDeadline = this.now() + this.config.untrackedLeaseMs; // fresh window
881
+ }
882
+ else if (j.phase === "running_tracked") {
883
+ const alive = liveness(e);
884
+ if (alive === false) {
885
+ // Confirmed dead while we were down: release, but keep a terminal
886
+ // entry + token mapping so a late/duplicate release is idempotent.
887
+ e.state = "released";
888
+ e.since = this.now();
889
+ this.entries.set(e.id, e);
890
+ if (e.permitToken)
891
+ this.permitTokens.set(e.permitToken, e.id);
892
+ releasedDead++;
893
+ continue;
894
+ }
895
+ }
896
+ this.entries.set(e.id, e);
897
+ if (e.permitToken)
898
+ this.permitTokens.set(e.permitToken, e.id);
899
+ this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
900
+ kept++;
901
+ }
902
+ this.pushEvent({
903
+ type: "daemon_recovered",
904
+ targetAgent: "*",
905
+ detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released)`,
906
+ });
907
+ return { kept, releasedDead };
908
+ }
909
+ }