@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.
Files changed (53) hide show
  1. package/README.md +40 -12
  2. package/demo/demo.sh +1 -1
  3. package/dist/adapters/opencode/plugin.js +39 -18
  4. package/dist/adapters/opencode/tui.d.ts +61 -0
  5. package/dist/adapters/opencode/tui.js +358 -0
  6. package/dist/cli/hook.js +35 -1
  7. package/dist/cli/install.js +708 -24
  8. package/dist/cli/main.js +130 -61
  9. package/dist/cli/top.d.ts +7 -0
  10. package/dist/cli/top.js +99 -0
  11. package/dist/core/daemon.d.ts +25 -4
  12. package/dist/core/daemon.js +67 -30
  13. package/dist/core/persist.js +5 -12
  14. package/dist/core/state.js +7 -0
  15. package/dist/dashboard/feed.d.ts +74 -0
  16. package/dist/dashboard/feed.js +256 -0
  17. package/dist/dashboard/frame.d.ts +13 -0
  18. package/dist/dashboard/frame.js +95 -0
  19. package/dist/dashboard/view.d.ts +149 -0
  20. package/dist/dashboard/view.js +214 -0
  21. package/dist/mcp/server.d.ts +2 -1
  22. package/dist/mcp/server.js +54 -19
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +3 -0
  25. package/dist/shared/client.d.ts +10 -0
  26. package/dist/shared/client.js +41 -14
  27. package/dist/shared/protocol.d.ts +29 -0
  28. package/dist/shared/transport.d.ts +40 -0
  29. package/dist/shared/transport.js +136 -0
  30. package/dist/shared/wait-turn.d.ts +29 -0
  31. package/dist/shared/wait-turn.js +315 -0
  32. package/dist/test/cli.test.d.ts +1 -0
  33. package/dist/test/cli.test.js +99 -0
  34. package/dist/test/dashboard.test.d.ts +1 -0
  35. package/dist/test/dashboard.test.js +406 -0
  36. package/dist/test/hook.test.js +53 -3
  37. package/dist/test/install.test.d.ts +1 -0
  38. package/dist/test/install.test.js +658 -0
  39. package/dist/test/mcp.test.js +36 -1
  40. package/dist/test/platform.test.d.ts +1 -0
  41. package/dist/test/platform.test.js +193 -0
  42. package/dist/test/testutil.js +4 -1
  43. package/dist/test/tui.test.d.ts +1 -0
  44. package/dist/test/tui.test.js +89 -0
  45. package/dist/test/wait-turn.test.d.ts +1 -0
  46. package/dist/test/wait-turn.test.js +376 -0
  47. package/docs/ADAPTERS.md +5 -2
  48. package/docs/AGENT_GUIDE.md +34 -1
  49. package/docs/ARCHITECTURE.md +25 -5
  50. package/docs/COMPATIBILITY.md +217 -26
  51. package/docs/OPERATIONS.md +335 -8
  52. package/package.json +25 -1
  53. 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
- document that honestly (see docs/COMPATIBILITY.md).
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; `LLClient` handles
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
 
@@ -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 # bounded wait → auto-claim
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
@@ -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, so all sessions share one capacity budget
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. The CLI `wait` command and MCP `wait` pattern provide longer
135
- bounded waits. No model-authored busy-polling required.
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 (contract-tested): `Stop` hook with `decision: "block"`
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 `/hooks` trust review).
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.