@aron98/load-lock 0.1.2 → 1.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/README.md +40 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +61 -0
- package/dist/adapters/opencode/tui.js +358 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +708 -24
- package/dist/cli/main.js +130 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +99 -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 +13 -0
- package/dist/dashboard/frame.js +95 -0
- package/dist/dashboard/view.d.ts +149 -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/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +99 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +406 -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/tui.test.d.ts +1 -0
- package/dist/test/tui.test.js +89 -0
- 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 +34 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +217 -26
- package/docs/OPERATIONS.md +335 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
|
@@ -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
|
|
package/docs/AGENT_GUIDE.md
CHANGED
|
@@ -32,6 +32,8 @@ acquire(reason, op_key)
|
|
|
32
32
|
├─ granted → you hold a permit → do the heavy work now
|
|
33
33
|
├─ queued → keep doing LIGHT work; offers reach you at your next
|
|
34
34
|
│ load-lock tool call, or via a wakeup message
|
|
35
|
+
│ no light work left? wait_for_turn() = "sleep until it
|
|
36
|
+
│ is my turn" (bounded; auto-claims the offer for you)
|
|
35
37
|
└─ ...
|
|
36
38
|
offer arrives (token, expiry) → claim(token) BEFORE the window closes
|
|
37
39
|
claim → held → start the heavy work
|
|
@@ -71,6 +73,36 @@ Notes on the details:
|
|
|
71
73
|
explicitly `rejoin(request_id)`. If you know you can't start in time,
|
|
72
74
|
`decline` instead of letting it expire.
|
|
73
75
|
|
|
76
|
+
### Sleeping until your turn (`wait_for_turn`)
|
|
77
|
+
|
|
78
|
+
Rule 2 says keep doing light work while queued — but sometimes there is
|
|
79
|
+
nothing useful left. `wait_for_turn(request_id?, timeout_ms?)` is the honest
|
|
80
|
+
way to idle: a **bounded blocking sleep** over one of your queued, offered
|
|
81
|
+
or paused requests (default: your most urgent one). When capacity reaches
|
|
82
|
+
you mid-sleep, the offer is **auto-claimed inside the claim window** and
|
|
83
|
+
you wake holding the permit (`granted`, `permit_token` + `auto_claimed:
|
|
84
|
+
true` in the response).
|
|
85
|
+
|
|
86
|
+
Every resolution is a structured result, never an opaque error:
|
|
87
|
+
|
|
88
|
+
- `granted` — your turn came; the permit is already claimed for you.
|
|
89
|
+
- `still_queued` — the bound expired; you get your queue position, queue
|
|
90
|
+
length and who is holding the capacity. Call it again to keep sleeping.
|
|
91
|
+
- `paused` — your offer expired or was declined meanwhile; `next_action`
|
|
92
|
+
has the rejoin instructions.
|
|
93
|
+
- `cancelled` — the request was cancelled/declined/reclaimed meanwhile.
|
|
94
|
+
- `not_found` — the request ended, or the daemon restarted (queues are
|
|
95
|
+
ephemeral); acquire again.
|
|
96
|
+
|
|
97
|
+
The sleep is bounded and **safe by construction**: it holds no permit and
|
|
98
|
+
no capacity, cancelling the tool call just stops the local sleep (nothing
|
|
99
|
+
is orphaned — your request keeps its queue position), and a daemon restart
|
|
100
|
+
mid-sleep resolves promptly with `not_found` instead of hanging. `timeout_ms`
|
|
101
|
+
defaults to 600000 (10 minutes) and is clamped to the machine's
|
|
102
|
+
`maxTurnWaitMs` cap (`get_config` shows it; `configure` can set it, valid
|
|
103
|
+
range 1000–3600000 ms). `timeout_ms: 0` means check-only. The CLI form is
|
|
104
|
+
`load-lock wait --id <request-id> --timeout <ms>`.
|
|
105
|
+
|
|
74
106
|
## 3. Running heavy work (lifecycle options)
|
|
75
107
|
|
|
76
108
|
How you start the work determines how reliably the permit cleans up after
|
|
@@ -104,7 +136,8 @@ load-lock acquire --reason "cargo test" --op-key "test/core" # prints JSON
|
|
|
104
136
|
load-lock run --permit <permit-token> -- cargo test --quiet # tracks + releases
|
|
105
137
|
# or acquire+run in one go:
|
|
106
138
|
load-lock run --reason "nightly build" -- npm run build
|
|
107
|
-
load-lock wait --id <request-id> --timeout 120000 #
|
|
139
|
+
load-lock wait --id <request-id> --timeout 120000 # sleep until your turn → auto-claim
|
|
140
|
+
load-lock help # one-screen usage of every command
|
|
108
141
|
```
|
|
109
142
|
|
|
110
143
|
## 4. Reading state
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -42,7 +42,8 @@ and inherit unrelated constraints.
|
|
|
42
42
|
|
|
43
43
|
**One daemon, one budget.** A single `load-lock` daemon process per machine
|
|
44
44
|
owns all state. Every client — any harness, any repo, any worktree — talks to
|
|
45
|
-
the same Unix-domain socket
|
|
45
|
+
the same Unix-domain socket (named pipe `\\.\pipe\load-lock` on Windows),
|
|
46
|
+
so all sessions share one capacity budget
|
|
46
47
|
regardless of working directory. The default configuration is one pool
|
|
47
48
|
`heavy` with capacity 1; pools, capacity, and timings are changed at runtime
|
|
48
49
|
through `configure` (versioned compare-and-swap, persisted with author +
|
|
@@ -131,8 +132,18 @@ mechanisms, and labels exactly which apply per harness:
|
|
|
131
132
|
offer at its next tool call, without polling.
|
|
132
133
|
2. **Bounded blocking wait.** `acquire(wait_ms)` blocks briefly (default
|
|
133
134
|
5s) inside the tool call and auto-claims if capacity frees during the
|
|
134
|
-
wait.
|
|
135
|
-
|
|
135
|
+
wait. For longer sleeps there is one shared client-side
|
|
136
|
+
implementation, `waitForTurn` (`src/shared/wait-turn.ts`), surfaced as
|
|
137
|
+
the CLI `wait` command, the MCP `wait_for_turn` tool and the OpenCode
|
|
138
|
+
plugin's `load_lock_wait_for_turn` tool: it polls light
|
|
139
|
+
events+pending pulls on the local socket, auto-claims inside the
|
|
140
|
+
offer window, returns structured results on every path (granted /
|
|
141
|
+
still_queued with position / paused / cancelled / not_found), is
|
|
142
|
+
bounded by `timeout_ms` (capped by the `maxTurnWaitMs` config), and —
|
|
143
|
+
being purely client-side — holds no permit, consumes no capacity, and
|
|
144
|
+
can never orphan a daemon-side waiter when the harness cancels the
|
|
145
|
+
tool mid-sleep. Daemon restarts are detected via the event cursor and
|
|
146
|
+
resolve bounded. No model-authored busy-polling required.
|
|
136
147
|
3. **Idle wakeup where the harness genuinely supports it.**
|
|
137
148
|
- OpenCode (live-verified): the native plugin watches daemon events and
|
|
138
149
|
calls `ctx.session.synthetic(...)`, delivering the offer (with token)
|
|
@@ -141,7 +152,7 @@ mechanisms, and labels exactly which apply per harness:
|
|
|
141
152
|
- Claude Code (contract-tested): a `Stop` hook checks for cwd-matching
|
|
142
153
|
offers and continues the session with a claim instruction; an async
|
|
143
154
|
`await-offer` watcher with `asyncRewake: true` wakes idle sessions.
|
|
144
|
-
- Codex CLI (
|
|
155
|
+
- Codex CLI (live-verified in Docker): `Stop` hook with `decision: "block"`
|
|
145
156
|
continuation. Note: Codex async background hooks do **not** start a new
|
|
146
157
|
turn — documented as a limitation.
|
|
147
158
|
4. **Durable event identities.** All events have monotonic IDs; agents can
|
|
@@ -164,7 +175,8 @@ and restored after context compaction:
|
|
|
164
175
|
- **Claude Code**: `SessionStart` hook (matchers include `compact`) +
|
|
165
176
|
`~/.claude/CLAUDE.md` section + skill.
|
|
166
177
|
- **Codex CLI**: `SessionStart` hook (matcher `compact`) + `SubagentStart`
|
|
167
|
-
hook + `~/.codex/AGENTS.md` section (hooks require
|
|
178
|
+
hook + `~/.codex/AGENTS.md` section (hooks require the interactive
|
|
179
|
+
hook-trust review once; live-verified in Docker).
|
|
168
180
|
- Other harnesses: MCP server `instructions` + a global instructions file
|
|
169
181
|
snippet where the harness supports one (see COMPATIBILITY.md).
|
|
170
182
|
|
|
@@ -193,3 +205,11 @@ capacity; admission is FIFO with head-of-line blocking) and multiple pools;
|
|
|
193
205
|
the machine currently uses one pool by default but accepts arbitrary pool
|
|
194
206
|
names through the same code paths. Adding a second budget (e.g. `network`)
|
|
195
207
|
is configuration, not code.
|
|
208
|
+
|
|
209
|
+
The read-only status surface also extends without protocol changes: the
|
|
210
|
+
`status` + `events` (durable ids) + `watch` + `inspect` methods are enough
|
|
211
|
+
to build live viewers. `load-lock dashboard` (alias `top`) and the OpenCode TUI panel
|
|
212
|
+
(`src/dashboard/`) are exactly that — pure view-model shaping over the
|
|
213
|
+
library client (`./shared/client`), never CLI-output scraping, and never
|
|
214
|
+
acquiring a permit themselves. Any future harness UI can reuse the same
|
|
215
|
+
`src/dashboard/` pipeline.
|