@aron98/load-lock 1.3.0 → 1.5.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 +100 -6
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +1 -9
- package/dist/cli/copies.js +2 -43
- package/dist/cli/install.js +1 -1
- package/dist/cli/main.js +82 -217
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/update.d.ts +53 -0
- package/dist/cli/update.js +274 -0
- 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/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +117 -8
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +6 -0
- package/dist/shared/client.js +11 -1
- package/dist/shared/drift.d.ts +21 -0
- package/dist/shared/drift.js +77 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +16 -0
- package/dist/shared/pkginfo.js +54 -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/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -0
- package/dist/test/update-command.test.d.ts +1 -0
- package/dist/test/update-command.test.js +608 -0
- 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 +61 -3
- package/docs/ARCHITECTURE.md +15 -0
- package/docs/COMPATIBILITY.md +16 -2
- package/docs/OPERATIONS.md +118 -9
- package/package.json +1 -1
|
@@ -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
|
|
76
|
-
|
|
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
|
|
package/docs/AGENT_GUIDE.md
CHANGED
|
@@ -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).
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
@@ -173,3 +219,15 @@ When you hold the permit, you own a share of the machine — bound your own
|
|
|
173
219
|
work too: prefer e.g. `cargo test -j 2` or a bounded test-worker count over
|
|
174
220
|
unbounded parallelism, and publish what you did via
|
|
175
221
|
`configure(... guidance ...)` notes if it should become convention.
|
|
222
|
+
|
|
223
|
+
## 7. Keeping load-lock itself current
|
|
224
|
+
|
|
225
|
+
If load-lock tells you its adapter copy is newer than the installed CLI
|
|
226
|
+
(`load-lock: this load-lock adapter copy (vX) is newer than the installed
|
|
227
|
+
CLI (vY). Run \`load-lock update\` ...`), run it **as an ordinary command**
|
|
228
|
+
(CLI: `load-lock update`; MCP/plugin: spawn the CLI or tell the user) —
|
|
229
|
+
never try to install anything behind the notice; it is notify-only.
|
|
230
|
+
`load-lock update --check` is the safe dry run: exit 0 = up to date,
|
|
231
|
+
1 = update available (or this copy cannot self-apply it — repo checkouts
|
|
232
|
+
and opencode-managed copies are refused honestly), 70 = failure. A
|
|
233
|
+
successful apply restarts the service itself; you do not need to.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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)
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -89,7 +89,11 @@ Which copy is authoritative for what:
|
|
|
89
89
|
(it refreshes the cache entry); you cannot reliably update it by hand.
|
|
90
90
|
- **CLI commands** (`load-lock status`, `service`, `doctor`, …) — the copy on
|
|
91
91
|
PATH (typically a global install via npm/bun). Update it with your usual
|
|
92
|
-
global install command
|
|
92
|
+
global install command, or `load-lock update` (since #38), which detects
|
|
93
|
+
the package manager from the copy's location and refuses to touch copies
|
|
94
|
+
it does not manage: it will not "update" the opencode cache copy (opencode
|
|
95
|
+
owns it) nor a git checkout (your git workflow owns it), and it restarts
|
|
96
|
+
the service after a successful apply.
|
|
93
97
|
- **The daemon's code** — whichever copy spawned it (usually the CLI copy,
|
|
94
98
|
since auto-spawn and `service start` run from the CLI client code). The
|
|
95
99
|
version handshake makes a daemon self-restart onto updated code when a
|
|
@@ -97,7 +101,17 @@ Which copy is authoritative for what:
|
|
|
97
101
|
argv, so it only picks up an update when its spawning copy was updated
|
|
98
102
|
*in place* (the normal global-upgrade case). A daemon spawned from a
|
|
99
103
|
different copy than the one you updated keeps running the old code until
|
|
100
|
-
`load-lock service
|
|
104
|
+
`load-lock service restart` (since #38) or `service stop` + any command
|
|
105
|
+
re-spawns it from the new one.
|
|
106
|
+
|
|
107
|
+
The mirror drift — a *newer* adapter copy (opencode refreshed its cache)
|
|
108
|
+
with an *older* CLI on PATH — surfaces at adapter init (issue #38): the
|
|
109
|
+
plugin/MCP server prints one clear notice naming `load-lock update` and
|
|
110
|
+
embeds it in the injected context/instructions. Notify only, never
|
|
111
|
+
auto-install. See
|
|
112
|
+
[OPERATIONS.md](OPERATIONS.md#adapter-init-drift-notice) for the
|
|
113
|
+
in-memory-staleness limitation (a running session keeps the old adapter
|
|
114
|
+
code until the harness restarts).
|
|
101
115
|
|
|
102
116
|
`load-lock doctor` lists every copy it can find (PATH-resolved CLI, this
|
|
103
117
|
running copy, opencode cache copies) with versions and warns when they
|