@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.
- package/README.md +61 -14
- package/dist/adapters/opencode/plugin.js +15 -1
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +3 -10
- package/dist/cli/copies.js +32 -44
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +68 -14
- package/dist/cli/main.js +108 -211
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +57 -0
- package/dist/cli/update.js +287 -0
- 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/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +11 -3
- package/dist/mcp/server.js +38 -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 +16 -4
- package/dist/shared/drift.d.ts +23 -0
- package/dist/shared/drift.js +95 -0
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +25 -0
- package/dist/shared/pkginfo.js +70 -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/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -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.d.ts +1 -0
- package/dist/test/update-command.test.js +679 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +48 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +156 -13
- package/docs/OPERATIONS.md +407 -21
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
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/dashboard/feed.d.ts
CHANGED
|
@@ -18,6 +18,19 @@ export interface FeedOptions {
|
|
|
18
18
|
inspectEveryMs?: number;
|
|
19
19
|
/** Reconnect backoff base (default 250 ms). */
|
|
20
20
|
reconnectBaseMs?: number;
|
|
21
|
+
/**
|
|
22
|
+
* How often to re-check install drift between this running copy and the
|
|
23
|
+
* PATH-resolved `load-lock` CLI (default 15_000 ms; 0 disables). A harness
|
|
24
|
+
* that loads plugin code once at startup keeps running the OLD copy after
|
|
25
|
+
* an update — this makes that visible on the dashboard (issue #39).
|
|
26
|
+
*/
|
|
27
|
+
driftCheckMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Wording of the drift hint: "opencode" says "restart opencode to pick up
|
|
30
|
+
* vX" (the plugin cannot hot-reload), "cli" (default) says the running
|
|
31
|
+
* copy is stale relative to the PATH CLI.
|
|
32
|
+
*/
|
|
33
|
+
driftSurface?: "cli" | "opencode";
|
|
21
34
|
}
|
|
22
35
|
export declare class DashboardFeed {
|
|
23
36
|
private client;
|
|
@@ -33,10 +46,14 @@ export declare class DashboardFeed {
|
|
|
33
46
|
private daemonStartedAt;
|
|
34
47
|
private refreshTimer;
|
|
35
48
|
private slowTimer;
|
|
49
|
+
private driftTimer;
|
|
50
|
+
private drift;
|
|
36
51
|
private stopped;
|
|
37
52
|
private unsub;
|
|
38
53
|
private connecting;
|
|
39
54
|
private viewSeq;
|
|
55
|
+
/** daemonVersion seen at the previous resync — powers the "restarted onto" note. */
|
|
56
|
+
private lastDaemonVersion;
|
|
40
57
|
/** Fired whenever the rendered view might have changed (state or data). */
|
|
41
58
|
onChange?: () => void;
|
|
42
59
|
/** Fired on connection-state transitions. */
|
|
@@ -69,6 +86,12 @@ export declare class DashboardFeed {
|
|
|
69
86
|
* Returns the reclaimed request's one-line description for toasts.
|
|
70
87
|
*/
|
|
71
88
|
forceRelease(requestId: string): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Re-probe install drift (running copy vs PATH-resolved CLI) and surface
|
|
91
|
+
* it on the next view. Public so surfaces can trigger it on demand
|
|
92
|
+
* (e.g. right after a plugin's panel regains focus).
|
|
93
|
+
*/
|
|
94
|
+
refreshDrift(): void;
|
|
72
95
|
/** Render-ready snapshot; countdowns are computed at `now`. */
|
|
73
96
|
view(now?: number): DashboardView;
|
|
74
97
|
}
|
package/dist/dashboard/feed.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { buildDashboardView } from "./view.js";
|
|
2
|
+
import { detectInstallDrift } from "../shared/installs.js";
|
|
2
3
|
const DEFAULTS = {
|
|
3
4
|
eventHistory: 60,
|
|
4
5
|
slowRefreshMs: 15_000,
|
|
5
6
|
refreshDebounceMs: 40,
|
|
6
7
|
inspectEveryMs: 60_000,
|
|
7
8
|
reconnectBaseMs: 250,
|
|
9
|
+
driftCheckMs: 15_000,
|
|
10
|
+
driftSurface: "cli",
|
|
8
11
|
};
|
|
9
12
|
export class DashboardFeed {
|
|
10
13
|
client;
|
|
@@ -20,10 +23,14 @@ export class DashboardFeed {
|
|
|
20
23
|
daemonStartedAt = 0;
|
|
21
24
|
refreshTimer = null;
|
|
22
25
|
slowTimer = null;
|
|
26
|
+
driftTimer = null;
|
|
27
|
+
drift = null;
|
|
23
28
|
stopped = false;
|
|
24
29
|
unsub = null;
|
|
25
30
|
connecting = null;
|
|
26
31
|
viewSeq = 0;
|
|
32
|
+
/** daemonVersion seen at the previous resync — powers the "restarted onto" note. */
|
|
33
|
+
lastDaemonVersion;
|
|
27
34
|
/** Fired whenever the rendered view might have changed (state or data). */
|
|
28
35
|
onChange;
|
|
29
36
|
/** Fired on connection-state transitions. */
|
|
@@ -63,7 +70,9 @@ export class DashboardFeed {
|
|
|
63
70
|
typeof this.client.onDisconnect === "function"
|
|
64
71
|
? (this.client.onDisconnect(() => void this.handleDisconnect()) ?? undefined)
|
|
65
72
|
: null;
|
|
66
|
-
|
|
73
|
+
// Timers are installed BEFORE the first connect: a failed initial
|
|
74
|
+
// connect must not leave the feed without its slow-refresh/drift
|
|
75
|
+
// timers once the reconnect loop eventually recovers (issue #39 H3).
|
|
67
76
|
this.slowTimer =
|
|
68
77
|
this.opts.slowRefreshMs > 0
|
|
69
78
|
? setInterval(() => {
|
|
@@ -72,6 +81,27 @@ export class DashboardFeed {
|
|
|
72
81
|
}, this.opts.slowRefreshMs)
|
|
73
82
|
: null;
|
|
74
83
|
this.slowTimer?.unref?.();
|
|
84
|
+
this.driftTimer =
|
|
85
|
+
this.opts.driftCheckMs > 0
|
|
86
|
+
? setInterval(() => {
|
|
87
|
+
void this.refreshDrift();
|
|
88
|
+
}, this.opts.driftCheckMs)
|
|
89
|
+
: null;
|
|
90
|
+
this.driftTimer?.unref?.();
|
|
91
|
+
void this.refreshDrift();
|
|
92
|
+
try {
|
|
93
|
+
await this.connectAndSync();
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
// Issue #39 (hypothesis 3): previously a failed initial connect
|
|
97
|
+
// rejected start() with no socket ever attached, so no close event
|
|
98
|
+
// could ever fire handleDisconnect and the feed wedged in
|
|
99
|
+
// "connecting" forever. Enter the standard reconnect loop instead —
|
|
100
|
+
// the loop owns recovery, and start() still reports the failure.
|
|
101
|
+
this.setState("reconnecting");
|
|
102
|
+
void this.reconnectLoop();
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
75
105
|
}
|
|
76
106
|
async stop() {
|
|
77
107
|
this.stopped = true;
|
|
@@ -79,8 +109,11 @@ export class DashboardFeed {
|
|
|
79
109
|
clearTimeout(this.refreshTimer);
|
|
80
110
|
if (this.slowTimer)
|
|
81
111
|
clearInterval(this.slowTimer);
|
|
112
|
+
if (this.driftTimer)
|
|
113
|
+
clearInterval(this.driftTimer);
|
|
82
114
|
this.refreshTimer = null;
|
|
83
115
|
this.slowTimer = null;
|
|
116
|
+
this.driftTimer = null;
|
|
84
117
|
this.unsub?.();
|
|
85
118
|
this.unsub = null;
|
|
86
119
|
this.client.close();
|
|
@@ -112,12 +145,23 @@ export class DashboardFeed {
|
|
|
112
145
|
let restartNote;
|
|
113
146
|
if (daemonStartChanged || evRes.latestEventId < this.latestEventId) {
|
|
114
147
|
// Daemon restarted: ids restarted from scratch. Reset and re-pull.
|
|
115
|
-
|
|
148
|
+
const pid = status.daemon?.pid ?? "?";
|
|
149
|
+
const newVersion = status.daemon?.daemonVersion;
|
|
150
|
+
// Issue #39 (deliverable d): when the v1.3.0 handshake reports a
|
|
151
|
+
// DIFFERENT version than the daemon we were talking to, say which
|
|
152
|
+
// version the daemon restarted onto (e.g. after an update).
|
|
153
|
+
if (this.lastDaemonVersion && newVersion && newVersion !== this.lastDaemonVersion) {
|
|
154
|
+
restartNote = `daemon restarted onto v${newVersion} (was v${this.lastDaemonVersion}; pid ${pid}); event history re-synced`;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
restartNote = `daemon restarted (pid ${pid}); event history re-synced`;
|
|
158
|
+
}
|
|
116
159
|
this.events = [];
|
|
117
160
|
this.latestEventId = 0;
|
|
118
161
|
const fresh = await this.client.request("events", { sinceEventId: 0 });
|
|
119
162
|
this.mergeEvents(fresh.events, fresh.latestEventId);
|
|
120
163
|
}
|
|
164
|
+
this.lastDaemonVersion = status.daemon?.daemonVersion;
|
|
121
165
|
this.restartNote = restartNote;
|
|
122
166
|
if (this.inspectAt === 0 || Date.now() - this.inspectAt > this.opts.inspectEveryMs) {
|
|
123
167
|
await this.refreshInspect();
|
|
@@ -240,6 +284,22 @@ export class DashboardFeed {
|
|
|
240
284
|
await this.client.request("admin_release", { requestId });
|
|
241
285
|
await this.refresh().catch(() => { });
|
|
242
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Re-probe install drift (running copy vs PATH-resolved CLI) and surface
|
|
289
|
+
* it on the next view. Public so surfaces can trigger it on demand
|
|
290
|
+
* (e.g. right after a plugin's panel regains focus).
|
|
291
|
+
*/
|
|
292
|
+
refreshDrift() {
|
|
293
|
+
if (this.stopped || this.opts.driftCheckMs === 0)
|
|
294
|
+
return;
|
|
295
|
+
try {
|
|
296
|
+
this.drift = detectInstallDrift();
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
this.drift = null;
|
|
300
|
+
}
|
|
301
|
+
this.emitChange();
|
|
302
|
+
}
|
|
243
303
|
/** Render-ready snapshot; countdowns are computed at `now`. */
|
|
244
304
|
view(now = Date.now()) {
|
|
245
305
|
return buildDashboardView({
|
|
@@ -250,6 +310,8 @@ export class DashboardFeed {
|
|
|
250
310
|
state: this.state,
|
|
251
311
|
lastSyncAt: this.lastSyncAt,
|
|
252
312
|
daemonRestartNote: this.restartNote,
|
|
313
|
+
installDrift: this.drift,
|
|
314
|
+
driftSurface: this.opts.driftSurface,
|
|
253
315
|
now,
|
|
254
316
|
});
|
|
255
317
|
}
|
package/dist/dashboard/frame.js
CHANGED
|
@@ -70,6 +70,8 @@ export function renderFrame(view, width = 100, height = 40) {
|
|
|
70
70
|
push(`note: ${view.daemonRestartNote}`);
|
|
71
71
|
if (view.versionSkewNote)
|
|
72
72
|
push(`note: ${view.versionSkewNote}`);
|
|
73
|
+
if (view.installDriftNote)
|
|
74
|
+
push(`note: ${view.installDriftNote}`);
|
|
73
75
|
if (view.machine) {
|
|
74
76
|
const m = view.machine;
|
|
75
77
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup ${m.cgroupVersion ?? ""} quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
package/dist/dashboard/view.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* "now" is always an explicit parameter, which makes countdowns testable.
|
|
10
10
|
*/
|
|
11
11
|
import type * as P from "../shared/protocol.js";
|
|
12
|
+
import type { InstallDrift } from "../shared/installs.js";
|
|
12
13
|
/** Connection state of the dashboard's daemon feed. */
|
|
13
14
|
export type FeedState = "connecting" | "live" | "reconnecting" | "down";
|
|
14
15
|
export interface HolderCard {
|
|
@@ -116,6 +117,12 @@ export interface DashboardView {
|
|
|
116
117
|
daemonRestartNote?: string;
|
|
117
118
|
/** Set when the running daemon is older than the installed copy. */
|
|
118
119
|
versionSkewNote?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Set when the RUNNING copy is older than the PATH-resolved CLI install
|
|
122
|
+
* (issue #39: harnesses like opencode load plugin code once and never
|
|
123
|
+
* hot-reload it, so an update leaves this session running stale code).
|
|
124
|
+
*/
|
|
125
|
+
installDriftNote?: string;
|
|
119
126
|
daemon: DaemonView | null;
|
|
120
127
|
pools: PoolView[];
|
|
121
128
|
events: EventLine[];
|
|
@@ -148,6 +155,10 @@ export interface DashboardInputs {
|
|
|
148
155
|
state: FeedState;
|
|
149
156
|
lastSyncAt: number;
|
|
150
157
|
daemonRestartNote?: string;
|
|
158
|
+
/** Running-copy vs PATH-install drift probe result (null = unknown). */
|
|
159
|
+
installDrift?: InstallDrift | null;
|
|
160
|
+
/** Wording hint for the drift note; the TUI plugin passes "opencode". */
|
|
161
|
+
driftSurface?: "cli" | "opencode";
|
|
151
162
|
now: number;
|
|
152
163
|
}
|
|
153
164
|
export declare function buildDashboardView(inps: DashboardInputs): DashboardView;
|
package/dist/dashboard/view.js
CHANGED
|
@@ -186,10 +186,23 @@ export function buildDashboardView(inps) {
|
|
|
186
186
|
: null;
|
|
187
187
|
// Newest first; keep the tail (feed already bounds history length).
|
|
188
188
|
const evs = [...inps.events].reverse();
|
|
189
|
+
// Issue #39: make in-memory staleness visible. A harness that loads
|
|
190
|
+
// plugin code once (opencode never hot-reloads plugins) keeps running
|
|
191
|
+
// the OLD copy after `npm i -g @aron98/load-lock@latest`; the panel can
|
|
192
|
+
// only hint at the recovery action, which differs per surface.
|
|
193
|
+
const drift = inps.installDrift;
|
|
194
|
+
let installDriftNote;
|
|
195
|
+
if (drift?.stale && drift.pathVersion) {
|
|
196
|
+
installDriftNote =
|
|
197
|
+
inps.driftSurface === "opencode"
|
|
198
|
+
? `plugin updated — restart opencode to pick up v${drift.pathVersion} (this session still runs v${drift.ownVersion}; opencode does not hot-reload plugins)`
|
|
199
|
+
: `PATH CLI v${drift.pathVersion} is newer than this copy v${drift.ownVersion} — you are running a stale install`;
|
|
200
|
+
}
|
|
189
201
|
return {
|
|
190
202
|
now,
|
|
191
203
|
state: inps.state,
|
|
192
204
|
daemonRestartNote: inps.daemonRestartNote,
|
|
205
|
+
installDriftNote,
|
|
193
206
|
versionSkewNote: d && typeof d.daemonVersion === "string" && compareVersions(d.daemonVersion, packageVersion()) < 0
|
|
194
207
|
? `daemon v${d.daemonVersion} is older than installed v${packageVersion()} — it self-restarts on newer-client contact`
|
|
195
208
|
: undefined,
|
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
|
|
@@ -16,11 +17,18 @@
|
|
|
16
17
|
*/
|
|
17
18
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
19
|
import { LLClient } from "../shared/client.js";
|
|
20
|
+
import { type CliDrift } from "../shared/drift.js";
|
|
19
21
|
export interface McpServerOptions {
|
|
20
22
|
client: LLClient;
|
|
21
23
|
serverName?: string;
|
|
22
24
|
/** Overridden by OpenCode's per-request session metadata when present. */
|
|
23
25
|
agentId?: string;
|
|
26
|
+
/**
|
|
27
|
+
* CLI-drift notice to surface (issue #38). Defaults to auto-detection at
|
|
28
|
+
* build time (this adapter copy vs. the PATH-resolved CLI copy);
|
|
29
|
+
* pass null to suppress / a value to inject in tests.
|
|
30
|
+
*/
|
|
31
|
+
drift?: CliDrift | null;
|
|
24
32
|
}
|
|
25
33
|
export declare function buildServer(opts: McpServerOptions): McpServer;
|
|
26
34
|
export declare function runStdioMcp(opts?: {
|
package/dist/mcp/server.js
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
|
|
@@ -19,10 +20,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
19
20
|
import { z } from "zod";
|
|
20
21
|
import { spawn } from "node:child_process";
|
|
21
22
|
import { LLClient } from "../shared/client.js";
|
|
22
|
-
import { POLICY_BRIEF } from "../policy/text.js";
|
|
23
|
+
import { POLICY_BRIEF, SETUP_HINT } from "../policy/text.js";
|
|
23
24
|
import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
|
|
24
25
|
import { waitForLocalJob, waitForTrackedJob } from "../shared/wait-completion.js";
|
|
25
26
|
import { killProcessGroup } from "../shared/transport.js";
|
|
27
|
+
import { detectCliDrift } from "../shared/drift.js";
|
|
28
|
+
import { packageVersion } from "../shared/version.js";
|
|
29
|
+
import { sessionIdentity } from "../shared/identity.js";
|
|
30
|
+
import { agentGuideText } from "../shared/agent-guide.js";
|
|
26
31
|
const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
|
|
27
32
|
|
|
28
33
|
Pass a short reason and a stable op_key identifying the pending operation (duplicate acquires for the same op_key return the existing request — do not invent a new key while one is live). Set wait_ms (default a few seconds) to block briefly for capacity instead of returning queued immediately; keep doing light work while queued. You will receive an offer to claim when capacity frees. Never start heavy work while merely queued.`;
|
|
@@ -30,8 +35,19 @@ const CLAIM_DESC = `Claim an offered permit within its claim window (offers expi
|
|
|
30
35
|
const RELEASE_DESC = `Release a held permit immediately after the heavy work finishes — success, failure, or termination (idempotent: releasing twice is fine). A permit survives the tool call and session that acquired it; automatic cleanup releases it when a tracked job exits or, for untracked permits, when your adapter stops heartbeating — but do not rely on that: release as soon as the work is done.`;
|
|
31
36
|
export function buildServer(opts) {
|
|
32
37
|
const client = opts.client;
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
// Session-derived (issue #50): two registrations in one harness session
|
|
39
|
+
// (plugin-bundled + standalone) resolve to the same id; the per-process
|
|
40
|
+
// random id remains the fallback when no session can be found.
|
|
41
|
+
const defaultAgent = opts.agentId ??
|
|
42
|
+
sessionIdentity({ fallback: "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8) }).id;
|
|
43
|
+
// Drift notice (issue #38): one clear notice naming the exact command when
|
|
44
|
+
// this adapter copy is newer than the PATH-installed CLI. Notify only —
|
|
45
|
+
// never auto-install. Printed to stderr at init and embedded in the
|
|
46
|
+
// initialize instructions so any MCP client sees it exactly once.
|
|
47
|
+
const drift = opts.drift !== undefined ? opts.drift : detectCliDrift(packageVersion());
|
|
48
|
+
if (drift)
|
|
49
|
+
process.stderr.write(drift.message + "\n");
|
|
50
|
+
const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF + "\n\n" + SETUP_HINT + (drift ? "\n\n" + drift.message : "") });
|
|
35
51
|
const json = (v) => ({
|
|
36
52
|
content: [{ type: "text", text: JSON.stringify(v, null, 2) }],
|
|
37
53
|
});
|
|
@@ -205,6 +221,20 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
|
|
|
205
221
|
description: "Current config: pools/capacity, offer timeout, heavy-work guidance, and change history with rationale.",
|
|
206
222
|
inputSchema: {},
|
|
207
223
|
}, async () => json(await client.request("get_config", {})));
|
|
224
|
+
// Issue #61: a plugin-only install may have neither the skill nor the CLI
|
|
225
|
+
// on PATH, so the policy brief's pointer to the detailed guide lands here.
|
|
226
|
+
server.registerTool("agent_guide", {
|
|
227
|
+
title: "Full agent guide",
|
|
228
|
+
description: "The detailed load-lock playbook (same text as `load-lock agent-guide`): heavy-vs-light heuristics, offer claiming, background jobs, configuration and recovery. Read it when the short policy is not enough. No daemon call.",
|
|
229
|
+
inputSchema: {},
|
|
230
|
+
}, async () => {
|
|
231
|
+
try {
|
|
232
|
+
return { content: [{ type: "text", text: agentGuideText() }] };
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return { content: [{ type: "text", text: POLICY_BRIEF }] };
|
|
236
|
+
}
|
|
237
|
+
});
|
|
208
238
|
server.registerTool("configure", {
|
|
209
239
|
title: "Update config (CAS)",
|
|
210
240
|
description: "Compare-and-swap config update so concurrent agents never silently overwrite each other. Read get_config, pass its version as expected_version, describe changes and a reason. Capacity decreases let current holders finish; increases admit queued requests immediately.",
|
|
@@ -468,6 +498,8 @@ Input: job_id OR request_id (one of them), timeout_ms (default 600000, clamped t
|
|
|
468
498
|
// Automatic adapter heartbeat: while this server process lives, untracked
|
|
469
499
|
// permits of this agent keep their lease; if the harness session dies, the
|
|
470
500
|
// lease lapses and the daemon reclaims. The model never has to remember.
|
|
501
|
+
// Servers sharing a session identity each renew the same lease; heartbeats
|
|
502
|
+
// are idempotent, so that is harmless.
|
|
471
503
|
const heartbeat = setInterval(() => {
|
|
472
504
|
void client.request("heartbeat", { agentId: defaultAgent }).catch(() => {
|
|
473
505
|
/* daemon unreachable; next tool call reconnects */
|
package/dist/policy/text.d.ts
CHANGED
|
@@ -4,14 +4,24 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - OpenCode plugin: injected via session "context" hook (every model call,
|
|
6
6
|
* compaction-proof) + registered skill for detail.
|
|
7
|
-
* - Claude Code: SessionStart hook stdout (matcher startup|resume|clear|compact|fork)
|
|
7
|
+
* - Claude Code: SessionStart hook stdout (matcher startup|resume|clear|compact|fork),
|
|
8
|
+
* from the loose install or the load-lock-plugin Claude Code plugin.
|
|
8
9
|
* - Codex CLI: SessionStart/SubagentStart hook stdout (needs /hooks trust).
|
|
9
10
|
* - MCP server: `instructions` field on every harness that surfaces it.
|
|
11
|
+
* - Skill only (ladder rung 0, docs/OPERATIONS.md): no brief is injected;
|
|
12
|
+
* skills/load-lock/SKILL.md carries its own version of these rules plus the
|
|
13
|
+
* MCP -> PATH -> pinned-npx bootstrap (docs/AGENT_GUIDE.md section 0).
|
|
10
14
|
*
|
|
11
15
|
* Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
|
|
12
|
-
* in
|
|
16
|
+
* in docs/AGENT_GUIDE.md, served by the MCP agent_guide tool, the skill and
|
|
17
|
+
* `load-lock agent-guide`.
|
|
13
18
|
*/
|
|
14
|
-
export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n Out of light work while it runs? Sleep honestly with wait_for_completion\n (MCP/plugin: run_with_permit job_id or tracked request_id; or run_with_permit\n with wait:true) or 'load-lock wait --job <request-id>' (CLI) \u2014 a bounded\n sleep that wakes promptly when the job finishes (exit code + output tail;\n the permit is freed before you are woken). On still_running it is a\n checkpoint: call again. Do NOT guess sleeps or poll job_status in a loop.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the \"load-lock\" skill or `load-lock agent-guide
|
|
19
|
+
export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n Out of light work while it runs? Sleep honestly with wait_for_completion\n (MCP/plugin: run_with_permit job_id or tracked request_id; or run_with_permit\n with wait:true) or 'load-lock wait --job <request-id>' (CLI) \u2014 a bounded\n sleep that wakes promptly when the job finishes (exit code + output tail;\n the permit is freed before you are woken). On still_running it is a\n checkpoint: call again. Do NOT guess sleeps or poll job_status in a loop.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the load-lock agent_guide tool (MCP/plugin), else the\n\"load-lock\" skill or `load-lock agent-guide` (CLI) where installed.";
|
|
20
|
+
/**
|
|
21
|
+
* The one upgrade hint every rung carries (#47 ladder, #59): appended after
|
|
22
|
+
* the brief in the MCP instructions and the session-start hook output.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SETUP_HINT = "Wiring: `load-lock setup` lists this machine's load-lock integrations per harness; `load-lock setup --yes` converges them to the recommended layout (hooks for idle wakeups, policy that survives compaction).";
|
|
15
25
|
export declare function policyWithContext(statusSummary: string, configSummary?: string): string;
|
|
16
26
|
export declare function statusLine(st: {
|
|
17
27
|
pools: {
|