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.
Files changed (72) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +28 -6
  4. package/bundle/server/dist/adapters/agent-health.test.js +66 -11
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/github.js +110 -12
  8. package/bundle/server/dist/adapters/github.test.js +274 -3
  9. package/bundle/server/dist/adapters/muse-capability.js +356 -0
  10. package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
  11. package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
  12. package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
  13. package/bundle/server/dist/capacity/muse-host.js +92 -32
  14. package/bundle/server/dist/capacity/muse-host.test.js +130 -4
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/args.js +4 -3
  17. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  18. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  19. package/bundle/server/dist/coordinator/commands.js +100 -12
  20. package/bundle/server/dist/coordinator/developer-effect.js +128 -16
  21. package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
  22. package/bundle/server/dist/coordinator/human-resolution.js +33 -1
  23. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  24. package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
  25. package/bundle/server/dist/coordinator/projection.js +1 -0
  26. package/bundle/server/dist/coordinator/prompts.js +25 -6
  27. package/bundle/server/dist/coordinator/prompts.test.js +36 -8
  28. package/bundle/server/dist/coordinator/routing.js +1 -0
  29. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  30. package/bundle/server/dist/coordinator/spawn.js +9 -1
  31. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  32. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  33. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  34. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  35. package/bundle/server/dist/repository/human-actions.js +13 -0
  36. package/bundle/server/dist/routes/human-actions.js +10 -1
  37. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  38. package/bundle/server/dist/routes/index.js +12 -5
  39. package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
  40. package/bundle/server/dist/routes/version.js +30 -0
  41. package/bundle/server/dist/routes/version.test.js +21 -0
  42. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  43. package/bundle/server/dist/runners/muse-config.js +1 -1
  44. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  45. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  46. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  47. package/bundle/server/package.json +2 -2
  48. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  49. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
  50. package/bundle/server/static-ui/index.html +2 -2
  51. package/bundle/shared/dist/agents.d.ts +15 -15
  52. package/bundle/shared/dist/agents.js +6 -0
  53. package/bundle/shared/dist/index.d.ts +7 -7
  54. package/bundle/shared/package.json +1 -1
  55. package/dist/doctor.d.ts +7 -3
  56. package/dist/doctor.js +21 -13
  57. package/dist/doctor.test.js +9 -8
  58. package/dist/managed/index.d.ts +1 -1
  59. package/dist/managed/index.js +1 -1
  60. package/dist/managed/updater.d.ts +16 -6
  61. package/dist/managed/updater.js +28 -11
  62. package/dist/managed-update-restart.test.d.ts +1 -0
  63. package/dist/managed-update-restart.test.js +227 -0
  64. package/dist/ports.d.ts +6 -0
  65. package/dist/ports.js +10 -0
  66. package/dist/runtime-state.d.ts +10 -0
  67. package/dist/runtime-state.js +26 -0
  68. package/dist/start.js +67 -2
  69. package/dist/status.js +30 -2
  70. package/dist/update-check.js +3 -6
  71. package/package.json +1 -1
  72. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
@@ -203,10 +203,11 @@ async function materializeWorkerMcpConfig(opts) {
203
203
  const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
204
204
  const mcpUrl = `${mcpBase}/mcp`;
205
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.
206
+ // NOT-278: Muse Code settings come only from `prepareMuseAttempt`
207
+ // (runners/muse-config.ts) — never emit a Claude/Codex/Cursor config for Muse. Refuse
208
+ // before anything is written rather than fall through to the Claude config below.
208
209
  if (opts.runtime === "muse_code") {
209
- throw new Error("Muse Code does not support Agent Deck MCP configuration");
210
+ throw new Error("Muse Code does not support Agent Deck MCP configuration via the shared materializer — its settings come only from prepareMuseAttempt");
210
211
  }
211
212
  if (opts.runtime === "codex_local") {
212
213
  const codexHome = path.join(getWorkerMcpConfigDir(), `codex-home-${opts.deckId.slice(0, 8)}-${randomUUID()}`);
@@ -370,6 +371,32 @@ async function verifyDeckConnection(opts) {
370
371
  }
371
372
  }
372
373
  }
374
+ function toVerificationOutcome(verified) {
375
+ if (verified.ok)
376
+ return { ok: true };
377
+ return verified.kind === "deck_unavailable"
378
+ ? { ok: false, kind: "deck_unavailable", reason: `Agent Deck is unreachable — ${verified.reason}` }
379
+ : { ok: false, kind: "infra_failure", reason: `preflight failed: ${verified.reason}` };
380
+ }
381
+ /**
382
+ * NOT-278: live `get_bound_deck` (+ configured-playbook) preflight without materializing any
383
+ * runtime MCP config. Muse Code sessions use this: their settings.json comes only from
384
+ * `prepareMuseAttempt` (runners/muse-config.ts), so the shared materializer below must
385
+ * never run for them. Same result mapping as the materializing path: no response is
386
+ * `deck_unavailable`; a response that fails validation is `infra_failure` (surfaced by the
387
+ * effect as `deck_failure`).
388
+ */
389
+ export async function verifyWorkerDeckConnection(opts) {
390
+ const timeoutMs = opts.timeoutMs ?? Number(process.env.DECK_BIND_TIMEOUT_MS ?? 30_000);
391
+ const verified = await verifyDeckConnection({
392
+ deckId: opts.deckId,
393
+ worktreePath: opts.worktreePath,
394
+ playbookIds: opts.playbookIds ?? [],
395
+ callTool: opts.callTool,
396
+ timeoutMs,
397
+ });
398
+ return toVerificationOutcome(verified);
399
+ }
373
400
  /**
374
401
  * Materialize a per-attempt MCP config for the profile's deck and verify with
375
402
  * `get_bound_deck` and every configured playbook before spawn. No mint, no ledger, no
@@ -408,6 +435,9 @@ export async function prepareWorkerDeckConnection(opts) {
408
435
  catch {
409
436
  // best-effort
410
437
  }
438
+ // Same mapping as toVerificationOutcome, inlined so the result carries the
439
+ // materializing-path type (the shared helper's `ok: true` variant has no
440
+ // mcpConfigPath and is unreachable in this branch).
411
441
  return verified.kind === "deck_unavailable"
412
442
  ? { ok: false, kind: "deck_unavailable", reason: `Agent Deck is unreachable — ${verified.reason}` }
413
443
  : { ok: false, kind: "infra_failure", reason: `preflight failed: ${verified.reason}` };
@@ -12,7 +12,7 @@ import { parse as parseToml } from "smol-toml";
12
12
  import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
13
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deckbind-"));
14
14
  const { migrate } = await import("../db/index.js");
15
- const { prepareWorkerDeckConnection, releaseWorkerDeckConnection, parseDeckToolResult, isDeckUnreachableError } = await import("./agent-deck-bind.js");
15
+ const { prepareWorkerDeckConnection, releaseWorkerDeckConnection, parseDeckToolResult, isDeckUnreachableError, verifyWorkerDeckConnection } = await import("./agent-deck-bind.js");
16
16
  const { codexScopedConfigDeniesSendGate } = await import("./codex-scoped-config.js");
17
17
  const { roleCeiling } = await import("@agent-dealer/shared");
18
18
  /** Default worker policy: outboundMutation is false for BOTH roles (NOT-134). */
@@ -59,7 +59,65 @@ 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 () => {
62
+ // NOT-278: Muse Code settings come only from prepareMuseAttempt — the shared materializer
63
+ // must never run for Muse. Muse preflights through verifyWorkerDeckConnection instead.
64
+ test("verifyWorkerDeckConnection preflights Muse without materializing any MCP config", async () => {
65
+ const calls = [];
66
+ const result = await verifyWorkerDeckConnection({
67
+ deckId: DECK,
68
+ worktreePath: WT,
69
+ callTool: async (name, args) => {
70
+ calls.push(name);
71
+ if (name === "get_bound_deck")
72
+ return textResult({ id: DECK, name: "personal-dev" });
73
+ return textResult({ id: args.playbook_id });
74
+ },
75
+ });
76
+ assert.equal(result.ok, true);
77
+ assert.deepEqual(calls, ["get_bound_deck"]);
78
+ });
79
+ test("verifyWorkerDeckConnection maps a wrong deck to infra_failure and silence to deck_unavailable", async () => {
80
+ const wrong = await verifyWorkerDeckConnection({
81
+ deckId: DECK,
82
+ worktreePath: WT,
83
+ callTool: async () => textResult({ id: "some-other-deck-id" }),
84
+ });
85
+ assert.equal(wrong.ok, false);
86
+ if (!wrong.ok) {
87
+ assert.equal(wrong.kind, "infra_failure");
88
+ assert.match(wrong.reason, /returned deck some-other-deck-id, expected/);
89
+ }
90
+ const silent = await verifyWorkerDeckConnection({
91
+ deckId: DECK,
92
+ worktreePath: WT,
93
+ callTool: async () => {
94
+ throw Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1110"), { code: "ECONNREFUSED" });
95
+ },
96
+ });
97
+ assert.equal(silent.ok, false);
98
+ if (!silent.ok) {
99
+ assert.equal(silent.kind, "deck_unavailable");
100
+ assert.match(silent.reason, /unreachable/);
101
+ }
102
+ });
103
+ test("verifyWorkerDeckConnection maps a missing configured playbook to infra_failure (NOT-278)", async () => {
104
+ const missing = await verifyWorkerDeckConnection({
105
+ deckId: DECK,
106
+ worktreePath: WT,
107
+ playbookIds: ["pb-required"],
108
+ callTool: async (name, args) => {
109
+ if (name === "get_bound_deck")
110
+ return textResult({ id: DECK, name: "personal-dev" });
111
+ return errorResult(`playbook ${args.playbook_id} not found`);
112
+ },
113
+ });
114
+ assert.equal(missing.ok, false);
115
+ if (!missing.ok) {
116
+ assert.equal(missing.kind, "infra_failure");
117
+ assert.match(missing.reason, /preflight failed/);
118
+ }
119
+ });
120
+ test("prepareWorkerDeckConnection refuses Muse Code before writing any MCP config or verifying (NOT-278)", async () => {
63
121
  let verifyCalled = false;
64
122
  const result = await prepareWorkerDeckConnection({
65
123
  policy: DENIED,
@@ -4,6 +4,7 @@ import { CODEX_AUTH_REMEDIATION, MUSE_AUTH_REMEDIATION, cursorAuthIssueFromOutpu
4
4
  import { claudeBinExists, cursorBinExists, resolveClaudeBin, cursorInvokeArgs, resolveCursorBin, resolveCodexBin, codexBinExists, resolveMuseBin, museBinExists, resolveMuseAuthFile, MUSE_CLI_ENV, } from "../cli-env.js";
5
5
  import { checkAgentDeckHealth, checkAgentDeckMcpRegistration, fetchDecks, } from "./agent-deck.js";
6
6
  import { runtimeAvailability } from "../repository/runtime-availability.js";
7
+ import { museCapabilityCheckInFlight, museCapabilityIssues, museCapabilitySettleCount, parseMuseVersion, resetMuseCapabilityStateForTests, } from "./muse-capability.js";
7
8
  const RUNTIME_LABEL = {
8
9
  claude_code: "Claude",
9
10
  cursor_local: "Cursor",
@@ -112,6 +113,7 @@ export function clearAgentHealthCaches() {
112
113
  githubIssueCache = null;
113
114
  cursorSoftFailStreak = 0;
114
115
  cursorLastHealthyAt = null;
116
+ resetMuseCapabilityStateForTests();
115
117
  }
116
118
  function isSoftCursorProbeIssue(issue) {
117
119
  return (issue.code === "runtime_auth" &&
@@ -195,6 +197,11 @@ async function cursorRuntimeIssues() {
195
197
  * billed `muse exec`, so a *present* but expired login is not detectable here — it surfaces at
196
198
  * the first run, which the same classifier reads from stderr. `auth.json` is tested for
197
199
  * existence only, never read.
200
+ *
201
+ * NOT-277: with CLI and credentials present, the reported version is handed to the capability
202
+ * check — a version not yet checked runs one real developer-shell probe (muse-capability.ts);
203
+ * `runtime_capability` blocks while it runs, when it finds shell/write missing, or when it could
204
+ * not complete.
198
205
  */
199
206
  async function museRuntimeIssues() {
200
207
  const bin = resolveMuseBin();
@@ -224,7 +231,17 @@ async function museRuntimeIssues() {
224
231
  if (!process.env.META_API_KEY && !fs.existsSync(resolveMuseAuthFile())) {
225
232
  return [{ code: "runtime_auth", message: MUSE_AUTH_REMEDIATION }];
226
233
  }
227
- return [];
234
+ const version = parseMuseVersion(ver.output);
235
+ if (!version) {
236
+ return [
237
+ {
238
+ code: "runtime_unknown",
239
+ message: "Could not determine Muse Code health — `muse --version` printed no version",
240
+ },
241
+ ];
242
+ }
243
+ // A settled check drops the cached result so admission sees it on its next health read.
244
+ return museCapabilityIssues(version, () => runtimeIssueCache.delete("muse_code"));
228
245
  }
229
246
  /** Exported for direct testing — bypasses the 60s cache in runtimeIssues(). */
230
247
  export async function runtimeIssuesUncached(runtime) {
@@ -427,12 +444,18 @@ async function runtimeIssues(runtime) {
427
444
  if (cached && Date.now() - cached.at < ttl) {
428
445
  return [...capIssues, ...cached.issues];
429
446
  }
447
+ const museSettles = museCapabilitySettleCount();
430
448
  const issues = await runtimeIssuesUncached(runtime);
431
449
  const nonCap = issues.filter((i) => i.code !== "usage_capped");
450
+ // NOT-277: a capability check that settled during this read already superseded what it returned.
451
+ if (runtime === "muse_code" && museCapabilitySettleCount() !== museSettles) {
452
+ return runtimeIssues(runtime);
453
+ }
432
454
  // Soft fail (published or grace-held) uses a short TTL so a wake retry can clear quickly;
433
455
  // a sticky 60s cache of "Could not confirm" is what parked the queue after sleep (NOT-157).
434
456
  const softProbeFailure = nonCap.some(isSoftCursorProbeIssue) ||
435
- (runtime === "cursor_local" && cursorSoftFailStreak > 0);
457
+ (runtime === "cursor_local" && cursorSoftFailStreak > 0) ||
458
+ (runtime === "muse_code" && museCapabilityCheckInFlight());
436
459
  runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap, softProbeFailure });
437
460
  return [...capIssues, ...nonCap];
438
461
  }
@@ -446,10 +469,9 @@ function resolveMcpRegistration(mcpRegistered) {
446
469
  }
447
470
  function agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult) {
448
471
  const issues = [];
449
- // NOT-181: a Muse Code worker gets no MCP servers and no Agent Deck, so a missing, offline or
450
- // deleted deck cannot stop it. (The profile form still asks for one; Muse ignores it.)
451
- if (agent.runtime === "muse_code")
452
- return issues;
472
+ // NOT-278: a Muse Code developer session receives the selected deck exactly like the other
473
+ // developer runtimes, so `deck_missing`, `deck_offline`, and `deck_unauthorized` apply to
474
+ // Muse too. Only the Claude-only `mcp_not_registered` check below stays Claude-only.
453
475
  if (!agent.deckId) {
454
476
  issues.push({
455
477
  code: "deck_missing",
@@ -16,7 +16,10 @@ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-ag
16
16
  const { migrate, getDb } = await import("../db/index.js");
17
17
  const { createAgent, getAgent } = await import("../repository/agents.js");
18
18
  const { healthForAgent, runtimeIssuesUncached, githubIssuesUncached, githubIssuesSync, classifyGithubAuthStatus, clearAgentHealthCaches, setCursorProbeTimingForTests, setRunCommandForTests, } = await import("./agent-health.js");
19
+ const { setMuseCapabilityProbeForTests, settleMuseCapabilityCheckForTests } = await import("./muse-capability.js");
19
20
  migrate();
21
+ // NOT-277: health never runs the real (billed) Muse capability probe in tests.
22
+ setMuseCapabilityProbeForTests(async () => ({ status: "capable" }));
20
23
  const FAILURE = { ok: false, code: "DECK_UNAVAILABLE", message: "Agent Deck API error: 502" };
21
24
  /** Tests inject an empty github list so host `gh auth` does not pollute assertions. */
22
25
  const NO_GITHUB = [];
@@ -34,18 +37,20 @@ describe("agent-health", { concurrency: false }, () => {
34
37
  assert.equal(result.issues.some((i) => i.code === "deck_missing"), true);
35
38
  assert.equal(result.healthy, false);
36
39
  });
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 () => {
40
+ // NOT-278: a Muse Code developer session receives the selected deck exactly like the other
41
+ // developer runtimes, so deck_missing, deck_offline, and deck_unauthorized apply to Muse too.
42
+ test("muse_code reports deck state: missing deck, offline deck, and unreadable deck raise deck issues", async () => {
39
43
  const created = createAgent({ name: "muse-no-deck", runtime: "muse_code", deckId: randomUUID() });
40
44
  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
- ]) {
45
+ const cases = [
46
+ { agent: getAgent(created.id), online: true, code: "deck_missing" },
47
+ { agent: createAgent({ name: "muse-offline", runtime: "muse_code", deckId: randomUUID() }), online: false, code: "deck_offline" },
48
+ { agent: createAgent({ name: "muse-stale", runtime: "muse_code", deckId: randomUUID() }), online: true, code: "deck_unauthorized" },
49
+ ];
50
+ for (const { agent, online, code } of cases) {
46
51
  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);
52
+ assert.equal(result.issues.some((i) => i.code === code), true, `${agent.name} reports ${code}`);
53
+ assert.equal(result.healthy, false);
49
54
  }
50
55
  });
51
56
  test("agent deck offline: reports deck_offline, not deck_unauthorized", async () => {
@@ -414,8 +419,14 @@ The keychain item is stuck. Delete it and sign in again:
414
419
  const withLogin = emptyConfigHome();
415
420
  fs.mkdirSync(path.join(withLogin, "muse"));
416
421
  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")), []);
422
+ // NOT-277: the first read of a version starts its one-time capability check; healthy once settled.
423
+ const settledIssues = async () => {
424
+ await runtimeIssuesUncached("muse_code");
425
+ await settleMuseCapabilityCheckForTests();
426
+ return runtimeIssuesUncached("muse_code");
427
+ };
428
+ assert.deepEqual(await withMuseEnv({ MUSE_CLI: stub.bin, configHome: withLogin }, settledIssues), []);
429
+ assert.deepEqual(await withMuseEnv({ MUSE_CLI: stub.bin, META_API_KEY: "k", configHome: emptyConfigHome() }, settledIssues), []);
419
430
  });
420
431
  test("muse probes run with MUSE_NO_AUTO_UPDATE=1 so the launcher cannot swap the pinned version", async () => {
421
432
  const stub = stubMuse("echo 'Muse Code 1.3.0 (1.3.0-R3401.1)'");
@@ -442,6 +453,50 @@ The keychain item is stuck. Delete it and sign in again:
442
453
  setCursorProbeTimingForTests(null);
443
454
  }
444
455
  });
456
+ // NOT-277: through the cached health path the agent list / admission read — a Muse auto-update
457
+ // fires the capability check exactly once for the new version, and the result (not a generic
458
+ // symptom) is what the unhealthy agent shows.
459
+ test("a Muse version change is capability-checked once and its named result surfaces on the agent", async () => {
460
+ const OLD = "1.3.0-R3401.1";
461
+ const NEW = "1.4.0-R4161.1";
462
+ const versionFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-ver-")), "v");
463
+ fs.writeFileSync(versionFile, `Muse Code 1.3.0 (${OLD})\n`);
464
+ const stub = stubMuse(`cat ${JSON.stringify(versionFile)}`);
465
+ const calls = [];
466
+ setMuseCapabilityProbeForTests(async (version) => {
467
+ calls.push(version);
468
+ return version === OLD
469
+ ? { status: "capable" }
470
+ : { status: "missing", detail: "probe session completed without running its shell command" };
471
+ });
472
+ const agent = createAgent({ name: "muse-updated", runtime: "muse_code", deckId: randomUUID() });
473
+ const health = () => healthForAgent(agent, true, undefined, true, null, NO_GITHUB);
474
+ try {
475
+ await withMuseEnv({ MUSE_CLI: stub.bin, META_API_KEY: "k", configHome: emptyConfigHome() }, async () => {
476
+ // The stub probe settles within this read; the health read must not cache its stale
477
+ // "verifying" block (the in-flight block itself is covered in muse-capability.test.ts).
478
+ await health();
479
+ await settleMuseCapabilityCheckForTests();
480
+ for (let i = 0; i < 3; i++)
481
+ assert.deepEqual((await health()).issues, []);
482
+ assert.deepEqual(calls, [OLD]);
483
+ // Muse auto-updates. The 60s runtime cache notices on its next miss.
484
+ fs.writeFileSync(versionFile, `Muse Code 1.4.0 (${NEW})\n`);
485
+ await runtimeIssuesUncached("muse_code");
486
+ await settleMuseCapabilityCheckForTests();
487
+ for (let i = 0; i < 3; i++) {
488
+ const after = await health();
489
+ assert.equal(after.healthy, false);
490
+ assert.deepEqual(after.issues.map((x) => x.code), ["runtime_capability"]);
491
+ assert.match(after.issues[0].message, new RegExp(`Muse Code updated ${OLD} → ${NEW}: developer sessions no longer get shell/write access`));
492
+ }
493
+ assert.deepEqual(calls, [OLD, NEW]);
494
+ });
495
+ }
496
+ finally {
497
+ setMuseCapabilityProbeForTests(async () => ({ status: "capable" }));
498
+ }
499
+ });
445
500
  test("claude MCP endpoint mismatch surfaces a distinct message, not the generic setup hint", async () => {
446
501
  const agent = createAgent({
447
502
  name: "claude-mcp-mismatch",
@@ -9,6 +9,7 @@
9
9
  // Lifted from archive/not-57-full-p0-slice and extended with role-aware creation,
10
10
  // safe (non-destructive) removal, and leftover inspection for crash recovery.
11
11
  import { execFile } from "node:child_process";
12
+ import { createHash } from "node:crypto";
12
13
  import fs from "node:fs";
13
14
  import path from "node:path";
14
15
  import { promisify } from "node:util";
@@ -192,14 +193,28 @@ export async function removeWorktree(opts) {
192
193
  args.push(opts.path);
193
194
  await git(opts.repo, args);
194
195
  }
196
+ /**
197
+ * NOT-280: `git status --porcelain` without the opportunistic index refresh — plain status
198
+ * rewrites a stale-stat index, which would mutate a checkout a refusal path must leave
199
+ * untouched (outside root, wrong branch, unknown owner).
200
+ */
201
+ async function readOnlyStatus(worktreePath) {
202
+ return (await git(worktreePath, ["--no-optional-locks", "status", "--porcelain"])).stdout;
203
+ }
195
204
  export async function isWorktreeClean(worktreePath) {
196
- const { stdout } = await git(worktreePath, ["status", "--porcelain"]);
197
- return stdout.trim().length === 0;
205
+ return (await readOnlyStatus(worktreePath)).trim().length === 0;
198
206
  }
199
207
  /** Commit message for a timeout salvage tip (NOT-145). */
200
208
  export const SALVAGE_TIMEOUT_MESSAGE = "wip: timeout salvage";
201
209
  /** Commit message for a crash/non-zero exit salvage tip (NOT-145). */
202
210
  export const SALVAGE_CRASH_MESSAGE = "wip: crash salvage";
211
+ /** Commit message for a dead predecessor's dirt salvaged before a new attempt (NOT-280). */
212
+ export const SALVAGE_RESUME_MESSAGE = "wip: resume salvage";
213
+ const SALVAGE_MESSAGES = {
214
+ timeout: SALVAGE_TIMEOUT_MESSAGE,
215
+ crash: SALVAGE_CRASH_MESSAGE,
216
+ resume: SALVAGE_RESUME_MESSAGE,
217
+ };
203
218
  /**
204
219
  * NOT-145: stage everything and commit a clearly marked salvage tip so a timeout/crash
205
220
  * can remove the worktree for retry without silently discarding uncommitted WIP.
@@ -207,7 +222,7 @@ export const SALVAGE_CRASH_MESSAGE = "wip: crash salvage";
207
222
  * (parent NOT-143), not a preserved dirty checkout the next attempt cannot adopt.
208
223
  */
209
224
  export async function salvageDirtyWorktree(worktreePath, kind) {
210
- const message = kind === "timeout" ? SALVAGE_TIMEOUT_MESSAGE : SALVAGE_CRASH_MESSAGE;
225
+ const message = SALVAGE_MESSAGES[kind];
211
226
  try {
212
227
  await git(worktreePath, ["add", "-A"]);
213
228
  // Explicit identity: coordinator-managed worktrees may lack user.name/email, and a
@@ -690,6 +705,96 @@ export function sessionIdFromRoleWorktreePath(worktreePath) {
690
705
  const m = /^(.*)-(developer|reviewer)$/.exec(base);
691
706
  return m?.[1] ?? null;
692
707
  }
708
+ function sha256Hex(parts) {
709
+ return createHash("sha256").update(parts.join("\0")).digest("hex");
710
+ }
711
+ /**
712
+ * NOT-280: stable identity of a leftover's blocking state. Best-effort reads of HEAD and
713
+ * status: an unreadable checkout still fingerprints (by path + reason), so repeated
714
+ * Resumes against the same corrupt tree dedupe too.
715
+ */
716
+ export async function leftoverFingerprint(worktreePath, reason) {
717
+ const head = await git(worktreePath, ["rev-parse", "HEAD"]).then((r) => r.stdout.trim(), () => "");
718
+ const status = await readOnlyStatus(worktreePath).catch(() => "");
719
+ return sha256Hex([tryRealpath(worktreePath), reason, head, status]);
720
+ }
721
+ /** Unmerged index entries (`git status --porcelain` XY codes) — a half-resolved merge. */
722
+ const UNMERGED_STATUS = /^(DD|AU|UD|UA|DU|AA|UU) /m;
723
+ /**
724
+ * NOT-280: commit a proven-dead predecessor's dirty managed checkout on the issue branch
725
+ * so the next attempt can continue from that tip. Inspects before touching anything:
726
+ * the checkout must be on exactly `refs/heads/<branch>` with no in-progress merge/
727
+ * cherry-pick/revert and no unmerged entries — anything else fails closed. A failed
728
+ * commit (e.g. a rejecting hook) restores the index it staged, so the checkout is left
729
+ * exactly as found.
730
+ */
731
+ export async function salvagePredecessorWorktree(worktreePath, branchName) {
732
+ let headRef;
733
+ let status;
734
+ let indexTree;
735
+ // Branch identity first, so a wrong-branch checkout is refused before any status read.
736
+ try {
737
+ headRef = (await git(worktreePath, ["symbolic-ref", "-q", "HEAD"])).stdout.trim();
738
+ }
739
+ catch (err) {
740
+ return { ok: false, reason: `inspection failed: ${err.message}` };
741
+ }
742
+ if (headRef !== `refs/heads/${branchName}`) {
743
+ return { ok: false, reason: `checkout is on ${headRef || "a detached HEAD"}, not refs/heads/${branchName}` };
744
+ }
745
+ try {
746
+ status = await readOnlyStatus(worktreePath);
747
+ }
748
+ catch (err) {
749
+ return { ok: false, reason: `inspection failed: ${err.message}` };
750
+ }
751
+ if (UNMERGED_STATUS.test(status)) {
752
+ return { ok: false, reason: "checkout has unmerged paths (an unfinished merge/rebase)" };
753
+ }
754
+ for (const marker of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]) {
755
+ if (await refExists(worktreePath, marker)) {
756
+ return { ok: false, reason: `checkout has an in-progress operation (${marker} exists)` };
757
+ }
758
+ }
759
+ try {
760
+ indexTree = (await git(worktreePath, ["write-tree"])).stdout.trim();
761
+ }
762
+ catch (err) {
763
+ return { ok: false, reason: `inspection failed: ${err.message}` };
764
+ }
765
+ const headBefore = await revParseHead(worktreePath);
766
+ const salvaged = await salvageDirtyWorktree(worktreePath, "resume");
767
+ if (salvaged.ok)
768
+ return salvaged;
769
+ // Undo the `git add -A` staging when the commit itself did not land; a landed commit
770
+ // with residual dirt keeps its commit (the work is saved) and still fails closed.
771
+ // A failed rollback leaves the salvage's staging in place, so it is reported alongside
772
+ // the commit failure rather than claiming the checkout was left as found.
773
+ const headAfter = await revParseHead(worktreePath).catch(() => null);
774
+ if (headAfter === headBefore) {
775
+ try {
776
+ await git(worktreePath, ["read-tree", indexTree]);
777
+ }
778
+ catch (err) {
779
+ return {
780
+ ok: false,
781
+ reason: `${salvaged.reason}; restoring the index afterwards also failed (git read-tree ${indexTree}): ${err.message} — files in ${worktreePath} are untouched but \`git add -A\` staging remains`,
782
+ };
783
+ }
784
+ }
785
+ return { ok: false, reason: salvaged.reason };
786
+ }
787
+ /** Whether HEAD's subject is a resume-salvage tip (restart after the commit landed). */
788
+ async function headIsResumeSalvage(worktreePath) {
789
+ try {
790
+ const { stdout } = await git(worktreePath, ["log", "-1", "--format=%H%x00%s"]);
791
+ const [sha, subject] = stdout.trim().split("\0");
792
+ return subject === SALVAGE_RESUME_MESSAGE && sha ? sha : null;
793
+ }
794
+ catch {
795
+ return null;
796
+ }
797
+ }
693
798
  /**
694
799
  * The developer-setup half of design §"Worktree lifecycle and concurrency"'s crash-recovery
695
800
  * requirement: a leftover worktree from an earlier round/escalation on the SAME issue branch
@@ -707,31 +812,40 @@ export function sessionIdFromRoleWorktreePath(worktreePath) {
707
812
  */
708
813
  export async function resolveDeveloperWorktree(opts) {
709
814
  return withRepoLock(opts.repo, async () => {
710
- await pruneWorktrees(opts.repo);
711
- // NOT-219: a repair round reuses an existing issue branch, so first fetch
815
+ // NOT-280: no up-front `git worktree prune` — it deletes `.git/worktrees/*` admin
816
+ // entries, so it runs only once no refusal (outside root, live owner, dirt, unreadable
817
+ // checkout) applies: for a vanished leftover here, otherwise right before the add below.
818
+ // NOT-219: a repair round reuses an existing issue branch, so fetch
712
819
  // `origin/<branch>` — the worktree must start at exactly what Dealer pushed,
713
820
  // never the managed clone's possibly-stale local ref. A failed fetch defers the
714
821
  // start (`base_unavailable`) instead of falling back to the stale local branch.
822
+ // NOT-280: the fetch runs only once every refusal check (outside root, live owner,
823
+ // unknown-owner dirt, unreadable checkout) has passed, so a refused leftover leaves
824
+ // the shared repository's refs, FETCH_HEAD and objects untouched.
825
+ const fetchReuse = async () => opts.reuseBranch
826
+ ? fetchReusedBranch(opts.repo, opts.branchName, opts.fetchTimeoutMs)
827
+ : { ok: true, remoteSha: null };
715
828
  let reuseRemoteSha = null;
716
- if (opts.reuseBranch) {
717
- const reused = await fetchReusedBranch(opts.repo, opts.branchName, opts.fetchTimeoutMs);
718
- if (!reused.ok) {
719
- return { kind: "base_unavailable", reason: reused.reason };
720
- }
721
- reuseRemoteSha = reused.remoteSha;
829
+ let existing = await findWorktreeForBranch(opts.repo, opts.branchName);
830
+ if (existing && !fs.existsSync(existing)) {
831
+ // A registered checkout whose directory is gone is a stale administrative entry
832
+ // (pre-NOT-280 this was pruned before the lookup): prune it and look again.
833
+ await pruneWorktrees(opts.repo);
834
+ existing = await findWorktreeForBranch(opts.repo, opts.branchName);
722
835
  }
723
- const existing = await findWorktreeForBranch(opts.repo, opts.branchName);
724
836
  if (existing) {
725
837
  const managedRoot = opts.worktreePath
726
838
  ? tryRealpath(path.dirname(opts.worktreePath))
727
839
  : tryRealpath(worktreesRoot(opts.repo));
728
840
  const underRoot = tryRealpath(existing).startsWith(managedRoot + path.sep);
729
841
  if (!underRoot) {
842
+ const reason = `Branch ${opts.branchName} is already checked out at ${existing}, outside the coordinator's managed worktrees — it cannot be safely reused or removed automatically.`;
730
843
  return {
731
844
  kind: "conflict",
732
845
  path: existing,
733
- reason: `Branch ${opts.branchName} is already checked out at ${existing}, outside the coordinator's managed worktrees — it cannot be safely reused or removed automatically.`,
846
+ reason,
734
847
  recoveryCommands: [`git -C ${opts.repo} worktree list`, `# free the branch, then Resume: cd ${existing} && git status`],
848
+ fingerprint: await leftoverFingerprint(existing, reason),
735
849
  };
736
850
  }
737
851
  // Live owner first — clean/dirty are only meaningful once the predecessor is gone.
@@ -744,8 +858,45 @@ export async function resolveDeveloperWorktree(opts) {
744
858
  reason: `Developer worktree for branch ${opts.branchName} at ${existing} is still in use by session ${owner.sessionId} (live process) — refusing to adopt it or escalate as a worktree conflict.`,
745
859
  };
746
860
  }
747
- const state = await inspectLeftoverWorktree(existing);
861
+ // NOT-280: only a known Dealer session proven dead may have its dirt salvaged.
862
+ const deadPredecessor = owner.state === "dead" ? (owner.sessionId ?? null) : null;
863
+ let state = await inspectLeftoverWorktree(existing);
864
+ let salvage;
865
+ let salvageFailure = null;
866
+ if (state === "dirty_or_unpushed" && deadPredecessor) {
867
+ const salvaged = await salvagePredecessorWorktree(existing, opts.branchName);
868
+ if (salvaged.ok) {
869
+ salvage = {
870
+ commitSha: salvaged.commitSha,
871
+ message: salvaged.message,
872
+ predecessorSessionId: deadPredecessor,
873
+ recovered: false,
874
+ };
875
+ state = await inspectLeftoverWorktree(existing);
876
+ }
877
+ else {
878
+ salvageFailure = salvaged.reason;
879
+ }
880
+ }
881
+ else if (state === "clean" && deadPredecessor) {
882
+ // Restart after the salvage commit landed but before the attempt continued: the
883
+ // saved tip is detected, never re-committed.
884
+ const landed = await headIsResumeSalvage(existing);
885
+ if (landed) {
886
+ salvage = {
887
+ commitSha: landed,
888
+ message: SALVAGE_RESUME_MESSAGE,
889
+ predecessorSessionId: deadPredecessor,
890
+ recovered: true,
891
+ };
892
+ }
893
+ }
748
894
  if (state === "clean") {
895
+ const reused = await fetchReuse();
896
+ if (!reused.ok) {
897
+ return { kind: "base_unavailable", reason: reused.reason };
898
+ }
899
+ reuseRemoteSha = reused.remoteSha;
749
900
  // NOT-219: a clean leftover holding a stale local branch advances to the
750
901
  // fetched remote tip when that is a strict fast-forward (nothing unique to
751
902
  // lose — the local tip is already an ancestor of the remote one). Any other
@@ -758,19 +909,26 @@ export async function resolveDeveloperWorktree(opts) {
758
909
  await git(existing, ["merge", "--ff-only", "-q", `origin/${opts.branchName}`]).catch(() => null);
759
910
  }
760
911
  }
761
- return { kind: "reused", path: tryRealpath(existing) };
912
+ return { kind: "reused", path: tryRealpath(existing), ...(salvage ? { salvage } : {}) };
762
913
  }
763
914
  if (state === "dirty_or_unpushed") {
915
+ const base = `A previous developer worktree for branch ${opts.branchName} still holds it at ${existing} with uncommitted changes.`;
916
+ const reason = salvageFailure
917
+ ? `${base} Automatic salvage of dead session ${deadPredecessor}'s work failed and the checkout was preserved (not removed or reset): ${salvageFailure}`
918
+ : salvage
919
+ ? `${base} Salvage tip ${salvage.commitSha.slice(0, 7)} landed but the checkout is still dirty.`
920
+ : base;
764
921
  return {
765
922
  kind: "conflict",
766
923
  path: existing,
767
- reason: `A previous developer worktree for branch ${opts.branchName} still holds it at ${existing} with uncommitted changes.`,
924
+ reason,
768
925
  recoveryCommands: [
769
926
  `cd ${existing}`,
770
927
  "git status",
771
928
  "git log --oneline -5",
772
929
  `# once the work there is safe (pushed or intentionally discarded): git -C ${opts.repo} worktree remove ${existing} --force`,
773
930
  ],
931
+ fingerprint: await leftoverFingerprint(existing, reason),
774
932
  };
775
933
  }
776
934
  // inspectLeftoverWorktree reports "missing" for ANY `git status` failure, not just a
@@ -780,23 +938,34 @@ export async function resolveDeveloperWorktree(opts) {
780
938
  // below — a narrower repeat of the exact loop this function exists to close). Only a
781
939
  // truly gone directory is safe to treat as a stale administrative entry.
782
940
  if (fs.existsSync(existing)) {
941
+ const reason = `A previous developer worktree for branch ${opts.branchName} exists at ${existing} but its status could not be determined.`;
783
942
  return {
784
943
  kind: "conflict",
785
944
  path: existing,
786
- reason: `A previous developer worktree for branch ${opts.branchName} exists at ${existing} but its status could not be determined.`,
945
+ reason,
787
946
  recoveryCommands: [
788
947
  `cd ${existing}`,
789
948
  "git status",
790
949
  `# once resolved: git -C ${opts.repo} worktree remove ${existing} --force`,
791
950
  ],
951
+ fingerprint: await leftoverFingerprint(existing, reason),
792
952
  };
793
953
  }
794
- await pruneWorktrees(opts.repo);
795
954
  }
955
+ // Clear stale registrations (e.g. a vanished checkout at the target path) before the
956
+ // add — every refusal path above has already returned without mutating anything.
957
+ await pruneWorktrees(opts.repo);
796
958
  // NOT-197: a fresh issue branch starts from the freshly fetched
797
959
  // `origin/<baseBranch>` tip — never the cached clone's possibly-stale local base.
798
960
  // A failed fetch returns `base_unavailable` (no branch is created) instead of
799
961
  // silently falling back to the stale local branch.
962
+ if (opts.reuseBranch) {
963
+ const reused = await fetchReuse();
964
+ if (!reused.ok) {
965
+ return { kind: "base_unavailable", reason: reused.reason };
966
+ }
967
+ reuseRemoteSha = reused.remoteSha;
968
+ }
800
969
  let ref;
801
970
  let newBranch;
802
971
  let baseSha = null;