@aron98/load-lock 1.2.1 → 1.3.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/tui.js +5 -1
- package/dist/cli/copies.d.ts +28 -0
- package/dist/cli/copies.js +207 -0
- package/dist/cli/main.js +264 -16
- package/dist/core/daemon.d.ts +18 -0
- package/dist/core/daemon.js +84 -3
- package/dist/core/persist.js +5 -3
- package/dist/core/state.d.ts +7 -4
- package/dist/core/state.js +64 -10
- package/dist/dashboard/frame.js +5 -1
- package/dist/dashboard/view.d.ts +4 -0
- package/dist/dashboard/view.js +5 -0
- package/dist/shared/client.js +4 -1
- package/dist/shared/protocol.d.ts +40 -2
- package/dist/shared/protocol.js +5 -0
- package/dist/shared/version.d.ts +10 -0
- package/dist/shared/version.js +57 -0
- package/dist/test/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/docs/ARCHITECTURE.md +10 -6
- package/docs/COMPATIBILITY.md +32 -0
- package/docs/OPERATIONS.md +52 -5
- package/package.json +1 -1
package/dist/core/daemon.js
CHANGED
|
@@ -20,11 +20,21 @@ import { createServer } from "node:net";
|
|
|
20
20
|
import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
21
21
|
import { readdirSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
23
24
|
import { Machine } from "./state.js";
|
|
24
25
|
import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
|
|
25
26
|
import { inspectMachine } from "./sysinfo.js";
|
|
26
27
|
import { ProtocolError } from "../shared/protocol.js";
|
|
27
28
|
import { isWindows, probeTransport } from "../shared/transport.js";
|
|
29
|
+
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
30
|
+
/** Package version of the copy of load-lock this daemon process runs from. */
|
|
31
|
+
const DAEMON_VERSION = packageVersion();
|
|
32
|
+
/**
|
|
33
|
+
* Env marker set on the respawned daemon: the version this process already
|
|
34
|
+
* restarted for. Prevents restart loops when a newer client pings twice —
|
|
35
|
+
* only one respawn per version bump.
|
|
36
|
+
*/
|
|
37
|
+
const RESTARTED_FOR_ENV = "LOAD_LOCK_RESTARTED_FOR";
|
|
28
38
|
export class Daemon {
|
|
29
39
|
machine;
|
|
30
40
|
server = null;
|
|
@@ -39,6 +49,7 @@ export class Daemon {
|
|
|
39
49
|
socketPath;
|
|
40
50
|
sweepTimer = null;
|
|
41
51
|
expiryTimer = null;
|
|
52
|
+
restartTimer = null;
|
|
42
53
|
stopped = false;
|
|
43
54
|
constructor(opts = {}) {
|
|
44
55
|
this.stateDirPath = defaultStateDir(opts.stateDir);
|
|
@@ -108,6 +119,8 @@ export class Daemon {
|
|
|
108
119
|
clearInterval(this.sweepTimer);
|
|
109
120
|
if (this.expiryTimer)
|
|
110
121
|
clearTimeout(this.expiryTimer);
|
|
122
|
+
if (this.restartTimer)
|
|
123
|
+
clearTimeout(this.restartTimer);
|
|
111
124
|
for (const w of this.watchers)
|
|
112
125
|
w.socket.destroy();
|
|
113
126
|
this.watchers.clear();
|
|
@@ -171,6 +184,8 @@ export class Daemon {
|
|
|
171
184
|
this.send(sock, { id: null, ok: false, error: { code: "invalid", message: "malformed JSON line" } });
|
|
172
185
|
return;
|
|
173
186
|
}
|
|
187
|
+
// Version handshake: remember the client's version for skew detection.
|
|
188
|
+
const clientVersion = typeof msg.clientVersion === "string" ? msg.clientVersion : undefined;
|
|
174
189
|
let result;
|
|
175
190
|
let agentId;
|
|
176
191
|
try {
|
|
@@ -187,6 +202,9 @@ export class Daemon {
|
|
|
187
202
|
else {
|
|
188
203
|
this.send(sock, { id: msg.id, ok: false, error: { code: "internal", message: String(err?.message ?? err) } });
|
|
189
204
|
}
|
|
205
|
+
// Respond first, then consider a self-restart — even on errors, a
|
|
206
|
+
// newer client means this daemon is running stale on-disk code.
|
|
207
|
+
this.maybeScheduleSelfRestart(clientVersion);
|
|
190
208
|
return;
|
|
191
209
|
}
|
|
192
210
|
// Tool-boundary delivery: surface this agent's outstanding offers/queue
|
|
@@ -195,6 +213,63 @@ export class Daemon {
|
|
|
195
213
|
result.pending = this.machine.pending(agentId);
|
|
196
214
|
}
|
|
197
215
|
this.send(sock, { id: msg.id, ok: true, result });
|
|
216
|
+
this.maybeScheduleSelfRestart(clientVersion);
|
|
217
|
+
}
|
|
218
|
+
// -------------------------------------------------------- version handshake
|
|
219
|
+
/**
|
|
220
|
+
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
221
|
+
* package version than this daemon runs, schedule a respawn so the daemon
|
|
222
|
+
* picks up the updated on-disk code (e.g. after `npm i -g
|
|
223
|
+
* @aron98/load-lock` while the daemon kept running). Skew detection is
|
|
224
|
+
* entirely daemon-side and best-effort: pre-handshake clients that send
|
|
225
|
+
* no version never trigger a restart.
|
|
226
|
+
*/
|
|
227
|
+
maybeScheduleSelfRestart(clientVersion) {
|
|
228
|
+
if (this.stopped || this.restartTimer || !clientVersion)
|
|
229
|
+
return;
|
|
230
|
+
if (compareVersions(clientVersion, DAEMON_VERSION) <= 0)
|
|
231
|
+
return;
|
|
232
|
+
const restartedFor = process.env[RESTARTED_FOR_ENV];
|
|
233
|
+
if (restartedFor && compareVersions(clientVersion, restartedFor) <= 0)
|
|
234
|
+
return; // already tried
|
|
235
|
+
this.restartTimer = setTimeout(() => {
|
|
236
|
+
this.restartTimer = null;
|
|
237
|
+
this.selfRestart(clientVersion);
|
|
238
|
+
}, 250);
|
|
239
|
+
this.restartTimer.unref?.();
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
243
|
+
* journal FIRST, then stops (destroying all client connections — they
|
|
244
|
+
* reconnect to the fresh daemon), then spawns the replacement with the
|
|
245
|
+
* exact same argv so a daemon started via the old copy runs the new copy's
|
|
246
|
+
* code, and exits.
|
|
247
|
+
*/
|
|
248
|
+
selfRestart(clientVersion) {
|
|
249
|
+
if (this.stopped)
|
|
250
|
+
return;
|
|
251
|
+
// Best-effort persistence; a failed save must not block the restart.
|
|
252
|
+
try {
|
|
253
|
+
saveState(this.stateDirPath, this.machine.config, this.machine.history);
|
|
254
|
+
saveJournal(this.stateDirPath, this.machine.journalEntries());
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
/* journal is best-effort */
|
|
258
|
+
}
|
|
259
|
+
this.stop();
|
|
260
|
+
try {
|
|
261
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
262
|
+
detached: true,
|
|
263
|
+
stdio: "ignore",
|
|
264
|
+
cwd: process.cwd(),
|
|
265
|
+
env: { ...process.env, [RESTARTED_FOR_ENV]: clientVersion },
|
|
266
|
+
});
|
|
267
|
+
child.unref();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
/* if the respawn fails the next client contact re-triggers it */
|
|
271
|
+
}
|
|
272
|
+
process.exit(0);
|
|
198
273
|
}
|
|
199
274
|
// --------------------------------------------------------------- dispatch
|
|
200
275
|
/** Returns [result, agentIdForPendingAttachment]. */
|
|
@@ -202,7 +277,10 @@ export class Daemon {
|
|
|
202
277
|
const m = this.machine;
|
|
203
278
|
switch (method) {
|
|
204
279
|
case "hello":
|
|
205
|
-
return [
|
|
280
|
+
return [
|
|
281
|
+
{ protocolVersion: 1, pid: process.pid, startedAt: m.startedAt, configVersion: m.config.version, daemonVersion: DAEMON_VERSION },
|
|
282
|
+
undefined,
|
|
283
|
+
];
|
|
206
284
|
case "acquire": {
|
|
207
285
|
const params = p;
|
|
208
286
|
let res = m.acquire(params);
|
|
@@ -265,8 +343,11 @@ export class Daemon {
|
|
|
265
343
|
const res = m.pending(String(p.agentId));
|
|
266
344
|
return [res, String(p.agentId)];
|
|
267
345
|
}
|
|
268
|
-
case "status":
|
|
269
|
-
|
|
346
|
+
case "status": {
|
|
347
|
+
const res = m.status(p.pool ? String(p.pool) : undefined);
|
|
348
|
+
res.daemon.daemonVersion = DAEMON_VERSION;
|
|
349
|
+
return [res, undefined];
|
|
350
|
+
}
|
|
270
351
|
case "get_config":
|
|
271
352
|
return [m.get_config(), undefined];
|
|
272
353
|
case "configure": {
|
package/dist/core/persist.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock;
|
|
4
4
|
* Windows: %LOCALAPPDATA%\load-lock — see shared/transport.ts).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Configuration persists across daemon restarts, plus the journal
|
|
7
|
+
* (journal.json): held permits AND queued/offered requests. Held permits
|
|
8
|
+
* are restored conservatively (tracked jobs re-checked for liveness, same
|
|
9
|
+
* permit tokens so late releases stay idempotent); queued requests are
|
|
10
|
+
* re-queued FIFO with the same requestIds (see docs/ARCHITECTURE.md).
|
|
9
11
|
*/
|
|
10
12
|
import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from "node:fs";
|
|
11
13
|
import { dirname, join } from "node:path";
|
package/dist/core/state.d.ts
CHANGED
|
@@ -180,16 +180,18 @@ export declare class Machine {
|
|
|
180
180
|
get_config(): GetConfigResult;
|
|
181
181
|
configure(p: ConfigureParams): ConfigureResult;
|
|
182
182
|
history: ConfigHistoryEntry[];
|
|
183
|
-
/** Held permits to persist
|
|
183
|
+
/** Held permits (first) and queued/offered requests to persist across restarts. */
|
|
184
184
|
journalEntries(): JournalEntry[];
|
|
185
185
|
/**
|
|
186
|
-
* Restore
|
|
186
|
+
* Restore state after a daemon restart (conservative reconciliation):
|
|
187
|
+
* - queued (incl. previously offered): re-queued FIFO by original enqueue
|
|
188
|
+
* time, same requestIds so idempotent re-acquire still works; pools no
|
|
189
|
+
* longer in the config are dropped. Offered entries restart as queued
|
|
190
|
+
* and get a fresh offer (old offer tokens are invalid after restart).
|
|
187
191
|
* - tracked: re-check liveness via the supplied probe; dead => release,
|
|
188
192
|
* alive => keep (same token, so a late explicit release still works).
|
|
189
193
|
* - claimed: keep the start deadline (reclaimed by tick if already past).
|
|
190
194
|
* - untracked: keep, with a FRESH lease window from restore time.
|
|
191
|
-
* Queued/offered requests are deliberately NOT restored — queued agents
|
|
192
|
-
* re-acquire (documented restart behavior).
|
|
193
195
|
*/
|
|
194
196
|
restore(journal: JournalEntry[], liveness: (e: {
|
|
195
197
|
pid?: number;
|
|
@@ -198,6 +200,7 @@ export declare class Machine {
|
|
|
198
200
|
}) => boolean | null): {
|
|
199
201
|
kept: number;
|
|
200
202
|
releasedDead: number;
|
|
203
|
+
queued: number;
|
|
201
204
|
};
|
|
202
205
|
}
|
|
203
206
|
export {};
|
package/dist/core/state.js
CHANGED
|
@@ -830,7 +830,7 @@ export class Machine {
|
|
|
830
830
|
}
|
|
831
831
|
history = [];
|
|
832
832
|
// ----------------------------------------------------------------- journal
|
|
833
|
-
/** Held permits to persist
|
|
833
|
+
/** Held permits (first) and queued/offered requests to persist across restarts. */
|
|
834
834
|
journalEntries() {
|
|
835
835
|
const out = [];
|
|
836
836
|
for (const e of this.entries.values()) {
|
|
@@ -845,6 +845,7 @@ export class Machine {
|
|
|
845
845
|
opKey: e.opKey,
|
|
846
846
|
reason: e.reason,
|
|
847
847
|
weight: e.weight,
|
|
848
|
+
state: "held",
|
|
848
849
|
permitToken: e.permitToken,
|
|
849
850
|
phase: e.phase ?? "claimed",
|
|
850
851
|
pid: e.pid,
|
|
@@ -855,21 +856,45 @@ export class Machine {
|
|
|
855
856
|
leaseDeadline: e.leaseDeadline,
|
|
856
857
|
});
|
|
857
858
|
}
|
|
859
|
+
for (const e of this.entries.values()) {
|
|
860
|
+
if (e.state !== "queued" && e.state !== "offered")
|
|
861
|
+
continue;
|
|
862
|
+
out.push({
|
|
863
|
+
requestId: e.id,
|
|
864
|
+
pool: e.pool,
|
|
865
|
+
agentId: e.agentId,
|
|
866
|
+
agentLabel: e.agentLabel,
|
|
867
|
+
cwd: e.cwd,
|
|
868
|
+
opKey: e.opKey,
|
|
869
|
+
reason: e.reason,
|
|
870
|
+
weight: e.weight,
|
|
871
|
+
state: "queued",
|
|
872
|
+
enqueuedAt: e.enqueuedAt ?? e.since,
|
|
873
|
+
claimedAt: e.since,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
858
876
|
return out;
|
|
859
877
|
}
|
|
860
878
|
/**
|
|
861
|
-
* Restore
|
|
879
|
+
* Restore state after a daemon restart (conservative reconciliation):
|
|
880
|
+
* - queued (incl. previously offered): re-queued FIFO by original enqueue
|
|
881
|
+
* time, same requestIds so idempotent re-acquire still works; pools no
|
|
882
|
+
* longer in the config are dropped. Offered entries restart as queued
|
|
883
|
+
* and get a fresh offer (old offer tokens are invalid after restart).
|
|
862
884
|
* - tracked: re-check liveness via the supplied probe; dead => release,
|
|
863
885
|
* alive => keep (same token, so a late explicit release still works).
|
|
864
886
|
* - claimed: keep the start deadline (reclaimed by tick if already past).
|
|
865
887
|
* - untracked: keep, with a FRESH lease window from restore time.
|
|
866
|
-
* Queued/offered requests are deliberately NOT restored — queued agents
|
|
867
|
-
* re-acquire (documented restart behavior).
|
|
868
888
|
*/
|
|
869
889
|
restore(journal, liveness) {
|
|
870
890
|
let kept = 0;
|
|
871
891
|
let releasedDead = 0;
|
|
892
|
+
const queuedEntries = [];
|
|
872
893
|
for (const j of journal) {
|
|
894
|
+
if (j.state === "queued") {
|
|
895
|
+
queuedEntries.push({ j, at: j.enqueuedAt ?? j.claimedAt });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
873
898
|
const e = {
|
|
874
899
|
id: j.requestId,
|
|
875
900
|
pool: j.pool,
|
|
@@ -882,20 +907,20 @@ export class Machine {
|
|
|
882
907
|
state: "held",
|
|
883
908
|
since: j.claimedAt,
|
|
884
909
|
permitToken: j.permitToken,
|
|
885
|
-
phase: j.phase,
|
|
910
|
+
phase: j.phase ?? "claimed",
|
|
886
911
|
pid: j.pid,
|
|
887
912
|
pgid: j.pgid,
|
|
888
913
|
pidStartTime: j.pidStartTime,
|
|
889
914
|
startedAt: j.startedAt,
|
|
890
915
|
lastSeen: this.now(),
|
|
891
916
|
};
|
|
892
|
-
if (
|
|
917
|
+
if (e.phase === "claimed") {
|
|
893
918
|
e.startDeadline = j.claimedAt + this.config.startTimeoutMs;
|
|
894
919
|
}
|
|
895
|
-
else if (
|
|
920
|
+
else if (e.phase === "running_untracked") {
|
|
896
921
|
e.leaseDeadline = this.now() + this.config.untrackedLeaseMs; // fresh window
|
|
897
922
|
}
|
|
898
|
-
else if (
|
|
923
|
+
else if (e.phase === "running_tracked") {
|
|
899
924
|
const alive = liveness(e);
|
|
900
925
|
if (alive === false) {
|
|
901
926
|
// Confirmed dead while we were down: release, but keep a terminal
|
|
@@ -915,11 +940,40 @@ export class Machine {
|
|
|
915
940
|
this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
|
|
916
941
|
kept++;
|
|
917
942
|
}
|
|
943
|
+
// Re-queue queued requests, oldest first, preserving FIFO fairness.
|
|
944
|
+
queuedEntries.sort((a, b) => a.at - b.at);
|
|
945
|
+
let queued = 0;
|
|
946
|
+
for (const { j } of queuedEntries) {
|
|
947
|
+
if (!this.config.pools[j.pool])
|
|
948
|
+
continue; // pool removed while down
|
|
949
|
+
const e = {
|
|
950
|
+
id: j.requestId,
|
|
951
|
+
pool: j.pool,
|
|
952
|
+
agentId: j.agentId,
|
|
953
|
+
agentLabel: j.agentLabel,
|
|
954
|
+
cwd: j.cwd,
|
|
955
|
+
opKey: j.opKey,
|
|
956
|
+
reason: j.reason,
|
|
957
|
+
weight: j.weight,
|
|
958
|
+
state: "queued",
|
|
959
|
+
since: this.now(),
|
|
960
|
+
enqueuedAt: j.enqueuedAt ?? j.claimedAt,
|
|
961
|
+
};
|
|
962
|
+
this.entries.set(e.id, e);
|
|
963
|
+
this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
|
|
964
|
+
if (!this.queues.has(e.pool))
|
|
965
|
+
this.queues.set(e.pool, []);
|
|
966
|
+
this.queues.get(e.pool).push(e.id);
|
|
967
|
+
queued++;
|
|
968
|
+
}
|
|
969
|
+
// Admit newly-eligible waiters (capacity freed while we were down).
|
|
970
|
+
for (const name of this.poolNames())
|
|
971
|
+
this.admit(name);
|
|
918
972
|
this.pushEvent({
|
|
919
973
|
type: "daemon_recovered",
|
|
920
974
|
targetAgent: "*",
|
|
921
|
-
detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released)`,
|
|
975
|
+
detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)`,
|
|
922
976
|
});
|
|
923
|
-
return { kept, releasedDead };
|
|
977
|
+
return { kept, releasedDead, queued };
|
|
924
978
|
}
|
|
925
979
|
}
|
package/dist/dashboard/frame.js
CHANGED
|
@@ -59,13 +59,17 @@ export function renderFrame(view, width = 100, height = 40) {
|
|
|
59
59
|
const syncAge = view.lastSyncAt ? fmtDuration(Math.max(0, view.now - view.lastSyncAt)) : "never";
|
|
60
60
|
push(`load-lock dashboard — machine-wide heavy-work budget ${state} synced ${syncAge} ago`);
|
|
61
61
|
if (view.daemon) {
|
|
62
|
-
push(`daemon pid ${view.daemon.pid} · uptime ${fmtDuration(view.daemon.uptimeMs)} · config v${view.daemon.configVersion} · protocol v${view.daemon.protocolVersion}
|
|
62
|
+
push(`daemon pid ${view.daemon.pid} · uptime ${fmtDuration(view.daemon.uptimeMs)} · config v${view.daemon.configVersion} · protocol v${view.daemon.protocolVersion}` +
|
|
63
|
+
(view.daemon.daemonVersion ? ` · daemon v${view.daemon.daemonVersion}` : "") +
|
|
64
|
+
` · events through #${view.latestEventId}`);
|
|
63
65
|
}
|
|
64
66
|
else {
|
|
65
67
|
push(`daemon: unreachable — it auto-spawns on the next load-lock command`);
|
|
66
68
|
}
|
|
67
69
|
if (view.daemonRestartNote)
|
|
68
70
|
push(`note: ${view.daemonRestartNote}`);
|
|
71
|
+
if (view.versionSkewNote)
|
|
72
|
+
push(`note: ${view.versionSkewNote}`);
|
|
69
73
|
if (view.machine) {
|
|
70
74
|
const m = view.machine;
|
|
71
75
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup ${m.cgroupVersion ?? ""} quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
package/dist/dashboard/view.d.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface DaemonView {
|
|
|
105
105
|
uptimeMs: number;
|
|
106
106
|
protocolVersion: number;
|
|
107
107
|
configVersion: number;
|
|
108
|
+
/** Package version of the daemon's code (pre-handshake daemons omit it). */
|
|
109
|
+
daemonVersion?: string;
|
|
108
110
|
}
|
|
109
111
|
/** The complete render-ready snapshot of the machine-wide budget. */
|
|
110
112
|
export interface DashboardView {
|
|
@@ -112,6 +114,8 @@ export interface DashboardView {
|
|
|
112
114
|
state: FeedState;
|
|
113
115
|
/** False right after a daemon restart was detected (history was reset). */
|
|
114
116
|
daemonRestartNote?: string;
|
|
117
|
+
/** Set when the running daemon is older than the installed copy. */
|
|
118
|
+
versionSkewNote?: string;
|
|
115
119
|
daemon: DaemonView | null;
|
|
116
120
|
pools: PoolView[];
|
|
117
121
|
events: EventLine[];
|
package/dist/dashboard/view.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
1
2
|
// ---------------------------------------------------------------- formatters
|
|
2
3
|
/** Compact human duration: "412ms", "3s", "4m 12s", "2h 05m", "3d 4h". */
|
|
3
4
|
export function fmtDuration(ms) {
|
|
@@ -189,6 +190,9 @@ export function buildDashboardView(inps) {
|
|
|
189
190
|
now,
|
|
190
191
|
state: inps.state,
|
|
191
192
|
daemonRestartNote: inps.daemonRestartNote,
|
|
193
|
+
versionSkewNote: d && typeof d.daemonVersion === "string" && compareVersions(d.daemonVersion, packageVersion()) < 0
|
|
194
|
+
? `daemon v${d.daemonVersion} is older than installed v${packageVersion()} — it self-restarts on newer-client contact`
|
|
195
|
+
: undefined,
|
|
192
196
|
daemon: d
|
|
193
197
|
? {
|
|
194
198
|
pid: d.pid,
|
|
@@ -196,6 +200,7 @@ export function buildDashboardView(inps) {
|
|
|
196
200
|
uptimeMs: now - d.startedAt,
|
|
197
201
|
protocolVersion: d.protocolVersion,
|
|
198
202
|
configVersion: inps.status?.config.version ?? 0,
|
|
203
|
+
daemonVersion: d.daemonVersion,
|
|
199
204
|
}
|
|
200
205
|
: null,
|
|
201
206
|
pools,
|
package/dist/shared/client.js
CHANGED
|
@@ -29,6 +29,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
29
29
|
import { ProtocolError } from "./protocol.js";
|
|
30
30
|
import { socketPath as defaultSocket, stateDir as defaultStateDir } from "../core/persist.js";
|
|
31
31
|
import { probeTransport } from "./transport.js";
|
|
32
|
+
import { packageVersion } from "./version.js";
|
|
32
33
|
export class LLClient {
|
|
33
34
|
sock = null;
|
|
34
35
|
nextId = 1;
|
|
@@ -170,7 +171,9 @@ export class LLClient {
|
|
|
170
171
|
},
|
|
171
172
|
});
|
|
172
173
|
const sock = this.ensureSock();
|
|
173
|
-
|
|
174
|
+
// clientVersion: lets the daemon detect version skew and self-restart
|
|
175
|
+
// onto newer on-disk code (pre-handshake daemons ignore the field).
|
|
176
|
+
sock.write(JSON.stringify({ id, method, params, clientVersion: packageVersion() }) + "\n");
|
|
174
177
|
});
|
|
175
178
|
}
|
|
176
179
|
/** Subscribe to daemon events targeted at agentId (or "*" for all). */
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* receive unsolicited event lines.
|
|
7
7
|
*
|
|
8
8
|
* Request: {"id": <number>, "method": "<name>", "params": {...}}
|
|
9
|
+
* Newer clients also stamp the envelope with their package
|
|
10
|
+
* version: {..., "clientVersion": "1.2.1"}. Old daemons ignore
|
|
11
|
+
* the extra field (forward-compatible); newer daemons use it
|
|
12
|
+
* for the update handshake: a client strictly newer than the
|
|
13
|
+
* running daemon triggers a daemon self-restart (issue #34).
|
|
9
14
|
* Response: {"id": <number>, "ok": true, "result": {...}}
|
|
10
15
|
* {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
|
|
11
16
|
* Watch push: {"event": true, ...event fields...}
|
|
@@ -140,14 +145,40 @@ export interface JournalEntry {
|
|
|
140
145
|
opKey: string;
|
|
141
146
|
reason: string;
|
|
142
147
|
weight: number;
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
/** Held permits only; queued entries carry no token. */
|
|
149
|
+
permitToken?: string;
|
|
150
|
+
/** Held permits only. */
|
|
151
|
+
phase?: PermitPhase;
|
|
145
152
|
pid?: number;
|
|
146
153
|
pgid?: number;
|
|
147
154
|
pidStartTime?: string;
|
|
155
|
+
/** When the permit was claimed (held) / the request was journaled. */
|
|
148
156
|
claimedAt: number;
|
|
149
157
|
startedAt?: number;
|
|
150
158
|
leaseDeadline?: number;
|
|
159
|
+
/**
|
|
160
|
+
* Journaled lifecycle state (issue #34): "held" (permit — liveness is
|
|
161
|
+
* reconciled on restore) or "queued" (FIFO position preserved across
|
|
162
|
+
* restarts/self-restarts; offered entries are journaled as queued and
|
|
163
|
+
* re-offered with a fresh token after the restart). Absent in journals
|
|
164
|
+
* written before the update story => "held" (the original format).
|
|
165
|
+
*/
|
|
166
|
+
state?: "held" | "queued";
|
|
167
|
+
/** Queue insertion time (queued only) — restores FIFO order. */
|
|
168
|
+
enqueuedAt?: number;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Result of `hello`. `daemonVersion` is the running daemon's package
|
|
172
|
+
* version. OPTIONAL in responses from daemons older than the handshake —
|
|
173
|
+
* clients must treat a missing value as "unknown (old daemon)" and warn
|
|
174
|
+
* instead of failing.
|
|
175
|
+
*/
|
|
176
|
+
export interface HelloResult {
|
|
177
|
+
protocolVersion: number;
|
|
178
|
+
pid: number;
|
|
179
|
+
startedAt: number;
|
|
180
|
+
configVersion: number;
|
|
181
|
+
daemonVersion?: string;
|
|
151
182
|
}
|
|
152
183
|
export interface PoolStatus {
|
|
153
184
|
pool: string;
|
|
@@ -163,6 +194,13 @@ export interface StatusResult {
|
|
|
163
194
|
pid: number;
|
|
164
195
|
startedAt: number;
|
|
165
196
|
protocolVersion: number;
|
|
197
|
+
/**
|
|
198
|
+
* Running daemon's package version (update handshake, issue #34).
|
|
199
|
+
* Optional because daemons older than the handshake never send it —
|
|
200
|
+
* clients must treat a missing value as "unknown, old daemon" and
|
|
201
|
+
* warn rather than fail.
|
|
202
|
+
*/
|
|
203
|
+
daemonVersion?: string;
|
|
166
204
|
};
|
|
167
205
|
config: {
|
|
168
206
|
version: number;
|
package/dist/shared/protocol.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* receive unsolicited event lines.
|
|
7
7
|
*
|
|
8
8
|
* Request: {"id": <number>, "method": "<name>", "params": {...}}
|
|
9
|
+
* Newer clients also stamp the envelope with their package
|
|
10
|
+
* version: {..., "clientVersion": "1.2.1"}. Old daemons ignore
|
|
11
|
+
* the extra field (forward-compatible); newer daemons use it
|
|
12
|
+
* for the update handshake: a client strictly newer than the
|
|
13
|
+
* running daemon triggers a daemon self-restart (issue #34).
|
|
9
14
|
* Response: {"id": <number>, "ok": true, "result": {...}}
|
|
10
15
|
* {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
|
|
11
16
|
* Watch push: {"event": true, ...event fields...}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Version of THIS copy of load-lock (the on-disk install running now). */
|
|
2
|
+
export declare function packageVersion(): string;
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort dotted-version comparison ("1.2.1" vs "1.10.0"): numeric
|
|
5
|
+
* segments compared left to right, missing segments as 0, non-numeric
|
|
6
|
+
* prefixes of a segment ("2-beta" → 2) tolerated. Returns -1 / 0 / 1.
|
|
7
|
+
* Unparseable input never throws — it compares as 0.0.0, so the daemon
|
|
8
|
+
* never acts (restarts) on a garbage version.
|
|
9
|
+
*/
|
|
10
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-version plumbing for the update story (issue #34).
|
|
3
|
+
*
|
|
4
|
+
* Every load-lock process — daemon, CLI, MCP server, plugin — learns its
|
|
5
|
+
* own on-disk package version by reading the package.json that ships next
|
|
6
|
+
* to its compiled dist/ tree. That version takes part in the client→daemon
|
|
7
|
+
* handshake: clients stamp every request envelope with it
|
|
8
|
+
* (`clientVersion`), the daemon stamps every hello with it
|
|
9
|
+
* (`daemonVersion`), and a strictly-newer client triggers a daemon
|
|
10
|
+
* self-restart (see core/daemon.ts).
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
let cached;
|
|
16
|
+
/** Version of THIS copy of load-lock (the on-disk install running now). */
|
|
17
|
+
export function packageVersion() {
|
|
18
|
+
if (cached === undefined) {
|
|
19
|
+
try {
|
|
20
|
+
// dist/shared/version.js → <package root>/package.json — resolves the
|
|
21
|
+
// same way in the repo checkout, a global CLI install, and an
|
|
22
|
+
// opencode cache copy. npm always ships package.json with the tarball.
|
|
23
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
24
|
+
cached = String(JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
cached = "0.0.0";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Best-effort dotted-version comparison ("1.2.1" vs "1.10.0"): numeric
|
|
34
|
+
* segments compared left to right, missing segments as 0, non-numeric
|
|
35
|
+
* prefixes of a segment ("2-beta" → 2) tolerated. Returns -1 / 0 / 1.
|
|
36
|
+
* Unparseable input never throws — it compares as 0.0.0, so the daemon
|
|
37
|
+
* never acts (restarts) on a garbage version.
|
|
38
|
+
*/
|
|
39
|
+
export function compareVersions(a, b) {
|
|
40
|
+
const pa = parse(a);
|
|
41
|
+
const pb = parse(b);
|
|
42
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
43
|
+
const x = pa[i] ?? 0;
|
|
44
|
+
const y = pb[i] ?? 0;
|
|
45
|
+
if (x !== y)
|
|
46
|
+
return x < y ? -1 : 1;
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
function parse(v) {
|
|
51
|
+
return v
|
|
52
|
+
.split(".")
|
|
53
|
+
.map((seg) => {
|
|
54
|
+
const m = /^(\d+)/.exec(seg.trim());
|
|
55
|
+
return m ? Number(m[1]) : 0;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|