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
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
// packages/server/src/coordinator/muse-developer.integration.test.ts
|
|
2
2
|
//
|
|
3
|
-
// NOT-
|
|
4
|
-
// (startWorkflow → tick → routing) and the real developer effect —
|
|
5
|
-
// real spawn of a *fake Muse process* (fixtures/fake-muse.mjs)
|
|
6
|
-
//
|
|
7
|
-
|
|
3
|
+
// NOT-278 acceptance: a `muse_code` developer profile with a deck goes through the real
|
|
4
|
+
// coordinator (startWorkflow → tick → routing) and the real developer effect — deck preflight,
|
|
5
|
+
// real git worktree, real push, real spawn of a *fake Muse process* (fixtures/fake-muse.mjs)
|
|
6
|
+
// on the isolated exec lane, fake GitHub. No paid service is ever called. Scenarios are picked
|
|
7
|
+
// with FAKE_MUSE_SCENARIO; the invocation is recorded to a file.
|
|
8
|
+
//
|
|
9
|
+
// The per-attempt Muse home lives under the Dealer-owned worker MCP config root (outside the
|
|
10
|
+
// worktree and outside OS temp — the temp roots are refused, so every fixture root here is a
|
|
11
|
+
// home scratch dir). The serve lane is never used for a deck-enabled Muse turn.
|
|
12
|
+
import { test, before, beforeEach, after, afterEach } from "node:test";
|
|
8
13
|
import assert from "node:assert/strict";
|
|
9
14
|
import { execFileSync } from "node:child_process";
|
|
10
15
|
import fs from "node:fs";
|
|
11
16
|
import os from "node:os";
|
|
12
17
|
import path from "node:path";
|
|
13
18
|
import { fileURLToPath } from "node:url";
|
|
14
|
-
|
|
19
|
+
const HOME_SCRATCH = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-it-"));
|
|
20
|
+
process.env.AGENT_DEALER_HOME = path.join(HOME_SCRATCH, "dealer-home");
|
|
21
|
+
fs.mkdirSync(process.env.AGENT_DEALER_HOME, { recursive: true });
|
|
15
22
|
process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
|
|
16
23
|
process.env.MAX_COORDINATOR_CONCURRENCY = "1";
|
|
17
24
|
process.env.COORDINATOR_HEARTBEAT_MS = "20";
|
|
@@ -42,32 +49,68 @@ const { runCoordinatorTick, drainCoordinator } = await import("./worker-loop.js"
|
|
|
42
49
|
const { runDeveloperEffect } = await import("./developer-effect.js");
|
|
43
50
|
const { realDeveloperSpawn } = await import("./spawn.js");
|
|
44
51
|
const { realGithubAdapter } = await import("../adapters/github.js");
|
|
45
|
-
const { runMuseDeveloperSession
|
|
52
|
+
const { runMuseDeveloperSession } = await import("./muse-spawn.js");
|
|
53
|
+
const { getAgentDeckMcpUrl } = await import("../adapters/agent-deck.js");
|
|
54
|
+
const { getWorkerMcpConfigDir } = await import("../paths.js");
|
|
55
|
+
const { assertMuseSettings } = await import("../runners/muse-config.js");
|
|
46
56
|
before(() => migrate());
|
|
47
57
|
beforeEach(() => {
|
|
48
58
|
getDb().exec("DELETE FROM work_items; DELETE FROM runtime_availability");
|
|
49
59
|
fs.rmSync(RECORD, { force: true });
|
|
50
60
|
delete process.env.FAKE_MUSE_SCENARIO;
|
|
61
|
+
saveEnv();
|
|
62
|
+
// Deterministic credentials: no saved login under this config home, so the attempt uses the
|
|
63
|
+
// fake API key on stdin — unless a test creates muse/auth.json below it (the symlink path).
|
|
64
|
+
// The sentinel vars prove the child receives exactly the approved environment.
|
|
65
|
+
process.env.XDG_CONFIG_HOME = fs.mkdtempSync(path.join(HOME_SCRATCH, "cfg-"));
|
|
66
|
+
process.env.META_API_KEY = FAKE_API_KEY;
|
|
67
|
+
process.env.MUSE_SENTINEL = "must-not-reach-child";
|
|
68
|
+
process.env.CODEX_HOME = path.join(HOME_SCRATCH, "codex-home-sentinel");
|
|
69
|
+
process.env.DEALER_MUSE_SENTINEL = "must-not-reach-child";
|
|
51
70
|
});
|
|
71
|
+
afterEach(() => restoreEnv());
|
|
52
72
|
after(() => {
|
|
53
73
|
resetEffectHandlers();
|
|
54
74
|
delete process.env.DEVELOPER_TIMEOUT_MS;
|
|
75
|
+
restoreEnv();
|
|
76
|
+
fs.rmSync(HOME_SCRATCH, { recursive: true, force: true });
|
|
55
77
|
});
|
|
78
|
+
/** Fake API key for the stdin-delivery path — assertions require it to appear nowhere. */
|
|
79
|
+
const FAKE_API_KEY = "mk-test-fake-key-0123456789abcdef";
|
|
80
|
+
const MANAGED_ENV = ["XDG_CONFIG_HOME", "META_API_KEY", "MUSE_SENTINEL", "CODEX_HOME", "DEALER_MUSE_SENTINEL"];
|
|
81
|
+
let savedEnv = {};
|
|
82
|
+
function saveEnv() {
|
|
83
|
+
savedEnv = Object.fromEntries(MANAGED_ENV.map((k) => [k, process.env[k]]));
|
|
84
|
+
}
|
|
85
|
+
function restoreEnv() {
|
|
86
|
+
for (const k of MANAGED_ENV) {
|
|
87
|
+
if (savedEnv[k] === undefined)
|
|
88
|
+
delete process.env[k];
|
|
89
|
+
else
|
|
90
|
+
process.env[k] = savedEnv[k];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
56
93
|
const TEST_DECK_ID = "00000000-0000-4000-a000-000000000099";
|
|
94
|
+
/** The MCP endpoint the spawn binds: the configured endpoint, always ending in `/mcp`. */
|
|
95
|
+
function expectedMcpUrl() {
|
|
96
|
+
return `${getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "")}/mcp`;
|
|
97
|
+
}
|
|
57
98
|
let repo;
|
|
58
99
|
let remote;
|
|
59
100
|
function git(cwd, ...args) {
|
|
60
101
|
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
61
102
|
}
|
|
62
103
|
before(() => {
|
|
63
|
-
|
|
104
|
+
// Home scratch, never OS temp: the per-attempt guard refuses temp-dir worktrees, and the
|
|
105
|
+
// coordinator worktree lives beside the repo it was cloned from.
|
|
106
|
+
repo = fs.mkdtempSync(path.join(HOME_SCRATCH, "dealer-muse-repo-"));
|
|
64
107
|
git(repo, "init", "-q", "-b", "main");
|
|
65
108
|
git(repo, "config", "user.email", "test@example.com");
|
|
66
109
|
git(repo, "config", "user.name", "Test");
|
|
67
110
|
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
|
68
111
|
git(repo, "add", ".");
|
|
69
112
|
git(repo, "commit", "-q", "-m", "init");
|
|
70
|
-
remote = fs.mkdtempSync(path.join(
|
|
113
|
+
remote = fs.mkdtempSync(path.join(HOME_SCRATCH, "dealer-muse-remote-"));
|
|
71
114
|
execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
|
|
72
115
|
git(repo, "remote", "add", "origin", remote);
|
|
73
116
|
git(repo, "push", "-q", "origin", "main");
|
|
@@ -77,8 +120,17 @@ after(() => {
|
|
|
77
120
|
fs.rmSync(remote, { recursive: true, force: true });
|
|
78
121
|
});
|
|
79
122
|
function makeMuseIssue(opts = {}) {
|
|
80
|
-
// The Agents form
|
|
81
|
-
|
|
123
|
+
// The Agents form requires a deck; the Muse developer session receives it on the exec lane.
|
|
124
|
+
// A null deckId simulates a legacy deck-less profile: the form can no longer produce one,
|
|
125
|
+
// so create with a deck and clear the column directly.
|
|
126
|
+
const dev = createAgent({
|
|
127
|
+
name: `muse-${Math.random()}`,
|
|
128
|
+
runtime: "muse_code",
|
|
129
|
+
deckId: opts.deckId ?? TEST_DECK_ID,
|
|
130
|
+
});
|
|
131
|
+
if (opts.deckId === null) {
|
|
132
|
+
getDb().prepare("UPDATE agents SET deck_id = NULL WHERE id = ?").run(dev.id);
|
|
133
|
+
}
|
|
82
134
|
const rev = createAgent({ name: `rev-${Math.random()}`, runtime: "codex_local", deckId: TEST_DECK_ID });
|
|
83
135
|
return createIssue({
|
|
84
136
|
title: "Add widget",
|
|
@@ -121,12 +173,25 @@ function fakeGithub() {
|
|
|
121
173
|
},
|
|
122
174
|
};
|
|
123
175
|
}
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
176
|
+
/** Live deck preflight stub: the test deck answers as bound (same shape as the real MCP tool). */
|
|
177
|
+
function museDeckCallTool(deckId = TEST_DECK_ID) {
|
|
178
|
+
return async (name, _args) => {
|
|
179
|
+
if (name !== "get_bound_deck")
|
|
180
|
+
throw new Error(`unexpected deck call ${name}`);
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: "text", text: JSON.stringify({ id: deckId, name: "test-deck" }) }],
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function registerMuseDeveloper(deckCallTool = museDeckCallTool()) {
|
|
187
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool, spawn: realDeveloperSpawn, github: fakeGithub() }));
|
|
188
|
+
}
|
|
189
|
+
/** Spawn wrapper that counts invocations — proves preflight failures never reach a child process. */
|
|
190
|
+
function countingSpawn(counter) {
|
|
191
|
+
return async (input) => {
|
|
192
|
+
counter.count += 1;
|
|
193
|
+
return realDeveloperSpawn(input);
|
|
194
|
+
};
|
|
130
195
|
}
|
|
131
196
|
async function pump(max = 1) {
|
|
132
197
|
for (let i = 0; i < max; i++) {
|
|
@@ -137,6 +202,26 @@ async function pump(max = 1) {
|
|
|
137
202
|
function recorded() {
|
|
138
203
|
return JSON.parse(fs.readFileSync(RECORD, "utf8"));
|
|
139
204
|
}
|
|
205
|
+
/** The captured settings file: exactly one required agent-deck server, accepted by the guard. */
|
|
206
|
+
function expectDeckSettings(rec, deckId = TEST_DECK_ID) {
|
|
207
|
+
const settings = JSON.parse(rec.settings);
|
|
208
|
+
assert.deepEqual(Object.keys(settings).sort(), ["mcpServers", "run", "runtime_capabilities", "schema_version"]);
|
|
209
|
+
assert.deepEqual(Object.keys(settings.mcpServers), ["agent-deck"]);
|
|
210
|
+
const server = settings.mcpServers["agent-deck"];
|
|
211
|
+
assert.equal(server.type, "streamable-http");
|
|
212
|
+
assert.equal(server.url, expectedMcpUrl());
|
|
213
|
+
assert.equal(server.mode, "required");
|
|
214
|
+
// cwdReal was resolved by the fixture at spawn time: the coordinator removes the
|
|
215
|
+
// worktree after the turn, so resolving rec.cwd here would race cleanup.
|
|
216
|
+
assert.deepEqual(server.headers, {
|
|
217
|
+
"x-agent-deck-deck-id": deckId,
|
|
218
|
+
"x-agent-deck-workspace": rec.cwdReal,
|
|
219
|
+
});
|
|
220
|
+
assert.equal("enabled_tools" in server, false, "developers are unfiltered: no allowlist claim");
|
|
221
|
+
assert.equal("disabled_tools" in server, false, "developers are unfiltered: no denylist claim");
|
|
222
|
+
assertMuseSettings(settings, { url: expectedMcpUrl(), deckId, workspace: rec.cwdReal }, "developer");
|
|
223
|
+
return settings;
|
|
224
|
+
}
|
|
140
225
|
test("fake-Muse developer session goes admission → verified branch → draft PR, with a frozen muse_code snapshot", async () => {
|
|
141
226
|
const issueId = makeMuseIssue();
|
|
142
227
|
registerMuseDeveloper();
|
|
@@ -149,8 +234,6 @@ test("fake-Muse developer session goes admission → verified branch → draft P
|
|
|
149
234
|
assert.ok(issue.prNumber);
|
|
150
235
|
assert.ok(git(repo, "ls-remote", "origin", `refs/heads/${issue.branch}`).length > 0);
|
|
151
236
|
assert.equal(git(remote, "show", `${issue.branch}:feature.txt`), "implemented");
|
|
152
|
-
// The per-attempt config dir was excluded from `git add .` and never reached the pushed tree.
|
|
153
|
-
assert.equal(git(remote, "ls-tree", "-r", "--name-only", issue.branch).includes(MUSE_ATTEMPT_ROOT), false);
|
|
154
237
|
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
155
238
|
assert.equal(dev.status, "done");
|
|
156
239
|
assert.equal(dev.runtime, "muse_code");
|
|
@@ -160,15 +243,17 @@ test("fake-Muse developer session goes admission → verified branch → draft P
|
|
|
160
243
|
const conclusion = listArtifactsForIssue(issueId).find((a) => a.kind === "implementation_conclusion");
|
|
161
244
|
assert.match(String(JSON.parse(conclusion.contentJson).text), /added the widget/);
|
|
162
245
|
});
|
|
163
|
-
test("the
|
|
246
|
+
test("the turn runs the isolated exec lane (never serve) with the required agent-deck server and the exact environment", async () => {
|
|
164
247
|
const issueId = makeMuseIssue();
|
|
165
248
|
registerMuseDeveloper();
|
|
166
249
|
process.env.FAKE_MUSE_SCENARIO = "success";
|
|
167
250
|
startWorkflow(issueId);
|
|
168
251
|
await pump();
|
|
169
252
|
const rec = recorded();
|
|
253
|
+
// Exec lane, not the shared serve host: the serve protocol has no per-session MCP identity and
|
|
254
|
+
// could never produce a per-attempt settings file — the binary itself ran here.
|
|
170
255
|
assert.equal(rec.argv[0], "exec");
|
|
171
|
-
for (const expected of ["--json", "--no-foreign-personal-context", "--disable-web-tools"]) {
|
|
256
|
+
for (const expected of ["--json", "--no-foreign-personal-context", "--disable-web-tools", "--api-key-stdin"]) {
|
|
172
257
|
assert.ok(rec.argv.includes(expected), expected);
|
|
173
258
|
}
|
|
174
259
|
assert.equal(rec.argv[rec.argv.indexOf("--model") + 1], MUSE_CODE_CONTRIBUTOR_MODEL);
|
|
@@ -179,15 +264,52 @@ test("the spawn is the pinned developer posture with per-attempt config under th
|
|
|
179
264
|
assert.ok(!rec.argv.includes(forbidden), forbidden);
|
|
180
265
|
}
|
|
181
266
|
assert.equal(rec.noAutoUpdate, "1");
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
assert.
|
|
187
|
-
assert.
|
|
188
|
-
|
|
267
|
+
// Per-attempt config lives under the Dealer-owned worker MCP config root — outside the
|
|
268
|
+
// worktree and outside OS temp — and is removed after the session.
|
|
269
|
+
const mcpRoot = fs.realpathSync(getWorkerMcpConfigDir());
|
|
270
|
+
assert.ok(rec.xdgConfigHome.startsWith(`${mcpRoot}${path.sep}`), rec.xdgConfigHome);
|
|
271
|
+
assert.ok(rec.xdgDataHome.startsWith(`${mcpRoot}${path.sep}`), rec.xdgDataHome);
|
|
272
|
+
assert.ok(!rec.xdgConfigHome.startsWith(rec.cwd), "config is not under the worktree");
|
|
273
|
+
assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after success");
|
|
274
|
+
assert.equal(fs.existsSync(rec.xdgDataHome), false, "per-attempt data removed after success");
|
|
275
|
+
expectDeckSettings(rec);
|
|
276
|
+
// Exact environment: ambient secrets and sentinels never reached the child.
|
|
277
|
+
assert.deepEqual(rec.envLeak, {
|
|
278
|
+
META_API_KEY: false,
|
|
279
|
+
MUSE_SENTINEL: false,
|
|
280
|
+
CODEX_HOME: false,
|
|
281
|
+
DEALER_MUSE_SENTINEL: false,
|
|
282
|
+
});
|
|
283
|
+
// API-key auth travels on stdin only: the key is in neither argv, env, settings, nor the record.
|
|
284
|
+
assert.equal(rec.apiKeyStdin, true);
|
|
285
|
+
assert.equal(rec.stdinBytes, FAKE_API_KEY.length + 1);
|
|
286
|
+
assert.equal(rec.authLinked, false, "api-key auth links no auth file");
|
|
287
|
+
const recordRaw = fs.readFileSync(RECORD, "utf8");
|
|
288
|
+
assert.ok(!recordRaw.includes(FAKE_API_KEY), "record holds a stdin length, never the key");
|
|
289
|
+
assert.ok(!rec.settings.includes(FAKE_API_KEY), "settings hold no key");
|
|
290
|
+
// The config dir is outside the worktree, so the worktree was porcelain-clean at spawn time.
|
|
189
291
|
assert.equal(rec.porcelain, "");
|
|
190
292
|
});
|
|
293
|
+
test("saved-login auth is symlinked, never copied, and carries no stdin key", async () => {
|
|
294
|
+
const operatorAuthDir = path.join(process.env.XDG_CONFIG_HOME, "muse");
|
|
295
|
+
fs.mkdirSync(operatorAuthDir, { recursive: true });
|
|
296
|
+
const operatorAuth = path.join(operatorAuthDir, "auth.json");
|
|
297
|
+
fs.writeFileSync(operatorAuth, '{"token":"operator-owned"}');
|
|
298
|
+
const issueId = makeMuseIssue();
|
|
299
|
+
registerMuseDeveloper();
|
|
300
|
+
process.env.FAKE_MUSE_SCENARIO = "success";
|
|
301
|
+
startWorkflow(issueId);
|
|
302
|
+
await pump();
|
|
303
|
+
assert.equal(getIssue(issueId).status, "reviewing");
|
|
304
|
+
const rec = recorded();
|
|
305
|
+
assert.equal(rec.authLinked, true);
|
|
306
|
+
assert.equal(rec.authSymlink, true, "the login is linked, never copied");
|
|
307
|
+
assert.equal(rec.apiKeyStdin, false);
|
|
308
|
+
assert.equal(rec.stdinBytes, 0, "no key on stdin for saved-login auth");
|
|
309
|
+
assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config (and link) removed");
|
|
310
|
+
assert.equal(fs.readFileSync(operatorAuth, "utf8"), '{"token":"operator-owned"}', "operator credentials untouched");
|
|
311
|
+
expectDeckSettings(rec);
|
|
312
|
+
});
|
|
191
313
|
test("usage events record muse_code, the confirmed model, duration and tokens; cost stays null", async () => {
|
|
192
314
|
const issueId = makeMuseIssue();
|
|
193
315
|
registerMuseDeveloper();
|
|
@@ -232,6 +354,8 @@ test("auth failure takes the infra-retry path without spending a review round",
|
|
|
232
354
|
const retry = listWorkItemsForIssue(issueId).find((w) => w.status === "pending");
|
|
233
355
|
assert.ok(retry, "a fresh developer attempt is queued");
|
|
234
356
|
assert.equal(listUsageEventsForIssue(issueId).length, 1, "the failed spawn still records usage");
|
|
357
|
+
const rec = recorded();
|
|
358
|
+
assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after auth failure");
|
|
235
359
|
});
|
|
236
360
|
test("a usage cap defers the work item and records Muse availability, spending no attempt", async () => {
|
|
237
361
|
const issueId = makeMuseIssue();
|
|
@@ -240,8 +364,8 @@ test("a usage cap defers the work item and records Muse availability, spending n
|
|
|
240
364
|
startWorkflow(issueId);
|
|
241
365
|
await pump();
|
|
242
366
|
const issue = getIssue(issueId);
|
|
243
|
-
assert.equal(issue.infraAttempts, 0);
|
|
244
367
|
assert.equal(issue.currentRound, 1);
|
|
368
|
+
assert.equal(issue.infraAttempts, 0, "a usage cap defers without spending an attempt");
|
|
245
369
|
const item = listWorkItemsForIssue(issueId)[0];
|
|
246
370
|
assert.equal(item.status, "pending");
|
|
247
371
|
assert.ok(Date.parse(item.availableAt) > Date.now());
|
|
@@ -267,6 +391,8 @@ test("a timeout follows the existing timeout path (infra retry, no review round)
|
|
|
267
391
|
assert.equal(issue.infraAttempts, 1);
|
|
268
392
|
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
269
393
|
assert.equal(dev.status, "timed_out");
|
|
394
|
+
const rec = recorded();
|
|
395
|
+
assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after timeout");
|
|
270
396
|
});
|
|
271
397
|
test("a run whose server-confirmed model differs is a failed session, not a handoff", async () => {
|
|
272
398
|
const issueId = makeMuseIssue();
|
|
@@ -304,14 +430,68 @@ for (const [scenario, tool] of [
|
|
|
304
430
|
assert.equal(action?.actionType, "policy_escalation");
|
|
305
431
|
assert.match(action?.reason ?? JSON.stringify(action), /muse_cron_used/);
|
|
306
432
|
assert.ok(listWorkflowEventsForIssue(issueId).some((e) => e.type === "worker.failed"));
|
|
307
|
-
// The worktree is kept for inspection
|
|
308
|
-
|
|
433
|
+
// The worktree is kept for inspection; the per-attempt config outside it is removed.
|
|
434
|
+
const rec = recorded();
|
|
435
|
+
assert.equal(fs.existsSync(rec.xdgConfigHome), false);
|
|
436
|
+
assert.equal(fs.existsSync(rec.xdgDataHome), false);
|
|
309
437
|
assert.equal(listUsageEventsForIssue(issueId).length, 1, "the spend is still recorded");
|
|
310
438
|
});
|
|
311
439
|
}
|
|
440
|
+
// ── deck admission: Muse is fail-closed without a deck, like every runtime ───────────────────
|
|
441
|
+
test("a Muse developer profile without deckId fails as deck_failure before any Muse process starts", async () => {
|
|
442
|
+
const issueId = makeMuseIssue({ deckId: null });
|
|
443
|
+
const spawnCounter = { count: 0 };
|
|
444
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
|
|
445
|
+
deckCallTool: museDeckCallTool(),
|
|
446
|
+
spawn: countingSpawn(spawnCounter),
|
|
447
|
+
github: fakeGithub(),
|
|
448
|
+
}));
|
|
449
|
+
assert.equal(startWorkflow(issueId).ok, true);
|
|
450
|
+
await pump();
|
|
451
|
+
assert.equal(spawnCounter.count, 0, "no spawn without a deck");
|
|
452
|
+
assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
|
|
453
|
+
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
454
|
+
assert.equal(dev.status, "failed");
|
|
455
|
+
assert.match(dev.errorJson ?? "", /deck/i);
|
|
456
|
+
assert.notEqual(getIssue(issueId).status, "reviewing");
|
|
457
|
+
});
|
|
458
|
+
test("an unreachable deck waits as deck_unavailable without spawning", async () => {
|
|
459
|
+
const issueId = makeMuseIssue();
|
|
460
|
+
const spawnCounter = { count: 0 };
|
|
461
|
+
const unreachable = async () => {
|
|
462
|
+
throw Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1110"), { code: "ECONNREFUSED" });
|
|
463
|
+
};
|
|
464
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
|
|
465
|
+
deckCallTool: unreachable,
|
|
466
|
+
spawn: countingSpawn(spawnCounter),
|
|
467
|
+
github: fakeGithub(),
|
|
468
|
+
}));
|
|
469
|
+
assert.equal(startWorkflow(issueId).ok, true);
|
|
470
|
+
await pump();
|
|
471
|
+
assert.equal(spawnCounter.count, 0, "no spawn while the deck is unreachable");
|
|
472
|
+
assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
|
|
473
|
+
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
474
|
+
assert.equal(dev.status, "cancelled", "a deck wait is a cancellation, never a crash");
|
|
475
|
+
});
|
|
476
|
+
test("a responding but wrong deck fails as deck_failure without spawning", async () => {
|
|
477
|
+
const issueId = makeMuseIssue();
|
|
478
|
+
const spawnCounter = { count: 0 };
|
|
479
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
|
|
480
|
+
deckCallTool: museDeckCallTool("some-other-deck-id"),
|
|
481
|
+
spawn: countingSpawn(spawnCounter),
|
|
482
|
+
github: fakeGithub(),
|
|
483
|
+
}));
|
|
484
|
+
assert.equal(startWorkflow(issueId).ok, true);
|
|
485
|
+
await pump();
|
|
486
|
+
assert.equal(spawnCounter.count, 0, "no spawn on a deck mismatch");
|
|
487
|
+
assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
|
|
488
|
+
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
489
|
+
assert.equal(dev.status, "failed");
|
|
490
|
+
assert.match(dev.errorJson ?? "", /deck/i);
|
|
491
|
+
});
|
|
312
492
|
// ── per-attempt config cleanup, on the spawn itself ────────────────────────────────────────────
|
|
313
493
|
function scratchWorktree() {
|
|
314
|
-
const wt = fs.mkdtempSync(path.join(
|
|
494
|
+
const wt = fs.mkdtempSync(path.join(HOME_SCRATCH, "dealer-muse-wt-"));
|
|
315
495
|
git(wt, "init", "-q", "-b", "main");
|
|
316
496
|
git(wt, "config", "user.email", "t@example.com");
|
|
317
497
|
git(wt, "config", "user.name", "T");
|
|
@@ -320,26 +500,40 @@ function scratchWorktree() {
|
|
|
320
500
|
git(wt, "commit", "-q", "-m", "init");
|
|
321
501
|
return wt;
|
|
322
502
|
}
|
|
503
|
+
/** Direct spawn input on a scratch worktree: the deck comes from the frozen profile value. */
|
|
504
|
+
function directInput(wt, sessionId, extra = {}) {
|
|
505
|
+
return {
|
|
506
|
+
sessionId,
|
|
507
|
+
runtime: "muse_code",
|
|
508
|
+
policy: { worktreeWrite: true },
|
|
509
|
+
model: MUSE_CODE_CONTRIBUTOR_MODEL,
|
|
510
|
+
deckId: TEST_DECK_ID,
|
|
511
|
+
agentDeckUrl: "http://127.0.0.1:1110/mcp",
|
|
512
|
+
prompt: "Implement it",
|
|
513
|
+
cwd: wt,
|
|
514
|
+
timeoutMs: 20_000,
|
|
515
|
+
logPath: path.join(HOME_SCRATCH, `muse-log-${sessionId}.ndjson`),
|
|
516
|
+
...extra,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
323
519
|
for (const scenario of ["success", "auth", "usage-cap", "cron"]) {
|
|
324
520
|
test(`per-attempt Muse config is removed after a ${scenario} session`, async () => {
|
|
325
521
|
const wt = scratchWorktree();
|
|
326
522
|
try {
|
|
327
523
|
process.env.FAKE_MUSE_SCENARIO = scenario;
|
|
328
|
-
const result = await runMuseDeveloperSession(
|
|
329
|
-
sessionId: "00000000-0000-4000-8000-000000000001",
|
|
330
|
-
runtime: "muse_code",
|
|
331
|
-
policy: { worktreeWrite: true },
|
|
332
|
-
model: MUSE_CODE_CONTRIBUTOR_MODEL,
|
|
333
|
-
prompt: "Implement it",
|
|
334
|
-
cwd: wt,
|
|
335
|
-
timeoutMs: 20_000,
|
|
336
|
-
logPath: path.join(wt, "..", `muse-log-${scenario}-${Date.now()}.ndjson`),
|
|
337
|
-
});
|
|
524
|
+
const result = await runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000001"));
|
|
338
525
|
const rec = recorded();
|
|
339
|
-
|
|
340
|
-
assert.
|
|
526
|
+
const mcpRoot = fs.realpathSync(getWorkerMcpConfigDir());
|
|
527
|
+
assert.ok(rec.xdgConfigHome.startsWith(`${mcpRoot}${path.sep}`), "config was written outside the worktree and outside temp");
|
|
341
528
|
assert.equal(fs.existsSync(rec.xdgConfigHome), false);
|
|
342
529
|
assert.equal(fs.existsSync(rec.xdgDataHome), false);
|
|
530
|
+
assert.deepEqual(rec.envLeak, {
|
|
531
|
+
META_API_KEY: false,
|
|
532
|
+
MUSE_SENTINEL: false,
|
|
533
|
+
CODEX_HOME: false,
|
|
534
|
+
DEALER_MUSE_SENTINEL: false,
|
|
535
|
+
});
|
|
536
|
+
assert.ok(!fs.readFileSync(RECORD, "utf8").includes(FAKE_API_KEY), "no key in the record");
|
|
343
537
|
assert.ok(result.muse);
|
|
344
538
|
fs.rmSync(result.logPath, { force: true });
|
|
345
539
|
if (result.muse.rawLogPath)
|
|
@@ -354,21 +548,63 @@ test("per-attempt Muse config is removed when the process cannot be started", as
|
|
|
354
548
|
const wt = scratchWorktree();
|
|
355
549
|
const prev = process.env.MUSE_CLI;
|
|
356
550
|
process.env.MUSE_CLI = path.join(wt, "no-such-muse");
|
|
551
|
+
const before = new Set(fs.readdirSync(getWorkerMcpConfigDir()));
|
|
357
552
|
try {
|
|
358
|
-
await assert.rejects(runMuseDeveloperSession(
|
|
359
|
-
|
|
360
|
-
runtime: "muse_code",
|
|
361
|
-
policy: { worktreeWrite: true },
|
|
362
|
-
model: MUSE_CODE_CONTRIBUTOR_MODEL,
|
|
363
|
-
prompt: "Implement it",
|
|
364
|
-
cwd: wt,
|
|
365
|
-
timeoutMs: 5_000,
|
|
366
|
-
logPath: path.join(wt, "..", `muse-log-enoent-${Date.now()}.ndjson`),
|
|
367
|
-
}));
|
|
368
|
-
assert.equal(fs.existsSync(path.join(wt, MUSE_ATTEMPT_ROOT)), false);
|
|
553
|
+
await assert.rejects(runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000002")));
|
|
554
|
+
assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
|
|
369
555
|
}
|
|
370
556
|
finally {
|
|
371
557
|
process.env.MUSE_CLI = prev;
|
|
372
558
|
fs.rmSync(wt, { recursive: true, force: true });
|
|
373
559
|
}
|
|
560
|
+
const after = new Set(fs.readdirSync(getWorkerMcpConfigDir()));
|
|
561
|
+
assert.deepEqual([...after].filter((e) => !before.has(e)), [], "no stranded attempt dir");
|
|
562
|
+
});
|
|
563
|
+
test("an aborted session still removes the per-attempt config", async () => {
|
|
564
|
+
const wt = scratchWorktree();
|
|
565
|
+
const controller = new AbortController();
|
|
566
|
+
try {
|
|
567
|
+
process.env.FAKE_MUSE_SCENARIO = "hang";
|
|
568
|
+
const pending = runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000003", {
|
|
569
|
+
timeoutMs: 30_000,
|
|
570
|
+
signal: controller.signal,
|
|
571
|
+
}));
|
|
572
|
+
setTimeout(() => controller.abort(), 500);
|
|
573
|
+
await pending;
|
|
574
|
+
const rec = recorded();
|
|
575
|
+
assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after abort");
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
delete process.env.FAKE_MUSE_SCENARIO;
|
|
579
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
test("missing credentials fail before spawn: nothing is written and no process starts", async () => {
|
|
583
|
+
const wt = scratchWorktree();
|
|
584
|
+
const savedKey = process.env.META_API_KEY;
|
|
585
|
+
const savedCfg = process.env.XDG_CONFIG_HOME;
|
|
586
|
+
process.env.XDG_CONFIG_HOME = fs.mkdtempSync(path.join(HOME_SCRATCH, "cfg-noauth-"));
|
|
587
|
+
delete process.env.META_API_KEY;
|
|
588
|
+
try {
|
|
589
|
+
await assert.rejects(runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000004")), /Muse Code auth required/);
|
|
590
|
+
assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
if (savedKey === undefined)
|
|
594
|
+
delete process.env.META_API_KEY;
|
|
595
|
+
else
|
|
596
|
+
process.env.META_API_KEY = savedKey;
|
|
597
|
+
process.env.XDG_CONFIG_HOME = savedCfg;
|
|
598
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
test("a missing deckId fails before spawn: nothing is written and no process starts", async () => {
|
|
602
|
+
const wt = scratchWorktree();
|
|
603
|
+
try {
|
|
604
|
+
await assert.rejects(runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000005", { deckId: null })), /deckId/);
|
|
605
|
+
assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
609
|
+
}
|
|
374
610
|
});
|