@aron98/load-lock 1.2.1 → 1.4.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.
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Integration tests for wait_for_completion (issue #36): honest sleeping
3
+ * through the EXECUTION phase — the counterpart of wait_for_turn. Covers:
4
+ * - daemon-tracked jobs: the daemon holds the wait server-side and wakes it
5
+ * from its liveness sweep (real daemons, real processes), with the permit
6
+ * freed BEFORE the waiter is woken (ordering),
7
+ * - adapter-parented jobs: waitForLocalJob wakes on the child exit event
8
+ * (pure unit tests with real timers),
9
+ * - the old-daemon polling fallback (client that does not know the method),
10
+ * - daemon restart transparency mid-wait,
11
+ * - all structured paths: finished / still_running / timeout / job_unknown /
12
+ * stopped_by_request,
13
+ * - the CLI `wait --job` surface.
14
+ */
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { spawn } from "node:child_process";
18
+ import { ProtocolError } from "../shared/protocol.js";
19
+ import { waitForLocalJob, waitForTrackedJob, } from "../shared/wait-completion.js";
20
+ import { startDaemon, delay, until, probe, runCli, daemonMainPath, } from "./testutil.js";
21
+ const ACQ = (agentId, opKey) => ({
22
+ agentId,
23
+ opKey,
24
+ reason: "integration test",
25
+ });
26
+ /** A wait expected to stay pending for at least `ms` (race sentinel). */
27
+ function expectPending(ms) {
28
+ return delay(ms).then(() => "pending");
29
+ }
30
+ /**
31
+ * Acquire a permit and attach a REAL detached process to it (daemon-tracked,
32
+ * exactly what an adapter does on behalf of an agent).
33
+ */
34
+ async function startTrackedJob(c, agentId, opKey) {
35
+ const acq = (await c.request("acquire", ACQ(agentId, opKey)));
36
+ assert.equal(acq.state, "granted");
37
+ const child = spawn("sleep", ["30"], { detached: true, stdio: "ignore" });
38
+ child.unref();
39
+ const sj = (await c.request("start_job", { permitToken: acq.entry.permitToken, pid: child.pid }));
40
+ assert.ok(sj.entry.requestId, "start_job returns the entry snapshot");
41
+ return { requestId: sj.entry.requestId, permitToken: acq.entry.permitToken, child };
42
+ }
43
+ test("wait_for_completion: liveness sweep wakes the sleeper when the tracked process dies (ordering: permit freed first)", async () => {
44
+ const d = await startDaemon();
45
+ const clients = [];
46
+ try {
47
+ const cA = d.client();
48
+ await cA.connect();
49
+ clients.push(cA);
50
+ const cB = d.client();
51
+ await cB.connect();
52
+ clients.push(cB);
53
+ const job = await startTrackedJob(cA, "holderA", "opA");
54
+ // Two independent waiters on the same job — multiple waiters are fine.
55
+ const waitA = waitForTrackedJob({ client: cA, requestId: job.requestId, timeoutMs: 30_000 });
56
+ const waitB = waitForTrackedJob({ client: cB, requestId: job.requestId, timeoutMs: 30_000 });
57
+ const early = await Promise.race([waitA.then(() => "done"), expectPending(400)]);
58
+ assert.equal(early, "pending", "the wait blocks while the tracked process runs");
59
+ const t0 = Date.now();
60
+ job.child.kill("SIGKILL");
61
+ const [rA, rB] = await Promise.all([waitA, waitB]);
62
+ const elapsed = Date.now() - t0;
63
+ assert.ok(elapsed < 7_000, `woke promptly after process death (took ${elapsed}ms; sweep interval is 5s)`);
64
+ assert.equal(rA.state, "finished");
65
+ assert.equal(rB.state, "finished");
66
+ assert.equal(rA.requestId, job.requestId);
67
+ assert.equal(rA.permitReleased, true, "the permit is released by the time we wake");
68
+ assert.ok(rA.durationMs === undefined || rA.durationMs >= 0);
69
+ assert.ok(rA.waitedMs >= 300, `waitedMs reported (got ${rA.waitedMs})`);
70
+ // ORDERING: capacity was freed BEFORE the waiters were woken.
71
+ const st = (await cA.request("status", {}));
72
+ assert.equal(st.pools[0].used, 0, "pool is empty the moment the wait resolves");
73
+ }
74
+ finally {
75
+ for (const cl of clients)
76
+ cl.close();
77
+ await d.stop();
78
+ }
79
+ });
80
+ test("wait_for_completion: budget elapsing is a still_running checkpoint, not an error; timeout_ms 0 is check-only", async () => {
81
+ const d = await startDaemon();
82
+ const clients = [];
83
+ try {
84
+ const cA = d.client();
85
+ await cA.connect();
86
+ clients.push(cA);
87
+ const job = await startTrackedJob(cA, "holderA", "opA");
88
+ const t0 = Date.now();
89
+ const r = await waitForTrackedJob({ client: cA, requestId: job.requestId, timeoutMs: 800 });
90
+ assert.ok(Date.now() - t0 < 5_000, "checkpoint returns promptly after the budget");
91
+ assert.equal(r.state, "still_running", "timeout is a structured checkpoint result");
92
+ assert.ok(r.waitedMs >= 700, `waited the full budget (waitedMs=${r.waitedMs})`);
93
+ assert.ok(r.startedAt, "startedAt reported so callers can reason about duration");
94
+ assert.match(r.nextAction ?? "", /wait_for_completion|call again/i);
95
+ // Check-only: no budget spent, immediate structured answer.
96
+ const t1 = Date.now();
97
+ const check = await waitForTrackedJob({ client: cA, requestId: job.requestId, timeoutMs: 0 });
98
+ assert.ok(Date.now() - t1 < 1_000, "check-only does not sleep");
99
+ assert.equal(check.state, "still_running");
100
+ assert.ok(check.waitedMs < 1_000, "check-only spends no budget waiting");
101
+ assert.match(check.detail ?? "", /check-only/);
102
+ job.child.kill("SIGKILL");
103
+ }
104
+ finally {
105
+ for (const cl of clients)
106
+ cl.close();
107
+ await d.stop();
108
+ }
109
+ });
110
+ test("wait_for_completion: job_unknown for unknown ids, queued requests and untracked permits", async () => {
111
+ const d = await startDaemon();
112
+ const clients = [];
113
+ try {
114
+ const cA = d.client();
115
+ await cA.connect();
116
+ clients.push(cA);
117
+ // Unknown id resolves immediately with a structured result.
118
+ const t0 = Date.now();
119
+ const unknown = await waitForTrackedJob({ client: cA, requestId: "no-such-request", timeoutMs: 5_000 });
120
+ assert.ok(Date.now() - t0 < 1_000, "unknown id does not busy-wait");
121
+ assert.equal(unknown.state, "job_unknown");
122
+ assert.match(unknown.detail ?? "", /unknown request id/);
123
+ // An untracked (mark_started) permit has no observable completion.
124
+ const u = (await cA.request("acquire", ACQ("untrackedA", "opU")));
125
+ assert.equal(u.state, "granted");
126
+ await cA.request("mark_started", { permitToken: u.entry.permitToken });
127
+ const untracked = await waitForTrackedJob({ client: cA, requestId: u.entry.requestId, timeoutMs: 1_000 });
128
+ assert.equal(untracked.state, "job_unknown");
129
+ assert.match(untracked.detail ?? "", /untracked|not observable|phase/);
130
+ await cA.request("release", { permitToken: u.entry.permitToken });
131
+ // A queued request is not a running job either.
132
+ const a = (await cA.request("acquire", ACQ("holderA", "opA")));
133
+ assert.equal(a.state, "granted");
134
+ const cB = d.client();
135
+ await cB.connect();
136
+ clients.push(cB);
137
+ const b = (await cB.request("acquire", ACQ("waitB", "opB")));
138
+ assert.equal(b.state, "queued");
139
+ const queued = await waitForTrackedJob({ client: cB, requestId: b.entry.requestId, timeoutMs: 1_000 });
140
+ assert.equal(queued.state, "job_unknown", "a queued request has no completion to wait for");
141
+ }
142
+ finally {
143
+ for (const cl of clients)
144
+ cl.close();
145
+ await d.stop();
146
+ }
147
+ });
148
+ test("wait_for_completion: explicit release mid-wait wakes the sleeper finished", async () => {
149
+ const d = await startDaemon();
150
+ const clients = [];
151
+ try {
152
+ const cA = d.client();
153
+ await cA.connect();
154
+ clients.push(cA);
155
+ const cB = d.client();
156
+ await cB.connect();
157
+ clients.push(cB);
158
+ const job = await startTrackedJob(cA, "holderA", "opA");
159
+ const wait = waitForTrackedJob({ client: cB, requestId: job.requestId, timeoutMs: 30_000 });
160
+ const early = await Promise.race([wait.then(() => "done"), expectPending(400)]);
161
+ assert.equal(early, "pending");
162
+ // The owner releases explicitly while someone sleeps on the job.
163
+ await cA.request("release", { permitToken: job.permitToken });
164
+ const r = await wait;
165
+ assert.equal(r.state, "finished");
166
+ assert.equal(r.permitReleased, true);
167
+ assert.ok(r.waitedMs >= 300);
168
+ const st = (await cB.request("status", {}));
169
+ assert.equal(st.pools[0].used, 0);
170
+ job.child.kill("SIGKILL");
171
+ }
172
+ finally {
173
+ for (const cl of clients)
174
+ cl.close();
175
+ await d.stop();
176
+ }
177
+ });
178
+ test("wait_for_completion: admin force-release mid-wait wakes the sleeper stopped_by_request", async () => {
179
+ const d = await startDaemon();
180
+ const clients = [];
181
+ try {
182
+ const cA = d.client();
183
+ await cA.connect();
184
+ clients.push(cA);
185
+ const cB = d.client();
186
+ await cB.connect();
187
+ clients.push(cB);
188
+ const job = await startTrackedJob(cA, "holderA", "opA");
189
+ const wait = waitForTrackedJob({ client: cB, requestId: job.requestId, timeoutMs: 30_000 });
190
+ const early = await Promise.race([wait.then(() => "done"), expectPending(400)]);
191
+ assert.equal(early, "pending");
192
+ await cB.request("admin_release", { requestId: job.requestId });
193
+ const r = await wait;
194
+ assert.equal(r.state, "stopped_by_request", "admin force-release classifies as stopped_by_request");
195
+ assert.match(r.nextAction ?? "", /re-acquire|force-released/i);
196
+ const st = (await cB.request("status", {}));
197
+ assert.equal(st.pools[0].used, 0);
198
+ job.child.kill("SIGKILL");
199
+ }
200
+ finally {
201
+ for (const cl of clients)
202
+ cl.close();
203
+ await d.stop();
204
+ }
205
+ });
206
+ test("wait_for_completion: daemon restart mid-wait is transparent — the wait continues against the recovered state", async () => {
207
+ const d = await startDaemon();
208
+ const clients = [];
209
+ let child2 = null;
210
+ let job = null;
211
+ try {
212
+ const cA = d.client();
213
+ await cA.connect();
214
+ clients.push(cA);
215
+ job = await startTrackedJob(cA, "holderA", "opA");
216
+ const wait = waitForTrackedJob({ client: cA, requestId: job.requestId, timeoutMs: 30_000 });
217
+ const early = await Promise.race([wait.then(() => "done"), expectPending(400)]);
218
+ assert.equal(early, "pending");
219
+ // Restart the daemon mid-wait. Tracked permits survive via journal restore.
220
+ d.child.kill("SIGTERM");
221
+ await new Promise((r) => d.child.once("exit", r));
222
+ child2 = spawn(process.execPath, [daemonMainPath, "--state-dir", d.dir, "--socket", d.sock], {
223
+ stdio: ["ignore", "pipe", "pipe"],
224
+ env: { ...process.env, LOAD_LOCK_STATE_DIR: d.dir, LOAD_LOCK_SOCKET: d.sock },
225
+ });
226
+ await until("restarted daemon listens", async () => await probe(d.sock));
227
+ // The wait survives (re-issued transparently): killing the process now
228
+ // wakes it finished via the restarted daemon's sweep.
229
+ job.child.kill("SIGKILL");
230
+ const r = await wait;
231
+ assert.equal(r.state, "finished", "the wait continued through the restart and woke on completion");
232
+ assert.equal(r.permitReleased, true);
233
+ const st = (await cA.request("status", {}));
234
+ assert.equal(st.pools[0].used, 0);
235
+ }
236
+ finally {
237
+ if (job && job.child.exitCode === null && !job.child.killed)
238
+ job.child.kill("SIGKILL");
239
+ if (child2 && child2.exitCode === null && !child2.killed)
240
+ child2.kill("SIGKILL");
241
+ for (const cl of clients)
242
+ cl.close();
243
+ d.child.kill("SIGKILL");
244
+ await delay(100);
245
+ await d.stop();
246
+ }
247
+ });
248
+ test("wait_for_completion: old daemons without the method degrade to a light events-based poll (fallback)", async () => {
249
+ const d = await startDaemon();
250
+ const clients = [];
251
+ try {
252
+ const real = d.client();
253
+ await real.connect();
254
+ clients.push(real);
255
+ const job = await startTrackedJob(real, "holderA", "opA");
256
+ // A client facing an OLD daemon: wait_for_completion is "unknown method".
257
+ const fake = Object.create(real);
258
+ fake.request = (method, params, timeoutMs) => {
259
+ if (method === "wait_for_completion") {
260
+ return Promise.reject(new ProtocolError("invalid", `unknown method "${method}"`));
261
+ }
262
+ return real.request(method, params, timeoutMs);
263
+ };
264
+ const t0 = Date.now();
265
+ const waitP = waitForTrackedJob({ client: fake, requestId: job.requestId, timeoutMs: 30_000 });
266
+ const early = await Promise.race([waitP.then(() => "done"), expectPending(400)]);
267
+ assert.equal(early, "pending", "the fallback polls quietly");
268
+ job.child.kill("SIGKILL");
269
+ const r = await waitP;
270
+ const elapsed = Date.now() - t0;
271
+ assert.ok(elapsed < 8_000, `fallback woke on completion (took ${elapsed}ms)`);
272
+ assert.equal(r.state, "finished");
273
+ assert.equal(r.permitReleased, true);
274
+ // Fallback also classifies job_unknown ids without spinning.
275
+ const unknown = await waitForTrackedJob({ client: fake, requestId: "no-such-request", timeoutMs: 2_000 });
276
+ assert.equal(unknown.state, "job_unknown");
277
+ assert.match(unknown.detail ?? "", /absent|unknown/i);
278
+ }
279
+ finally {
280
+ for (const cl of clients)
281
+ cl.close();
282
+ await d.stop();
283
+ }
284
+ });
285
+ test("waitForLocalJob: adapter-parented job wakes on the child exit event with exit code, tail and duration", async () => {
286
+ let resolveExit;
287
+ let resolveRelease;
288
+ const job = {
289
+ id: "job_unit",
290
+ startedAt: Date.now(),
291
+ exited: false,
292
+ output: [],
293
+ // Adapter contract: exitPromise resolves only AFTER releasePromise exists.
294
+ exitPromise: new Promise((r) => (resolveExit = r)),
295
+ };
296
+ job.releasePromise = new Promise((r) => (resolveRelease = r));
297
+ const t0 = Date.now();
298
+ const waitP = waitForLocalJob(job, { timeoutMs: 10_000 });
299
+ setTimeout(() => {
300
+ // The child exit handler runs: record exit, then release, then wake.
301
+ job.exited = true;
302
+ job.exitedAt = Date.now();
303
+ job.exitCode = 0;
304
+ job.output.push("building...\n", "done\n");
305
+ resolveRelease();
306
+ job.permitReleased = true;
307
+ resolveExit(0);
308
+ }, 300);
309
+ const r = await waitP;
310
+ const elapsed = Date.now() - t0;
311
+ assert.ok(elapsed < 2_000, `woke promptly on the exit event (took ${elapsed}ms)`);
312
+ assert.equal(r.state, "finished");
313
+ assert.equal(r.exitCode, 0);
314
+ assert.ok(r.outputTail.includes("done"), "output tail included");
315
+ assert.ok(r.durationMs >= 250, `duration reported (got ${r.durationMs}ms)`);
316
+ assert.equal(r.permitReleased, true);
317
+ });
318
+ test("waitForLocalJob: already-exited job resolves immediately (waits for the release round-trip)", async () => {
319
+ const job = {
320
+ id: "job_done",
321
+ startedAt: Date.now() - 500,
322
+ exited: true,
323
+ exitedAt: Date.now() - 100,
324
+ exitCode: 0,
325
+ output: ["done\n"],
326
+ permitReleased: false,
327
+ };
328
+ const t0 = Date.now();
329
+ const r = await waitForLocalJob(job, { timeoutMs: 5_000 });
330
+ assert.ok(Date.now() - t0 < 1_000, "no busy-wait for an already-exited job");
331
+ assert.equal(r.state, "finished");
332
+ assert.equal(r.exitCode, 0);
333
+ assert.equal(r.permitReleased, false, "honest: release round-trip had not completed");
334
+ assert.match(r.detail ?? "", /verify with status/);
335
+ });
336
+ test("waitForLocalJob: budget elapsing is a still_running checkpoint; abort resolves timeout with the job untouched", async () => {
337
+ // Budget checkpoint.
338
+ const slow = {
339
+ id: "job_slow",
340
+ startedAt: Date.now(),
341
+ exited: false,
342
+ output: [],
343
+ exitPromise: new Promise(() => { }),
344
+ };
345
+ const r = await waitForLocalJob(slow, { timeoutMs: 250 });
346
+ assert.equal(r.state, "still_running");
347
+ assert.ok(r.waitedMs >= 200, `waited the budget (waitedMs=${r.waitedMs})`);
348
+ assert.equal(slow.exited, false, "the job keeps running after a checkpoint");
349
+ // AbortSignal (harness tool timeout) — the call resolves, the job untouched.
350
+ const ac = new AbortController();
351
+ const waitP = waitForLocalJob(slow, { timeoutMs: 10_000, signal: ac.signal });
352
+ await delay(200);
353
+ ac.abort();
354
+ const aborted = await waitP;
355
+ assert.equal(aborted.state, "timeout", "an aborted call is a structured timeout, not an error");
356
+ assert.match(aborted.detail ?? "", /aborted by caller/);
357
+ assert.match(aborted.nextAction ?? "", /wait_for_completion|again/);
358
+ assert.equal(slow.exited, false, "aborting the wait never touches the job");
359
+ });
360
+ test("waitForLocalJob: job_stop before exit classifies stopped_by_request; missing exitPromise is job_unknown", async () => {
361
+ let resolveExit;
362
+ const job = {
363
+ id: "job_stop",
364
+ startedAt: Date.now(),
365
+ exited: false,
366
+ output: [],
367
+ stopRequested: false,
368
+ exitPromise: new Promise((r) => (resolveExit = r)),
369
+ };
370
+ const waitP = waitForLocalJob(job, { timeoutMs: 10_000 });
371
+ await delay(150);
372
+ job.stopRequested = true; // job_stop flags the job before killing the group
373
+ job.exited = true;
374
+ job.exitedAt = Date.now();
375
+ job.exitCode = null;
376
+ resolveExit(null);
377
+ const r = await waitP;
378
+ assert.equal(r.state, "stopped_by_request");
379
+ assert.equal(r.exitCode, null);
380
+ assert.match(r.nextAction ?? "", /job_stop|re-run/i);
381
+ const broken = { id: "job_broken", startedAt: Date.now(), exited: false, output: [] };
382
+ const u = await waitForLocalJob(broken, { timeoutMs: 100 });
383
+ assert.equal(u.state, "job_unknown");
384
+ assert.match(u.detail ?? "", /exitPromise/);
385
+ });
386
+ test("cli: wait --job sleeps through a tracked job, exits 0 on finish and reports structured states otherwise", async () => {
387
+ const d = await startDaemon();
388
+ const clients = [];
389
+ try {
390
+ const cA = d.client();
391
+ await cA.connect();
392
+ clients.push(cA);
393
+ const job = await startTrackedJob(cA, "holderA", "opA");
394
+ // Checkpoint path: budget elapses -> still_running, exit code 68.
395
+ const ck = await runCli(d, ["wait", "--job", job.requestId, "--timeout", "400"]);
396
+ assert.equal(ck.code, 68);
397
+ assert.equal(JSON.parse(ck.stdout).state, "still_running");
398
+ // Sleep-through path: kill the process mid-wait -> finished, exit 0.
399
+ const cliP = runCli(d, ["wait", "--job", job.requestId, "--timeout", "25000"]);
400
+ await delay(500);
401
+ job.child.kill("SIGKILL");
402
+ const done = await cliP;
403
+ assert.equal(done.code, 0, `stdout=${done.stdout} stderr=${done.stderr}`);
404
+ const out = JSON.parse(done.stdout);
405
+ assert.equal(out.state, "finished");
406
+ assert.equal(out.permit_released, true);
407
+ assert.equal(out.request_id, job.requestId);
408
+ // Unknown id -> job_unknown, exit 69.
409
+ const unknown = await runCli(d, ["wait", "--job", "no-such-request", "--timeout", "1000"]);
410
+ assert.equal(unknown.code, 69);
411
+ assert.equal(JSON.parse(unknown.stdout).state, "job_unknown");
412
+ }
413
+ finally {
414
+ for (const cl of clients)
415
+ cl.close();
416
+ await d.stop();
417
+ }
418
+ });
package/docs/ADAPTERS.md CHANGED
@@ -72,8 +72,9 @@ 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
+ and returns a `permit_token`) as the primary idle mechanism, and to
76
+ `wait_for_completion` (bounded execution-phase sleep) once a job runs.
77
+ Document the remaining gap honestly (see docs/COMPATIBILITY.md).
77
78
 
78
79
  ## Protocol quick reference
79
80
 
@@ -103,6 +103,50 @@ defaults to 600000 (10 minutes) and is clamped to the machine's
103
103
  range 1000–3600000 ms). `timeout_ms: 0` means check-only. The CLI form is
104
104
  `load-lock wait --id <request-id> --timeout <ms>`.
105
105
 
106
+ ### Sleeping through execution (`wait_for_completion`)
107
+
108
+ The queue phase has `wait_for_turn`; the execution phase has its counterpart.
109
+ After you start a heavy job, do not guess sleeps or poll `job_status` in a
110
+ loop — sleep honestly: one bounded call that **wakes promptly when the job
111
+ finishes**. There are two mechanisms, and the identifier picks the right one:
112
+
113
+ - **`job_id` of a `run_with_permit` job** (the job belongs to your adapter
114
+ process): the wake is the child's exact `exit` event — no polling, prompt,
115
+ with the exact exit code and an output tail. The same thing in one call:
116
+ `run_with_permit(..., wait: true)` starts the job and sleeps through it.
117
+ - **`request_id` of a daemon-tracked permit** (`attach_pid` etc. — the daemon
118
+ is not the parent): the daemon holds the wait server-side and wakes it when
119
+ its liveness sweep observes the process gone (worst case one sweep interval,
120
+ default 5s). The permit is **released before you are woken**, so a woken
121
+ agent sees the capacity freed. The CLI form is
122
+ `load-lock wait --job <request-id> --timeout <ms>`.
123
+
124
+ Every resolution is a structured result, never an opaque error:
125
+
126
+ - `finished` — exit code, output tail, duration, and the permit-release
127
+ state (`permit_released`; if the release round-trip was still in flight
128
+ the `detail` says so — verify with `status` if in doubt).
129
+ - `still_running` — the bound expired mid-execution; this is a **checkpoint**,
130
+ not a failure. Call again; a long job is a handful of long sleeps, not a
131
+ poll loop.
132
+ - `timeout` — this wait call was aborted/cancelled (harness tool timeout,
133
+ AbortSignal). The job is untouched; call again.
134
+ - `job_unknown` — no such job/request, or the permit was reclaimed/expired
135
+ meanwhile (e.g. the daemon or adapter restarted); re-check before
136
+ proceeding.
137
+ - `stopped_by_request` — the job or permit was stopped by an explicit
138
+ (admin) action; re-acquire before running more heavy work.
139
+
140
+ The sleep is bounded and safe by construction: it holds no permit and no
141
+ capacity itself, and for daemon-tracked waits `timeout_ms` is clamped to the
142
+ machine's `maxTurnWaitMs` cap (same as `wait_for_turn`; adapter-parented
143
+ waits default to 600000). `timeout_ms: 0` means check-only. A daemon restart
144
+ mid-wait is handled transparently (tracked permits survive restarts via the
145
+ journal); against an older daemon the client falls back to a light
146
+ events-based poll with the same structured results. Cancelling the call
147
+ never kills the job. If someone calls `job_stop` while you are waiting, you
148
+ wake with `stopped_by_request`.
149
+
106
150
  ## 3. Running heavy work (lifecycle options)
107
151
 
108
152
  How you start the work determines how reliably the permit cleans up after
@@ -111,9 +155,11 @@ you — pick the best available:
111
155
  1. **`run_with_permit(permit_token, command)`** — the helper spawns your
112
156
  command in its own process group, atomically associates it with the
113
157
  permit, and auto-releases when the process group *actually finishes*
114
- (including background children). Check `job_status(job_id)` for
115
- completion/exit code/tail of output; `job_stop(job_id)` to kill it.
116
- Do **not** manually release a permit you handed to `run_with_permit`.
158
+ (including background children). Sleep through the execution with
159
+ `wait_for_completion(job_id)` (or pass `wait: true` to fire-and-wait in
160
+ one call); quick-check with `job_status(job_id)`; kill with
161
+ `job_stop(job_id)`. Do **not** manually release a permit you handed to
162
+ `run_with_permit`.
117
163
  2. **`attach_pid(permit_token, pid)`** — you spawned the process yourself;
118
164
  the daemon now tracks that pid (and its process group) and releases when
119
165
  it is confirmed dead. Works for `... &` background jobs whose parent
@@ -112,11 +112,15 @@ permits are journaled: on daemon restart, tracked permits are reconciled
112
112
  against real process liveness (dead ⇒ released, alive ⇒ kept, same tokens
113
113
  still valid), claimed permits keep their original start deadline, untracked
114
114
  permits get a fresh lease window, and a release arriving late for an entry
115
- that was reconciled is idempotent. Queues and offers are *not* journaled:
116
- they are ephemeral by design — queued agents are still running, notice the
117
- missed offer via their next tool call, and re-acquire; the daemon
118
- auto-spawns on the next client connection, so an agent never needs to know
119
- the daemon restarted. This is documented, deliberate behavior, not a bug.
115
+ that was reconciled is idempotent. Queued *and offered* requests are journaled
116
+ too (issue #34): they are re-queued FIFO by original enqueue time with the
117
+ same request ids (idempotent re-acquire keeps working), entries offered at
118
+ crash time restart as queued and are re-offered with fresh tokens — so a
119
+ restart no longer silently drops a queue position. The daemon auto-spawns on
120
+ the next client connection, and a *self-ordered* restart (version handshake:
121
+ a newer client than the daemon) respawns with the same argv after persisting
122
+ state — see the update lifecycle in
123
+ [OPERATIONS.md](OPERATIONS.md#update-lifecycle).
120
124
 
121
125
  ## Notification honesty
122
126
 
@@ -144,6 +148,21 @@ mechanisms, and labels exactly which apply per harness:
144
148
  can never orphan a daemon-side waiter when the harness cancels the
145
149
  tool mid-sleep. Daemon restarts are detected via the event cursor and
146
150
  resolve bounded. No model-authored busy-polling required.
151
+ The execution phase has its mirror, `wait_for_completion`
152
+ (`src/shared/wait-completion.ts`), surfaced as the same CLI `wait`
153
+ command (`--job`), the MCP/OpenCode `wait_for_completion` tool and
154
+ `run_with_permit(wait: true)`. Two wake mechanisms: for
155
+ adapter-parented jobs (`run_with_permit`) the adapter awaits the
156
+ child's `exit` event directly — exact exit code, no polling; for
157
+ daemon-tracked permits (`attach_pid`) the daemon holds the wait
158
+ server-side and wakes it when the liveness sweep observes the pid
159
+ gone — mirroring `wait_for_turn`'s bounded-sleep shape (checkpoint
160
+ results on every path: finished / still_running / timeout /
161
+ job_unknown / stopped_by_request). Ordering guarantee: the permit is
162
+ released (capacity freed) *before* waiters are woken. Restarts are
163
+ transparent for tracked permits (journal restore), and against older
164
+ daemons the client degrades to a light events-based poll with the
165
+ same structured results.
147
166
  3. **Idle wakeup where the harness genuinely supports it.**
148
167
  - OpenCode (live-verified): the native plugin watches daemon events and
149
168
  calls `ctx.session.synthetic(...)`, delivering the offer (with token)
@@ -191,7 +210,7 @@ prompts.
191
210
  | Agent disconnects mid-heavy-work | Permit retained; tracked job keeps capacity until the real process finishes; untracked job's lease survives until heartbeats stop |
192
211
  | Agent process crashes | Tracked permit auto-releases on confirmed process death; untracked reclaims after lease expiry |
193
212
  | Background job outlives the tool call | pgid tracking retains the permit until the whole group exits |
194
- | Daemon crash/restart | Auto-respawn on next connect; journal reconciliation of held permits (conservative: dead ⇒ released, alive ⇒ kept); queues/offers ephemeral by design |
213
+ | Daemon crash/restart | Auto-respawn on next connect; journal reconciliation of held permits (conservative: dead ⇒ released, alive ⇒ kept); queued/offered requests re-queued FIFO with same ids, offered entries re-offered with fresh tokens |
195
214
  | Stale/forged token | Opaque tokens; wrong-owner claim/release rejected; expired offers rejected with a precise error |
196
215
  | Forgotten release | start deadline (claimed), process death (tracked), lease expiry (untracked), plus human `recover force-release` |
197
216
  | PID reuse | `/proc` start-time comparison |
@@ -73,6 +73,38 @@ tool round-trip (acquire → queued → wakeup → claim → release), execution
73
73
  boundary delivery without interrupting a running `sleep`, skill visible,
74
74
  and graceful handling of a deliberately missed offer (paused → cancel).
75
75
 
76
+ #### Multiple copies of load-lock (opencode)
77
+
78
+ `install --harness opencode` registers the **bare package name** in
79
+ `~/.config/opencode/opencode.json`, so opencode maintains its own private
80
+ copy of load-lock under
81
+ `~/.cache/opencode/npm/@aron98/load-lock@latest/<timestamp>/node_modules/@aron98/load-lock/`,
82
+ refreshed on opencode's schedule. That copy is *separate* from a globally
83
+ installed CLI on PATH, and the two can drift (this was observed on a real
84
+ machine with cache copies at 0.1.2, 1.0.0, 1.1.0 and 1.2.1 — issue #34).
85
+
86
+ Which copy is authoritative for what:
87
+
88
+ - **opencode plugin behavior** — the cache copy. Updated by opencode itself
89
+ (it refreshes the cache entry); you cannot reliably update it by hand.
90
+ - **CLI commands** (`load-lock status`, `service`, `doctor`, …) — the copy on
91
+ PATH (typically a global install via npm/bun). Update it with your usual
92
+ global install command.
93
+ - **The daemon's code** — whichever copy spawned it (usually the CLI copy,
94
+ since auto-spawn and `service start` run from the CLI client code). The
95
+ version handshake makes a daemon self-restart onto updated code when a
96
+ newer client contacts it — but the respawn reuses the daemon's original
97
+ argv, so it only picks up an update when its spawning copy was updated
98
+ *in place* (the normal global-upgrade case). A daemon spawned from a
99
+ different copy than the one you updated keeps running the old code until
100
+ `load-lock service stop` + any command re-spawns it from the new one.
101
+
102
+ `load-lock doctor` lists every copy it can find (PATH-resolved CLI, this
103
+ running copy, opencode cache copies) with versions and warns when they
104
+ drift. Detection is best-effort (never crashes on missing paths) and was
105
+ contract-tested with planted copies; the drift scenario itself was
106
+ observed live on this machine before the feature existed.
107
+
76
108
  ### Tier 2 — Claude Code / Codex CLI (hooks + MCP, live-verified)
77
109
 
78
110
  `load-lock install --harness claude-code|codex` configures the MCP server
@@ -95,15 +95,62 @@ uninstall — see issue #19); not live-verified inside the real CLIs.
95
95
  [Sandboxes](#sandboxes)).
96
96
  - Also honored by the shared client: `LOAD_LOCK_LABEL_PREFIX` (prefix for
97
97
  holder labels; see Sandboxes).
98
- - Files: `config.json` (settings + history), `journal.json` (held permits),
99
- `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
98
+ - Files: `config.json` (settings + history), `journal.json` (held permits +
99
+ queued requests), `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
100
100
 
101
101
  ```sh
102
- load-lock service status # hello + pid
103
- load-lock service start # ensure running (idempotent)
104
- load-lock service stop # SIGTERM; next tool call respawns
102
+ load-lock service status # hello + pid + daemon version
103
+ load-lock service start # ensure running (idempotent); refusals name the conflicting pid
104
+ load-lock service stop # SIGTERM → bounded wait → SIGKILL; verifies the socket is free
105
105
  ```
106
106
 
107
+ `service stop` is honest, not fire-and-forget: it signals the daemon
108
+ (`--timeout <ms>` bounds the wait, default 5s), escalates to SIGKILL if the
109
+ socket still answers, and only reports success once the endpoint is
110
+ verifiably free (stale `daemon.json`/socket files are cleaned up either way).
111
+ `service start` refuses loudly when something else owns the endpoint —
112
+ naming the conflicting pid — instead of a bare "did not come up within 10s".
113
+
114
+ ## Update lifecycle
115
+
116
+ The daemon is long-lived, so an update (`npm i -g @aron98/load-lock@latest`,
117
+ or opencode refreshing its plugin copy) leaves the running daemon on stale
118
+ code. The **version handshake** closes that gap (v1.3.0+):
119
+
120
+ - Every client envelope carries `clientVersion`; `hello`/`status` replies
121
+ carry `daemonVersion` (the package version of the code the daemon runs).
122
+ - When a **newer** client contacts an older daemon, the daemon finishes
123
+ answering, then **self-restarts**: it persists state + journal, drains all
124
+ connections, respawns itself with the same argv (`process.execPath` +
125
+ original arguments, so it runs whatever is now on disk at the same path),
126
+ and exits. The respawn inherits a `LOAD_LOCK_RESTARTED_FOR` marker so
127
+ repeated contact with the same version cannot cause a restart loop — one
128
+ respawn attempt per version.
129
+ - Clients that were connected see a disconnect and simply reconnect to the
130
+ fresh daemon.
131
+ - **What survives a restart:** held permits are journaled and reconciled
132
+ conservatively (tracked jobs re-checked for liveness, same permit tokens
133
+ valid; dead jobs released); queued *and offered* requests are journaled and
134
+ re-queued FIFO with the same request ids (idempotent re-acquire keeps
135
+ working). Offered entries restart as queued and are re-offered with fresh
136
+ tokens — pre-restart offer tokens are invalid, exactly as for any restart.
137
+ - Skew is visible before it heals: `load-lock status`, `service status`,
138
+ `doctor`, and the dashboards show "daemon vX, installed vY" and note the
139
+ pending self-restart; `doctor` actively waits a few seconds to confirm the
140
+ daemon came back on the new version.
141
+
142
+ Limits, stated honestly: the respawn re-runs the **same argv** — it picks up
143
+ an updated copy only if the daemon's launching copy was updated *in place*
144
+ (the normal `npm i -g` case). A daemon spawned from a different copy (e.g.
145
+ opencode's private cache copy) keeps running that copy's code after a
146
+ restart; see the multi-copy notes in
147
+ [COMPATIBILITY.md](COMPATIBILITY.md#multiple-copies-of-load-lock-opencode).
148
+ Pre-1.3.0 daemons do not answer the handshake and never self-restart —
149
+ `service stop` followed by any load-lock command moves them onto current
150
+ code. The restart behavior itself is live-verified on Linux by the test
151
+ suite (`src/test/update.test.ts`); the in-place-update path beyond
152
+ same-argv respawn is inspected, not live-tested.
153
+
107
154
  ## Configuration
108
155
 
109
156
  All configuration is agent-editable through tools (no file editing for
@@ -119,7 +166,8 @@ load-lock get-config
119
166
  ```
120
167
 
121
168
  Defaults: one pool `heavy` capacity 1 · offer window 30s · blocking wait 5s ·
122
- turn-wait cap (`maxTurnWaitMs`, bounds `wait_for_turn`/`load-lock wait`) 10min ·
169
+ turn-wait cap (`maxTurnWaitMs`, bounds `wait_for_turn`, `wait_for_completion`
170
+ and `load-lock wait --id/--job`) 10min ·
123
171
  start deadline 120s · untracked lease 15min. Valid ranges:
124
172
  offer window 1s–10min, wait 0–10min, turn-wait cap 1s–1h, start deadline 1s–1h, lease 10s–24h.
125
173