agent-dealer 1.2.4 → 1.2.6
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-deck-bind.js +33 -3
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
- package/bundle/server/dist/adapters/agent-health.js +28 -6
- package/bundle/server/dist/adapters/agent-health.test.js +66 -11
- package/bundle/server/dist/adapters/git-worktree.js +187 -18
- package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
- 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 +356 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
- package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
- package/bundle/server/dist/capacity/muse-host.js +92 -32
- package/bundle/server/dist/capacity/muse-host.test.js +130 -4
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- package/bundle/server/dist/coordinator/args.js +4 -3
- package/bundle/server/dist/coordinator/checkpoint.js +5 -3
- package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
- package/bundle/server/dist/coordinator/commands.js +100 -12
- package/bundle/server/dist/coordinator/developer-effect.js +128 -16
- package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
- package/bundle/server/dist/coordinator/human-resolution.js +33 -1
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +25 -6
- package/bundle/server/dist/coordinator/prompts.test.js +36 -8
- package/bundle/server/dist/coordinator/routing.js +1 -0
- package/bundle/server/dist/coordinator/routing.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +9 -1
- 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/coordinator/worktree-owner-liveness.js +6 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
- package/bundle/server/dist/repository/human-actions.js +13 -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 +12 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- package/bundle/server/dist/routes/version.js +30 -0
- package/bundle/server/dist/routes/version.test.js +21 -0
- package/bundle/server/dist/runners/muse-config-core.js +63 -16
- package/bundle/server/dist/runners/muse-config.js +1 -1
- package/bundle/server/dist/runners/muse-config.test.js +135 -29
- package/bundle/server/dist/runners/spawn-cli.js +17 -3
- package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
- 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-CoDzZcsz.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 +21 -13
- package/dist/doctor.test.js +9 -8
- package/dist/managed/index.d.ts +1 -1
- package/dist/managed/index.js +1 -1
- package/dist/managed/updater.d.ts +16 -6
- package/dist/managed/updater.js +28 -11
- package/dist/managed-update-restart.test.d.ts +1 -0
- package/dist/managed-update-restart.test.js +227 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +10 -0
- package/dist/runtime-state.d.ts +10 -0
- package/dist/runtime-state.js +26 -0
- package/dist/start.js +67 -2
- package/dist/status.js +30 -2
- package/dist/update-check.js +3 -6
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// packages/server/src/adapters/muse-capability.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-277: Muse Code developer shell/write capability is re-validated once per newly reported
|
|
4
|
+
// version — cached per version, auto-confirmed on success, blocked by name on loss, fail-closed
|
|
5
|
+
// when the check cannot complete. The real probe runs against fixtures/fake-muse.mjs (never Meta).
|
|
6
|
+
import { describe, test, beforeEach, before, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import net from "node:net";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
// NOT-278: the worker MCP config root (the per-attempt base dir) refuses temp dirs, so the
|
|
16
|
+
// dealer home for this file is a home scratch root, never OS temp. Removed in `after`
|
|
17
|
+
// below so local runs do not clutter $HOME.
|
|
18
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-capability-"));
|
|
19
|
+
after(() => {
|
|
20
|
+
try {
|
|
21
|
+
fs.rmSync(process.env.AGENT_DEALER_HOME, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// best-effort — a failed rm must not fail the suite
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
const { museCapabilityIssues, museCapabilityCheckInFlight, parseMuseVersion, settleMuseCapabilityCheckForTests, setMuseCapabilityProbeForTests, resetMuseCapabilityStateForTests, ageMuseCapabilityCheckForTests, defaultMuseCapabilityProbe, } = await import("./muse-capability.js");
|
|
28
|
+
const OLD = "1.3.0-R3401.1";
|
|
29
|
+
const NEW = "1.4.0-R4161.1";
|
|
30
|
+
const STATE = path.join(process.env.AGENT_DEALER_HOME, "muse-capability.json");
|
|
31
|
+
/** Probe stub answering from `results` per version and counting calls per version. */
|
|
32
|
+
function stubProbe(results) {
|
|
33
|
+
const calls = new Map();
|
|
34
|
+
setMuseCapabilityProbeForTests(async (version) => {
|
|
35
|
+
calls.set(version, (calls.get(version) ?? 0) + 1);
|
|
36
|
+
const r = results[version];
|
|
37
|
+
if (!r)
|
|
38
|
+
throw new Error(`no stub result for ${version}`);
|
|
39
|
+
if (r instanceof Error)
|
|
40
|
+
throw r;
|
|
41
|
+
return r;
|
|
42
|
+
});
|
|
43
|
+
return calls;
|
|
44
|
+
}
|
|
45
|
+
/** One health read for `version`, letting any check it starts settle. */
|
|
46
|
+
async function check(version) {
|
|
47
|
+
const first = museCapabilityIssues(version);
|
|
48
|
+
await settleMuseCapabilityCheckForTests();
|
|
49
|
+
return { first, settled: museCapabilityIssues(version) };
|
|
50
|
+
}
|
|
51
|
+
describe("muse-capability", { concurrency: false }, () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
resetMuseCapabilityStateForTests();
|
|
54
|
+
setMuseCapabilityProbeForTests(null);
|
|
55
|
+
});
|
|
56
|
+
test("parseMuseVersion reads the build id from `muse --version`", () => {
|
|
57
|
+
assert.equal(parseMuseVersion("Muse Code 1.3.0 (1.3.0-R3401.1)\n"), OLD);
|
|
58
|
+
assert.equal(parseMuseVersion("1.4.0-R4161.1\n"), NEW);
|
|
59
|
+
assert.equal(parseMuseVersion(" \n"), null);
|
|
60
|
+
});
|
|
61
|
+
test("a version not yet checked blocks while its one-time check runs (never assumed capable)", async () => {
|
|
62
|
+
let release;
|
|
63
|
+
setMuseCapabilityProbeForTests(() => new Promise((resolve) => (release = () => resolve({ status: "capable" }))));
|
|
64
|
+
const issues = museCapabilityIssues(NEW);
|
|
65
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_capability"]);
|
|
66
|
+
assert.match(issues[0].message, /verifying developer shell\/write access/);
|
|
67
|
+
assert.equal(museCapabilityCheckInFlight(), true);
|
|
68
|
+
// A second read while in flight does not start a second probe.
|
|
69
|
+
museCapabilityIssues(NEW);
|
|
70
|
+
release();
|
|
71
|
+
await settleMuseCapabilityCheckForTests();
|
|
72
|
+
assert.equal(museCapabilityCheckInFlight(), false);
|
|
73
|
+
assert.deepEqual(museCapabilityIssues(NEW), []);
|
|
74
|
+
});
|
|
75
|
+
// (a) same version as last check → no re-probe, cached result reused.
|
|
76
|
+
test("the same version is checked once; later reads reuse the cached result", async () => {
|
|
77
|
+
const calls = stubProbe({ [OLD]: { status: "capable" } });
|
|
78
|
+
assert.deepEqual((await check(OLD)).settled, []);
|
|
79
|
+
for (let i = 0; i < 5; i++)
|
|
80
|
+
assert.deepEqual(museCapabilityIssues(OLD), []);
|
|
81
|
+
assert.equal(museCapabilityCheckInFlight(), false);
|
|
82
|
+
assert.equal(calls.get(OLD), 1);
|
|
83
|
+
});
|
|
84
|
+
test("the cached result survives a restart (persisted), so a restart does not re-probe", async () => {
|
|
85
|
+
const calls = stubProbe({ [OLD]: { status: "capable" } });
|
|
86
|
+
await check(OLD);
|
|
87
|
+
// Drop in-memory state only, as a fresh process would start.
|
|
88
|
+
const persisted = fs.readFileSync(STATE, "utf8");
|
|
89
|
+
resetMuseCapabilityStateForTests();
|
|
90
|
+
fs.writeFileSync(STATE, persisted);
|
|
91
|
+
assert.deepEqual(museCapabilityIssues(OLD), []);
|
|
92
|
+
assert.equal(museCapabilityCheckInFlight(), false);
|
|
93
|
+
assert.equal(calls.get(OLD), 1);
|
|
94
|
+
});
|
|
95
|
+
// (b) new version, capability confirmed → recorded capable, admission proceeds, no manual step.
|
|
96
|
+
test("a new version that passes the check is recorded as the confirmed baseline automatically", async () => {
|
|
97
|
+
const calls = stubProbe({ [OLD]: { status: "capable" }, [NEW]: { status: "capable" } });
|
|
98
|
+
await check(OLD);
|
|
99
|
+
const { first, settled } = await check(NEW);
|
|
100
|
+
assert.match(first[0].message, new RegExp(`Muse Code updated ${OLD} → ${NEW}: verifying`));
|
|
101
|
+
assert.deepEqual(settled, []);
|
|
102
|
+
assert.equal(calls.get(NEW), 1);
|
|
103
|
+
const saved = JSON.parse(fs.readFileSync(STATE, "utf8"));
|
|
104
|
+
assert.equal(saved.confirmedVersion, NEW);
|
|
105
|
+
assert.equal(saved.lastChecked.version, NEW);
|
|
106
|
+
assert.equal(saved.lastChecked.status, "capable");
|
|
107
|
+
});
|
|
108
|
+
// (c) new version, capability missing → blocked with the named versions + capability.
|
|
109
|
+
test("a new version that lost shell/write is blocked with both versions and the capability named", async () => {
|
|
110
|
+
const calls = stubProbe({
|
|
111
|
+
[OLD]: { status: "capable" },
|
|
112
|
+
[NEW]: { status: "missing", detail: "probe session completed without running its shell command" },
|
|
113
|
+
});
|
|
114
|
+
await check(OLD);
|
|
115
|
+
const { settled } = await check(NEW);
|
|
116
|
+
assert.deepEqual(settled.map((i) => i.code), ["runtime_capability"]);
|
|
117
|
+
assert.match(settled[0].message, new RegExp(`^Muse Code updated ${OLD} → ${NEW}: developer sessions no longer get shell/write access`));
|
|
118
|
+
assert.match(settled[0].message, /developer admission blocked/);
|
|
119
|
+
// Stays blocked on this version without re-probing; the baseline is still the old version.
|
|
120
|
+
assert.deepEqual(museCapabilityIssues(NEW), settled);
|
|
121
|
+
assert.equal(calls.get(NEW), 1);
|
|
122
|
+
assert.equal(JSON.parse(fs.readFileSync(STATE, "utf8")).confirmedVersion, OLD);
|
|
123
|
+
});
|
|
124
|
+
test("a later update that restores the capability unblocks by itself", async () => {
|
|
125
|
+
const FIXED = "1.4.0-R4302.1";
|
|
126
|
+
stubProbe({
|
|
127
|
+
[OLD]: { status: "capable" },
|
|
128
|
+
[NEW]: { status: "missing", detail: "no shell" },
|
|
129
|
+
[FIXED]: { status: "capable" },
|
|
130
|
+
});
|
|
131
|
+
await check(OLD);
|
|
132
|
+
await check(NEW);
|
|
133
|
+
assert.deepEqual((await check(FIXED)).settled, []);
|
|
134
|
+
});
|
|
135
|
+
// (d) the check itself errors / times out → blocked with a distinct "could not verify" message.
|
|
136
|
+
for (const [label, result] of [
|
|
137
|
+
["throws", new Error("spawn EACCES")],
|
|
138
|
+
["times out", { status: "error", detail: `probe session on ${NEW} timed out` }],
|
|
139
|
+
]) {
|
|
140
|
+
test(`a check that ${label} fails closed with a distinct "could not verify" message`, async () => {
|
|
141
|
+
const calls = stubProbe({ [OLD]: { status: "capable" }, [NEW]: result });
|
|
142
|
+
await check(OLD);
|
|
143
|
+
const { settled } = await check(NEW);
|
|
144
|
+
assert.deepEqual(settled.map((i) => i.code), ["runtime_capability"]);
|
|
145
|
+
assert.match(settled[0].message, new RegExp(`^Could not verify Muse Code developer shell/write access after version change \\(${OLD} → ${NEW}\\)`));
|
|
146
|
+
assert.doesNotMatch(settled[0].message, /no longer get/);
|
|
147
|
+
// Not re-probed before the retry backoff, and the block stays up meanwhile.
|
|
148
|
+
assert.deepEqual(museCapabilityIssues(NEW), settled);
|
|
149
|
+
assert.equal(calls.get(NEW), 1);
|
|
150
|
+
assert.equal(JSON.parse(fs.readFileSync(STATE, "utf8")).confirmedVersion, OLD);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
test("a could-not-verify result is retried after the backoff, staying blocked until it passes", async () => {
|
|
154
|
+
let next = { status: "error", detail: "timed out" };
|
|
155
|
+
let calls = 0;
|
|
156
|
+
setMuseCapabilityProbeForTests(async () => {
|
|
157
|
+
calls += 1;
|
|
158
|
+
return next;
|
|
159
|
+
});
|
|
160
|
+
await check(NEW);
|
|
161
|
+
next = { status: "capable" };
|
|
162
|
+
ageMuseCapabilityCheckForTests(11 * 60_000);
|
|
163
|
+
const retrying = museCapabilityIssues(NEW);
|
|
164
|
+
assert.match(retrying[0].message, /Could not verify/);
|
|
165
|
+
await settleMuseCapabilityCheckForTests();
|
|
166
|
+
assert.equal(calls, 2);
|
|
167
|
+
assert.deepEqual(museCapabilityIssues(NEW), []);
|
|
168
|
+
});
|
|
169
|
+
test("consecutive updates name the exact previous version, not the last confirmed baseline", async () => {
|
|
170
|
+
const LATER = "1.4.0-R4302.1";
|
|
171
|
+
stubProbe({
|
|
172
|
+
[OLD]: { status: "capable" },
|
|
173
|
+
[NEW]: { status: "missing", detail: "no shell" },
|
|
174
|
+
[LATER]: { status: "missing", detail: "no shell" },
|
|
175
|
+
});
|
|
176
|
+
await check(OLD);
|
|
177
|
+
await check(NEW);
|
|
178
|
+
const { first, settled } = await check(LATER);
|
|
179
|
+
assert.match(first[0].message, new RegExp(`^Muse Code updated ${NEW} → ${LATER}: verifying`));
|
|
180
|
+
assert.match(settled[0].message, new RegExp(`^Muse Code updated ${NEW} → ${LATER}: developer sessions no longer get`));
|
|
181
|
+
assert.doesNotMatch(settled[0].message, new RegExp(OLD.replace(/\./g, "\\.")));
|
|
182
|
+
assert.equal(JSON.parse(fs.readFileSync(STATE, "utf8")).confirmedVersion, OLD);
|
|
183
|
+
});
|
|
184
|
+
test("consecutive could-not-verify updates also name the exact previous version", async () => {
|
|
185
|
+
const LATER = "1.4.0-R4302.1";
|
|
186
|
+
stubProbe({
|
|
187
|
+
[OLD]: { status: "capable" },
|
|
188
|
+
[NEW]: { status: "error", detail: "timed out" },
|
|
189
|
+
[LATER]: { status: "error", detail: "timed out" },
|
|
190
|
+
});
|
|
191
|
+
await check(OLD);
|
|
192
|
+
await check(NEW);
|
|
193
|
+
const { settled } = await check(LATER);
|
|
194
|
+
assert.match(settled[0].message, new RegExp(`after version change \\(${NEW} → ${LATER}\\)`));
|
|
195
|
+
});
|
|
196
|
+
test("a version reported mid-probe is checked once, after the running probe, and the stale verdict is discarded", async () => {
|
|
197
|
+
const LATER = "1.4.0-R4302.1";
|
|
198
|
+
const calls = [];
|
|
199
|
+
const release = new Map();
|
|
200
|
+
setMuseCapabilityProbeForTests((version) => new Promise((resolve) => {
|
|
201
|
+
calls.push(version);
|
|
202
|
+
release.set(version, () => resolve(version === NEW ? { status: "capable" } : { status: "missing", detail: "no shell" }));
|
|
203
|
+
}));
|
|
204
|
+
museCapabilityIssues(NEW);
|
|
205
|
+
assert.deepEqual(calls, [NEW]);
|
|
206
|
+
// Muse updates again while NEW is still being probed: no second concurrent probe.
|
|
207
|
+
const during = museCapabilityIssues(LATER);
|
|
208
|
+
assert.match(during[0].message, new RegExp(`^Muse Code updated ${NEW} → ${LATER}: verifying`));
|
|
209
|
+
museCapabilityIssues(LATER);
|
|
210
|
+
assert.deepEqual(calls, [NEW]);
|
|
211
|
+
// NEW's verdict lands after Muse moved on: discarded, and LATER is probed next (once).
|
|
212
|
+
release.get(NEW)();
|
|
213
|
+
await new Promise((r) => setImmediate(r));
|
|
214
|
+
assert.deepEqual(calls, [NEW, LATER]);
|
|
215
|
+
let saved = JSON.parse(fs.readFileSync(STATE, "utf8"));
|
|
216
|
+
assert.equal(saved.confirmedVersion, null);
|
|
217
|
+
assert.equal(saved.lastChecked, null);
|
|
218
|
+
assert.equal(saved.current.version, LATER);
|
|
219
|
+
release.get(LATER)();
|
|
220
|
+
await settleMuseCapabilityCheckForTests();
|
|
221
|
+
const settled = museCapabilityIssues(LATER);
|
|
222
|
+
assert.match(settled[0].message, new RegExp(`^Muse Code updated ${NEW} → ${LATER}: developer sessions no longer get`));
|
|
223
|
+
for (let i = 0; i < 3; i++)
|
|
224
|
+
museCapabilityIssues(LATER);
|
|
225
|
+
assert.equal(museCapabilityCheckInFlight(), false);
|
|
226
|
+
assert.deepEqual(calls, [NEW, LATER]);
|
|
227
|
+
saved = JSON.parse(fs.readFileSync(STATE, "utf8"));
|
|
228
|
+
assert.equal(saved.lastChecked.version, LATER);
|
|
229
|
+
});
|
|
230
|
+
test("onSettled fires once the check lands so the caller can drop its health cache", async () => {
|
|
231
|
+
stubProbe({ [NEW]: { status: "capable" } });
|
|
232
|
+
let settled = 0;
|
|
233
|
+
museCapabilityIssues(NEW, () => (settled += 1));
|
|
234
|
+
await settleMuseCapabilityCheckForTests();
|
|
235
|
+
await new Promise((r) => setImmediate(r));
|
|
236
|
+
assert.equal(settled, 1);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
// The real probe: one developer-posture session against the fake Muse, always a fresh `muse exec`.
|
|
240
|
+
describe("defaultMuseCapabilityProbe (fake muse)", { concurrency: false }, () => {
|
|
241
|
+
const FAKE_MUSE = path.join(path.dirname(fileURLToPath(import.meta.url)), "../coordinator/fixtures/fake-muse.mjs");
|
|
242
|
+
// A stub Agent Deck API: only `/health` matters to the probe's cheap pre-spawn gate
|
|
243
|
+
// (the fake muse never dials the deck). Started once for this block on an ephemeral port.
|
|
244
|
+
let deckApiUrl = "";
|
|
245
|
+
let deckServer = null;
|
|
246
|
+
before(() => new Promise((resolve) => {
|
|
247
|
+
deckServer = http
|
|
248
|
+
.createServer((req, res) => {
|
|
249
|
+
if (req.url === "/health") {
|
|
250
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
251
|
+
res.end("ok");
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
res.writeHead(404);
|
|
255
|
+
res.end();
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
.listen(0, "127.0.0.1", () => {
|
|
259
|
+
deckApiUrl = `http://127.0.0.1:${deckServer.address().port}`;
|
|
260
|
+
resolve();
|
|
261
|
+
});
|
|
262
|
+
}));
|
|
263
|
+
// `closeAllConnections` first: fetch keep-alive sockets would otherwise hold the
|
|
264
|
+
// stub open and the runner would never exit.
|
|
265
|
+
after(() => new Promise((resolve) => {
|
|
266
|
+
if (!deckServer)
|
|
267
|
+
return resolve();
|
|
268
|
+
deckServer.closeAllConnections();
|
|
269
|
+
deckServer.close(() => resolve());
|
|
270
|
+
}));
|
|
271
|
+
/** A surely-closed loopback port: nothing answers, so the deck reads as unreachable. */
|
|
272
|
+
async function deadDeckApiUrl() {
|
|
273
|
+
const srv = net.createServer();
|
|
274
|
+
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
|
275
|
+
const port = srv.address().port;
|
|
276
|
+
await new Promise((r) => srv.close(() => r()));
|
|
277
|
+
return `http://127.0.0.1:${port}`;
|
|
278
|
+
}
|
|
279
|
+
// The probe binds a real listed deck and preflights it before spawning; there is no
|
|
280
|
+
// live deck/MCP here, so tests substitute both steps (production defaults hit the live
|
|
281
|
+
// `fetchDecks` + `verifyWorkerDeckConnection`). Overrides exercise the fail-closed paths.
|
|
282
|
+
const PROBE_DECK_ID = "11111111-1111-4111-8111-111111111111";
|
|
283
|
+
async function probeWith(scenario, extraEnv = {}, opts = {}) {
|
|
284
|
+
const env = {
|
|
285
|
+
MUSE_CLI: FAKE_MUSE,
|
|
286
|
+
FAKE_MUSE_SCENARIO: scenario,
|
|
287
|
+
FAKE_MUSE_VERSION: NEW,
|
|
288
|
+
// NOT-278: the probe runs the deck-required exec lane without a database — the endpoint
|
|
289
|
+
// comes from the env override and the credential from a fake API key on stdin. The
|
|
290
|
+
// stub above answers `/health`, so the cheap pre-spawn gate passes and the fake runs.
|
|
291
|
+
AGENT_DECK_API_URL: deckApiUrl,
|
|
292
|
+
META_API_KEY: "mk-test-fake-key-0123456789abcdef",
|
|
293
|
+
...extraEnv,
|
|
294
|
+
};
|
|
295
|
+
const keys = [...Object.keys(env)];
|
|
296
|
+
const prev = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
297
|
+
Object.assign(process.env, env);
|
|
298
|
+
try {
|
|
299
|
+
return await defaultMuseCapabilityProbe(NEW, {
|
|
300
|
+
timeoutMs: opts.timeoutMs,
|
|
301
|
+
listDecks: opts.listDecks ?? (async () => ({ ok: true, decks: [{ id: PROBE_DECK_ID, name: "probe" }] })),
|
|
302
|
+
verifyDeck: opts.verifyDeck ?? (async () => ({ ok: true })),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
for (const k of keys) {
|
|
307
|
+
if (prev[k] === undefined)
|
|
308
|
+
delete process.env[k];
|
|
309
|
+
else
|
|
310
|
+
process.env[k] = prev[k];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
test("a session that runs its shell command is capable", async () => {
|
|
315
|
+
assert.deepEqual(await probeWith("capability-shell"), { status: "capable" });
|
|
316
|
+
});
|
|
317
|
+
test("a session that completes without a shell call is missing shell/write", async () => {
|
|
318
|
+
const result = await probeWith("capability-no-shell");
|
|
319
|
+
assert.equal(result.status, "missing");
|
|
320
|
+
});
|
|
321
|
+
test("a session that fails to run at all is an error (could not verify), not missing", async () => {
|
|
322
|
+
const result = await probeWith("auth");
|
|
323
|
+
assert.equal(result.status, "error");
|
|
324
|
+
});
|
|
325
|
+
test("an unreachable deck fails closed before any model session is spent", async () => {
|
|
326
|
+
// The fixture would succeed and record under this scenario — an absent record proves no
|
|
327
|
+
// child ever spawned, so the deck outage cost a cheap health check, never a paid turn.
|
|
328
|
+
const record = path.join(process.env.AGENT_DEALER_HOME, `no-spawn-${randomUUID()}.json`);
|
|
329
|
+
const result = await probeWith("capability-shell", {
|
|
330
|
+
AGENT_DECK_API_URL: await deadDeckApiUrl(),
|
|
331
|
+
FAKE_MUSE_RECORD: record,
|
|
332
|
+
});
|
|
333
|
+
assert.equal(result.status, "error");
|
|
334
|
+
assert.match(result.detail, /Agent Deck is unreachable/);
|
|
335
|
+
assert.match(result.detail, /no model session spent/);
|
|
336
|
+
assert.equal(fs.existsSync(record), false, "no child spawned, so the fixture never recorded");
|
|
337
|
+
});
|
|
338
|
+
test("the probe binds the listed real deck id, never a synthetic one", async () => {
|
|
339
|
+
const record = path.join(process.env.AGENT_DEALER_HOME, `deck-id-${randomUUID()}.json`);
|
|
340
|
+
const result = await probeWith("capability-shell", { FAKE_MUSE_RECORD: record });
|
|
341
|
+
assert.deepEqual(result, { status: "capable" });
|
|
342
|
+
const seen = JSON.parse(fs.readFileSync(record, "utf8"));
|
|
343
|
+
assert.match(seen.settings, new RegExp(PROBE_DECK_ID));
|
|
344
|
+
assert.doesNotMatch(seen.settings, /00000000-0000-4000-a000-000000000000/);
|
|
345
|
+
});
|
|
346
|
+
test("a deck that responds but rejects the probe deck fails closed without a model session", async () => {
|
|
347
|
+
const record = path.join(process.env.AGENT_DEALER_HOME, `rejected-${randomUUID()}.json`);
|
|
348
|
+
const result = await probeWith("capability-shell", { FAKE_MUSE_RECORD: record }, { verifyDeck: async () => ({ ok: false, kind: "infra_failure", reason: "get_bound_deck returned deck other, expected probe" }) });
|
|
349
|
+
assert.equal(result.status, "error");
|
|
350
|
+
assert.match(result.detail, /rejected probe deck/);
|
|
351
|
+
assert.match(result.detail, /no model session spent/);
|
|
352
|
+
assert.equal(fs.existsSync(record), false, "no child spawned, so the fixture never recorded");
|
|
353
|
+
});
|
|
354
|
+
test("an empty deck list fails closed without a model session", async () => {
|
|
355
|
+
const record = path.join(process.env.AGENT_DEALER_HOME, `no-decks-${randomUUID()}.json`);
|
|
356
|
+
const result = await probeWith("capability-shell", { FAKE_MUSE_RECORD: record }, { listDecks: async () => ({ ok: true, decks: [] }) });
|
|
357
|
+
assert.equal(result.status, "error");
|
|
358
|
+
assert.match(result.detail, /no decks/);
|
|
359
|
+
assert.match(result.detail, /no model session spent/);
|
|
360
|
+
assert.equal(fs.existsSync(record), false, "no child spawned, so the fixture never recorded");
|
|
361
|
+
});
|
|
362
|
+
test("a session that ran the shell but then timed out is could-not-verify, never capable", async () => {
|
|
363
|
+
const result = await probeWith("capability-shell-then-hang", {}, { timeoutMs: 1500 });
|
|
364
|
+
assert.equal(result.status, "error");
|
|
365
|
+
assert.match(result.detail, /timed out/);
|
|
366
|
+
});
|
|
367
|
+
test("a session that ran the shell but then failed is could-not-verify, never capable", async () => {
|
|
368
|
+
const result = await probeWith("capability-shell-then-fail");
|
|
369
|
+
assert.equal(result.status, "error");
|
|
370
|
+
assert.match(result.detail, /probe session (failed|exited)/);
|
|
371
|
+
});
|
|
372
|
+
test("the probe never runs on the shared serve host (deck-enabled turns are always isolated exec)", async () => {
|
|
373
|
+
const { getMuseCapacityHost, resetMuseCapacityHostForTests } = await import("../capacity/muse-host.js");
|
|
374
|
+
await resetMuseCapacityHostForTests();
|
|
375
|
+
let serveSpawns = 0;
|
|
376
|
+
getMuseCapacityHost({
|
|
377
|
+
spawnImpl: (() => {
|
|
378
|
+
serveSpawns += 1;
|
|
379
|
+
throw new Error("serve host must not be used by the capability probe");
|
|
380
|
+
}),
|
|
381
|
+
});
|
|
382
|
+
try {
|
|
383
|
+
assert.deepEqual(await probeWith("capability-shell"), { status: "capable" });
|
|
384
|
+
assert.equal(serveSpawns, 0);
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
await resetMuseCapacityHostForTests();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
test("a binary that changes version during the probe is could-not-verify, not capable", async () => {
|
|
391
|
+
const versionFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-ver-")), "version");
|
|
392
|
+
fs.writeFileSync(versionFile, NEW);
|
|
393
|
+
const result = await probeWith("capability-shell", {
|
|
394
|
+
FAKE_MUSE_VERSION_FILE: versionFile,
|
|
395
|
+
FAKE_MUSE_UPDATE_TO: "1.5.0-R5000.1",
|
|
396
|
+
});
|
|
397
|
+
assert.equal(result.status, "error");
|
|
398
|
+
assert.match(result.detail, /1\.5\.0-R5000\.1 after probing 1\.4\.0-R4161\.1/);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
// NOT-278: deck-enabled developer turns always run the isolated `muse exec` lane — the shared
|
|
402
|
+
// serve host cannot carry per-session deck/workspace identity, so no version check can route a
|
|
403
|
+
// session onto it. The capability gate still admits (or blocks) the on-disk binary; the lane is
|
|
404
|
+
// always exec.
|
|
405
|
+
describe("deck-enabled developer sessions always use the isolated exec lane", { concurrency: false }, () => {
|
|
406
|
+
const FAKE_MUSE = path.join(path.dirname(fileURLToPath(import.meta.url)), "../coordinator/fixtures/fake-muse.mjs");
|
|
407
|
+
/** One deck-enabled developer session while a serve host exists; returns host spawn attempts. */
|
|
408
|
+
async function sessionBesideServeHost() {
|
|
409
|
+
const { getMuseCapacityHost, resetMuseCapacityHostForTests } = await import("../capacity/muse-host.js");
|
|
410
|
+
const { runMuseDeveloperSession } = await import("../coordinator/muse-spawn.js");
|
|
411
|
+
await resetMuseCapacityHostForTests();
|
|
412
|
+
let serveSpawns = 0;
|
|
413
|
+
getMuseCapacityHost({
|
|
414
|
+
spawnImpl: (() => {
|
|
415
|
+
serveSpawns += 1;
|
|
416
|
+
throw new Error("serve host must not be used by a deck-enabled developer session");
|
|
417
|
+
}),
|
|
418
|
+
});
|
|
419
|
+
// NOT-278: home scratch, never OS temp (the attempt rejects temp dirs).
|
|
420
|
+
const dir = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-exec-"));
|
|
421
|
+
const configHome = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-exec-cfg-"));
|
|
422
|
+
const keys = ["MUSE_CLI", "FAKE_MUSE_SCENARIO", "META_API_KEY", "XDG_CONFIG_HOME"];
|
|
423
|
+
const prev = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
424
|
+
Object.assign(process.env, {
|
|
425
|
+
MUSE_CLI: FAKE_MUSE,
|
|
426
|
+
FAKE_MUSE_SCENARIO: "success",
|
|
427
|
+
META_API_KEY: "mk-test-fake-key-0123456789abcdef",
|
|
428
|
+
XDG_CONFIG_HOME: configHome,
|
|
429
|
+
});
|
|
430
|
+
try {
|
|
431
|
+
const { execFileSync } = await import("node:child_process");
|
|
432
|
+
execFileSync("git", ["init", "-q"], { cwd: dir });
|
|
433
|
+
const run = await runMuseDeveloperSession({
|
|
434
|
+
sessionId: randomUUID(),
|
|
435
|
+
runtime: "muse_code",
|
|
436
|
+
policy: {},
|
|
437
|
+
model: null,
|
|
438
|
+
deckId: "00000000-0000-4000-a000-000000000099",
|
|
439
|
+
agentDeckUrl: "http://127.0.0.1:1110/mcp",
|
|
440
|
+
prompt: "implement",
|
|
441
|
+
cwd: dir,
|
|
442
|
+
timeoutMs: 30_000,
|
|
443
|
+
logPath: path.join(dir, "session.ndjson"),
|
|
444
|
+
});
|
|
445
|
+
return { exitCode: run.exitCode, serveSpawns };
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
for (const k of keys) {
|
|
449
|
+
if (prev[k] === undefined)
|
|
450
|
+
delete process.env[k];
|
|
451
|
+
else
|
|
452
|
+
process.env[k] = prev[k];
|
|
453
|
+
}
|
|
454
|
+
await resetMuseCapacityHostForTests();
|
|
455
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
456
|
+
fs.rmSync(configHome, { recursive: true, force: true });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
test("the session runs on the exec lane and never touches the serve host", async () => {
|
|
460
|
+
const { exitCode, serveSpawns } = await sessionBesideServeHost();
|
|
461
|
+
assert.equal(exitCode, 0);
|
|
462
|
+
assert.equal(serveSpawns, 0, "no serve-host spawn for a deck-enabled developer turn");
|
|
463
|
+
});
|
|
464
|
+
});
|