@aron98/load-lock 0.1.2 → 1.0.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/README.md +37 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +31 -0
- package/dist/adapters/opencode/tui.js +268 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +708 -24
- package/dist/cli/main.js +36 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +98 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +12 -0
- package/dist/dashboard/frame.js +94 -0
- package/dist/dashboard/view.d.ts +148 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +405 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +33 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +215 -26
- package/docs/OPERATIONS.md +313 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
package/dist/cli/main.js
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
-
import { homedir } from "node:os";
|
|
11
10
|
import { join } from "node:path";
|
|
12
11
|
import { LLClient, withClient } from "../shared/client.js";
|
|
13
12
|
import { ProtocolError } from "../shared/protocol.js";
|
|
13
|
+
import { waitForTurn } from "../shared/wait-turn.js";
|
|
14
|
+
import { stateDir, socketPath } from "../core/persist.js";
|
|
15
|
+
import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
|
|
14
16
|
import { installCommand, uninstallCommand } from "./install.js";
|
|
15
17
|
import { hookCommand } from "./hook.js";
|
|
18
|
+
import { topCommand } from "./top.js";
|
|
16
19
|
// ---------------------------------------------------------------- arg parsing
|
|
17
20
|
const argv = process.argv.slice(2);
|
|
18
21
|
let flags = {};
|
|
@@ -81,6 +84,13 @@ async function main() {
|
|
|
81
84
|
});
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
87
|
+
// ------------------------------------------------------------- dashboard
|
|
88
|
+
case "top": {
|
|
89
|
+
// Full-screen live dashboard (read-only, never acquires a permit).
|
|
90
|
+
// Falls back to a single rendered frame when stdout is not a TTY.
|
|
91
|
+
await topCommand({ once: B("once") });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
84
94
|
case "inspect":
|
|
85
95
|
case "machine":
|
|
86
96
|
out(await withClient((c) => c.request("inspect", {})));
|
|
@@ -108,6 +118,8 @@ async function main() {
|
|
|
108
118
|
changes.offerTimeoutMs = N("offer-timeout-ms", 0);
|
|
109
119
|
if (S("max-wait-ms"))
|
|
110
120
|
changes.maxWaitMs = N("max-wait-ms", 0);
|
|
121
|
+
if (S("max-turn-wait-ms"))
|
|
122
|
+
changes.maxTurnWaitMs = N("max-turn-wait-ms", 0);
|
|
111
123
|
if (S("start-timeout-ms"))
|
|
112
124
|
changes.startTimeoutMs = N("start-timeout-ms", 0);
|
|
113
125
|
if (S("untracked-lease-ms"))
|
|
@@ -115,7 +127,7 @@ async function main() {
|
|
|
115
127
|
if (S("heavy-work-heuristic"))
|
|
116
128
|
changes.guidance = { heavyWorkHeuristic: S("heavy-work-heuristic") };
|
|
117
129
|
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)");
|
|
130
|
+
fail("configure: no changes given (--capacity, --offer-timeout-ms, --max-wait-ms, --max-turn-wait-ms, --start-timeout-ms, --untracked-lease-ms, --heavy-work-heuristic)");
|
|
119
131
|
out(await c.request("configure", { expectedVersion: expected, changes, reason, author }));
|
|
120
132
|
});
|
|
121
133
|
return;
|
|
@@ -196,55 +208,24 @@ async function main() {
|
|
|
196
208
|
case "wait": {
|
|
197
209
|
// Watches a queued request; auto-claims the offer when capacity is
|
|
198
210
|
// handed to it and prints the permit token. Honest bounded blocking
|
|
199
|
-
// for scriptable flows (aider, plain shell scripts).
|
|
211
|
+
// for scriptable flows (aider, plain shell scripts). Shares the exact
|
|
212
|
+
// implementation with the MCP wait_for_turn tool and the OpenCode
|
|
213
|
+
// plugin (src/shared/wait-turn.ts).
|
|
200
214
|
const id = S("id") || fail("wait: --id <request-id> required");
|
|
201
215
|
const timeoutMs = N("timeout", 600_000);
|
|
202
216
|
const agent = S("agent", defaultAgent());
|
|
203
217
|
const c = new LLClient();
|
|
204
218
|
await c.connect();
|
|
205
219
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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;
|
|
220
|
+
const r = await waitForTurn({ client: c, agentId: agent, requestId: id, timeoutMs });
|
|
221
|
+
if (r.state === "granted" && r.entry?.permitToken) {
|
|
222
|
+
out({ permit_token: r.entry.permitToken, state: r.state, request_id: r.requestId, auto_claimed: r.autoClaimed ?? false });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
out({ state: r.state, request_id: r.requestId, detail: r.detail ?? r.nextAction });
|
|
226
|
+
if (r.state === "still_queued")
|
|
227
|
+
fail(`wait: still queued (position ${r.queuePosition ?? "?"}) after ${r.waitedMs}ms`, 68);
|
|
228
|
+
fail(`wait: request is ${r.state.replace("_", " ")} — ${r.nextAction}`, 69);
|
|
248
229
|
}
|
|
249
230
|
finally {
|
|
250
231
|
c.close();
|
|
@@ -264,7 +245,7 @@ async function main() {
|
|
|
264
245
|
const sub = rest[0] ?? "status";
|
|
265
246
|
if (sub === "stop") {
|
|
266
247
|
// Connect and ask... simplest: SIGTERM via daemon.json pid.
|
|
267
|
-
const dir =
|
|
248
|
+
const dir = stateDir();
|
|
268
249
|
try {
|
|
269
250
|
const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
270
251
|
process.kill(info.pid, "SIGTERM");
|
|
@@ -291,9 +272,13 @@ async function main() {
|
|
|
291
272
|
const report = {};
|
|
292
273
|
report.node = process.version;
|
|
293
274
|
report.platform = process.platform;
|
|
294
|
-
const dir =
|
|
275
|
+
const dir = stateDir();
|
|
295
276
|
report.stateDir = dir;
|
|
296
|
-
|
|
277
|
+
const sock = socketPath();
|
|
278
|
+
report.socket = sock;
|
|
279
|
+
// Named pipes are kernel objects, not filesystem entries — probe by
|
|
280
|
+
// connecting on Windows; on POSIX a file check is enough.
|
|
281
|
+
report.socketExists = isWindows() ? await probeTransport(sock) : existsSync(sock);
|
|
297
282
|
report.configExists = existsSync(join(dir, "config.json"));
|
|
298
283
|
report.journalExists = existsSync(join(dir, "journal.json"));
|
|
299
284
|
try {
|
|
@@ -356,7 +341,7 @@ async function main() {
|
|
|
356
341
|
return;
|
|
357
342
|
}
|
|
358
343
|
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`);
|
|
344
|
+
fail(`unknown command "${cmd}". Commands: status, top, inspect, config, pending, events, acquire, claim, release, cancel, rejoin, mark-started, attach-pid, wait, run, service, doctor, recover, install, uninstall, hook, agent-guide`);
|
|
360
345
|
}
|
|
361
346
|
}
|
|
362
347
|
// ------------------------------------------------------------------ run helper
|
|
@@ -416,21 +401,11 @@ async function runWithPermit(jobArgv) {
|
|
|
416
401
|
await client.request("start_job", { permitToken, pid: child.pid, pgid });
|
|
417
402
|
}
|
|
418
403
|
catch (err) {
|
|
419
|
-
|
|
420
|
-
process.kill(-pgid, "SIGKILL");
|
|
421
|
-
}
|
|
422
|
-
catch {
|
|
423
|
-
/* already gone */
|
|
424
|
-
}
|
|
404
|
+
killProcessGroup(pgid, "SIGKILL");
|
|
425
405
|
fail(`permit no longer startable (${err.message}); job not started`, 69);
|
|
426
406
|
}
|
|
427
407
|
const forward = (sig) => {
|
|
428
|
-
|
|
429
|
-
process.kill(-pgid, sig);
|
|
430
|
-
}
|
|
431
|
-
catch {
|
|
432
|
-
/* already gone */
|
|
433
|
-
}
|
|
408
|
+
killProcessGroup(pgid, sig);
|
|
434
409
|
};
|
|
435
410
|
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
436
411
|
process.on("SIGINT", () => forward("SIGINT"));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface TopOptions {
|
|
2
|
+
/** Render one frame and exit (also implied when stdout is not a TTY). */
|
|
3
|
+
once?: boolean;
|
|
4
|
+
/** Local re-render cadence for countdowns (default 500 ms). */
|
|
5
|
+
tickMs?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function topCommand(opts?: TopOptions): Promise<void>;
|
package/dist/cli/top.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `load-lock top` — portable, zero-dependency full-screen terminal dashboard
|
|
3
|
+
* for the machine-wide budget. Uses the library's own client + protocol
|
|
4
|
+
* (never scrapes CLI output); live updates come from the `watch` event
|
|
5
|
+
* stream, with reconnect + event-cursor resume across daemon restarts.
|
|
6
|
+
*
|
|
7
|
+
* Read-only. The viewer itself is light work — it never acquires a permit.
|
|
8
|
+
*/
|
|
9
|
+
import { DashboardFeed } from "../dashboard/feed.js";
|
|
10
|
+
import { renderFrame } from "../dashboard/frame.js";
|
|
11
|
+
import { LLClient } from "../shared/client.js";
|
|
12
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
13
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
14
|
+
const ALT_SCREEN = "\x1b[?1049h";
|
|
15
|
+
const LEAVE_ALT = "\x1b[?1049l";
|
|
16
|
+
export async function topCommand(opts = {}) {
|
|
17
|
+
const once = opts.once || !process.stdout.isTTY;
|
|
18
|
+
const client = new LLClient();
|
|
19
|
+
const feed = new DashboardFeed(client);
|
|
20
|
+
try {
|
|
21
|
+
await feed.start();
|
|
22
|
+
if (once) {
|
|
23
|
+
const frame = renderFrame(feed.view(), process.stdout.columns || 100, 1000);
|
|
24
|
+
process.stdout.write(frame.join("\n") + "\n");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
await runLive(feed, opts);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
await feed.stop();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function runLive(feed, opts) {
|
|
34
|
+
const out = process.stdout;
|
|
35
|
+
const tickMs = Math.max(100, opts.tickMs ?? 500);
|
|
36
|
+
let stopped = false;
|
|
37
|
+
const paint = () => {
|
|
38
|
+
const width = out.columns || 100;
|
|
39
|
+
const height = out.rows || 40;
|
|
40
|
+
const frame = renderFrame(feed.view(), width, Math.max(1, height - 1));
|
|
41
|
+
// Pad to full height so old content never lingers after shrinking.
|
|
42
|
+
while (frame.length < height - 1)
|
|
43
|
+
frame.push("");
|
|
44
|
+
const body = frame.map((l) => l + "\x1b[K").join("\n");
|
|
45
|
+
out.write(`\x1b[H\x1b[2J${body}\n`);
|
|
46
|
+
};
|
|
47
|
+
// The feed signals change asynchronously; the tick loop below repaints on
|
|
48
|
+
// every tick anyway (cheap string render) so new events and countdowns
|
|
49
|
+
// both appear promptly without a paint storm per event burst.
|
|
50
|
+
out.write(ALT_SCREEN + HIDE_CURSOR);
|
|
51
|
+
out.write("\x1b[H\x1b[2J");
|
|
52
|
+
process.stdin.setRawMode?.(true);
|
|
53
|
+
process.stdin.resume();
|
|
54
|
+
const restore = () => {
|
|
55
|
+
process.stdin.setRawMode?.(false);
|
|
56
|
+
out.write(SHOW_CURSOR + LEAVE_ALT);
|
|
57
|
+
};
|
|
58
|
+
let exiting = false;
|
|
59
|
+
const quit = () => {
|
|
60
|
+
if (exiting)
|
|
61
|
+
return;
|
|
62
|
+
exiting = true;
|
|
63
|
+
restore();
|
|
64
|
+
stopped = true;
|
|
65
|
+
process.stdin.pause();
|
|
66
|
+
};
|
|
67
|
+
const onKey = (data) => {
|
|
68
|
+
const k = data.toString("utf8");
|
|
69
|
+
if (k === "q" || k === "Q" || k === "\x1b" || k === "\x03")
|
|
70
|
+
quit();
|
|
71
|
+
else if (k === "r" || k === "R")
|
|
72
|
+
void feed.resync().catch(() => { });
|
|
73
|
+
};
|
|
74
|
+
process.stdin.on("data", onKey);
|
|
75
|
+
const onResize = () => paint();
|
|
76
|
+
out.on("resize", onResize);
|
|
77
|
+
const onSignal = () => {
|
|
78
|
+
quit();
|
|
79
|
+
};
|
|
80
|
+
process.on("SIGINT", onSignal);
|
|
81
|
+
process.on("SIGTERM", onSignal);
|
|
82
|
+
paint();
|
|
83
|
+
try {
|
|
84
|
+
while (!stopped) {
|
|
85
|
+
await new Promise((r) => setTimeout(r, tickMs));
|
|
86
|
+
if (stopped)
|
|
87
|
+
continue;
|
|
88
|
+
paint(); // cheap local re-render keeps countdowns ticking and shows new events
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
restore();
|
|
93
|
+
process.stdin.removeListener("data", onKey);
|
|
94
|
+
out.removeListener("resize", onResize);
|
|
95
|
+
process.removeListener("SIGINT", onSignal);
|
|
96
|
+
process.removeListener("SIGTERM", onSignal);
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export declare class Daemon {
|
|
|
22
22
|
machine: Machine;
|
|
23
23
|
server: Server | null;
|
|
24
24
|
private watchers;
|
|
25
|
+
/** All open client connections — destroyed on stop() so a graceful
|
|
26
|
+
* shutdown cuts waiting clients loose (they reconnect + detect the
|
|
27
|
+
* restart via the event cursor) and the process can exit promptly. */
|
|
28
|
+
private conns;
|
|
25
29
|
private waiters;
|
|
26
30
|
private sentEventId;
|
|
27
31
|
private stateDirPath;
|
|
@@ -36,7 +40,11 @@ export declare class Daemon {
|
|
|
36
40
|
recover?: boolean;
|
|
37
41
|
});
|
|
38
42
|
start(): Promise<void>;
|
|
39
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Bind the endpoint, replacing it only if it is verifiably stale.
|
|
45
|
+
* Windows: named pipes are kernel objects — no stale file can exist, so
|
|
46
|
+
* just refuse if another daemon owns the pipe.
|
|
47
|
+
*/
|
|
40
48
|
private claimSocket;
|
|
41
49
|
stop(): void;
|
|
42
50
|
private onConnection;
|
|
@@ -65,15 +73,28 @@ export declare class Daemon {
|
|
|
65
73
|
*/
|
|
66
74
|
private sweep;
|
|
67
75
|
}
|
|
68
|
-
/**
|
|
69
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Liveness via signal 0 — the /proc-less fallback (macOS, Windows).
|
|
78
|
+
* EPERM counts as alive: the process exists but is not signalable by us.
|
|
79
|
+
*/
|
|
80
|
+
export declare function aliveBySignalZero(pid: number): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse.
|
|
83
|
+
* Windows: no /proc and no start time — signal-0 fallback only; the
|
|
84
|
+
* pid-reuse defense is unavailable there (documented limitation).
|
|
85
|
+
* macOS: /proc is absent, so the signal-0 fallback applies too.
|
|
86
|
+
*/
|
|
87
|
+
export declare function isProcessAlive(pid: number, expectedStartTime?: string, platform?: NodeJS.Platform): boolean | null;
|
|
70
88
|
/**
|
|
71
89
|
* Job liveness: a tracked job is alive if its pid is alive (start-time
|
|
72
90
|
* matched) OR any process in its process group is alive — covering
|
|
73
91
|
* subprocesses and background jobs that outlive their parent. Returns
|
|
74
92
|
* true/false on Linux; on platforms without /proc the pgid scan is
|
|
75
93
|
* unavailable and the result may be null (unknown => conservative keep).
|
|
94
|
+
* Windows: no process groups — only the main pid is checked, so a permit
|
|
95
|
+
* is released once the main pid dies even if children survive (documented
|
|
96
|
+
* limitation; adapters should kill the tree via taskkill /T).
|
|
76
97
|
*/
|
|
77
|
-
export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string): boolean | null;
|
|
98
|
+
export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string, platform?: NodeJS.Platform): boolean | null;
|
|
78
99
|
/** Read the Linux start time of a pid (pid-reuse-safe association). */
|
|
79
100
|
export declare function processStartTime(pid: number): string | undefined;
|
package/dist/core/daemon.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* All state mutations run on one event loop => every RPC is atomic wrt all
|
|
17
17
|
* others; start_job is therefore atomic wrt reclamation.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import { createServer } from "node:net";
|
|
20
20
|
import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
21
21
|
import { readdirSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
@@ -24,10 +24,15 @@ import { Machine } from "./state.js";
|
|
|
24
24
|
import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
|
|
25
25
|
import { inspectMachine } from "./sysinfo.js";
|
|
26
26
|
import { ProtocolError } from "../shared/protocol.js";
|
|
27
|
+
import { isWindows, probeTransport } from "../shared/transport.js";
|
|
27
28
|
export class Daemon {
|
|
28
29
|
machine;
|
|
29
30
|
server = null;
|
|
30
31
|
watchers = new Set();
|
|
32
|
+
/** All open client connections — destroyed on stop() so a graceful
|
|
33
|
+
* shutdown cuts waiting clients loose (they reconnect + detect the
|
|
34
|
+
* restart via the event cursor) and the process can exit promptly. */
|
|
35
|
+
conns = new Set();
|
|
31
36
|
waiters = new Map();
|
|
32
37
|
sentEventId;
|
|
33
38
|
stateDirPath;
|
|
@@ -70,10 +75,20 @@ export class Daemon {
|
|
|
70
75
|
this.sweepTimer.unref?.();
|
|
71
76
|
this.sweep(); // catch anything that expired while we were down
|
|
72
77
|
}
|
|
73
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Bind the endpoint, replacing it only if it is verifiably stale.
|
|
80
|
+
* Windows: named pipes are kernel objects — no stale file can exist, so
|
|
81
|
+
* just refuse if another daemon owns the pipe.
|
|
82
|
+
*/
|
|
74
83
|
async claimSocket() {
|
|
84
|
+
if (isWindows()) {
|
|
85
|
+
if (await probeTransport(this.socketPath)) {
|
|
86
|
+
throw new ProtocolError("denied", `another daemon is already listening on ${this.socketPath}`);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
75
90
|
if (existsSync(this.socketPath)) {
|
|
76
|
-
const live = await
|
|
91
|
+
const live = await probeTransport(this.socketPath);
|
|
77
92
|
if (live) {
|
|
78
93
|
throw new ProtocolError("denied", `another daemon is already listening on ${this.socketPath}`);
|
|
79
94
|
}
|
|
@@ -96,13 +111,23 @@ export class Daemon {
|
|
|
96
111
|
for (const w of this.watchers)
|
|
97
112
|
w.socket.destroy();
|
|
98
113
|
this.watchers.clear();
|
|
114
|
+
// Cut every client loose too: a graceful stop must not leave a zombie
|
|
115
|
+
// serving already-connected clients while the socket path moves on.
|
|
116
|
+
// Clients see the disconnect, reconnect, and detect the restart.
|
|
117
|
+
for (const s of this.conns)
|
|
118
|
+
s.destroy();
|
|
119
|
+
this.conns.clear();
|
|
99
120
|
this.server?.close();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
if (!isWindows()) {
|
|
122
|
+
// Windows: the named pipe is a kernel object and vanishes on close;
|
|
123
|
+
// touching the pipe path from the filesystem side can block.
|
|
124
|
+
try {
|
|
125
|
+
if (existsSync(this.socketPath))
|
|
126
|
+
unlinkSync(this.socketPath);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* ignore */
|
|
130
|
+
}
|
|
106
131
|
}
|
|
107
132
|
try {
|
|
108
133
|
rmSync(join(this.stateDirPath, "daemon.json"), { force: true });
|
|
@@ -113,6 +138,7 @@ export class Daemon {
|
|
|
113
138
|
}
|
|
114
139
|
// ----------------------------------------------------------------- framing
|
|
115
140
|
onConnection(sock) {
|
|
141
|
+
this.conns.add(sock);
|
|
116
142
|
let buf = "";
|
|
117
143
|
sock.on("data", (chunk) => {
|
|
118
144
|
buf += chunk.toString("utf8");
|
|
@@ -126,6 +152,7 @@ export class Daemon {
|
|
|
126
152
|
});
|
|
127
153
|
sock.on("error", () => sock.destroy());
|
|
128
154
|
sock.on("close", () => {
|
|
155
|
+
this.conns.delete(sock);
|
|
129
156
|
for (const w of [...this.watchers])
|
|
130
157
|
if (w.socket === sock)
|
|
131
158
|
this.watchers.delete(w);
|
|
@@ -428,18 +455,31 @@ export class Daemon {
|
|
|
428
455
|
}
|
|
429
456
|
}
|
|
430
457
|
// --------------------------------------------------------------------- helpers
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
458
|
+
/**
|
|
459
|
+
* Liveness via signal 0 — the /proc-less fallback (macOS, Windows).
|
|
460
|
+
* EPERM counts as alive: the process exists but is not signalable by us.
|
|
461
|
+
*/
|
|
462
|
+
export function aliveBySignalZero(pid) {
|
|
463
|
+
try {
|
|
464
|
+
process.kill(pid, 0);
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
return err?.code === "EPERM";
|
|
469
|
+
}
|
|
440
470
|
}
|
|
441
|
-
/**
|
|
442
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse.
|
|
473
|
+
* Windows: no /proc and no start time — signal-0 fallback only; the
|
|
474
|
+
* pid-reuse defense is unavailable there (documented limitation).
|
|
475
|
+
* macOS: /proc is absent, so the signal-0 fallback applies too.
|
|
476
|
+
*/
|
|
477
|
+
export function isProcessAlive(pid, expectedStartTime, platform = process.platform) {
|
|
478
|
+
if (isWindows(platform)) {
|
|
479
|
+
// Start time is unobtainable without native APIs; pid reuse is possible
|
|
480
|
+
// during this window (documented in docs/OPERATIONS.md).
|
|
481
|
+
return aliveBySignalZero(pid);
|
|
482
|
+
}
|
|
443
483
|
try {
|
|
444
484
|
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
445
485
|
if (expectedStartTime !== undefined) {
|
|
@@ -455,13 +495,7 @@ export function isProcessAlive(pid, expectedStartTime) {
|
|
|
455
495
|
// /proc unavailable (macOS): fall back to signal 0. Pid reuse is possible
|
|
456
496
|
// here; documented limitation — that is why adapters should prefer
|
|
457
497
|
// run_with_permit / process-group tracking where available.
|
|
458
|
-
|
|
459
|
-
process.kill(pid, 0);
|
|
460
|
-
return true;
|
|
461
|
-
}
|
|
462
|
-
catch {
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
498
|
+
return aliveBySignalZero(pid);
|
|
465
499
|
}
|
|
466
500
|
}
|
|
467
501
|
/**
|
|
@@ -470,14 +504,17 @@ export function isProcessAlive(pid, expectedStartTime) {
|
|
|
470
504
|
* subprocesses and background jobs that outlive their parent. Returns
|
|
471
505
|
* true/false on Linux; on platforms without /proc the pgid scan is
|
|
472
506
|
* unavailable and the result may be null (unknown => conservative keep).
|
|
507
|
+
* Windows: no process groups — only the main pid is checked, so a permit
|
|
508
|
+
* is released once the main pid dies even if children survive (documented
|
|
509
|
+
* limitation; adapters should kill the tree via taskkill /T).
|
|
473
510
|
*/
|
|
474
|
-
export function jobAlive(pid, pgid, pidStartTime) {
|
|
511
|
+
export function jobAlive(pid, pgid, pidStartTime, platform = process.platform) {
|
|
475
512
|
if (pid !== undefined) {
|
|
476
|
-
const alive = isProcessAlive(pid, pidStartTime);
|
|
513
|
+
const alive = isProcessAlive(pid, pidStartTime, platform);
|
|
477
514
|
if (alive === true)
|
|
478
515
|
return true;
|
|
479
516
|
}
|
|
480
|
-
if (pgid !== undefined) {
|
|
517
|
+
if (pgid !== undefined && !isWindows(platform)) {
|
|
481
518
|
const scan = scanProcessGroup(pgid);
|
|
482
519
|
if (scan !== null)
|
|
483
520
|
return scan;
|
package/dist/core/persist.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Atomic JSON persistence for daemon config + change history.
|
|
3
|
-
* State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock
|
|
3
|
+
* State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock;
|
|
4
|
+
* Windows: %LOCALAPPDATA%\load-lock — see shared/transport.ts).
|
|
4
5
|
*
|
|
5
6
|
* Only *configuration* persists across daemon restarts. Permits and queue
|
|
6
7
|
* are ephemeral by design (see docs/ARCHITECTURE.md — restart behavior):
|
|
@@ -9,20 +10,12 @@
|
|
|
9
10
|
import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from "node:fs";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
12
|
import { defaultConfig } from "./state.js";
|
|
13
|
+
import { resolveStateDir, resolveSocketPath } from "../shared/transport.js";
|
|
12
14
|
export function stateDir(override) {
|
|
13
|
-
|
|
14
|
-
return override;
|
|
15
|
-
if (process.env.LOAD_LOCK_STATE_DIR)
|
|
16
|
-
return process.env.LOAD_LOCK_STATE_DIR;
|
|
17
|
-
const xdg = process.env.XDG_STATE_HOME || join(process.env.HOME ?? "~", ".local", "state");
|
|
18
|
-
return join(xdg, "load-lock");
|
|
15
|
+
return resolveStateDir(process.platform, process.env, override);
|
|
19
16
|
}
|
|
20
17
|
export function socketPath(override) {
|
|
21
|
-
|
|
22
|
-
return override;
|
|
23
|
-
if (process.env.LOAD_LOCK_SOCKET)
|
|
24
|
-
return process.env.LOAD_LOCK_SOCKET;
|
|
25
|
-
return join(stateDir(), "daemon.sock");
|
|
18
|
+
return resolveSocketPath(process.platform, process.env, override);
|
|
26
19
|
}
|
|
27
20
|
export function loadState(dir) {
|
|
28
21
|
const path = join(dir, "config.json");
|
package/dist/core/state.js
CHANGED
|
@@ -42,6 +42,7 @@ export function defaultConfig() {
|
|
|
42
42
|
pools: { heavy: { capacity: 1 } },
|
|
43
43
|
offerTimeoutMs: 30_000,
|
|
44
44
|
maxWaitMs: 5_000,
|
|
45
|
+
maxTurnWaitMs: 600_000,
|
|
45
46
|
startTimeoutMs: 120_000,
|
|
46
47
|
untrackedLeaseMs: 15 * 60_000,
|
|
47
48
|
guidance: {},
|
|
@@ -635,6 +636,7 @@ export class Machine {
|
|
|
635
636
|
pools: this.config.pools,
|
|
636
637
|
offerTimeoutMs: this.config.offerTimeoutMs,
|
|
637
638
|
maxWaitMs: this.config.maxWaitMs,
|
|
639
|
+
maxTurnWaitMs: this.config.maxTurnWaitMs,
|
|
638
640
|
startTimeoutMs: this.config.startTimeoutMs,
|
|
639
641
|
untrackedLeaseMs: this.config.untrackedLeaseMs,
|
|
640
642
|
},
|
|
@@ -755,6 +757,9 @@ export class Machine {
|
|
|
755
757
|
if (c.maxWaitMs !== undefined && (c.maxWaitMs < 0 || c.maxWaitMs > 600_000)) {
|
|
756
758
|
throw new ProtocolError("invalid", "maxWaitMs must be within [0, 600000]");
|
|
757
759
|
}
|
|
760
|
+
if (c.maxTurnWaitMs !== undefined && (c.maxTurnWaitMs < 1000 || c.maxTurnWaitMs > 3_600_000)) {
|
|
761
|
+
throw new ProtocolError("invalid", "maxTurnWaitMs must be within [1000, 3600000]");
|
|
762
|
+
}
|
|
758
763
|
if (c.startTimeoutMs !== undefined && (c.startTimeoutMs < 1000 || c.startTimeoutMs > 3_600_000)) {
|
|
759
764
|
throw new ProtocolError("invalid", "startTimeoutMs must be within [1000, 3600000]");
|
|
760
765
|
}
|
|
@@ -770,6 +775,8 @@ export class Machine {
|
|
|
770
775
|
this.config.offerTimeoutMs = c.offerTimeoutMs;
|
|
771
776
|
if (c.maxWaitMs !== undefined)
|
|
772
777
|
this.config.maxWaitMs = c.maxWaitMs;
|
|
778
|
+
if (c.maxTurnWaitMs !== undefined)
|
|
779
|
+
this.config.maxTurnWaitMs = c.maxTurnWaitMs;
|
|
773
780
|
if (c.startTimeoutMs !== undefined)
|
|
774
781
|
this.config.startTimeoutMs = c.startTimeoutMs;
|
|
775
782
|
if (c.untrackedLeaseMs !== undefined)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type DashboardView, type FeedState } from "./view.js";
|
|
2
|
+
/** Structural subset of LLClient the feed needs (LLClient satisfies it). */
|
|
3
|
+
export interface FeedClient {
|
|
4
|
+
connect(): Promise<void>;
|
|
5
|
+
request<T = any>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
6
|
+
watch(agentId: string, onEvent: (ev: Record<string, unknown>) => void): Promise<void>;
|
|
7
|
+
onDisconnect(cb: () => void): unknown;
|
|
8
|
+
close(): void;
|
|
9
|
+
}
|
|
10
|
+
export interface FeedOptions {
|
|
11
|
+
/** Rolling event history length (default 60). */
|
|
12
|
+
eventHistory?: number;
|
|
13
|
+
/** Slow safety refresh while live (default 15_000 ms; 0 disables). */
|
|
14
|
+
slowRefreshMs?: number;
|
|
15
|
+
/** Debounce window for post-event status pulls (default 40 ms). */
|
|
16
|
+
refreshDebounceMs?: number;
|
|
17
|
+
/** Inspect refresh cadence (default 60_000 ms). */
|
|
18
|
+
inspectEveryMs?: number;
|
|
19
|
+
/** Reconnect backoff base (default 250 ms). */
|
|
20
|
+
reconnectBaseMs?: number;
|
|
21
|
+
}
|
|
22
|
+
export declare class DashboardFeed {
|
|
23
|
+
private client;
|
|
24
|
+
private opts;
|
|
25
|
+
private events;
|
|
26
|
+
private latestEventId;
|
|
27
|
+
private status;
|
|
28
|
+
private inspect;
|
|
29
|
+
private inspectAt;
|
|
30
|
+
private lastSyncAt;
|
|
31
|
+
private state;
|
|
32
|
+
private restartNote;
|
|
33
|
+
private daemonStartedAt;
|
|
34
|
+
private refreshTimer;
|
|
35
|
+
private slowTimer;
|
|
36
|
+
private stopped;
|
|
37
|
+
private unsub;
|
|
38
|
+
private connecting;
|
|
39
|
+
private viewSeq;
|
|
40
|
+
/** Fired whenever the rendered view might have changed (state or data). */
|
|
41
|
+
onChange?: () => void;
|
|
42
|
+
/** Fired on connection-state transitions. */
|
|
43
|
+
onState?: (s: FeedState) => void;
|
|
44
|
+
constructor(client: FeedClient, opts?: FeedOptions);
|
|
45
|
+
currentState(): FeedState;
|
|
46
|
+
private setState;
|
|
47
|
+
private emitChange;
|
|
48
|
+
/** Connect, subscribe, and pull an initial full snapshot. */
|
|
49
|
+
start(): Promise<void>;
|
|
50
|
+
stop(): Promise<void>;
|
|
51
|
+
private connectAndSync;
|
|
52
|
+
/**
|
|
53
|
+
* Full snapshot: status + events since cursor (+ inspect if stale).
|
|
54
|
+
* Detects daemon restarts two ways — event ids going backwards, or the
|
|
55
|
+
* daemon's own startedAt changing — then resets the cursor and re-pulls
|
|
56
|
+
* history (ids are durable per daemon lifetime, not across restarts).
|
|
57
|
+
*/
|
|
58
|
+
resync(): Promise<void>;
|
|
59
|
+
/** Status-only pull (used after event bursts; keeps inspect cached). */
|
|
60
|
+
refresh(): Promise<void>;
|
|
61
|
+
private refreshInspect;
|
|
62
|
+
private handleEvent;
|
|
63
|
+
private mergeEvents;
|
|
64
|
+
private handleDisconnect;
|
|
65
|
+
private reconnectLoop;
|
|
66
|
+
/**
|
|
67
|
+
* Admin force-release. Callers MUST gate this behind their own explicit
|
|
68
|
+
* confirm step — the feed itself never initiates destructive actions.
|
|
69
|
+
* Returns the reclaimed request's one-line description for toasts.
|
|
70
|
+
*/
|
|
71
|
+
forceRelease(requestId: string): Promise<void>;
|
|
72
|
+
/** Render-ready snapshot; countdowns are computed at `now`. */
|
|
73
|
+
view(now?: number): DashboardView;
|
|
74
|
+
}
|