agent-dealer 1.2.6 → 1.2.8

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 (96) 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 +4 -1
  5. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  6. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  7. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  8. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  9. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  10. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  11. package/bundle/server/dist/coordinator/admission.js +14 -1
  12. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  13. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  14. package/bundle/server/dist/coordinator/auto-merge.js +39 -3
  15. package/bundle/server/dist/coordinator/commands.js +90 -5
  16. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  17. package/bundle/server/dist/coordinator/developer-effect.js +79 -1
  18. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  19. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  20. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  21. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  22. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  23. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  24. package/bundle/server/dist/coordinator/human-resolution.js +17 -1
  25. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  26. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  27. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  28. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  29. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  30. package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
  31. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  32. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  33. package/bundle/server/dist/coordinator/prompts.js +78 -2
  34. package/bundle/server/dist/coordinator/prompts.test.js +83 -0
  35. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  36. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  37. package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
  38. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  39. package/bundle/server/dist/coordinator/routing.test.js +14 -0
  40. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  41. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  42. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  43. package/bundle/server/dist/db/index.js +5 -0
  44. package/bundle/server/dist/db/schema.sql +3 -0
  45. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  46. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  47. package/bundle/server/dist/repository/human-actions.js +14 -0
  48. package/bundle/server/dist/repository/issues.js +29 -4
  49. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  50. package/bundle/server/dist/routes/human-actions.js +10 -8
  51. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  52. package/bundle/server/dist/routes/issues.js +19 -2
  53. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  54. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  55. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  56. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  57. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  58. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  59. package/bundle/server/package.json +2 -2
  60. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +64 -0
  61. package/bundle/server/static-ui/assets/index-K_YcYkQU.css +1 -0
  62. package/bundle/server/static-ui/index.html +2 -2
  63. package/bundle/shared/dist/action-presentation.d.ts +51 -0
  64. package/bundle/shared/dist/action-presentation.js +280 -0
  65. package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
  66. package/bundle/shared/dist/action-presentation.test.js +180 -0
  67. package/bundle/shared/dist/attempt-waste.d.ts +2 -2
  68. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  69. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  70. package/bundle/shared/dist/execution-contract.js +307 -0
  71. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  72. package/bundle/shared/dist/execution-contract.test.js +499 -0
  73. package/bundle/shared/dist/execution-report.d.ts +11 -11
  74. package/bundle/shared/dist/execution-report.js +2 -0
  75. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  76. package/bundle/shared/dist/failure-cause.js +2 -0
  77. package/bundle/shared/dist/human-actions.d.ts +14 -14
  78. package/bundle/shared/dist/human-actions.js +9 -0
  79. package/bundle/shared/dist/index.d.ts +126 -124
  80. package/bundle/shared/dist/index.js +4 -0
  81. package/bundle/shared/dist/issues.d.ts +158 -28
  82. package/bundle/shared/dist/issues.js +8 -0
  83. package/bundle/shared/dist/issues.test.js +1 -0
  84. package/bundle/shared/dist/outbound-draft.d.ts +18 -18
  85. package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
  86. package/bundle/shared/dist/usage-events.d.ts +2 -2
  87. package/bundle/shared/dist/worker-sessions.d.ts +16 -6
  88. package/bundle/shared/dist/worker-sessions.js +8 -0
  89. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  90. package/bundle/shared/dist/workflow.d.ts +6 -6
  91. package/bundle/shared/dist/workflow.js +10 -0
  92. package/bundle/shared/dist/workflow.test.js +2 -0
  93. package/bundle/shared/package.json +1 -1
  94. package/package.json +1 -1
  95. package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
  96. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +0 -60
@@ -0,0 +1,114 @@
1
+ // packages/server/src/adapters/muse-visual-qa.ts
2
+ //
3
+ // NOT-303: Muse Dev visual-QA screenshot-path preflight.
4
+ //
5
+ // `Google Chrome.app` headless cannot run inside a Muse developer session: every
6
+ // launch aborts at macOS process registration
7
+ // (`TransformProcessType -> _RegisterApplication`, SIGABRT/exit 134, no output, no
8
+ // file) under the Muse sandbox, and `--no-sandbox` only disables Chrome's own
9
+ // sandbox so it cannot help. Evidence:
10
+ // `docs/evaluations/muse-code/chrome-headless-screenshot.md`.
11
+ //
12
+ // This module resolves, coordinator-side and before the session starts, which
13
+ // screenshot path a Muse developer worker gets. It never launches a browser and
14
+ // never touches the sandbox: the default verdict is unusable-with-reason (fail
15
+ // loud, so the worker reads it in its prompt instead of discovering the abort
16
+ // mid-session). Only an operator pre-installed non-AppKit headless shell,
17
+ // pointed at by `MUSE_HEADLESS_SHELL_BIN`, flips the verdict — the binary cannot
18
+ // be fetched in-session because the developer sandbox network is `restricted`.
19
+ //
20
+ // Deliberately NOT covered: widening the Muse sandbox, a coordinator-side
21
+ // screenshot step, and how Cursor/Claude/Codex workers run Chrome.
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ /** Operator override: absolute path to a pre-installed headless shell binary. */
25
+ export const MUSE_HEADLESS_SHELL_BIN_ENV = "MUSE_HEADLESS_SHELL_BIN";
26
+ /** Phase 1 evidence behind the default verdict. */
27
+ export const MUSE_VISUAL_QA_DOC = "docs/evaluations/muse-code/chrome-headless-screenshot.md";
28
+ /** The abort every `Google Chrome.app` headless launch dies with in-sandbox. */
29
+ export const MUSE_CHROME_APP_ABORT = "TransformProcessType -> _RegisterApplication (SIGABRT, exit 134)";
30
+ /**
31
+ * Resolve the screenshot path for a Muse developer worker. Pure apart from the
32
+ * injected `exists` probe; never spawns anything (probing `Google Chrome.app`
33
+ * would only mint another crash report).
34
+ */
35
+ export function checkMuseVisualQa(opts = {}) {
36
+ const env = opts.env ?? process.env;
37
+ const exists = opts.exists ?? fs.existsSync;
38
+ const configured = env[MUSE_HEADLESS_SHELL_BIN_ENV]?.trim() || null;
39
+ if (configured) {
40
+ if (!path.isAbsolute(configured)) {
41
+ return {
42
+ usable: false,
43
+ binary: null,
44
+ args: [],
45
+ reason: `${MUSE_HEADLESS_SHELL_BIN_ENV} must be an absolute path (got a non-absolute value) — ` +
46
+ `no headless shell configured, and Google Chrome.app headless aborts in-session ` +
47
+ `(${MUSE_CHROME_APP_ABORT}); see ${MUSE_VISUAL_QA_DOC}`,
48
+ };
49
+ }
50
+ if (!exists(configured)) {
51
+ return {
52
+ usable: false,
53
+ binary: null,
54
+ args: [],
55
+ reason: `headless shell not found at ${MUSE_HEADLESS_SHELL_BIN_ENV} (${configured}) — ` +
56
+ `no screenshot path is installed, and Google Chrome.app headless aborts in-session ` +
57
+ `(${MUSE_CHROME_APP_ABORT}); see ${MUSE_VISUAL_QA_DOC}`,
58
+ };
59
+ }
60
+ return {
61
+ usable: true,
62
+ binary: configured,
63
+ args: ["--screenshot=<png>", "--window-size=1280,800"],
64
+ reason: `pre-installed headless shell at ${configured}`,
65
+ };
66
+ }
67
+ return {
68
+ usable: false,
69
+ binary: null,
70
+ args: [],
71
+ reason: `no headless shell installed (${MUSE_HEADLESS_SHELL_BIN_ENV} is unset — it must be ` +
72
+ `pre-installed on the host because the developer sandbox network is restricted), and ` +
73
+ `Google Chrome.app headless aborts in-session (${MUSE_CHROME_APP_ABORT}); ` +
74
+ `see ${MUSE_VISUAL_QA_DOC}`,
75
+ };
76
+ }
77
+ function chromeAppWarningLines() {
78
+ return [
79
+ `- Never launch \`Google Chrome.app\` headless here: every launch aborts at startup`,
80
+ ` (${MUSE_CHROME_APP_ABORT}, no output, no file) under the Muse sandbox.`,
81
+ ` \`--no-sandbox\` only disables Chrome's own sandbox and cannot help.`,
82
+ ];
83
+ }
84
+ /**
85
+ * Worker-prompt section for a Muse developer session. The unusable variant tells
86
+ * the worker not to burn steps probing and to close with an explicit
87
+ * `Visual QA: not run` conclusion line, so review evidence states it instead of
88
+ * reading a missing screenshot as a pass.
89
+ */
90
+ export function museVisualQaPromptSection(status) {
91
+ if (status.usable && status.binary) {
92
+ return [
93
+ `## Visual QA (screenshots)`,
94
+ `A pre-installed headless shell is available: \`${status.binary}\`. For UI-visible`,
95
+ `changes, capture a screenshot with`,
96
+ `\`${status.binary} ${[...status.args, "<page-url>"].join(" ")}\``,
97
+ `saving the PNG into the worktree, and commit it with your change.`,
98
+ ...chromeAppWarningLines(),
99
+ `End your implementation conclusion with a \`Visual QA:\` line —`,
100
+ `\`Visual QA: verified (<png path>, exit <code>)\` or \`Visual QA: not run (<reason>)\`.`,
101
+ ``,
102
+ ];
103
+ }
104
+ return [
105
+ `## Visual QA (screenshots)`,
106
+ `Headless visual QA does not work inside this session — do not spend steps probing it:`,
107
+ ...chromeAppWarningLines(),
108
+ `- ${status.reason}`,
109
+ `Visual QA is out of scope for this session: never read the absence of a screenshot as`,
110
+ `a pass. End your implementation conclusion with a \`Visual QA:\` line —`,
111
+ `\`Visual QA: not run (<short reason>)\` — so the review evidence states it explicitly.`,
112
+ ``,
113
+ ];
114
+ }
@@ -0,0 +1,68 @@
1
+ // packages/server/src/adapters/muse-visual-qa.test.ts
2
+ //
3
+ // NOT-303: the Muse Dev screenshot-path preflight. The unusable verdict must fail
4
+ // loudly — naming the RegisterApplication abort and the missing binary — instead
5
+ // of letting the worker discover it mid-session.
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { checkMuseVisualQa, MUSE_HEADLESS_SHELL_BIN_ENV, museVisualQaPromptSection, } from "./muse-visual-qa.js";
9
+ test("unset override: unusable, naming the Chrome.app abort and the missing binary", () => {
10
+ const status = checkMuseVisualQa({ env: {}, exists: () => false });
11
+ assert.equal(status.usable, false);
12
+ assert.equal(status.binary, null);
13
+ assert.match(status.reason, /RegisterApplication/);
14
+ assert.match(status.reason, /MUSE_HEADLESS_SHELL_BIN/);
15
+ });
16
+ test("configured but absent binary: unusable, naming the path", () => {
17
+ const status = checkMuseVisualQa({
18
+ env: { [MUSE_HEADLESS_SHELL_BIN_ENV]: "/opt/headless/chrome-headless-shell" },
19
+ exists: () => false,
20
+ });
21
+ assert.equal(status.usable, false);
22
+ assert.equal(status.binary, null);
23
+ assert.match(status.reason, /\/opt\/headless\/chrome-headless-shell/);
24
+ assert.match(status.reason, /RegisterApplication/);
25
+ });
26
+ test("configured but non-absolute value: unusable, not treated as a binary", () => {
27
+ const status = checkMuseVisualQa({
28
+ env: { [MUSE_HEADLESS_SHELL_BIN_ENV]: "relative/bin" },
29
+ exists: () => true,
30
+ });
31
+ assert.equal(status.usable, false);
32
+ assert.equal(status.binary, null);
33
+ assert.match(status.reason, /absolute path/);
34
+ });
35
+ test("pre-installed binary present: usable with binary and screenshot flags", () => {
36
+ const status = checkMuseVisualQa({
37
+ env: { [MUSE_HEADLESS_SHELL_BIN_ENV]: "/opt/headless/chrome-headless-shell" },
38
+ exists: (p) => p === "/opt/headless/chrome-headless-shell",
39
+ });
40
+ assert.equal(status.usable, true);
41
+ assert.equal(status.binary, "/opt/headless/chrome-headless-shell");
42
+ assert.ok(status.args.some((a) => a.startsWith("--screenshot=")));
43
+ });
44
+ test("unusable prompt section: forbids Chrome.app probing, declares out of scope, requires the conclusion line", () => {
45
+ const lines = museVisualQaPromptSection(checkMuseVisualQa({ env: {}, exists: () => false }));
46
+ const text = lines.join("\n");
47
+ assert.match(text, /## Visual QA/);
48
+ assert.match(text, /Google Chrome\.app/);
49
+ assert.match(text, /RegisterApplication/);
50
+ assert.match(text, /do not spend steps probing/i);
51
+ assert.match(text, /out of scope/i);
52
+ assert.match(text, /Visual QA: not run/);
53
+ assert.doesNotMatch(text, /--disable-sandbox/);
54
+ assert.doesNotMatch(text, /--yolo/);
55
+ });
56
+ test("usable prompt section: names the binary and still forbids Google Chrome.app", () => {
57
+ const lines = museVisualQaPromptSection({
58
+ usable: true,
59
+ binary: "/opt/headless/chrome-headless-shell",
60
+ args: ["--screenshot=<png>", "--window-size=1280,800"],
61
+ reason: "pre-installed headless shell",
62
+ });
63
+ const text = lines.join("\n");
64
+ assert.match(text, /\/opt\/headless\/chrome-headless-shell/);
65
+ assert.match(text, /--screenshot=<png>/);
66
+ assert.match(text, /Google Chrome\.app/);
67
+ assert.match(text, /Visual QA:/);
68
+ });
@@ -91,6 +91,44 @@ export function hasFreshMuseCapacityPair(nowMs = Date.now()) {
91
91
  function museProbeDiagnosticLogPath() {
92
92
  return path.join(getDataDir(), "capacity", "muse-probe.log");
93
93
  }
94
+ const MUSE_PROBE_FAILURE_MESSAGE_MAX = 300;
95
+ const MUSE_PROBE_FAILURE_KIND_MAX = 100;
96
+ /** Keep the diagnostic log one JSON object per line and free of anything
97
+ * beyond kind + message: strip control characters, then truncate. */
98
+ function sanitizeFailureText(value, max) {
99
+ return value.replace(/[\x00-\x1f\x7f]/g, "").slice(0, max);
100
+ }
101
+ function failureDetailFromTurn(failure) {
102
+ if (!failure)
103
+ return null;
104
+ return {
105
+ kind: sanitizeFailureText(failure.kind, MUSE_PROBE_FAILURE_KIND_MAX),
106
+ message: sanitizeFailureText(failure.message, MUSE_PROBE_FAILURE_MESSAGE_MAX),
107
+ };
108
+ }
109
+ function failureDetailFromError(err) {
110
+ let message;
111
+ if (err instanceof Error) {
112
+ message = err.message;
113
+ }
114
+ else if (typeof err === "string") {
115
+ message = err;
116
+ }
117
+ else {
118
+ try {
119
+ message = JSON.stringify(err) ?? "unknown error";
120
+ }
121
+ catch {
122
+ message = "unknown error";
123
+ }
124
+ }
125
+ if (!message)
126
+ message = "unknown error";
127
+ return {
128
+ kind: "exception",
129
+ message: sanitizeFailureText(message, MUSE_PROBE_FAILURE_MESSAGE_MAX),
130
+ };
131
+ }
94
132
  function appendMuseProbeDiagnostic(nowMs, result) {
95
133
  try {
96
134
  const file = museProbeDiagnosticLogPath();
@@ -134,6 +172,7 @@ export async function runMuseCapacityProbe(nowMs = Date.now(), opts = {}) {
134
172
  admitted: false,
135
173
  terminal: null,
136
174
  failureKind: "unadmitted",
175
+ failureDetail: null,
137
176
  windowsUpdated: [],
138
177
  durationMs: Date.now() - startedRealMs,
139
178
  };
@@ -142,28 +181,33 @@ export async function runMuseCapacityProbe(nowMs = Date.now(), opts = {}) {
142
181
  await host.readUsage();
143
182
  const checkNowMs = Math.max(nowMs, Date.now());
144
183
  const ok = hasFreshMuseCapacityPair(checkNowMs);
184
+ const failureKind = ok
185
+ ? null
186
+ : turn.timedOut
187
+ ? "timeout"
188
+ : turn.failure !== null
189
+ ? "turn_failed"
190
+ : "no_windows";
145
191
  result = {
146
192
  ok,
147
193
  admitted: true,
148
194
  terminal: turn.terminal,
149
- failureKind: ok
150
- ? null
151
- : turn.timedOut
152
- ? "timeout"
153
- : turn.failure !== null
154
- ? "turn_failed"
155
- : "no_windows",
195
+ failureKind,
196
+ failureDetail: failureKind === "turn_failed" || failureKind === "timeout"
197
+ ? failureDetailFromTurn(turn.failure)
198
+ : null,
156
199
  windowsUpdated: ok ? ["rolling_all_models", "weekly_all_models"] : [],
157
200
  durationMs: Date.now() - startedRealMs,
158
201
  };
159
202
  }
160
203
  }
161
- catch {
204
+ catch (err) {
162
205
  result = {
163
206
  ok: false,
164
207
  admitted: false,
165
208
  terminal: null,
166
209
  failureKind: "exception",
210
+ failureDetail: failureDetailFromError(err),
167
211
  windowsUpdated: [],
168
212
  durationMs: Date.now() - startedRealMs,
169
213
  };
@@ -173,7 +217,10 @@ export async function runMuseCapacityProbe(nowMs = Date.now(), opts = {}) {
173
217
  await host.shutdown().catch(() => undefined);
174
218
  }
175
219
  appendMuseProbeDiagnostic(nowMs, result);
176
- const outcome = result.ok ? "ok" : `failed: ${result.failureKind ?? "unknown"}`;
220
+ const detail = result.failureDetail !== null
221
+ ? ` (${result.failureDetail.kind}: ${result.failureDetail.message})`
222
+ : "";
223
+ const outcome = result.ok ? "ok" : `failed: ${result.failureKind ?? "unknown"}${detail}`;
177
224
  console.error(`[muse-capacity] fallback ${outcome}`);
178
225
  return result;
179
226
  }
@@ -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).