@aron98/load-lock 1.4.0 → 1.6.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.
Files changed (80) hide show
  1. package/README.md +61 -14
  2. package/dist/adapters/opencode/plugin.js +15 -1
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +3 -10
  5. package/dist/cli/copies.js +32 -44
  6. package/dist/cli/hook.js +48 -4
  7. package/dist/cli/install.d.ts +2 -0
  8. package/dist/cli/install.js +68 -14
  9. package/dist/cli/main.js +108 -211
  10. package/dist/cli/service.d.ts +56 -0
  11. package/dist/cli/service.js +261 -0
  12. package/dist/cli/setup.d.ts +131 -0
  13. package/dist/cli/setup.js +942 -0
  14. package/dist/cli/update.d.ts +57 -0
  15. package/dist/cli/update.js +287 -0
  16. package/dist/core/daemon.d.ts +29 -3
  17. package/dist/core/daemon.js +111 -11
  18. package/dist/core/persist.d.ts +3 -0
  19. package/dist/core/persist.js +49 -10
  20. package/dist/core/state.d.ts +9 -0
  21. package/dist/core/state.js +41 -3
  22. package/dist/dashboard/feed.d.ts +23 -0
  23. package/dist/dashboard/feed.js +64 -2
  24. package/dist/dashboard/frame.js +2 -0
  25. package/dist/dashboard/view.d.ts +11 -0
  26. package/dist/dashboard/view.js +13 -0
  27. package/dist/mcp/server.d.ts +11 -3
  28. package/dist/mcp/server.js +38 -6
  29. package/dist/policy/text.d.ts +13 -3
  30. package/dist/policy/text.js +14 -3
  31. package/dist/shared/agent-guide.d.ts +3 -0
  32. package/dist/shared/agent-guide.js +16 -0
  33. package/dist/shared/client.js +16 -4
  34. package/dist/shared/drift.d.ts +23 -0
  35. package/dist/shared/drift.js +95 -0
  36. package/dist/shared/identity.d.ts +51 -0
  37. package/dist/shared/identity.js +116 -0
  38. package/dist/shared/installs.d.ts +39 -0
  39. package/dist/shared/installs.js +101 -0
  40. package/dist/shared/pkginfo.d.ts +25 -0
  41. package/dist/shared/pkginfo.js +70 -0
  42. package/dist/shared/protocol.d.ts +22 -0
  43. package/dist/shared/protocol.js +15 -0
  44. package/dist/shared/runtimes.d.ts +41 -0
  45. package/dist/shared/runtimes.js +81 -0
  46. package/dist/shared/version.d.ts +6 -0
  47. package/dist/shared/version.js +9 -1
  48. package/dist/test/ace-mcp.test.d.ts +1 -0
  49. package/dist/test/ace-mcp.test.js +157 -0
  50. package/dist/test/ace-opencode.test.d.ts +1 -0
  51. package/dist/test/ace-opencode.test.js +287 -0
  52. package/dist/test/check-pins.test.d.ts +1 -0
  53. package/dist/test/check-pins.test.js +108 -0
  54. package/dist/test/compat.test.d.ts +1 -0
  55. package/dist/test/compat.test.js +190 -0
  56. package/dist/test/hook.test.js +179 -1
  57. package/dist/test/identity.test.d.ts +1 -0
  58. package/dist/test/identity.test.js +155 -0
  59. package/dist/test/install.test.js +135 -1
  60. package/dist/test/launcher.test.d.ts +1 -0
  61. package/dist/test/launcher.test.js +222 -0
  62. package/dist/test/mcp.test.js +76 -7
  63. package/dist/test/plugin.test.d.ts +1 -0
  64. package/dist/test/plugin.test.js +144 -0
  65. package/dist/test/reconnect.test.d.ts +1 -0
  66. package/dist/test/reconnect.test.js +389 -0
  67. package/dist/test/runtimes.test.d.ts +1 -0
  68. package/dist/test/runtimes.test.js +282 -0
  69. package/dist/test/setup.test.d.ts +1 -0
  70. package/dist/test/setup.test.js +354 -0
  71. package/dist/test/update-command.test.d.ts +1 -0
  72. package/dist/test/update-command.test.js +679 -0
  73. package/dist/test/update.test.js +33 -1
  74. package/docs/ADAPTERS.md +3 -2
  75. package/docs/AGENT_GUIDE.md +48 -2
  76. package/docs/ARCHITECTURE.md +122 -7
  77. package/docs/COMPATIBILITY.md +156 -13
  78. package/docs/OPERATIONS.md +407 -21
  79. package/package.json +3 -2
  80. package/skills/load-lock/SKILL.md +62 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Reconnect + update-staleness tests (issue #39): "TUI plugin fails to
3
+ * reconnect after daemon/plugin update without an opencode restart".
4
+ *
5
+ * Diagnosis outcome (see the PR description): the reported incident is the
6
+ * harness loading plugin code ONCE at startup (opencode never hot-reloads
7
+ * plugins) — an update swaps the on-disk copy while the session keeps
8
+ * executing the old code in memory. The feed's reconnect machinery itself
9
+ * works. These tests pin down the surrounding behaviors that make that
10
+ * diagnosis visible and close the real gaps found along the way:
11
+ *
12
+ * - H3 (genuine bug, FIXED): a failed INITIAL connect wedged the feed in
13
+ * "connecting" forever — no socket was attached, so no close event could
14
+ * ever trigger the reconnect loop. Regression test: start a feed with
15
+ * spawnDaemon:false, fail, then bring a real daemon up late.
16
+ * - H1 repro: SIGKILL the daemon under a live feed; the client auto-spawns
17
+ * a replacement (same on-disk code) and the feed goes live again with a
18
+ * "daemon restarted" note — proving reconnect works when the plugin code
19
+ * in memory matches what is on disk.
20
+ * - "restarted onto vX" note: when the v1.3.0 handshake reports a different
21
+ * daemon version across a restart, the note says which version it
22
+ * restarted onto (mock FeedClient so we can force exact versions).
23
+ * - install drift: running copy older than the PATH CLI install is
24
+ * detected (fixture PATH) and surfaced as a surface-appropriate hint
25
+ * ("restart opencode to pick up vX" for the plugin).
26
+ * - H2 hardening: a vanished/unreadable daemon entry must fail the spawn
27
+ * probe ("did not come up") instead of crashing the host process with an
28
+ * uncaught ENOENT (subprocess test).
29
+ */
30
+ import test from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { spawn } from "node:child_process";
33
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
34
+ import { tmpdir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { DashboardFeed } from "../dashboard/feed.js";
37
+ import { LLClient } from "../shared/client.js";
38
+ import { buildDashboardView } from "../dashboard/view.js";
39
+ import { renderFrame } from "../dashboard/frame.js";
40
+ import { detectInstallDrift, pathInstall, resolveDaemonEntry, } from "../shared/installs.js";
41
+ import { packageVersion } from "../shared/version.js";
42
+ import { daemonMainPath, delay, startDaemon, until } from "./testutil.js";
43
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
44
+ /** Kill whatever daemon currently owns the state dir (respawned daemons are not our child). */
45
+ function killDaemonFromJson(dir) {
46
+ try {
47
+ const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
48
+ if (typeof info.pid === "number") {
49
+ try {
50
+ process.kill(info.pid, "SIGKILL");
51
+ }
52
+ catch {
53
+ /* already gone */
54
+ }
55
+ }
56
+ }
57
+ catch {
58
+ /* no daemon.json — nothing to kill */
59
+ }
60
+ }
61
+ // ---------------------------------------------------------------- H3 regression
62
+ test("feed: failed initial connect recovers once the daemon appears (issue #39 H3)", async () => {
63
+ const dir = mkdtempSync(join(TMP_BASE, "ll-h3-"));
64
+ const sock = join(dir, "daemon.sock");
65
+ // spawnDaemon:false so the FIRST connect fails hard (no daemon, no spawn).
66
+ const client = new LLClient({ socket: sock, stateDir: dir, spawnDaemon: false });
67
+ const feed = new DashboardFeed(client, {
68
+ reconnectBaseMs: 40,
69
+ refreshDebounceMs: 5,
70
+ slowRefreshMs: 60_000,
71
+ inspectEveryMs: 60_000,
72
+ driftCheckMs: 0,
73
+ });
74
+ await assert.rejects(feed.start(), /unavailable|not running/);
75
+ // The reconnect loop re-enters "connecting" on each attempt; wait for the
76
+ // failed attempt to land in "reconnecting" (old code: wedged in
77
+ // "connecting" forever, so this alone catches the regression).
78
+ await until("failed start enters the reconnect loop", () => feed.currentState() === "reconnecting");
79
+ // Daemon shows up LATE. Before the fix, the feed stayed wedged in
80
+ // "connecting" forever (no socket ever attached → no close event →
81
+ // handleDisconnect never fired → no reconnect loop).
82
+ const child = spawn(process.execPath, [daemonMainPath, "--state-dir", dir, "--socket", sock], {
83
+ stdio: "ignore",
84
+ });
85
+ try {
86
+ await until("feed goes live after late daemon spawn", () => feed.currentState() === "live");
87
+ const v = feed.view();
88
+ assert.ok(v.daemon, "daemon card present once live");
89
+ assert.ok(v.daemon.pid > 0);
90
+ }
91
+ finally {
92
+ await feed.stop();
93
+ child.kill("SIGKILL");
94
+ await delay(200);
95
+ rmSync(dir, { recursive: true, force: true });
96
+ }
97
+ });
98
+ // ---------------------------------------------------------------- H1 repro
99
+ test("feed: SIGKILL daemon under live feed → auto-respawn and live again (issue #39 H1 repro)", async () => {
100
+ const d = await startDaemon();
101
+ let feed = null;
102
+ try {
103
+ // spawnDaemon defaults to true: the client auto-spawns a replacement.
104
+ const client = new LLClient({ socket: d.sock, stateDir: d.dir });
105
+ feed = new DashboardFeed(client, {
106
+ reconnectBaseMs: 40,
107
+ refreshDebounceMs: 5,
108
+ slowRefreshMs: 60_000,
109
+ inspectEveryMs: 60_000,
110
+ driftCheckMs: 0,
111
+ eventHistory: 100,
112
+ });
113
+ await feed.start();
114
+ assert.equal(feed.currentState(), "live");
115
+ const oldPid = feed.view().daemon?.pid;
116
+ assert.ok(oldPid, "daemon pid visible before kill");
117
+ d.child.kill("SIGKILL");
118
+ await until("feed notices the daemon died", () => feed.currentState() !== "live");
119
+ // (State briefly reads "reconnecting" then "connecting" while the client
120
+ // auto-spawns a replacement; the outcome assertion below is what counts.)
121
+ // Reconnect machinery (existing code) + client auto-spawn bring it back.
122
+ await until("feed is live again on the respawned daemon", () => feed.currentState() === "live");
123
+ const v = feed.view();
124
+ assert.ok(v.daemon, "daemon card present after respawn");
125
+ assert.notEqual(v.daemon.pid, oldPid, "a NEW daemon process answered");
126
+ assert.match(v.daemonRestartNote ?? "", /daemon restarted/);
127
+ assert.equal(v.daemon.daemonVersion, packageVersion(), "respawned daemon runs the current code");
128
+ }
129
+ finally {
130
+ await feed?.stop();
131
+ // The respawned daemon is nobody's child — kill via its state dir.
132
+ killDaemonFromJson(d.dir);
133
+ await d.stop();
134
+ }
135
+ });
136
+ // ------------------------------------------------- restart-note version change
137
+ /** Switchable mock daemon for resync-level tests (no real processes). */
138
+ class MockClient {
139
+ daemon;
140
+ events;
141
+ latestEventId;
142
+ watcher = null;
143
+ constructor(version) {
144
+ this.daemon = { pid: 111, startedAt: 1_000, protocolVersion: 1, daemonVersion: version };
145
+ this.events = [{ id: 1, ts: 1_000, type: "daemon_started" }];
146
+ this.latestEventId = 1;
147
+ }
148
+ async connect() { }
149
+ async request(method, params = {}) {
150
+ if (method === "status") {
151
+ return {
152
+ daemon: this.daemon,
153
+ config: {
154
+ version: 1,
155
+ pools: {},
156
+ offerTimeoutMs: 30_000,
157
+ maxWaitMs: 600_000,
158
+ maxTurnWaitMs: 600_000,
159
+ startTimeoutMs: 600_000,
160
+ untrackedLeaseMs: 600_000,
161
+ },
162
+ pools: [],
163
+ events: { latestEventId: this.latestEventId },
164
+ };
165
+ }
166
+ if (method === "events") {
167
+ const since = params.sinceEventId ?? 0;
168
+ return {
169
+ events: this.events.filter((e) => e.id > since),
170
+ latestEventId: this.latestEventId,
171
+ };
172
+ }
173
+ if (method === "watch")
174
+ return {};
175
+ throw new Error(`mock: unsupported method ${method}`);
176
+ }
177
+ async watch(_agentId, onEvent) {
178
+ this.watcher = onEvent;
179
+ }
180
+ onDisconnect() {
181
+ return () => { };
182
+ }
183
+ close() { }
184
+ /** Simulate a daemon restart, optionally onto a different version. */
185
+ restart(newVersion) {
186
+ this.daemon = { pid: 222, startedAt: 2_000, protocolVersion: 1, daemonVersion: newVersion };
187
+ this.events = [{ id: 1, ts: 2_000, type: "daemon_recovered" }];
188
+ this.latestEventId = 1;
189
+ }
190
+ }
191
+ function mockFeed(c) {
192
+ return new DashboardFeed(c, {
193
+ slowRefreshMs: 0,
194
+ driftCheckMs: 0,
195
+ refreshDebounceMs: 60_000,
196
+ inspectEveryMs: 60_000,
197
+ });
198
+ }
199
+ test("resync: daemon restart onto a DIFFERENT version says 'restarted onto vX (was vY)'", async () => {
200
+ const c = new MockClient("1.4.0");
201
+ const feed = mockFeed(c);
202
+ await feed.start();
203
+ assert.equal(feed.currentState(), "live");
204
+ assert.equal(feed.view().daemonRestartNote, undefined, "no note before any restart");
205
+ c.restart("1.5.0");
206
+ await feed.resync();
207
+ const note = feed.view().daemonRestartNote ?? "";
208
+ assert.match(note, /daemon restarted onto v1\.5\.0/);
209
+ assert.match(note, /was v1\.4\.0/);
210
+ await feed.stop();
211
+ });
212
+ test("resync: same-version daemon restart keeps the plain pid-only note", async () => {
213
+ const c = new MockClient("1.4.0");
214
+ const feed = mockFeed(c);
215
+ await feed.start();
216
+ c.restart("1.4.0");
217
+ await feed.resync();
218
+ const note = feed.view().daemonRestartNote ?? "";
219
+ assert.match(note, /daemon restarted/);
220
+ assert.doesNotMatch(note, /onto/);
221
+ await feed.stop();
222
+ });
223
+ test("resync: pre-handshake daemon (no version) never gets an 'onto' note", async () => {
224
+ const c = new MockClient("1.4.0");
225
+ const feed = mockFeed(c);
226
+ await feed.start();
227
+ c.restart(undefined); // old daemon: daemonVersion absent
228
+ await feed.resync();
229
+ const note = feed.view().daemonRestartNote ?? "";
230
+ assert.match(note, /daemon restarted/);
231
+ assert.doesNotMatch(note, /onto/);
232
+ await feed.stop();
233
+ });
234
+ // ---------------------------------------------------------------- install drift
235
+ /** Fixture PATH dir containing a fake `load-lock` install of `version`. */
236
+ function pathFixture(version) {
237
+ const dir = mkdtempSync(join(TMP_BASE, "ll-pathfix-"));
238
+ if (version === null)
239
+ return dir; // empty dir: no bin, no package
240
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version }));
241
+ const bin = join(dir, "load-lock");
242
+ writeFileSync(bin, "#!/bin/sh\nexit 0\n");
243
+ chmodSync(bin, 0o755);
244
+ return dir;
245
+ }
246
+ test("detectInstallDrift: PATH CLI newer than this copy ⇒ stale", () => {
247
+ const dir = pathFixture("99.0.0");
248
+ try {
249
+ const drift = detectInstallDrift(process.platform, { PATH: dir });
250
+ assert.equal(drift.pathVersion, "99.0.0");
251
+ assert.ok(drift.stale, "99.0.0 > " + drift.ownVersion);
252
+ }
253
+ finally {
254
+ rmSync(dir, { recursive: true, force: true });
255
+ }
256
+ });
257
+ test("detectInstallDrift: same version ⇒ not stale; no PATH install ⇒ null", () => {
258
+ const same = pathFixture(packageVersion());
259
+ const empty = pathFixture(null);
260
+ try {
261
+ const drift = detectInstallDrift(process.platform, { PATH: same });
262
+ assert.equal(drift.pathVersion, packageVersion());
263
+ assert.ok(!drift.stale);
264
+ const none = detectInstallDrift(process.platform, { PATH: empty });
265
+ assert.equal(none.pathVersion, null);
266
+ assert.ok(!none.stale);
267
+ }
268
+ finally {
269
+ rmSync(same, { recursive: true, force: true });
270
+ rmSync(empty, { recursive: true, force: true });
271
+ }
272
+ });
273
+ test("pathInstall: resolves through the fixture PATH dir", () => {
274
+ const dir = pathFixture("99.0.0");
275
+ try {
276
+ const inst = pathInstall(process.platform, { PATH: dir });
277
+ assert.ok(inst, "fixture install found");
278
+ assert.equal(inst.path, dir);
279
+ assert.equal(inst.version, "99.0.0");
280
+ }
281
+ finally {
282
+ rmSync(dir, { recursive: true, force: true });
283
+ }
284
+ });
285
+ function viewInputs(drift, surface) {
286
+ return {
287
+ status: null,
288
+ inspect: null,
289
+ events: [],
290
+ latestEventId: 0,
291
+ state: "live",
292
+ lastSyncAt: Date.now(),
293
+ installDrift: drift,
294
+ driftSurface: surface,
295
+ now: 1_760_000_000_000,
296
+ };
297
+ }
298
+ const STALE_DRIFT = { ownVersion: "1.4.0", pathVersion: "99.0.0", stale: true };
299
+ test("view: drift note for the opencode surface says 'restart opencode to pick up vX'", () => {
300
+ const v = buildDashboardView(viewInputs(STALE_DRIFT, "opencode"));
301
+ assert.match(v.installDriftNote ?? "", /plugin updated — restart opencode to pick up v99\.0\.0/);
302
+ assert.match(v.installDriftNote ?? "", /this session still runs v1\.4\.0/);
303
+ });
304
+ test("view: default (cli) drift note calls out the stale install", () => {
305
+ const v = buildDashboardView(viewInputs(STALE_DRIFT));
306
+ assert.match(v.installDriftNote ?? "", /PATH CLI v99\.0\.0 is newer than this copy v1\.4\.0/);
307
+ assert.match(v.installDriftNote ?? "", /stale install/);
308
+ });
309
+ test("view: no drift note when not stale or unknown", () => {
310
+ assert.equal(buildDashboardView(viewInputs({ ownVersion: "1.4.0", pathVersion: "1.4.0", stale: false })).installDriftNote, undefined);
311
+ assert.equal(buildDashboardView(viewInputs({ ownVersion: "1.4.0", pathVersion: null, stale: false })).installDriftNote, undefined);
312
+ assert.equal(buildDashboardView(viewInputs(null)).installDriftNote, undefined);
313
+ });
314
+ test("frame: drift note renders as a `note:` line", () => {
315
+ const v = buildDashboardView(viewInputs(STALE_DRIFT, "opencode"));
316
+ const lines = renderFrame(v, 160).join("\n");
317
+ assert.match(lines, /note: plugin updated — restart opencode to pick up v99\.0\.0/);
318
+ });
319
+ // ------------------------------------------------------------ resolveDaemonEntry
320
+ test("resolveDaemonEntry: own entry wins when it exists", () => {
321
+ assert.equal(resolveDaemonEntry(daemonMainPath, process.platform, { PATH: "" }), daemonMainPath);
322
+ });
323
+ test("resolveDaemonEntry: vanished own entry falls back to the PATH install", () => {
324
+ const dir = pathFixture("99.0.0");
325
+ try {
326
+ const nested = join(dir, "dist", "core");
327
+ mkdirSync(nested, { recursive: true });
328
+ const entry = join(nested, "daemon-main.js");
329
+ writeFileSync(entry, "// fixture daemon entry\n");
330
+ const own = join(dir, "does-not-exist", "daemon-main.js");
331
+ assert.ok(!existsSync(own));
332
+ assert.equal(resolveDaemonEntry(own, process.platform, { PATH: dir }), entry);
333
+ }
334
+ finally {
335
+ rmSync(dir, { recursive: true, force: true });
336
+ }
337
+ });
338
+ test("resolveDaemonEntry: vanished own entry with no usable PATH install → own unchanged", () => {
339
+ const empty = pathFixture(null);
340
+ try {
341
+ const own = join(empty, "gone", "daemon-main.js");
342
+ assert.equal(resolveDaemonEntry(own, process.platform, { PATH: empty }), own);
343
+ }
344
+ finally {
345
+ rmSync(empty, { recursive: true, force: true });
346
+ }
347
+ });
348
+ // --------------------------------------------------------------------- H2
349
+ test("ensureDaemon: unreadable daemon entry fails the probe, does not crash the host (issue #39 H2)", async () => {
350
+ const dir = mkdtempSync(join(TMP_BASE, "ll-h2-"));
351
+ const sock = join(dir, "daemon.sock");
352
+ // The child imports the COMPILED client and calls ensureDaemon with a
353
+ // daemon entry that cannot spawn (ENOENT). Without the spawn error
354
+ // listener, the 'error' event on the detached child is an uncaught
355
+ // exception and the process dies before it can report anything.
356
+ const script = join(dir, "child.mjs");
357
+ writeFileSync(script, `import { ensureDaemon } from ${JSON.stringify(new URL("../shared/client.js", import.meta.url).href)};\ntry {\n await ensureDaemon(${JSON.stringify(dir)}, ${JSON.stringify(sock)});\n console.log("unexpected: spawned");\n} catch (e) {\n console.log("code=" + (e && e.code) + " msg=" + (e && e.message));\n}\n`);
358
+ const child = spawn(process.execPath, ["--no-warnings", script], {
359
+ env: {
360
+ ...process.env,
361
+ LOAD_LOCK_DAEMON_BIN: "/nonexistent/load-lock-daemon-main.js",
362
+ LOAD_LOCK_STATE_DIR: dir,
363
+ LOAD_LOCK_SOCKET: sock,
364
+ },
365
+ stdio: ["ignore", "pipe", "pipe"],
366
+ });
367
+ let out = "";
368
+ child.stdout.on("data", (b) => (out += b));
369
+ let err = "";
370
+ child.stderr.on("data", (b) => (err += b));
371
+ const code = await new Promise((resolve) => {
372
+ const timer = setTimeout(() => {
373
+ child.kill("SIGKILL");
374
+ resolve(-1);
375
+ }, 30_000);
376
+ child.once("exit", (c) => {
377
+ clearTimeout(timer);
378
+ resolve(c);
379
+ });
380
+ });
381
+ try {
382
+ assert.equal(code, 0, `child must exit cleanly (stderr: ${err})`);
383
+ assert.match(out, /unavailable/);
384
+ assert.match(out, /did not come up/);
385
+ }
386
+ finally {
387
+ rmSync(dir, { recursive: true, force: true });
388
+ }
389
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Runtime-copy bookkeeping (issue #52):
3
+ * - daemon.json records the daemon's package root + version
4
+ * - the client envelope carries clientRoot; the daemon keeps a known-copies
5
+ * list (runtimes.json) deduped by real path, ignoring bogus hints and
6
+ * dropping copies whose path is gone
7
+ * - `doctor` lists the copies
8
+ * - self-restart onto the newer client's copy (issue #53), with bogus or
9
+ * mismatched hints falling back to the same-argv respawn, no loops
10
+ *
11
+ * Real daemons over real sockets in temp state dirs (never the user's).
12
+ */
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { spawn } from "node:child_process";
16
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, realpathSync, symlinkSync, cpSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { connect, createServer } from "node:net";
20
+ import { LLClient } from "../shared/client.js";
21
+ import { packageVersion, packageRoot } from "../shared/version.js";
22
+ import { loadRuntimes } from "../shared/runtimes.js";
23
+ import { restartEntry } from "../core/daemon.js";
24
+ import { startDaemon, cliMainPath, probe, delay, until } from "./testutil.js";
25
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
26
+ mkdirSync(TMP_BASE, { recursive: true });
27
+ /** Send one raw envelope line; resolve the first response (or null). */
28
+ function rawRequest(sockPath, envelope, timeoutMs = 3000) {
29
+ return new Promise((resolve) => {
30
+ let buf = "";
31
+ const s = connect(sockPath, () => s.write(JSON.stringify(envelope) + "\n"));
32
+ const done = (v) => {
33
+ s.destroy();
34
+ resolve(v);
35
+ };
36
+ s.on("data", (chunk) => {
37
+ buf += chunk.toString("utf8");
38
+ const idx = buf.indexOf("\n");
39
+ if (idx !== -1)
40
+ done(JSON.parse(buf.slice(0, idx)));
41
+ });
42
+ s.on("error", () => done(null));
43
+ setTimeout(() => done(null), timeoutMs).unref?.();
44
+ });
45
+ }
46
+ /** Plant a fake package root with the given package.json name/version. */
47
+ function plantCopy(base, name, pkgName, version) {
48
+ const dir = join(base, name);
49
+ mkdirSync(dir, { recursive: true });
50
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: pkgName, version }));
51
+ return dir;
52
+ }
53
+ test("daemon.json records the daemon's package root and version", async () => {
54
+ const d = await startDaemon();
55
+ try {
56
+ const info = JSON.parse(readFileSync(join(d.dir, "daemon.json"), "utf8"));
57
+ assert.equal(info.pid, d.child.pid);
58
+ assert.equal(info.version, packageVersion());
59
+ assert.equal(info.packageRoot, realpathSync(packageRoot()));
60
+ // The daemon's own copy is the first known runtime.
61
+ assert.ok(loadRuntimes(d.dir).some((c) => c.path === info.packageRoot && c.version === packageVersion()));
62
+ }
63
+ finally {
64
+ await d.stop();
65
+ }
66
+ });
67
+ test("client envelope carries clientRoot", async () => {
68
+ const capDir = mkdtempSync(join(TMP_BASE, "ll-capture-"));
69
+ const capSock = join(capDir, "cap.sock");
70
+ let captured = "";
71
+ const server = createServer((s) => {
72
+ s.on("data", (chunk) => {
73
+ captured += chunk.toString("utf8");
74
+ const idx = captured.indexOf("\n");
75
+ if (idx !== -1)
76
+ s.write(JSON.stringify({ id: JSON.parse(captured.slice(0, idx)).id, ok: true, result: {} }) + "\n");
77
+ });
78
+ });
79
+ await new Promise((r) => server.listen(capSock, r));
80
+ try {
81
+ const c = new LLClient({ socket: capSock, stateDir: capDir, spawnDaemon: false });
82
+ await c.connect();
83
+ await c.request("hello", {});
84
+ c.close();
85
+ const envelope = JSON.parse(captured.trim());
86
+ assert.equal(envelope.clientRoot, packageRoot());
87
+ }
88
+ finally {
89
+ await new Promise((r) => server.close(() => r()));
90
+ rmSync(capDir, { recursive: true, force: true });
91
+ }
92
+ });
93
+ test("known copies: recorded from handshakes, deduped by real path, bogus hints ignored, gone paths dropped", async () => {
94
+ const d = await startDaemon();
95
+ const base = mkdtempSync(join(TMP_BASE, "ll-copies-"));
96
+ try {
97
+ const a = plantCopy(base, "a", "@aron98/load-lock", "0.0.1");
98
+ const b = plantCopy(base, "b", "@aron98/load-lock", "0.0.2");
99
+ const foreign = plantCopy(base, "foreign", "some-other-pkg", "9.9.9");
100
+ symlinkSync(a, join(base, "a-link"));
101
+ // No clientVersion: these hints never trigger a self-restart.
102
+ for (const root of [a, join(base, "a-link"), foreign, join(base, "missing"), b]) {
103
+ const r = await rawRequest(d.sock, { id: 1, method: "hello", params: {}, clientRoot: root });
104
+ assert.ok(r?.ok, `hello with clientRoot ${root}`);
105
+ }
106
+ let copies = loadRuntimes(d.dir);
107
+ const paths = copies.map((c) => c.path);
108
+ assert.equal(paths.filter((p) => p === realpathSync(a)).length, 1, "symlinked hint deduped by real path");
109
+ assert.ok(copies.some((c) => c.path === realpathSync(b) && c.version === "0.0.2"), "version read from disk");
110
+ assert.ok(!paths.some((p) => p.includes("foreign") || p.includes("missing")), `bogus hints ignored: ${paths}`);
111
+ // Copy a disappears (e.g. npx cache cleaned); the next new sighting
112
+ // prunes it, and a reader never reports it either.
113
+ rmSync(a, { recursive: true, force: true });
114
+ const c = plantCopy(base, "c", "@aron98/load-lock", "0.0.3");
115
+ await rawRequest(d.sock, { id: 1, method: "hello", params: {}, clientRoot: c });
116
+ const raw = JSON.parse(readFileSync(join(d.dir, "runtimes.json"), "utf8"));
117
+ assert.ok(!raw.copies.some((x) => x.path === join(realpathSync(base), "a")), "gone copy pruned on disk");
118
+ copies = loadRuntimes(d.dir);
119
+ assert.ok(copies.some((x) => x.version === "0.0.3"));
120
+ }
121
+ finally {
122
+ await d.stop();
123
+ rmSync(base, { recursive: true, force: true });
124
+ }
125
+ });
126
+ test("doctor lists the daemon's runtime and known copies", async () => {
127
+ const d = await startDaemon();
128
+ const base = mkdtempSync(join(TMP_BASE, "ll-copies-"));
129
+ try {
130
+ const a = plantCopy(base, "a", "@aron98/load-lock", "0.0.1");
131
+ await rawRequest(d.sock, { id: 1, method: "hello", params: {}, clientRoot: a });
132
+ const r = await new Promise((resolve) => {
133
+ const child = spawn(process.execPath, [cliMainPath, "doctor", "--json"], { env: d.env });
134
+ let stdout = "";
135
+ child.stdout.on("data", (b) => (stdout += b));
136
+ child.stdin.end();
137
+ child.once("exit", (code) => resolve({ code, stdout }));
138
+ });
139
+ assert.equal(r.code, 0);
140
+ const report = JSON.parse(r.stdout);
141
+ assert.equal(report.daemonRuntime.path, realpathSync(packageRoot()));
142
+ assert.equal(report.daemonRuntime.version, packageVersion());
143
+ const listed = report.knownRuntimes.map((c) => `${c.version}@${c.path}`);
144
+ assert.ok(listed.includes(`0.0.1@${realpathSync(a)}`), `planted copy listed: ${listed}`);
145
+ assert.ok(listed.includes(`${packageVersion()}@${realpathSync(packageRoot())}`), `own copy listed: ${listed}`);
146
+ }
147
+ finally {
148
+ await d.stop();
149
+ rmSync(base, { recursive: true, force: true });
150
+ }
151
+ });
152
+ // ------------------------------------------------------------- issue #53
153
+ /**
154
+ * Plant a runnable load-lock copy: the built dist/ (minus tests), the real
155
+ * package.json with a different version, node_modules symlinked to ours.
156
+ */
157
+ function plantRuntime(base, name, version) {
158
+ const root = realpathSync(packageRoot());
159
+ const dir = join(base, name);
160
+ mkdirSync(dir, { recursive: true });
161
+ cpSync(join(root, "dist"), join(dir, "dist"), { recursive: true, filter: (src) => !src.startsWith(join(root, "dist", "test")) });
162
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
163
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ ...pkg, version }));
164
+ symlinkSync(join(root, "node_modules"), join(dir, "node_modules"));
165
+ return realpathSync(dir);
166
+ }
167
+ /** Start a daemon from a planted copy; only raw (version-less) envelopes talk to it. */
168
+ async function startCopyDaemon(copy, dir) {
169
+ const sock = join(dir, "daemon.sock");
170
+ const child = spawn(process.execPath, [join(copy, "dist", "core", "daemon-main.js"), "--state-dir", dir, "--socket", sock], {
171
+ stdio: "ignore",
172
+ detached: true,
173
+ });
174
+ child.unref();
175
+ await until("planted daemon up", () => probe(sock), 10_000, 100);
176
+ return { sock, pid: child.pid };
177
+ }
178
+ function killFromJson(dir) {
179
+ try {
180
+ process.kill(JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8")).pid, "SIGKILL");
181
+ }
182
+ catch {
183
+ /* gone */
184
+ }
185
+ }
186
+ async function helloRaw(sock, extra = {}) {
187
+ const r = await rawRequest(sock, { id: 1, method: "hello", params: {}, ...extra }, 2000);
188
+ return r?.ok ? r.result : null;
189
+ }
190
+ async function waitNewPid(sock, oldPid) {
191
+ let hello = null;
192
+ await until("daemon respawned", async () => {
193
+ if (!(await probe(sock)))
194
+ return false;
195
+ hello = await helloRaw(sock);
196
+ return hello !== null && hello.pid !== oldPid;
197
+ });
198
+ return hello;
199
+ }
200
+ test("restartEntry: only a matching load-lock copy with a daemon entry qualifies", () => {
201
+ const base = mkdtempSync(join(TMP_BASE, "ll-entry-"));
202
+ try {
203
+ const good = plantCopy(base, "good", "@aron98/load-lock", "0.2.0");
204
+ mkdirSync(join(good, "dist", "core"), { recursive: true });
205
+ writeFileSync(join(good, "dist", "core", "daemon-main.js"), "");
206
+ const noEntry = plantCopy(base, "noentry", "@aron98/load-lock", "0.2.0");
207
+ const foreign = plantCopy(base, "foreign", "not-load-lock", "0.2.0");
208
+ mkdirSync(join(foreign, "dist", "core"), { recursive: true });
209
+ writeFileSync(join(foreign, "dist", "core", "daemon-main.js"), "");
210
+ assert.equal(restartEntry(good, "0.2.0"), join(realpathSync(good), "dist", "core", "daemon-main.js"));
211
+ assert.equal(restartEntry(good, "0.3.0"), null, "claimed version must match the copy on disk");
212
+ assert.equal(restartEntry(noEntry, "0.2.0"), null, "copy without a daemon entry");
213
+ assert.equal(restartEntry(foreign, "0.2.0"), null, "foreign package");
214
+ assert.equal(restartEntry(join(base, "missing"), "0.2.0"), null, "nonexistent path");
215
+ assert.equal(restartEntry(undefined, "0.2.0"), null, "no hint");
216
+ }
217
+ finally {
218
+ rmSync(base, { recursive: true, force: true });
219
+ }
220
+ });
221
+ test("self-restart moves the daemon onto the newer client's copy; state and journal survive", async () => {
222
+ const base = mkdtempSync(join(TMP_BASE, "ll-2c-"));
223
+ const dir = join(base, "s");
224
+ mkdirSync(dir);
225
+ const sleeper = spawn("sleep", ["60"], { stdio: "ignore" });
226
+ try {
227
+ const oldCopy = plantRuntime(base, "old", "0.1.0");
228
+ const newCopy = plantRuntime(base, "new", "0.2.0");
229
+ const { sock, pid } = await startCopyDaemon(oldCopy, dir);
230
+ assert.equal((await helloRaw(sock)).daemonVersion, "0.1.0");
231
+ // A tracked permit held on the old daemon.
232
+ const g = await rawRequest(sock, { id: 2, method: "acquire", params: { agentId: "A", opKey: "op1", reason: "two copies" } });
233
+ const permitToken = g.result.entry.permitToken;
234
+ await rawRequest(sock, { id: 3, method: "attach_pid", params: { permitToken, pid: sleeper.pid } });
235
+ // A newer client from a different copy shows up.
236
+ assert.ok(await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: newCopy }));
237
+ const h = await waitNewPid(sock, pid);
238
+ assert.equal(h.daemonVersion, "0.2.0", "daemon now runs the newer copy");
239
+ const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
240
+ assert.equal(info.packageRoot, newCopy);
241
+ assert.equal(info.version, "0.2.0");
242
+ const st = await rawRequest(sock, { id: 4, method: "status", params: {} });
243
+ assert.equal(st.result.pools[0].holders.length, 1, "held permit survives the move");
244
+ assert.equal(st.result.pools[0].holders[0].pid, sleeper.pid);
245
+ const rel = await rawRequest(sock, { id: 5, method: "release", params: { permitToken } });
246
+ assert.equal(rel.result.releasedNow, true, "same permit token still releases");
247
+ // Stable afterwards: the same newer client does not restart it again.
248
+ await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: newCopy });
249
+ await delay(600);
250
+ assert.equal((await helloRaw(sock)).pid, h.pid);
251
+ }
252
+ finally {
253
+ sleeper.kill("SIGKILL");
254
+ killFromJson(dir);
255
+ rmSync(base, { recursive: true, force: true });
256
+ }
257
+ });
258
+ test("bogus or mismatched copy hint: no respawn onto it, same-argv fallback, no restart loop", async () => {
259
+ const base = mkdtempSync(join(TMP_BASE, "ll-2c-"));
260
+ const dir = join(base, "s");
261
+ mkdirSync(dir);
262
+ try {
263
+ const oldCopy = plantRuntime(base, "old", "0.1.0");
264
+ // Claims 0.2.0 but the copy on disk is 0.3.0.
265
+ const mismatched = plantRuntime(base, "mismatch", "0.3.0");
266
+ const { sock, pid } = await startCopyDaemon(oldCopy, dir);
267
+ await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: mismatched });
268
+ const h = await waitNewPid(sock, pid);
269
+ assert.equal(h.daemonVersion, "0.1.0", "respawned on its own copy, not the mismatched hint");
270
+ assert.equal(JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8")).packageRoot, oldCopy);
271
+ // Further bogus hints for the same or an older version: no loop.
272
+ await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: mismatched });
273
+ await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: join(base, "nowhere") });
274
+ await helloRaw(sock, { clientVersion: "0.1.5", clientRoot: "/" });
275
+ await delay(800);
276
+ assert.equal((await helloRaw(sock)).pid, h.pid, "no restart loop");
277
+ }
278
+ finally {
279
+ killFromJson(dir);
280
+ rmSync(base, { recursive: true, force: true });
281
+ }
282
+ });
@@ -0,0 +1 @@
1
+ export {};