@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.
@@ -4,7 +4,8 @@
4
4
  * Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
5
5
  * Provides:
6
6
  * - native tools in the `load_lock` namespace (acquire/claim/release/...)
7
- * - run_with_permit / job_status / job_stop: full execution lifecycle
7
+ * - run_with_permit / job_status / job_stop / wait_for_completion:
8
+ * full execution lifecycle incl. honest execution-phase sleeping
8
9
  * - automatic policy injection on every model call via the session
9
10
  * "context" hook (compaction-proof by construction)
10
11
  * - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
@@ -4,7 +4,8 @@
4
4
  * Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
5
5
  * Provides:
6
6
  * - native tools in the `load_lock` namespace (acquire/claim/release/...)
7
- * - run_with_permit / job_status / job_stop: full execution lifecycle
7
+ * - run_with_permit / job_status / job_stop / wait_for_completion:
8
+ * full execution lifecycle incl. honest execution-phase sleeping
8
9
  * - automatic policy injection on every model call via the session
9
10
  * "context" hook (compaction-proof by construction)
10
11
  * - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
@@ -21,6 +22,7 @@ import path from "node:path";
21
22
  import { LLClient } from "../../shared/client.js";
22
23
  import { POLICY_BRIEF } from "../../policy/text.js";
23
24
  import { waitForTurn, WaitAbortedError } from "../../shared/wait-turn.js";
25
+ import { waitForLocalJob, waitForTrackedJob } from "../../shared/wait-completion.js";
24
26
  import { killProcessGroup } from "../../shared/transport.js";
25
27
  const client = new LLClient();
26
28
  const knownSessions = new Set();
@@ -156,10 +158,12 @@ export default Plugin.define({
156
158
  reason: input.reason,
157
159
  author: "opencode:" + context.sessionID,
158
160
  }));
159
- add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs. Poll job_status.", {
161
+ add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs. Set wait:true to sleep through execution in this one call; otherwise sleep via wait_for_completion(job_id), quick-check via job_status.", {
160
162
  permit_token: { type: "string" },
161
163
  command: { type: "string", description: "The heavy shell command (not unrelated light commands)" },
162
164
  cwd: { type: "string" },
165
+ wait: { type: "boolean", description: "true = sleep through execution in this one call; wake promptly when the job finishes (bounded by wait_timeout_ms)" },
166
+ wait_timeout_ms: { type: "number", description: "Bounded sleep for wait:true (ms, default 600000); still_running checkpoint on timeout" },
163
167
  }, async (input, context) => {
164
168
  const child = spawn(input.command, {
165
169
  shell: true,
@@ -168,15 +172,20 @@ export default Plugin.define({
168
172
  env: process.env,
169
173
  stdio: ["ignore", "pipe", "pipe"],
170
174
  });
175
+ let resolveExit;
171
176
  const job = {
172
177
  id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
173
178
  child,
174
179
  permitToken: input.permit_token,
175
180
  command: input.command,
176
181
  sessionID: context.sessionID,
182
+ startedAt: Date.now(),
177
183
  exited: false,
178
184
  exitCode: null,
179
185
  output: [],
186
+ exitPromise: new Promise((resolve) => {
187
+ resolveExit = resolve;
188
+ }),
180
189
  };
181
190
  const push = (b) => {
182
191
  job.output.push(b.toString("utf8"));
@@ -190,18 +199,43 @@ export default Plugin.define({
190
199
  // round-trip to the daemon, or the permit would never auto-release.
191
200
  child.on("exit", (code) => {
192
201
  job.exited = true;
202
+ job.exitedAt = Date.now();
193
203
  job.exitCode = code;
194
- void client.request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" }).catch(() => { });
204
+ // releasePromise is assigned BEFORE resolveExit so a woken waiter
205
+ // sees the release round-trip in flight (see waitForLocalJob).
206
+ job.releasePromise = client
207
+ .request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" })
208
+ .then(() => {
209
+ job.permitReleased = true;
210
+ }, () => { });
211
+ resolveExit(code);
195
212
  });
196
213
  try {
197
- await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
214
+ const res = await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
215
+ job.requestId = res.entry?.requestId;
198
216
  }
199
217
  catch (err) {
200
218
  killProcessGroup(child.pid, "SIGKILL");
201
219
  return { error: `permit no longer startable (${err.message}); job killed before doing work; acquire again` };
202
220
  }
203
221
  jobs.set(job.id, job);
204
- return { job_id: job.id, pid: child.pid, state: "running", note: "auto-releases when the job exits; poll job_status" };
222
+ if (!input.wait) {
223
+ return { job_id: job.id, pid: child.pid, state: "running", note: "auto-releases when the job exits; sleep via wait_for_completion(job_id), quick-check via job_status" };
224
+ }
225
+ const r = await waitForLocalJob(job, { timeoutMs: input.wait_timeout_ms, signal: context.signal });
226
+ return {
227
+ job_id: job.id,
228
+ pid: child.pid,
229
+ command: job.command,
230
+ state: r.state,
231
+ exit_code: r.exitCode,
232
+ output_tail: r.outputTail,
233
+ duration_ms: r.durationMs,
234
+ permit_released: r.permitReleased,
235
+ waited_ms: r.waitedMs,
236
+ next_action: r.nextAction,
237
+ detail: r.detail,
238
+ };
205
239
  });
206
240
  add("job_status", "Poll a run_with_permit job: running/exited, exit code, output tail.", { job_id: { type: "string" } }, (input) => {
207
241
  const job = jobs.get(input.job_id);
@@ -220,6 +254,7 @@ export default Plugin.define({
220
254
  if (!job)
221
255
  return { error: "unknown job_id" };
222
256
  if (!job.exited) {
257
+ job.stopRequested = true;
223
258
  killProcessGroup(job.child.pid, "SIGTERM");
224
259
  setTimeout(() => {
225
260
  if (!job.exited) {
@@ -229,6 +264,51 @@ export default Plugin.define({
229
264
  }
230
265
  return { job_id: job.id, stopping: true };
231
266
  });
267
+ add("wait_for_completion", "Sleep honestly through the EXECUTION phase (replaces sleep-and-poll): bounded blocking wait that wakes promptly when the heavy job finishes. Pass job_id (run_with_permit job; exact exit-code wake) or request_id (daemon-tracked permit; liveness-sweep wake, permit released before you are woken). Structured result on every path: finished (exit_code/output_tail/duration/permit_released), still_running (checkpoint — call again), timeout (wait aborted, job untouched), job_unknown, stopped_by_request. timeout_ms bounds one call (default 600000; 0 = check-only).", {
268
+ job_id: { type: "string", description: "A run_with_permit job_id" },
269
+ request_id: { type: "string", description: "A daemon-tracked permit's request_id" },
270
+ timeout_ms: { type: "number", description: "Bounded sleep (ms, default 600000); still_running checkpoint on timeout — re-call" },
271
+ }, async (input, context) => {
272
+ if (!input.job_id && !input.request_id) {
273
+ return {
274
+ state: "job_unknown",
275
+ waited_ms: 0,
276
+ next_action: "Pass job_id (a run_with_permit job) or request_id (a daemon-tracked permit).",
277
+ detail: "no identifier given",
278
+ };
279
+ }
280
+ if (input.job_id && jobs.has(input.job_id)) {
281
+ const job = jobs.get(input.job_id);
282
+ const r = await waitForLocalJob(job, { timeoutMs: input.timeout_ms, signal: context.signal });
283
+ return {
284
+ state: r.state,
285
+ job_id: job.id,
286
+ request_id: job.requestId,
287
+ exit_code: r.exitCode,
288
+ output_tail: r.outputTail,
289
+ duration_ms: r.durationMs,
290
+ permit_released: r.permitReleased,
291
+ waited_ms: r.waitedMs,
292
+ started_at: r.startedAt,
293
+ next_action: r.nextAction,
294
+ detail: r.detail,
295
+ };
296
+ }
297
+ const requestId = input.request_id ?? input.job_id;
298
+ const r = await waitForTrackedJob({ client, requestId, timeoutMs: input.timeout_ms, signal: context.signal });
299
+ return {
300
+ state: r.state,
301
+ request_id: r.requestId ?? requestId,
302
+ exit_code: r.exitCode,
303
+ duration_ms: r.durationMs,
304
+ permit_released: r.permitReleased,
305
+ waited_ms: r.waitedMs,
306
+ effective_wait_ms: r.effectiveWaitMs,
307
+ started_at: r.startedAt,
308
+ next_action: r.nextAction,
309
+ detail: r.detail,
310
+ };
311
+ });
232
312
  add("attach_pid", "Associate a process you already started (e.g. a background shell task) with your claimed permit. Atomic; auto-releases when the process group exits.", { permit_token: { type: "string" }, pid: { type: "number" } }, (input) => client.request("attach_pid", { permitToken: input.permit_token, pid: input.pid }));
233
313
  add("mark_started", "Tell load-lock you started the work when no process can be associated. Weaker guarantee: heartbeat lease applies (see docs). Prefer run_with_permit.", { permit_token: { type: "string" } }, (input) => client.request("mark_started", { permitToken: input.permit_token }));
234
314
  });
package/dist/cli/main.js CHANGED
@@ -13,6 +13,7 @@ import { setTimeout as delay } from "node:timers/promises";
13
13
  import { LLClient, withClient } from "../shared/client.js";
14
14
  import { ProtocolError } from "../shared/protocol.js";
15
15
  import { waitForTurn } from "../shared/wait-turn.js";
16
+ import { waitForTrackedJob } from "../shared/wait-completion.js";
16
17
  import { stateDir, socketPath } from "../core/persist.js";
17
18
  import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
18
19
  import { packageVersion, compareVersions } from "../shared/version.js";
@@ -162,7 +163,7 @@ const HELP_GROUPS = [
162
163
  {
163
164
  title: "waiting & running",
164
165
  items: [
165
- { names: ["wait"], desc: "sleep until a queued request's turn, then auto-claim the offer: --id <request-id> [--timeout <ms>]" },
166
+ { names: ["wait"], desc: "sleep until a queued request's turn (--id <request-id>), auto-claiming the offer — or until a heavy job finishes (--job <request-id>): [--timeout <ms>]" },
166
167
  { names: ["run"], desc: "acquire (or reuse) a permit and run a command under it: run [--permit <token> | acquire flags] -- <cmd>" },
167
168
  ],
168
169
  },
@@ -388,19 +389,43 @@ async function main() {
388
389
  out(await withClient((c) => c.request("attach_pid", { permitToken: token, pid })));
389
390
  return;
390
391
  }
391
- // ------------------------------------------------------- wait (offer -> permit)
392
+ // ------------------------------------------------------- wait (offer -> permit / job -> done)
392
393
  case "wait": {
393
- // Watches a queued request; auto-claims the offer when capacity is
394
- // handed to it and prints the permit token. Honest bounded blocking
395
- // for scriptable flows (aider, plain shell scripts). Shares the exact
396
- // implementation with the MCP wait_for_turn tool and the OpenCode
397
- // plugin (src/shared/wait-turn.ts).
398
- const id = S("id") || fail("wait: --id <request-id> required");
394
+ // Two honest bounded sleeps sharing one command:
395
+ // --id : queue phase — watches a queued request, auto-claims the
396
+ // offer when capacity reaches it, prints the permit token.
397
+ // --job : execution phase — sleeps until the daemon-tracked job for
398
+ // that request-id finishes (liveness sweep wake; the permit
399
+ // is released before you are woken). Structured result on
400
+ // every path; still_running = checkpoint, call again.
401
+ // Both share the exact implementations with the MCP tools and the
402
+ // OpenCode plugin (src/shared/wait-turn.ts / wait-completion.ts).
399
403
  const timeoutMs = N("timeout", 600_000);
400
- const agent = S("agent", defaultAgent());
401
404
  const c = new LLClient();
402
405
  await c.connect();
403
406
  try {
407
+ const job = S("job");
408
+ if (job) {
409
+ const r = await waitForTrackedJob({ client: c, requestId: job, timeoutMs });
410
+ out({
411
+ state: r.state,
412
+ request_id: r.requestId ?? job,
413
+ exit_code: r.exitCode,
414
+ duration_ms: r.durationMs,
415
+ permit_released: r.permitReleased,
416
+ waited_ms: r.waitedMs,
417
+ effective_wait_ms: r.effectiveWaitMs,
418
+ started_at: r.startedAt,
419
+ detail: r.detail ?? r.nextAction,
420
+ });
421
+ if (r.state === "finished")
422
+ return;
423
+ if (r.state === "still_running")
424
+ fail(`wait: job still running after ${r.waitedMs}ms (checkpoint — call again)`, 68);
425
+ fail(`wait: job is ${r.state.replace(/_/g, " ")} — ${r.nextAction}`, 69);
426
+ }
427
+ const id = S("id") || fail("wait: --id <request-id> (queue wait) or --job <request-id> (job wait) required");
428
+ const agent = S("agent", defaultAgent());
404
429
  const r = await waitForTurn({ client: c, agentId: agent, requestId: id, timeoutMs });
405
430
  if (r.state === "granted" && r.entry?.permitToken) {
406
431
  out({ permit_token: r.entry.permitToken, state: r.state, request_id: r.requestId, auto_claimed: r.autoClaimed ?? false });
@@ -27,6 +27,8 @@ export declare class Daemon {
27
27
  * restart via the event cursor) and the process can exit promptly. */
28
28
  private conns;
29
29
  private waiters;
30
+ /** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
31
+ private completionWaiters;
30
32
  private sentEventId;
31
33
  private stateDirPath;
32
34
  private socketPath;
@@ -77,6 +79,28 @@ export declare class Daemon {
77
79
  * push notifications — no model-authored busy-polling required.
78
80
  */
79
81
  private waitForAdmission;
82
+ /**
83
+ * Bounded blocking wait for a daemon-tracked job's completion. Mirrors
84
+ * waitForAdmission: the call is held server-side and woken by
85
+ * resolveCompletionWaiters() when the job is observed done — which always
86
+ * happens AFTER the permit has been released (finish() frees capacity
87
+ * before the event is emitted, and waiters are resolved after that).
88
+ * timeoutMs 0 is check-only. Never blocks unbounded.
89
+ */
90
+ private waitForJobCompletion;
91
+ /**
92
+ * Classify a request whose entry is no longer held (released, reclaimed,
93
+ * cancelled, or never started) into a structured completion result, using
94
+ * the most recent event for the request.
95
+ */
96
+ private classifyEntryCompletion;
97
+ /**
98
+ * Wake blocked wait_for_completion calls whose tracked job has ended.
99
+ * Called after every machine transition (afterOp, sweep, expiry timer) —
100
+ * always AFTER finish() has freed capacity and after the event flush, so
101
+ * a woken agent observes the pool as free. Entries still held are skipped.
102
+ */
103
+ private resolveCompletionWaiters;
80
104
  /** Runs after every dispatch: sweep deadlines, auto-claim for waiters, flush events, journal. */
81
105
  private afterOp;
82
106
  private armExpiryTimer;
@@ -44,6 +44,8 @@ export class Daemon {
44
44
  * restart via the event cursor) and the process can exit promptly. */
45
45
  conns = new Set();
46
46
  waiters = new Map();
47
+ /** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
48
+ completionWaiters = new Map();
47
49
  sentEventId;
48
50
  stateDirPath;
49
51
  socketPath;
@@ -115,6 +117,22 @@ export class Daemon {
115
117
  if (this.stopped)
116
118
  return;
117
119
  this.stopped = true;
120
+ // Resolve blocked wait_for_completion calls first so no promise dangles.
121
+ // Responses may not make it out (conns are destroyed below) — that is
122
+ // fine: clients see the disconnect, reconnect, and re-issue the wait.
123
+ for (const [, waiters] of this.completionWaiters) {
124
+ for (const w of waiters) {
125
+ clearTimeout(w.timer);
126
+ w.resolve({
127
+ state: "job_unknown",
128
+ requestId: w.requestId,
129
+ waitedMs: this.machine.now() - w.registeredAt,
130
+ nextAction: "daemon is stopping — call wait_for_completion again after it is back",
131
+ detail: "daemon stopping",
132
+ });
133
+ }
134
+ }
135
+ this.completionWaiters.clear();
118
136
  if (this.sweepTimer)
119
137
  clearInterval(this.sweepTimer);
120
138
  if (this.expiryTimer)
@@ -395,6 +413,24 @@ export class Daemon {
395
413
  m.adminRelease(String(p.requestId));
396
414
  return [{ ok: true }, undefined];
397
415
  }
416
+ case "wait_for_completion": {
417
+ // Bounded "sleep until the job finishes" for a daemon-tracked job
418
+ // (issue #36) — the execution-phase counterpart of wait_for_turn.
419
+ const requestId = String(p.requestId ?? "");
420
+ if (!requestId)
421
+ throw new ProtocolError("invalid", "wait_for_completion: requestId is required");
422
+ const requested = Number(p.timeoutMs ?? 0);
423
+ if (!Number.isFinite(requested) || requested < 0) {
424
+ throw new ProtocolError("invalid", "wait_for_completion: timeoutMs must be a non-negative number of milliseconds");
425
+ }
426
+ // Same bounded-sleep contract as wait_for_turn: one call is capped by
427
+ // maxTurnWaitMs; longer waits are checkpointed (still_running) and re-issued.
428
+ const timeoutMs = Math.min(requested, m.config.maxTurnWaitMs);
429
+ const res = await this.waitForJobCompletion(requestId, timeoutMs);
430
+ if (timeoutMs !== requested)
431
+ res.effectiveWaitMs = timeoutMs;
432
+ return [res, undefined];
433
+ }
398
434
  default:
399
435
  throw new ProtocolError("invalid", `unknown method "${method}"`);
400
436
  }
@@ -414,6 +450,181 @@ export class Daemon {
414
450
  this.waiters.set(requestId, { requestId, resolve, timer });
415
451
  });
416
452
  }
453
+ // ---------------------------------------------- wait_for_completion (issue #36)
454
+ /**
455
+ * Bounded blocking wait for a daemon-tracked job's completion. Mirrors
456
+ * waitForAdmission: the call is held server-side and woken by
457
+ * resolveCompletionWaiters() when the job is observed done — which always
458
+ * happens AFTER the permit has been released (finish() frees capacity
459
+ * before the event is emitted, and waiters are resolved after that).
460
+ * timeoutMs 0 is check-only. Never blocks unbounded.
461
+ */
462
+ async waitForJobCompletion(requestId, timeoutMs) {
463
+ const m = this.machine;
464
+ let entry;
465
+ try {
466
+ entry = m.entryById(requestId);
467
+ }
468
+ catch {
469
+ return {
470
+ state: "job_unknown",
471
+ requestId,
472
+ waitedMs: 0,
473
+ nextAction: "no request with this id exists (yet) — check the id, or acquire first",
474
+ detail: "unknown request id",
475
+ };
476
+ }
477
+ if (entry.state === "held") {
478
+ if (entry.phase !== "running_tracked") {
479
+ const why = entry.phase === "claimed"
480
+ ? "permit is held but no process is attached yet — start the job (run_with_permit / attach_pid / start_job) first"
481
+ : "permit is running untracked (no process association) — completion is not observable; attach a pid or poll your job directly";
482
+ return { state: "job_unknown", requestId, waitedMs: 0, startedAt: entry.startedAt, nextAction: why, detail: `held permit in phase "${entry.phase}"` };
483
+ }
484
+ if (timeoutMs === 0) {
485
+ return {
486
+ state: "still_running",
487
+ requestId,
488
+ waitedMs: 0,
489
+ startedAt: entry.startedAt,
490
+ nextAction: "job still running — call wait_for_completion again (with a timeout) to sleep until it finishes",
491
+ detail: "check-only",
492
+ };
493
+ }
494
+ // Register a completion waiter; woken by resolveCompletionWaiters()
495
+ // when the job ends, or checkpointed by the bounded timer below.
496
+ return await new Promise((resolve) => {
497
+ const registeredAt = m.now();
498
+ const waiter = {
499
+ requestId,
500
+ resolve,
501
+ timer: null,
502
+ registeredAt,
503
+ };
504
+ waiter.timer = setTimeout(() => {
505
+ const arr = this.completionWaiters.get(requestId);
506
+ if (arr) {
507
+ const idx = arr.indexOf(waiter);
508
+ if (idx >= 0)
509
+ arr.splice(idx, 1);
510
+ if (arr.length === 0)
511
+ this.completionWaiters.delete(requestId);
512
+ }
513
+ resolve({
514
+ state: "still_running",
515
+ requestId,
516
+ waitedMs: m.now() - registeredAt,
517
+ startedAt: entry.startedAt,
518
+ nextAction: "job still running — call wait_for_completion again to keep sleeping honestly",
519
+ detail: "wait budget elapsed",
520
+ });
521
+ }, timeoutMs);
522
+ waiter.timer.unref?.();
523
+ const arr = this.completionWaiters.get(requestId) ?? [];
524
+ arr.push(waiter);
525
+ this.completionWaiters.set(requestId, arr);
526
+ });
527
+ }
528
+ // Terminal or pre-execution states: classify from the entry's last event.
529
+ return this.classifyEntryCompletion(requestId, 0);
530
+ }
531
+ /**
532
+ * Classify a request whose entry is no longer held (released, reclaimed,
533
+ * cancelled, or never started) into a structured completion result, using
534
+ * the most recent event for the request.
535
+ */
536
+ classifyEntryCompletion(requestId, waitedMs) {
537
+ const m = this.machine;
538
+ const ev = m.lastEventForRequest(requestId, 0);
539
+ const startedAt = ev?.entry?.startedAt;
540
+ if (!ev) {
541
+ return {
542
+ state: "job_unknown",
543
+ requestId,
544
+ waitedMs,
545
+ nextAction: "no completion is observable for this request id — verify the id or re-acquire",
546
+ detail: "entry is no longer held and no matching event was found",
547
+ };
548
+ }
549
+ switch (ev.type) {
550
+ case "released":
551
+ case "auto_released":
552
+ return {
553
+ state: "finished",
554
+ requestId,
555
+ durationMs: startedAt !== undefined ? ev.ts - startedAt : undefined,
556
+ permitReleased: true,
557
+ waitedMs,
558
+ startedAt,
559
+ nextAction: "job finished and the permit is released — capacity is free; proceed with the next step",
560
+ detail: ev.detail,
561
+ };
562
+ case "reclaimed":
563
+ if ((ev.detail ?? "").includes("admin")) {
564
+ return {
565
+ state: "stopped_by_request",
566
+ requestId,
567
+ waitedMs,
568
+ startedAt,
569
+ nextAction: "the permit was force-released by an admin while you were waiting — re-acquire before running more heavy work",
570
+ detail: ev.detail,
571
+ };
572
+ }
573
+ return {
574
+ state: "job_unknown",
575
+ requestId,
576
+ waitedMs,
577
+ startedAt,
578
+ nextAction: "the permit was reclaimed before completion could be observed — re-acquire before running more heavy work",
579
+ detail: ev.detail,
580
+ };
581
+ case "cancelled":
582
+ return {
583
+ state: "job_unknown",
584
+ requestId,
585
+ waitedMs,
586
+ startedAt,
587
+ nextAction: "the request was cancelled — re-acquire before running more heavy work",
588
+ detail: ev.detail,
589
+ };
590
+ default:
591
+ return {
592
+ state: "job_unknown",
593
+ requestId,
594
+ waitedMs,
595
+ startedAt,
596
+ nextAction: "no completion is observable for this request id — verify the id or re-acquire",
597
+ detail: `entry is in state "${ev.entry?.state ?? "unknown"}" (last event: ${ev.type})`,
598
+ };
599
+ }
600
+ }
601
+ /**
602
+ * Wake blocked wait_for_completion calls whose tracked job has ended.
603
+ * Called after every machine transition (afterOp, sweep, expiry timer) —
604
+ * always AFTER finish() has freed capacity and after the event flush, so
605
+ * a woken agent observes the pool as free. Entries still held are skipped.
606
+ */
607
+ resolveCompletionWaiters() {
608
+ if (this.completionWaiters.size === 0)
609
+ return;
610
+ const m = this.machine;
611
+ for (const [requestId, waiters] of [...this.completionWaiters]) {
612
+ let entry;
613
+ try {
614
+ entry = m.entryById(requestId);
615
+ }
616
+ catch {
617
+ entry = undefined;
618
+ }
619
+ if (entry && entry.state === "held")
620
+ continue; // job still running
621
+ this.completionWaiters.delete(requestId);
622
+ for (const w of waiters) {
623
+ clearTimeout(w.timer);
624
+ w.resolve(this.classifyEntryCompletion(requestId, m.now() - w.registeredAt));
625
+ }
626
+ }
627
+ }
417
628
  // ------------------------------------------------------------- post-op pass
418
629
  /** Runs after every dispatch: sweep deadlines, auto-claim for waiters, flush events, journal. */
419
630
  afterOp() {
@@ -448,6 +659,10 @@ export class Daemon {
448
659
  for (const ev of fresh)
449
660
  this.broadcast(ev);
450
661
  }
662
+ // Wake completion waiters whose job ended in this op — after the events
663
+ // flushed, i.e. after finish() already freed the capacity (ordering
664
+ // guarantee: a woken agent sees the pool as free).
665
+ this.resolveCompletionWaiters();
451
666
  // Persist held permits for restart reconciliation.
452
667
  try {
453
668
  saveJournal(this.stateDirPath, m.journalEntries());
@@ -474,6 +689,8 @@ export class Daemon {
474
689
  for (const ev of fresh)
475
690
  this.broadcast(ev);
476
691
  }
692
+ // Reclaims performed by the tick above must also wake waiters.
693
+ this.resolveCompletionWaiters();
477
694
  try {
478
695
  saveJournal(this.stateDirPath, this.machine.journalEntries());
479
696
  }
@@ -533,6 +750,10 @@ export class Daemon {
533
750
  for (const ev of fresh)
534
751
  this.broadcast(ev);
535
752
  }
753
+ // Wake completion waiters woken by this sweep (tracked job confirmed
754
+ // dead => auto-released above) — after capacity was freed and events
755
+ // flushed, per the wake ordering guarantee.
756
+ this.resolveCompletionWaiters();
536
757
  }
537
758
  }
538
759
  // --------------------------------------------------------------------- helpers
@@ -75,6 +75,11 @@ export declare class Machine {
75
75
  /** Events with id > since, newest last. */
76
76
  eventsSince(since: number, agentId?: string, limit?: number): MachineEvent[];
77
77
  latestEventId(): number;
78
+ /**
79
+ * Newest event with id > `since` whose entry is the given request, or null.
80
+ * Scans backwards, so the first match is the most recent one.
81
+ */
82
+ lastEventForRequest(requestId: string, since: number): MachineEvent | null;
78
83
  private emit;
79
84
  snapshot(e: Entry): EntrySnapshot;
80
85
  private ownerSnapshot;
@@ -87,6 +87,20 @@ export class Machine {
87
87
  latestEventId() {
88
88
  return this.nextEventId - 1;
89
89
  }
90
+ /**
91
+ * Newest event with id > `since` whose entry is the given request, or null.
92
+ * Scans backwards, so the first match is the most recent one.
93
+ */
94
+ lastEventForRequest(requestId, since) {
95
+ for (let i = this.events.length - 1; i >= 0; i--) {
96
+ const ev = this.events[i];
97
+ if (ev.id <= since)
98
+ break;
99
+ if (ev.entry?.requestId === requestId)
100
+ return ev;
101
+ }
102
+ return null;
103
+ }
90
104
  emit(type, entry, extra) {
91
105
  const targetAgent = entry ? entry.agentId : "*";
92
106
  const ev = {
@@ -134,6 +148,8 @@ export class Machine {
134
148
  s.leaseDeadline = e.leaseDeadline;
135
149
  if (e.lastSeen)
136
150
  s.lastSeen = e.lastSeen;
151
+ if (e.startedAt !== undefined)
152
+ s.startedAt = e.startedAt;
137
153
  return s;
138
154
  }
139
155
  ownerSnapshot(e) {