@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
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
|
|
5
5
|
* Provides:
|
|
6
6
|
* - native tools in the `load_lock` namespace (acquire/claim/release/...)
|
|
7
|
-
* - run_with_permit / job_status / job_stop
|
|
7
|
+
* - run_with_permit / job_status / job_stop / wait_for_completion:
|
|
8
|
+
* full execution lifecycle incl. honest execution-phase sleeping
|
|
8
9
|
* - automatic policy injection on every model call via the session
|
|
9
10
|
* "context" hook (compaction-proof by construction)
|
|
10
11
|
* - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
|
|
5
5
|
* Provides:
|
|
6
6
|
* - native tools in the `load_lock` namespace (acquire/claim/release/...)
|
|
7
|
-
* - run_with_permit / job_status / job_stop
|
|
7
|
+
* - run_with_permit / job_status / job_stop / wait_for_completion:
|
|
8
|
+
* full execution lifecycle incl. honest execution-phase sleeping
|
|
8
9
|
* - automatic policy injection on every model call via the session
|
|
9
10
|
* "context" hook (compaction-proof by construction)
|
|
10
11
|
* - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
|
|
@@ -21,7 +22,10 @@ import path from "node:path";
|
|
|
21
22
|
import { LLClient } from "../../shared/client.js";
|
|
22
23
|
import { POLICY_BRIEF } from "../../policy/text.js";
|
|
23
24
|
import { waitForTurn, WaitAbortedError } from "../../shared/wait-turn.js";
|
|
25
|
+
import { waitForLocalJob, waitForTrackedJob } from "../../shared/wait-completion.js";
|
|
24
26
|
import { killProcessGroup } from "../../shared/transport.js";
|
|
27
|
+
import { detectCliDrift } from "../../shared/drift.js";
|
|
28
|
+
import { packageVersion } from "../../shared/version.js";
|
|
25
29
|
const client = new LLClient();
|
|
26
30
|
const knownSessions = new Set();
|
|
27
31
|
const jobs = new Map();
|
|
@@ -42,6 +46,18 @@ function toolContent(v) {
|
|
|
42
46
|
export default Plugin.define({
|
|
43
47
|
id: "load-lock",
|
|
44
48
|
setup(ctx) {
|
|
49
|
+
// Drift notice (issue #38): when this adapter copy is NEWER than the
|
|
50
|
+
// `load-lock` CLI that PATH resolves to (opencode refreshed its cache
|
|
51
|
+
// copy but the global install was never updated), surface one clear
|
|
52
|
+
// notice naming the exact command — on stderr at init, and once in the
|
|
53
|
+
// injected context. Notify only: nothing is ever auto-installed. Note
|
|
54
|
+
// the honest limitation (docs/OPERATIONS.md): a harness session that
|
|
55
|
+
// was already running keeps the OLD adapter code in memory until the
|
|
56
|
+
// harness itself restarts — this fires from the NEW copy at its init.
|
|
57
|
+
const cliDrift = detectCliDrift(packageVersion());
|
|
58
|
+
if (cliDrift)
|
|
59
|
+
process.stderr.write(cliDrift.message + "\n");
|
|
60
|
+
const DRIFT_NOTE = cliDrift ? "\n\n" + cliDrift.message : "";
|
|
45
61
|
// ------------------------------------------------------------- tools
|
|
46
62
|
ctx.tool.transform((editor) => {
|
|
47
63
|
editor.namespace({
|
|
@@ -156,10 +172,12 @@ export default Plugin.define({
|
|
|
156
172
|
reason: input.reason,
|
|
157
173
|
author: "opencode:" + context.sessionID,
|
|
158
174
|
}));
|
|
159
|
-
add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs.
|
|
175
|
+
add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs. Set wait:true to sleep through execution in this one call; otherwise sleep via wait_for_completion(job_id), quick-check via job_status.", {
|
|
160
176
|
permit_token: { type: "string" },
|
|
161
177
|
command: { type: "string", description: "The heavy shell command (not unrelated light commands)" },
|
|
162
178
|
cwd: { type: "string" },
|
|
179
|
+
wait: { type: "boolean", description: "true = sleep through execution in this one call; wake promptly when the job finishes (bounded by wait_timeout_ms)" },
|
|
180
|
+
wait_timeout_ms: { type: "number", description: "Bounded sleep for wait:true (ms, default 600000); still_running checkpoint on timeout" },
|
|
163
181
|
}, async (input, context) => {
|
|
164
182
|
const child = spawn(input.command, {
|
|
165
183
|
shell: true,
|
|
@@ -168,15 +186,20 @@ export default Plugin.define({
|
|
|
168
186
|
env: process.env,
|
|
169
187
|
stdio: ["ignore", "pipe", "pipe"],
|
|
170
188
|
});
|
|
189
|
+
let resolveExit;
|
|
171
190
|
const job = {
|
|
172
191
|
id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
|
|
173
192
|
child,
|
|
174
193
|
permitToken: input.permit_token,
|
|
175
194
|
command: input.command,
|
|
176
195
|
sessionID: context.sessionID,
|
|
196
|
+
startedAt: Date.now(),
|
|
177
197
|
exited: false,
|
|
178
198
|
exitCode: null,
|
|
179
199
|
output: [],
|
|
200
|
+
exitPromise: new Promise((resolve) => {
|
|
201
|
+
resolveExit = resolve;
|
|
202
|
+
}),
|
|
180
203
|
};
|
|
181
204
|
const push = (b) => {
|
|
182
205
|
job.output.push(b.toString("utf8"));
|
|
@@ -190,18 +213,43 @@ export default Plugin.define({
|
|
|
190
213
|
// round-trip to the daemon, or the permit would never auto-release.
|
|
191
214
|
child.on("exit", (code) => {
|
|
192
215
|
job.exited = true;
|
|
216
|
+
job.exitedAt = Date.now();
|
|
193
217
|
job.exitCode = code;
|
|
194
|
-
|
|
218
|
+
// releasePromise is assigned BEFORE resolveExit so a woken waiter
|
|
219
|
+
// sees the release round-trip in flight (see waitForLocalJob).
|
|
220
|
+
job.releasePromise = client
|
|
221
|
+
.request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" })
|
|
222
|
+
.then(() => {
|
|
223
|
+
job.permitReleased = true;
|
|
224
|
+
}, () => { });
|
|
225
|
+
resolveExit(code);
|
|
195
226
|
});
|
|
196
227
|
try {
|
|
197
|
-
await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
|
|
228
|
+
const res = await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
|
|
229
|
+
job.requestId = res.entry?.requestId;
|
|
198
230
|
}
|
|
199
231
|
catch (err) {
|
|
200
232
|
killProcessGroup(child.pid, "SIGKILL");
|
|
201
233
|
return { error: `permit no longer startable (${err.message}); job killed before doing work; acquire again` };
|
|
202
234
|
}
|
|
203
235
|
jobs.set(job.id, job);
|
|
204
|
-
|
|
236
|
+
if (!input.wait) {
|
|
237
|
+
return { job_id: job.id, pid: child.pid, state: "running", note: "auto-releases when the job exits; sleep via wait_for_completion(job_id), quick-check via job_status" };
|
|
238
|
+
}
|
|
239
|
+
const r = await waitForLocalJob(job, { timeoutMs: input.wait_timeout_ms, signal: context.signal });
|
|
240
|
+
return {
|
|
241
|
+
job_id: job.id,
|
|
242
|
+
pid: child.pid,
|
|
243
|
+
command: job.command,
|
|
244
|
+
state: r.state,
|
|
245
|
+
exit_code: r.exitCode,
|
|
246
|
+
output_tail: r.outputTail,
|
|
247
|
+
duration_ms: r.durationMs,
|
|
248
|
+
permit_released: r.permitReleased,
|
|
249
|
+
waited_ms: r.waitedMs,
|
|
250
|
+
next_action: r.nextAction,
|
|
251
|
+
detail: r.detail,
|
|
252
|
+
};
|
|
205
253
|
});
|
|
206
254
|
add("job_status", "Poll a run_with_permit job: running/exited, exit code, output tail.", { job_id: { type: "string" } }, (input) => {
|
|
207
255
|
const job = jobs.get(input.job_id);
|
|
@@ -220,6 +268,7 @@ export default Plugin.define({
|
|
|
220
268
|
if (!job)
|
|
221
269
|
return { error: "unknown job_id" };
|
|
222
270
|
if (!job.exited) {
|
|
271
|
+
job.stopRequested = true;
|
|
223
272
|
killProcessGroup(job.child.pid, "SIGTERM");
|
|
224
273
|
setTimeout(() => {
|
|
225
274
|
if (!job.exited) {
|
|
@@ -229,12 +278,57 @@ export default Plugin.define({
|
|
|
229
278
|
}
|
|
230
279
|
return { job_id: job.id, stopping: true };
|
|
231
280
|
});
|
|
281
|
+
add("wait_for_completion", "Sleep honestly through the EXECUTION phase (replaces sleep-and-poll): bounded blocking wait that wakes promptly when the heavy job finishes. Pass job_id (run_with_permit job; exact exit-code wake) or request_id (daemon-tracked permit; liveness-sweep wake, permit released before you are woken). Structured result on every path: finished (exit_code/output_tail/duration/permit_released), still_running (checkpoint — call again), timeout (wait aborted, job untouched), job_unknown, stopped_by_request. timeout_ms bounds one call (default 600000; 0 = check-only).", {
|
|
282
|
+
job_id: { type: "string", description: "A run_with_permit job_id" },
|
|
283
|
+
request_id: { type: "string", description: "A daemon-tracked permit's request_id" },
|
|
284
|
+
timeout_ms: { type: "number", description: "Bounded sleep (ms, default 600000); still_running checkpoint on timeout — re-call" },
|
|
285
|
+
}, async (input, context) => {
|
|
286
|
+
if (!input.job_id && !input.request_id) {
|
|
287
|
+
return {
|
|
288
|
+
state: "job_unknown",
|
|
289
|
+
waited_ms: 0,
|
|
290
|
+
next_action: "Pass job_id (a run_with_permit job) or request_id (a daemon-tracked permit).",
|
|
291
|
+
detail: "no identifier given",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (input.job_id && jobs.has(input.job_id)) {
|
|
295
|
+
const job = jobs.get(input.job_id);
|
|
296
|
+
const r = await waitForLocalJob(job, { timeoutMs: input.timeout_ms, signal: context.signal });
|
|
297
|
+
return {
|
|
298
|
+
state: r.state,
|
|
299
|
+
job_id: job.id,
|
|
300
|
+
request_id: job.requestId,
|
|
301
|
+
exit_code: r.exitCode,
|
|
302
|
+
output_tail: r.outputTail,
|
|
303
|
+
duration_ms: r.durationMs,
|
|
304
|
+
permit_released: r.permitReleased,
|
|
305
|
+
waited_ms: r.waitedMs,
|
|
306
|
+
started_at: r.startedAt,
|
|
307
|
+
next_action: r.nextAction,
|
|
308
|
+
detail: r.detail,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const requestId = input.request_id ?? input.job_id;
|
|
312
|
+
const r = await waitForTrackedJob({ client, requestId, timeoutMs: input.timeout_ms, signal: context.signal });
|
|
313
|
+
return {
|
|
314
|
+
state: r.state,
|
|
315
|
+
request_id: r.requestId ?? requestId,
|
|
316
|
+
exit_code: r.exitCode,
|
|
317
|
+
duration_ms: r.durationMs,
|
|
318
|
+
permit_released: r.permitReleased,
|
|
319
|
+
waited_ms: r.waitedMs,
|
|
320
|
+
effective_wait_ms: r.effectiveWaitMs,
|
|
321
|
+
started_at: r.startedAt,
|
|
322
|
+
next_action: r.nextAction,
|
|
323
|
+
detail: r.detail,
|
|
324
|
+
};
|
|
325
|
+
});
|
|
232
326
|
add("attach_pid", "Associate a process you already started (e.g. a background shell task) with your claimed permit. Atomic; auto-releases when the process group exits.", { permit_token: { type: "string" }, pid: { type: "number" } }, (input) => client.request("attach_pid", { permitToken: input.permit_token, pid: input.pid }));
|
|
233
327
|
add("mark_started", "Tell load-lock you started the work when no process can be associated. Weaker guarantee: heartbeat lease applies (see docs). Prefer run_with_permit.", { permit_token: { type: "string" } }, (input) => client.request("mark_started", { permitToken: input.permit_token }));
|
|
234
328
|
});
|
|
235
329
|
// ------------------------------------------- policy on every model call
|
|
236
330
|
void ctx.session.hook("context", async (event) => {
|
|
237
|
-
event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) });
|
|
331
|
+
event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) + DRIFT_NOTE });
|
|
238
332
|
});
|
|
239
333
|
// -------------------------------------------------- idle wakeup via watch
|
|
240
334
|
void (async () => {
|
|
@@ -100,6 +100,8 @@ function dashboardLines(v, width, f) {
|
|
|
100
100
|
push(`note: ${v.daemonRestartNote}`, f.warn);
|
|
101
101
|
if (v.versionSkewNote)
|
|
102
102
|
push(`note: ${v.versionSkewNote}`, f.warn);
|
|
103
|
+
if (v.installDriftNote)
|
|
104
|
+
push(`note: ${v.installDriftNote}`, f.warn);
|
|
103
105
|
if (v.machine) {
|
|
104
106
|
const m = v.machine;
|
|
105
107
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
|
@@ -209,11 +211,14 @@ export default Plugin.define({
|
|
|
209
211
|
id: "load-lock.tui",
|
|
210
212
|
setup(ctx) {
|
|
211
213
|
const [view, setView] = createSignal(null);
|
|
212
|
-
|
|
214
|
+
// driftSurface "opencode": when this session's copy is older than the
|
|
215
|
+
// PATH CLI, the hint says to restart opencode (plugins never hot-reload).
|
|
216
|
+
const feed = new DashboardFeed(new LLClient(), { driftSurface: "opencode" });
|
|
213
217
|
feed.onChange = () => setView(feed.view());
|
|
214
218
|
feed.onState = () => setView(feed.view());
|
|
215
219
|
void feed.start().catch(() => {
|
|
216
|
-
/* daemon down or spawning; the
|
|
220
|
+
/* daemon down or spawning; the reconnect loop keeps retrying and
|
|
221
|
+
will go live — start() only reports the FIRST connect failure */
|
|
217
222
|
});
|
|
218
223
|
// Local countdown tick: produces a fresh snapshot once per second so
|
|
219
224
|
// ages/deadlines advance even between daemon events.
|
package/dist/cli/copies.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { enclosingPackage, opencodeCacheRoot } from "../shared/pkginfo.js";
|
|
1
2
|
export interface InstalledCopy {
|
|
2
3
|
/** Where this copy was found. */
|
|
3
4
|
source: "cli" | "path" | "opencode-cache";
|
|
@@ -6,15 +7,6 @@ export interface InstalledCopy {
|
|
|
6
7
|
/** Version string, or null when the package.json could not be read. */
|
|
7
8
|
version: string | null;
|
|
8
9
|
}
|
|
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): {
|
|
15
|
-
path: string;
|
|
16
|
-
version: string | null;
|
|
17
|
-
} | null;
|
|
18
10
|
/** The copy of load-lock this process runs from (its own package root). */
|
|
19
11
|
export declare function thisCopy(): InstalledCopy | null;
|
|
20
12
|
/**
|
package/dist/cli/copies.js
CHANGED
|
@@ -10,41 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
-
import { homedir } from "node:os";
|
|
14
13
|
import { fileURLToPath } from "node:url";
|
|
15
14
|
import { isWindows } from "../shared/transport.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
20
|
-
* given file, by climbing parent directories. Best-effort: returns null if
|
|
21
|
-
* no enclosing package.json matches (or is readable).
|
|
22
|
-
*/
|
|
23
|
-
export function enclosingPackage(startDir, maxDepth = 12) {
|
|
24
|
-
let dir = startDir;
|
|
25
|
-
for (let i = 0; i < maxDepth; i++) {
|
|
26
|
-
const pkgPath = join(dir, "package.json");
|
|
27
|
-
try {
|
|
28
|
-
if (existsSync(pkgPath)) {
|
|
29
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
30
|
-
if (pkg?.name === PKG_NAME) {
|
|
31
|
-
return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
|
|
32
|
-
}
|
|
33
|
-
// A foreign package.json terminates the climb (e.g. a PATH bin
|
|
34
|
-
// inside some other tool's package).
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
const parent = dirname(dir);
|
|
42
|
-
if (parent === dir)
|
|
43
|
-
return null;
|
|
44
|
-
dir = parent;
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
15
|
+
import { enclosingPackage, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
|
|
16
|
+
export { enclosingPackage, opencodeCacheRoot } from "../shared/pkginfo.js";
|
|
48
17
|
/** The copy of load-lock this process runs from (its own package root). */
|
|
49
18
|
export function thisCopy() {
|
|
50
19
|
// dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
|
|
@@ -195,13 +164,3 @@ export function findInstalledCopies(platform = process.platform, env = process.e
|
|
|
195
164
|
}
|
|
196
165
|
return copies;
|
|
197
166
|
}
|
|
198
|
-
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
199
|
-
function opencodeCacheRoot(platform, env) {
|
|
200
|
-
if (env.XDG_CACHE_HOME)
|
|
201
|
-
return env.XDG_CACHE_HOME;
|
|
202
|
-
if (platform === "win32")
|
|
203
|
-
return env.LOCALAPPDATA ?? null;
|
|
204
|
-
if (platform === "darwin")
|
|
205
|
-
return join(env.HOME ?? homedir(), "Library", "Caches");
|
|
206
|
-
return join(env.HOME ?? homedir(), ".cache");
|
|
207
|
-
}
|
package/dist/cli/install.js
CHANGED
|
@@ -1154,7 +1154,7 @@ function installOmac(opts, P) {
|
|
|
1154
1154
|
"LOAD_LOCK_LABEL_PREFIX=omac (dashboard shows holders as omac:<label>); " +
|
|
1155
1155
|
"LOAD_LOCK_SOCKET only if the default <state dir>/daemon.sock is not what the sandbox sees");
|
|
1156
1156
|
actions.push("label prefixes need load-lock >= 1.2.0 on BOTH ends: a daemon started before v1.2.0 " +
|
|
1157
|
-
"silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service
|
|
1157
|
+
"silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service restart)");
|
|
1158
1158
|
actions.push("verified live against omac 0.11.0 (bubblewrap + Landlock): sandboxed client -> host daemon " +
|
|
1159
1159
|
"round-trip, prefixed labels, LOAD_LOCK_NO_SPAWN error contract");
|
|
1160
1160
|
return actions;
|