agent-dealer 1.0.4 → 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/bundle/server/dist/adapters/agent-deck-bind.js +8 -26
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +18 -0
- package/bundle/server/dist/adapters/agent-health.js +56 -6
- package/bundle/server/dist/adapters/agent-health.test.js +99 -0
- package/bundle/server/dist/adapters/linear-graphql.js +8 -1
- package/bundle/server/dist/adapters/worktree-exclude.js +40 -0
- package/bundle/server/dist/cli-env.js +29 -0
- package/bundle/server/dist/coordinator/admission.js +14 -3
- package/bundle/server/dist/coordinator/admission.test.js +65 -1
- package/bundle/server/dist/coordinator/args.js +6 -0
- package/bundle/server/dist/coordinator/args.test.js +14 -0
- package/bundle/server/dist/coordinator/commands.js +97 -8
- package/bundle/server/dist/coordinator/commands.test.js +197 -3
- package/bundle/server/dist/coordinator/dependencies.js +64 -6
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +96 -4
- package/bundle/server/dist/coordinator/developer-effect.js +46 -8
- package/bundle/server/dist/coordinator/failure-reason.js +4 -1
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +374 -0
- package/bundle/server/dist/coordinator/muse-spawn.js +170 -0
- package/bundle/server/dist/coordinator/non-convergence.js +64 -0
- package/bundle/server/dist/coordinator/profile-snapshot.test.js +46 -0
- package/bundle/server/dist/coordinator/prompts.js +11 -2
- package/bundle/server/dist/coordinator/prompts.test.js +14 -0
- package/bundle/server/dist/coordinator/routing.js +4 -0
- package/bundle/server/dist/coordinator/routing.test.js +11 -0
- package/bundle/server/dist/coordinator/spawn.js +11 -1
- package/bundle/server/dist/coordinator/worker-loop.js +4 -1
- package/bundle/server/dist/db/index.js +6 -0
- package/bundle/server/dist/db/schema.sql +2 -1
- package/bundle/server/dist/db/worker-session-pid-migration.test.js +9 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +135 -0
- package/bundle/server/dist/repository/agents.js +11 -3
- package/bundle/server/dist/repository/artifacts.js +1 -1
- package/bundle/server/dist/repository/findings.js +17 -0
- package/bundle/server/dist/repository/findings.test.js +17 -1
- package/bundle/server/dist/repository/usage-events.js +4 -2
- package/bundle/server/dist/routes/issues.js +16 -2
- package/bundle/server/dist/routes/issues.test.js +72 -1
- package/bundle/server/dist/runners/models.js +8 -1
- package/bundle/server/dist/runners/muse-code-args.js +52 -0
- package/bundle/server/dist/runners/muse-code-fixtures.test.js +132 -0
- package/bundle/server/dist/runners/muse-code-jsonl.js +285 -0
- package/bundle/server/dist/runners/muse-code-settings.js +23 -0
- package/bundle/server/dist/runners/muse-code-settings.test.js +18 -0
- package/bundle/server/dist/runners/muse-code.test.js +385 -0
- package/bundle/server/dist/runners/usage-cap.js +21 -0
- package/bundle/server/dist/runners/usage-cap.test.js +15 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-PU5ONp31.js +60 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/agent-form-fields.contract.test.d.ts +1 -0
- package/bundle/shared/dist/agent-form-fields.contract.test.js +48 -0
- package/bundle/shared/dist/agents.d.ts +53 -48
- package/bundle/shared/dist/agents.js +10 -0
- package/bundle/shared/dist/agents.test.d.ts +1 -0
- package/bundle/shared/dist/agents.test.js +79 -0
- package/bundle/shared/dist/index.d.ts +98 -98
- package/bundle/shared/dist/playbook-reflect.d.ts +12 -12
- package/bundle/shared/dist/profile-snapshot.d.ts +3 -3
- package/bundle/shared/dist/runtime-auth-health.d.ts +6 -0
- package/bundle/shared/dist/runtime-auth-health.js +24 -1
- package/bundle/shared/dist/runtime-auth-health.test.js +51 -1
- package/bundle/shared/dist/runtime.d.ts +1 -1
- package/bundle/shared/dist/runtime.js +1 -1
- package/bundle/shared/dist/usage-events.d.ts +4 -0
- package/bundle/shared/dist/usage-events.js +2 -0
- package/bundle/shared/dist/worker-sessions.d.ts +6 -6
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +2 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-DXTVo_vz.js +0 -60
|
@@ -19,6 +19,7 @@ import { getAgentDeckMcpUrl } from "./agent-deck.js";
|
|
|
19
19
|
import { getWorkerMcpConfigDir } from "../paths.js";
|
|
20
20
|
import { resolveAmbientCodexHome } from "../cli-env.js";
|
|
21
21
|
import { codexMcpServersTable } from "./codex-scoped-config.js";
|
|
22
|
+
import { ensureWorktreeExcluded } from "./worktree-exclude.js";
|
|
22
23
|
const CODEX_HOME_ENV_VAR = "CODEX_HOME";
|
|
23
24
|
/**
|
|
24
25
|
* NOT-136: a preflight call got no answer at all. Raised **only** where a call is awaited
|
|
@@ -187,32 +188,8 @@ function isCursorMcpTracked(worktreePath) {
|
|
|
187
188
|
const result = gitCaptured(worktreePath, ["ls-files", "--error-unmatch", ".cursor/mcp.json"]);
|
|
188
189
|
return result.ok;
|
|
189
190
|
}
|
|
190
|
-
/** Append `/.cursor/mcp.json` idempotently to git's info/exclude.
|
|
191
|
-
*
|
|
192
|
-
* Note: for a linked worktree, `git rev-parse --git-path info/exclude` resolves to the
|
|
193
|
-
* *main* repository's `.git/info/exclude` (shared across all worktrees of that repo), not
|
|
194
|
-
* a per-worktree file. The `/.cursor/mcp.json` pattern is still correct per worktree root;
|
|
195
|
-
* we rewrite with a single occurrence so concurrent preparations can't leave duplicate lines. */
|
|
196
191
|
function ensureCursorMcpExcluded(worktreePath) {
|
|
197
|
-
|
|
198
|
-
if (!excludePathResult.ok) {
|
|
199
|
-
throw new Error(`could not resolve git info/exclude: ${excludePathResult.stderr || "unknown error"}`);
|
|
200
|
-
}
|
|
201
|
-
const excludePath = path.resolve(worktreePath, excludePathResult.stdout.trim());
|
|
202
|
-
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
|
203
|
-
const line = "/.cursor/mcp.json";
|
|
204
|
-
let existing = "";
|
|
205
|
-
try {
|
|
206
|
-
existing = fs.readFileSync(excludePath, "utf8");
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
existing = "";
|
|
210
|
-
}
|
|
211
|
-
const kept = existing
|
|
212
|
-
.split(/\r?\n/)
|
|
213
|
-
.filter((l) => l.trim() !== "" && l.trim() !== line);
|
|
214
|
-
kept.push(line);
|
|
215
|
-
fs.writeFileSync(excludePath, `${kept.join("\n")}\n`, { mode: 0o644 });
|
|
192
|
+
ensureWorktreeExcluded(worktreePath, "/.cursor/mcp.json");
|
|
216
193
|
}
|
|
217
194
|
/**
|
|
218
195
|
* Writes this attempt's deck-launch MCP config as the spawned worker's route to Agent Deck
|
|
@@ -226,6 +203,11 @@ async function materializeWorkerMcpConfig(opts) {
|
|
|
226
203
|
const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
|
|
227
204
|
const mcpUrl = `${mcpBase}/mcp`;
|
|
228
205
|
const headers = deckLaunchHeaders(opts.deckId, opts.worktreePath);
|
|
206
|
+
// Muse Code has no Agent Deck / MCP wiring (NOT-178 non-goal; NOT-181 owns it). Refuse before
|
|
207
|
+
// anything is written rather than fall through to the Claude config below.
|
|
208
|
+
if (opts.runtime === "muse_code") {
|
|
209
|
+
throw new Error("Muse Code does not support Agent Deck MCP configuration");
|
|
210
|
+
}
|
|
229
211
|
if (opts.runtime === "codex_local") {
|
|
230
212
|
const codexHome = path.join(getWorkerMcpConfigDir(), `codex-home-${opts.deckId.slice(0, 8)}-${randomUUID()}`);
|
|
231
213
|
fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
|
|
@@ -270,7 +252,7 @@ async function materializeWorkerMcpConfig(opts) {
|
|
|
270
252
|
fs.writeFileSync(filePath, JSON.stringify({ mcpServers: { "agent-deck": { url: mcpUrl, headers } } }, null, 2) + "\n", { mode: 0o600 });
|
|
271
253
|
return { mcpConfigPath: filePath };
|
|
272
254
|
}
|
|
273
|
-
// claude_code
|
|
255
|
+
// claude_code — the only runtime left.
|
|
274
256
|
const dir = getWorkerMcpConfigDir();
|
|
275
257
|
const filePath = path.join(dir, `claude-mcp-${opts.deckId.slice(0, 8)}-${randomUUID()}.json`);
|
|
276
258
|
try {
|
|
@@ -59,6 +59,24 @@ test("prepareWorkerDeckConnection verifies and writes a claude MCP config with d
|
|
|
59
59
|
assert.equal(fs.existsSync(result.mcpConfigPath), false);
|
|
60
60
|
}
|
|
61
61
|
});
|
|
62
|
+
test("prepareWorkerDeckConnection refuses Muse Code before writing any MCP config or verifying (NOT-178)", async () => {
|
|
63
|
+
let verifyCalled = false;
|
|
64
|
+
const result = await prepareWorkerDeckConnection({
|
|
65
|
+
policy: DENIED,
|
|
66
|
+
...BASE_OPTS,
|
|
67
|
+
runtime: "muse_code",
|
|
68
|
+
verifyCallTool: async () => {
|
|
69
|
+
verifyCalled = true;
|
|
70
|
+
return textResult({ id: DECK });
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
assert.equal(result.ok, false);
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
assert.equal(result.kind, "infra_failure");
|
|
76
|
+
assert.match(result.reason, /Muse Code does not support Agent Deck MCP/);
|
|
77
|
+
}
|
|
78
|
+
assert.equal(verifyCalled, false);
|
|
79
|
+
});
|
|
62
80
|
test("prepareWorkerDeckConnection rejects when get_bound_deck reports a different deck", async () => {
|
|
63
81
|
const result = await prepareWorkerDeckConnection({
|
|
64
82
|
policy: DENIED,
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { CODEX_AUTH_REMEDIATION, MUSE_AUTH_REMEDIATION, cursorAuthIssueFromOutput, runtimeAuthIssueFromOutput, } from "@agent-dealer/shared";
|
|
4
|
+
import { claudeBinExists, cursorBinExists, resolveClaudeBin, cursorInvokeArgs, resolveCursorBin, resolveCodexBin, codexBinExists, resolveMuseBin, museBinExists, resolveMuseAuthFile, MUSE_CLI_ENV, } from "../cli-env.js";
|
|
4
5
|
import { checkAgentDeckHealth, checkAgentDeckMcpRegistration, fetchDecks, } from "./agent-deck.js";
|
|
5
6
|
import { runtimeAvailability } from "../repository/runtime-availability.js";
|
|
6
7
|
const RUNTIME_LABEL = {
|
|
7
8
|
claude_code: "Claude",
|
|
8
9
|
cursor_local: "Cursor",
|
|
9
10
|
codex_local: "Codex",
|
|
11
|
+
muse_code: "Muse Code",
|
|
10
12
|
};
|
|
11
13
|
function capHealthIssues(runtime) {
|
|
12
14
|
const avail = runtimeAvailability(runtime);
|
|
@@ -60,11 +62,11 @@ function softRetryBackoffsMs() {
|
|
|
60
62
|
function sleep(ms) {
|
|
61
63
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
62
64
|
}
|
|
63
|
-
function defaultRunCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
|
|
65
|
+
function defaultRunCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS, env = process.env) {
|
|
64
66
|
return new Promise((resolve) => {
|
|
65
67
|
const child = spawn(cmd, args, {
|
|
66
68
|
stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
-
env
|
|
69
|
+
env,
|
|
68
70
|
});
|
|
69
71
|
let output = "";
|
|
70
72
|
let settled = false;
|
|
@@ -101,8 +103,8 @@ let runCommandImpl = defaultRunCommand;
|
|
|
101
103
|
export function setRunCommandForTests(fn) {
|
|
102
104
|
runCommandImpl = fn ?? defaultRunCommand;
|
|
103
105
|
}
|
|
104
|
-
function runCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
|
|
105
|
-
return runCommandImpl(cmd, args, timeoutMs);
|
|
106
|
+
function runCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS, env) {
|
|
107
|
+
return runCommandImpl(cmd, args, timeoutMs, env);
|
|
106
108
|
}
|
|
107
109
|
/** Exported for tests — clears the shared github + runtime health caches and soft-fail streak. */
|
|
108
110
|
export function clearAgentHealthCaches() {
|
|
@@ -182,6 +184,48 @@ async function cursorRuntimeIssues() {
|
|
|
182
184
|
}
|
|
183
185
|
return [softIssue];
|
|
184
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Muse Code health (NOT-178). Three outcomes, nothing guessed:
|
|
189
|
+
* - `cli_missing`: the spawn itself failed with ENOENT (or the resolved path does not exist);
|
|
190
|
+
* - `runtime_auth`: no credential to run with — `META_API_KEY` unset and no saved login file
|
|
191
|
+
* (the state whose `muse exec` stderr is captured in muse-exec-missing-credentials.txt);
|
|
192
|
+
* - `runtime_unknown`: the version probe failed or timed out for any other reason.
|
|
193
|
+
*
|
|
194
|
+
* Muse has no offline `auth status` (NOT-177), and its only credential-validating call is a
|
|
195
|
+
* billed `muse exec`, so a *present* but expired login is not detectable here — it surfaces at
|
|
196
|
+
* the first run, which the same classifier reads from stderr. `auth.json` is tested for
|
|
197
|
+
* existence only, never read.
|
|
198
|
+
*/
|
|
199
|
+
async function museRuntimeIssues() {
|
|
200
|
+
const bin = resolveMuseBin();
|
|
201
|
+
const ver = await runCommand(bin, ["--version"], probeTimeoutMs(), {
|
|
202
|
+
...process.env,
|
|
203
|
+
...MUSE_CLI_ENV,
|
|
204
|
+
});
|
|
205
|
+
if (!ver.ok && (/\bENOENT\b/.test(ver.output) || (!ver.output.trim() && !museBinExists()))) {
|
|
206
|
+
return [
|
|
207
|
+
{
|
|
208
|
+
code: "cli_missing",
|
|
209
|
+
message: "Muse Code CLI not found — install Muse Code (`muse`) or set MUSE_CLI",
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
if (!ver.ok) {
|
|
214
|
+
const detail = ver.timedOut
|
|
215
|
+
? "timed out"
|
|
216
|
+
: (ver.output.trim().split("\n").slice(-1)[0] ?? "no output");
|
|
217
|
+
return [
|
|
218
|
+
{
|
|
219
|
+
code: "runtime_unknown",
|
|
220
|
+
message: `Could not determine Muse Code health — \`muse --version\` failed (${detail})`,
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
if (!process.env.META_API_KEY && !fs.existsSync(resolveMuseAuthFile())) {
|
|
225
|
+
return [{ code: "runtime_auth", message: MUSE_AUTH_REMEDIATION }];
|
|
226
|
+
}
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
185
229
|
/** Exported for direct testing — bypasses the 60s cache in runtimeIssues(). */
|
|
186
230
|
export async function runtimeIssuesUncached(runtime) {
|
|
187
231
|
const issues = [];
|
|
@@ -234,6 +278,8 @@ export async function runtimeIssuesUncached(runtime) {
|
|
|
234
278
|
}
|
|
235
279
|
return issues;
|
|
236
280
|
}
|
|
281
|
+
if (runtime === "muse_code")
|
|
282
|
+
return museRuntimeIssues();
|
|
237
283
|
// NOT-157: Cursor auth probe distinguishes hard (classified logged-out / keychain) from
|
|
238
284
|
// soft (timeout / unclassified probe flake). Soft path retries with backoff and holds a
|
|
239
285
|
// recent healthy result across a single post-sleep streak.
|
|
@@ -355,6 +401,10 @@ function resolveMcpRegistration(mcpRegistered) {
|
|
|
355
401
|
}
|
|
356
402
|
function agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult) {
|
|
357
403
|
const issues = [];
|
|
404
|
+
// NOT-181: a Muse Code worker gets no MCP servers and no Agent Deck, so a missing, offline or
|
|
405
|
+
// deleted deck cannot stop it. (The profile form still asks for one; Muse ignores it.)
|
|
406
|
+
if (agent.runtime === "muse_code")
|
|
407
|
+
return issues;
|
|
358
408
|
if (!agent.deckId) {
|
|
359
409
|
issues.push({
|
|
360
410
|
code: "deck_missing",
|
|
@@ -34,6 +34,20 @@ describe("agent-health", { concurrency: false }, () => {
|
|
|
34
34
|
assert.equal(result.issues.some((i) => i.code === "deck_missing"), true);
|
|
35
35
|
assert.equal(result.healthy, false);
|
|
36
36
|
});
|
|
37
|
+
// NOT-181: a Muse Code worker gets no MCP servers and no Agent Deck, so no deck problem may park it.
|
|
38
|
+
test("muse_code ignores deck state: no deck, offline deck or unreadable deck raises a deck issue", async () => {
|
|
39
|
+
const created = createAgent({ name: "muse-no-deck", runtime: "muse_code", deckId: randomUUID() });
|
|
40
|
+
getDb().prepare("UPDATE agents SET deck_id = NULL WHERE id = ?").run(created.id);
|
|
41
|
+
for (const [agent, online] of [
|
|
42
|
+
[getAgent(created.id), true],
|
|
43
|
+
[createAgent({ name: "muse-offline", runtime: "muse_code", deckId: randomUUID() }), false],
|
|
44
|
+
[createAgent({ name: "muse-stale", runtime: "muse_code", deckId: randomUUID() }), true],
|
|
45
|
+
]) {
|
|
46
|
+
const result = await healthForAgent(agent, online, new Map(), true, FAILURE, NO_GITHUB);
|
|
47
|
+
assert.deepEqual(result.issues, [], agent.name);
|
|
48
|
+
assert.equal(result.healthy, true);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
37
51
|
test("agent deck offline: reports deck_offline, not deck_unauthorized", async () => {
|
|
38
52
|
const agent = createAgent({
|
|
39
53
|
name: "offline",
|
|
@@ -343,6 +357,91 @@ The keychain item is stuck. Delete it and sign in again:
|
|
|
343
357
|
clearAgentHealthCaches();
|
|
344
358
|
}
|
|
345
359
|
});
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// NOT-178: Muse Code health. Missing CLI, missing credentials and healthy classify distinctly;
|
|
362
|
+
// a probe that fails for any other reason is `runtime_unknown`, never a guessed auth failure.
|
|
363
|
+
// The missing-credentials remediation quotes the captured `muse exec` stderr
|
|
364
|
+
// (shared/src/fixtures/runtime-auth/muse-exec-missing-credentials.txt).
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
/** Stub `muse` that runs `body` and records the env it saw, so the pin can be asserted. */
|
|
367
|
+
function stubMuse(body) {
|
|
368
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-stub-"));
|
|
369
|
+
const bin = path.join(dir, "muse");
|
|
370
|
+
const envLog = path.join(dir, "env.log");
|
|
371
|
+
fs.writeFileSync(bin, `#!/bin/sh\necho "$MUSE_NO_AUTO_UPDATE" > ${JSON.stringify(envLog)}\n${body}\n`);
|
|
372
|
+
fs.chmodSync(bin, 0o755);
|
|
373
|
+
return { bin, envLog };
|
|
374
|
+
}
|
|
375
|
+
async function withMuseEnv(env, fn) {
|
|
376
|
+
const keys = ["MUSE_CLI", "META_API_KEY", "XDG_CONFIG_HOME"];
|
|
377
|
+
const prev = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
378
|
+
process.env.MUSE_CLI = env.MUSE_CLI;
|
|
379
|
+
process.env.XDG_CONFIG_HOME = env.configHome;
|
|
380
|
+
if (env.META_API_KEY === undefined)
|
|
381
|
+
delete process.env.META_API_KEY;
|
|
382
|
+
else
|
|
383
|
+
process.env.META_API_KEY = env.META_API_KEY;
|
|
384
|
+
clearAgentHealthCaches();
|
|
385
|
+
try {
|
|
386
|
+
return await fn();
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
for (const k of keys) {
|
|
390
|
+
if (prev[k] === undefined)
|
|
391
|
+
delete process.env[k];
|
|
392
|
+
else
|
|
393
|
+
process.env[k] = prev[k];
|
|
394
|
+
}
|
|
395
|
+
clearAgentHealthCaches();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const emptyConfigHome = () => fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-cfg-"));
|
|
399
|
+
test("muse_code with a missing CLI reports cli_missing", async () => {
|
|
400
|
+
const missing = path.join(os.tmpdir(), `dealer-absent-muse-${randomUUID()}`);
|
|
401
|
+
const issues = await withMuseEnv({ MUSE_CLI: missing, META_API_KEY: "k", configHome: emptyConfigHome() }, () => runtimeIssuesUncached("muse_code"));
|
|
402
|
+
assert.deepEqual(issues.map((i) => i.code), ["cli_missing"]);
|
|
403
|
+
assert.match(issues[0].message, /Muse Code CLI not found/);
|
|
404
|
+
});
|
|
405
|
+
test("muse_code with no META_API_KEY and no saved login reports runtime_auth", async () => {
|
|
406
|
+
const stub = stubMuse(`cat ${JSON.stringify(path.join(FIXTURES, "muse-version.txt"))}`);
|
|
407
|
+
const issues = await withMuseEnv({ MUSE_CLI: stub.bin, configHome: emptyConfigHome() }, () => runtimeIssuesUncached("muse_code"));
|
|
408
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
|
|
409
|
+
assert.match(issues[0].message, /muse login/);
|
|
410
|
+
assert.match(issues[0].message, /META_API_KEY/);
|
|
411
|
+
});
|
|
412
|
+
test("muse_code is healthy with a saved login file or a META_API_KEY", async () => {
|
|
413
|
+
const stub = stubMuse(`cat ${JSON.stringify(path.join(FIXTURES, "muse-version.txt"))}`);
|
|
414
|
+
const withLogin = emptyConfigHome();
|
|
415
|
+
fs.mkdirSync(path.join(withLogin, "muse"));
|
|
416
|
+
fs.writeFileSync(path.join(withLogin, "muse", "auth.json"), "{}");
|
|
417
|
+
assert.deepEqual(await withMuseEnv({ MUSE_CLI: stub.bin, configHome: withLogin }, () => runtimeIssuesUncached("muse_code")), []);
|
|
418
|
+
assert.deepEqual(await withMuseEnv({ MUSE_CLI: stub.bin, META_API_KEY: "k", configHome: emptyConfigHome() }, () => runtimeIssuesUncached("muse_code")), []);
|
|
419
|
+
});
|
|
420
|
+
test("muse probes run with MUSE_NO_AUTO_UPDATE=1 so the launcher cannot swap the pinned version", async () => {
|
|
421
|
+
const stub = stubMuse("echo 'Muse Code 1.3.0 (1.3.0-R3401.1)'");
|
|
422
|
+
delete process.env.MUSE_NO_AUTO_UPDATE;
|
|
423
|
+
await withMuseEnv({ MUSE_CLI: stub.bin, META_API_KEY: "k", configHome: emptyConfigHome() }, () => runtimeIssuesUncached("muse_code"));
|
|
424
|
+
assert.equal(fs.readFileSync(stub.envLog, "utf8").trim(), "1");
|
|
425
|
+
});
|
|
426
|
+
test("a muse probe that fails for any other reason is runtime_unknown, not a guessed auth failure", async () => {
|
|
427
|
+
const stub = stubMuse("echo 'panic: something else broke'; exit 3");
|
|
428
|
+
const issues = await withMuseEnv({ MUSE_CLI: stub.bin, configHome: emptyConfigHome() }, () => runtimeIssuesUncached("muse_code"));
|
|
429
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_unknown"]);
|
|
430
|
+
assert.match(issues[0].message, /panic: something else broke/);
|
|
431
|
+
assert.doesNotMatch(issues[0].message, /muse login/);
|
|
432
|
+
});
|
|
433
|
+
test("a muse probe that hangs past the timeout is runtime_unknown", async () => {
|
|
434
|
+
const stub = stubMuse("sleep 30");
|
|
435
|
+
setCursorProbeTimingForTests({ timeoutMs: 150 });
|
|
436
|
+
try {
|
|
437
|
+
const issues = await withMuseEnv({ MUSE_CLI: stub.bin, META_API_KEY: "k", configHome: emptyConfigHome() }, () => runtimeIssuesUncached("muse_code"));
|
|
438
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_unknown"]);
|
|
439
|
+
assert.match(issues[0].message, /timed out/);
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
setCursorProbeTimingForTests(null);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
346
445
|
test("claude MCP endpoint mismatch surfaces a distinct message, not the generic setup hint", async () => {
|
|
347
446
|
const agent = createAgent({
|
|
348
447
|
name: "claude-mcp-mismatch",
|
|
@@ -39,6 +39,13 @@ export class LinearHttpError extends Error {
|
|
|
39
39
|
this.retryAfterMs = init.retryAfterMs;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
/** Thrown before any request is made; lets callers tell a missing key from an API failure. */
|
|
43
|
+
export class LinearApiKeyMissingError extends Error {
|
|
44
|
+
constructor() {
|
|
45
|
+
super("LINEAR_API_KEY not set");
|
|
46
|
+
this.name = "LinearApiKeyMissingError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
42
49
|
export function parseLinearRateLimitHeaders(headers) {
|
|
43
50
|
const out = {};
|
|
44
51
|
const set = (key, name) => {
|
|
@@ -290,7 +297,7 @@ function logLinearTrace(operation, status, rateLimit) {
|
|
|
290
297
|
export async function linearGraphqlRequest(opts) {
|
|
291
298
|
const key = process.env.LINEAR_API_KEY;
|
|
292
299
|
if (!key)
|
|
293
|
-
throw new
|
|
300
|
+
throw new LinearApiKeyMissingError();
|
|
294
301
|
let res;
|
|
295
302
|
try {
|
|
296
303
|
res = await fetch(LINEAR_API, {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Keeps a coordinator-written path inside a worktree out of the worker's `git add` and out of the
|
|
2
|
+
// clean-worktree check, without touching any tracked file. Shared by the Cursor MCP config
|
|
3
|
+
// (agent-deck-bind.ts) and the per-attempt Muse config (coordinator/muse-spawn.ts).
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
/**
|
|
8
|
+
* Append `line` (a root-anchored gitignore pattern such as `/.cursor/mcp.json`) idempotently to
|
|
9
|
+
* git's info/exclude.
|
|
10
|
+
*
|
|
11
|
+
* For a linked worktree, `git rev-parse --git-path info/exclude` resolves to the *main*
|
|
12
|
+
* repository's `.git/info/exclude` (shared across all worktrees of that repo), not a per-worktree
|
|
13
|
+
* file. A root-anchored pattern is still correct per worktree root; the file is rewritten with a
|
|
14
|
+
* single occurrence so concurrent preparations can't leave duplicate lines.
|
|
15
|
+
*/
|
|
16
|
+
export function ensureWorktreeExcluded(worktreePath, line) {
|
|
17
|
+
let excludePathOut;
|
|
18
|
+
try {
|
|
19
|
+
excludePathOut = execFileSync("git", ["-C", worktreePath, "rev-parse", "--git-path", "info/exclude"], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
const e = err;
|
|
26
|
+
throw new Error(`could not resolve git info/exclude: ${e.stderr || String(err) || "unknown error"}`);
|
|
27
|
+
}
|
|
28
|
+
const excludePath = path.resolve(worktreePath, excludePathOut.trim());
|
|
29
|
+
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
|
30
|
+
let existing = "";
|
|
31
|
+
try {
|
|
32
|
+
existing = fs.readFileSync(excludePath, "utf8");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
existing = "";
|
|
36
|
+
}
|
|
37
|
+
const kept = existing.split(/\r?\n/).filter((l) => l.trim() !== "" && l.trim() !== line);
|
|
38
|
+
kept.push(line);
|
|
39
|
+
fs.writeFileSync(excludePath, `${kept.join("\n")}\n`, { mode: 0o644 });
|
|
40
|
+
}
|
|
@@ -101,3 +101,32 @@ export function codexBinExists() {
|
|
|
101
101
|
export function resolveAmbientCodexHome() {
|
|
102
102
|
return process.env.CODEX_HOME ?? path.join(process.env.HOME ?? os.homedir(), ".codex");
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the Muse Code CLI (`muse`). `~/.local/bin/muse` is a launcher that re-execs the
|
|
106
|
+
* versioned `muse-bin-<version>`; callers must set `MUSE_NO_AUTO_UPDATE=1` (`MUSE_CLI_ENV`) or
|
|
107
|
+
* the launcher can swap the pinned version mid-fleet (NOT-177).
|
|
108
|
+
*/
|
|
109
|
+
export function resolveMuseBin() {
|
|
110
|
+
const home = process.env.HOME ?? os.homedir();
|
|
111
|
+
if (process.env.MUSE_CLI)
|
|
112
|
+
return process.env.MUSE_CLI;
|
|
113
|
+
return (firstExisting([
|
|
114
|
+
path.join(home, ".local/bin/muse"),
|
|
115
|
+
"/opt/homebrew/bin/muse",
|
|
116
|
+
"/usr/local/bin/muse",
|
|
117
|
+
]) ?? "muse");
|
|
118
|
+
}
|
|
119
|
+
export function museBinExists() {
|
|
120
|
+
const bin = resolveMuseBin();
|
|
121
|
+
return bin !== "muse" ? fs.existsSync(bin) : false;
|
|
122
|
+
}
|
|
123
|
+
/** Env every Muse invocation carries so the launcher never self-updates (NOT-177 pin). */
|
|
124
|
+
export const MUSE_CLI_ENV = { MUSE_NO_AUTO_UPDATE: "1" };
|
|
125
|
+
/**
|
|
126
|
+
* Where Muse keeps its file-backed login (`$XDG_CONFIG_HOME/muse/auth.json`, else
|
|
127
|
+
* `~/.config/muse/auth.json`). Callers may test for existence only — never read it.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveMuseAuthFile() {
|
|
130
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(process.env.HOME ?? os.homedir(), ".config");
|
|
131
|
+
return path.join(configHome, "muse", "auth.json");
|
|
132
|
+
}
|
|
@@ -17,7 +17,7 @@ import { runtimeAvailability } from "../repository/runtime-availability.js";
|
|
|
17
17
|
import { getActiveWorkflowInstance } from "../repository/workflow-events.js";
|
|
18
18
|
import { listWorkItemsForIssue } from "../repository/work-items.js";
|
|
19
19
|
import { activeWorkflowConflictMessage, checkIssueReadiness, startWorkflowCore, StartPreconditionError, } from "./commands.js";
|
|
20
|
-
import { blockerSnapshotFor, DEPENDENCY_STATE_UNAVAILABLE, isDependencyTracked, unsatisfiedBlockerReason, } from "./dependencies.js";
|
|
20
|
+
import { blockerSnapshotFor, blockerUnavailableReason, DEPENDENCY_STATE_UNAVAILABLE, isDependencyTracked, unsatisfiedBlockerReason, } from "./dependencies.js";
|
|
21
21
|
/** Statuses that occupy an admission slot (Decision 2). */
|
|
22
22
|
export const occupyingStatuses = new Set(["developing", "reviewing", "repairing"]);
|
|
23
23
|
/**
|
|
@@ -60,6 +60,13 @@ export function setAdmissionHealthCheckerForTests(checker) {
|
|
|
60
60
|
healthChecker = checker;
|
|
61
61
|
}
|
|
62
62
|
async function defaultAgentHealth(agent, role, deckOnline) {
|
|
63
|
+
// NOT-178: Muse Code is a developer-only trial runtime — the reviewer stays Codex/Claude.
|
|
64
|
+
if (role === "reviewer" && agent.runtime === "muse_code") {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: `reviewer unhealthy: ${agent.name} — Muse Code cannot be a reviewer (developer role only)`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
63
70
|
// Unit/CI tests must not call real CLIs / Agent Deck — runners usually have no deck MCP.
|
|
64
71
|
// Production leaves this unset. NOT-133 clears it so the real auth classifier still runs.
|
|
65
72
|
if (process.env.AGENT_DEALER_SKIP_AGENT_HEALTH === "1") {
|
|
@@ -154,7 +161,7 @@ function blockedByDependency(issue, ctx) {
|
|
|
154
161
|
const blockers = ctx.blockers.get(issue.externalId);
|
|
155
162
|
// Absent means unknown, not unblocked (see BlockerSnapshot) — park until the next fetch.
|
|
156
163
|
if (!blockers)
|
|
157
|
-
return { ok: false, reason: DEPENDENCY_STATE_UNAVAILABLE };
|
|
164
|
+
return { ok: false, reason: ctx.blockersUnavailableReason ?? DEPENDENCY_STATE_UNAVAILABLE };
|
|
158
165
|
const reason = unsatisfiedBlockerReason(blockers);
|
|
159
166
|
return reason ? { ok: false, reason } : { ok: true };
|
|
160
167
|
}
|
|
@@ -215,7 +222,11 @@ export async function admitNext() {
|
|
|
215
222
|
checkAgentDeckHealth(),
|
|
216
223
|
blockerSnapshotFor(queuedIssues),
|
|
217
224
|
]);
|
|
218
|
-
const ctx = {
|
|
225
|
+
const ctx = {
|
|
226
|
+
deckOnline,
|
|
227
|
+
blockers,
|
|
228
|
+
blockersUnavailableReason: blockerUnavailableReason(),
|
|
229
|
+
};
|
|
219
230
|
for (const entry of remaining) {
|
|
220
231
|
const issue = getIssue(entry.issueId);
|
|
221
232
|
if (!issue) {
|
|
@@ -17,7 +17,7 @@ const { getActiveWorkflowInstance } = await import("../repository/workflow-event
|
|
|
17
17
|
const { recordRuntimeAvailability, clearAllRuntimeAvailability } = await import("../repository/runtime-availability.js");
|
|
18
18
|
const { enqueueIssue, enqueueIssueWithOutcome, dequeueIssue, listQueuedEntries, getQueuedEntryForIssue } = await import("../repository/queue-entries.js");
|
|
19
19
|
const { startWorkflow } = await import("./commands.js");
|
|
20
|
-
const { sequentialCapacityPolicy, occupyingStatuses, countOccupyingIssues, admitNext, setAdmissionHealthCheckerForTests, setCapacityPolicyForTests, resetCapacityPolicyForTests, resetEligibilityRulesForTests } = await import("./admission.js");
|
|
20
|
+
const { sequentialCapacityPolicy, occupyingStatuses, countOccupyingIssues, admitNext, checkRoleAgentHealthy, setAdmissionHealthCheckerForTests, setCapacityPolicyForTests, resetCapacityPolicyForTests, resetEligibilityRulesForTests } = await import("./admission.js");
|
|
21
21
|
before(() => migrate());
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
getDb().exec(`
|
|
@@ -343,6 +343,70 @@ test("NOT-133: a logged-out Cursor runtime is not admitted; it waits with an aut
|
|
|
343
343
|
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
344
344
|
}
|
|
345
345
|
});
|
|
346
|
+
// NOT-178: an unhealthy Muse Code developer profile waits with a Muse-specific reason and
|
|
347
|
+
// spends nothing — status stays ready, no workflow instance, no attempt. Runs the *real*
|
|
348
|
+
// health checker against a stub CLI and an empty config home (no META_API_KEY, no auth.json).
|
|
349
|
+
test("NOT-178: a Muse Code developer without credentials is refused at admission", async () => {
|
|
350
|
+
const { clearAgentHealthCaches } = await import("../adapters/agent-health.js");
|
|
351
|
+
const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-stub-"));
|
|
352
|
+
const stub = path.join(stubDir, "muse");
|
|
353
|
+
fs.writeFileSync(stub, "#!/bin/sh\necho 'Muse Code 1.3.0 (1.3.0-R3401.1)'\n");
|
|
354
|
+
fs.chmodSync(stub, 0o755);
|
|
355
|
+
const saved = {
|
|
356
|
+
MUSE_CLI: process.env.MUSE_CLI,
|
|
357
|
+
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
|
358
|
+
META_API_KEY: process.env.META_API_KEY,
|
|
359
|
+
SKIP: process.env.AGENT_DEALER_SKIP_AGENT_HEALTH,
|
|
360
|
+
};
|
|
361
|
+
process.env.MUSE_CLI = stub;
|
|
362
|
+
process.env.XDG_CONFIG_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-cfg-"));
|
|
363
|
+
delete process.env.META_API_KEY;
|
|
364
|
+
delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
365
|
+
setAdmissionHealthCheckerForTests(null);
|
|
366
|
+
clearAgentHealthCaches();
|
|
367
|
+
try {
|
|
368
|
+
const issue = readyIssue("muse-logged-out", {
|
|
369
|
+
runtimes: { dev: "muse_code", rev: "codex_local" },
|
|
370
|
+
});
|
|
371
|
+
enqueueIssue(issue.id);
|
|
372
|
+
assert.equal(await admitNext(), null);
|
|
373
|
+
assert.equal(getIssue(issue.id).status, "ready");
|
|
374
|
+
assert.equal(getActiveWorkflowInstance(issue.id), null);
|
|
375
|
+
const entry = getQueuedEntryForIssue(issue.id);
|
|
376
|
+
assert.equal(entry?.state, "queued");
|
|
377
|
+
assert.match(entry.waitReason, /developer unhealthy/);
|
|
378
|
+
assert.match(entry.waitReason, /muse login/);
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
for (const [key, value] of Object.entries({
|
|
382
|
+
MUSE_CLI: saved.MUSE_CLI,
|
|
383
|
+
XDG_CONFIG_HOME: saved.XDG_CONFIG_HOME,
|
|
384
|
+
META_API_KEY: saved.META_API_KEY,
|
|
385
|
+
AGENT_DEALER_SKIP_AGENT_HEALTH: saved.SKIP,
|
|
386
|
+
})) {
|
|
387
|
+
if (value === undefined)
|
|
388
|
+
delete process.env[key];
|
|
389
|
+
else
|
|
390
|
+
process.env[key] = value;
|
|
391
|
+
}
|
|
392
|
+
clearAgentHealthCaches();
|
|
393
|
+
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
// NOT-178: Muse Code is developer-only, so it is never a healthy reviewer — even with the
|
|
397
|
+
// unit-test health skip on, and without probing the CLI.
|
|
398
|
+
test("NOT-178: a Muse Code reviewer is refused regardless of CLI health", async () => {
|
|
399
|
+
const issue = readyIssue("muse-reviewer", { runtimes: { dev: "claude_code", rev: "muse_code" } });
|
|
400
|
+
setAdmissionHealthCheckerForTests(null);
|
|
401
|
+
try {
|
|
402
|
+
const result = await checkRoleAgentHealthy(getIssue(issue.id), "reviewer", { deckOnline: true });
|
|
403
|
+
assert.equal(result.ok, false);
|
|
404
|
+
assert.match(result.ok ? "" : result.reason, /reviewer unhealthy.*developer role only/);
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
408
|
+
}
|
|
409
|
+
});
|
|
346
410
|
// NOT-156: reviewer health must not block developer admit; developer health still fails closed.
|
|
347
411
|
test("NOT-156: developer healthy + reviewer unhealthy still admits and leases developer work", async () => {
|
|
348
412
|
const { listWorkItemsForIssue } = await import("../repository/work-items.js");
|
|
@@ -32,6 +32,12 @@ function claudeDisallowedTools(policy) {
|
|
|
32
32
|
return policy.outboundMutation ? [] : [DENY_SEND_TOOL];
|
|
33
33
|
}
|
|
34
34
|
function buildArgs(runtime, policy, prompt, model, mcpConfigPath, effort) {
|
|
35
|
+
if (runtime === "muse_code") {
|
|
36
|
+
// Muse's developer argv is built by runners/muse-code-args.ts inside coordinator/muse-spawn.ts
|
|
37
|
+
// (NOT-181); it needs a per-attempt session id and XDG dirs this pure builder has no place
|
|
38
|
+
// for. Falling through to the Claude arm below would spawn `muse` with Claude flags.
|
|
39
|
+
throw new Error("muse_code is not wired through the shared arg builder — its developer argv is built in coordinator/muse-spawn.ts");
|
|
40
|
+
}
|
|
35
41
|
if (runtime === "codex_local") {
|
|
36
42
|
const args = ["exec", "--json", "-s", policy.worktreeWrite ? "workspace-write" : "read-only"];
|
|
37
43
|
// codex's read-only sandbox constrains shell/files but NOT configured MCP/plugin
|
|
@@ -130,3 +130,17 @@ test("buildDeveloperArgs for cursor_local has no separate effort flag (effort li
|
|
|
130
130
|
assert.ok(!args.some((a) => a.includes("model_reasoning_effort")));
|
|
131
131
|
assert.ok(!args.includes("-c"));
|
|
132
132
|
});
|
|
133
|
+
test("every runtime either builds developer args or is the not-yet-wired muse_code", async () => {
|
|
134
|
+
const { Runtime } = await import("@agent-dealer/shared");
|
|
135
|
+
for (const runtime of Runtime.options) {
|
|
136
|
+
if (runtime === "muse_code") {
|
|
137
|
+
// NOT-178 made Muse selectable but the runner is NOT-179/181. Falling through to the
|
|
138
|
+
// Claude flags would launch `muse` with arguments it does not understand.
|
|
139
|
+
assert.throws(() => buildDeveloperArgs(runtime, "prompt"), /not wired/);
|
|
140
|
+
assert.throws(() => buildReviewerArgs(runtime, "prompt"), /not wired/);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
assert.ok(buildDeveloperArgs(runtime, "prompt").includes("prompt"), runtime);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|