agent-dealer 1.2.7 → 1.2.9

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 (112) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +84 -5
  5. package/bundle/server/dist/adapters/github.test.js +83 -1
  6. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  7. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  8. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  9. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  10. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  11. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  12. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  13. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  14. package/bundle/server/dist/coordinator/admission.js +14 -1
  15. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  16. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  17. package/bundle/server/dist/coordinator/auto-merge.js +244 -4
  18. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  19. package/bundle/server/dist/coordinator/commands.js +284 -23
  20. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  21. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  22. package/bundle/server/dist/coordinator/developer-effect.js +224 -4
  23. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  24. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  25. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  26. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  27. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  28. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  29. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  30. package/bundle/server/dist/coordinator/human-resolution.js +36 -1
  31. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  32. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  33. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  34. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  35. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  36. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  37. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  38. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  39. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  40. package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
  41. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  42. package/bundle/server/dist/coordinator/projection.js +10 -4
  43. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  44. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  45. package/bundle/server/dist/coordinator/prompts.js +211 -2
  46. package/bundle/server/dist/coordinator/prompts.test.js +231 -0
  47. package/bundle/server/dist/coordinator/recovery.js +57 -3
  48. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  49. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  50. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  51. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  52. package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
  53. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  54. package/bundle/server/dist/coordinator/routing.js +45 -6
  55. package/bundle/server/dist/coordinator/routing.test.js +76 -15
  56. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  57. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  58. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  59. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  60. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  61. package/bundle/server/dist/db/index.js +18 -0
  62. package/bundle/server/dist/db/schema.sql +5 -0
  63. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  64. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  65. package/bundle/server/dist/repository/human-actions.js +14 -0
  66. package/bundle/server/dist/repository/issues.js +67 -6
  67. package/bundle/server/dist/repository/work-items.js +7 -0
  68. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  69. package/bundle/server/dist/routes/human-actions.js +10 -8
  70. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  71. package/bundle/server/dist/routes/issues.js +19 -2
  72. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  73. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  74. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  75. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  76. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  77. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  78. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  79. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  80. package/bundle/server/package.json +2 -2
  81. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  82. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  83. package/bundle/server/static-ui/index.html +2 -2
  84. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  85. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  86. package/bundle/shared/dist/execution-contract.js +307 -0
  87. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  88. package/bundle/shared/dist/execution-contract.test.js +499 -0
  89. package/bundle/shared/dist/execution-report.d.ts +9 -9
  90. package/bundle/shared/dist/execution-report.js +2 -0
  91. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  92. package/bundle/shared/dist/failure-cause.js +2 -0
  93. package/bundle/shared/dist/human-actions.d.ts +4 -4
  94. package/bundle/shared/dist/human-actions.js +17 -0
  95. package/bundle/shared/dist/index.d.ts +85 -84
  96. package/bundle/shared/dist/index.js +3 -0
  97. package/bundle/shared/dist/issues.d.ts +176 -16
  98. package/bundle/shared/dist/issues.js +22 -0
  99. package/bundle/shared/dist/issues.test.js +3 -0
  100. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  101. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  102. package/bundle/shared/dist/worker-sessions.js +8 -0
  103. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  104. package/bundle/shared/dist/workflow.d.ts +4 -4
  105. package/bundle/shared/dist/workflow.js +16 -0
  106. package/bundle/shared/dist/workflow.test.js +3 -0
  107. package/bundle/shared/package.json +1 -1
  108. package/dist/action.js +4 -1
  109. package/dist/action.test.js +42 -0
  110. package/dist/index.js +1 -1
  111. package/package.json +1 -1
  112. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
@@ -6,10 +6,11 @@ import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-probe-"));
9
+ import { MUSE_CODE_CONTRIBUTOR_MODEL } from "@agent-dealer/shared";
9
10
  import { MuseCapacityHost } from "./muse-host.js";
10
11
  import { hasFreshMuseCapacityPair, isMusePaidFallbackEnabled, maybeProbeMuseCapacity, MUSE_CAPACITY_PROBE_SERVE_ARGV, MUSE_CAPACITY_REFRESH_ENV, resetMuseCapacityProbeStateForTests, runMuseCapacityProbe, } from "./muse-probe.js";
11
12
  import { ingestMuseUsagePayload } from "./muse.js";
12
- const { migrate } = await import("../db/index.js");
13
+ const { migrate, getDataDir } = await import("../db/index.js");
13
14
  const { createAgent } = await import("../repository/agents.js");
14
15
  const { clearAllCapacitySnapshots, listCapacitySnapshots } = await import("../repository/runtime-capacity.js");
15
16
  const FAKE = new URL("./fixtures/fake-muse-serve.mjs", import.meta.url).pathname;
@@ -181,3 +182,189 @@ test("direct probe success requires the complete pair", async () => {
181
182
  await host.shutdown();
182
183
  }
183
184
  });
185
+ // NOT-300: the probe log must explain the next failure. A stub host drives
186
+ // runMuseServeTurn to the exact turn outcome — no live model call.
187
+ function stubProbeHost(opts = {}) {
188
+ const sessionId = "sess-stub-1";
189
+ const session = { sessionId, modelId: MUSE_CODE_CONTRIBUTOR_MODEL };
190
+ let notifications = 0;
191
+ return {
192
+ ensureStarted: async () => {
193
+ if (opts.failStart)
194
+ throw opts.failStart;
195
+ return true;
196
+ },
197
+ execRequest: async (_id, method) => {
198
+ if (method === "session/start")
199
+ return { result: { session } };
200
+ if (method === "turn/start")
201
+ return { result: { turnId: "turn-stub-1" } };
202
+ if (method === "session/read") {
203
+ return { result: { session, history: { items: [] } } };
204
+ }
205
+ return { result: {} };
206
+ },
207
+ waitForHostNotification: async () => {
208
+ notifications += 1;
209
+ if (opts.hangTurnMs !== undefined && notifications === 1) {
210
+ await new Promise((r) => setTimeout(r, opts.hangTurnMs));
211
+ return null;
212
+ }
213
+ if (opts.turnParams !== undefined || opts.hangTurnMs === undefined) {
214
+ return {
215
+ method: "turn/completed",
216
+ params: opts.turnParams ??
217
+ {
218
+ sessionId,
219
+ terminal: "failed",
220
+ error: { kind: "other", message: "boom" },
221
+ durationMs: 982,
222
+ },
223
+ };
224
+ }
225
+ return {
226
+ method: "turn/completed",
227
+ params: { sessionId, terminal: "cancelled", reason: "cancel requested" },
228
+ };
229
+ },
230
+ isConnected: () => true,
231
+ cancelExecTurn: async () => { },
232
+ readUsage: async () => ({ ok: false, reason: "missing" }),
233
+ shutdown: async () => { },
234
+ };
235
+ }
236
+ function probeLogPath() {
237
+ return path.join(getDataDir(), "capacity", "muse-probe.log");
238
+ }
239
+ function probeLogSize() {
240
+ return fs.existsSync(probeLogPath()) ? fs.statSync(probeLogPath()).size : 0;
241
+ }
242
+ function probeLogLinesSince(bytes) {
243
+ const text = fs.existsSync(probeLogPath())
244
+ ? fs.readFileSync(probeLogPath(), "utf8").slice(bytes)
245
+ : "";
246
+ return text
247
+ .split("\n")
248
+ .filter((l) => l.trim())
249
+ .map((l) => JSON.parse(l));
250
+ }
251
+ const PROBE_LOG_ALLOWED_KEYS = [
252
+ "admitted",
253
+ "durationMs",
254
+ "event",
255
+ "failureDetail",
256
+ "failureKind",
257
+ "model",
258
+ "ok",
259
+ "terminal",
260
+ "trigger",
261
+ "ts",
262
+ "windowsUpdated",
263
+ ];
264
+ function assertProbeLogShape(line) {
265
+ assert.deepEqual(Object.keys(line).sort(), [...PROBE_LOG_ALLOWED_KEYS].sort());
266
+ const detail = line.failureDetail;
267
+ if (detail !== null) {
268
+ assert.deepEqual(Object.keys(detail).sort(), ["kind", "message"]);
269
+ }
270
+ const raw = JSON.stringify(line);
271
+ for (const secret of ["Reply with exactly", "transcript", "META_API_KEY", "password"]) {
272
+ assert.ok(!raw.includes(secret), `log line leaks ${secret}`);
273
+ }
274
+ }
275
+ test("failed turn records failureDetail kind+message in the probe log", async () => {
276
+ const before = probeLogSize();
277
+ const stderr = [];
278
+ const origError = console.error;
279
+ console.error = (...args) => {
280
+ stderr.push(args.map(String).join(" "));
281
+ };
282
+ try {
283
+ const host = stubProbeHost();
284
+ const result = await runMuseCapacityProbe(Date.now(), { host, timeoutMs: 10_000 });
285
+ assert.equal(result.ok, false);
286
+ assert.equal(result.failureKind, "turn_failed");
287
+ assert.deepEqual(result.failureDetail, { kind: "other", message: "boom" });
288
+ }
289
+ finally {
290
+ console.error = origError;
291
+ }
292
+ assert.match(stderr.join("\n"), /fallback failed: turn_failed \(other: boom\)/);
293
+ const lines = probeLogLinesSince(before);
294
+ assert.equal(lines.length, 1);
295
+ assertProbeLogShape(lines[0]);
296
+ assert.equal(lines[0].failureKind, "turn_failed");
297
+ assert.deepEqual(lines[0].failureDetail, { kind: "other", message: "boom" });
298
+ });
299
+ test("thrown probe error records failureDetail with the error message", async () => {
300
+ const before = probeLogSize();
301
+ const host = stubProbeHost({ failStart: new Error("x") });
302
+ const result = await runMuseCapacityProbe(Date.now(), { host, timeoutMs: 10_000 });
303
+ assert.equal(result.ok, false);
304
+ assert.equal(result.failureKind, "exception");
305
+ assert.equal(result.failureDetail?.kind, "exception");
306
+ assert.ok(result.failureDetail?.message.includes("x"));
307
+ const lines = probeLogLinesSince(before);
308
+ assert.equal(lines.length, 1);
309
+ assertProbeLogShape(lines[0]);
310
+ assert.equal(lines[0].failureKind, "exception");
311
+ assert.ok(String(lines[0].failureDetail.message).includes("x"));
312
+ });
313
+ test("timed-out turn records the timeout failureDetail", async () => {
314
+ const before = probeLogSize();
315
+ const host = stubProbeHost({ hangTurnMs: 400 });
316
+ const result = await runMuseCapacityProbe(Date.now(), { host, timeoutMs: 200 });
317
+ assert.equal(result.ok, false);
318
+ assert.equal(result.failureKind, "timeout");
319
+ assert.deepEqual(result.failureDetail, {
320
+ kind: "other",
321
+ message: "turn timed out and was cancelled",
322
+ });
323
+ const lines = probeLogLinesSince(before);
324
+ assert.equal(lines.length, 1);
325
+ assertProbeLogShape(lines[0]);
326
+ assert.equal(lines[0].failureKind, "timeout");
327
+ assert.deepEqual(lines[0].failureDetail, result.failureDetail);
328
+ });
329
+ test("failureDetail message is truncated to 300 chars with controls stripped", async () => {
330
+ const before = probeLogSize();
331
+ const long = `AB\nCD\t${"x".repeat(1000)}\u0000\u001f END`;
332
+ const host = stubProbeHost({
333
+ turnParams: {
334
+ sessionId: "sess-stub-1",
335
+ terminal: "failed",
336
+ error: { kind: "other", message: long },
337
+ durationMs: 5,
338
+ },
339
+ });
340
+ const result = await runMuseCapacityProbe(Date.now(), { host, timeoutMs: 10_000 });
341
+ assert.equal(result.failureKind, "turn_failed");
342
+ const message = result.failureDetail?.message ?? "";
343
+ assert.equal(message.length, 300);
344
+ assert.ok(!/[\x00-\x1f\x7f]/.test(message));
345
+ assert.ok(message.startsWith("ABCD"));
346
+ const lines = probeLogLinesSince(before);
347
+ assert.equal(lines.length, 1);
348
+ assertProbeLogShape(lines[0]);
349
+ assert.equal(lines[0].failureDetail.message, message);
350
+ });
351
+ test("successful probe logs failureDetail null with existing fields unchanged", async () => {
352
+ const before = probeLogSize();
353
+ const host = new MuseCapacityHost(hostOpts("serve-turn-full"));
354
+ try {
355
+ const result = await runMuseCapacityProbe(Date.now(), { host, timeoutMs: 10_000 });
356
+ assert.equal(result.ok, true);
357
+ assert.equal(result.failureDetail, null);
358
+ }
359
+ finally {
360
+ await host.shutdown();
361
+ }
362
+ const lines = probeLogLinesSince(before);
363
+ assert.equal(lines.length, 1);
364
+ assertProbeLogShape(lines[0]);
365
+ assert.equal(lines[0].failureDetail, null);
366
+ assert.equal(lines[0].event, "muse_capacity_probe");
367
+ assert.equal(lines[0].trigger, "stale_60m");
368
+ assert.equal(lines[0].failureKind, null);
369
+ assert.equal(lines[0].ok, true);
370
+ });
@@ -10,6 +10,7 @@
10
10
  import { admissionLimitOptions, getEffectiveMaxActiveIssues, getMaxActiveIssues, workerSpawnCeiling, } from "../repository/admission-settings.js";
11
11
  import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
12
12
  import { healthForAgent } from "../adapters/agent-health.js";
13
+ import { ensureMuseCapabilityEscalation } from "../adapters/muse-capability.js";
13
14
  import { getDb } from "../db/index.js";
14
15
  import { getAgent } from "../repository/agents.js";
15
16
  import { getIssue, listIssues } from "../repository/issues.js";
@@ -150,7 +151,19 @@ export async function checkRoleAgentHealthy(issue, role, ctx) {
150
151
  if (!agent)
151
152
  return { ok: false, reason: `${role} agent not found` };
152
153
  const check = healthChecker ?? ((a, r) => defaultAgentHealth(a, r, ctx.deckOnline));
153
- return check(agent, role);
154
+ const result = await check(agent, role);
155
+ // NOT-308: a Muse verdict the gate cannot clear by itself (`missing`, or an exhausted
156
+ // `error`) must reach the operator as a human action on this issue — deduped per
157
+ // version and verdict kind, so repeated polls never pile up. Best-effort: escalation never fails health.
158
+ if (role === "developer" && agent.runtime === "muse_code") {
159
+ try {
160
+ ensureMuseCapabilityEscalation(issue.id);
161
+ }
162
+ catch {
163
+ // The gate already reported; a failed escalation must not flip it.
164
+ }
165
+ }
166
+ return result;
154
167
  }
155
168
  async function checkAgentsHealthy(issue, ctx) {
156
169
  // Admit always starts a developer round — do not require reviewer health yet (NOT-156).
@@ -403,7 +403,10 @@ test("NOT-178: a Muse Code developer without credentials is refused at admission
403
403
  });
404
404
  // NOT-277: a Muse Code auto-update is re-validated automatically. A version whose developer
405
405
  // shell/write check passes is admitted with no manual step; one that lost it is refused with the
406
- // versions and capability named; one whose check could not complete is refused, never assumed ok.
406
+ // versions and capability named.
407
+ // NOT-308: an inconclusive check never blocks on a confirmed baseline (admitted, retried in
408
+ // the background); a confirmed loss escalates exactly one human action, and acknowledging
409
+ // it lifts the block.
407
410
  test("NOT-277: a Muse Code update is admitted when capable, refused by name when not", async () => {
408
411
  const { clearAgentHealthCaches, runtimeIssuesUncached } = await import("../adapters/agent-health.js");
409
412
  const { setMuseCapabilityProbeForTests, settleMuseCapabilityCheckForTests } = await import("../adapters/muse-capability.js");
@@ -463,11 +466,22 @@ test("NOT-277: a Muse Code update is admitted when capable, refused by name when
463
466
  const reason = getQueuedEntryForIssue(issue.id).waitReason;
464
467
  assert.match(reason, /developer unhealthy/);
465
468
  assert.match(reason, new RegExp(`Muse Code updated ${NEW} → ${BROKEN}: developer sessions no longer get shell/write access`));
469
+ // NOT-308: the confirmed loss escalates exactly one human action naming the update
470
+ // and the capability — and acknowledging it lifts the admission block.
471
+ const { listHumanActionsByRequestId } = await import("../repository/human-actions.js");
472
+ const { museCapabilityRequestId } = await import("../adapters/muse-capability.js");
473
+ const { resolveHumanActionAndAdvance } = await import("./commands.js");
474
+ const escalated = listHumanActionsByRequestId("muse_capability", museCapabilityRequestId(BROKEN, "missing"));
475
+ assert.equal(escalated.filter((a) => a.status === "open").length, 1);
476
+ assert.match(escalated[0].reason, new RegExp(`Muse Code updated ${NEW} → ${BROKEN}: developer sessions no longer get shell/write access`));
477
+ const acknowledged = resolveHumanActionAndAdvance(escalated[0].id, "test", "acknowledge");
478
+ assert.equal(acknowledged.ok, true);
479
+ assert.deepEqual(await developerHealth(), { ok: true }, "acknowledge lifts the block");
480
+ // NOT-308: an inconclusive check (the probe throws) does NOT block on the baseline.
466
481
  await museReports("9.9.9-R1");
467
- const unverified = await developerHealth();
468
- assert.equal(unverified.ok, false);
469
- assert.match(unverified.reason, new RegExp(`Could not verify Muse Code developer shell/write access after version change \\(${BROKEN} → 9\\.9\\.9-R1\\)`));
482
+ assert.deepEqual(await developerHealth(), { ok: true });
470
483
  assert.deepEqual(probed, [OLD, NEW, BROKEN, "9.9.9-R1"]);
484
+ assert.equal(listHumanActionsByRequestId("muse_capability", museCapabilityRequestId("9.9.9-R1", "unverified")).length, 0, "a first inconclusive result escalates nothing");
471
485
  }
472
486
  finally {
473
487
  for (const [key, value] of Object.entries({
@@ -486,6 +500,185 @@ test("NOT-277: a Muse Code update is admitted when capable, refused by name when
486
500
  setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
487
501
  }
488
502
  });
503
+ // NOT-308: real-checker Muse harness — stub `muse --version`, stub gh login, stub the
504
+ // capability probe. Restores every override so later tests keep the unit-test skip.
505
+ async function withMuseHarness(probe, fn) {
506
+ const { clearAgentHealthCaches, invalidateMuseHealthCache, runtimeIssuesUncached } = await import("../adapters/agent-health.js");
507
+ const cap = await import("../adapters/muse-capability.js");
508
+ cap.resetMuseCapabilityStateForTests();
509
+ const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-stub-"));
510
+ const versionFile = path.join(stubDir, "version");
511
+ const stub = path.join(stubDir, "muse");
512
+ fs.writeFileSync(stub, `#!/bin/sh\ncat ${JSON.stringify(versionFile)}\n`);
513
+ fs.chmodSync(stub, 0o755);
514
+ const ghStub = path.join(stubDir, "gh");
515
+ fs.writeFileSync(ghStub, "#!/bin/sh\necho 'Logged in to github.com account dealer-test'\nexit 0\n");
516
+ fs.chmodSync(ghStub, 0o755);
517
+ cap.setMuseCapabilityProbeForTests(probe);
518
+ const saved = {
519
+ MUSE_CLI: process.env.MUSE_CLI,
520
+ META_API_KEY: process.env.META_API_KEY,
521
+ SKIP: process.env.AGENT_DEALER_SKIP_AGENT_HEALTH,
522
+ PATH: process.env.PATH,
523
+ };
524
+ process.env.MUSE_CLI = stub;
525
+ process.env.PATH = `${stubDir}${path.delimiter}${process.env.PATH ?? ""}`;
526
+ process.env.META_API_KEY = "k";
527
+ delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
528
+ setAdmissionHealthCheckerForTests(null);
529
+ clearAgentHealthCaches();
530
+ const writeVersion = (version) => {
531
+ fs.writeFileSync(versionFile, `Muse Code ${version.split("-")[0]} (${version})\n`);
532
+ };
533
+ try {
534
+ await fn({
535
+ versionFile,
536
+ show: writeVersion,
537
+ report: async (version) => {
538
+ writeVersion(version);
539
+ await runtimeIssuesUncached("muse_code");
540
+ await cap.settleMuseCapabilityCheckForTests();
541
+ },
542
+ reportUnsettled: async (version) => {
543
+ writeVersion(version);
544
+ // Targeted invalidation only: clearAgentHealthCaches would also reset the
545
+ // capability state under test.
546
+ invalidateMuseHealthCache();
547
+ return runtimeIssuesUncached("muse_code");
548
+ },
549
+ });
550
+ }
551
+ finally {
552
+ for (const [key, value] of Object.entries({
553
+ MUSE_CLI: saved.MUSE_CLI,
554
+ META_API_KEY: saved.META_API_KEY,
555
+ AGENT_DEALER_SKIP_AGENT_HEALTH: saved.SKIP,
556
+ PATH: saved.PATH,
557
+ })) {
558
+ if (value === undefined)
559
+ delete process.env[key];
560
+ else
561
+ process.env[key] = value;
562
+ }
563
+ cap.setMuseCapabilityProbeForTests(null);
564
+ clearAgentHealthCaches();
565
+ setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
566
+ }
567
+ }
568
+ // NOT-308: while the new version is still being verified, developer admission is allowed
569
+ // on the confirmed baseline — no `runtime_capability` block while the check is in flight.
570
+ test("NOT-308: developer admission is allowed while the new version check is in flight", async () => {
571
+ const cap = await import("../adapters/muse-capability.js");
572
+ const OLD = "1.3.0-R3401.1";
573
+ const NEW = "1.4.0-R4161.1";
574
+ let release;
575
+ await withMuseHarness(async (version) => {
576
+ if (version === NEW) {
577
+ return new Promise((resolve) => {
578
+ release = () => resolve({ status: "capable" });
579
+ });
580
+ }
581
+ return { status: "capable" };
582
+ }, async ({ report, reportUnsettled }) => {
583
+ await report(OLD);
584
+ const issues = await reportUnsettled(NEW);
585
+ assert.deepEqual(issues.filter((i) => i.code === "runtime_capability"), [], "no runtime_capability block while verifying on a baseline");
586
+ assert.equal(cap.museCapabilityCheckInFlight(), true);
587
+ // The admission gate agrees: healthy, with the check still running.
588
+ const issue = readyIssue("muse-inflight", { runtimes: { dev: "muse_code", rev: "codex_local" } });
589
+ const { invalidateMuseHealthCache } = await import("../adapters/agent-health.js");
590
+ invalidateMuseHealthCache();
591
+ assert.deepEqual(await checkRoleAgentHealthy(issue, "developer", { deckOnline: true }), {
592
+ ok: true,
593
+ });
594
+ assert.equal(cap.museCapabilityCheckInFlight(), true);
595
+ release();
596
+ await cap.settleMuseCapabilityCheckForTests();
597
+ invalidateMuseHealthCache();
598
+ assert.deepEqual(await checkRoleAgentHealthy(issue, "developer", { deckOnline: true }), {
599
+ ok: true,
600
+ });
601
+ });
602
+ });
603
+ // NOT-308: three inconclusive polls escalate exactly one "could not verify" action and stop
604
+ // probing — admission never blocks.
605
+ test("NOT-308: three inconclusive polls escalate once, then stop probing, never blocking", async () => {
606
+ const cap = await import("../adapters/muse-capability.js");
607
+ const { invalidateMuseHealthCache } = await import("../adapters/agent-health.js");
608
+ const { listHumanActionsByRequestId } = await import("../repository/human-actions.js");
609
+ const OLD = "1.3.0-R3401.1";
610
+ const NEW = "1.4.0-R4161.1";
611
+ const probed = [];
612
+ await withMuseHarness(async (version) => {
613
+ probed.push(version);
614
+ if (version === NEW)
615
+ return { status: "error", detail: "probe session timed out" };
616
+ return { status: "capable" };
617
+ }, async ({ report, show }) => {
618
+ await report(OLD);
619
+ show(NEW);
620
+ const issue = readyIssue("muse-unverified", {
621
+ runtimes: { dev: "muse_code", rev: "codex_local" },
622
+ });
623
+ const poll = async () => {
624
+ // Targeted invalidation only: clearAgentHealthCaches would also reset the
625
+ // capability attempts under test.
626
+ invalidateMuseHealthCache();
627
+ const health = await checkRoleAgentHealthy(issue, "developer", { deckOnline: true });
628
+ await cap.settleMuseCapabilityCheckForTests();
629
+ return health;
630
+ };
631
+ const { ageMuseCapabilityCheckForTests } = await import("../adapters/muse-capability.js");
632
+ assert.deepEqual(await poll(), { ok: true }, "attempt 1: allowed");
633
+ ageMuseCapabilityCheckForTests(61_000);
634
+ assert.deepEqual(await poll(), { ok: true }, "attempt 2: allowed");
635
+ ageMuseCapabilityCheckForTests(122_000);
636
+ assert.deepEqual(await poll(), { ok: true }, "attempt 3: allowed");
637
+ assert.deepEqual(probed, [OLD, NEW, NEW, NEW]);
638
+ // The third poll settles exhausted, so the next polls escalate — exactly once.
639
+ assert.deepEqual(await poll(), { ok: true }, "exhausted: still allowed");
640
+ assert.deepEqual(await poll(), { ok: true }, "second post-exhaustion poll: still allowed");
641
+ const actions = listHumanActionsByRequestId("muse_capability", cap.museCapabilityRequestId(NEW, "unverified"));
642
+ assert.equal(actions.filter((a) => a.status === "open").length, 1);
643
+ assert.match(actions[0].reason, /Could not verify Muse Code developer shell\/write access/);
644
+ assert.match(actions[0].reason, new RegExp(`for ${NEW} after 3 inconclusive checks`));
645
+ // No fourth probe ever, however long the wait.
646
+ ageMuseCapabilityCheckForTests(10 * 60_000);
647
+ invalidateMuseHealthCache();
648
+ assert.deepEqual(await checkRoleAgentHealthy(issue, "developer", { deckOnline: true }), {
649
+ ok: true,
650
+ });
651
+ assert.deepEqual(probed, [OLD, NEW, NEW, NEW], "no further probes after exhaustion");
652
+ });
653
+ });
654
+ // NOT-308: with no confirmed baseline ever (fresh install), the unknown still fails
655
+ // closed — in flight and errored alike.
656
+ test("NOT-308: with no confirmed baseline, admission stays blocked while unconfirmed", async () => {
657
+ const cap = await import("../adapters/muse-capability.js");
658
+ const NEW = "1.4.0-R4161.1";
659
+ let release;
660
+ let calls = 0;
661
+ await withMuseHarness(async () => {
662
+ calls += 1;
663
+ return new Promise((resolve) => {
664
+ release = () => resolve({ status: "error", detail: "timed out" });
665
+ });
666
+ }, async ({ reportUnsettled }) => {
667
+ const issue = readyIssue("muse-fresh", { runtimes: { dev: "muse_code", rev: "codex_local" } });
668
+ const health = (i) => checkRoleAgentHealthy(i, "developer", { deckOnline: true });
669
+ await reportUnsettled(NEW);
670
+ assert.equal(cap.museCapabilityCheckInFlight(), true);
671
+ const blocked = await health(issue);
672
+ assert.equal(blocked.ok, false);
673
+ assert.match(blocked.reason, /verifying developer shell\/write access/);
674
+ release();
675
+ await cap.settleMuseCapabilityCheckForTests();
676
+ const errored = await health(issue);
677
+ assert.equal(errored.ok, false);
678
+ assert.match(errored.reason, /Could not verify Muse Code/);
679
+ assert.equal(calls, 1);
680
+ });
681
+ });
489
682
  // NOT-178: Muse Code is developer-only, so it is never a healthy reviewer — even with the
490
683
  // unit-test health skip on, and without probing the CLI.
491
684
  test("NOT-178: a Muse Code reviewer is refused regardless of CLI health", async () => {