@aron98/load-lock 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -2
- package/demo/demo.sh +112 -0
- package/demo/sim-agent.mjs +149 -0
- package/dist/adapters/opencode/plugin.d.ts +18 -0
- package/dist/adapters/opencode/plugin.js +301 -0
- package/dist/cli/hook.d.ts +1 -0
- package/dist/cli/hook.js +141 -0
- package/dist/cli/install.d.ts +14 -0
- package/dist/cli/install.js +377 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +461 -0
- package/dist/core/daemon-main.d.ts +2 -0
- package/dist/core/daemon-main.js +27 -0
- package/dist/core/daemon.d.ts +79 -0
- package/dist/core/daemon.js +526 -0
- package/dist/core/persist.d.ts +11 -0
- package/dist/core/persist.js +66 -0
- package/dist/core/state.d.ts +203 -0
- package/dist/core/state.js +909 -0
- package/dist/core/sysinfo.d.ts +2 -0
- package/dist/core/sysinfo.js +123 -0
- package/dist/mcp/main.d.ts +2 -0
- package/dist/mcp/main.js +4 -0
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.js +352 -0
- package/dist/policy/text.d.ts +30 -0
- package/dist/policy/text.js +58 -0
- package/dist/shared/client.d.ts +30 -0
- package/dist/shared/client.js +227 -0
- package/dist/shared/protocol.d.ts +333 -0
- package/dist/shared/protocol.js +22 -0
- package/dist/test/core.test.d.ts +1 -0
- package/dist/test/core.test.js +281 -0
- package/dist/test/daemon.test.d.ts +1 -0
- package/dist/test/daemon.test.js +384 -0
- package/dist/test/hook.test.d.ts +1 -0
- package/dist/test/hook.test.js +126 -0
- package/dist/test/mcp.test.d.ts +1 -0
- package/dist/test/mcp.test.js +139 -0
- package/dist/test/run-helper.test.d.ts +1 -0
- package/dist/test/run-helper.test.js +84 -0
- package/dist/test/testutil.d.ts +27 -0
- package/dist/test/testutil.js +111 -0
- package/docs/ADAPTERS.md +118 -0
- package/docs/AGENT_GUIDE.md +142 -0
- package/docs/ARCHITECTURE.md +195 -0
- package/docs/COMPATIBILITY.md +116 -0
- package/docs/OPERATIONS.md +126 -0
- package/package.json +102 -4
- package/skills/load-lock/SKILL.md +73 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook contract tests: `load-lock hook <harness> <event>` invoked exactly the
|
|
3
|
+
* way Claude Code / Codex invoke hooks (harness JSON on stdin), against a real
|
|
4
|
+
* daemon with real offers outstanding.
|
|
5
|
+
*/
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { mkdtempSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { startDaemon, delay, cliMainPath } from "./testutil.js";
|
|
13
|
+
const POLICY_MARK = "# load-lock: machine-wide heavy-work budget";
|
|
14
|
+
function runHook(d, args, input, env) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(process.execPath, [cliMainPath, "hook", ...args], {
|
|
17
|
+
env: { ...d.env, ...env },
|
|
18
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
+
});
|
|
20
|
+
let stdout = "";
|
|
21
|
+
let stderr = "";
|
|
22
|
+
child.stdout.on("data", (b) => (stdout += b));
|
|
23
|
+
child.stderr.on("data", (b) => (stderr += b));
|
|
24
|
+
child.on("exit", (code) => resolve({ code, stdout, stderr }));
|
|
25
|
+
child.on("error", reject);
|
|
26
|
+
child.stdin.end(input === undefined ? "" : JSON.stringify(input));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Set up: A holds, B queued with cwd; release A so B has an outstanding offer. */
|
|
30
|
+
async function outstandingOffer(d, cwd) {
|
|
31
|
+
const c = d.client();
|
|
32
|
+
await c.connect();
|
|
33
|
+
await c.request("acquire", { agentId: "hook-holder", opKey: "h1", reason: "holding" });
|
|
34
|
+
await c.request("acquire", { agentId: "hook-waiter", opKey: "w1", reason: "waiting", cwd });
|
|
35
|
+
const rel = (await c.request("release", { permitToken: (await c.request("pending", { agentId: "hook-holder" })).held[0].permitToken }));
|
|
36
|
+
const offers = (await c.request("offers_for_cwd", { cwd }));
|
|
37
|
+
c.close();
|
|
38
|
+
return offers.offers[0];
|
|
39
|
+
}
|
|
40
|
+
test("hooks: session-start events print the policy brief + machine state", async () => {
|
|
41
|
+
const d = await startDaemon();
|
|
42
|
+
try {
|
|
43
|
+
for (const ev of [
|
|
44
|
+
["claude-code", "session-start"],
|
|
45
|
+
["codex", "session-start"],
|
|
46
|
+
["codex", "subagent-start"],
|
|
47
|
+
]) {
|
|
48
|
+
const r = await runHook(d, ev, { session_id: "s1", cwd: "/tmp", source: "startup" });
|
|
49
|
+
assert.equal(r.code, 0, `${ev.join("/")} exits 0`);
|
|
50
|
+
assert.ok(r.stdout.startsWith(POLICY_MARK), `${ev.join("/")} emits the policy brief as stdout context`);
|
|
51
|
+
assert.ok(r.stdout.includes("effective core"), "includes machine awareness line");
|
|
52
|
+
assert.equal(r.stderr, "");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await d.stop();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
test("hooks: claude-code stop with a matching offer -> decision block carrying the offer token", async () => {
|
|
60
|
+
const d = await startDaemon();
|
|
61
|
+
try {
|
|
62
|
+
const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
|
|
63
|
+
const offer = await outstandingOffer(d, cwd);
|
|
64
|
+
assert.ok(offer?.offerToken, "waiter has an outstanding offer");
|
|
65
|
+
const r = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd });
|
|
66
|
+
assert.equal(r.code, 0);
|
|
67
|
+
const out = JSON.parse(r.stdout);
|
|
68
|
+
assert.equal(out.decision, "block", "Stop hook continues the session");
|
|
69
|
+
assert.ok(out.reason.includes(offer.offerToken), "reason tells the agent which offer_token to claim");
|
|
70
|
+
assert.ok(out.reason.includes("claim"), "reason instructs claiming");
|
|
71
|
+
assert.ok(out.reason.includes(offer.opKey));
|
|
72
|
+
// stop_hook_active: never loop
|
|
73
|
+
const quiet = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd, stop_hook_active: true });
|
|
74
|
+
assert.equal(quiet.code, 0);
|
|
75
|
+
assert.equal(quiet.stdout, "", "already-continued stops are silent");
|
|
76
|
+
// no matching offers -> silent
|
|
77
|
+
const none = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd: "/definitely/not/a/worktree" });
|
|
78
|
+
assert.equal(none.stdout, "");
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await d.stop();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
test("hooks: codex stop behaves the same (decision block JSON)", async () => {
|
|
85
|
+
const d = await startDaemon();
|
|
86
|
+
try {
|
|
87
|
+
const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
|
|
88
|
+
const offer = await outstandingOffer(d, cwd);
|
|
89
|
+
assert.ok(offer?.offerToken);
|
|
90
|
+
const r = await runHook(d, ["codex", "stop"], { session_id: "s1", cwd, stop_hook_active: false });
|
|
91
|
+
const out = JSON.parse(r.stdout);
|
|
92
|
+
assert.equal(out.decision, "block");
|
|
93
|
+
assert.ok(out.reason.includes(offer.offerToken));
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await d.stop();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
test("hooks: claude-code await-offer wakes (exit 2 + stderr, no token leak) on offer", async () => {
|
|
100
|
+
const d = await startDaemon();
|
|
101
|
+
try {
|
|
102
|
+
const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
|
|
103
|
+
const c = d.client();
|
|
104
|
+
await c.connect();
|
|
105
|
+
// holder + waiter, offer NOT yet delivered
|
|
106
|
+
await c.request("acquire", { agentId: "hook-holder2", opKey: "h2", reason: "holding" });
|
|
107
|
+
await c.request("acquire", { agentId: "hook-waiter2", opKey: "w2", reason: "waiting", cwd });
|
|
108
|
+
const awaiting = runHook(d, ["claude-code", "await-offer"], { session_id: "s1", cwd }, { LOAD_LOCK_AWAIT_MS: "8000" });
|
|
109
|
+
await delay(400);
|
|
110
|
+
// release the holder -> offer created for the waiter in `cwd`
|
|
111
|
+
const held = (await c.request("pending", { agentId: "hook-holder2" }));
|
|
112
|
+
await c.request("release", { permitToken: held.held[0].permitToken });
|
|
113
|
+
const r = await awaiting;
|
|
114
|
+
assert.equal(r.code, 2, "asyncRewake contract: exit code 2 wakes Claude");
|
|
115
|
+
assert.ok(r.stderr.includes("claim"), "stderr explains how to claim");
|
|
116
|
+
assert.ok(!r.stderr.includes("off_"), "the offer token itself is NOT in stderr (session must claim via pending)");
|
|
117
|
+
assert.equal(r.stdout, "");
|
|
118
|
+
// timeout path: no offer in window -> exit 0, silent
|
|
119
|
+
const t = await runHook(d, ["claude-code", "await-offer"], { session_id: "s1", cwd: "/nowhere" }, { LOAD_LOCK_AWAIT_MS: "500" });
|
|
120
|
+
assert.equal(t.code, 0);
|
|
121
|
+
assert.equal(t.stderr, "");
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await d.stop();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server integration tests: spawn dist/mcp/main.js as a real MCP stdio
|
|
3
|
+
* server (the way harnesses launch it) and exercise the tool round trips,
|
|
4
|
+
* including the run_with_permit lifecycle.
|
|
5
|
+
*/
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
|
+
import { startDaemon, delay, mcpMainPath } from "./testutil.js";
|
|
11
|
+
async function connectMcp(d) {
|
|
12
|
+
const client = new Client({ name: "load-lock-test", version: "0.1.0" }, { capabilities: {} });
|
|
13
|
+
const transport = new StdioClientTransport({
|
|
14
|
+
command: process.execPath,
|
|
15
|
+
args: [mcpMainPath],
|
|
16
|
+
env: d.env,
|
|
17
|
+
});
|
|
18
|
+
await client.connect(transport);
|
|
19
|
+
return client;
|
|
20
|
+
}
|
|
21
|
+
async function callJson(c, name, args) {
|
|
22
|
+
const res = await c.callTool({ name, arguments: args });
|
|
23
|
+
assert.equal(res.isError, undefined, `tool ${name} errored: ${JSON.stringify(res.content)}`);
|
|
24
|
+
return JSON.parse(res.content[0].text);
|
|
25
|
+
}
|
|
26
|
+
test("mcp: tool listing + acquire/release/configure round trip", async () => {
|
|
27
|
+
const d = await startDaemon();
|
|
28
|
+
const c = await connectMcp(d);
|
|
29
|
+
try {
|
|
30
|
+
const tools = await c.listTools();
|
|
31
|
+
const names = tools.tools.map((t) => t.name);
|
|
32
|
+
for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started"]) {
|
|
33
|
+
assert.ok(names.includes(expected), `tool ${expected} registered`);
|
|
34
|
+
}
|
|
35
|
+
const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
|
|
36
|
+
assert.equal(acq.state, "granted");
|
|
37
|
+
assert.ok(acq.permit_token, "acquire result carries permit_token");
|
|
38
|
+
assert.ok(acq.next_action, "structured next_action present");
|
|
39
|
+
// idempotent acquire with the same op_key
|
|
40
|
+
const acq2 = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
|
|
41
|
+
assert.equal(acq2.request_id, acq.request_id, "same op_key -> same request");
|
|
42
|
+
// a second agent queues
|
|
43
|
+
const acqB = await callJson(c, "acquire", { reason: "tests", op_key: "mcp-b", agent_id: "mcpAgentB" });
|
|
44
|
+
assert.equal(acqB.state, "queued");
|
|
45
|
+
assert.equal(acqB.queue_position, 1);
|
|
46
|
+
// configure: capacity up (CAS)
|
|
47
|
+
const cfg = await callJson(c, "get_config", {});
|
|
48
|
+
const conf = await callJson(c, "configure", {
|
|
49
|
+
expected_version: cfg.config.version,
|
|
50
|
+
changes: { pools: { heavy: { capacity: 2 } } },
|
|
51
|
+
reason: "test: two cores of headroom",
|
|
52
|
+
author: "mcp test",
|
|
53
|
+
});
|
|
54
|
+
assert.equal(conf.config.version, cfg.config.version + 1);
|
|
55
|
+
// capacity increase admits the queued request -> offered to B
|
|
56
|
+
const pend = await callJson(c, "pending", { agent_id: "mcpAgentB" });
|
|
57
|
+
assert.equal(pend.offers.length, 1, "B has an outstanding offer after capacity increase");
|
|
58
|
+
assert.ok(pend.offers[0].offerToken, "offer carries token");
|
|
59
|
+
// claim it
|
|
60
|
+
const claimed = await callJson(c, "claim", { offer_token: pend.offers[0].offerToken });
|
|
61
|
+
assert.equal(claimed.state, "held");
|
|
62
|
+
assert.ok(claimed.permit_token);
|
|
63
|
+
// duplicate claim must fail cleanly (expired/stale token), not corrupt state
|
|
64
|
+
const dup = await c.callTool({ name: "claim", arguments: { offer_token: pend.offers[0].offerToken } });
|
|
65
|
+
assert.ok(dup.isError, "duplicate claim rejected (stale offer token)");
|
|
66
|
+
// releases are idempotent
|
|
67
|
+
const rel1 = await callJson(c, "release", { permit_token: acq.permit_token });
|
|
68
|
+
assert.equal(rel1.released_now, true);
|
|
69
|
+
const rel2 = await callJson(c, "release", { permit_token: acq.permit_token });
|
|
70
|
+
assert.equal(rel2.released_now, false, "second release is idempotent");
|
|
71
|
+
const st = await callJson(c, "status", {});
|
|
72
|
+
assert.equal(st.pools[0].capacity, 2);
|
|
73
|
+
assert.ok(st.pools[0].holders.length <= 1);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await c.close();
|
|
77
|
+
await d.stop();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
test("mcp: run_with_permit tracks a background job and auto-releases", async () => {
|
|
81
|
+
const d = await startDaemon();
|
|
82
|
+
const c = await connectMcp(d);
|
|
83
|
+
try {
|
|
84
|
+
const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-job" });
|
|
85
|
+
assert.equal(acq.state, "granted");
|
|
86
|
+
const job = await callJson(c, "run_with_permit", {
|
|
87
|
+
permit_token: acq.permit_token,
|
|
88
|
+
command: `sh -c 'sleep 1 && echo done'`,
|
|
89
|
+
});
|
|
90
|
+
assert.ok(job.job_id, "returns a job handle for background execution");
|
|
91
|
+
assert.equal(job.state, "running");
|
|
92
|
+
// status while running
|
|
93
|
+
const running = await callJson(c, "job_status", { job_id: job.job_id });
|
|
94
|
+
assert.equal(running.running, true);
|
|
95
|
+
// wait for completion (poll with cap)
|
|
96
|
+
let exited = null;
|
|
97
|
+
for (let i = 0; i < 40 && !exited; i++) {
|
|
98
|
+
await delay(150);
|
|
99
|
+
const s = await callJson(c, "job_status", { job_id: job.job_id });
|
|
100
|
+
if (!s.running)
|
|
101
|
+
exited = s;
|
|
102
|
+
}
|
|
103
|
+
assert.ok(exited, "job completed");
|
|
104
|
+
assert.equal(exited.exit_code, 0);
|
|
105
|
+
assert.ok(exited.output_tail.includes("done"));
|
|
106
|
+
// auto-release happened without an explicit release call
|
|
107
|
+
await delay(300);
|
|
108
|
+
const st = await callJson(c, "status", {});
|
|
109
|
+
assert.equal(st.pools[0].holders.length, 0, "permit auto-released after job exit");
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await c.close();
|
|
113
|
+
await d.stop();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
test("mcp: multiple server processes share one machine-wide budget", async () => {
|
|
117
|
+
const d = await startDaemon();
|
|
118
|
+
const c1 = await connectMcp(d);
|
|
119
|
+
const c2 = await connectMcp(d); // separate process = separate harness session
|
|
120
|
+
try {
|
|
121
|
+
const a1 = await callJson(c1, "acquire", { reason: "agent1 build", op_key: "a1" });
|
|
122
|
+
assert.equal(a1.state, "granted");
|
|
123
|
+
const a2 = await callJson(c2, "acquire", { reason: "agent2 tests", op_key: "a2" });
|
|
124
|
+
assert.equal(a2.state, "queued", "second harness session sees the same capacity budget");
|
|
125
|
+
assert.equal(a2.queue_position, 1);
|
|
126
|
+
await callJson(c1, "release", { permit_token: a1.permit_token });
|
|
127
|
+
// the offer for agent2 shows up in ITS tool boundary results
|
|
128
|
+
const pend = await callJson(c2, "pending", {});
|
|
129
|
+
assert.equal(pend.offers.length, 1);
|
|
130
|
+
const claimed = await callJson(c2, "claim", { offer_token: pend.offers[0].offerToken });
|
|
131
|
+
assert.equal(claimed.state, "held");
|
|
132
|
+
await callJson(c2, "release", { permit_token: claimed.permit_token });
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await c1.close();
|
|
136
|
+
await c2.close();
|
|
137
|
+
await d.stop();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end tests for the `load-lock run` helper: separate CLI processes
|
|
3
|
+
* (independent OS processes, as a real agent would spawn them) against a real
|
|
4
|
+
* daemon; exit codes and automatic release semantics.
|
|
5
|
+
*/
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { startDaemon, runCli, delay } from "./testutil.js";
|
|
9
|
+
test("run: success and failure propagate exit codes and auto-release", async () => {
|
|
10
|
+
const d = await startDaemon();
|
|
11
|
+
try {
|
|
12
|
+
const ok = await runCli(d, ["run", "--reason", "build", "--op-key", "ok1", "--", "sh", "-c", "echo heavy-done"]);
|
|
13
|
+
assert.equal(ok.code, 0, `stdout: ${ok.stdout}\nstderr: ${ok.stderr}`);
|
|
14
|
+
assert.ok(ok.stdout.includes("heavy-done"));
|
|
15
|
+
const bad = await runCli(d, ["run", "--reason", "build", "--op-key", "bad1", "--", "sh", "-c", "exit 3"]);
|
|
16
|
+
assert.equal(bad.code, 3, "the job's exit code propagates");
|
|
17
|
+
// both permits auto-released
|
|
18
|
+
const c = d.client();
|
|
19
|
+
await c.connect();
|
|
20
|
+
const st = (await c.request("status", {}));
|
|
21
|
+
assert.equal(st.pools[0].holders.length, 0);
|
|
22
|
+
assert.equal(st.pools[0].used, 0);
|
|
23
|
+
c.close();
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await d.stop();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
test("run: exits 68 (queued) while another process holds the permit; FIFO continuation", async () => {
|
|
30
|
+
const d = await startDaemon();
|
|
31
|
+
const c = d.client();
|
|
32
|
+
try {
|
|
33
|
+
await c.connect();
|
|
34
|
+
// a holder that keeps the permit for ~2s, in a SEPARATE process
|
|
35
|
+
const holder = runCli(d, ["run", "--reason", "hold", "--op-key", "hold1", "--wait-ms", "0", "--", "sleep", "2"]);
|
|
36
|
+
await (async () => {
|
|
37
|
+
// wait until the holder actually holds
|
|
38
|
+
for (let i = 0; i < 50; i++) {
|
|
39
|
+
const st = (await c.request("status", {}));
|
|
40
|
+
if (st.pools[0].holders.length === 1)
|
|
41
|
+
return;
|
|
42
|
+
await delay(100);
|
|
43
|
+
}
|
|
44
|
+
throw new Error("holder did not acquire");
|
|
45
|
+
})();
|
|
46
|
+
const queued = await runCli(d, ["run", "--reason", "second", "--op-key", "q1", "--wait-ms", "0", "--", "true"]);
|
|
47
|
+
assert.equal(queued.code, 68, "queued run exits 68 instead of blocking forever");
|
|
48
|
+
assert.equal((await holder).code, 0);
|
|
49
|
+
// after the holder finishes, capacity is free again
|
|
50
|
+
const st2 = (await c.request("status", {}));
|
|
51
|
+
assert.equal(st2.pools[0].holders.length, 0);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await d.stop();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
test("wait: bounded blocking offer-watcher for scriptable flows", async () => {
|
|
58
|
+
const d = await startDaemon();
|
|
59
|
+
const c = d.client();
|
|
60
|
+
try {
|
|
61
|
+
await c.connect();
|
|
62
|
+
// A holds; B queues (as a separate CLI process for realism)
|
|
63
|
+
const acqA = await runCli(d, ["acquire", "--agent", "agentA", "--op-key", "a1", "--reason", "hold", "--json"]);
|
|
64
|
+
assert.equal(acqA.code, 0);
|
|
65
|
+
const aPermit = JSON.parse(acqA.stdout).entry.permitToken;
|
|
66
|
+
const acqB = await runCli(d, ["acquire", "--agent", "agentB", "--op-key", "b1", "--reason", "waiter", "--json"]);
|
|
67
|
+
assert.equal(acqB.code, 68);
|
|
68
|
+
const bReqId = JSON.parse(acqB.stdout).entry.requestId;
|
|
69
|
+
// waiter watches for its offer in a separate process
|
|
70
|
+
const waiter = runCli(d, ["wait", "--id", bReqId, "--agent", "agentB", "--timeout", "8000", "--json"]);
|
|
71
|
+
await delay(300);
|
|
72
|
+
await c.request("release", { permitToken: aPermit });
|
|
73
|
+
const w = await waiter;
|
|
74
|
+
assert.equal(w.code, 0, `waiter stdout: ${w.stdout}\nstderr: ${w.stderr}`);
|
|
75
|
+
const permit = JSON.parse(w.stdout).permit_token;
|
|
76
|
+
assert.ok(permit, "wait prints the claimed permit token");
|
|
77
|
+
// clean up: release B
|
|
78
|
+
const rel = await runCli(d, ["release", "--permit", permit, "--json"]);
|
|
79
|
+
assert.equal(rel.code, 0);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await d.stop();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for integration tests (not matched by the test-file pattern).
|
|
3
|
+
*/
|
|
4
|
+
import { type ChildProcess } from "node:child_process";
|
|
5
|
+
import { LLClient } from "../shared/client.js";
|
|
6
|
+
export declare const daemonMainPath: string;
|
|
7
|
+
export declare const cliMainPath: string;
|
|
8
|
+
export declare const mcpMainPath: string;
|
|
9
|
+
export interface DaemonHandle {
|
|
10
|
+
child: ChildProcess;
|
|
11
|
+
dir: string;
|
|
12
|
+
sock: string;
|
|
13
|
+
env: Record<string, string>;
|
|
14
|
+
client(): LLClient;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function startDaemon(): Promise<DaemonHandle>;
|
|
18
|
+
export declare function probe(sock: string): Promise<boolean>;
|
|
19
|
+
export declare function delay(ms: number): Promise<void>;
|
|
20
|
+
export declare function until(desc: string, fn: () => boolean | Promise<boolean>, timeoutMs?: number, step?: number): Promise<void>;
|
|
21
|
+
export interface CliResult {
|
|
22
|
+
code: number | null;
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
}
|
|
26
|
+
/** Run the compiled CLI as a separate process against the given daemon env. */
|
|
27
|
+
export declare function runCli(d: DaemonHandle, args: string[], input?: string, timeoutMs?: number): Promise<CliResult>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for integration tests (not matched by the test-file pattern).
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { connect } from "node:net";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { LLClient } from "../shared/client.js";
|
|
11
|
+
const TMP_BASE = join(tmpdir(), "opencode");
|
|
12
|
+
mkdirSync(TMP_BASE, { recursive: true });
|
|
13
|
+
export const daemonMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "core", "daemon-main.js");
|
|
14
|
+
export const cliMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli", "main.js");
|
|
15
|
+
export const mcpMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "mcp", "main.js");
|
|
16
|
+
export async function startDaemon() {
|
|
17
|
+
const dir = mkdtempSync(join(TMP_BASE, "ll-daemon-"));
|
|
18
|
+
const sock = join(dir, "daemon.sock");
|
|
19
|
+
const env = {
|
|
20
|
+
...process.env,
|
|
21
|
+
LOAD_LOCK_STATE_DIR: dir,
|
|
22
|
+
LOAD_LOCK_SOCKET: sock,
|
|
23
|
+
};
|
|
24
|
+
const child = spawn(process.execPath, [daemonMainPath, "--state-dir", dir, "--socket", sock], {
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
26
|
+
env,
|
|
27
|
+
});
|
|
28
|
+
const deadline = Date.now() + 10_000;
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (await probe(sock))
|
|
31
|
+
break;
|
|
32
|
+
if (Date.now() > deadline) {
|
|
33
|
+
child.kill("SIGKILL");
|
|
34
|
+
throw new Error("daemon did not come up");
|
|
35
|
+
}
|
|
36
|
+
await delay(100);
|
|
37
|
+
}
|
|
38
|
+
const handle = {
|
|
39
|
+
child,
|
|
40
|
+
dir,
|
|
41
|
+
sock,
|
|
42
|
+
env,
|
|
43
|
+
client: () => new LLClient({ socket: sock, stateDir: dir, spawnDaemon: false }),
|
|
44
|
+
stop: async () => {
|
|
45
|
+
if (child.exitCode === null && !child.killed) {
|
|
46
|
+
child.kill("SIGTERM");
|
|
47
|
+
await new Promise((resolve) => {
|
|
48
|
+
const t = setTimeout(() => {
|
|
49
|
+
child.kill("SIGKILL");
|
|
50
|
+
resolve();
|
|
51
|
+
}, 3000);
|
|
52
|
+
child.once("exit", () => {
|
|
53
|
+
clearTimeout(t);
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return handle;
|
|
62
|
+
}
|
|
63
|
+
export function probe(sock) {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const s = connect(sock);
|
|
66
|
+
s.once("connect", () => {
|
|
67
|
+
s.destroy();
|
|
68
|
+
resolve(true);
|
|
69
|
+
});
|
|
70
|
+
s.once("error", () => resolve(false));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function delay(ms) {
|
|
74
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
75
|
+
}
|
|
76
|
+
export async function until(desc, fn, timeoutMs = 15_000, step = 200) {
|
|
77
|
+
const deadline = Date.now() + timeoutMs;
|
|
78
|
+
for (;;) {
|
|
79
|
+
if (await fn())
|
|
80
|
+
return;
|
|
81
|
+
if (Date.now() > deadline)
|
|
82
|
+
throw new Error(`timeout waiting for: ${desc}`);
|
|
83
|
+
await delay(step);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Run the compiled CLI as a separate process against the given daemon env. */
|
|
87
|
+
export function runCli(d, args, input, timeoutMs = 60_000) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const child = spawn(process.execPath, [cliMainPath, ...args], { env: d.env });
|
|
90
|
+
let stdout = "";
|
|
91
|
+
let stderr = "";
|
|
92
|
+
child.stdout.on("data", (b) => (stdout += b));
|
|
93
|
+
child.stderr.on("data", (b) => (stderr += b));
|
|
94
|
+
if (input !== undefined)
|
|
95
|
+
child.stdin.end(input);
|
|
96
|
+
else
|
|
97
|
+
child.stdin.end();
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
child.kill("SIGKILL");
|
|
100
|
+
reject(new Error(`CLI timed out: load-lock ${args.join(" ")}`));
|
|
101
|
+
}, timeoutMs);
|
|
102
|
+
child.once("exit", (code) => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
resolve({ code, stdout, stderr });
|
|
105
|
+
});
|
|
106
|
+
child.once("error", (e) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
reject(e);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
package/docs/ADAPTERS.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Writing a harness adapter for load-lock
|
|
2
|
+
|
|
3
|
+
load-lock ships first-class support for OpenCode, Claude Code, and Codex CLI,
|
|
4
|
+
plus a generic MCP fallback that works with any MCP-capable harness. This
|
|
5
|
+
guide is for anyone adding support for a *new* harness. Everything you need
|
|
6
|
+
is importable from the published package — no private APIs required.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { LLClient, withClient } from "@aron98/load-lock/shared/client";
|
|
10
|
+
import type * as P from "@aron98/load-lock/shared/protocol";
|
|
11
|
+
import { POLICY_BRIEF } from "@aron98/load-lock/policy/text";
|
|
12
|
+
import { buildServer } from "@aron98/load-lock/mcp/server";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What a good adapter provides (in priority order)
|
|
16
|
+
|
|
17
|
+
1. **Tools** — the agent must be able to call acquire / claim / release /
|
|
18
|
+
cancel / pending / status / inspect_machine / get_config / configure, and
|
|
19
|
+
ideally the lifecycle helpers (run_with_permit / job_status / attach_pid /
|
|
20
|
+
mark_started).
|
|
21
|
+
2. **Policy at session start** — inject `POLICY_BRIEF` where the harness
|
|
22
|
+
puts per-session instructions, and re-inject after context compaction if
|
|
23
|
+
the harness supports it. This is the part agents actually follow; tools
|
|
24
|
+
without teaching get ignored.
|
|
25
|
+
3. **Idle wakeup (if the harness allows it)** — when the daemon emits an
|
|
26
|
+
`offered` event for your session, deliver it to the agent at a safe
|
|
27
|
+
point (execution boundary), including the claim token and expiry. Never
|
|
28
|
+
interrupt an in-flight tool call.
|
|
29
|
+
4. **Heartbeats** — every ~30s while the session is alive, so
|
|
30
|
+
`mark_started` (untracked) permits aren't reclaimed while the agent is
|
|
31
|
+
merely quiet.
|
|
32
|
+
|
|
33
|
+
Pick the tier your harness allows:
|
|
34
|
+
|
|
35
|
+
## Tier 1 — native plugin (best)
|
|
36
|
+
|
|
37
|
+
For harnesses with an in-process extension API (this is what the OpenCode
|
|
38
|
+
adapter does — see `src/adapters/opencode/plugin.ts` as the worked example):
|
|
39
|
+
|
|
40
|
+
- One module-level `LLClient` (it auto-spawns the daemon on first use; a
|
|
41
|
+
machine typically runs one client per plugin host).
|
|
42
|
+
- Register tools that translate your harness's tool-call format into
|
|
43
|
+
`client.request(...)`. Give the agent a stable identity (harness session
|
|
44
|
+
id) and pass the session's cwd at acquire time.
|
|
45
|
+
- Subscribe `client.watch("*")` and route `offered` events to the owning
|
|
46
|
+
session via the harness's wake/synthetic-message API.
|
|
47
|
+
- Inject `POLICY_BRIEF` on session start and after every compaction.
|
|
48
|
+
- For heavy work, implement a `run_with_permit` helper following the
|
|
49
|
+
lifecycle contract below — or teach agents to use the CLI
|
|
50
|
+
(`load-lock run --permit ... -- <cmd>`), which already implements it.
|
|
51
|
+
|
|
52
|
+
## Tier 2 — hooks + MCP
|
|
53
|
+
|
|
54
|
+
For harnesses with lifecycle hooks but no in-process API (Claude Code and
|
|
55
|
+
Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary, or
|
|
56
|
+
`node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
|
|
57
|
+
to the CLI:
|
|
58
|
+
|
|
59
|
+
- `load-lock hook <harness> session-start` → prints `POLICY_BRIEF` +
|
|
60
|
+
machine summary for context injection (register for post-compaction too,
|
|
61
|
+
if the harness has a compact matcher).
|
|
62
|
+
- `load-lock hook <harness> stop` → checks cwd-matched offers and, if one
|
|
63
|
+
exists, prints the harness's "continue the session" JSON with the claim
|
|
64
|
+
token. Honors any stop-loop flags the harness provides.
|
|
65
|
+
- See `src/cli/hook.ts` for the implementation pattern and
|
|
66
|
+
`src/cli/install.ts` for the config-writing/installer pattern.
|
|
67
|
+
|
|
68
|
+
## Tier 3 — MCP only
|
|
69
|
+
|
|
70
|
+
Register the MCP server with the harness and put a short policy section in
|
|
71
|
+
whatever global instructions file the harness reads (GEMINI.md / rules /
|
|
72
|
+
hints...). Offers are surfaced in every tool result (tool-boundary
|
|
73
|
+
delivery), so correctness is preserved; what you lose is idle wakeup —
|
|
74
|
+
document that honestly (see docs/COMPATIBILITY.md).
|
|
75
|
+
|
|
76
|
+
## Protocol quick reference
|
|
77
|
+
|
|
78
|
+
Wire protocol: NDJSON over a Unix domain socket; `LLClient` handles
|
|
79
|
+
framing, timeouts, auto-spawn, and event subscriptions. Methods an adapter
|
|
80
|
+
normally touches:
|
|
81
|
+
|
|
82
|
+
| Method | Purpose |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `acquire {pool?, agentId, agentLabel?, cwd?, opKey, reason, weight?, waitMs?, subscribeIfBusy?}` | grant / queue (idempotent per agent+opKey) |
|
|
85
|
+
| `claim {offerToken}` | offer → permit (before starting heavy work) |
|
|
86
|
+
| `release {permitToken, outcome?}` | idempotent |
|
|
87
|
+
| `cancel {requestId}` / `rejoin {requestId}` / `decline {offerToken}` | queue hygiene |
|
|
88
|
+
| `pending {agentId}` | the caller's offers/queue/holds (attach to every tool result) |
|
|
89
|
+
| `status {pool?}` / `inspect` / `get_config` / `configure {expectedVersion, changes, reason, author}` | state + versioned CAS config |
|
|
90
|
+
| `start_job {permitToken, pid, pgid?}` | atomic permit↔process association (tracked) |
|
|
91
|
+
| `mark_started {permitToken}` | untracked lease fallback |
|
|
92
|
+
| `heartbeat {agentId}` | keep untracked leases alive |
|
|
93
|
+
| `watch {agentId\|*}` / `events {sinceEventId, agentId?}` | push/pull event streams (durable ids) |
|
|
94
|
+
| `offers_for_cwd {cwd}` | for hook scripts without session identity |
|
|
95
|
+
|
|
96
|
+
## Lifecycle contract (the part that must not be improvised)
|
|
97
|
+
|
|
98
|
+
- Associate a permit with real execution **atomically**: spawn the job in
|
|
99
|
+
its own process group, then `start_job` with pid+pgid; if association
|
|
100
|
+
fails, kill the group immediately. Register exit listeners *before*
|
|
101
|
+
awaiting the association RPC (a fast-exiting job can otherwise lose its
|
|
102
|
+
release event — this was a real bug here).
|
|
103
|
+
- Never release because the shell returned a background-task id; release
|
|
104
|
+
(or auto-release) on actual process-group completion.
|
|
105
|
+
- Do not auto-release on agent disconnect; that's the whole point of the
|
|
106
|
+
daemon holding ownership.
|
|
107
|
+
- Untracked (`mark_started`) permits are only as good as your heartbeats;
|
|
108
|
+
document the tradeoff and steer agents toward tracked paths.
|
|
109
|
+
|
|
110
|
+
## Testing your adapter
|
|
111
|
+
|
|
112
|
+
Reuse `src/test/testutil.ts`-style harness spawning (isolated
|
|
113
|
+
`LOAD_LOCK_STATE_DIR`/`LOAD_LOCK_SOCKET`, probe the socket, then
|
|
114
|
+
`LLClient`), or simply script against the CLI. Concurrency rules to verify:
|
|
115
|
+
capacity is never exceeded by held+offered, FIFO order holds, duplicate
|
|
116
|
+
acquires are idempotent, and a daemon restart keeps tracked permits
|
|
117
|
+
alive. Label your adapter honestly in docs/COMPATIBILITY.md:
|
|
118
|
+
implemented / contract-tested / live-verified.
|