@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.
- package/dist/adapters/opencode/plugin.d.ts +2 -1
- package/dist/adapters/opencode/plugin.js +85 -5
- package/dist/cli/main.js +34 -9
- package/dist/core/daemon.d.ts +24 -0
- package/dist/core/daemon.js +221 -0
- package/dist/core/state.d.ts +5 -0
- package/dist/core/state.js +16 -0
- package/dist/mcp/server.js +107 -7
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +6 -0
- package/dist/shared/protocol.d.ts +32 -0
- package/dist/shared/wait-completion.d.ts +67 -0
- package/dist/shared/wait-completion.js +505 -0
- package/dist/test/mcp.test.js +115 -1
- package/dist/test/wait-completion.test.d.ts +1 -0
- package/dist/test/wait-completion.test.js +418 -0
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +49 -3
- package/docs/ARCHITECTURE.md +15 -0
- package/docs/OPERATIONS.md +2 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
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 });
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/daemon.js
CHANGED
|
@@ -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
|
package/dist/core/state.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/state.js
CHANGED
|
@@ -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) {
|