agent-dealer 1.2.4 → 1.2.5
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/bundle/server/dist/adapters/agent-health.js +25 -2
- package/bundle/server/dist/adapters/agent-health.test.js +55 -2
- package/bundle/server/dist/adapters/github.js +110 -12
- package/bundle/server/dist/adapters/github.test.js +274 -3
- package/bundle/server/dist/adapters/muse-capability.js +330 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +378 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +217 -64
- package/bundle/server/dist/capacity/claude-local-cache.test.js +174 -29
- package/bundle/server/dist/capacity/muse-host.js +60 -2
- package/bundle/server/dist/capacity/muse-host.test.js +125 -1
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- package/bundle/server/dist/coordinator/commands.js +35 -8
- package/bundle/server/dist/coordinator/developer-effect.js +40 -6
- package/bundle/server/dist/coordinator/developer-effect.test.js +121 -12
- package/bundle/server/dist/coordinator/human-resolution.js +31 -1
- package/bundle/server/dist/coordinator/muse-spawn.js +12 -2
- package/bundle/server/dist/coordinator/prompts.js +15 -0
- package/bundle/server/dist/coordinator/prompts.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +7 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
- package/bundle/server/dist/routes/human-actions.js +10 -1
- package/bundle/server/dist/routes/human-actions.test.js +117 -1
- package/bundle/server/dist/routes/index.js +9 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
- package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +15 -15
- package/bundle/shared/dist/agents.js +6 -0
- package/bundle/shared/dist/index.d.ts +7 -7
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +7 -3
- package/dist/doctor.js +17 -12
- package/dist/doctor.test.js +9 -8
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// packages/server/src/adapters/muse-capability.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-277: Muse Code auto-updates itself (expected, never fought). An update once silently stopped
|
|
4
|
+
// granting developer sessions the shell/write tool, and every developer round after it surfaced
|
|
5
|
+
// only as a generic `dirty_worktree` escalation. So whenever `muse --version` reports a version not
|
|
6
|
+
// yet checked, one real developer-posture session is run whose only path to success is a shell
|
|
7
|
+
// call; the result is cached per version (and persisted, so a restart does not re-bill it):
|
|
8
|
+
//
|
|
9
|
+
// - capable → recorded as the confirmed baseline, admission proceeds with no manual step;
|
|
10
|
+
// - missing → developer admission blocked, message names old → new version + capability;
|
|
11
|
+
// - error → the check could not complete: blocked with a distinct "could not verify"
|
|
12
|
+
// message (fail closed), retried after a backoff;
|
|
13
|
+
// - in flight → blocked while the one-time check runs (never assumed capable).
|
|
14
|
+
//
|
|
15
|
+
// The probe is a fresh `muse exec` of the on-disk binary (never the long-lived serve host, which can
|
|
16
|
+
// still be the pre-update build). Checks are serialized: a version reported mid-probe is checked
|
|
17
|
+
// once the running probe settles, and a verdict for a version no longer reported is discarded.
|
|
18
|
+
// Messages name the exact update — the previously reported version → the current one.
|
|
19
|
+
//
|
|
20
|
+
// Deliberately NOT covered: NOT-177's security-enforcement evidence (`mcp_tool_allowlist_enforcement`,
|
|
21
|
+
// `cron_tool_disable`, pinned in the runners' Muse config core) stays manually re-validated as before.
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { DEVELOPER_ROLE_CEILING } from "@agent-dealer/shared";
|
|
28
|
+
import { MUSE_CLI_ENV, resolveMuseBin } from "../cli-env.js";
|
|
29
|
+
import { getDataDir } from "../db/index.js";
|
|
30
|
+
/** A probe that could not complete is retried after this long (the block stays up meanwhile). */
|
|
31
|
+
const ERROR_RETRY_MS = 10 * 60_000;
|
|
32
|
+
const PROBE_TIMEOUT_MS = 5 * 60_000;
|
|
33
|
+
const PROBE_MAX_MODEL_STEPS = 20;
|
|
34
|
+
const STATE_FILE = "muse-capability.json";
|
|
35
|
+
const CAPABILITY = "shell/write access";
|
|
36
|
+
let state = null;
|
|
37
|
+
/** At most one probe at a time (serialized); a newer version is chained after it settles. */
|
|
38
|
+
let inFlight = null;
|
|
39
|
+
/** Bumped whenever a check settles, so callers can tell a result landed mid-read. */
|
|
40
|
+
let settledCount = 0;
|
|
41
|
+
/** Bumped by resets so a probe started before a reset cannot write into the fresh state. */
|
|
42
|
+
let generation = 0;
|
|
43
|
+
let probeImpl = defaultMuseCapabilityProbe;
|
|
44
|
+
/** Latest caller hook; a chained check reports through it too. */
|
|
45
|
+
let settledHook = () => { };
|
|
46
|
+
function statePath() {
|
|
47
|
+
return path.join(getDataDir(), STATE_FILE);
|
|
48
|
+
}
|
|
49
|
+
function loadState() {
|
|
50
|
+
if (state)
|
|
51
|
+
return state;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(fs.readFileSync(statePath(), "utf8"));
|
|
54
|
+
const lastChecked = parsed.lastChecked && typeof parsed.lastChecked.version === "string" ? parsed.lastChecked : null;
|
|
55
|
+
const current = parsed.current && typeof parsed.current.version === "string"
|
|
56
|
+
? { version: parsed.current.version, from: typeof parsed.current.from === "string" ? parsed.current.from : null }
|
|
57
|
+
: null;
|
|
58
|
+
state = {
|
|
59
|
+
confirmedVersion: typeof parsed.confirmedVersion === "string" ? parsed.confirmedVersion : null,
|
|
60
|
+
current,
|
|
61
|
+
lastChecked: lastChecked && lastChecked.version === current?.version ? lastChecked : null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
state = { confirmedVersion: null, current: null, lastChecked: null };
|
|
66
|
+
}
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
69
|
+
function saveState(next) {
|
|
70
|
+
state = next;
|
|
71
|
+
try {
|
|
72
|
+
fs.writeFileSync(statePath(), `${JSON.stringify(next, null, 2)}\n`);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// In-memory state still gates this process; only restart persistence is lost.
|
|
76
|
+
console.warn(`[muse-capability] could not persist ${statePath()}: ${String(err)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Record `version` as the one Muse now reports; a change remembers what it changed from. */
|
|
80
|
+
function observe(version) {
|
|
81
|
+
const s = loadState();
|
|
82
|
+
if (s.current?.version === version)
|
|
83
|
+
return s;
|
|
84
|
+
const from = s.current?.version ?? s.confirmedVersion;
|
|
85
|
+
saveState({ ...s, current: { version, from: from !== version ? from : null }, lastChecked: null });
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
/** `Muse Code 1.3.0 (1.3.0-R3401.1)` → `1.3.0-R3401.1`; otherwise the last non-empty line. */
|
|
89
|
+
export function parseMuseVersion(output) {
|
|
90
|
+
const paren = /\(([^()\s]+)\)\s*$/m.exec(output.trim());
|
|
91
|
+
if (paren)
|
|
92
|
+
return paren[1];
|
|
93
|
+
const last = output.trim().split("\n").map((l) => l.trim()).filter(Boolean).pop();
|
|
94
|
+
return last ?? null;
|
|
95
|
+
}
|
|
96
|
+
function transition(from, to) {
|
|
97
|
+
return from && from !== to ? `Muse Code updated ${from} → ${to}` : `Muse Code ${to}`;
|
|
98
|
+
}
|
|
99
|
+
function issueFor(checked, from) {
|
|
100
|
+
if (checked.status === "capable")
|
|
101
|
+
return [];
|
|
102
|
+
const head = transition(from, checked.version);
|
|
103
|
+
if (checked.status === "missing") {
|
|
104
|
+
return [
|
|
105
|
+
{
|
|
106
|
+
code: "runtime_capability",
|
|
107
|
+
message: `${head}: developer sessions no longer get ${CAPABILITY} (${checked.detail}) — ` +
|
|
108
|
+
`developer admission blocked until a Muse Code version passes the check`,
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
code: "runtime_capability",
|
|
115
|
+
message: `Could not verify Muse Code developer ${CAPABILITY} after version change ` +
|
|
116
|
+
`(${from && from !== checked.version ? `${from} → ` : ""}${checked.version}): ` +
|
|
117
|
+
`${checked.detail} — developer admission blocked; the check retries automatically`,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
function verifyingIssue(version, from) {
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
code: "runtime_capability",
|
|
125
|
+
message: `${transition(from, version)}: verifying developer ${CAPABILITY} ` +
|
|
126
|
+
`(one-time check for this version) — developer admission waits for the result`,
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
/** Current version is unchecked, or its could-not-verify result is due a retry. */
|
|
131
|
+
function checkDue(s) {
|
|
132
|
+
if (!s.current)
|
|
133
|
+
return false;
|
|
134
|
+
const checked = s.lastChecked;
|
|
135
|
+
if (!checked)
|
|
136
|
+
return true;
|
|
137
|
+
return checked.status === "error" && Date.now() - checked.checkedAt >= ERROR_RETRY_MS;
|
|
138
|
+
}
|
|
139
|
+
function startCheck(version) {
|
|
140
|
+
const gen = generation;
|
|
141
|
+
const promise = (async () => {
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await probeImpl(version);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
result = { status: "error", detail: err instanceof Error ? err.message : String(err) };
|
|
148
|
+
}
|
|
149
|
+
if (gen !== generation)
|
|
150
|
+
return;
|
|
151
|
+
const prev = loadState();
|
|
152
|
+
// Muse moved on while this probe ran: its verdict is about a build no longer reported, so it
|
|
153
|
+
// must not overwrite state for the newer one (which is checked next, below).
|
|
154
|
+
if (prev.current?.version !== version)
|
|
155
|
+
return;
|
|
156
|
+
const checked = { version, checkedAt: Date.now(), ...result };
|
|
157
|
+
saveState({
|
|
158
|
+
...prev,
|
|
159
|
+
confirmedVersion: result.status === "capable" ? version : prev.confirmedVersion,
|
|
160
|
+
lastChecked: checked,
|
|
161
|
+
});
|
|
162
|
+
const from = prev.current.from;
|
|
163
|
+
if (result.status === "capable") {
|
|
164
|
+
console.log(`[muse-capability] ${transition(from, version)}: developer ${CAPABILITY} confirmed`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.warn(`[muse-capability] ${issueFor(checked, from)[0].message}`);
|
|
168
|
+
}
|
|
169
|
+
})().finally(() => {
|
|
170
|
+
if (inFlight?.promise === promise)
|
|
171
|
+
inFlight = null;
|
|
172
|
+
if (gen !== generation)
|
|
173
|
+
return;
|
|
174
|
+
settledCount += 1;
|
|
175
|
+
const s = loadState();
|
|
176
|
+
if (!inFlight && s.current && s.current.version !== version && checkDue(s))
|
|
177
|
+
startCheck(s.current.version);
|
|
178
|
+
settledHook();
|
|
179
|
+
});
|
|
180
|
+
inFlight = { version, promise };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Capability issues for the currently reported Muse version. Never awaits the probe: a version not
|
|
184
|
+
* yet checked starts one background check and blocks until it settles. Checks are serialized — a
|
|
185
|
+
* version observed while another is being probed waits for that probe, then is checked once.
|
|
186
|
+
* `onSettled` lets the caller drop its health cache so admission unblocks as soon as the result is in.
|
|
187
|
+
*/
|
|
188
|
+
export function museCapabilityIssues(version, onSettled = () => { }) {
|
|
189
|
+
settledHook = onSettled;
|
|
190
|
+
const s = observe(version);
|
|
191
|
+
const from = s.current.from;
|
|
192
|
+
if (checkDue(s) && !inFlight)
|
|
193
|
+
startCheck(version);
|
|
194
|
+
if (s.lastChecked)
|
|
195
|
+
return issueFor(s.lastChecked, from); // incl. error retry: keep the block up
|
|
196
|
+
return verifyingIssue(version, from);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* NOT-277: the Muse version developer work may run on outside the on-disk binary (i.e. on the
|
|
200
|
+
* long-lived serve host). `undefined` when no version has been observed yet — nothing to bind to;
|
|
201
|
+
* `null` when the reported version is not confirmed capable (only the on-disk binary, which
|
|
202
|
+
* admission gates on, may run work then); otherwise the reported, confirmed-capable version.
|
|
203
|
+
*/
|
|
204
|
+
export function museVersionRequiredForWork() {
|
|
205
|
+
const s = loadState();
|
|
206
|
+
if (!s.current)
|
|
207
|
+
return undefined;
|
|
208
|
+
const checked = s.lastChecked;
|
|
209
|
+
return checked?.status === "capable" && checked.version === s.current.version ? checked.version : null;
|
|
210
|
+
}
|
|
211
|
+
/** True while a capability check is running (callers use a short health-cache TTL meanwhile). */
|
|
212
|
+
export function museCapabilityCheckInFlight() {
|
|
213
|
+
return inFlight !== null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Changes each time a check settles. A health read that spans a settle must not cache what it
|
|
217
|
+
* read — it may be the "verifying" block the settle just superseded.
|
|
218
|
+
*/
|
|
219
|
+
export function museCapabilitySettleCount() {
|
|
220
|
+
return settledCount;
|
|
221
|
+
}
|
|
222
|
+
/** Tests: resolve once any in-flight capability check has settled. */
|
|
223
|
+
export async function settleMuseCapabilityCheckForTests() {
|
|
224
|
+
while (inFlight)
|
|
225
|
+
await inFlight.promise;
|
|
226
|
+
}
|
|
227
|
+
/** Tests: replace the real `muse` probe. Pass `null` to restore it. */
|
|
228
|
+
export function setMuseCapabilityProbeForTests(fn) {
|
|
229
|
+
probeImpl = fn ?? defaultMuseCapabilityProbe;
|
|
230
|
+
}
|
|
231
|
+
/** Tests: forget in-memory and persisted state; any in-flight probe result is discarded. */
|
|
232
|
+
export function resetMuseCapabilityStateForTests() {
|
|
233
|
+
generation += 1;
|
|
234
|
+
inFlight = null;
|
|
235
|
+
state = null;
|
|
236
|
+
settledHook = () => { };
|
|
237
|
+
fs.rmSync(statePath(), { force: true });
|
|
238
|
+
}
|
|
239
|
+
/** Tests: backdate the last check so the error-retry path can be exercised without waiting. */
|
|
240
|
+
export function ageMuseCapabilityCheckForTests(ms) {
|
|
241
|
+
const s = loadState();
|
|
242
|
+
if (s.lastChecked)
|
|
243
|
+
saveState({ ...s, lastChecked: { ...s.lastChecked, checkedAt: s.lastChecked.checkedAt - ms } });
|
|
244
|
+
}
|
|
245
|
+
/** `git hash-object` of a string — what `probe.sh` writes; not computable without running it. */
|
|
246
|
+
function gitBlobSha1(content) {
|
|
247
|
+
return createHash("sha1").update(`blob ${Buffer.byteLength(content)}\0${content}`).digest("hex");
|
|
248
|
+
}
|
|
249
|
+
/** What the on-disk `muse` reports now (null when it cannot say). */
|
|
250
|
+
function reportedMuseVersion() {
|
|
251
|
+
try {
|
|
252
|
+
const out = execFileSync(resolveMuseBin(), ["--version"], {
|
|
253
|
+
encoding: "utf8",
|
|
254
|
+
timeout: 30_000,
|
|
255
|
+
env: { ...process.env, ...MUSE_CLI_ENV },
|
|
256
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
257
|
+
});
|
|
258
|
+
return parseMuseVersion(out);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* One real developer session (same posture and model a developer round uses) in a throwaway
|
|
266
|
+
* git repo. Always a fresh `muse exec` of the on-disk binary — never the shared serve host, which
|
|
267
|
+
* may still be the pre-update build — and the binary must still report `version` afterwards, so
|
|
268
|
+
* the verdict is about the build that was probed. Its only path to success is running `sh probe.sh`, which writes a git blob hash of a
|
|
269
|
+
* fresh nonce to result.txt — the model cannot produce that value without a shell call, and the
|
|
270
|
+
* file only exists if the shell could write the workspace.
|
|
271
|
+
*/
|
|
272
|
+
export async function defaultMuseCapabilityProbe(version, opts = {}) {
|
|
273
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-capability-"));
|
|
274
|
+
try {
|
|
275
|
+
execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" });
|
|
276
|
+
const nonce = randomUUID();
|
|
277
|
+
fs.writeFileSync(path.join(dir, "probe.sh"), `#!/bin/sh\nprintf '%s' '${nonce}' | git hash-object --stdin > result.txt\n`);
|
|
278
|
+
const { runMuseDeveloperSession } = await import("../coordinator/muse-spawn.js");
|
|
279
|
+
const run = await runMuseDeveloperSession({
|
|
280
|
+
sessionId: randomUUID(),
|
|
281
|
+
runtime: "muse_code",
|
|
282
|
+
policy: DEVELOPER_ROLE_CEILING,
|
|
283
|
+
model: null,
|
|
284
|
+
maxModelSteps: PROBE_MAX_MODEL_STEPS,
|
|
285
|
+
prompt: "Dealer capability check. Using your shell tool, run exactly this command in the current " +
|
|
286
|
+
"directory: sh probe.sh\nDo not create or edit result.txt any other way. When the command " +
|
|
287
|
+
"has finished, reply with the single word DONE.",
|
|
288
|
+
cwd: dir,
|
|
289
|
+
timeoutMs: opts.timeoutMs ?? PROBE_TIMEOUT_MS,
|
|
290
|
+
logPath: path.join(dir, "probe.ndjson"),
|
|
291
|
+
execLaneOnly: true,
|
|
292
|
+
});
|
|
293
|
+
const after = reportedMuseVersion();
|
|
294
|
+
if (after !== version) {
|
|
295
|
+
return {
|
|
296
|
+
status: "error",
|
|
297
|
+
detail: `muse reported ${after ?? "no version"} after probing ${version} (changed during the check)`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
let written = null;
|
|
301
|
+
try {
|
|
302
|
+
written = fs.readFileSync(path.join(dir, "result.txt"), "utf8").trim();
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
written = null;
|
|
306
|
+
}
|
|
307
|
+
// A session that did not complete cleanly is never a verdict, even if the shell ran first.
|
|
308
|
+
if (run.timedOut)
|
|
309
|
+
return { status: "error", detail: `probe session on ${version} timed out` };
|
|
310
|
+
const failure = run.muse?.failure;
|
|
311
|
+
if (failure)
|
|
312
|
+
return { status: "error", detail: `probe session failed (${failure.kind}: ${failure.message})` };
|
|
313
|
+
if (run.exitCode !== 0)
|
|
314
|
+
return { status: "error", detail: `probe session exited ${run.exitCode}` };
|
|
315
|
+
if (written === gitBlobSha1(nonce))
|
|
316
|
+
return { status: "capable" };
|
|
317
|
+
return {
|
|
318
|
+
status: "missing",
|
|
319
|
+
detail: written === null
|
|
320
|
+
? "probe session completed without running its shell command"
|
|
321
|
+
: "probe session completed but the shell command's output was not produced",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
return { status: "error", detail: `probe could not run: ${err instanceof Error ? err.message : String(err)}` };
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
}
|