@aron98/load-lock 0.1.1 → 1.0.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/README.md +37 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +31 -0
- package/dist/adapters/opencode/tui.js +268 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +740 -61
- package/dist/cli/main.js +36 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +98 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +12 -0
- package/dist/dashboard/frame.js +94 -0
- package/dist/dashboard/view.d.ts +148 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +405 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +33 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +215 -26
- package/docs/OPERATIONS.md +313 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard tests (issue #10): the data plumbing behind `load-lock top`
|
|
3
|
+
* and the OpenCode TUI plugin panel.
|
|
4
|
+
*
|
|
5
|
+
* - view-model shaping (buildDashboardView) against protocol fixtures,
|
|
6
|
+
* with "now" pinned so countdowns are deterministic
|
|
7
|
+
* - formatters (fmtDuration / fmtBytes) and the utilization bar
|
|
8
|
+
* - event one-liners (eventText)
|
|
9
|
+
* - frame renderer bounds (width clamp, height slice)
|
|
10
|
+
* - DashboardFeed against a REAL daemon: live view, queue visibility,
|
|
11
|
+
* release, resync idempotence, and reconnect + event-cursor resume
|
|
12
|
+
* across a daemon SIGKILL -> fresh-daemon-on-the-same-socket restart
|
|
13
|
+
* - `load-lock top --once` smoke test (read-only, never acquires)
|
|
14
|
+
*/
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { rmSync } from "node:fs";
|
|
19
|
+
import { DashboardFeed } from "../dashboard/feed.js";
|
|
20
|
+
import { renderFrame, utilizationBar } from "../dashboard/frame.js";
|
|
21
|
+
import { buildDashboardView, eventText, fmtBytes, fmtDuration } from "../dashboard/view.js";
|
|
22
|
+
import { daemonMainPath, probe, runCli, startDaemon, until } from "./testutil.js";
|
|
23
|
+
const NOW = 1_760_000_000_000;
|
|
24
|
+
// ----------------------------------------------------------------- fixtures
|
|
25
|
+
function holder() {
|
|
26
|
+
return {
|
|
27
|
+
requestId: "req_holder_000001",
|
|
28
|
+
pool: "heavy",
|
|
29
|
+
agentId: "agent-a",
|
|
30
|
+
agentLabel: "claude-verify",
|
|
31
|
+
cwd: "/w",
|
|
32
|
+
opKey: "docker-build",
|
|
33
|
+
reason: "docker build of harness images",
|
|
34
|
+
weight: 1,
|
|
35
|
+
state: "held",
|
|
36
|
+
phase: "running_tracked",
|
|
37
|
+
since: NOW - 30_000,
|
|
38
|
+
pid: 111,
|
|
39
|
+
pgid: 111,
|
|
40
|
+
heldFor: 30_000,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function statusFixture() {
|
|
44
|
+
return {
|
|
45
|
+
daemon: { pid: 4242, startedAt: NOW - 90_000, protocolVersion: 1 },
|
|
46
|
+
config: {
|
|
47
|
+
version: 3,
|
|
48
|
+
pools: {},
|
|
49
|
+
offerTimeoutMs: 30_000,
|
|
50
|
+
maxWaitMs: 60_000,
|
|
51
|
+
maxTurnWaitMs: 600_000,
|
|
52
|
+
startTimeoutMs: 60_000,
|
|
53
|
+
untrackedLeaseMs: 60_000,
|
|
54
|
+
},
|
|
55
|
+
pools: [
|
|
56
|
+
{
|
|
57
|
+
pool: "heavy",
|
|
58
|
+
capacity: 2,
|
|
59
|
+
used: 2,
|
|
60
|
+
holders: [holder()],
|
|
61
|
+
offers: [
|
|
62
|
+
{
|
|
63
|
+
requestId: "req_offer_000001",
|
|
64
|
+
pool: "heavy",
|
|
65
|
+
agentId: "agent-b",
|
|
66
|
+
opKey: "npm-test",
|
|
67
|
+
reason: "npm test",
|
|
68
|
+
weight: 1,
|
|
69
|
+
state: "offered",
|
|
70
|
+
since: NOW - 2_000,
|
|
71
|
+
offerExpiresAt: NOW + 28_000,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
queue: [
|
|
75
|
+
{
|
|
76
|
+
requestId: "req_queue_000001",
|
|
77
|
+
pool: "heavy",
|
|
78
|
+
agentId: "agent-c",
|
|
79
|
+
opKey: "npm-ci",
|
|
80
|
+
reason: "npm ci",
|
|
81
|
+
weight: 1,
|
|
82
|
+
state: "queued",
|
|
83
|
+
since: NOW - 5_000,
|
|
84
|
+
position: 1,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
paused: [
|
|
88
|
+
{
|
|
89
|
+
requestId: "req_paused_000001",
|
|
90
|
+
pool: "heavy",
|
|
91
|
+
agentId: "agent-d",
|
|
92
|
+
opKey: "build",
|
|
93
|
+
reason: "docker build images",
|
|
94
|
+
weight: 1,
|
|
95
|
+
state: "paused",
|
|
96
|
+
since: NOW - 45_000,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
events: { latestEventId: 7 },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function inspectFixture() {
|
|
105
|
+
return {
|
|
106
|
+
cpu: {
|
|
107
|
+
logicalCores: 16,
|
|
108
|
+
model: "Test CPU",
|
|
109
|
+
loadavg: [0.4, 0.3, 0.25],
|
|
110
|
+
cgroupVersion: "2",
|
|
111
|
+
cgroupCpuQuota: 200,
|
|
112
|
+
effectiveCores: 2,
|
|
113
|
+
loadPerEffectiveCore: 0.2,
|
|
114
|
+
},
|
|
115
|
+
memory: {
|
|
116
|
+
totalBytes: 34359738368,
|
|
117
|
+
availableBytes: 8000000000,
|
|
118
|
+
freeBytes: 7000000000,
|
|
119
|
+
effectiveLimitBytes: 17179869184,
|
|
120
|
+
},
|
|
121
|
+
pressure: { note: "cgroup limits apply", loadPressure: "low", memPressure: "moderate" },
|
|
122
|
+
loadLock: { pools: {}, suggestion: "1 parallel heavy job fits the quota" },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function eventsFixture() {
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
id: 5,
|
|
129
|
+
ts: NOW - 1_000,
|
|
130
|
+
type: "claimed",
|
|
131
|
+
pool: "heavy",
|
|
132
|
+
entry: holder(),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 6,
|
|
136
|
+
ts: NOW - 500,
|
|
137
|
+
type: "started",
|
|
138
|
+
pool: "heavy",
|
|
139
|
+
entry: holder(),
|
|
140
|
+
detail: "tracked pid 111",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 7,
|
|
144
|
+
ts: NOW - 100,
|
|
145
|
+
type: "config_changed",
|
|
146
|
+
detail: "by aron98: bump heavy capacity to 2",
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
function inputs(over = {}) {
|
|
151
|
+
return {
|
|
152
|
+
status: statusFixture(),
|
|
153
|
+
inspect: inspectFixture(),
|
|
154
|
+
events: eventsFixture(),
|
|
155
|
+
latestEventId: 7,
|
|
156
|
+
state: "live",
|
|
157
|
+
lastSyncAt: NOW - 250,
|
|
158
|
+
now: NOW,
|
|
159
|
+
...over,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// --------------------------------------------------------------- formatters
|
|
163
|
+
test("fmtDuration covers ms..days and negatives", () => {
|
|
164
|
+
assert.equal(fmtDuration(412), "412ms");
|
|
165
|
+
assert.equal(fmtDuration(3_000), "3s");
|
|
166
|
+
assert.equal(fmtDuration(252_000), "4m 12s");
|
|
167
|
+
assert.equal(fmtDuration(7_500_000), "2h 05m");
|
|
168
|
+
assert.equal(fmtDuration((3 * 86_400 + 4 * 3_600) * 1000), "3d 4h");
|
|
169
|
+
assert.equal(fmtDuration(-500), "-500ms");
|
|
170
|
+
});
|
|
171
|
+
test("fmtBytes is compact and clamps units", () => {
|
|
172
|
+
assert.equal(fmtBytes(512), "512B");
|
|
173
|
+
assert.equal(fmtBytes(512 * 1024), "512Ki");
|
|
174
|
+
assert.equal(fmtBytes(12.4 * 1024 ** 3), "12.4Gi");
|
|
175
|
+
assert.equal(fmtBytes(200 * 1024 ** 2), "200Mi");
|
|
176
|
+
});
|
|
177
|
+
test("utilizationBar clamps and fills", () => {
|
|
178
|
+
assert.equal(utilizationBar(0.5, 10), "#####-----");
|
|
179
|
+
assert.equal(utilizationBar(1.5, 10), "##########");
|
|
180
|
+
assert.equal(utilizationBar(-1, 10), "----------");
|
|
181
|
+
assert.equal(utilizationBar(0, 0).length, 3); // width floor of 3
|
|
182
|
+
});
|
|
183
|
+
// ---------------------------------------------------------------- eventText
|
|
184
|
+
test("eventText renders one-liners per event type", () => {
|
|
185
|
+
assert.equal(eventText({ id: 6, ts: 0, type: "started", entry: holder(), detail: "tracked pid 111" }), "started claude-verify: tracked pid 111");
|
|
186
|
+
assert.match(eventText({ id: 2, ts: 0, type: "offer_expired", pool: "heavy" }), /paused \(rejoin to requeue\)/);
|
|
187
|
+
assert.equal(eventText({ id: 7, ts: 0, type: "config_changed", detail: "by aron98: bump capacity" }), "config changed: by aron98: bump capacity");
|
|
188
|
+
assert.equal(eventText({
|
|
189
|
+
id: 1,
|
|
190
|
+
ts: 0,
|
|
191
|
+
type: "released",
|
|
192
|
+
pool: "heavy",
|
|
193
|
+
entry: {
|
|
194
|
+
requestId: "r",
|
|
195
|
+
pool: "heavy",
|
|
196
|
+
agentId: "agent-x",
|
|
197
|
+
opKey: "k",
|
|
198
|
+
reason: "npm test",
|
|
199
|
+
weight: 1,
|
|
200
|
+
state: "released",
|
|
201
|
+
since: 0,
|
|
202
|
+
},
|
|
203
|
+
}), "released heavy agent-x (npm test)");
|
|
204
|
+
});
|
|
205
|
+
// ------------------------------------------------------------ view shaping
|
|
206
|
+
test("buildDashboardView shapes pools, cards, machine and events", () => {
|
|
207
|
+
const v = buildDashboardView(inputs());
|
|
208
|
+
assert.equal(v.state, "live");
|
|
209
|
+
assert.equal(v.latestEventId, 7);
|
|
210
|
+
assert.equal(v.daemon?.pid, 4242);
|
|
211
|
+
assert.equal(v.daemon?.uptimeMs, 90_000);
|
|
212
|
+
assert.equal(v.daemon?.configVersion, 3);
|
|
213
|
+
const pool = v.pools[0];
|
|
214
|
+
assert.equal(pool.pool, "heavy");
|
|
215
|
+
assert.equal(pool.capacity, 2);
|
|
216
|
+
assert.equal(pool.used, 2);
|
|
217
|
+
assert.equal(pool.free, 0);
|
|
218
|
+
assert.equal(pool.utilization, 1);
|
|
219
|
+
assert.equal(pool.heldWeight, 1);
|
|
220
|
+
assert.equal(pool.offeredWeight, 1);
|
|
221
|
+
const h = pool.holders[0];
|
|
222
|
+
assert.equal(h.agent, "claude-verify");
|
|
223
|
+
assert.equal(h.phase, "running_tracked");
|
|
224
|
+
assert.equal(h.phaseLabel, "running (tracked)");
|
|
225
|
+
assert.equal(h.pid, 111);
|
|
226
|
+
assert.equal(h.heldMs, 30_000);
|
|
227
|
+
assert.equal(h.startDeadlineInMs, undefined); // only for claimed
|
|
228
|
+
const o = pool.offers[0];
|
|
229
|
+
assert.equal(o.agentId, "agent-b");
|
|
230
|
+
assert.equal(o.claimInMs, 28_000);
|
|
231
|
+
const q = pool.queue[0];
|
|
232
|
+
assert.equal(q.position, 1);
|
|
233
|
+
assert.equal(q.waitMs, 5_000);
|
|
234
|
+
assert.equal(pool.paused[0].pausedMs, 45_000);
|
|
235
|
+
assert.equal(v.machine?.effectiveCores, 2);
|
|
236
|
+
assert.equal(v.machine?.cgroupCpuQuota, 200);
|
|
237
|
+
assert.equal(v.machine?.memPressure, "moderate");
|
|
238
|
+
assert.equal(v.machine?.suggestion, "1 parallel heavy job fits the quota");
|
|
239
|
+
// events are newest-first with agoMs relative to now
|
|
240
|
+
assert.deepEqual(v.events.map((e) => e.id), [7, 6, 5]);
|
|
241
|
+
assert.equal(v.events[2].agoMs, 1_000);
|
|
242
|
+
});
|
|
243
|
+
test("buildDashboardView tolerates missing status/inspect and carries the restart note", () => {
|
|
244
|
+
const v = buildDashboardView(inputs({ status: null, inspect: null, state: "reconnecting", daemonRestartNote: "daemon restarted (pid 7); event history re-synced" }));
|
|
245
|
+
assert.equal(v.daemon, null);
|
|
246
|
+
assert.equal(v.machine, null);
|
|
247
|
+
assert.deepEqual(v.pools, []);
|
|
248
|
+
assert.equal(v.state, "reconnecting");
|
|
249
|
+
assert.match(v.daemonRestartNote ?? "", /daemon restarted/);
|
|
250
|
+
});
|
|
251
|
+
// ------------------------------------------------------------ frame bounds
|
|
252
|
+
test("renderFrame clamps width and height", () => {
|
|
253
|
+
const v = buildDashboardView(inputs());
|
|
254
|
+
for (const [w, h] of [
|
|
255
|
+
[60, 20],
|
|
256
|
+
[100, 40],
|
|
257
|
+
[10, 10], // width floors at 48
|
|
258
|
+
]) {
|
|
259
|
+
const maxW = Math.max(48, Math.min(w, 240));
|
|
260
|
+
const lines = renderFrame(v, w, h);
|
|
261
|
+
assert.ok(lines.length <= h, `height respected for ${w}x${h}`);
|
|
262
|
+
for (const line of lines)
|
|
263
|
+
assert.ok(line.length <= maxW, `width respected for ${w}x${h}`);
|
|
264
|
+
}
|
|
265
|
+
const tall = renderFrame(v, 100, 1000);
|
|
266
|
+
assert.ok(tall.some((l) => l.includes("POOL heavy")));
|
|
267
|
+
assert.ok(tall.some((l) => l.includes("EVENTS (newest first)")));
|
|
268
|
+
assert.ok(tall.some((l) => l.includes("HELD claude-verify")));
|
|
269
|
+
assert.ok(tall.some((l) => l.includes("OFFER agent-b")));
|
|
270
|
+
assert.ok(tall.some((l) => l.includes("QUEUE #1")));
|
|
271
|
+
assert.ok(tall.some((l) => l.includes("PAUSED agent-d")));
|
|
272
|
+
});
|
|
273
|
+
// -------------------------------------------------- feed against a real daemon
|
|
274
|
+
/** Kill the daemon handle's child hard (SIGKILL) and wait for exit. */
|
|
275
|
+
async function killDaemon(child) {
|
|
276
|
+
child.kill("SIGKILL");
|
|
277
|
+
if (child.exitCode === null) {
|
|
278
|
+
await new Promise((resolve) => child.once("exit", () => resolve()));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
test("feed: live view, queue, release, cursor resume across a daemon restart", async () => {
|
|
282
|
+
const d = await startDaemon();
|
|
283
|
+
const feed = new DashboardFeed(d.client(), {
|
|
284
|
+
refreshDebounceMs: 5,
|
|
285
|
+
reconnectBaseMs: 40,
|
|
286
|
+
slowRefreshMs: 60_000,
|
|
287
|
+
inspectEveryMs: 10_000,
|
|
288
|
+
eventHistory: 100,
|
|
289
|
+
});
|
|
290
|
+
let child2 = null;
|
|
291
|
+
try {
|
|
292
|
+
await feed.start();
|
|
293
|
+
assert.equal(feed.currentState(), "live");
|
|
294
|
+
const ctl = d.client();
|
|
295
|
+
const granted = (await ctl.request("acquire", {
|
|
296
|
+
agentId: "agent-a",
|
|
297
|
+
agentLabel: "holder-agent",
|
|
298
|
+
opKey: "build-a",
|
|
299
|
+
reason: "integration test heavy build",
|
|
300
|
+
}));
|
|
301
|
+
assert.equal(granted.state, "granted");
|
|
302
|
+
const tokenA = granted.entry.permitToken;
|
|
303
|
+
// Associate the pid via the documented flow (acquire -> start_job).
|
|
304
|
+
// Note: `AcquireParams.pid` is a deprecated shorthand the daemon does
|
|
305
|
+
// not implement — start_job is the supported association path.
|
|
306
|
+
await ctl.request("start_job", { permitToken: tokenA, pid: process.pid });
|
|
307
|
+
await until("holder card visible (tracked)", () => feed.view().pools[0]?.holders[0]?.phase === "running_tracked");
|
|
308
|
+
const card = feed.view().pools[0].holders[0];
|
|
309
|
+
assert.equal(card.agent, "holder-agent");
|
|
310
|
+
assert.equal(card.phase, "running_tracked");
|
|
311
|
+
assert.equal(card.pid, process.pid);
|
|
312
|
+
// Regression (watch_started cursor bug): the daemon's watch confirmation
|
|
313
|
+
// reports the full latestEventId, but the feed must NOT adopt it as its
|
|
314
|
+
// cursor — the initial sync still has to pull the whole event history.
|
|
315
|
+
await until("event feed populated from history", () => feed.view().events.length > 0);
|
|
316
|
+
assert.ok(feed.view().events.some((e) => e.text.includes("daemon started")), "daemon_started from history must be in the feed");
|
|
317
|
+
assert.ok(feed.view().events.some((e) => e.text.includes("holder-agent")), "the grant/claim events for holder-agent must be in the feed");
|
|
318
|
+
// second acquire queues (capacity 1 by default)
|
|
319
|
+
const queued = (await ctl.request("acquire", {
|
|
320
|
+
agentId: "agent-b",
|
|
321
|
+
agentLabel: "queued-agent",
|
|
322
|
+
opKey: "test-b",
|
|
323
|
+
reason: "npm test suite",
|
|
324
|
+
waitMs: 200,
|
|
325
|
+
}));
|
|
326
|
+
assert.equal(queued.state, "queued");
|
|
327
|
+
await until("queue card visible", () => feed.view().pools[0]?.queue.length === 1);
|
|
328
|
+
assert.equal(feed.view().pools[0].queue[0].agent, "queued-agent");
|
|
329
|
+
// resync is idempotent: repeated pulls never duplicate event ids
|
|
330
|
+
await feed.resync();
|
|
331
|
+
await feed.resync();
|
|
332
|
+
const ids = feed.view().events.map((e) => e.id);
|
|
333
|
+
assert.equal(new Set(ids).size, ids.length, "no duplicate event ids after repeated resync");
|
|
334
|
+
// release frees capacity; FIFO immediately reserves it for the queued
|
|
335
|
+
// head (agent-b) as an offer — the feed must surface the offer card live.
|
|
336
|
+
await ctl.request("release", { permitToken: tokenA, outcome: "success" });
|
|
337
|
+
await until("holder released", () => feed.view().pools[0]?.holders.length === 0);
|
|
338
|
+
await until("queued head receives the offer", () => feed.view().pools[0]?.offers.length === 1);
|
|
339
|
+
const offer = feed.view().pools[0].offers[0];
|
|
340
|
+
assert.equal(offer.agent, "queued-agent");
|
|
341
|
+
assert.ok(offer.claimInMs > 0, "claim-window countdown is live");
|
|
342
|
+
// retire the queued request so capacity frees up for the next acquire
|
|
343
|
+
await ctl.request("cancel", { requestId: queued.entry.requestId });
|
|
344
|
+
await until("offer and queue drained after cancel", () => (feed.view().pools[0]?.offers.length ?? 0) + (feed.view().pools[0]?.queue.length ?? 0) === 0);
|
|
345
|
+
// hold again so the journal has a permit to restore across the restart
|
|
346
|
+
const granted2 = (await ctl.request("acquire", {
|
|
347
|
+
agentId: "agent-a",
|
|
348
|
+
agentLabel: "holder-agent",
|
|
349
|
+
opKey: "build-a2",
|
|
350
|
+
reason: "survives daemon restart",
|
|
351
|
+
}));
|
|
352
|
+
const tokenA2 = granted2.entry.permitToken;
|
|
353
|
+
await ctl.request("start_job", { permitToken: tokenA2, pid: process.pid });
|
|
354
|
+
await until("second holder visible (tracked)", () => feed.view().pools[0]?.holders[0]?.phase === "running_tracked");
|
|
355
|
+
const oldPid = feed.view().daemon?.pid;
|
|
356
|
+
await killDaemon(d.child); // SIGKILL: no clean shutdown
|
|
357
|
+
await until("feed notices the loss", () => feed.currentState() === "reconnecting");
|
|
358
|
+
// fresh daemon on the SAME state dir + socket (journal recovery path)
|
|
359
|
+
child2 = spawn(process.execPath, [daemonMainPath, "--state-dir", d.dir, "--socket", d.sock], {
|
|
360
|
+
env: d.env,
|
|
361
|
+
stdio: "ignore",
|
|
362
|
+
});
|
|
363
|
+
await until("replacement daemon is up", () => probe(d.sock));
|
|
364
|
+
await until("feed is live again", () => feed.currentState() === "live");
|
|
365
|
+
const v = feed.view();
|
|
366
|
+
assert.notEqual(v.daemon?.pid, oldPid);
|
|
367
|
+
assert.match(v.daemonRestartNote ?? "", /daemon restarted/);
|
|
368
|
+
// event ids restarted from ~1 and the cursor reset with them
|
|
369
|
+
assert.ok(v.latestEventId < 20, `cursor reset across restart (got #${v.latestEventId})`);
|
|
370
|
+
// journal recovered the held permit and the event feed re-synced
|
|
371
|
+
await until("recovered holder visible", () => feed.view().pools[0]?.holders.length === 1);
|
|
372
|
+
const texts = feed.view().events.map((e) => e.text).join("\n");
|
|
373
|
+
assert.match(texts, /daemon recovered/);
|
|
374
|
+
// live updates still flow against the new daemon
|
|
375
|
+
const ctl2 = d.client();
|
|
376
|
+
await ctl2.request("release", { permitToken: tokenA2, outcome: "success" });
|
|
377
|
+
await until("post-restart release reflected", () => feed.view().pools[0]?.holders.length === 0);
|
|
378
|
+
ctl2.close();
|
|
379
|
+
ctl.close();
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
await feed.stop();
|
|
383
|
+
if (child2 && child2.exitCode === null) {
|
|
384
|
+
child2.kill("SIGKILL");
|
|
385
|
+
await new Promise((resolve) => child2.once("exit", () => resolve()));
|
|
386
|
+
}
|
|
387
|
+
rmSync(d.dir, { recursive: true, force: true });
|
|
388
|
+
d.child.kill("SIGKILL");
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
// ------------------------------------------------------------- top smoke
|
|
392
|
+
test("load-lock top --once renders one frame and exits clean", async () => {
|
|
393
|
+
const d = await startDaemon();
|
|
394
|
+
try {
|
|
395
|
+
const r = await runCli(d, ["top", "--once"]);
|
|
396
|
+
assert.equal(r.code, 0);
|
|
397
|
+
assert.ok(r.stdout.includes("load-lock top"), "banner present");
|
|
398
|
+
assert.ok(r.stdout.includes("POOL heavy"), "pool line present");
|
|
399
|
+
assert.ok(r.stdout.includes("q quit"), "footer present");
|
|
400
|
+
assert.ok(r.stdout.includes("never acquires a permit"), "read-only note present");
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await d.stop();
|
|
404
|
+
}
|
|
405
|
+
});
|
package/dist/test/hook.test.js
CHANGED
|
@@ -11,6 +11,9 @@ import { tmpdir } from "node:os";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { startDaemon, delay, cliMainPath } from "./testutil.js";
|
|
13
13
|
const POLICY_MARK = "# load-lock: machine-wide heavy-work budget";
|
|
14
|
+
// Same override as daemon.test.ts / testutil.ts: never touch the user's
|
|
15
|
+
// real state dir; LL_TEST_TMP redirects where scratch dirs are created.
|
|
16
|
+
const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
|
|
14
17
|
function runHook(d, args, input, env) {
|
|
15
18
|
return new Promise((resolve, reject) => {
|
|
16
19
|
const child = spawn(process.execPath, [cliMainPath, "hook", ...args], {
|
|
@@ -59,7 +62,7 @@ test("hooks: session-start events print the policy brief + machine state", async
|
|
|
59
62
|
test("hooks: claude-code stop with a matching offer -> decision block carrying the offer token", async () => {
|
|
60
63
|
const d = await startDaemon();
|
|
61
64
|
try {
|
|
62
|
-
const cwd = mkdtempSync(join(
|
|
65
|
+
const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
|
|
63
66
|
const offer = await outstandingOffer(d, cwd);
|
|
64
67
|
assert.ok(offer?.offerToken, "waiter has an outstanding offer");
|
|
65
68
|
const r = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd });
|
|
@@ -84,7 +87,7 @@ test("hooks: claude-code stop with a matching offer -> decision block carrying t
|
|
|
84
87
|
test("hooks: codex stop behaves the same (decision block JSON)", async () => {
|
|
85
88
|
const d = await startDaemon();
|
|
86
89
|
try {
|
|
87
|
-
const cwd = mkdtempSync(join(
|
|
90
|
+
const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
|
|
88
91
|
const offer = await outstandingOffer(d, cwd);
|
|
89
92
|
assert.ok(offer?.offerToken);
|
|
90
93
|
const r = await runHook(d, ["codex", "stop"], { session_id: "s1", cwd, stop_hook_active: false });
|
|
@@ -96,10 +99,57 @@ test("hooks: codex stop behaves the same (decision block JSON)", async () => {
|
|
|
96
99
|
await d.stop();
|
|
97
100
|
}
|
|
98
101
|
});
|
|
102
|
+
test("hooks: cursor stop with a matching offer -> followup_message carrying the offer token", async () => {
|
|
103
|
+
const d = await startDaemon();
|
|
104
|
+
try {
|
|
105
|
+
const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
|
|
106
|
+
const offer = await outstandingOffer(d, cwd);
|
|
107
|
+
assert.ok(offer?.offerToken, "waiter has an outstanding offer");
|
|
108
|
+
// Cursor hooks v1 input: workspace_roots (no cwd), loop_count, status.
|
|
109
|
+
const r = await runHook(d, ["cursor", "stop"], {
|
|
110
|
+
conversation_id: "c1",
|
|
111
|
+
hook_event_name: "stop",
|
|
112
|
+
workspace_roots: [cwd],
|
|
113
|
+
loop_count: 0,
|
|
114
|
+
status: "completed",
|
|
115
|
+
});
|
|
116
|
+
assert.equal(r.code, 0);
|
|
117
|
+
const out = JSON.parse(r.stdout);
|
|
118
|
+
assert.equal(typeof out.followup_message, "string", "Cursor Stop continues via followup_message");
|
|
119
|
+
assert.ok(out.followup_message.includes(offer.offerToken), "message tells the agent which offer_token to claim");
|
|
120
|
+
assert.ok(out.followup_message.includes("claim"), "message instructs claiming");
|
|
121
|
+
assert.ok(out.followup_message.includes(offer.opKey));
|
|
122
|
+
assert.equal(out.decision, undefined, "Cursor does not use the Claude/Codex decision shape");
|
|
123
|
+
// loop_count > 0: never chain followups (Cursor's loop guard)
|
|
124
|
+
const quiet = await runHook(d, ["cursor", "stop"], {
|
|
125
|
+
conversation_id: "c1",
|
|
126
|
+
workspace_roots: [cwd],
|
|
127
|
+
loop_count: 1,
|
|
128
|
+
status: "completed",
|
|
129
|
+
});
|
|
130
|
+
assert.equal(quiet.code, 0);
|
|
131
|
+
assert.equal(quiet.stdout, "", "already-continued stops are silent");
|
|
132
|
+
// workspace_roots elsewhere -> silent
|
|
133
|
+
const none = await runHook(d, ["cursor", "stop"], {
|
|
134
|
+
conversation_id: "c1",
|
|
135
|
+
workspace_roots: ["/definitely/not/a/worktree"],
|
|
136
|
+
loop_count: 0,
|
|
137
|
+
status: "completed",
|
|
138
|
+
});
|
|
139
|
+
assert.equal(none.stdout, "");
|
|
140
|
+
// empty stdin (missing/blank payload) must not break the hook
|
|
141
|
+
const blank = await runHook(d, ["cursor", "stop"]);
|
|
142
|
+
assert.equal(blank.code, 0);
|
|
143
|
+
assert.equal(blank.stdout, "");
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await d.stop();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
99
149
|
test("hooks: claude-code await-offer wakes (exit 2 + stderr, no token leak) on offer", async () => {
|
|
100
150
|
const d = await startDaemon();
|
|
101
151
|
try {
|
|
102
|
-
const cwd = mkdtempSync(join(
|
|
152
|
+
const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
|
|
103
153
|
const c = d.client();
|
|
104
154
|
await c.connect();
|
|
105
155
|
// holder + waiter, offer NOT yet delivered
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|