@aron98/load-lock 1.5.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.
- package/README.md +61 -14
- package/dist/cli/copies.d.ts +3 -2
- package/dist/cli/copies.js +32 -3
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +67 -13
- package/dist/cli/main.js +62 -5
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +5 -1
- package/dist/cli/update.js +16 -3
- package/dist/core/daemon.d.ts +29 -3
- package/dist/core/daemon.js +111 -11
- package/dist/core/persist.d.ts +3 -0
- package/dist/core/persist.js +49 -10
- package/dist/core/state.d.ts +9 -0
- package/dist/core/state.js +41 -3
- package/dist/mcp/server.d.ts +4 -3
- package/dist/mcp/server.js +29 -6
- package/dist/policy/text.d.ts +13 -3
- package/dist/policy/text.js +14 -3
- package/dist/shared/agent-guide.d.ts +3 -0
- package/dist/shared/agent-guide.js +16 -0
- package/dist/shared/client.js +5 -3
- package/dist/shared/drift.d.ts +4 -2
- package/dist/shared/drift.js +24 -6
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/pkginfo.d.ts +9 -0
- package/dist/shared/pkginfo.js +16 -0
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +15 -0
- package/dist/shared/runtimes.d.ts +41 -0
- package/dist/shared/runtimes.js +81 -0
- package/dist/shared/version.d.ts +6 -0
- package/dist/shared/version.js +9 -1
- package/dist/test/ace-mcp.test.d.ts +1 -0
- package/dist/test/ace-mcp.test.js +157 -0
- package/dist/test/ace-opencode.test.d.ts +1 -0
- package/dist/test/ace-opencode.test.js +287 -0
- package/dist/test/check-pins.test.d.ts +1 -0
- package/dist/test/check-pins.test.js +108 -0
- package/dist/test/compat.test.d.ts +1 -0
- package/dist/test/compat.test.js +190 -0
- package/dist/test/hook.test.js +179 -1
- package/dist/test/identity.test.d.ts +1 -0
- package/dist/test/identity.test.js +155 -0
- package/dist/test/install.test.js +135 -1
- package/dist/test/launcher.test.d.ts +1 -0
- package/dist/test/launcher.test.js +222 -0
- package/dist/test/mcp.test.js +76 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/runtimes.test.d.ts +1 -0
- package/dist/test/runtimes.test.js +282 -0
- package/dist/test/setup.test.d.ts +1 -0
- package/dist/test/setup.test.js +354 -0
- package/dist/test/update-command.test.js +71 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +36 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +142 -13
- package/docs/OPERATIONS.md +292 -14
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
package/dist/cli/update.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SelfLocationKind = "bun-global" | "npm-global" | "opencode-cache" | "repo-checkout" | "unknown";
|
|
1
|
+
export type SelfLocationKind = "bun-global" | "npm-global" | "opencode-cache" | "npx-cache" | "repo-checkout" | "unknown";
|
|
2
2
|
export interface SelfLocation {
|
|
3
3
|
kind: SelfLocationKind;
|
|
4
4
|
/** Package root of this copy. */
|
|
@@ -15,6 +15,8 @@ export interface ClassifyCtx {
|
|
|
15
15
|
npmGlobalRoot: string | null;
|
|
16
16
|
/** opencode cache root (XDG_CACHE_HOME | platform default); null when unknown. */
|
|
17
17
|
cacheRoot: string | null;
|
|
18
|
+
/** npx cache root (<npm cache>/_npx); null/absent when unknown. */
|
|
19
|
+
npxRoot?: string | null;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* Classify where this copy of load-lock lives, BEFORE touching anything:
|
|
@@ -22,6 +24,8 @@ export interface ClassifyCtx {
|
|
|
22
24
|
* - npm global (under `npm root -g`) → update via npm
|
|
23
25
|
* - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
|
|
24
26
|
* manages this copy and refreshes it on its own schedule
|
|
27
|
+
* - npx cache copy (<npm cache>/_npx/...) → refuse: whatever launches it
|
|
28
|
+
* via `npx @aron98/load-lock@<pin>` (skill / ACE / MCP entry) owns the pin
|
|
25
29
|
* - repo checkout (nearest .git found by walking up, like the omac
|
|
26
30
|
* read-grant logic) → refuse with a pointer to the git workflow
|
|
27
31
|
* - anything else → unknown → refuse honestly rather than guessing
|
package/dist/cli/update.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* version (npm is the only distribution channel), classify where THIS copy
|
|
4
4
|
* lives before touching anything, and apply the update via the detected
|
|
5
5
|
* package manager — or refuse honestly (repo checkouts, opencode-managed
|
|
6
|
-
* cache copies, unknown locations) rather than guessing. After a
|
|
6
|
+
* cache copies, npx cache copies, unknown locations) rather than guessing. After a
|
|
7
7
|
* successful apply, `service restart` moves the daemon onto the new code
|
|
8
8
|
* immediately; the v1.3.0 version handshake stays as the safety net for
|
|
9
9
|
* daemons nobody explicitly restarts.
|
|
@@ -24,7 +24,7 @@ import { existsSync } from "node:fs";
|
|
|
24
24
|
import { dirname, join, sep } from "node:path";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { compareVersions, packageVersion } from "../shared/version.js";
|
|
27
|
-
import { opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
|
|
27
|
+
import { npxCacheRoot, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
|
|
28
28
|
import { thisCopy } from "./copies.js";
|
|
29
29
|
import { serviceRestart, ServiceError } from "./service.js";
|
|
30
30
|
/** Path containment with separator awareness ("a" contains "a" and "a/b"). */
|
|
@@ -39,6 +39,8 @@ function isUnder(dir, root) {
|
|
|
39
39
|
* - npm global (under `npm root -g`) → update via npm
|
|
40
40
|
* - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
|
|
41
41
|
* manages this copy and refreshes it on its own schedule
|
|
42
|
+
* - npx cache copy (<npm cache>/_npx/...) → refuse: whatever launches it
|
|
43
|
+
* via `npx @aron98/load-lock@<pin>` (skill / ACE / MCP entry) owns the pin
|
|
42
44
|
* - repo checkout (nearest .git found by walking up, like the omac
|
|
43
45
|
* read-grant logic) → refuse with a pointer to the git workflow
|
|
44
46
|
* - anything else → unknown → refuse honestly rather than guessing
|
|
@@ -54,6 +56,9 @@ export function classifySelf(pkgRoot, ctx) {
|
|
|
54
56
|
if (ctx.cacheRoot && isUnder(pkgRoot, join(ctx.cacheRoot, "opencode", "npm"))) {
|
|
55
57
|
return { kind: "opencode-cache", path: pkgRoot, detail: `opencode-managed cache copy under ${ctx.cacheRoot}` };
|
|
56
58
|
}
|
|
59
|
+
if (ctx.npxRoot && isUnder(pkgRoot, ctx.npxRoot)) {
|
|
60
|
+
return { kind: "npx-cache", path: pkgRoot, detail: `npx cache copy under ${ctx.npxRoot}` };
|
|
61
|
+
}
|
|
57
62
|
for (let dir = pkgRoot;;) {
|
|
58
63
|
if (existsSync(join(dir, ".git"))) {
|
|
59
64
|
return { kind: "repo-checkout", path: pkgRoot, detail: `git checkout (.git at ${dir})` };
|
|
@@ -63,7 +68,7 @@ export function classifySelf(pkgRoot, ctx) {
|
|
|
63
68
|
break;
|
|
64
69
|
dir = parent;
|
|
65
70
|
}
|
|
66
|
-
return { kind: "unknown", path: pkgRoot, detail: "not a recognized bun/npm global, opencode cache, or git checkout location" };
|
|
71
|
+
return { kind: "unknown", path: pkgRoot, detail: "not a recognized bun/npm global, opencode/npx cache, or git checkout location" };
|
|
67
72
|
}
|
|
68
73
|
/** What to run for the detected location — or why we refuse to guess. */
|
|
69
74
|
export function planUpdate(loc) {
|
|
@@ -78,6 +83,13 @@ export function planUpdate(loc) {
|
|
|
78
83
|
reason: `this copy lives in opencode's npm cache (${loc.path}); opencode manages it and refreshes it on its own schedule — ` +
|
|
79
84
|
`update the PATH-installed CLI instead (npm i -g ${PKG_NAME}@latest)`,
|
|
80
85
|
};
|
|
86
|
+
case "npx-cache":
|
|
87
|
+
return {
|
|
88
|
+
action: "refuse",
|
|
89
|
+
reason: `this copy lives in the npx cache (${loc.path}); it is whatever version the launching npx command pins — ` +
|
|
90
|
+
`bump the pinned version (the \`npx -y ${PKG_NAME}@<version>\` in your MCP/skill config, or the ACE artifact) ` +
|
|
91
|
+
`to move it, and update the PATH-installed CLI separately (npm i -g ${PKG_NAME}@latest)`,
|
|
92
|
+
};
|
|
81
93
|
case "repo-checkout":
|
|
82
94
|
return {
|
|
83
95
|
action: "refuse",
|
|
@@ -170,6 +182,7 @@ export async function updateCommand(opts) {
|
|
|
170
182
|
home: process.env.HOME ?? homedir(),
|
|
171
183
|
npmGlobalRoot: root,
|
|
172
184
|
cacheRoot: opencodeCacheRoot(process.platform, process.env),
|
|
185
|
+
npxRoot: npxCacheRoot(process.platform, process.env),
|
|
173
186
|
});
|
|
174
187
|
// 2. Check the registry (npm is the only distribution channel).
|
|
175
188
|
const latest = await viewLatest(timeoutMs);
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -29,6 +29,9 @@ export declare class Daemon {
|
|
|
29
29
|
private waiters;
|
|
30
30
|
/** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
|
|
31
31
|
private completionWaiters;
|
|
32
|
+
/** hook_once keys (issue #54): in-memory only, never persisted. A key is
|
|
33
|
+
* live until expiresAt, or — when held — until its socket closes. */
|
|
34
|
+
private hookKeys;
|
|
32
35
|
private sentEventId;
|
|
33
36
|
private stateDirPath;
|
|
34
37
|
private socketPath;
|
|
@@ -36,6 +39,10 @@ export declare class Daemon {
|
|
|
36
39
|
private expiryTimer;
|
|
37
40
|
private restartTimer;
|
|
38
41
|
private stopped;
|
|
42
|
+
/** Known runtime copies (issue #52), mirrored to runtimes.json. */
|
|
43
|
+
private runtimes;
|
|
44
|
+
/** clientRoot hints already resolved this run: raw hint -> real path (null = invalid). */
|
|
45
|
+
private runtimeHints;
|
|
39
46
|
constructor(opts?: {
|
|
40
47
|
stateDir?: string;
|
|
41
48
|
socket?: string;
|
|
@@ -53,6 +60,13 @@ export declare class Daemon {
|
|
|
53
60
|
private onConnection;
|
|
54
61
|
private send;
|
|
55
62
|
private handleLine;
|
|
63
|
+
/**
|
|
64
|
+
* Known runtime copies (issue #52): remember a copy seen via the
|
|
65
|
+
* handshake. Deduped by real path; the version is read from the copy's
|
|
66
|
+
* own package.json. Disk writes only on a new copy, a version change, or
|
|
67
|
+
* a stale lastSeen — never per request. Best-effort: never throws.
|
|
68
|
+
*/
|
|
69
|
+
private recordRuntime;
|
|
56
70
|
/**
|
|
57
71
|
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
58
72
|
* package version than this daemon runs, schedule a respawn so the daemon
|
|
@@ -65,9 +79,12 @@ export declare class Daemon {
|
|
|
65
79
|
/**
|
|
66
80
|
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
67
81
|
* journal FIRST, then stops (destroying all client connections — they
|
|
68
|
-
* reconnect to the fresh daemon), then spawns the replacement
|
|
69
|
-
*
|
|
70
|
-
*
|
|
82
|
+
* reconnect to the fresh daemon), then spawns the replacement and exits.
|
|
83
|
+
*
|
|
84
|
+
* The replacement runs the newer client's copy when its package root
|
|
85
|
+
* (clientRoot, issue #53) checks out — see restartEntry(). Otherwise it
|
|
86
|
+
* reruns the exact same argv, which picks up an in-place update of the
|
|
87
|
+
* daemon's own copy (e.g. `npm i -g`).
|
|
71
88
|
*/
|
|
72
89
|
private selfRestart;
|
|
73
90
|
/** Returns [result, agentIdForPendingAttachment]. */
|
|
@@ -140,3 +157,12 @@ export declare function isProcessAlive(pid: number, expectedStartTime?: string,
|
|
|
140
157
|
export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string, platform?: NodeJS.Platform): boolean | null;
|
|
141
158
|
/** Read the Linux start time of a pid (pid-reuse-safe association). */
|
|
142
159
|
export declare function processStartTime(pid: number): string | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Daemon entry point of the newer client's copy (issue #53), or null to
|
|
162
|
+
* fall back to the same-argv respawn. The hint must name a load-lock
|
|
163
|
+
* package root whose on-disk version equals the version the client
|
|
164
|
+
* claimed, with a dist/core/daemon-main.js. The hint arrives over the
|
|
165
|
+
* same-user socket, so following it grants no privilege the client did
|
|
166
|
+
* not already have (it could run that file itself).
|
|
167
|
+
*/
|
|
168
|
+
export declare function restartEntry(clientRoot: string | undefined, clientVersion: string): string | null;
|
package/dist/core/daemon.js
CHANGED
|
@@ -25,8 +25,10 @@ import { Machine } from "./state.js";
|
|
|
25
25
|
import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
|
|
26
26
|
import { inspectMachine } from "./sysinfo.js";
|
|
27
27
|
import { ProtocolError } from "../shared/protocol.js";
|
|
28
|
+
import { HOOK_ONCE_DEFAULT_TTL_MS, HOOK_ONCE_MAX_TTL_MS } from "../shared/protocol.js";
|
|
28
29
|
import { isWindows, probeTransport } from "../shared/transport.js";
|
|
29
|
-
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
30
|
+
import { packageVersion, packageRoot, compareVersions } from "../shared/version.js";
|
|
31
|
+
import { inspectPackageRoot, loadRuntimes, mergeRuntime, saveRuntimes } from "../shared/runtimes.js";
|
|
30
32
|
/** Package version of the copy of load-lock this daemon process runs from. */
|
|
31
33
|
const DAEMON_VERSION = packageVersion();
|
|
32
34
|
/**
|
|
@@ -35,6 +37,8 @@ const DAEMON_VERSION = packageVersion();
|
|
|
35
37
|
* only one respawn per version bump.
|
|
36
38
|
*/
|
|
37
39
|
const RESTARTED_FOR_ENV = "LOAD_LOCK_RESTARTED_FOR";
|
|
40
|
+
/** Re-persist a known copy's lastSeen at most this often (issue #52). */
|
|
41
|
+
const RUNTIME_TOUCH_MS = 60_000;
|
|
38
42
|
export class Daemon {
|
|
39
43
|
machine;
|
|
40
44
|
server = null;
|
|
@@ -46,6 +50,9 @@ export class Daemon {
|
|
|
46
50
|
waiters = new Map();
|
|
47
51
|
/** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
|
|
48
52
|
completionWaiters = new Map();
|
|
53
|
+
/** hook_once keys (issue #54): in-memory only, never persisted. A key is
|
|
54
|
+
* live until expiresAt, or — when held — until its socket closes. */
|
|
55
|
+
hookKeys = new Map();
|
|
49
56
|
sentEventId;
|
|
50
57
|
stateDirPath;
|
|
51
58
|
socketPath;
|
|
@@ -53,6 +60,10 @@ export class Daemon {
|
|
|
53
60
|
expiryTimer = null;
|
|
54
61
|
restartTimer = null;
|
|
55
62
|
stopped = false;
|
|
63
|
+
/** Known runtime copies (issue #52), mirrored to runtimes.json. */
|
|
64
|
+
runtimes = [];
|
|
65
|
+
/** clientRoot hints already resolved this run: raw hint -> real path (null = invalid). */
|
|
66
|
+
runtimeHints = new Map();
|
|
56
67
|
constructor(opts = {}) {
|
|
57
68
|
this.stateDirPath = defaultStateDir(opts.stateDir);
|
|
58
69
|
this.socketPath = defaultSocket(opts.socket);
|
|
@@ -81,7 +92,17 @@ export class Daemon {
|
|
|
81
92
|
this.server.on("error", reject);
|
|
82
93
|
this.server.listen(this.socketPath, () => resolve());
|
|
83
94
|
});
|
|
84
|
-
|
|
95
|
+
const own = inspectPackageRoot(packageRoot());
|
|
96
|
+
const info = {
|
|
97
|
+
pid: process.pid,
|
|
98
|
+
startedAt: Date.now(),
|
|
99
|
+
socket: this.socketPath,
|
|
100
|
+
packageRoot: own?.path ?? packageRoot(),
|
|
101
|
+
version: DAEMON_VERSION,
|
|
102
|
+
};
|
|
103
|
+
writeFileSync(join(this.stateDirPath, "daemon.json"), JSON.stringify(info, null, 2) + "\n");
|
|
104
|
+
this.runtimes = loadRuntimes(this.stateDirPath);
|
|
105
|
+
this.recordRuntime(packageRoot());
|
|
85
106
|
process.once("SIGTERM", () => this.stop());
|
|
86
107
|
process.once("SIGINT", () => this.stop());
|
|
87
108
|
this.sweepTimer = setInterval(() => this.sweep(), 5_000);
|
|
@@ -187,6 +208,9 @@ export class Daemon {
|
|
|
187
208
|
for (const w of [...this.watchers])
|
|
188
209
|
if (w.socket === sock)
|
|
189
210
|
this.watchers.delete(w);
|
|
211
|
+
for (const [k, v] of this.hookKeys)
|
|
212
|
+
if (v.sock === sock)
|
|
213
|
+
this.hookKeys.delete(k);
|
|
190
214
|
});
|
|
191
215
|
}
|
|
192
216
|
send(sock, obj) {
|
|
@@ -204,6 +228,8 @@ export class Daemon {
|
|
|
204
228
|
}
|
|
205
229
|
// Version handshake: remember the client's version for skew detection.
|
|
206
230
|
const clientVersion = typeof msg.clientVersion === "string" ? msg.clientVersion : undefined;
|
|
231
|
+
if (typeof msg.clientRoot === "string")
|
|
232
|
+
this.recordRuntime(msg.clientRoot);
|
|
207
233
|
let result;
|
|
208
234
|
let agentId;
|
|
209
235
|
try {
|
|
@@ -222,7 +248,7 @@ export class Daemon {
|
|
|
222
248
|
}
|
|
223
249
|
// Respond first, then consider a self-restart — even on errors, a
|
|
224
250
|
// newer client means this daemon is running stale on-disk code.
|
|
225
|
-
this.maybeScheduleSelfRestart(clientVersion);
|
|
251
|
+
this.maybeScheduleSelfRestart(clientVersion, msg.clientRoot);
|
|
226
252
|
return;
|
|
227
253
|
}
|
|
228
254
|
// Tool-boundary delivery: surface this agent's outstanding offers/queue
|
|
@@ -231,9 +257,41 @@ export class Daemon {
|
|
|
231
257
|
result.pending = this.machine.pending(agentId);
|
|
232
258
|
}
|
|
233
259
|
this.send(sock, { id: msg.id, ok: true, result });
|
|
234
|
-
this.maybeScheduleSelfRestart(clientVersion);
|
|
260
|
+
this.maybeScheduleSelfRestart(clientVersion, msg.clientRoot);
|
|
235
261
|
}
|
|
236
262
|
// -------------------------------------------------------- version handshake
|
|
263
|
+
/**
|
|
264
|
+
* Known runtime copies (issue #52): remember a copy seen via the
|
|
265
|
+
* handshake. Deduped by real path; the version is read from the copy's
|
|
266
|
+
* own package.json. Disk writes only on a new copy, a version change, or
|
|
267
|
+
* a stale lastSeen — never per request. Best-effort: never throws.
|
|
268
|
+
*/
|
|
269
|
+
recordRuntime(root) {
|
|
270
|
+
try {
|
|
271
|
+
let real = this.runtimeHints.get(root);
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
if (real === undefined) {
|
|
274
|
+
real = inspectPackageRoot(root)?.path ?? null;
|
|
275
|
+
if (this.runtimeHints.size > 256)
|
|
276
|
+
this.runtimeHints.clear();
|
|
277
|
+
this.runtimeHints.set(root, real);
|
|
278
|
+
}
|
|
279
|
+
if (real === null)
|
|
280
|
+
return;
|
|
281
|
+
const known = this.runtimes.find((c) => c.path === real);
|
|
282
|
+
if (known && now - known.lastSeen < RUNTIME_TOUCH_MS)
|
|
283
|
+
return;
|
|
284
|
+
const merged = mergeRuntime(this.runtimes, real, now);
|
|
285
|
+
if (!merged)
|
|
286
|
+
return;
|
|
287
|
+
// Drop copies whose path disappeared since they were last seen.
|
|
288
|
+
this.runtimes = merged.filter((c) => c.path === real || existsSync(join(c.path, "package.json")));
|
|
289
|
+
saveRuntimes(this.stateDirPath, this.runtimes);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
/* bookkeeping only */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
237
295
|
/**
|
|
238
296
|
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
239
297
|
* package version than this daemon runs, schedule a respawn so the daemon
|
|
@@ -242,7 +300,7 @@ export class Daemon {
|
|
|
242
300
|
* entirely daemon-side and best-effort: pre-handshake clients that send
|
|
243
301
|
* no version never trigger a restart.
|
|
244
302
|
*/
|
|
245
|
-
maybeScheduleSelfRestart(clientVersion) {
|
|
303
|
+
maybeScheduleSelfRestart(clientVersion, clientRoot) {
|
|
246
304
|
if (this.stopped || this.restartTimer || !clientVersion)
|
|
247
305
|
return;
|
|
248
306
|
if (compareVersions(clientVersion, DAEMON_VERSION) <= 0)
|
|
@@ -252,18 +310,21 @@ export class Daemon {
|
|
|
252
310
|
return; // already tried
|
|
253
311
|
this.restartTimer = setTimeout(() => {
|
|
254
312
|
this.restartTimer = null;
|
|
255
|
-
this.selfRestart(clientVersion);
|
|
313
|
+
this.selfRestart(clientVersion, typeof clientRoot === "string" ? clientRoot : undefined);
|
|
256
314
|
}, 250);
|
|
257
315
|
this.restartTimer.unref?.();
|
|
258
316
|
}
|
|
259
317
|
/**
|
|
260
318
|
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
261
319
|
* journal FIRST, then stops (destroying all client connections — they
|
|
262
|
-
* reconnect to the fresh daemon), then spawns the replacement
|
|
263
|
-
*
|
|
264
|
-
*
|
|
320
|
+
* reconnect to the fresh daemon), then spawns the replacement and exits.
|
|
321
|
+
*
|
|
322
|
+
* The replacement runs the newer client's copy when its package root
|
|
323
|
+
* (clientRoot, issue #53) checks out — see restartEntry(). Otherwise it
|
|
324
|
+
* reruns the exact same argv, which picks up an in-place update of the
|
|
325
|
+
* daemon's own copy (e.g. `npm i -g`).
|
|
265
326
|
*/
|
|
266
|
-
selfRestart(clientVersion) {
|
|
327
|
+
selfRestart(clientVersion, clientRoot) {
|
|
267
328
|
if (this.stopped)
|
|
268
329
|
return;
|
|
269
330
|
// Best-effort persistence; a failed save must not block the restart.
|
|
@@ -276,7 +337,9 @@ export class Daemon {
|
|
|
276
337
|
}
|
|
277
338
|
this.stop();
|
|
278
339
|
try {
|
|
279
|
-
const
|
|
340
|
+
const entry = restartEntry(clientRoot, clientVersion);
|
|
341
|
+
const args = entry ? [entry, "--state-dir", this.stateDirPath, "--socket", this.socketPath] : process.argv.slice(1);
|
|
342
|
+
const child = spawn(process.execPath, args, {
|
|
280
343
|
detached: true,
|
|
281
344
|
stdio: "ignore",
|
|
282
345
|
cwd: process.cwd(),
|
|
@@ -406,6 +469,26 @@ export class Daemon {
|
|
|
406
469
|
const cwd = String(p.cwd);
|
|
407
470
|
return [{ offers: m.allOffers().filter((e) => e.cwd === cwd) }, undefined];
|
|
408
471
|
}
|
|
472
|
+
case "hook_once": {
|
|
473
|
+
// Duplicate hook wiring (loose + plugin, two plugins, ...) fires the
|
|
474
|
+
// same hook several times; the first caller per key acts, the rest
|
|
475
|
+
// stay silent. Keys are in-memory only and expire on their own
|
|
476
|
+
// (ttlMs) or, with hold, live exactly as long as this connection.
|
|
477
|
+
const params = p;
|
|
478
|
+
if (!params.harness || !params.sessionId || !params.event) {
|
|
479
|
+
throw new ProtocolError("invalid", "hook_once: harness, sessionId and event are required");
|
|
480
|
+
}
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
for (const [k, v] of this.hookKeys)
|
|
483
|
+
if (!v.sock && v.expiresAt <= now)
|
|
484
|
+
this.hookKeys.delete(k);
|
|
485
|
+
const key = JSON.stringify([params.harness, params.sessionId, params.event, params.occurrence ?? ""]);
|
|
486
|
+
if (this.hookKeys.has(key))
|
|
487
|
+
return [{ first: false }, undefined];
|
|
488
|
+
const ttl = Math.min(Math.max(Number(params.ttlMs ?? HOOK_ONCE_DEFAULT_TTL_MS) || HOOK_ONCE_DEFAULT_TTL_MS, 1_000), HOOK_ONCE_MAX_TTL_MS);
|
|
489
|
+
this.hookKeys.set(key, params.hold ? { expiresAt: Infinity, sock } : { expiresAt: now + ttl });
|
|
490
|
+
return [{ first: true }, undefined];
|
|
491
|
+
}
|
|
409
492
|
case "admin_list":
|
|
410
493
|
return [{ holders: m.diagnose((e) => jobAlive(e.pid, e.pgid, e.pidStartTime)), latestEventId: m.latestEventId() }, undefined];
|
|
411
494
|
case "admin_release": {
|
|
@@ -863,3 +946,20 @@ export function processStartTime(pid) {
|
|
|
863
946
|
return undefined;
|
|
864
947
|
}
|
|
865
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* Daemon entry point of the newer client's copy (issue #53), or null to
|
|
951
|
+
* fall back to the same-argv respawn. The hint must name a load-lock
|
|
952
|
+
* package root whose on-disk version equals the version the client
|
|
953
|
+
* claimed, with a dist/core/daemon-main.js. The hint arrives over the
|
|
954
|
+
* same-user socket, so following it grants no privilege the client did
|
|
955
|
+
* not already have (it could run that file itself).
|
|
956
|
+
*/
|
|
957
|
+
export function restartEntry(clientRoot, clientVersion) {
|
|
958
|
+
if (!clientRoot)
|
|
959
|
+
return null;
|
|
960
|
+
const found = inspectPackageRoot(clientRoot);
|
|
961
|
+
if (!found || found.version !== clientVersion)
|
|
962
|
+
return null;
|
|
963
|
+
const entry = join(found.path, "dist", "core", "daemon-main.js");
|
|
964
|
+
return existsSync(entry) ? entry : null;
|
|
965
|
+
}
|
package/dist/core/persist.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { MachineConfig, ConfigHistoryEntry, JournalEntry } from "../shared/protocol.js";
|
|
2
2
|
export declare function stateDir(override?: string): string;
|
|
3
3
|
export declare function socketPath(override?: string): string;
|
|
4
|
+
/** Format version this code writes into config.json and journal entries. */
|
|
5
|
+
export declare const STATE_FORMAT_VERSION = 1;
|
|
4
6
|
export declare function loadState(dir: string): {
|
|
5
7
|
config: MachineConfig;
|
|
6
8
|
history: ConfigHistoryEntry[];
|
|
9
|
+
formatVersion: number;
|
|
7
10
|
};
|
|
8
11
|
export declare function saveState(dir: string, config: MachineConfig, history: ConfigHistoryEntry[]): void;
|
|
9
12
|
export declare function journalPath(dir: string): string;
|
package/dist/core/persist.js
CHANGED
|
@@ -19,35 +19,73 @@ export function stateDir(override) {
|
|
|
19
19
|
export function socketPath(override) {
|
|
20
20
|
return resolveSocketPath(process.platform, process.env, override);
|
|
21
21
|
}
|
|
22
|
+
// --------------------------------------------------------------------------
|
|
23
|
+
// Format version + compatibility (issue #51). Several runtime copies may share
|
|
24
|
+
// one state dir, so an OLDER daemon can load files written by a NEWER one
|
|
25
|
+
// (downgrade) and vice versa. The promise (docs/ARCHITECTURE.md):
|
|
26
|
+
// - format changes are additive; readers ignore what they don't understand;
|
|
27
|
+
// - unknown fields survive a rewrite (config.json: top level, inside
|
|
28
|
+
// `config`, inside pools; journal: per entry while the entry is live);
|
|
29
|
+
// - unknown journal entry kinds are skipped (reported), never fatal;
|
|
30
|
+
// - an incompatible change would use a NEW file name, never reinterpret
|
|
31
|
+
// these files.
|
|
32
|
+
// Files written before versioning carry no formatVersion (=> 0).
|
|
33
|
+
/** Format version this code writes into config.json and journal entries. */
|
|
34
|
+
export const STATE_FORMAT_VERSION = 1;
|
|
35
|
+
const KNOWN_STATE_KEYS = new Set(["formatVersion", "config", "history"]);
|
|
36
|
+
function isObject(v) {
|
|
37
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
38
|
+
}
|
|
22
39
|
export function loadState(dir) {
|
|
23
40
|
const path = join(dir, "config.json");
|
|
24
41
|
if (!existsSync(path))
|
|
25
|
-
return { config: defaultConfig(), history: [] };
|
|
42
|
+
return { config: defaultConfig(), history: [], formatVersion: STATE_FORMAT_VERSION };
|
|
26
43
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
27
|
-
|
|
44
|
+
// Unknown fields inside `config` ride along in memory and are written back
|
|
45
|
+
// by saveState (configure() mutates in place).
|
|
46
|
+
const config = { ...defaultConfig(), ...(isObject(raw?.config) ? raw.config : {}) };
|
|
28
47
|
// Defensive: always at least the default pool.
|
|
29
|
-
if (!config.pools || Object.keys(config.pools).length === 0)
|
|
48
|
+
if (!isObject(config.pools) || Object.keys(config.pools).length === 0)
|
|
30
49
|
config.pools = { heavy: { capacity: 1 } };
|
|
31
|
-
|
|
50
|
+
const formatVersion = typeof raw?.formatVersion === "number" ? raw.formatVersion : 0;
|
|
51
|
+
return { config, history: Array.isArray(raw?.history) ? raw.history : [], formatVersion };
|
|
32
52
|
}
|
|
33
53
|
export function saveState(dir, config, history) {
|
|
34
54
|
mkdirSync(dir, { recursive: true });
|
|
35
55
|
const path = join(dir, "config.json");
|
|
56
|
+
// Preserve top-level fields written by a newer format (never rewrite
|
|
57
|
+
// destructively). Best-effort: an unreadable file has nothing to keep.
|
|
58
|
+
const extra = {};
|
|
59
|
+
try {
|
|
60
|
+
const prev = JSON.parse(readFileSync(path, "utf8"));
|
|
61
|
+
if (isObject(prev))
|
|
62
|
+
for (const [k, v] of Object.entries(prev))
|
|
63
|
+
if (!KNOWN_STATE_KEYS.has(k))
|
|
64
|
+
extra[k] = v;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* missing or corrupt: nothing to preserve */
|
|
68
|
+
}
|
|
36
69
|
const tmp = join(dirname(path), ".config.json.tmp-" + process.pid);
|
|
37
|
-
writeFileSync(tmp, JSON.stringify({ config, history }, null, 2) + "\n", { mode: 0o644 });
|
|
70
|
+
writeFileSync(tmp, JSON.stringify({ formatVersion: STATE_FORMAT_VERSION, ...extra, config, history }, null, 2) + "\n", { mode: 0o644 });
|
|
38
71
|
renameSync(tmp, path); // atomic on POSIX
|
|
39
72
|
}
|
|
40
73
|
// --------------------------------------------------------------------------
|
|
41
|
-
// Journal: held permits, persisted so a daemon
|
|
42
|
-
// reconcile running ownership (tracked jobs keep
|
|
43
|
-
//
|
|
74
|
+
// Journal: held permits AND queued/offered requests, persisted so a daemon
|
|
75
|
+
// restart can conservatively reconcile running ownership (tracked jobs keep
|
|
76
|
+
// their permit; confirmed-dead jobs release) and re-queue waiters FIFO with
|
|
77
|
+
// the same requestIds (offered entries come back as queued; issue #34).
|
|
44
78
|
export function journalPath(dir) {
|
|
45
79
|
return join(dir, "journal.json");
|
|
46
80
|
}
|
|
81
|
+
// The journal stays a bare JSON array (what every released daemon reads); the
|
|
82
|
+
// format version is stamped per entry. An object form `{ entries: [...] }` is
|
|
83
|
+
// accepted too, so a future format may wrap the array without breaking us.
|
|
47
84
|
export function loadJournal(dir) {
|
|
48
85
|
try {
|
|
49
86
|
const raw = JSON.parse(readFileSync(journalPath(dir), "utf8"));
|
|
50
|
-
|
|
87
|
+
const list = Array.isArray(raw) ? raw : isObject(raw) && Array.isArray(raw.entries) ? raw.entries : [];
|
|
88
|
+
return list.filter(isObject);
|
|
51
89
|
}
|
|
52
90
|
catch {
|
|
53
91
|
return [];
|
|
@@ -56,6 +94,7 @@ export function loadJournal(dir) {
|
|
|
56
94
|
export function saveJournal(dir, entries) {
|
|
57
95
|
const path = journalPath(dir);
|
|
58
96
|
const tmp = join(dirname(path), ".journal.json.tmp-" + process.pid);
|
|
59
|
-
|
|
97
|
+
const stamped = entries.map((e) => ({ formatVersion: STATE_FORMAT_VERSION, ...e }));
|
|
98
|
+
writeFileSync(tmp, JSON.stringify(stamped, null, 2) + "\n", { mode: 0o644 });
|
|
60
99
|
renameSync(tmp, path);
|
|
61
100
|
}
|
package/dist/core/state.d.ts
CHANGED
|
@@ -56,6 +56,8 @@ interface Entry {
|
|
|
56
56
|
leaseDeadline?: number;
|
|
57
57
|
lastSeen?: number;
|
|
58
58
|
enqueuedAt?: number;
|
|
59
|
+
/** Journal fields this version doesn't know (written by a newer daemon); written back as-is (issue #51). */
|
|
60
|
+
journalExtra?: Record<string, unknown>;
|
|
59
61
|
}
|
|
60
62
|
export declare class Machine {
|
|
61
63
|
config: MachineConfig;
|
|
@@ -197,6 +199,12 @@ export declare class Machine {
|
|
|
197
199
|
* alive => keep (same token, so a late explicit release still works).
|
|
198
200
|
* - claimed: keep the start deadline (reclaimed by tick if already past).
|
|
199
201
|
* - untracked: keep, with a FRESH lease window from restore time.
|
|
202
|
+
* - forward compatibility (issue #51): entries of an unknown `state`
|
|
203
|
+
* (written by a newer daemon) or missing required fields are skipped
|
|
204
|
+
* and reported, never fatal; a held entry with an unknown `phase` is
|
|
205
|
+
* kept as untracked with a fresh lease (capacity stays accounted, but
|
|
206
|
+
* the lease reclaims it if nobody heartbeats). Unknown fields are kept
|
|
207
|
+
* and written back to the journal while the entry lives.
|
|
200
208
|
*/
|
|
201
209
|
restore(journal: JournalEntry[], liveness: (e: {
|
|
202
210
|
pid?: number;
|
|
@@ -206,6 +214,7 @@ export declare class Machine {
|
|
|
206
214
|
kept: number;
|
|
207
215
|
releasedDead: number;
|
|
208
216
|
queued: number;
|
|
217
|
+
skipped: number;
|
|
209
218
|
};
|
|
210
219
|
}
|
|
211
220
|
export {};
|
package/dist/core/state.js
CHANGED
|
@@ -48,6 +48,21 @@ export function defaultConfig() {
|
|
|
48
48
|
guidance: {},
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
+
/** Journal entry fields this version understands; everything else is carried through (issue #51). */
|
|
52
|
+
const KNOWN_JOURNAL_KEYS = new Set([
|
|
53
|
+
"formatVersion", "requestId", "pool", "agentId", "agentLabel", "cwd", "opKey", "reason", "weight",
|
|
54
|
+
"permitToken", "phase", "pid", "pgid", "pidStartTime", "claimedAt", "startedAt", "leaseDeadline", "state", "enqueuedAt",
|
|
55
|
+
]);
|
|
56
|
+
const KNOWN_PHASES = new Set(["claimed", "running_tracked", "running_untracked"]);
|
|
57
|
+
function journalExtra(j) {
|
|
58
|
+
let out;
|
|
59
|
+
for (const [k, v] of Object.entries(j)) {
|
|
60
|
+
if (KNOWN_JOURNAL_KEYS.has(k))
|
|
61
|
+
continue;
|
|
62
|
+
(out ??= {})[k] = v;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
51
66
|
const rid = () => "req_" + randomBytes(12).toString("hex");
|
|
52
67
|
const tok = (p) => p + "_" + randomBytes(16).toString("hex");
|
|
53
68
|
export class Machine {
|
|
@@ -793,8 +808,9 @@ export class Machine {
|
|
|
793
808
|
}
|
|
794
809
|
this.config.version += 1;
|
|
795
810
|
if (c.pools) {
|
|
811
|
+
// Spread the previous pool config so fields from a newer format survive (issue #51).
|
|
796
812
|
for (const [name, pc] of Object.entries(c.pools))
|
|
797
|
-
this.config.pools[name] = { capacity: pc.capacity };
|
|
813
|
+
this.config.pools[name] = { ...this.config.pools[name], capacity: pc.capacity };
|
|
798
814
|
}
|
|
799
815
|
if (c.offerTimeoutMs !== undefined)
|
|
800
816
|
this.config.offerTimeoutMs = c.offerTimeoutMs;
|
|
@@ -853,6 +869,7 @@ export class Machine {
|
|
|
853
869
|
if (e.state !== "held")
|
|
854
870
|
continue;
|
|
855
871
|
out.push({
|
|
872
|
+
...e.journalExtra,
|
|
856
873
|
requestId: e.id,
|
|
857
874
|
pool: e.pool,
|
|
858
875
|
agentId: e.agentId,
|
|
@@ -876,6 +893,7 @@ export class Machine {
|
|
|
876
893
|
if (e.state !== "queued" && e.state !== "offered")
|
|
877
894
|
continue;
|
|
878
895
|
out.push({
|
|
896
|
+
...e.journalExtra,
|
|
879
897
|
requestId: e.id,
|
|
880
898
|
pool: e.pool,
|
|
881
899
|
agentId: e.agentId,
|
|
@@ -901,12 +919,27 @@ export class Machine {
|
|
|
901
919
|
* alive => keep (same token, so a late explicit release still works).
|
|
902
920
|
* - claimed: keep the start deadline (reclaimed by tick if already past).
|
|
903
921
|
* - untracked: keep, with a FRESH lease window from restore time.
|
|
922
|
+
* - forward compatibility (issue #51): entries of an unknown `state`
|
|
923
|
+
* (written by a newer daemon) or missing required fields are skipped
|
|
924
|
+
* and reported, never fatal; a held entry with an unknown `phase` is
|
|
925
|
+
* kept as untracked with a fresh lease (capacity stays accounted, but
|
|
926
|
+
* the lease reclaims it if nobody heartbeats). Unknown fields are kept
|
|
927
|
+
* and written back to the journal while the entry lives.
|
|
904
928
|
*/
|
|
905
929
|
restore(journal, liveness) {
|
|
906
930
|
let kept = 0;
|
|
907
931
|
let releasedDead = 0;
|
|
932
|
+
let skipped = 0;
|
|
908
933
|
const queuedEntries = [];
|
|
909
934
|
for (const j of journal) {
|
|
935
|
+
const st = j.state;
|
|
936
|
+
if ((st !== undefined && st !== "held" && st !== "queued") ||
|
|
937
|
+
typeof j.requestId !== "string" ||
|
|
938
|
+
typeof j.pool !== "string" ||
|
|
939
|
+
typeof j.agentId !== "string") {
|
|
940
|
+
skipped++;
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
910
943
|
if (j.state === "queued") {
|
|
911
944
|
queuedEntries.push({ j, at: j.enqueuedAt ?? j.claimedAt });
|
|
912
945
|
continue;
|
|
@@ -929,7 +962,10 @@ export class Machine {
|
|
|
929
962
|
pidStartTime: j.pidStartTime,
|
|
930
963
|
startedAt: j.startedAt,
|
|
931
964
|
lastSeen: this.now(),
|
|
965
|
+
journalExtra: journalExtra(j),
|
|
932
966
|
};
|
|
967
|
+
if (!KNOWN_PHASES.has(e.phase))
|
|
968
|
+
e.phase = "running_untracked";
|
|
933
969
|
if (e.phase === "claimed") {
|
|
934
970
|
e.startDeadline = j.claimedAt + this.config.startTimeoutMs;
|
|
935
971
|
}
|
|
@@ -974,6 +1010,7 @@ export class Machine {
|
|
|
974
1010
|
state: "queued",
|
|
975
1011
|
since: this.now(),
|
|
976
1012
|
enqueuedAt: j.enqueuedAt ?? j.claimedAt,
|
|
1013
|
+
journalExtra: journalExtra(j),
|
|
977
1014
|
};
|
|
978
1015
|
this.entries.set(e.id, e);
|
|
979
1016
|
this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
|
|
@@ -988,8 +1025,9 @@ export class Machine {
|
|
|
988
1025
|
this.pushEvent({
|
|
989
1026
|
type: "daemon_recovered",
|
|
990
1027
|
targetAgent: "*",
|
|
991
|
-
detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)
|
|
1028
|
+
detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)` +
|
|
1029
|
+
(skipped ? `; skipped ${skipped} unrecognized journal entr${skipped === 1 ? "y" : "ies"} (newer format?)` : ""),
|
|
992
1030
|
});
|
|
993
|
-
return { kept, releasedDead, queued };
|
|
1031
|
+
return { kept, releasedDead, queued, skipped };
|
|
994
1032
|
}
|
|
995
1033
|
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* load-lock MCP server (stdio) — the universal adapter for harnesses that
|
|
3
3
|
* speak MCP (Claude Code, Codex CLI, Gemini CLI, Copilot CLI, Cline, Roo,
|
|
4
|
-
* Goose, ...). Each harness session spawns one MCP server process
|
|
5
|
-
*
|
|
6
|
-
* watch
|
|
4
|
+
* Goose, ...). Each harness session spawns one MCP server process per
|
|
5
|
+
* registration; the session-derived agent identity (issue #50, see
|
|
6
|
+
* shared/identity.ts) routes offers back through tool results and watch
|
|
7
|
+
* pushes, and is shared by every registration in the same session.
|
|
7
8
|
*
|
|
8
9
|
* Notification honesty (see docs/COMPATIBILITY.md): MCP transports can
|
|
9
10
|
* surface server-initiated *messages*, but most harnesses do not inject them
|