@aron98/load-lock 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +31 -0
- package/dist/adapters/opencode/tui.js +268 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +740 -61
- package/dist/cli/main.js +36 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +98 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +12 -0
- package/dist/dashboard/frame.js +94 -0
- package/dist/dashboard/view.d.ts +148 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +405 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +33 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +215 -26
- package/docs/OPERATIONS.md +313 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
package/dist/test/mcp.test.js
CHANGED
|
@@ -29,7 +29,7 @@ test("mcp: tool listing + acquire/release/configure round trip", async () => {
|
|
|
29
29
|
try {
|
|
30
30
|
const tools = await c.listTools();
|
|
31
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"]) {
|
|
32
|
+
for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "wait_for_turn", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started"]) {
|
|
33
33
|
assert.ok(names.includes(expected), `tool ${expected} registered`);
|
|
34
34
|
}
|
|
35
35
|
const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
|
|
@@ -77,6 +77,41 @@ test("mcp: tool listing + acquire/release/configure round trip", async () => {
|
|
|
77
77
|
await d.stop();
|
|
78
78
|
}
|
|
79
79
|
});
|
|
80
|
+
test("mcp: wait_for_turn sleeps bounded and returns structured snake_case results", async () => {
|
|
81
|
+
const d = await startDaemon();
|
|
82
|
+
const c = await connectMcp(d);
|
|
83
|
+
try {
|
|
84
|
+
const a = await callJson(c, "acquire", { reason: "build", op_key: "wft-a", agent_id: "mcpWaitA" });
|
|
85
|
+
assert.equal(a.state, "granted");
|
|
86
|
+
const b = await callJson(c, "acquire", { reason: "tests", op_key: "wft-b", agent_id: "mcpWaitB" });
|
|
87
|
+
assert.equal(b.state, "queued");
|
|
88
|
+
// Timeout is a structured still_queued result — never a tool error.
|
|
89
|
+
const t0 = Date.now();
|
|
90
|
+
const out1 = await callJson(c, "wait_for_turn", { agent_id: "mcpWaitB", timeout_ms: 700 });
|
|
91
|
+
assert.equal(out1.state, "still_queued");
|
|
92
|
+
assert.equal(out1.request_id, b.request_id);
|
|
93
|
+
assert.equal(out1.queue_position, 1);
|
|
94
|
+
assert.equal(out1.queue_length, 1);
|
|
95
|
+
assert.ok(Array.isArray(out1.holders) && out1.holders.length >= 1, "holder info included");
|
|
96
|
+
assert.equal(out1.holders[0].agentId, "mcpWaitA");
|
|
97
|
+
assert.ok(out1.waited_ms >= 600 && Date.now() - t0 < 5_000, "wait respected the bound");
|
|
98
|
+
// The cap is discoverable via configure + get_config (snake_case surface).
|
|
99
|
+
const cfg = await callJson(c, "get_config", {});
|
|
100
|
+
assert.equal(cfg.config.maxTurnWaitMs, 600_000, "default turn-wait cap present in config");
|
|
101
|
+
// Wake path: release, sleep again, wake holding an auto-claimed permit.
|
|
102
|
+
await callJson(c, "release", { permit_token: a.permit_token });
|
|
103
|
+
const out2 = await callJson(c, "wait_for_turn", { agent_id: "mcpWaitB", timeout_ms: 10_000 });
|
|
104
|
+
assert.equal(out2.state, "granted");
|
|
105
|
+
assert.equal(out2.auto_claimed, true, "auto-claim is explicit in the response");
|
|
106
|
+
assert.ok(out2.permit_token, "granted result carries a top-level permit_token");
|
|
107
|
+
assert.equal(out2.request_id, b.request_id);
|
|
108
|
+
await callJson(c, "release", { permit_token: out2.permit_token });
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await c.close();
|
|
112
|
+
await d.stop();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
80
115
|
test("mcp: run_with_permit tracks a background job and auto-releases", async () => {
|
|
81
116
|
const d = await startDaemon();
|
|
82
117
|
const c = await connectMcp(d);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the cross-platform transport seam (src/shared/transport.ts)
|
|
3
|
+
* and its integration into state-dir/socket-path resolution (persist.ts).
|
|
4
|
+
*
|
|
5
|
+
* These tests run on Linux: the Windows branches are exercised by
|
|
6
|
+
* explicitly injecting platform = "win32" into the pure functions. No
|
|
7
|
+
* Windows machine was available for live verification — see
|
|
8
|
+
* docs/COMPATIBILITY.md (platform matrix).
|
|
9
|
+
*/
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync, mkdirSync, existsSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { createServer } from "node:net";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { DEFAULT_PIPE_NAME, isWindows, namedPipePath, resolveStateDir, resolveSocketPath, probeTransport, removeStaleEndpoint, killProcessGroup, } from "../shared/transport.js";
|
|
18
|
+
import { stateDir, socketPath } from "../core/persist.js";
|
|
19
|
+
import { isProcessAlive, jobAlive, aliveBySignalZero, processStartTime } from "../core/daemon.js";
|
|
20
|
+
const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
|
|
21
|
+
mkdirSync(TMP_BASE, { recursive: true });
|
|
22
|
+
const tmp = mkdtempSync(join(TMP_BASE, "ll-platform-"));
|
|
23
|
+
test("namedPipePath builds \\\\.\\pipe\\load-lock by default", () => {
|
|
24
|
+
assert.equal(namedPipePath(), "\\\\.\\pipe\\load-lock");
|
|
25
|
+
assert.equal(namedPipePath(), `\\\\.\\pipe\\${DEFAULT_PIPE_NAME}`);
|
|
26
|
+
assert.equal(namedPipePath("other"), "\\\\.\\pipe\\other");
|
|
27
|
+
});
|
|
28
|
+
test("isWindows only matches win32", () => {
|
|
29
|
+
assert.equal(isWindows("win32"), true);
|
|
30
|
+
assert.equal(isWindows("linux"), false);
|
|
31
|
+
assert.equal(isWindows("darwin"), false);
|
|
32
|
+
});
|
|
33
|
+
test("resolveStateDir: override wins over env and default", () => {
|
|
34
|
+
const env = { LOAD_LOCK_STATE_DIR: "/from-env", HOME: "/home/u" };
|
|
35
|
+
assert.equal(resolveStateDir("win32", env, "/explicit"), "/explicit");
|
|
36
|
+
assert.equal(resolveStateDir("linux", env, "/explicit"), "/explicit");
|
|
37
|
+
assert.equal(resolveStateDir("win32", env), "/from-env");
|
|
38
|
+
});
|
|
39
|
+
test("resolveStateDir: win32 uses LOCALAPPDATA, then USERPROFILE fallback", () => {
|
|
40
|
+
const env = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", USERPROFILE: "C:\\Users\\u" };
|
|
41
|
+
assert.equal(resolveStateDir("win32", env), "C:\\Users\\u\\AppData\\Local\\load-lock");
|
|
42
|
+
const env2 = { USERPROFILE: "C:\\Users\\u" };
|
|
43
|
+
assert.equal(resolveStateDir("win32", env2), "C:\\Users\\u\\AppData\\Local\\load-lock");
|
|
44
|
+
});
|
|
45
|
+
test("resolveStateDir: linux uses XDG_STATE_HOME, then HOME fallback", () => {
|
|
46
|
+
const env = { XDG_STATE_HOME: "/xdg/state", HOME: "/home/u" };
|
|
47
|
+
assert.equal(resolveStateDir("linux", env), "/xdg/state/load-lock");
|
|
48
|
+
const env2 = { HOME: "/home/u" };
|
|
49
|
+
assert.equal(resolveStateDir("linux", env2), "/home/u/.local/state/load-lock");
|
|
50
|
+
});
|
|
51
|
+
test("resolveSocketPath: win32 default is the named pipe", () => {
|
|
52
|
+
const env = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" };
|
|
53
|
+
assert.equal(resolveSocketPath("win32", env), "\\\\.\\pipe\\load-lock");
|
|
54
|
+
});
|
|
55
|
+
test("resolveSocketPath: LOAD_LOCK_SOCKET override wins on every platform", () => {
|
|
56
|
+
const env = { LOAD_LOCK_SOCKET: "C:\\somewhere\\other.sock" };
|
|
57
|
+
assert.equal(resolveSocketPath("win32", env), "C:\\somewhere\\other.sock");
|
|
58
|
+
assert.equal(resolveSocketPath("linux", env), "C:\\somewhere\\other.sock");
|
|
59
|
+
});
|
|
60
|
+
test("resolveSocketPath: linux default is <state dir>/daemon.sock", () => {
|
|
61
|
+
const env = { HOME: "/home/u" };
|
|
62
|
+
assert.equal(resolveSocketPath("linux", env), "/home/u/.local/state/load-lock/daemon.sock");
|
|
63
|
+
});
|
|
64
|
+
test("resolveSocketPath: explicit override wins over everything", () => {
|
|
65
|
+
const env = { LOAD_LOCK_SOCKET: "/from-env", LOAD_LOCK_STATE_DIR: "/s" };
|
|
66
|
+
assert.equal(resolveSocketPath("linux", env, "/explicit"), "/explicit");
|
|
67
|
+
});
|
|
68
|
+
test("persist stateDir()/socketPath() delegate with the real platform", () => {
|
|
69
|
+
// On Linux (the only platform these tests run on) the defaults must keep
|
|
70
|
+
// their current shape, and env overrides must flow through.
|
|
71
|
+
const savedState = process.env.LOAD_LOCK_STATE_DIR;
|
|
72
|
+
const savedSock = process.env.LOAD_LOCK_SOCKET;
|
|
73
|
+
try {
|
|
74
|
+
delete process.env.LOAD_LOCK_STATE_DIR;
|
|
75
|
+
delete process.env.LOAD_LOCK_SOCKET;
|
|
76
|
+
assert.equal(socketPath(), resolveSocketPath(process.platform, process.env));
|
|
77
|
+
assert.equal(stateDir(), resolveStateDir(process.platform, process.env));
|
|
78
|
+
process.env.LOAD_LOCK_SOCKET = "/tmp/opencode/ll-platform-override.sock";
|
|
79
|
+
assert.equal(socketPath(), "/tmp/opencode/ll-platform-override.sock");
|
|
80
|
+
assert.equal(socketPath("/cli-flag.sock"), "/cli-flag.sock");
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (savedState === undefined)
|
|
84
|
+
delete process.env.LOAD_LOCK_STATE_DIR;
|
|
85
|
+
else
|
|
86
|
+
process.env.LOAD_LOCK_STATE_DIR = savedState;
|
|
87
|
+
if (savedSock === undefined)
|
|
88
|
+
delete process.env.LOAD_LOCK_SOCKET;
|
|
89
|
+
else
|
|
90
|
+
process.env.LOAD_LOCK_SOCKET = savedSock;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
async function withUdsServer(fn) {
|
|
94
|
+
const sockPath = join(tmp, "probe.sock");
|
|
95
|
+
const server = createServer();
|
|
96
|
+
await new Promise((resolve) => server.listen(sockPath, resolve));
|
|
97
|
+
try {
|
|
98
|
+
await fn(sockPath, server);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
102
|
+
rmSync(sockPath, { force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
test("probeTransport: true for a live server, false after close", async () => {
|
|
106
|
+
await withUdsServer(async (sockPath, server) => {
|
|
107
|
+
assert.equal(await probeTransport(sockPath), true);
|
|
108
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
109
|
+
assert.equal(await probeTransport(sockPath), false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
test("probeTransport: false for a nonexistent endpoint (never throws)", async () => {
|
|
113
|
+
assert.equal(await probeTransport(join(tmp, "no-such.sock")), false);
|
|
114
|
+
});
|
|
115
|
+
test("removeStaleEndpoint: POSIX unlinks, win32 is a no-op", () => {
|
|
116
|
+
const stale = join(tmp, "stale.sock");
|
|
117
|
+
writeFileSync(stale, "");
|
|
118
|
+
removeStaleEndpoint(stale, "linux");
|
|
119
|
+
assert.equal(existsSync(stale), false);
|
|
120
|
+
const stale2 = join(tmp, "stale2.sock");
|
|
121
|
+
writeFileSync(stale2, "");
|
|
122
|
+
removeStaleEndpoint(stale2, "win32");
|
|
123
|
+
assert.equal(existsSync(stale2), true); // untouched on Windows
|
|
124
|
+
rmSync(stale2, { force: true });
|
|
125
|
+
// Missing file: must not throw.
|
|
126
|
+
removeStaleEndpoint(join(tmp, "never-there.sock"), "linux");
|
|
127
|
+
});
|
|
128
|
+
test("killProcessGroup: POSIX path kills a detached process group", async () => {
|
|
129
|
+
const child = spawn("sleep", ["30"], { detached: true, stdio: "ignore" });
|
|
130
|
+
child.unref();
|
|
131
|
+
const pid = child.pid;
|
|
132
|
+
// Give the process a moment to be scheduled.
|
|
133
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
134
|
+
killProcessGroup(pid, "SIGKILL", "linux");
|
|
135
|
+
const exited = await new Promise((resolve) => {
|
|
136
|
+
const t = setTimeout(() => resolve(false), 5000);
|
|
137
|
+
child.once("exit", () => {
|
|
138
|
+
clearTimeout(t);
|
|
139
|
+
resolve(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
assert.equal(exited, true);
|
|
143
|
+
});
|
|
144
|
+
test("killProcessGroup: win32 branch on Linux swallows taskkill failure", () => {
|
|
145
|
+
// taskkill does not exist on Linux; the spawn must not throw.
|
|
146
|
+
assert.doesNotThrow(() => killProcessGroup(12345, "SIGKILL", "win32"));
|
|
147
|
+
assert.doesNotThrow(() => killProcessGroup(12345, "SIGTERM", "win32"));
|
|
148
|
+
});
|
|
149
|
+
test("killProcessGroup: POSIX path tolerates a dead pgid", () => {
|
|
150
|
+
assert.doesNotThrow(() => killProcessGroup(999_999, "SIGKILL", "linux"));
|
|
151
|
+
});
|
|
152
|
+
// ------------------------------------------------------------- liveness (win32)
|
|
153
|
+
test("isProcessAlive: win32 branch uses signal-0 semantics (injected platform)", () => {
|
|
154
|
+
assert.equal(isProcessAlive(process.pid, undefined, "win32"), true);
|
|
155
|
+
// A start time can't be verified without /proc — pid must still count as
|
|
156
|
+
// alive (conservative; the pid-reuse defense is documented as absent).
|
|
157
|
+
assert.equal(isProcessAlive(process.pid, "9999", "win32"), true);
|
|
158
|
+
// 4194303 is the Windows ceiling; a pid that high is never in use here.
|
|
159
|
+
assert.equal(isProcessAlive(4_194_303, undefined, "win32"), false);
|
|
160
|
+
});
|
|
161
|
+
test("aliveBySignalZero: own pid alive, pid 1 alive (ours or EPERM), huge pid dead", () => {
|
|
162
|
+
assert.equal(aliveBySignalZero(process.pid), true);
|
|
163
|
+
// On Linux init is owned by root: signal 0 yields EPERM, which must count
|
|
164
|
+
// as alive. On a container where we are root, it trivially succeeds.
|
|
165
|
+
assert.equal(aliveBySignalZero(1), true);
|
|
166
|
+
assert.equal(aliveBySignalZero(4_194_303), false);
|
|
167
|
+
});
|
|
168
|
+
test("isProcessAlive: Linux branch still checks /proc start time", () => {
|
|
169
|
+
const st = processStartTime(process.pid);
|
|
170
|
+
assert.ok(st !== undefined, "/proc start time must be readable on Linux");
|
|
171
|
+
assert.equal(isProcessAlive(process.pid, st, "linux"), true);
|
|
172
|
+
assert.equal(isProcessAlive(process.pid, "9999", "linux"), false); // mismatch = reuse
|
|
173
|
+
});
|
|
174
|
+
test("jobAlive: win32 skips the process-group scan", () => {
|
|
175
|
+
assert.equal(jobAlive(process.pid, undefined, undefined, "win32"), true);
|
|
176
|
+
// Dead main pid: released even though a group scan would have been
|
|
177
|
+
// possible on Linux — Windows has no process groups.
|
|
178
|
+
assert.equal(jobAlive(4_194_303, process.pid, undefined, "win32"), false);
|
|
179
|
+
// pgid-only on win32: unknown => conservative keep.
|
|
180
|
+
assert.equal(jobAlive(undefined, 12345, undefined, "win32"), null);
|
|
181
|
+
});
|
|
182
|
+
test("jobAlive: Linux still scans process groups", () => {
|
|
183
|
+
assert.equal(jobAlive(process.pid, undefined, undefined, "linux"), true);
|
|
184
|
+
// pgid 1 exists but a dead pid + live foreign pgid keeps the job "alive"
|
|
185
|
+
// via the group scan — semantics unchanged on Linux.
|
|
186
|
+
assert.equal(jobAlive(4_194_303, 1, undefined, "linux"), true);
|
|
187
|
+
// Both dead => false on Linux (group 999999 has no members).
|
|
188
|
+
assert.equal(jobAlive(4_194_303, 999_999, undefined, "linux"), false);
|
|
189
|
+
});
|
|
190
|
+
// Clean up once everything finished.
|
|
191
|
+
test("cleanup tmp dir", () => {
|
|
192
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
193
|
+
});
|
package/dist/test/testutil.js
CHANGED
|
@@ -8,7 +8,10 @@ import { join, dirname } from "node:path";
|
|
|
8
8
|
import { connect } from "node:net";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { LLClient } from "../shared/client.js";
|
|
11
|
-
|
|
11
|
+
// Tests must never touch the user's real state dir. LL_TEST_TMP lets the
|
|
12
|
+
// suite run on machines where /tmp/opencode is not writable (same override
|
|
13
|
+
// as daemon.test.ts).
|
|
14
|
+
const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
|
|
12
15
|
mkdirSync(TMP_BASE, { recursive: true });
|
|
13
16
|
export const daemonMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "core", "daemon-main.js");
|
|
14
17
|
export const cliMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli", "main.js");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for wait_for_turn (issue #11): a REAL daemon process over
|
|
3
|
+
* a real Unix socket with multiple independent client connections. Exercises
|
|
4
|
+
* the shared client-side bounded wait (src/shared/wait-turn.ts) that serves
|
|
5
|
+
* the CLI `wait` command, the MCP wait_for_turn tool and the OpenCode plugin
|
|
6
|
+
* tool: FIFO wakeup with auto-claim, structured timeout, paused/rejoin,
|
|
7
|
+
* mid-wait cancellation, bounded resolution across a daemon restart, and
|
|
8
|
+
* clean AbortSignal cancellation with no orphaned waiter.
|
|
9
|
+
*/
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
|
|
14
|
+
import { startDaemon, delay, until, probe, daemonMainPath } from "./testutil.js";
|
|
15
|
+
const ACQ = (agentId, opKey, extra = {}) => ({
|
|
16
|
+
agentId,
|
|
17
|
+
opKey,
|
|
18
|
+
reason: "integration test",
|
|
19
|
+
...extra,
|
|
20
|
+
});
|
|
21
|
+
/** Start a wait that is expected to stay pending for at least `ms`. */
|
|
22
|
+
function expectPending(ms) {
|
|
23
|
+
return delay(ms).then(() => "pending");
|
|
24
|
+
}
|
|
25
|
+
test("wait_for_turn: sleeper wakes granted with an auto-claimed permit after the holder releases (FIFO preserved)", async () => {
|
|
26
|
+
const d = await startDaemon();
|
|
27
|
+
const clients = [];
|
|
28
|
+
try {
|
|
29
|
+
const cA = d.client();
|
|
30
|
+
await cA.connect();
|
|
31
|
+
clients.push(cA);
|
|
32
|
+
await cA.request("configure", {
|
|
33
|
+
expectedVersion: 1,
|
|
34
|
+
changes: { offerTimeoutMs: 60_000, maxWaitMs: 0 },
|
|
35
|
+
reason: "test timings",
|
|
36
|
+
author: "test",
|
|
37
|
+
});
|
|
38
|
+
const a = (await cA.request("acquire", ACQ("agentA", "opA")));
|
|
39
|
+
assert.equal(a.state, "granted");
|
|
40
|
+
const cB = d.client();
|
|
41
|
+
const cC = d.client();
|
|
42
|
+
await cB.connect();
|
|
43
|
+
await cC.connect();
|
|
44
|
+
clients.push(cB, cC);
|
|
45
|
+
const b = (await cB.request("acquire", ACQ("agentB", "opB")));
|
|
46
|
+
assert.equal(b.state, "queued");
|
|
47
|
+
assert.equal(b.entry.position, 1);
|
|
48
|
+
const c = (await cC.request("acquire", ACQ("agentC", "opC")));
|
|
49
|
+
assert.equal(c.state, "queued");
|
|
50
|
+
assert.equal(c.entry.position, 2);
|
|
51
|
+
// B sleeps (no request_id: default target resolution must pick the queued one).
|
|
52
|
+
const waitB = waitForTurn({ client: cB, agentId: "agentB", timeoutMs: 15_000 });
|
|
53
|
+
const early = await Promise.race([waitB.then(() => "done"), expectPending(400)]);
|
|
54
|
+
assert.equal(early, "pending", "the wait blocks while the holder keeps the permit");
|
|
55
|
+
const st = (await cA.request("status", {}));
|
|
56
|
+
assert.equal(st.pools[0].used, 1, "the sleeping wait holds no capacity");
|
|
57
|
+
// The holder finishes; B must wake already holding a claimed permit.
|
|
58
|
+
await cA.request("release", { permitToken: a.entry.permitToken });
|
|
59
|
+
const rB = await waitB;
|
|
60
|
+
assert.equal(rB.state, "granted");
|
|
61
|
+
assert.equal(rB.autoClaimed, true, "the offer was auto-claimed inside the claim window");
|
|
62
|
+
assert.ok(rB.entry?.permitToken, "granted result carries the permit token");
|
|
63
|
+
assert.ok(rB.entry.offerToken === undefined || !rB.entry.offerToken, "claimed permit has no lingering offer token");
|
|
64
|
+
assert.ok(rB.waitedMs >= 300);
|
|
65
|
+
// FIFO: C is still queued behind B's claimed permit, and goes next.
|
|
66
|
+
const pendC = (await cC.request("pending", { agentId: "agentC" }));
|
|
67
|
+
assert.equal(pendC.queue.length, 1);
|
|
68
|
+
assert.equal(pendC.queue[0].position, 1);
|
|
69
|
+
await cB.request("release", { permitToken: rB.entry.permitToken });
|
|
70
|
+
const rC = await waitForTurn({ client: cC, agentId: "agentC", timeoutMs: 15_000 });
|
|
71
|
+
assert.equal(rC.state, "granted");
|
|
72
|
+
assert.equal(rC.autoClaimed, true);
|
|
73
|
+
assert.ok(rC.entry?.permitToken);
|
|
74
|
+
await cC.request("release", { permitToken: rC.entry.permitToken });
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
for (const cl of clients)
|
|
78
|
+
cl.close();
|
|
79
|
+
await d.stop();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
test("wait_for_turn: timeout returns still_queued with position and holders, not an error", async () => {
|
|
83
|
+
const d = await startDaemon();
|
|
84
|
+
const clients = [];
|
|
85
|
+
try {
|
|
86
|
+
const cA = d.client();
|
|
87
|
+
await cA.connect();
|
|
88
|
+
clients.push(cA);
|
|
89
|
+
await cA.request("configure", {
|
|
90
|
+
expectedVersion: 1,
|
|
91
|
+
changes: { offerTimeoutMs: 60_000, maxWaitMs: 0 },
|
|
92
|
+
reason: "test timings",
|
|
93
|
+
author: "test",
|
|
94
|
+
});
|
|
95
|
+
const a = (await cA.request("acquire", ACQ("holderA", "opA")));
|
|
96
|
+
assert.equal(a.state, "granted");
|
|
97
|
+
const cB = d.client();
|
|
98
|
+
await cB.connect();
|
|
99
|
+
clients.push(cB);
|
|
100
|
+
const b = (await cB.request("acquire", ACQ("waiterB", "opB")));
|
|
101
|
+
assert.equal(b.state, "queued");
|
|
102
|
+
const requestId = b.entry.requestId;
|
|
103
|
+
const r = await waitForTurn({ client: cB, agentId: "waiterB", requestId, timeoutMs: 900 });
|
|
104
|
+
assert.equal(r.state, "still_queued", "timeout is a structured result, not an error");
|
|
105
|
+
assert.equal(r.requestId, requestId);
|
|
106
|
+
assert.equal(r.queuePosition, 1);
|
|
107
|
+
assert.equal(r.queueLength, 1);
|
|
108
|
+
assert.ok(Array.isArray(r.holders) && r.holders.length >= 1, "holder info included");
|
|
109
|
+
assert.equal(r.holders[0].agentId, "holderA");
|
|
110
|
+
assert.ok(r.waitedMs >= 800, `waited the full bound (waitedMs=${r.waitedMs})`);
|
|
111
|
+
assert.match(r.nextAction ?? "", /wait_for_turn|wait again/i);
|
|
112
|
+
// The queued request is untouched by the timed-out wait.
|
|
113
|
+
const pend = (await cB.request("pending", { agentId: "waiterB" }));
|
|
114
|
+
assert.equal(pend.queue.length, 1);
|
|
115
|
+
assert.equal(pend.queue[0].position, 1);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
for (const cl of clients)
|
|
119
|
+
cl.close();
|
|
120
|
+
await d.stop();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
test("wait_for_turn: paused request returns rejoin instructions, and rejoin then wait succeeds", async () => {
|
|
124
|
+
const d = await startDaemon();
|
|
125
|
+
const clients = [];
|
|
126
|
+
try {
|
|
127
|
+
const cA = d.client();
|
|
128
|
+
await cA.connect();
|
|
129
|
+
clients.push(cA);
|
|
130
|
+
const cfg = (await cA.request("get_config", {}));
|
|
131
|
+
await cA.request("configure", {
|
|
132
|
+
expectedVersion: cfg.config.version,
|
|
133
|
+
changes: { offerTimeoutMs: 1_000, maxWaitMs: 0 },
|
|
134
|
+
reason: "fast offer expiry for the paused path",
|
|
135
|
+
author: "test",
|
|
136
|
+
});
|
|
137
|
+
const a = (await cA.request("acquire", ACQ("holdA", "opA")));
|
|
138
|
+
assert.equal(a.state, "granted");
|
|
139
|
+
const cB = d.client();
|
|
140
|
+
await cB.connect();
|
|
141
|
+
clients.push(cB);
|
|
142
|
+
const b = (await cB.request("acquire", ACQ("pauseB", "opB")));
|
|
143
|
+
assert.equal(b.state, "queued");
|
|
144
|
+
const requestId = b.entry.requestId;
|
|
145
|
+
// Let the offer expire so the request lands in paused.
|
|
146
|
+
await cA.request("release", { permitToken: a.entry.permitToken });
|
|
147
|
+
await until("offer expires and B lands in paused", async () => {
|
|
148
|
+
const p = (await cB.request("pending", { agentId: "pauseB" }));
|
|
149
|
+
return p.paused.length === 1;
|
|
150
|
+
}, 15_000);
|
|
151
|
+
const r = await waitForTurn({ client: cB, agentId: "pauseB", requestId, timeoutMs: 5_000 });
|
|
152
|
+
assert.equal(r.state, "paused", "paused resolves immediately with a structured result");
|
|
153
|
+
assert.match(r.nextAction ?? "", /rejoin/i, "rejoin instructions included");
|
|
154
|
+
assert.equal(r.entry?.state, "paused");
|
|
155
|
+
// Rejoin, then sleep again: the freed capacity must reach B and auto-claim.
|
|
156
|
+
await cB.request("rejoin", { requestId });
|
|
157
|
+
const r2 = await waitForTurn({ client: cB, agentId: "pauseB", timeoutMs: 10_000 });
|
|
158
|
+
assert.equal(r2.state, "granted");
|
|
159
|
+
assert.equal(r2.autoClaimed, true);
|
|
160
|
+
assert.ok(r2.entry?.permitToken);
|
|
161
|
+
await cB.request("release", { permitToken: r2.entry.permitToken });
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
for (const cl of clients)
|
|
165
|
+
cl.close();
|
|
166
|
+
await d.stop();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
test("wait_for_turn: request cancelled mid-wait resolves cancelled with re-acquire instructions", async () => {
|
|
170
|
+
const d = await startDaemon();
|
|
171
|
+
const clients = [];
|
|
172
|
+
try {
|
|
173
|
+
const cA = d.client();
|
|
174
|
+
await cA.connect();
|
|
175
|
+
clients.push(cA);
|
|
176
|
+
await cA.request("configure", {
|
|
177
|
+
expectedVersion: 1,
|
|
178
|
+
changes: { offerTimeoutMs: 60_000, maxWaitMs: 0 },
|
|
179
|
+
reason: "test timings",
|
|
180
|
+
author: "test",
|
|
181
|
+
});
|
|
182
|
+
const a = (await cA.request("acquire", ACQ("holdA", "opA")));
|
|
183
|
+
assert.equal(a.state, "granted");
|
|
184
|
+
const cB = d.client();
|
|
185
|
+
await cB.connect();
|
|
186
|
+
clients.push(cB);
|
|
187
|
+
const b = (await cB.request("acquire", ACQ("waitB", "opB")));
|
|
188
|
+
assert.equal(b.state, "queued");
|
|
189
|
+
const requestId = b.entry.requestId;
|
|
190
|
+
const wait = waitForTurn({ client: cB, agentId: "waitB", requestId, timeoutMs: 15_000 });
|
|
191
|
+
const early = await Promise.race([wait.then(() => "done"), expectPending(400)]);
|
|
192
|
+
assert.equal(early, "pending");
|
|
193
|
+
// Someone else cancels the request while we sleep.
|
|
194
|
+
await cA.request("cancel", { requestId });
|
|
195
|
+
const r = await wait;
|
|
196
|
+
assert.equal(r.state, "cancelled", "cancellation resolves the wait with the request's state");
|
|
197
|
+
assert.match(r.nextAction ?? "", /acquire/i, "re-acquire instructions included");
|
|
198
|
+
const pend = (await cB.request("pending", { agentId: "waitB" }));
|
|
199
|
+
assert.equal(pend.queue.length + pend.paused.length + pend.offers.length, 0, "nothing orphaned in pending");
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
for (const cl of clients)
|
|
203
|
+
cl.close();
|
|
204
|
+
await d.stop();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
test("wait_for_turn: daemon restart mid-wait resolves bounded with not_found + re-acquire guidance", async () => {
|
|
208
|
+
const d = await startDaemon();
|
|
209
|
+
const clients = [];
|
|
210
|
+
let child2 = null;
|
|
211
|
+
try {
|
|
212
|
+
const cA = d.client();
|
|
213
|
+
await cA.connect();
|
|
214
|
+
clients.push(cA);
|
|
215
|
+
await cA.request("configure", {
|
|
216
|
+
expectedVersion: 1,
|
|
217
|
+
changes: { offerTimeoutMs: 60_000, maxWaitMs: 0 },
|
|
218
|
+
reason: "test timings",
|
|
219
|
+
author: "test",
|
|
220
|
+
});
|
|
221
|
+
const a = (await cA.request("acquire", ACQ("holdA", "opA")));
|
|
222
|
+
assert.equal(a.state, "granted");
|
|
223
|
+
const cB = d.client();
|
|
224
|
+
await cB.connect();
|
|
225
|
+
clients.push(cB);
|
|
226
|
+
const b = (await cB.request("acquire", ACQ("waitB", "opB")));
|
|
227
|
+
assert.equal(b.state, "queued");
|
|
228
|
+
const requestId = b.entry.requestId;
|
|
229
|
+
const wait = waitForTurn({ client: cB, agentId: "waitB", requestId, timeoutMs: 60_000 });
|
|
230
|
+
const early = await Promise.race([wait.then(() => "done"), expectPending(400)]);
|
|
231
|
+
assert.equal(early, "pending");
|
|
232
|
+
// Restart the daemon mid-wait.
|
|
233
|
+
const t0 = Date.now();
|
|
234
|
+
d.child.kill("SIGTERM");
|
|
235
|
+
await new Promise((r) => d.child.once("exit", r));
|
|
236
|
+
child2 = spawn(process.execPath, [daemonMainPath, "--state-dir", d.dir, "--socket", d.sock], {
|
|
237
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
238
|
+
env: { ...process.env, LOAD_LOCK_STATE_DIR: d.dir, LOAD_LOCK_SOCKET: d.sock },
|
|
239
|
+
});
|
|
240
|
+
await until("restarted daemon listens", async () => await probe(d.sock));
|
|
241
|
+
const r = await wait;
|
|
242
|
+
const elapsed = Date.now() - t0;
|
|
243
|
+
assert.ok(elapsed < 5_000, `restart resolution is bounded (took ${elapsed}ms)`);
|
|
244
|
+
assert.equal(r.state, "not_found", "queued requests do not survive a restart");
|
|
245
|
+
assert.match(r.nextAction ?? "", /acquire/i, "re-acquire guidance included");
|
|
246
|
+
assert.ok(r.waitedMs >= 300);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
if (child2 && child2.exitCode === null && !child2.killed)
|
|
250
|
+
child2.kill("SIGKILL");
|
|
251
|
+
for (const cl of clients)
|
|
252
|
+
cl.close();
|
|
253
|
+
// stop() would kill the already-dead child again + rm the dir; safe either way.
|
|
254
|
+
d.child.kill("SIGKILL");
|
|
255
|
+
await delay(100);
|
|
256
|
+
await d.stop();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
test("wait_for_turn: caller abort leaves no orphan — the request stays healthy and FIFO", async () => {
|
|
260
|
+
const d = await startDaemon();
|
|
261
|
+
const clients = [];
|
|
262
|
+
try {
|
|
263
|
+
const cA = d.client();
|
|
264
|
+
await cA.connect();
|
|
265
|
+
clients.push(cA);
|
|
266
|
+
await cA.request("configure", {
|
|
267
|
+
expectedVersion: 1,
|
|
268
|
+
changes: { offerTimeoutMs: 60_000, maxWaitMs: 0 },
|
|
269
|
+
reason: "test timings",
|
|
270
|
+
author: "test",
|
|
271
|
+
});
|
|
272
|
+
const a = (await cA.request("acquire", ACQ("holdA", "opA")));
|
|
273
|
+
assert.equal(a.state, "granted");
|
|
274
|
+
const cB = d.client();
|
|
275
|
+
await cB.connect();
|
|
276
|
+
clients.push(cB);
|
|
277
|
+
const b = (await cB.request("acquire", ACQ("waitB", "opB")));
|
|
278
|
+
assert.equal(b.state, "queued");
|
|
279
|
+
const requestId = b.entry.requestId;
|
|
280
|
+
const ac = new AbortController();
|
|
281
|
+
const wait = waitForTurn({ client: cB, agentId: "waitB", requestId, timeoutMs: 60_000, signal: ac.signal });
|
|
282
|
+
const early = await Promise.race([wait.then(() => "done"), expectPending(400)]);
|
|
283
|
+
assert.equal(early, "pending");
|
|
284
|
+
// The harness cancels the tool call mid-sleep.
|
|
285
|
+
ac.abort();
|
|
286
|
+
await assert.rejects(wait, (e) => e instanceof WaitAbortedError && e.name === "AbortError");
|
|
287
|
+
// Nothing orphaned: the request is still queued, capacity untouched, no claim consumed.
|
|
288
|
+
const pend = (await cB.request("pending", { agentId: "waitB" }));
|
|
289
|
+
assert.equal(pend.queue.length, 1, "the request keeps its queue slot after the abort");
|
|
290
|
+
assert.equal(pend.queue[0].position, 1);
|
|
291
|
+
assert.equal(pend.offers.length, 0);
|
|
292
|
+
const st = (await cA.request("status", {}));
|
|
293
|
+
assert.equal(st.pools[0].used, 1, "no capacity was consumed by the wait or the abort");
|
|
294
|
+
// The request is still perfectly usable: release, sleep again, get the turn.
|
|
295
|
+
await cA.request("release", { permitToken: a.entry.permitToken });
|
|
296
|
+
const r = await waitForTurn({ client: cB, agentId: "waitB", requestId, timeoutMs: 15_000 });
|
|
297
|
+
assert.equal(r.state, "granted");
|
|
298
|
+
assert.equal(r.autoClaimed, true);
|
|
299
|
+
assert.ok(r.entry?.permitToken);
|
|
300
|
+
await cB.request("release", { permitToken: r.entry.permitToken });
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
for (const cl of clients)
|
|
304
|
+
cl.close();
|
|
305
|
+
await d.stop();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
test("wait_for_turn: the maxTurnWaitMs config caps the bound (validated, persisted)", async () => {
|
|
309
|
+
const d = await startDaemon();
|
|
310
|
+
const clients = [];
|
|
311
|
+
try {
|
|
312
|
+
const cA = d.client();
|
|
313
|
+
await cA.connect();
|
|
314
|
+
clients.push(cA);
|
|
315
|
+
await cA.request("configure", {
|
|
316
|
+
expectedVersion: 1,
|
|
317
|
+
changes: { offerTimeoutMs: 60_000, maxWaitMs: 0 },
|
|
318
|
+
reason: "test timings",
|
|
319
|
+
author: "test",
|
|
320
|
+
});
|
|
321
|
+
const a = (await cA.request("acquire", ACQ("holdA", "opA")));
|
|
322
|
+
assert.equal(a.state, "granted");
|
|
323
|
+
const cB = d.client();
|
|
324
|
+
await cB.connect();
|
|
325
|
+
clients.push(cB);
|
|
326
|
+
const b = (await cB.request("acquire", ACQ("waitB", "opB")));
|
|
327
|
+
assert.equal(b.state, "queued");
|
|
328
|
+
// Out-of-range values are rejected by the configure validation.
|
|
329
|
+
await assert.rejects(cA.request("configure", {
|
|
330
|
+
expectedVersion: 2,
|
|
331
|
+
changes: { maxTurnWaitMs: 500 },
|
|
332
|
+
reason: "below the floor",
|
|
333
|
+
author: "test",
|
|
334
|
+
}), /maxTurnWaitMs/);
|
|
335
|
+
const cfg = (await cA.request("get_config", {}));
|
|
336
|
+
assert.equal(cfg.config.maxTurnWaitMs, 600_000, "default cap is 10 minutes");
|
|
337
|
+
const conf = (await cA.request("configure", {
|
|
338
|
+
expectedVersion: cfg.config.version,
|
|
339
|
+
changes: { maxTurnWaitMs: 1_000 },
|
|
340
|
+
reason: "short cap for the test",
|
|
341
|
+
author: "test",
|
|
342
|
+
}));
|
|
343
|
+
assert.equal(conf.config.maxTurnWaitMs, 1_000, "cap persisted via CAS configure");
|
|
344
|
+
// A 10-minute ask is clamped to the 1-second machine cap and reported.
|
|
345
|
+
const t0 = Date.now();
|
|
346
|
+
const r = await waitForTurn({ client: cB, agentId: "waitB", timeoutMs: 600_000 });
|
|
347
|
+
const elapsed = Date.now() - t0;
|
|
348
|
+
assert.equal(r.state, "still_queued");
|
|
349
|
+
assert.equal(r.effectiveWaitMs, 1_000, "the effective (clamped) bound is reported");
|
|
350
|
+
assert.ok(elapsed < 2_500, `the wait respected the clamp (took ${elapsed}ms)`);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
for (const cl of clients)
|
|
354
|
+
cl.close();
|
|
355
|
+
await d.stop();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
test("wait_for_turn: nothing to watch resolves not_found immediately", async () => {
|
|
359
|
+
const d = await startDaemon();
|
|
360
|
+
const clients = [];
|
|
361
|
+
try {
|
|
362
|
+
const c = d.client();
|
|
363
|
+
await c.connect();
|
|
364
|
+
clients.push(c);
|
|
365
|
+
const t0 = Date.now();
|
|
366
|
+
const r = await waitForTurn({ client: c, agentId: "nobody", timeoutMs: 5_000 });
|
|
367
|
+
assert.ok(Date.now() - t0 < 1_000, "no busy-wait when there is nothing to watch");
|
|
368
|
+
assert.equal(r.state, "not_found");
|
|
369
|
+
assert.match(r.nextAction ?? "", /acquire/i);
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
for (const cl of clients)
|
|
373
|
+
cl.close();
|
|
374
|
+
await d.stop();
|
|
375
|
+
}
|
|
376
|
+
});
|
package/docs/ADAPTERS.md
CHANGED
|
@@ -71,11 +71,14 @@ Register the MCP server with the harness and put a short policy section in
|
|
|
71
71
|
whatever global instructions file the harness reads (GEMINI.md / rules /
|
|
72
72
|
hints...). Offers are surfaced in every tool result (tool-boundary
|
|
73
73
|
delivery), so correctness is preserved; what you lose is idle wakeup —
|
|
74
|
-
|
|
74
|
+
steer agents to `wait_for_turn` (bounded blocking sleep that auto-claims
|
|
75
|
+
and returns a `permit_token`) as the primary idle mechanism, and document
|
|
76
|
+
the remaining gap honestly (see docs/COMPATIBILITY.md).
|
|
75
77
|
|
|
76
78
|
## Protocol quick reference
|
|
77
79
|
|
|
78
|
-
Wire protocol: NDJSON over a Unix domain socket
|
|
80
|
+
Wire protocol: NDJSON over a Unix domain socket (named pipe on Windows);
|
|
81
|
+
`LLClient` handles
|
|
79
82
|
framing, timeouts, auto-spawn, and event subscriptions. Methods an adapter
|
|
80
83
|
normally touches:
|
|
81
84
|
|