@aron98/load-lock 1.3.0 → 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
@@ -148,6 +148,21 @@ mechanisms, and labels exactly which apply per harness:
148
148
  can never orphan a daemon-side waiter when the harness cancels the
149
149
  tool mid-sleep. Daemon restarts are detected via the event cursor and
150
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.
151
166
  3. **Idle wakeup where the harness genuinely supports it.**
152
167
  - OpenCode (live-verified): the native plugin watches daemon events and
153
168
  calls `ctx.session.synthetic(...)`, delivering the offer (with token)
@@ -166,7 +166,8 @@ load-lock get-config
166
166
  ```
167
167
 
168
168
  Defaults: one pool `heavy` capacity 1 · offer window 30s · blocking wait 5s ·
169
- 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 ·
170
171
  start deadline 120s · untracked lease 15min. Valid ranges:
171
172
  offer window 1s–10min, wait 0–10min, turn-wait cap 1s–1h, start deadline 1s–1h, lease 10s–24h.
172
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aron98/load-lock",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Machine-wide counting semaphore coordinating resource-intensive work across parallel coding agents (OpenCode, Codex CLI, Claude Code, ...)",
5
5
  "type": "module",
6
6
  "license": "MIT",