@aron98/load-lock 1.3.0 → 1.5.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/dist/adapters/opencode/plugin.d.ts +2 -1
- package/dist/adapters/opencode/plugin.js +100 -6
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +1 -9
- package/dist/cli/copies.js +2 -43
- package/dist/cli/install.js +1 -1
- package/dist/cli/main.js +82 -217
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/update.d.ts +53 -0
- package/dist/cli/update.js +274 -0
- package/dist/core/daemon.d.ts +24 -0
- package/dist/core/daemon.js +221 -0
- package/dist/core/state.d.ts +5 -0
- package/dist/core/state.js +16 -0
- package/dist/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +117 -8
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +6 -0
- package/dist/shared/client.js +11 -1
- package/dist/shared/drift.d.ts +21 -0
- package/dist/shared/drift.js +77 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +16 -0
- package/dist/shared/pkginfo.js +54 -0
- package/dist/shared/protocol.d.ts +32 -0
- package/dist/shared/wait-completion.d.ts +67 -0
- package/dist/shared/wait-completion.js +505 -0
- package/dist/test/mcp.test.js +115 -1
- package/dist/test/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -0
- package/dist/test/update-command.test.d.ts +1 -0
- package/dist/test/update-command.test.js +608 -0
- package/dist/test/wait-completion.test.d.ts +1 -0
- package/dist/test/wait-completion.test.js +418 -0
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +61 -3
- package/docs/ARCHITECTURE.md +15 -0
- package/docs/COMPATIBILITY.md +16 -2
- package/docs/OPERATIONS.md +118 -9
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -21,7 +21,10 @@ import { spawn } from "node:child_process";
|
|
|
21
21
|
import { LLClient } from "../shared/client.js";
|
|
22
22
|
import { POLICY_BRIEF } from "../policy/text.js";
|
|
23
23
|
import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
|
|
24
|
+
import { waitForLocalJob, waitForTrackedJob } from "../shared/wait-completion.js";
|
|
24
25
|
import { killProcessGroup } from "../shared/transport.js";
|
|
26
|
+
import { detectCliDrift } from "../shared/drift.js";
|
|
27
|
+
import { packageVersion } from "../shared/version.js";
|
|
25
28
|
const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
|
|
26
29
|
|
|
27
30
|
Pass a short reason and a stable op_key identifying the pending operation (duplicate acquires for the same op_key return the existing request — do not invent a new key while one is live). Set wait_ms (default a few seconds) to block briefly for capacity instead of returning queued immediately; keep doing light work while queued. You will receive an offer to claim when capacity frees. Never start heavy work while merely queued.`;
|
|
@@ -30,7 +33,14 @@ const RELEASE_DESC = `Release a held permit immediately after the heavy work fin
|
|
|
30
33
|
export function buildServer(opts) {
|
|
31
34
|
const client = opts.client;
|
|
32
35
|
const defaultAgent = opts.agentId ?? "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8);
|
|
33
|
-
|
|
36
|
+
// Drift notice (issue #38): one clear notice naming the exact command when
|
|
37
|
+
// this adapter copy is newer than the PATH-installed CLI. Notify only —
|
|
38
|
+
// never auto-install. Printed to stderr at init and embedded in the
|
|
39
|
+
// initialize instructions so any MCP client sees it exactly once.
|
|
40
|
+
const drift = opts.drift !== undefined ? opts.drift : detectCliDrift(packageVersion());
|
|
41
|
+
if (drift)
|
|
42
|
+
process.stderr.write(drift.message + "\n");
|
|
43
|
+
const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF + (drift ? "\n\n" + drift.message : "") });
|
|
34
44
|
const json = (v) => ({
|
|
35
45
|
content: [{ type: "text", text: JSON.stringify(v, null, 2) }],
|
|
36
46
|
});
|
|
@@ -263,13 +273,15 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
|
|
|
263
273
|
const jobs = new Map();
|
|
264
274
|
server.registerTool("run_with_permit", {
|
|
265
275
|
title: "Run heavy command under permit",
|
|
266
|
-
description: "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns the command in its own process group, atomically associates it with your claimed permit, returns a job_id immediately (background execution — do NOT release the permit merely because this returned), and auto-releases the permit when the job exits (success, failure, or kill). Survives this tool call and your session: if this server dies, the daemon still watches the job's process group.
|
|
276
|
+
description: "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns the command in its own process group, atomically associates it with your claimed permit, returns a job_id immediately (background execution — do NOT release the permit merely because this returned), and auto-releases the permit when the job exits (success, failure, or kill). Survives this tool call and your session: if this server dies, the daemon still watches the job's process group. Set wait:true to sleep through execution in this one call (bounded by wait_timeout_ms; returns still_running on timeout — re-call), otherwise sleep via wait_for_completion(job_id) and quick-check via job_status.",
|
|
267
277
|
inputSchema: {
|
|
268
278
|
permit_token: z.string().describe("Permit token from acquire/claim"),
|
|
269
279
|
command: z.string().describe("Shell command to run (the heavy work you acquired the permit for — do not pass unrelated light commands)"),
|
|
270
280
|
cwd: z.string().optional().describe("Working directory (default: this server's cwd)"),
|
|
281
|
+
wait: z.boolean().optional().describe("true = sleep through execution in this one call: wake promptly when the job finishes (bounded by wait_timeout_ms), returning exit code + output tail"),
|
|
282
|
+
wait_timeout_ms: z.number().int().optional().describe("Bounded sleep for wait:true (ms); default 600000. Returns still_running (a checkpoint) on timeout — call wait_for_completion again."),
|
|
271
283
|
},
|
|
272
|
-
}, async (input) => {
|
|
284
|
+
}, async (input, extra) => {
|
|
273
285
|
const child = spawn(input.command, {
|
|
274
286
|
shell: true,
|
|
275
287
|
detached: true, // own process group => pgid === child.pid on Linux
|
|
@@ -277,6 +289,7 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
|
|
|
277
289
|
env: process.env,
|
|
278
290
|
stdio: ["ignore", "pipe", "pipe"],
|
|
279
291
|
});
|
|
292
|
+
let resolveExit;
|
|
280
293
|
const job = {
|
|
281
294
|
id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
|
|
282
295
|
child,
|
|
@@ -286,6 +299,9 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
|
|
|
286
299
|
exited: false,
|
|
287
300
|
exitCode: null,
|
|
288
301
|
output: [],
|
|
302
|
+
exitPromise: new Promise((resolve) => {
|
|
303
|
+
resolveExit = resolve;
|
|
304
|
+
}),
|
|
289
305
|
};
|
|
290
306
|
// Watch for exit BEFORE associating (no awaits in between): a job that
|
|
291
307
|
// exits instantly must not lose its exit event while we round-trip to
|
|
@@ -299,22 +315,29 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
|
|
|
299
315
|
child.stderr?.on("data", push);
|
|
300
316
|
child.on("exit", (code) => {
|
|
301
317
|
job.exited = true;
|
|
318
|
+
job.exitedAt = Date.now();
|
|
302
319
|
job.exitCode = code;
|
|
303
320
|
// Auto-release on completion: success, failure, or termination.
|
|
304
|
-
|
|
321
|
+
// releasePromise is assigned BEFORE resolveExit so a woken waiter
|
|
322
|
+
// sees the release round-trip in flight (see waitForLocalJob).
|
|
323
|
+
job.releasePromise = client
|
|
305
324
|
.request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" })
|
|
306
|
-
.
|
|
325
|
+
.then(() => {
|
|
326
|
+
job.permitReleased = true;
|
|
327
|
+
}, () => {
|
|
307
328
|
/* already released/reclaimed — idempotent path */
|
|
308
329
|
});
|
|
330
|
+
resolveExit(code);
|
|
309
331
|
});
|
|
310
332
|
// Atomic association; if the permit was reclaimed already, kill the job
|
|
311
333
|
// before it can do meaningful work and report the failure.
|
|
312
334
|
try {
|
|
313
|
-
await client.request("start_job", {
|
|
335
|
+
const res = await client.request("start_job", {
|
|
314
336
|
permitToken: input.permit_token,
|
|
315
337
|
pid: child.pid,
|
|
316
338
|
pgid: child.pid,
|
|
317
339
|
});
|
|
340
|
+
job.requestId = res.entry?.requestId;
|
|
318
341
|
}
|
|
319
342
|
catch (err) {
|
|
320
343
|
killProcessGroup(child.pid, "SIGKILL");
|
|
@@ -323,12 +346,31 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
|
|
|
323
346
|
});
|
|
324
347
|
}
|
|
325
348
|
jobs.set(job.id, job);
|
|
349
|
+
const note = `Background job started under permit. Do NOT release the permit manually — it auto-releases when the job exits. Sleep through the execution with wait_for_completion(job_id); job_status(job_id) for a quick check; job_stop(job_id) to terminate.`;
|
|
350
|
+
if (!input.wait) {
|
|
351
|
+
return json({
|
|
352
|
+
job_id: job.id,
|
|
353
|
+
pid: child.pid,
|
|
354
|
+
pgid: child.pid,
|
|
355
|
+
state: "running",
|
|
356
|
+
note,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Fire-and-wait: sleep honestly through execution in this one call.
|
|
360
|
+
const r = await waitForLocalJob(job, { timeoutMs: input.wait_timeout_ms, signal: extra?.signal });
|
|
326
361
|
return json({
|
|
327
362
|
job_id: job.id,
|
|
328
363
|
pid: child.pid,
|
|
329
364
|
pgid: child.pid,
|
|
330
|
-
|
|
331
|
-
|
|
365
|
+
command: job.command,
|
|
366
|
+
state: r.state,
|
|
367
|
+
exit_code: r.exitCode,
|
|
368
|
+
output_tail: r.outputTail,
|
|
369
|
+
duration_ms: r.durationMs,
|
|
370
|
+
permit_released: r.permitReleased,
|
|
371
|
+
waited_ms: r.waitedMs,
|
|
372
|
+
next_action: r.nextAction,
|
|
373
|
+
detail: r.detail,
|
|
332
374
|
});
|
|
333
375
|
});
|
|
334
376
|
server.registerTool("job_status", {
|
|
@@ -356,6 +398,7 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
|
|
|
356
398
|
if (!job)
|
|
357
399
|
return json({ error: "unknown job_id" });
|
|
358
400
|
if (!job.exited) {
|
|
401
|
+
job.stopRequested = true;
|
|
359
402
|
killProcessGroup(job.child.pid, "SIGTERM");
|
|
360
403
|
setTimeout(() => {
|
|
361
404
|
if (!job.exited) {
|
|
@@ -365,6 +408,72 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
|
|
|
365
408
|
}
|
|
366
409
|
return json({ job_id: job.id, stopping: true });
|
|
367
410
|
});
|
|
411
|
+
server.registerTool("wait_for_completion", {
|
|
412
|
+
title: "Sleep until the job finishes",
|
|
413
|
+
description: `Bounded blocking sleep for the EXECUTION phase — "sleep honestly until my heavy job finishes", replacing sleep-and-poll cycles. Two mechanisms, selected by identifier:
|
|
414
|
+
- job_id of a run_with_permit job (adapter-parented): resolves on the child's exact exit event — no polling, prompt wake, exact exit code + output tail;
|
|
415
|
+
- request_id of any daemon-tracked permit (attach_pid etc.): the daemon holds the wait and wakes it when its liveness sweep observes the process gone (worst-case promptness: one sweep interval, default 5s; the permit is released BEFORE you are woken).
|
|
416
|
+
Returns a STRUCTURED result on every path — never an error for a normal resolution:
|
|
417
|
+
- finished: exit_code, output_tail, duration_ms, permit_released — the work is done, the permit is (being) freed;
|
|
418
|
+
- still_running: timed out mid-execution — a checkpoint; call wait_for_completion again (long jobs = a handful of long sleeps, not a poll loop);
|
|
419
|
+
- timeout: this wait call was aborted/cancelled (harness tool timeout, AbortSignal) — the job is UNTOUCHED; call again;
|
|
420
|
+
- job_unknown: no such job/request, or the permit was reclaimed/expired meanwhile;
|
|
421
|
+
- stopped_by_request: the job/permit was stopped by an explicit admin action.
|
|
422
|
+
Input: job_id OR request_id (one of them), timeout_ms (default 600000, clamped to the machine's maxTurnWaitMs cap for daemon-tracked waits; 0 = check-only). The wait itself holds NO permit and NO capacity. A daemon restart mid-wait is handled transparently (tracked permits survive restarts via the journal); worst case it resolves with job_unknown + re-check instructions.`,
|
|
423
|
+
inputSchema: {
|
|
424
|
+
job_id: z.string().optional().describe("A run_with_permit job_id (adapter-parented; exact exit wake)"),
|
|
425
|
+
request_id: z.string().optional().describe("A daemon-tracked permit's request_id (attach_pid etc.; liveness-sweep wake)"),
|
|
426
|
+
timeout_ms: z.number().int().optional().describe("Bounded sleep (ms); default 600000. 0 = check-only. still_running on timeout — re-call."),
|
|
427
|
+
agent_id: z.string().optional().describe("Override agent identity (rarely needed)"),
|
|
428
|
+
},
|
|
429
|
+
}, async (input, extra) => {
|
|
430
|
+
if (!input.job_id && !input.request_id) {
|
|
431
|
+
return json({
|
|
432
|
+
state: "job_unknown",
|
|
433
|
+
waited_ms: 0,
|
|
434
|
+
next_action: "Pass job_id (a run_with_permit job) or request_id (a daemon-tracked permit).",
|
|
435
|
+
detail: "no identifier given",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (input.job_id && jobs.has(input.job_id)) {
|
|
439
|
+
const job = jobs.get(input.job_id);
|
|
440
|
+
const r = await waitForLocalJob(job, { timeoutMs: input.timeout_ms, signal: extra?.signal });
|
|
441
|
+
return json({
|
|
442
|
+
state: r.state,
|
|
443
|
+
job_id: job.id,
|
|
444
|
+
request_id: job.requestId,
|
|
445
|
+
exit_code: r.exitCode,
|
|
446
|
+
output_tail: r.outputTail,
|
|
447
|
+
duration_ms: r.durationMs,
|
|
448
|
+
permit_released: r.permitReleased,
|
|
449
|
+
waited_ms: r.waitedMs,
|
|
450
|
+
started_at: r.startedAt,
|
|
451
|
+
next_action: r.nextAction,
|
|
452
|
+
detail: r.detail,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
// Daemon-tracked permit (request_id), or a job_id from another/previous
|
|
456
|
+
// server process (unknown here -> falls through to job_unknown).
|
|
457
|
+
const requestId = input.request_id ?? input.job_id;
|
|
458
|
+
const r = await waitForTrackedJob({
|
|
459
|
+
client,
|
|
460
|
+
requestId,
|
|
461
|
+
timeoutMs: input.timeout_ms,
|
|
462
|
+
signal: extra?.signal,
|
|
463
|
+
});
|
|
464
|
+
return json({
|
|
465
|
+
state: r.state,
|
|
466
|
+
request_id: r.requestId ?? requestId,
|
|
467
|
+
exit_code: r.exitCode,
|
|
468
|
+
duration_ms: r.durationMs,
|
|
469
|
+
permit_released: r.permitReleased,
|
|
470
|
+
waited_ms: r.waitedMs,
|
|
471
|
+
effective_wait_ms: r.effectiveWaitMs,
|
|
472
|
+
started_at: r.startedAt,
|
|
473
|
+
next_action: r.nextAction,
|
|
474
|
+
detail: r.detail,
|
|
475
|
+
});
|
|
476
|
+
});
|
|
368
477
|
// Automatic adapter heartbeat: while this server process lives, untracked
|
|
369
478
|
// permits of this agent keep their lease; if the harness session dies, the
|
|
370
479
|
// lease lapses and the daemon reclaims. The model never has to remember.
|
package/dist/policy/text.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
|
|
12
12
|
* in the skill / docs/AGENT_GUIDE.md.
|
|
13
13
|
*/
|
|
14
|
-
export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the \"load-lock\" skill or `load-lock agent-guide`.";
|
|
14
|
+
export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n Out of light work while it runs? Sleep honestly with wait_for_completion\n (MCP/plugin: run_with_permit job_id or tracked request_id; or run_with_permit\n with wait:true) or 'load-lock wait --job <request-id>' (CLI) \u2014 a bounded\n sleep that wakes promptly when the job finishes (exit code + output tail;\n the permit is freed before you are woken). On still_running it is a\n checkpoint: call again. Do NOT guess sleeps or poll job_status in a loop.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the \"load-lock\" skill or `load-lock agent-guide`.";
|
|
15
15
|
export declare function policyWithContext(statusSummary: string, configSummary?: string): string;
|
|
16
16
|
export declare function statusLine(st: {
|
|
17
17
|
pools: {
|
package/dist/policy/text.js
CHANGED
|
@@ -36,6 +36,12 @@ Rules:
|
|
|
36
36
|
5. Hold the permit until the heavy process actually finishes — including when
|
|
37
37
|
it runs in the background beyond this tool call or session. Optionally
|
|
38
38
|
attach its pid (load-lock attach_pid) so a dead process auto-releases.
|
|
39
|
+
Out of light work while it runs? Sleep honestly with wait_for_completion
|
|
40
|
+
(MCP/plugin: run_with_permit job_id or tracked request_id; or run_with_permit
|
|
41
|
+
with wait:true) or 'load-lock wait --job <request-id>' (CLI) — a bounded
|
|
42
|
+
sleep that wakes promptly when the job finishes (exit code + output tail;
|
|
43
|
+
the permit is freed before you are woken). On still_running it is a
|
|
44
|
+
checkpoint: call again. Do NOT guess sleeps or poll job_status in a loop.
|
|
39
45
|
6. Release immediately when done (success or failure), and cancel queued
|
|
40
46
|
requests you no longer need.
|
|
41
47
|
7. Capacity is a cooperative budget, not an enforced one. If the budget seems
|
package/dist/shared/client.js
CHANGED
|
@@ -30,6 +30,7 @@ import { ProtocolError } from "./protocol.js";
|
|
|
30
30
|
import { socketPath as defaultSocket, stateDir as defaultStateDir } from "../core/persist.js";
|
|
31
31
|
import { probeTransport } from "./transport.js";
|
|
32
32
|
import { packageVersion } from "./version.js";
|
|
33
|
+
import { resolveDaemonEntry } from "./installs.js";
|
|
33
34
|
export class LLClient {
|
|
34
35
|
sock = null;
|
|
35
36
|
nextId = 1;
|
|
@@ -230,7 +231,12 @@ function daemonMainPath() {
|
|
|
230
231
|
if (process.env.LOAD_LOCK_DAEMON_BIN)
|
|
231
232
|
return process.env.LOAD_LOCK_DAEMON_BIN;
|
|
232
233
|
const here = dirname(fileURLToPath(import.meta.url)); // .../dist/shared
|
|
233
|
-
|
|
234
|
+
const own = join(here, "..", "core", "daemon-main.js");
|
|
235
|
+
// Issue #39: a harness (e.g. opencode's timestamped plugin cache) may
|
|
236
|
+
// prune this copy's directory while a session still executes it from
|
|
237
|
+
// memory — resolve to the PATH-installed CLI's daemon entry instead of
|
|
238
|
+
// spawning an ENOENT forever.
|
|
239
|
+
return resolveDaemonEntry(own);
|
|
234
240
|
}
|
|
235
241
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
|
236
242
|
export async function ensureDaemon(stateDir, sockPath) {
|
|
@@ -274,6 +280,10 @@ export async function ensureDaemon(stateDir, sockPath) {
|
|
|
274
280
|
stdio: "ignore",
|
|
275
281
|
env: { ...process.env, LOAD_LOCK_DAEMON_SPAWNED: "1" },
|
|
276
282
|
});
|
|
283
|
+
// Issue #39: an unreadable/vanished entry must surface as the
|
|
284
|
+
// "did not come up" probe failure below, not crash the host process
|
|
285
|
+
// with an uncaught ENOENT 'error' event.
|
|
286
|
+
child.on("error", () => { });
|
|
277
287
|
child.unref();
|
|
278
288
|
}
|
|
279
289
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface CliDrift {
|
|
2
|
+
/** Version of this (adapter) copy — the newer one. */
|
|
3
|
+
ownVersion: string;
|
|
4
|
+
/** Version of the PATH-resolved `load-lock` CLI copy — the older one. */
|
|
5
|
+
cliVersion: string;
|
|
6
|
+
/** Package root of the PATH-resolved CLI copy. */
|
|
7
|
+
cliPath: string;
|
|
8
|
+
/** The one clear notice, with the exact command. */
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
/** The notice text (exported for tests and doc-sync checks). */
|
|
12
|
+
export declare function driftMessage(ownVersion: string, cliVersion: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Compare `ownVersion` (this adapter copy) with the version of the
|
|
15
|
+
* `load-lock` binary that PATH resolves to, by reading that copy's
|
|
16
|
+
* package.json — never by spawning the old CLI, which may predate any
|
|
17
|
+
* introspection. Returns a drift notice when the PATH CLI is strictly
|
|
18
|
+
* older; null when it is the same or newer, absent, or unreadable.
|
|
19
|
+
* Best-effort: never throws.
|
|
20
|
+
*/
|
|
21
|
+
export declare function detectCliDrift(ownVersion: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): CliDrift | null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter-init drift notice (issue #38): when a harness adapter copy NEWER
|
|
3
|
+
* than the PATH-installed `load-lock` CLI initializes — typically because
|
|
4
|
+
* opencode refreshed its private cache copy — surface ONE clear notice
|
|
5
|
+
* naming the exact command (`load-lock update`).
|
|
6
|
+
*
|
|
7
|
+
* Notify-first, NEVER auto-install: spawning a package manager from inside
|
|
8
|
+
* a harness plugin needs explicit opt-in; the default is to tell the
|
|
9
|
+
* agent/user, who can run `load-lock update` as an ordinary command or
|
|
10
|
+
* tool call.
|
|
11
|
+
*
|
|
12
|
+
* Honest limitation (documented in docs/OPERATIONS.md): a harness session
|
|
13
|
+
* that was already running keeps the OLD adapter code in memory until the
|
|
14
|
+
* harness restarts — this notice is emitted by the NEW copy at its init,
|
|
15
|
+
* which is the best it can do for sessions started before the refresh.
|
|
16
|
+
*
|
|
17
|
+
* Pure and synchronous (PATH scan + package.json reads only) so both the
|
|
18
|
+
* OpenCode plugin setup and the MCP server startup can call it inline.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { isWindows } from "./transport.js";
|
|
23
|
+
import { enclosingPackage, PKG_NAME } from "./pkginfo.js";
|
|
24
|
+
import { compareVersions } from "./version.js";
|
|
25
|
+
/** The notice text (exported for tests and doc-sync checks). */
|
|
26
|
+
export function driftMessage(ownVersion, cliVersion) {
|
|
27
|
+
return (`load-lock: this load-lock adapter copy (v${ownVersion}) is newer than the installed CLI (v${cliVersion}). ` +
|
|
28
|
+
`Run \`load-lock update\` to update the CLI on PATH and restart the daemon. ` +
|
|
29
|
+
`(Notify only — nothing is auto-installed; if your CLI predates the \`update\` command, run ` +
|
|
30
|
+
`\`npm i -g ${PKG_NAME}@latest\` instead.)`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compare `ownVersion` (this adapter copy) with the version of the
|
|
34
|
+
* `load-lock` binary that PATH resolves to, by reading that copy's
|
|
35
|
+
* package.json — never by spawning the old CLI, which may predate any
|
|
36
|
+
* introspection. Returns a drift notice when the PATH CLI is strictly
|
|
37
|
+
* older; null when it is the same or newer, absent, or unreadable.
|
|
38
|
+
* Best-effort: never throws.
|
|
39
|
+
*/
|
|
40
|
+
export function detectCliDrift(ownVersion, env = process.env, platform = process.platform) {
|
|
41
|
+
try {
|
|
42
|
+
const sep = isWindows(platform) ? ";" : ":";
|
|
43
|
+
const binNames = isWindows(platform) ? ["load-lock.cmd", "load-lock.exe", "load-lock"] : ["load-lock"];
|
|
44
|
+
const dirs = (env.PATH ?? "").split(sep).filter(Boolean);
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
for (const bin of binNames) {
|
|
47
|
+
const binPath = join(dir, bin);
|
|
48
|
+
if (!existsSync(binPath) || !statSync(binPath).isFile())
|
|
49
|
+
continue;
|
|
50
|
+
// Resolve through symlinks/shims to the copy that owns the bin.
|
|
51
|
+
let realBin;
|
|
52
|
+
try {
|
|
53
|
+
realBin = realpathSync(binPath);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
realBin = binPath;
|
|
57
|
+
}
|
|
58
|
+
const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
|
|
59
|
+
if (!found || found.version === null)
|
|
60
|
+
return null; // first PATH hit wins; cannot compare honestly
|
|
61
|
+
if (compareVersions(found.version, ownVersion) < 0) {
|
|
62
|
+
return {
|
|
63
|
+
ownVersion,
|
|
64
|
+
cliVersion: found.version,
|
|
65
|
+
cliPath: found.path,
|
|
66
|
+
message: driftMessage(ownVersion, found.version),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null; // PATH CLI is same/newer — no drift
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* best-effort: unreadable PATH entries are skipped, never fatal */
|
|
75
|
+
}
|
|
76
|
+
return null; // no load-lock on PATH — nothing to compare against
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** All `load-lock` binaries found on PATH, in PATH order (files only). */
|
|
2
|
+
export declare function pathBinCandidates(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string[];
|
|
3
|
+
export interface PathInstall {
|
|
4
|
+
/** Package root of the install (the directory containing package.json). */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Version from that package.json, or null when unreadable. */
|
|
7
|
+
version: string | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The FIRST PATH-resolved `load-lock`'s enclosing install. Symlinks are
|
|
11
|
+
* resolved (npm/bun global layouts symlink the bin into the install), and a
|
|
12
|
+
* plain shim script falls back to the PATH directory itself.
|
|
13
|
+
*/
|
|
14
|
+
export declare function pathInstall(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): PathInstall | null;
|
|
15
|
+
export interface InstallDrift {
|
|
16
|
+
/** Version of THIS running copy (src/shared/version.ts, cached). */
|
|
17
|
+
ownVersion: string;
|
|
18
|
+
/** Version of the PATH-resolved CLI install, or null when unresolvable. */
|
|
19
|
+
pathVersion: string | null;
|
|
20
|
+
/** True when the PATH install is strictly newer than this running copy. */
|
|
21
|
+
stale: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* In-memory staleness probe (issue #39): a harness that loads plugin code
|
|
25
|
+
* once at startup (opencode never hot-reloads plugins) keeps executing the
|
|
26
|
+
* old copy after `npm i -g @aron98/load-lock@latest` — this detects exactly
|
|
27
|
+
* that: the running copy vs. what the PATH `load-lock` resolves to.
|
|
28
|
+
*/
|
|
29
|
+
export declare function detectInstallDrift(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstallDrift;
|
|
30
|
+
/**
|
|
31
|
+
* Daemon entry to auto-spawn (issue #39, hypothesis 2): normally the running
|
|
32
|
+
* copy's own dist/core/daemon-main.js. When that file has vanished — a
|
|
33
|
+
* harness may prune its old plugin-cache timestamped dir while a session
|
|
34
|
+
* still runs that copy in memory — spawn from the PATH-installed CLI's copy
|
|
35
|
+
* instead, which is what the user most recently installed. If nothing on
|
|
36
|
+
* PATH resolves either, the own path is returned unchanged so the spawn
|
|
37
|
+
* fails with the original error rather than a misleading one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveDaemonEntry(ownEntry: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared install-location helpers (issue #39): resolving the PATH-installed
|
|
3
|
+
* copy of the CLI, detecting version drift between the running copy and that
|
|
4
|
+
* install, and finding a usable daemon entry when the running copy's own
|
|
5
|
+
* entry has vanished (e.g. a harness pruned an old plugin-cache copy while a
|
|
6
|
+
* session still executes it from memory).
|
|
7
|
+
*
|
|
8
|
+
* `enclosingPackage` / `PKG_NAME` live in `./pkginfo.js` (shipped by #38's
|
|
9
|
+
* adapter-init drift notice) and are reused here; this module adds the
|
|
10
|
+
* PATH-install scan and the two #39-specific probes on top of them:
|
|
11
|
+
* `detectInstallDrift` (running copy OLDER than the PATH CLI — the mirror
|
|
12
|
+
* image of pkginfo-neighbor `drift.ts`'s `detectCliDrift`, which flags the
|
|
13
|
+
* PATH CLI being older) and `resolveDaemonEntry` (spawn fallback when this
|
|
14
|
+
* copy's own entry has vanished).
|
|
15
|
+
*
|
|
16
|
+
* Everything here is best-effort and never throws: missing paths, unreadable
|
|
17
|
+
* files, and unexpected layouts yield null / false / the input unchanged.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { isWindows } from "./transport.js";
|
|
22
|
+
import { compareVersions, packageVersion } from "./version.js";
|
|
23
|
+
import { enclosingPackage } from "./pkginfo.js";
|
|
24
|
+
/** All `load-lock` binaries found on PATH, in PATH order (files only). */
|
|
25
|
+
export function pathBinCandidates(platform = process.platform, env = process.env) {
|
|
26
|
+
const binNames = isWindows(platform) ? ["load-lock.cmd", "load-lock.exe", "load-lock"] : ["load-lock"];
|
|
27
|
+
const sep = isWindows(platform) ? ";" : ":";
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const dir of (env.PATH ?? "").split(sep).filter(Boolean)) {
|
|
30
|
+
for (const bin of binNames) {
|
|
31
|
+
try {
|
|
32
|
+
const binPath = join(dir, bin);
|
|
33
|
+
if (statSync(binPath).isFile())
|
|
34
|
+
out.push(binPath);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* unreadable PATH entry — skip */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The FIRST PATH-resolved `load-lock`'s enclosing install. Symlinks are
|
|
45
|
+
* resolved (npm/bun global layouts symlink the bin into the install), and a
|
|
46
|
+
* plain shim script falls back to the PATH directory itself.
|
|
47
|
+
*/
|
|
48
|
+
export function pathInstall(platform = process.platform, env = process.env) {
|
|
49
|
+
for (const binPath of pathBinCandidates(platform, env)) {
|
|
50
|
+
let realBin = binPath;
|
|
51
|
+
try {
|
|
52
|
+
realBin = realpathSync(binPath);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* keep the raw path */
|
|
56
|
+
}
|
|
57
|
+
// A shim/wrapper script is not itself the copy — resolve through its
|
|
58
|
+
// enclosing package, falling back to the PATH dir (same logic the
|
|
59
|
+
// doctor's multi-copy scan uses).
|
|
60
|
+
const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dirname(binPath));
|
|
61
|
+
if (found)
|
|
62
|
+
return { path: found.path, version: found.version };
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* In-memory staleness probe (issue #39): a harness that loads plugin code
|
|
68
|
+
* once at startup (opencode never hot-reloads plugins) keeps executing the
|
|
69
|
+
* old copy after `npm i -g @aron98/load-lock@latest` — this detects exactly
|
|
70
|
+
* that: the running copy vs. what the PATH `load-lock` resolves to.
|
|
71
|
+
*/
|
|
72
|
+
export function detectInstallDrift(platform = process.platform, env = process.env) {
|
|
73
|
+
const ownVersion = packageVersion();
|
|
74
|
+
const inst = pathInstall(platform, env);
|
|
75
|
+
const pathVersion = inst?.version ?? null;
|
|
76
|
+
return {
|
|
77
|
+
ownVersion,
|
|
78
|
+
pathVersion,
|
|
79
|
+
stale: pathVersion != null && compareVersions(pathVersion, ownVersion) > 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Daemon entry to auto-spawn (issue #39, hypothesis 2): normally the running
|
|
84
|
+
* copy's own dist/core/daemon-main.js. When that file has vanished — a
|
|
85
|
+
* harness may prune its old plugin-cache timestamped dir while a session
|
|
86
|
+
* still runs that copy in memory — spawn from the PATH-installed CLI's copy
|
|
87
|
+
* instead, which is what the user most recently installed. If nothing on
|
|
88
|
+
* PATH resolves either, the own path is returned unchanged so the spawn
|
|
89
|
+
* fails with the original error rather than a misleading one.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveDaemonEntry(ownEntry, platform = process.platform, env = process.env) {
|
|
92
|
+
if (existsSync(ownEntry))
|
|
93
|
+
return ownEntry;
|
|
94
|
+
const inst = pathInstall(platform, env);
|
|
95
|
+
if (inst) {
|
|
96
|
+
const candidate = join(inst.path, "dist", "core", "daemon-main.js");
|
|
97
|
+
if (existsSync(candidate))
|
|
98
|
+
return candidate;
|
|
99
|
+
}
|
|
100
|
+
return ownEntry;
|
|
101
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Package name of this package (used to find enclosing package roots). */
|
|
2
|
+
export declare const PKG_NAME = "@aron98/load-lock";
|
|
3
|
+
export interface PkgRootInfo {
|
|
4
|
+
/** Package root (the directory holding the matching package.json). */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Version string, or null when the package.json has no/invalid version. */
|
|
7
|
+
version: string | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
11
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
12
|
+
* no enclosing package.json matches (or is readable).
|
|
13
|
+
*/
|
|
14
|
+
export declare function enclosingPackage(startDir: string, maxDepth?: number): PkgRootInfo | null;
|
|
15
|
+
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
16
|
+
export declare function opencodeCacheRoot(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-identity plumbing shared by copy detection (cli/copies.ts), the
|
|
3
|
+
* update command (cli/update.ts), and the adapter drift notice
|
|
4
|
+
* (shared/drift.ts): the package name and the "which package root
|
|
5
|
+
* contains this file" climb.
|
|
6
|
+
*
|
|
7
|
+
* Pure functions of their arguments (no process.env / import.meta at call
|
|
8
|
+
* time beyond what callers pass in) so tests can plant layouts.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
/** Package name of this package (used to find enclosing package roots). */
|
|
14
|
+
export const PKG_NAME = "@aron98/load-lock";
|
|
15
|
+
/**
|
|
16
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
17
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
18
|
+
* no enclosing package.json matches (or is readable).
|
|
19
|
+
*/
|
|
20
|
+
export function enclosingPackage(startDir, maxDepth = 12) {
|
|
21
|
+
let dir = startDir;
|
|
22
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
23
|
+
const pkgPath = join(dir, "package.json");
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(pkgPath)) {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
27
|
+
if (pkg?.name === PKG_NAME) {
|
|
28
|
+
return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
|
|
29
|
+
}
|
|
30
|
+
// A foreign package.json terminates the climb (e.g. a PATH bin
|
|
31
|
+
// inside some other tool's package).
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(dir);
|
|
39
|
+
if (parent === dir)
|
|
40
|
+
return null;
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
46
|
+
export function opencodeCacheRoot(platform, env) {
|
|
47
|
+
if (env.XDG_CACHE_HOME)
|
|
48
|
+
return env.XDG_CACHE_HOME;
|
|
49
|
+
if (platform === "win32")
|
|
50
|
+
return env.LOCALAPPDATA ?? null;
|
|
51
|
+
if (platform === "darwin")
|
|
52
|
+
return join(env.HOME ?? homedir(), "Library", "Caches");
|
|
53
|
+
return join(env.HOME ?? homedir(), ".cache");
|
|
54
|
+
}
|
|
@@ -130,6 +130,8 @@ export interface EntrySnapshot {
|
|
|
130
130
|
leaseDeadline?: number;
|
|
131
131
|
lastSeen?: number;
|
|
132
132
|
heldFor?: number;
|
|
133
|
+
/** When execution started (start_job / mark_started), epoch ms. Absent before that. */
|
|
134
|
+
startedAt?: number;
|
|
133
135
|
}
|
|
134
136
|
/** Owner-scoped snapshot: includes tokens. Never returned to non-owners. */
|
|
135
137
|
export interface OwnerEntrySnapshot extends EntrySnapshot {
|
|
@@ -394,6 +396,36 @@ export interface WaitForTurnResult {
|
|
|
394
396
|
nextAction: string;
|
|
395
397
|
detail?: string;
|
|
396
398
|
}
|
|
399
|
+
export type WaitForCompletionState = "finished" | "still_running" | "timeout" | "job_unknown" | "stopped_by_request";
|
|
400
|
+
export interface WaitForCompletionParams {
|
|
401
|
+
/** Request id whose tracked job to wait for. */
|
|
402
|
+
requestId: string;
|
|
403
|
+
/** Bounded wait budget for this call, ms. 0 = check-only (immediate return). */
|
|
404
|
+
timeoutMs?: number;
|
|
405
|
+
}
|
|
406
|
+
export interface WaitForCompletionResult {
|
|
407
|
+
state: WaitForCompletionState;
|
|
408
|
+
/** The job id (adapter-parented run_with_permit job). */
|
|
409
|
+
jobId?: string;
|
|
410
|
+
/** The request/permit id whose job was waited on. */
|
|
411
|
+
requestId?: string;
|
|
412
|
+
/** Adapter-parented jobs only: the child's exit code. */
|
|
413
|
+
exitCode?: number | null;
|
|
414
|
+
/** Adapter-parented jobs only: last lines of combined output. */
|
|
415
|
+
outputTail?: string;
|
|
416
|
+
/** Wall time from execution start to job end (ms). */
|
|
417
|
+
durationMs?: number;
|
|
418
|
+
/** True when the daemon confirms the permit is no longer held. */
|
|
419
|
+
permitReleased?: boolean;
|
|
420
|
+
/** How long the wait actually ran before resolving (ms). */
|
|
421
|
+
waitedMs: number;
|
|
422
|
+
/** The clamped budget actually applied, when it differed from the requested one. */
|
|
423
|
+
effectiveWaitMs?: number;
|
|
424
|
+
/** When execution started, when known (epoch ms). */
|
|
425
|
+
startedAt?: number;
|
|
426
|
+
nextAction: string;
|
|
427
|
+
detail?: string;
|
|
428
|
+
}
|
|
397
429
|
/** Recovery diagnostics: held permits with holder + job information. */
|
|
398
430
|
export interface HolderDiag {
|
|
399
431
|
entry: EntrySnapshot;
|