agent-dealer 1.2.7 → 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 (87) 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-yLyxRd-7.js → index-B6SVCzMR.js} +14 -14
  61. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  62. package/bundle/server/static-ui/index.html +2 -2
  63. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  64. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  65. package/bundle/shared/dist/execution-contract.js +307 -0
  66. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  67. package/bundle/shared/dist/execution-contract.test.js +499 -0
  68. package/bundle/shared/dist/execution-report.d.ts +9 -9
  69. package/bundle/shared/dist/execution-report.js +2 -0
  70. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  71. package/bundle/shared/dist/failure-cause.js +2 -0
  72. package/bundle/shared/dist/human-actions.d.ts +4 -4
  73. package/bundle/shared/dist/human-actions.js +9 -0
  74. package/bundle/shared/dist/index.d.ts +85 -84
  75. package/bundle/shared/dist/index.js +3 -0
  76. package/bundle/shared/dist/issues.d.ts +146 -16
  77. package/bundle/shared/dist/issues.js +8 -0
  78. package/bundle/shared/dist/issues.test.js +1 -0
  79. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  80. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  81. package/bundle/shared/dist/worker-sessions.js +8 -0
  82. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  83. package/bundle/shared/dist/workflow.d.ts +4 -4
  84. package/bundle/shared/dist/workflow.js +10 -0
  85. package/bundle/shared/dist/workflow.test.js +2 -0
  86. package/bundle/shared/package.json +1 -1
  87. package/package.json +1 -1
@@ -4,13 +4,21 @@
4
4
  // granting developer sessions the shell/write tool, and every developer round after it surfaced
5
5
  // only as a generic `dirty_worktree` escalation. So whenever `muse --version` reports a version not
6
6
  // yet checked, one real developer-posture session is run whose only path to success is a shell
7
- // call; the result is cached per version (and persisted, so a restart does not re-bill it):
7
+ // call; the result is cached per version (and persisted, so a restart does not re-bill it).
8
8
  //
9
- // - capable → recorded as the confirmed baseline, admission proceeds with no manual step;
10
- // - missing → developer admission blocked, message names old → new version + capability;
11
- // - error → the check could not complete: blocked with a distinct "could not verify"
12
- // message (fail closed), retried after a backoff;
13
- // - in flight → blocked while the one-time check runs (never assumed capable).
9
+ // NOT-308: that gate stalled execution ~15 minutes on an inconclusive (timed-out) probe —
10
+ // "we don't know yet" blocked exactly like a proven regression, retried forever on a flat
11
+ // timer, and never asked anyone to decide. New rule: an update is never assumed guilty.
12
+ // While the current version is unchecked (in flight) or its last result is inconclusive
13
+ // (error), admission proceeds on the last confirmed baseline and verification continues
14
+ // in the background. Only a *confirmed* capability loss (`missing`) blocks admission,
15
+ // and it escalates to exactly one human action per version (acknowledge/override, or pin
16
+ // /roll back outside Dealer). Inconclusive results retry with backoff (1/2/4 min), at most
17
+ // 3 attempts per version; then they escalate once as "could not verify" and stop probing
18
+ // (while a confirmed baseline exists to work on). A fresh install with no confirmed
19
+ // baseline at all keeps fail-closed behavior — and because nothing could ever unblock
20
+ // it otherwise, its inconclusive checks keep retrying at the maximum backoff past
21
+ // exhaustion until a version is confirmed.
14
22
  //
15
23
  // The probe is a fresh `muse exec` of the on-disk binary (never the long-lived serve host, which can
16
24
  // still be the pre-update build). Checks are serialized: a version reported mid-probe is checked
@@ -26,8 +34,16 @@ import path from "node:path";
26
34
  import { DEVELOPER_ROLE_CEILING } from "@agent-dealer/shared";
27
35
  import { MUSE_CLI_ENV, resolveMuseBin } from "../cli-env.js";
28
36
  import { getDataDir } from "../db/index.js";
29
- /** A probe that could not complete is retried after this long (the block stays up meanwhile). */
30
- const ERROR_RETRY_MS = 10 * 60_000;
37
+ import { createHumanAction, listHumanActionsByRequestId } from "../repository/human-actions.js";
38
+ /**
39
+ * NOT-308: an inconclusive check retries with backoff, not a flat timer — the delay after
40
+ * the Nth consecutive error is ERROR_BACKOFF_MS[N-1]. After MAX_ERROR_ATTEMPTS errors the
41
+ * version is exhausted: it escalates once as "could not verify" and is never re-probed
42
+ * automatically (the 4-minute entry is the delay a fourth attempt would have waited —
43
+ * asserting it never fires is the exhaustion proof).
44
+ */
45
+ const ERROR_BACKOFF_MS = [60_000, 2 * 60_000, 4 * 60_000];
46
+ const MAX_ERROR_ATTEMPTS = 3;
31
47
  const PROBE_TIMEOUT_MS = 5 * 60_000;
32
48
  const PROBE_MAX_MODEL_STEPS = 20;
33
49
  const STATE_FILE = "muse-capability.json";
@@ -45,23 +61,47 @@ let settledHook = () => { };
45
61
  function statePath() {
46
62
  return path.join(getDataDir(), STATE_FILE);
47
63
  }
64
+ function normalizeChecked(raw, currentVersion) {
65
+ if (!raw || typeof raw !== "object")
66
+ return null;
67
+ const c = raw;
68
+ if (typeof c.version !== "string" || c.version !== currentVersion)
69
+ return null;
70
+ if (c.status !== "capable" && c.status !== "missing" && c.status !== "error")
71
+ return null;
72
+ if (typeof c.checkedAt !== "number")
73
+ return null;
74
+ const status = c.status;
75
+ const base = {
76
+ version: c.version,
77
+ checkedAt: c.checkedAt,
78
+ // Pre-NOT-308 files have no attempts counter: a recorded error counts as its first.
79
+ attempts: typeof c.attempts === "number" ? c.attempts : status === "error" ? 1 : 0,
80
+ durationMs: typeof c.durationMs === "number" ? c.durationMs : null,
81
+ };
82
+ if (status === "capable")
83
+ return { ...base, status };
84
+ return { ...base, status, detail: typeof c.detail === "string" ? c.detail : "" };
85
+ }
48
86
  function loadState() {
49
87
  if (state)
50
88
  return state;
51
89
  try {
52
90
  const parsed = JSON.parse(fs.readFileSync(statePath(), "utf8"));
53
- const lastChecked = parsed.lastChecked && typeof parsed.lastChecked.version === "string" ? parsed.lastChecked : null;
54
91
  const current = parsed.current && typeof parsed.current.version === "string"
55
92
  ? { version: parsed.current.version, from: typeof parsed.current.from === "string" ? parsed.current.from : null }
56
93
  : null;
57
94
  state = {
58
95
  confirmedVersion: typeof parsed.confirmedVersion === "string" ? parsed.confirmedVersion : null,
59
96
  current,
60
- lastChecked: lastChecked && lastChecked.version === current?.version ? lastChecked : null,
97
+ lastChecked: normalizeChecked(parsed.lastChecked, current?.version ?? null),
98
+ overriddenVersions: Array.isArray(parsed.overriddenVersions)
99
+ ? parsed.overriddenVersions.filter((v) => typeof v === "string")
100
+ : [],
61
101
  };
62
102
  }
63
103
  catch {
64
- state = { confirmedVersion: null, current: null, lastChecked: null };
104
+ state = { confirmedVersion: null, current: null, lastChecked: null, overriddenVersions: [] };
65
105
  }
66
106
  return state;
67
107
  }
@@ -75,12 +115,36 @@ function saveState(next) {
75
115
  console.warn(`[muse-capability] could not persist ${statePath()}: ${String(err)}`);
76
116
  }
77
117
  }
118
+ /**
119
+ * NOT-308: the safety net (a dirty, shell-less developer session ending on an unconfirmed
120
+ * version) cannot await the paid probe, so it registers here: when the check for `version`
121
+ * settles conclusively bad (`missing`) or inconclusively exhausted, the watcher issue gets
122
+ * its escalation then. Watchers are best-effort and capped — admission-time `ensure`
123
+ * covers every verdict anyway, with dedupe.
124
+ */
125
+ let missWatchers = [];
126
+ function addMissWatcher(version, issueId) {
127
+ if (missWatchers.some((w) => w.version === version && w.issueId === issueId))
128
+ return;
129
+ missWatchers.push({ version, issueId });
130
+ if (missWatchers.length > 50)
131
+ missWatchers = missWatchers.slice(-50);
132
+ }
133
+ /** Drop watchers for every version but `keep` (a version change orphaned them). */
134
+ function dropMissWatchersExcept(keep) {
135
+ if (keep === null)
136
+ missWatchers = [];
137
+ else if (missWatchers.some((w) => w.version !== keep)) {
138
+ missWatchers = missWatchers.filter((w) => w.version === keep);
139
+ }
140
+ }
78
141
  /** Record `version` as the one Muse now reports; a change remembers what it changed from. */
79
142
  function observe(version) {
80
143
  const s = loadState();
81
144
  if (s.current?.version === version)
82
145
  return s;
83
146
  const from = s.current?.version ?? s.confirmedVersion;
147
+ dropMissWatchersExcept(version);
84
148
  saveState({ ...s, current: { version, from: from !== version ? from : null }, lastChecked: null });
85
149
  return state;
86
150
  }
@@ -95,7 +159,15 @@ export function parseMuseVersion(output) {
95
159
  function transition(from, to) {
96
160
  return from && from !== to ? `Muse Code updated ${from} → ${to}` : `Muse Code ${to}`;
97
161
  }
98
- function issueFor(checked, from) {
162
+ function formatDuration(ms) {
163
+ if (ms === null || !Number.isFinite(ms) || ms < 0)
164
+ return "unknown time";
165
+ const s = Math.round(ms / 1000);
166
+ if (s < 60)
167
+ return `${s}s`;
168
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
169
+ }
170
+ function issueFor(checked, from, exhausted = false) {
99
171
  if (checked.status === "capable")
100
172
  return [];
101
173
  const head = transition(from, checked.version);
@@ -108,12 +180,18 @@ function issueFor(checked, from) {
108
180
  },
109
181
  ];
110
182
  }
183
+ // Reachable only with no confirmed baseline (fresh install): with a baseline, an
184
+ // inconclusive result never blocks. The check always retries (checkDue keeps an
185
+ // unconfirmed version on the maximum backoff), so there is no "will not retry" text.
111
186
  return [
112
187
  {
113
188
  code: "runtime_capability",
114
189
  message: `Could not verify Muse Code developer ${CAPABILITY} after version change ` +
115
190
  `(${from && from !== checked.version ? `${from} → ` : ""}${checked.version}): ` +
116
- `${checked.detail} — developer admission blocked; the check retries automatically`,
191
+ `${checked.detail} — developer admission blocked` +
192
+ (exhausted
193
+ ? `; escalated for a human decision (no further automatic checks for this version)`
194
+ : `; the check retries automatically`),
117
195
  },
118
196
  ];
119
197
  }
@@ -126,17 +204,34 @@ function verifyingIssue(version, from) {
126
204
  },
127
205
  ];
128
206
  }
129
- /** Current version is unchecked, or its could-not-verify result is due a retry. */
207
+ /** True once an inconclusive version has spent its whole retry budget. */
208
+ function isExhausted(checked) {
209
+ return checked.status === "error" && checked.attempts >= MAX_ERROR_ATTEMPTS;
210
+ }
211
+ /**
212
+ * Current version is unchecked, or its could-not-verify result is due its next backoff
213
+ * retry. An exhausted version stops probing — unless there is no confirmed baseline at
214
+ * all (fresh install): then stopping would block admission permanently with no path to
215
+ * unblock (no session can be admitted, so the safety net can never fire), so retries
216
+ * continue at the maximum backoff until some version is confirmed.
217
+ */
130
218
  function checkDue(s) {
131
219
  if (!s.current)
132
220
  return false;
133
221
  const checked = s.lastChecked;
134
222
  if (!checked)
135
223
  return true;
136
- return checked.status === "error" && Date.now() - checked.checkedAt >= ERROR_RETRY_MS;
224
+ if (checked.status !== "error")
225
+ return false;
226
+ if (!isExhausted(checked)) {
227
+ return Date.now() - checked.checkedAt >= ERROR_BACKOFF_MS[checked.attempts - 1];
228
+ }
229
+ return (s.confirmedVersion === null &&
230
+ Date.now() - checked.checkedAt >= ERROR_BACKOFF_MS[ERROR_BACKOFF_MS.length - 1]);
137
231
  }
138
232
  function startCheck(version) {
139
233
  const gen = generation;
234
+ const startedAt = Date.now();
140
235
  const promise = (async () => {
141
236
  let result;
142
237
  try {
@@ -152,7 +247,18 @@ function startCheck(version) {
152
247
  // must not overwrite state for the newer one (which is checked next, below).
153
248
  if (prev.current?.version !== version)
154
249
  return;
155
- const checked = { version, checkedAt: Date.now(), ...result };
250
+ // Only consecutive errors accumulate: a conclusive verdict resets the budget, and a
251
+ // safety-net forced probe past exhaustion just keeps the version exhausted.
252
+ const prevAttempts = prev.lastChecked?.version === version && prev.lastChecked.status === "error"
253
+ ? prev.lastChecked.attempts
254
+ : 0;
255
+ const checked = {
256
+ version,
257
+ checkedAt: Date.now(),
258
+ attempts: result.status === "error" ? prevAttempts + 1 : 0,
259
+ durationMs: Date.now() - startedAt,
260
+ ...result,
261
+ };
156
262
  saveState({
157
263
  ...prev,
158
264
  confirmedVersion: result.status === "capable" ? version : prev.confirmedVersion,
@@ -161,9 +267,30 @@ function startCheck(version) {
161
267
  const from = prev.current.from;
162
268
  if (result.status === "capable") {
163
269
  console.log(`[muse-capability] ${transition(from, version)}: developer ${CAPABILITY} confirmed`);
270
+ dropMissWatchersExcept(null);
164
271
  }
165
- else {
272
+ else if (result.status === "missing") {
166
273
  console.warn(`[muse-capability] ${issueFor(checked, from)[0].message}`);
274
+ escalateWatchedVersion(version);
275
+ }
276
+ else if (isExhausted(checked)) {
277
+ // With a baseline the version is done (escalated once, admission on the baseline);
278
+ // with none the checks continue at the maximum backoff — and admission stays
279
+ // blocked — until some version is confirmed.
280
+ console.warn(`[muse-capability] Could not verify Muse Code developer ${CAPABILITY} for ${version} ` +
281
+ `after ${checked.attempts} inconclusive checks (last probe ran ${formatDuration(checked.durationMs)}): ` +
282
+ (prev.confirmedVersion
283
+ ? `escalating once, no further automatic checks; admission continues on baseline ${prev.confirmedVersion}`
284
+ : `escalated for a human decision; automatic checks continue at the maximum backoff; admission stays blocked (no confirmed baseline yet)`));
285
+ escalateWatchedVersion(version);
286
+ }
287
+ else if (checked.status === "error") {
288
+ console.warn(`[muse-capability] Could not verify Muse Code developer ${CAPABILITY} for ${version} ` +
289
+ `(attempt ${checked.attempts}/${MAX_ERROR_ATTEMPTS}, last probe ran ${formatDuration(checked.durationMs)}): ` +
290
+ `${checked.detail} — retrying in ${formatDuration(ERROR_BACKOFF_MS[Math.min(checked.attempts, ERROR_BACKOFF_MS.length) - 1])}; ` +
291
+ (prev.confirmedVersion
292
+ ? `admission continues on baseline ${prev.confirmedVersion}`
293
+ : `admission stays blocked (no confirmed baseline yet)`));
167
294
  }
168
295
  })().finally(() => {
169
296
  if (inFlight?.promise === promise)
@@ -179,10 +306,17 @@ function startCheck(version) {
179
306
  inFlight = { version, promise };
180
307
  }
181
308
  /**
182
- * Capability issues for the currently reported Muse version. Never awaits the probe: a version not
183
- * yet checked starts one background check and blocks until it settles. Checks are serialized — a
184
- * version observed while another is being probed waits for that probe, then is checked once.
185
- * `onSettled` lets the caller drop its health cache so admission unblocks as soon as the result is in.
309
+ * Capability issues for the currently reported Muse version. Never awaits the probe.
310
+ *
311
+ * NOT-308: only evidence of a real capability loss (`missing`, and not overridden by the
312
+ * operator) blocks admission. An unchecked version or an inconclusive (`error`) result
313
+ * keeps verifying in the background while admission proceeds on the last confirmed
314
+ * baseline. With no confirmed baseline at all (fresh install), every unknown blocks —
315
+ * fail closed until the first version is confirmed.
316
+ *
317
+ * Checks are serialized — a version observed while another is being probed waits for
318
+ * that probe, then is checked once. `onSettled` lets the caller drop its health cache
319
+ * so admission sees the result as soon as it lands.
186
320
  */
187
321
  export function museCapabilityIssues(version, onSettled = () => { }) {
188
322
  settledHook = onSettled;
@@ -190,9 +324,293 @@ export function museCapabilityIssues(version, onSettled = () => { }) {
190
324
  const from = s.current.from;
191
325
  if (checkDue(s) && !inFlight)
192
326
  startCheck(version);
193
- if (s.lastChecked)
194
- return issueFor(s.lastChecked, from); // incl. error retry: keep the block up
195
- return verifyingIssue(version, from);
327
+ const checked = s.lastChecked;
328
+ // Unchecked (including a retry already running): a baseline means "verify in the
329
+ // background, keep working"; without one the unknown blocks.
330
+ if (!checked)
331
+ return s.confirmedVersion ? [] : verifyingIssue(version, from);
332
+ if (checked.status === "capable")
333
+ return [];
334
+ if (checked.status === "missing") {
335
+ return s.overriddenVersions.includes(checked.version) ? [] : issueFor(checked, from);
336
+ }
337
+ // Inconclusive: never a block while any baseline exists to work on. With none, the
338
+ // block stands but the check keeps retrying at the maximum backoff (checkDue), so the
339
+ // message always says "retries" — never "no further automatic checks".
340
+ return s.confirmedVersion ? [] : issueFor(checked, from, false);
341
+ }
342
+ /**
343
+ * Dedupe key for the one human action per Muse version *and verdict kind* (NOT-308).
344
+ * Stored as the action's request_id so same-issue races collapse on the existing partial
345
+ * unique index, and so cross-issue lookups (`listHumanActionsByRequestId`) find it.
346
+ *
347
+ * The kind is part of the key because one version can produce both verdicts in sequence:
348
+ * an exhausted `error` escalates as "unverified", and a later safety-net probe of the
349
+ * same version can still return `missing`. Sharing one key would let the still-open
350
+ * "unverified" action swallow the "missing" escalation (wrong text, and dismissing it
351
+ * would record an override for a verdict the operator never saw). The unkinded form is
352
+ * only for reading actions written before the key carried a kind.
353
+ */
354
+ export function museCapabilityRequestId(version, kind) {
355
+ return kind ? `muse-capability:${kind}:${version}` : `muse-capability:${version}`;
356
+ }
357
+ /** Which verdict kind a prior escalation action belongs to. Legacy rows (unkinded
358
+ * request_id) are classified by their evidence; a row with no parseable evidence is
359
+ * conservatively treated as the kind being raised (dedupe, never spam). */
360
+ function actionKindOf(action, fallback) {
361
+ try {
362
+ const evidence = action.evidenceJson
363
+ ? JSON.parse(action.evidenceJson)
364
+ : null;
365
+ if (evidence?.kind === "missing" || evidence?.kind === "unverified")
366
+ return evidence.kind;
367
+ }
368
+ catch {
369
+ // Fall through to the request_id prefix below.
370
+ }
371
+ const prefixed = /^muse-capability:(missing|unverified):/.exec(action.requestId ?? "");
372
+ if (prefixed)
373
+ return prefixed[1];
374
+ return fallback;
375
+ }
376
+ /** The `choice` a resolved escalation action was resolved with, if parseable. The
377
+ * dedicated `muse_capability` resolve path always records `{choice: "acknowledge"}`;
378
+ * abort/close lifecycle resolutions record a `reason` instead and never a choice. */
379
+ function resolutionChoiceOf(action) {
380
+ try {
381
+ const resolution = action.resolutionJson ? JSON.parse(action.resolutionJson) : null;
382
+ return resolution && typeof resolution === "object" && "choice" in resolution &&
383
+ typeof resolution.choice === "string"
384
+ ? resolution.choice
385
+ : null;
386
+ }
387
+ catch {
388
+ return null;
389
+ }
390
+ }
391
+ function escalationFor(checked, s) {
392
+ const from = s.current?.version === checked.version ? s.current.from : s.confirmedVersion;
393
+ const baseline = s.confirmedVersion ?? "none";
394
+ if (checked.status === "missing" && !s.overriddenVersions.includes(checked.version)) {
395
+ const rollback = from
396
+ ? `, or pin/roll back Muse to ${from} outside Dealer (the next version change is checked automatically)?`
397
+ : `. Pin/roll back Muse outside Dealer to a working build if needed (the next version change is checked automatically)?`;
398
+ return {
399
+ kind: "missing",
400
+ reason: `${transition(from, checked.version)}: developer sessions no longer get ${CAPABILITY} ` +
401
+ `(${checked.detail}). Last probe ran ${formatDuration(checked.durationMs)} ` +
402
+ `(probe timeout ${formatDuration(PROBE_TIMEOUT_MS)}). ` +
403
+ `Developer admission is blocked for ${checked.version}; last confirmed baseline is ${baseline}.`,
404
+ question: `Muse Code ${checked.version} lost developer ${CAPABILITY}. Acknowledge to admit developers ` +
405
+ `on ${checked.version} anyway${rollback}`,
406
+ };
407
+ }
408
+ if (isExhausted(checked) && checked.status === "error") {
409
+ const detail = checked.detail;
410
+ // With a baseline, admission proceeds on it and probing stops; with none (fresh
411
+ // install), admission is blocked and the checks continue at the maximum backoff —
412
+ // the text must say which, never "continues on baseline none".
413
+ const standing = s.confirmedVersion
414
+ ? `Developer admission continues on the last confirmed baseline ${baseline}; ` +
415
+ `no further automatic checks will run for ${checked.version}.`
416
+ : `Developer admission is blocked (no confirmed baseline yet); ` +
417
+ `automatic checks continue at the maximum backoff until a version is confirmed.`;
418
+ return {
419
+ kind: "unverified",
420
+ reason: `Could not verify Muse Code developer ${CAPABILITY} for ${checked.version} after ` +
421
+ `${checked.attempts} inconclusive checks (last: ${detail}; last probe ran ` +
422
+ `${formatDuration(checked.durationMs)}, probe timeout ${formatDuration(PROBE_TIMEOUT_MS)}). ` +
423
+ standing,
424
+ question: `Muse Code ${checked.version} could not be verified. Acknowledge to dismiss, ` +
425
+ `or investigate the probe failures` +
426
+ (s.confirmedVersion
427
+ ? ` (roll back to ${baseline} outside Dealer if the new build is suspect)?`
428
+ : `?`),
429
+ };
430
+ }
431
+ return null;
432
+ }
433
+ /**
434
+ * NOT-308: raise the human action for a version the gate cannot clear by itself —
435
+ * exactly one per version and verdict kind, on whichever issue first observes the
436
+ * verdict. Later polls (and other issues' admissions) find the open action and skip
437
+ * creation; a resolved one of the same kind whose resolution chose `acknowledge`
438
+ * means the operator already decided, so the override is recorded (if missing) and
439
+ * nothing is re-raised. An action of the *other*
440
+ * kind neither suppresses this escalation nor counts as a decision on it: dismissing
441
+ * "could not verify" must never read as overriding a later confirmed "missing".
442
+ * Returns the open action, or null when nothing was (re)raised. Never blocks admission
443
+ * itself — the gate reads file state, not this.
444
+ *
445
+ * Repair round 3: only a resolved action whose resolution chose `acknowledge` (the
446
+ * dedicated resolve path's choice) counts as the operator's decision on the verdict.
447
+ * Abort/close resolve every open action on the issue with `{reason:
448
+ * "aborted_by_user" | "closed_by_operator"}` — issue lifecycle, not a capability
449
+ * decision — so such a resolution re-raises a fresh action instead of recording an
450
+ * override that would silently lift the block.
451
+ */
452
+ export function ensureMuseCapabilityEscalation(issueId) {
453
+ const s = loadState();
454
+ const checked = s.lastChecked;
455
+ if (!s.current || !checked || checked.version !== s.current.version)
456
+ return null;
457
+ const escalation = escalationFor(checked, s);
458
+ if (!escalation || checked.status === "capable")
459
+ return null;
460
+ const detail = checked.detail;
461
+ const requestId = museCapabilityRequestId(checked.version, escalation.kind);
462
+ // Same-kind rows under both the current key and the pre-kind legacy key.
463
+ const seen = new Map();
464
+ for (const key of [requestId, museCapabilityRequestId(checked.version)]) {
465
+ for (const action of listHumanActionsByRequestId("muse_capability", key)) {
466
+ if (!seen.has(action.id))
467
+ seen.set(action.id, action);
468
+ }
469
+ }
470
+ const sameKind = [...seen.values()].filter((a) => actionKindOf(a, escalation.kind) === escalation.kind);
471
+ const open = sameKind.find((a) => a.status === "open");
472
+ if (open)
473
+ return open;
474
+ // Only an explicit `acknowledge` resolution is the operator's decision on this
475
+ // verdict — anything else (abort/close lifecycle reasons, legacy rows with no
476
+ // parseable choice) falls through and re-raises a fresh action below.
477
+ const acknowledged = sameKind.some((a) => a.status === "resolved" && resolutionChoiceOf(a) === "acknowledge");
478
+ if (acknowledged) {
479
+ if (escalation.kind === "missing")
480
+ recordMuseCapabilityOverride(checked.version);
481
+ return null;
482
+ }
483
+ return createHumanAction({
484
+ issueId,
485
+ actionType: "muse_capability",
486
+ reason: escalation.reason,
487
+ question: escalation.question,
488
+ evidence: {
489
+ kind: escalation.kind,
490
+ version: checked.version,
491
+ from: s.current.from,
492
+ detail,
493
+ durationMs: checked.durationMs,
494
+ capability: CAPABILITY,
495
+ },
496
+ responseOptions: [{ choice: "acknowledge", label: `Acknowledge — ${escalation.kind === "missing" ? `admit on ${checked.version}` : "keep working on baseline"}` }],
497
+ requestId,
498
+ });
499
+ }
500
+ /** Escalate a safety-net-watched version whose probe just settled badly (best-effort). */
501
+ function escalateWatchedVersion(version) {
502
+ const owed = missWatchers.filter((w) => w.version === version);
503
+ missWatchers = missWatchers.filter((w) => w.version !== version);
504
+ for (const w of owed) {
505
+ try {
506
+ ensureMuseCapabilityEscalation(w.issueId);
507
+ }
508
+ catch (err) {
509
+ console.warn(`[muse-capability] could not escalate ${version} on ${w.issueId}: ${String(err)}`);
510
+ }
511
+ }
512
+ }
513
+ /**
514
+ * Record the operator's acknowledge/override for a `missing` version (called by the
515
+ * `muse_capability` resolve path). The gate stops blocking that version; later versions
516
+ * are unaffected.
517
+ */
518
+ export function recordMuseCapabilityOverride(version) {
519
+ const s = loadState();
520
+ if (s.overriddenVersions.includes(version))
521
+ return;
522
+ saveState({ ...s, overriddenVersions: [...s.overriddenVersions, version].slice(-50) });
523
+ }
524
+ /**
525
+ * Shell-ish tool names in Muse session logs. `bash` is the observed shell tool
526
+ * (fixtures + recorded runs); anything containing `shell` is counted defensively —
527
+ * no known non-shell Muse tool contains it. Unknown future shell names miss the count
528
+ * and cause one extra verification probe, never a missed regression.
529
+ */
530
+ const MUSE_SHELL_TOOL_RE = /^(bash|shell)$|^bash[\s_-]|shell/i;
531
+ /**
532
+ * Count shell tool calls in a Muse session log. Counts both the normalized
533
+ * `{type:"tool_call", name}` events the spawn writes to logPath and raw
534
+ * `tool:<name>` envelope operations, whichever the file holds. Returns null when
535
+ * the log is unreadable or holds no parseable lines — "unknown", never zero, so an
536
+ * unreadable log alone never fires the safety net.
537
+ */
538
+ export function countMuseShellToolCalls(logPath) {
539
+ let raw;
540
+ try {
541
+ raw = fs.readFileSync(logPath, "utf8");
542
+ }
543
+ catch {
544
+ return null;
545
+ }
546
+ let parsed = 0;
547
+ let shells = 0;
548
+ for (const line of raw.split("\n")) {
549
+ const t = line.trim();
550
+ if (!t)
551
+ continue;
552
+ let event;
553
+ try {
554
+ event = JSON.parse(t);
555
+ }
556
+ catch {
557
+ continue;
558
+ }
559
+ if (!event || typeof event !== "object" || Array.isArray(event))
560
+ continue;
561
+ parsed += 1;
562
+ const rec = event;
563
+ if (rec.type === "tool_call" && typeof rec.name === "string" && MUSE_SHELL_TOOL_RE.test(rec.name)) {
564
+ shells += 1;
565
+ continue;
566
+ }
567
+ const operation = rec.payload?.event;
568
+ const op = operation?.operation;
569
+ if (typeof op === "string" && op.startsWith("tool:") && MUSE_SHELL_TOOL_RE.test(op.slice("tool:".length))) {
570
+ shells += 1;
571
+ }
572
+ }
573
+ return parsed === 0 ? null : shells;
574
+ }
575
+ /**
576
+ * NOT-308 safety net: a developer session ending with a dirty worktree but zero shell
577
+ * tool calls is exactly what a shell-less build looks like from the outside, so when
578
+ * the running Muse version is not yet the confirmed baseline, verify it immediately —
579
+ * bypassing the error backoff — and escalate a `missing` verdict on this issue.
580
+ * Fire-and-forget (the probe settles in the background and the watcher escalates);
581
+ * never throws and never changes the session outcome.
582
+ */
583
+ export function museCapabilitySafetyNetAfterSession(input) {
584
+ try {
585
+ if (input.runtime !== "muse_code" || !input.dirty)
586
+ return "skipped";
587
+ const shells = countMuseShellToolCalls(input.logPath);
588
+ if (shells === null || shells > 0)
589
+ return "skipped";
590
+ const version = reportedMuseVersion();
591
+ if (!version)
592
+ return "skipped";
593
+ const s = observe(version);
594
+ if (s.confirmedVersion === version)
595
+ return "skipped";
596
+ const checked = s.lastChecked;
597
+ if (checked && checked.version === version && checked.status === "missing") {
598
+ ensureMuseCapabilityEscalation(input.issueId);
599
+ return "escalated";
600
+ }
601
+ addMissWatcher(version, input.issueId);
602
+ if (inFlight)
603
+ return "watching";
604
+ // The suspicion (dirty + shell-less) outranks the error backoff timer: probe now
605
+ // instead of waiting it out. Exhaustion does not stop this — the session is fresh
606
+ // evidence, and a `missing` verdict escalates while an `error` just stays exhausted.
607
+ startCheck(version);
608
+ return "probed";
609
+ }
610
+ catch (err) {
611
+ console.warn(`[muse-capability] safety net skipped: ${String(err)}`);
612
+ return "skipped";
613
+ }
196
614
  }
197
615
  /** True while a capability check is running (callers use a short health-cache TTL meanwhile). */
198
616
  export function museCapabilityCheckInFlight() {
@@ -220,6 +638,7 @@ export function resetMuseCapabilityStateForTests() {
220
638
  inFlight = null;
221
639
  state = null;
222
640
  settledHook = () => { };
641
+ missWatchers = [];
223
642
  fs.rmSync(statePath(), { force: true });
224
643
  }
225
644
  /** Tests: backdate the last check so the error-retry path can be exercised without waiting. */