@aron98/load-lock 0.1.2 → 1.1.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 +40 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +61 -0
- package/dist/adapters/opencode/tui.js +358 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +708 -24
- package/dist/cli/main.js +130 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +99 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +13 -0
- package/dist/dashboard/frame.js +95 -0
- package/dist/dashboard/view.d.ts +149 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +99 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +406 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/tui.test.d.ts +1 -0
- package/dist/test/tui.test.js +89 -0
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +34 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +217 -26
- package/docs/OPERATIONS.md +335 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ---------------------------------------------------------------- formatters
|
|
2
|
+
/** Compact human duration: "412ms", "3s", "4m 12s", "2h 05m", "3d 4h". */
|
|
3
|
+
export function fmtDuration(ms) {
|
|
4
|
+
if (!Number.isFinite(ms))
|
|
5
|
+
return "–";
|
|
6
|
+
const neg = ms < 0;
|
|
7
|
+
let s = Math.floor(Math.abs(ms) / 1000);
|
|
8
|
+
let out;
|
|
9
|
+
if (s < 1)
|
|
10
|
+
out = `${Math.max(0, Math.round(Math.abs(ms)))}ms`;
|
|
11
|
+
else if (s < 60)
|
|
12
|
+
out = `${s}s`;
|
|
13
|
+
else if (s < 3600)
|
|
14
|
+
out = `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
|
|
15
|
+
else if (s < 86400)
|
|
16
|
+
out = `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
|
|
17
|
+
else
|
|
18
|
+
out = `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
|
|
19
|
+
return neg ? `-${out}` : out;
|
|
20
|
+
}
|
|
21
|
+
/** Byte counts: "12.4Gi", "512Mi", "3.4Ki". */
|
|
22
|
+
export function fmtBytes(bytes) {
|
|
23
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
24
|
+
return "–";
|
|
25
|
+
const units = ["B", "Ki", "Mi", "Gi", "Ti"];
|
|
26
|
+
let v = bytes;
|
|
27
|
+
let u = 0;
|
|
28
|
+
while (v >= 1024 && u < units.length - 1) {
|
|
29
|
+
v /= 1024;
|
|
30
|
+
u++;
|
|
31
|
+
}
|
|
32
|
+
return `${v >= 100 || u === 0 ? Math.round(v) : v.toFixed(1)}${units[u]}`;
|
|
33
|
+
}
|
|
34
|
+
/** Local clock time HH:MM:SS. */
|
|
35
|
+
export function fmtClock(ts) {
|
|
36
|
+
const d = new Date(ts);
|
|
37
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
|
|
38
|
+
}
|
|
39
|
+
/** One-line label for a permit phase. */
|
|
40
|
+
export function phaseLabel(p) {
|
|
41
|
+
switch (p) {
|
|
42
|
+
case "claimed":
|
|
43
|
+
return "claimed (must start)";
|
|
44
|
+
case "running_tracked":
|
|
45
|
+
return "running (tracked)";
|
|
46
|
+
case "running_untracked":
|
|
47
|
+
return "running (untracked)";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Truncate with ellipsis, keeping the head (ids, reasons). */
|
|
51
|
+
export function trunc(s, max) {
|
|
52
|
+
if (s.length <= max)
|
|
53
|
+
return s;
|
|
54
|
+
return s.slice(0, Math.max(1, max - 1)) + "…";
|
|
55
|
+
}
|
|
56
|
+
export function agentName(e) {
|
|
57
|
+
return e.agentLabel || e.agentId;
|
|
58
|
+
}
|
|
59
|
+
// -------------------------------------------------------------- event text
|
|
60
|
+
/** One-line human sentence for a daemon event (durable ids are kept). */
|
|
61
|
+
export function eventText(ev) {
|
|
62
|
+
const who = ev.entry ? agentName(ev.entry) : ev.targetAgent && ev.targetAgent !== "*" ? ev.targetAgent : undefined;
|
|
63
|
+
const reason = ev.entry?.reason ? ` (${trunc(ev.entry.reason, 48)})` : "";
|
|
64
|
+
const w = who ? ` ${who}` : "";
|
|
65
|
+
const detail = ev.detail ? ` — ${ev.detail}` : "";
|
|
66
|
+
switch (ev.type) {
|
|
67
|
+
case "daemon_started":
|
|
68
|
+
return `daemon started${detail}`;
|
|
69
|
+
case "daemon_recovered":
|
|
70
|
+
return `daemon recovered after restart${detail}`;
|
|
71
|
+
case "granted":
|
|
72
|
+
return `granted ${ev.pool ?? ""}${w}${reason}`;
|
|
73
|
+
case "queued":
|
|
74
|
+
return `queued${w}${reason}`;
|
|
75
|
+
case "offered":
|
|
76
|
+
return `offered ${ev.pool ?? ""}${w} — awaiting claim${reason}`;
|
|
77
|
+
case "claimed":
|
|
78
|
+
return `claimed${w} — permit held${reason}`;
|
|
79
|
+
case "started":
|
|
80
|
+
return `started${w}: ${ev.detail ?? "execution began"}`;
|
|
81
|
+
case "released":
|
|
82
|
+
return `released ${ev.pool ?? ""}${w}${reason}${detail}`;
|
|
83
|
+
case "auto_released":
|
|
84
|
+
return `auto-released ${ev.pool ?? ""}${w}${detail}${reason}`;
|
|
85
|
+
case "reclaimed":
|
|
86
|
+
return `reclaimed ${ev.pool ?? ""}${w} — ${ev.detail ?? "permit revoked"}${reason}`;
|
|
87
|
+
case "offer_expired":
|
|
88
|
+
return `offer expired ${ev.pool ?? ""}${w} → paused (rejoin to requeue)`;
|
|
89
|
+
case "declined":
|
|
90
|
+
return `declined offer${w}${reason}`;
|
|
91
|
+
case "paused":
|
|
92
|
+
return `paused${w}${reason}`;
|
|
93
|
+
case "rejoined":
|
|
94
|
+
return `rejoined queue${w}${reason}`;
|
|
95
|
+
case "cancelled":
|
|
96
|
+
return `cancelled${w}${reason}`;
|
|
97
|
+
case "config_changed":
|
|
98
|
+
return `config changed: ${ev.detail ?? ""}`.trim();
|
|
99
|
+
default:
|
|
100
|
+
return `${String(ev.type)}${w}${detail}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function holderCard(e, now) {
|
|
104
|
+
const phase = e.phase ?? "claimed";
|
|
105
|
+
return {
|
|
106
|
+
requestId: e.requestId,
|
|
107
|
+
pool: e.pool,
|
|
108
|
+
agent: agentName(e),
|
|
109
|
+
agentId: e.agentId,
|
|
110
|
+
reason: e.reason,
|
|
111
|
+
weight: e.weight,
|
|
112
|
+
phase,
|
|
113
|
+
phaseLabel: phaseLabel(phase),
|
|
114
|
+
pid: e.pid,
|
|
115
|
+
pgid: e.pgid,
|
|
116
|
+
heldMs: now - e.since,
|
|
117
|
+
startDeadlineInMs: e.startDeadline !== undefined ? e.startDeadline - now : undefined,
|
|
118
|
+
leaseDeadlineInMs: e.leaseDeadline !== undefined ? e.leaseDeadline - now : undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function buildDashboardView(inps) {
|
|
122
|
+
const now = inps.now;
|
|
123
|
+
const pools = [];
|
|
124
|
+
for (const p of inps.status?.pools ?? []) {
|
|
125
|
+
const heldWeight = p.holders.reduce((a, h) => a + h.weight, 0);
|
|
126
|
+
const offeredWeight = p.offers.reduce((a, o) => a + o.weight, 0);
|
|
127
|
+
pools.push({
|
|
128
|
+
pool: p.pool,
|
|
129
|
+
capacity: p.capacity,
|
|
130
|
+
used: p.used,
|
|
131
|
+
heldWeight,
|
|
132
|
+
offeredWeight,
|
|
133
|
+
free: Math.max(0, p.capacity - p.used),
|
|
134
|
+
utilization: p.capacity > 0 ? Math.min(1, p.used / p.capacity) : 1,
|
|
135
|
+
holders: p.holders.map((h) => holderCard(h, now)),
|
|
136
|
+
offers: p.offers.map((o) => ({
|
|
137
|
+
requestId: o.requestId,
|
|
138
|
+
pool: o.pool,
|
|
139
|
+
agent: agentName(o),
|
|
140
|
+
agentId: o.agentId,
|
|
141
|
+
reason: o.reason,
|
|
142
|
+
weight: o.weight,
|
|
143
|
+
claimInMs: (o.offerExpiresAt ?? 0) - now,
|
|
144
|
+
expiresAt: o.offerExpiresAt ?? 0,
|
|
145
|
+
})),
|
|
146
|
+
queue: p.queue.map((q, i) => ({
|
|
147
|
+
requestId: q.requestId,
|
|
148
|
+
pool: q.pool,
|
|
149
|
+
agent: agentName(q),
|
|
150
|
+
agentId: q.agentId,
|
|
151
|
+
reason: q.reason,
|
|
152
|
+
weight: q.weight,
|
|
153
|
+
position: q.position ?? i + 1,
|
|
154
|
+
waitMs: now - q.since,
|
|
155
|
+
})),
|
|
156
|
+
paused: p.paused.map((q) => ({
|
|
157
|
+
requestId: q.requestId,
|
|
158
|
+
pool: q.pool,
|
|
159
|
+
agent: agentName(q),
|
|
160
|
+
agentId: q.agentId,
|
|
161
|
+
reason: q.reason,
|
|
162
|
+
weight: q.weight,
|
|
163
|
+
pausedMs: now - q.since,
|
|
164
|
+
})),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const d = inps.status?.daemon;
|
|
168
|
+
const machine = inps.inspect
|
|
169
|
+
? {
|
|
170
|
+
cpuModel: inps.inspect.cpu.model,
|
|
171
|
+
logicalCores: inps.inspect.cpu.logicalCores,
|
|
172
|
+
effectiveCores: inps.inspect.cpu.effectiveCores,
|
|
173
|
+
cgroupVersion: inps.inspect.cpu.cgroupVersion,
|
|
174
|
+
cgroupCpuQuota: inps.inspect.cpu.cgroupCpuQuota,
|
|
175
|
+
loadavg: inps.inspect.cpu.loadavg,
|
|
176
|
+
loadPerEffectiveCore: inps.inspect.cpu.loadPerEffectiveCore,
|
|
177
|
+
memTotalBytes: inps.inspect.memory.totalBytes,
|
|
178
|
+
memAvailableBytes: inps.inspect.memory.availableBytes,
|
|
179
|
+
memEffectiveLimitBytes: inps.inspect.memory.effectiveLimitBytes,
|
|
180
|
+
loadPressure: inps.inspect.pressure.loadPressure,
|
|
181
|
+
memPressure: inps.inspect.pressure.memPressure,
|
|
182
|
+
note: inps.inspect.pressure.note,
|
|
183
|
+
suggestion: inps.inspect.loadLock.suggestion,
|
|
184
|
+
}
|
|
185
|
+
: null;
|
|
186
|
+
// Newest first; keep the tail (feed already bounds history length).
|
|
187
|
+
const evs = [...inps.events].reverse();
|
|
188
|
+
return {
|
|
189
|
+
now,
|
|
190
|
+
state: inps.state,
|
|
191
|
+
daemonRestartNote: inps.daemonRestartNote,
|
|
192
|
+
daemon: d
|
|
193
|
+
? {
|
|
194
|
+
pid: d.pid,
|
|
195
|
+
startedAt: d.startedAt,
|
|
196
|
+
uptimeMs: now - d.startedAt,
|
|
197
|
+
protocolVersion: d.protocolVersion,
|
|
198
|
+
configVersion: inps.status?.config.version ?? 0,
|
|
199
|
+
}
|
|
200
|
+
: null,
|
|
201
|
+
pools,
|
|
202
|
+
events: evs.map((e) => ({
|
|
203
|
+
id: e.id,
|
|
204
|
+
ts: e.ts,
|
|
205
|
+
agoMs: now - e.ts,
|
|
206
|
+
type: e.type,
|
|
207
|
+
pool: e.pool,
|
|
208
|
+
text: eventText(e),
|
|
209
|
+
})),
|
|
210
|
+
latestEventId: inps.latestEventId,
|
|
211
|
+
machine,
|
|
212
|
+
lastSyncAt: inps.lastSyncAt,
|
|
213
|
+
};
|
|
214
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* into the model's context. load-lock therefore relies on:
|
|
11
11
|
* (a) tool-boundary delivery — every acquire/claim/release/... response
|
|
12
12
|
* carries the agent's pending offers and queue positions;
|
|
13
|
-
* (b) bounded blocking wait in acquire (waitMs)
|
|
13
|
+
* (b) bounded blocking wait in acquire (waitMs) and wait_for_turn
|
|
14
|
+
* (the "sleep until it is my turn" tool);
|
|
14
15
|
* (c) harness-specific idle wakeup (hooks/plugins) where verified.
|
|
15
16
|
*/
|
|
16
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
package/dist/mcp/server.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* into the model's context. load-lock therefore relies on:
|
|
11
11
|
* (a) tool-boundary delivery — every acquire/claim/release/... response
|
|
12
12
|
* carries the agent's pending offers and queue positions;
|
|
13
|
-
* (b) bounded blocking wait in acquire (waitMs)
|
|
13
|
+
* (b) bounded blocking wait in acquire (waitMs) and wait_for_turn
|
|
14
|
+
* (the "sleep until it is my turn" tool);
|
|
14
15
|
* (c) harness-specific idle wakeup (hooks/plugins) where verified.
|
|
15
16
|
*/
|
|
16
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -19,6 +20,8 @@ import { z } from "zod";
|
|
|
19
20
|
import { spawn } from "node:child_process";
|
|
20
21
|
import { LLClient } from "../shared/client.js";
|
|
21
22
|
import { POLICY_BRIEF } from "../policy/text.js";
|
|
23
|
+
import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
|
|
24
|
+
import { killProcessGroup } from "../shared/transport.js";
|
|
22
25
|
const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
|
|
23
26
|
|
|
24
27
|
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.`;
|
|
@@ -134,6 +137,51 @@ export function buildServer(opts) {
|
|
|
134
137
|
entry: res.entry,
|
|
135
138
|
});
|
|
136
139
|
});
|
|
140
|
+
server.registerTool("wait_for_turn", {
|
|
141
|
+
title: "Sleep until it is your turn",
|
|
142
|
+
description: `Bounded blocking sleep for one of your queued/offered/paused requests: "sleep until it is my turn". Returns a STRUCTURED result on every path — never an error for a normal resolution:
|
|
143
|
+
- granted: capacity reached you and the offer was AUTO-CLAIMED on your behalf (permit_token in entry, auto_claimed: true) — start the heavy work now;
|
|
144
|
+
- still_queued: timed out; you are still queued — queue_position, queue_length and holders (who is ahead of you) included; call wait_for_turn again to keep sleeping;
|
|
145
|
+
- paused: the offer expired or was declined meanwhile; next_action has rejoin instructions;
|
|
146
|
+
- cancelled: the request was cancelled/declined/reclaimed meanwhile;
|
|
147
|
+
- not_found: the request ended or the daemon restarted (queues are ephemeral); acquire again.
|
|
148
|
+
Input: request_id (default: your oldest offered, then front-most queued, then oldest paused request), timeout_ms (default 600000, clamped to the machine's maxTurnWaitMs config — get_config shows the cap). timeout_ms 0 = check-only. The wait holds NO permit and NO capacity (it is light work); cancelling this call is safe and never strands anything — you simply stop sleeping and the request keeps its queue position. A daemon restart mid-wait resolves promptly with not_found + re-acquire instructions.`,
|
|
149
|
+
inputSchema: {
|
|
150
|
+
request_id: z.string().optional().describe("Request to watch (default: your oldest offered/front-most queued/oldest paused request)"),
|
|
151
|
+
timeout_ms: z.number().int().optional().describe("Bounded sleep (ms); default 600000, clamped to the machine's maxTurnWaitMs cap. 0 = check-only."),
|
|
152
|
+
agent_id: z.string().optional().describe("Override agent identity (rarely needed)"),
|
|
153
|
+
},
|
|
154
|
+
}, async (input, extra) => {
|
|
155
|
+
try {
|
|
156
|
+
const r = await waitForTurn({
|
|
157
|
+
client,
|
|
158
|
+
agentId: agentIdOf(input),
|
|
159
|
+
requestId: input.request_id,
|
|
160
|
+
timeoutMs: input.timeout_ms,
|
|
161
|
+
signal: extra?.signal,
|
|
162
|
+
});
|
|
163
|
+
return json({
|
|
164
|
+
state: r.state,
|
|
165
|
+
request_id: r.requestId,
|
|
166
|
+
auto_claimed: r.autoClaimed,
|
|
167
|
+
permit_token: r.state === "granted" ? r.entry?.permitToken : undefined,
|
|
168
|
+
waited_ms: r.waitedMs,
|
|
169
|
+
effective_wait_ms: r.effectiveWaitMs,
|
|
170
|
+
entry: r.entry,
|
|
171
|
+
queue_position: r.queuePosition,
|
|
172
|
+
queue_length: r.queueLength,
|
|
173
|
+
holders: r.holders,
|
|
174
|
+
next_action: r.nextAction,
|
|
175
|
+
detail: r.detail,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (err instanceof WaitAbortedError) {
|
|
180
|
+
return json({ state: "aborted", detail: "wait cancelled by the harness/caller; the request keeps its queue position" });
|
|
181
|
+
}
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
137
185
|
server.registerTool("pending", {
|
|
138
186
|
title: "Your offers/queue/permits",
|
|
139
187
|
description: "List your outstanding offers (claim these NOW — they expire), queue positions, paused and held requests.",
|
|
@@ -164,6 +212,7 @@ export function buildServer(opts) {
|
|
|
164
212
|
pools: z.record(z.string(), z.object({ capacity: z.number().int() })).optional(),
|
|
165
213
|
offer_timeout_ms: z.number().int().optional(),
|
|
166
214
|
max_wait_ms: z.number().int().optional(),
|
|
215
|
+
max_turn_wait_ms: z.number().int().optional(),
|
|
167
216
|
start_timeout_ms: z.number().int().optional(),
|
|
168
217
|
untracked_lease_ms: z.number().int().optional(),
|
|
169
218
|
guidance: z
|
|
@@ -185,6 +234,7 @@ export function buildServer(opts) {
|
|
|
185
234
|
...input.changes,
|
|
186
235
|
offerTimeoutMs: input.changes.offer_timeout_ms,
|
|
187
236
|
maxWaitMs: input.changes.max_wait_ms,
|
|
237
|
+
maxTurnWaitMs: input.changes.max_turn_wait_ms,
|
|
188
238
|
startTimeoutMs: input.changes.start_timeout_ms,
|
|
189
239
|
untrackedLeaseMs: input.changes.untracked_lease_ms,
|
|
190
240
|
guidance: input.changes.guidance
|
|
@@ -265,12 +315,7 @@ export function buildServer(opts) {
|
|
|
265
315
|
});
|
|
266
316
|
}
|
|
267
317
|
catch (err) {
|
|
268
|
-
|
|
269
|
-
process.kill(-child.pid, "SIGKILL");
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
/* already gone */
|
|
273
|
-
}
|
|
318
|
+
killProcessGroup(child.pid, "SIGKILL");
|
|
274
319
|
return json({
|
|
275
320
|
error: `permit no longer startable (${err.message}); job killed before doing work; acquire again`,
|
|
276
321
|
});
|
|
@@ -309,20 +354,10 @@ export function buildServer(opts) {
|
|
|
309
354
|
if (!job)
|
|
310
355
|
return json({ error: "unknown job_id" });
|
|
311
356
|
if (!job.exited) {
|
|
312
|
-
|
|
313
|
-
process.kill(-job.child.pid, "SIGTERM");
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
/* already gone */
|
|
317
|
-
}
|
|
357
|
+
killProcessGroup(job.child.pid, "SIGTERM");
|
|
318
358
|
setTimeout(() => {
|
|
319
359
|
if (!job.exited) {
|
|
320
|
-
|
|
321
|
-
process.kill(-job.child.pid, "SIGKILL");
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
/* already gone */
|
|
325
|
-
}
|
|
360
|
+
killProcessGroup(job.child.pid, "SIGKILL");
|
|
326
361
|
}
|
|
327
362
|
}, 5_000).unref?.();
|
|
328
363
|
}
|
package/dist/policy/text.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
|
|
12
12
|
* in the skill / docs/AGENT_GUIDE.md.
|
|
13
13
|
*/
|
|
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.\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.\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`.";
|
|
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.\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`.";
|
|
15
15
|
export declare function policyWithContext(statusSummary: string, configSummary?: string): string;
|
|
16
16
|
export declare function statusLine(st: {
|
|
17
17
|
pools: {
|
package/dist/policy/text.js
CHANGED
|
@@ -28,6 +28,9 @@ Rules:
|
|
|
28
28
|
3. If you are queued: keep working on light tasks. When you receive an offer
|
|
29
29
|
(in any load-lock tool result or a wakeup message), claim it promptly —
|
|
30
30
|
offers expire (default 30s) and capacity passes to the next waiter.
|
|
31
|
+
Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or
|
|
32
|
+
'load-lock wait' (CLI) — a bounded sleep until your turn that
|
|
33
|
+
auto-claims and holds no capacity.
|
|
31
34
|
4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work
|
|
32
35
|
on a "queued" result; never treat a notification as authorization.
|
|
33
36
|
5. Hold the permit until the heavy process actually finishes — including when
|
package/dist/shared/client.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare class LLClient {
|
|
|
8
8
|
private nextId;
|
|
9
9
|
private pending;
|
|
10
10
|
private eventCb;
|
|
11
|
+
/** Optional disconnect notifications (dashboard feeds and other watchers). */
|
|
12
|
+
private disconnectCbs;
|
|
11
13
|
private buf;
|
|
12
14
|
private opts;
|
|
13
15
|
constructor(opts?: ClientOptions);
|
|
@@ -16,12 +18,20 @@ export declare class LLClient {
|
|
|
16
18
|
connect(): Promise<void>;
|
|
17
19
|
private tryConnect;
|
|
18
20
|
private attach;
|
|
21
|
+
private fireDisconnect;
|
|
19
22
|
private failAll;
|
|
20
23
|
private ensureSock;
|
|
21
24
|
/** Issue one RPC. Reconnects once if the connection was lost. */
|
|
22
25
|
request<T = any>(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
|
|
23
26
|
/** Subscribe to daemon events targeted at agentId (or "*" for all). */
|
|
24
27
|
watch(agentId: string, onEvent: (ev: Record<string, unknown>) => void): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Register a callback fired whenever the daemon connection drops (socket
|
|
30
|
+
* closed or errored) — including after later reconnects; the callback
|
|
31
|
+
* stays registered until its unregister function is called. Dashboard
|
|
32
|
+
* feeds use this to reconnect and resume via the durable event cursor.
|
|
33
|
+
*/
|
|
34
|
+
onDisconnect(cb: () => void): () => void;
|
|
25
35
|
close(): void;
|
|
26
36
|
}
|
|
27
37
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
package/dist/shared/client.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* NDJSON
|
|
2
|
+
* NDJSON transport client for the load-lock daemon, shared by the CLI, the
|
|
3
3
|
* MCP server, and harness hook scripts. The OpenCode plugin ships a
|
|
4
4
|
* self-contained copy of this logic (see adapters/opencode).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Transport: Unix-domain socket on POSIX, named pipe (`\\.\pipe\load-lock`)
|
|
7
|
+
* on Windows — Node's `net` handles both transparently, so only endpoint
|
|
8
|
+
* resolution differs (see shared/transport.ts).
|
|
9
|
+
*
|
|
10
|
+
* Auto-spawn: if the daemon endpoint is absent, exactly one client becomes
|
|
11
|
+
* the spawner (serialized by a mkdir-based lock — atomic on POSIX and on
|
|
12
|
+
* NTFS) and starts a detached daemon; losers wait for the endpoint to
|
|
13
|
+
* appear. The freshly spawned daemon itself re-checks the endpoint before
|
|
14
|
+
* binding, so a double-spawn is harmless (the loser exits).
|
|
11
15
|
*/
|
|
12
16
|
import { connect } from "node:net";
|
|
13
17
|
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
@@ -17,11 +21,14 @@ import { spawn } from "node:child_process";
|
|
|
17
21
|
import { setTimeout as delay } from "node:timers/promises";
|
|
18
22
|
import { ProtocolError } from "./protocol.js";
|
|
19
23
|
import { socketPath as defaultSocket, stateDir as defaultStateDir } from "../core/persist.js";
|
|
24
|
+
import { probeTransport } from "./transport.js";
|
|
20
25
|
export class LLClient {
|
|
21
26
|
sock = null;
|
|
22
27
|
nextId = 1;
|
|
23
28
|
pending = new Map();
|
|
24
29
|
eventCb = null;
|
|
30
|
+
/** Optional disconnect notifications (dashboard feeds and other watchers). */
|
|
31
|
+
disconnectCbs = [];
|
|
25
32
|
buf = "";
|
|
26
33
|
opts;
|
|
27
34
|
constructor(opts = {}) {
|
|
@@ -94,12 +101,27 @@ export class LLClient {
|
|
|
94
101
|
sock.on("error", () => {
|
|
95
102
|
this.failAll(new ProtocolError("unavailable", "daemon connection lost"));
|
|
96
103
|
this.sock = null;
|
|
104
|
+
this.fireDisconnect();
|
|
97
105
|
});
|
|
98
106
|
sock.on("close", () => {
|
|
99
107
|
this.failAll(new ProtocolError("unavailable", "daemon connection closed"));
|
|
100
108
|
this.sock = null;
|
|
109
|
+
this.fireDisconnect();
|
|
101
110
|
});
|
|
102
111
|
}
|
|
112
|
+
fireDisconnect() {
|
|
113
|
+
// Callbacks stay registered: a long-lived consumer (e.g. a dashboard
|
|
114
|
+
// feed) reconnects and must keep hearing about later disconnects.
|
|
115
|
+
// Copy first — a callback may (un)register during iteration.
|
|
116
|
+
for (const cb of [...this.disconnectCbs]) {
|
|
117
|
+
try {
|
|
118
|
+
cb();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* consumer errors must not break reconnect bookkeeping */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
103
125
|
failAll(err) {
|
|
104
126
|
for (const p of this.pending.values())
|
|
105
127
|
p.reject(err);
|
|
@@ -139,6 +161,18 @@ export class LLClient {
|
|
|
139
161
|
this.eventCb = onEvent;
|
|
140
162
|
await this.request("watch", { agentId });
|
|
141
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Register a callback fired whenever the daemon connection drops (socket
|
|
166
|
+
* closed or errored) — including after later reconnects; the callback
|
|
167
|
+
* stays registered until its unregister function is called. Dashboard
|
|
168
|
+
* feeds use this to reconnect and resume via the durable event cursor.
|
|
169
|
+
*/
|
|
170
|
+
onDisconnect(cb) {
|
|
171
|
+
this.disconnectCbs.push(cb);
|
|
172
|
+
return () => {
|
|
173
|
+
this.disconnectCbs = this.disconnectCbs.filter((c) => c !== cb);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
142
176
|
close() {
|
|
143
177
|
this.sock?.destroy();
|
|
144
178
|
this.sock = null;
|
|
@@ -205,14 +239,7 @@ export async function ensureDaemon(stateDir, sockPath) {
|
|
|
205
239
|
}
|
|
206
240
|
}
|
|
207
241
|
function probe(sockPath) {
|
|
208
|
-
return
|
|
209
|
-
const s = connect(sockPath);
|
|
210
|
-
s.once("connect", () => {
|
|
211
|
-
s.end();
|
|
212
|
-
resolve(true);
|
|
213
|
-
});
|
|
214
|
-
s.once("error", () => resolve(false));
|
|
215
|
-
});
|
|
242
|
+
return probeTransport(sockPath);
|
|
216
243
|
}
|
|
217
244
|
/** One-shot helper: connect (auto-spawning), run fn, close. */
|
|
218
245
|
export async function withClient(fn, opts = {}) {
|
|
@@ -29,6 +29,12 @@ export interface MachineConfig {
|
|
|
29
29
|
offerTimeoutMs: number;
|
|
30
30
|
/** Upper bound for bounded blocking waits in acquire (advisory). */
|
|
31
31
|
maxWaitMs: number;
|
|
32
|
+
/**
|
|
33
|
+
* Upper bound for ONE wait_for_turn call ("sleep until it is my turn",
|
|
34
|
+
* issue #11), in milliseconds. Callers' timeout_ms is clamped to this —
|
|
35
|
+
* operators tune how long an agent may block in a single tool call.
|
|
36
|
+
*/
|
|
37
|
+
maxTurnWaitMs: number;
|
|
32
38
|
/**
|
|
33
39
|
* Start deadline: after a permit is claimed/granted, execution must begin
|
|
34
40
|
* (run_with_permit / attach_pid / mark_started) within this window, or the
|
|
@@ -61,6 +67,7 @@ export interface ConfigChange {
|
|
|
61
67
|
pools?: Record<string, PoolConfig>;
|
|
62
68
|
offerTimeoutMs?: number;
|
|
63
69
|
maxWaitMs?: number;
|
|
70
|
+
maxTurnWaitMs?: number;
|
|
64
71
|
startTimeoutMs?: number;
|
|
65
72
|
untrackedLeaseMs?: number;
|
|
66
73
|
guidance?: {
|
|
@@ -162,6 +169,7 @@ export interface StatusResult {
|
|
|
162
169
|
pools: Record<string, PoolConfig>;
|
|
163
170
|
offerTimeoutMs: number;
|
|
164
171
|
maxWaitMs: number;
|
|
172
|
+
maxTurnWaitMs: number;
|
|
165
173
|
startTimeoutMs: number;
|
|
166
174
|
untrackedLeaseMs: number;
|
|
167
175
|
};
|
|
@@ -319,6 +327,27 @@ export interface EventsResult {
|
|
|
319
327
|
events: MachineEvent[];
|
|
320
328
|
latestEventId: number;
|
|
321
329
|
}
|
|
330
|
+
export type WaitForTurnState = "granted" | "still_queued" | "paused" | "cancelled" | "not_found";
|
|
331
|
+
export interface WaitForTurnResult {
|
|
332
|
+
state: WaitForTurnState;
|
|
333
|
+
requestId?: string;
|
|
334
|
+
/** True when THIS wait claimed the offer on the caller's behalf (explicit auto-claim). */
|
|
335
|
+
autoClaimed?: boolean;
|
|
336
|
+
/** How long the wait actually ran before resolving (ms). */
|
|
337
|
+
waitedMs: number;
|
|
338
|
+
/** The clamped budget actually applied, when it differed from the requested one. */
|
|
339
|
+
effectiveWaitMs?: number;
|
|
340
|
+
/** Owner snapshot of the request at resolution time (carries permit_token when granted). */
|
|
341
|
+
entry?: OwnerEntrySnapshot;
|
|
342
|
+
/** 1-based FIFO position (still_queued). */
|
|
343
|
+
queuePosition?: number;
|
|
344
|
+
/** Queue depth of the request's pool (still_queued). */
|
|
345
|
+
queueLength?: number;
|
|
346
|
+
/** Who holds the pool's capacity (still_queued) — the agents ahead of you. */
|
|
347
|
+
holders?: EntrySnapshot[];
|
|
348
|
+
nextAction: string;
|
|
349
|
+
detail?: string;
|
|
350
|
+
}
|
|
322
351
|
/** Recovery diagnostics: held permits with holder + job information. */
|
|
323
352
|
export interface HolderDiag {
|
|
324
353
|
entry: EntrySnapshot;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare const DEFAULT_PIPE_NAME = "load-lock";
|
|
2
|
+
export declare function isWindows(platform?: NodeJS.Platform): boolean;
|
|
3
|
+
/** Windows named-pipe endpoint for the given pipe name. */
|
|
4
|
+
export declare function namedPipePath(name?: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the state directory. Precedence: explicit override >
|
|
7
|
+
* LOAD_LOCK_STATE_DIR > platform default (POSIX: XDG_STATE_HOME or
|
|
8
|
+
* ~/.local/state; Windows: %LOCALAPPDATA% or %USERPROFILE%\AppData\Local),
|
|
9
|
+
* each + "/load-lock". Pure function of its arguments so tests can
|
|
10
|
+
* exercise the Windows branches on Linux.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveStateDir(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, override?: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the transport endpoint. Precedence: explicit override >
|
|
15
|
+
* LOAD_LOCK_SOCKET > platform default (Windows: named pipe; POSIX:
|
|
16
|
+
* <state dir>/daemon.sock). The same precedence rules are used by every
|
|
17
|
+
* caller, so the CLI, MCP server, hooks, and the daemon all agree on the
|
|
18
|
+
* endpoint without further coordination.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveSocketPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, override?: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Probe whether a daemon is listening on the endpoint (UDS or named
|
|
23
|
+
* pipe — Node's connect handles both). Never throws.
|
|
24
|
+
*/
|
|
25
|
+
export declare function probeTransport(path: string): Promise<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* Remove a stale endpoint left behind by a crashed daemon.
|
|
28
|
+
* POSIX: unlink the socket file (a crashed daemon leaves it behind).
|
|
29
|
+
* Windows: no-op — named pipes are kernel objects and vanish when the
|
|
30
|
+
* last handle closes, so there is nothing to clean up.
|
|
31
|
+
*/
|
|
32
|
+
export declare function removeStaleEndpoint(path: string, platform?: NodeJS.Platform): void;
|
|
33
|
+
/**
|
|
34
|
+
* Terminate a job process tree. POSIX: negative-pid group kill (callers
|
|
35
|
+
* spawn jobs detached, so pgid === child pid). Windows: `taskkill /PID
|
|
36
|
+
* <pid> /T` (tree kill; add /F for the uncatchable SIGKILL equivalent —
|
|
37
|
+
* plain taskkill posts WM_CLOSE, which console processes don't handle).
|
|
38
|
+
* Fire-and-forget; never throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function killProcessGroup(pid: number, signal: "SIGTERM" | "SIGINT" | "SIGKILL", platform?: NodeJS.Platform): void;
|