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