@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.
- package/LICENSE +21 -0
- package/README.md +115 -2
- package/demo/demo.sh +112 -0
- package/demo/sim-agent.mjs +149 -0
- package/dist/adapters/opencode/plugin.d.ts +18 -0
- package/dist/adapters/opencode/plugin.js +301 -0
- package/dist/cli/hook.d.ts +1 -0
- package/dist/cli/hook.js +141 -0
- package/dist/cli/install.d.ts +14 -0
- package/dist/cli/install.js +377 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +461 -0
- package/dist/core/daemon-main.d.ts +2 -0
- package/dist/core/daemon-main.js +27 -0
- package/dist/core/daemon.d.ts +79 -0
- package/dist/core/daemon.js +526 -0
- package/dist/core/persist.d.ts +11 -0
- package/dist/core/persist.js +66 -0
- package/dist/core/state.d.ts +203 -0
- package/dist/core/state.js +909 -0
- package/dist/core/sysinfo.d.ts +2 -0
- package/dist/core/sysinfo.js +123 -0
- package/dist/mcp/main.d.ts +2 -0
- package/dist/mcp/main.js +4 -0
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.js +352 -0
- package/dist/policy/text.d.ts +30 -0
- package/dist/policy/text.js +58 -0
- package/dist/shared/client.d.ts +30 -0
- package/dist/shared/client.js +227 -0
- package/dist/shared/protocol.d.ts +333 -0
- package/dist/shared/protocol.js +22 -0
- package/dist/test/core.test.d.ts +1 -0
- package/dist/test/core.test.js +281 -0
- package/dist/test/daemon.test.d.ts +1 -0
- package/dist/test/daemon.test.js +384 -0
- package/dist/test/hook.test.d.ts +1 -0
- package/dist/test/hook.test.js +126 -0
- package/dist/test/mcp.test.d.ts +1 -0
- package/dist/test/mcp.test.js +139 -0
- package/dist/test/run-helper.test.d.ts +1 -0
- package/dist/test/run-helper.test.js +84 -0
- package/dist/test/testutil.d.ts +27 -0
- package/dist/test/testutil.js +111 -0
- package/docs/ADAPTERS.md +118 -0
- package/docs/AGENT_GUIDE.md +142 -0
- package/docs/ARCHITECTURE.md +195 -0
- package/docs/COMPATIBILITY.md +116 -0
- package/docs/OPERATIONS.md +126 -0
- package/package.json +102 -4
- 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
|
+
}
|