amicus 4.6.1 → 4.6.3

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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +152 -0
  3. package/README.md +7 -8
  4. package/docs/ROADMAP.md +38 -4
  5. package/docs/configuration.md +18 -12
  6. package/docs/council.md +7 -2
  7. package/docs/troubleshooting.md +49 -20
  8. package/docs/usage.md +30 -3
  9. package/electron/setup-ui-aliases.js +2 -2
  10. package/electron/workspace-ui/index.html +3 -0
  11. package/electron/workspace-ui/live-model.js +146 -2
  12. package/electron/workspace-ui/workspace-app.js +8 -3
  13. package/electron/workspace-ui/workspace-panels.js +9 -10
  14. package/electron/workspace-ui/workspace-render.js +9 -3
  15. package/electron/workspace-ui/workspace-seats.js +132 -0
  16. package/electron/workspace-ui/workspace-verbs.js +2 -1
  17. package/electron/workspace-ui/workspace.css +6 -0
  18. package/package.json +1 -1
  19. package/schemas/alias-audit.schema.json +6 -1
  20. package/schemas/council-run.schema.json +14 -0
  21. package/src/cli-handlers-council.js +9 -0
  22. package/src/cli-handlers-doctor.js +25 -7
  23. package/src/cli.js +4 -0
  24. package/src/council/presets-cli.js +6 -2
  25. package/src/council/run-chair.js +55 -6
  26. package/src/headless.js +119 -9
  27. package/src/mcp-council-awareness.js +1 -0
  28. package/src/opencode-client.js +21 -0
  29. package/src/session-manager.js +6 -2
  30. package/src/sidecar/fanout-leg.js +2 -2
  31. package/src/sidecar/fanout.js +1 -1
  32. package/src/sidecar/models-probe.js +119 -0
  33. package/src/sidecar/models.js +81 -6
  34. package/src/utils/alias-audit.js +71 -1
  35. package/src/utils/base-url-classify.js +74 -0
  36. package/src/utils/council-presets.js +6 -2
  37. package/src/utils/curated-models.js +71 -16
  38. package/src/utils/doctor-base-url-check.js +41 -0
  39. package/src/utils/gateway-route-audit.js +16 -3
  40. package/src/utils/model-fetcher.js +9 -6
  41. package/src/utils/model-tiers.js +28 -7
  42. package/src/utils/no-output-backstop.js +48 -0
  43. package/src/utils/remediation-hints.js +14 -0
  44. package/src/utils/result-schema.js +29 -2
  45. package/src/utils/session-metadata-tmp-sweep.js +136 -0
  46. package/src/workspace/live-normalize.js +1 -0
@@ -42,6 +42,32 @@ function pickFallbackChair(statsRows, bench, failedChair) {
42
42
  return candidates.length ? candidates[0].model : null;
43
43
  }
44
44
 
45
+ /**
46
+ * Outcome taxonomy for one fallback-walk attempt (spec §8, LC-5). The ch4
47
+ * VERDICT repair is deliberately NOT an attempt: its chair leg already
48
+ * completed — only the verdict line is being re-prompted — and the outcome
49
+ * enum has no honest value for it.
50
+ * @param {object|null} rawLeg the UNFILTERED leg (attemptChair nulls `leg` on
51
+ * failure; this is the one before that narrowing, so a failed leg document
52
+ * is still visible here)
53
+ * @param {object|null} [errorDoc] set when the launch never produced a wave
54
+ * at all (pre-flight refusal) — the only source of a reason in that case
55
+ * @returns {{outcome: 'completed'|'error'|'timeout'|'no-output', reason: string|null}}
56
+ */
57
+ function classifyChairAttempt(rawLeg, errorDoc) {
58
+ if (!rawLeg) {
59
+ const reason = (errorDoc && (errorDoc.message || errorDoc.reason)) || 'no leg document';
60
+ return { outcome: 'error', reason };
61
+ }
62
+ if (rawLeg.status === 'timeout') { return { outcome: 'timeout', reason: rawLeg.reason || null }; }
63
+ if (rawLeg.status === 'complete') {
64
+ const hasOutput = rawLeg.summary && String(rawLeg.summary).trim();
65
+ return hasOutput ? { outcome: 'completed', reason: null }
66
+ : { outcome: 'no-output', reason: rawLeg.reason || null };
67
+ }
68
+ return { outcome: 'error', reason: rawLeg.reason || rawLeg.error || String(rawLeg.status) };
69
+ }
70
+
45
71
  /**
46
72
  * Chair chain (attempt → retry → ledger-promoted fallback → give up) plus the
47
73
  * single VERDICT-line repair re-prompt.
@@ -76,12 +102,28 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
76
102
  addWave(solo.wave);
77
103
  const ok = solo.leg && solo.leg.status === 'complete'
78
104
  && solo.leg.summary && solo.leg.summary.trim();
79
- return { leg: ok ? solo.leg : null, exitCode: solo.exitCode };
105
+ // rawLeg is the UN-nulled leg the classifier needs to see a failed leg
106
+ // document, not just the ok/null collapse the rest of the walk consumes.
107
+ return { leg: ok ? solo.leg : null, exitCode: solo.exitCode, errorDoc: solo.errorDoc, rawLeg: solo.leg };
80
108
  };
81
109
 
82
110
  let chairLeg = null;
83
111
  let actualChair = null;
84
112
  let skippedForCost = false;
113
+ // Additive on run.json (LC-5): one entry per resolved attempt (ch1/ch2/ch3;
114
+ // ch4 is a repair, not an attempt — see classifyChairAttempt). Declared here
115
+ // (not inside the else branch below) so it stays in scope for the
116
+ // chair-failed why enrichment after the branch closes, and so a
117
+ // cost-skipped chair (the `if` branch) simply never calls recordAttempt —
118
+ // chairAttempts is never checkpointed and the key stays absent on run.json.
119
+ const chairAttempts = [];
120
+ const recordAttempt = (attempt, waveId, model) => {
121
+ const cls = classifyChairAttempt(attempt.rawLeg, attempt.errorDoc);
122
+ chairAttempts.push({ waveId, model, outcome: cls.outcome, reason: cls.reason });
123
+ // Checkpointed HERE, before the caller's own isAbortExit bail — a mid-walk
124
+ // kill must not lose the attempts already resolved (spec §8 kill-mid-walk).
125
+ runState.checkpoint(o.runDir, { chairAttempts });
126
+ };
85
127
  if (overBudget()) {
86
128
  // Ceiling hit after the tally is computable: skip the chair, write the
87
129
  // verdict with overallVerdict null, exit 2 (spec §4 degradation table).
@@ -101,10 +143,14 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
101
143
  emitStageStarted(o.runDir, o.runId, 'chair', null, o.follow);
102
144
  // Fallback chain (spec §4): retry same chair once → promote best
103
145
  // non-bench model from the ledger → give up (no Claude fallback headless).
104
- let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
146
+ const waveId1 = `${o.runId}-ch1`;
147
+ let attempt = await attemptChair(o.chair, waveId1);
148
+ recordAttempt(attempt, waveId1, o.chair);
105
149
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
106
150
  if (!attempt.leg && !overBudget()) {
107
- attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
151
+ const waveId2 = `${o.runId}-ch2`;
152
+ attempt = await attemptChair(o.chair, waveId2);
153
+ recordAttempt(attempt, waveId2, o.chair);
108
154
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
109
155
  }
110
156
  if (attempt.leg) { actualChair = o.chair; }
@@ -113,7 +159,9 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
113
159
  try { statsRows = statsFn(); } catch { /* no ledger yet */ }
114
160
  const fallback = pickFallbackChair(statsRows, o.models, o.chair);
115
161
  if (fallback) {
116
- attempt = await attemptChair(fallback, `${o.runId}-ch3`);
162
+ const waveId3 = `${o.runId}-ch3`;
163
+ attempt = await attemptChair(fallback, waveId3);
164
+ recordAttempt(attempt, waveId3, fallback);
117
165
  if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
118
166
  if (attempt.leg) { actualChair = fallback; }
119
167
  }
@@ -160,7 +208,8 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
160
208
  what: 'the council has no chair synthesis',
161
209
  why: chairLeg
162
210
  ? 'the chair ran but its output carried no parseable VERDICT: line'
163
- : 'no chair leg completed, including after the fallback chain',
211
+ : `no chair leg completed after the fallback walk — ${chairAttempts.map(a =>
212
+ `${a.waveId.split('-').pop()} ${a.model}: ${a.reason || a.outcome}`).join(' · ')}`,
164
213
  effect: 'the verdict is written with overallVerdict null; will exit degraded (2)',
165
214
  });
166
215
  }
@@ -170,4 +219,4 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
170
219
  };
171
220
  }
172
221
 
173
- module.exports = { runChair, pickFallbackChair };
222
+ module.exports = { runChair, pickFallbackChair, classifyChairAttempt };
package/src/headless.js CHANGED
@@ -454,19 +454,72 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
454
454
  promptOptions.reasoning = reasoning;
455
455
  }
456
456
 
457
- // Send prompt asynchronously (returns immediately, we poll for results)
457
+ // v4.6.2 PR2 amendment (controller live smoke, field evidence): arm BEFORE
458
+ // the prompt send, not after. OpenCode's prompt-send handler can itself
459
+ // block on the upstream provider call before ever returning — a silently-
460
+ // accepting endpoint (the v4.6.1 gemini class) hung the very next line's
461
+ // await for 6+ minutes with the backstop never even created yet, upstream
462
+ // of every mechanism that was supposed to catch it. `startedAt` here means
463
+ // "time since the leg asked for output". Disarmed permanently by the first
464
+ // SUBSTANTIVE-activity tick in the poll loop below (output/tool/result/
465
+ // reasoning/settle — NOT the placeholder-compatible message/assistant-id
466
+ // signals; see substantiveActivity); 0 (or negative) disables — the
467
+ // send itself is unbounded in that case too (see withTimeout below).
468
+ const { resolveNoOutputBackstopMs, createNoOutputBackstop } = require('./utils/no-output-backstop');
469
+ // v4.6.2 PR3 Task 1: Number.isFinite, not `!== undefined` — a non-number
470
+ // (e.g. a string arriving from a CLI/JSON boundary) must fall through to
471
+ // env resolution instead of reaching the deadline arithmetic below.
472
+ // `startedAt + ms` string-concatenates when ms is a string, producing a
473
+ // deadline `nowMs >= deadline` can never satisfy — the backstop would
474
+ // silently never fire. Finite zero (the documented explicit-disable
475
+ // value) still takes the direct branch: Number.isFinite(0) === true.
476
+ const noOutputBackstopMs = Number.isFinite(options.noOutputBackstopMs)
477
+ ? options.noOutputBackstopMs : resolveNoOutputBackstopMs(options._env);
478
+ const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: Date.now() });
479
+ let backstopFired = false;
480
+ // Single source for the reason string so the pre-send firing site below and
481
+ // the per-poll firing site further down (still ticking the SAME instance)
482
+ // can never drift apart.
483
+ const noOutputBackstopReason = () => 'NO_OUTPUT_BACKSTOP: model produced no '
484
+ + `output, reasoning, or tool calls in ${Math.round(noOutputBackstopMs / 1000)}s `
485
+ + '— likely a listed-but-not-serving model or a dead endpoint';
486
+
487
+ // Send prompt asynchronously (returns immediately, we poll for results) —
488
+ // bounded by the backstop: an endpoint that accepts but never answers must
489
+ // not hang this await the way it hung the field-observed leg.
458
490
  logger.info('Sending prompt to OpenCode', {
459
491
  sessionId,
460
492
  model,
461
493
  agent: promptOptions.agent,
462
494
  userMessageLength: userMessage.length
463
495
  });
464
- const promptResult = await sendPromptAsync(client, sessionId, promptOptions);
465
- writeProgress(sessionDir, 'prompt_sent');
466
- logger.info('Prompt sent successfully, entering polling loop', {
467
- sessionId,
468
- timeoutMs
469
- });
496
+ const sendPromptLabel = 'sendPromptAsync';
497
+ const sendPromptPromise = sendPromptAsync(client, sessionId, promptOptions);
498
+ let promptResult = null;
499
+ try {
500
+ promptResult = await withTimeout(sendPromptPromise, noOutputBackstopMs, sendPromptLabel);
501
+ writeProgress(sessionDir, 'prompt_sent');
502
+ logger.info('Prompt sent successfully, entering polling loop', {
503
+ sessionId,
504
+ timeoutMs
505
+ });
506
+ } catch (sendErr) {
507
+ const isBackstopTimeout = noOutputBackstopMs > 0
508
+ && sendErr.message === `${sendPromptLabel} timed out after ${noOutputBackstopMs}ms`;
509
+ if (!isBackstopTimeout) { throw sendErr; } // a genuine sendPromptAsync failure — unchanged behavior
510
+ // The backstop deadline won the race — OpenCode never returned from the
511
+ // prompt-send call at all; the "accepts, never responds" shape dies
512
+ // upstream of the poll loop entirely. Swallow the orphaned promise so it
513
+ // can never surface as an unhandled rejection whenever/if it eventually
514
+ // settles on its own (Promise.race already subscribes each racer
515
+ // internally, so this is defensive belt-and-suspenders, not load-bearing
516
+ // — verified empirically before relying on it).
517
+ sendPromptPromise.catch(() => {});
518
+ backstopFired = true;
519
+ logger.warn('No-output backstop fired before the prompt send resolved', {
520
+ taskId, sessionId, backstopMs: noOutputBackstopMs,
521
+ });
522
+ }
470
523
 
471
524
  const mirror = createMirrorState();
472
525
  let completed = false;
@@ -474,6 +527,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
474
527
  let aborted = false;
475
528
  let sessionError = null; // Captures model/SDK errors from assistant messages
476
529
 
530
+ // Seed sessionError exactly like the #37 boundary-provider-error case right
531
+ // below does, so the run ends with a usable reason (the poll loop is
532
+ // skipped entirely on this path — see the while-condition and the
533
+ // backstop-abort block further down).
534
+ if (backstopFired) {
535
+ sessionError = noOutputBackstopReason();
536
+ }
537
+
477
538
  // Hard provider failure detected at the client boundary (#37): a non-2xx /
478
539
  // 402 from promptAsync surfaces here even when the server never emits an
479
540
  // assistant message carrying info.error. Seed sessionError so the loop's
@@ -573,7 +634,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
573
634
  return false;
574
635
  };
575
636
 
576
- while (!completed && (Date.now() - startTime) < timeoutMs) {
637
+ // `!backstopFired`: a no-op for the pre-existing mid-loop firing path (that
638
+ // branch already `break`s the instant it sets backstopFired, so this outer
639
+ // condition is never re-checked with it true from there) — it only matters
640
+ // for the NEW pre-send-timeout path above, where backstopFired can already
641
+ // be true before the loop ever starts. Skips the loop entirely rather than
642
+ // burning one wasted pollIntervalMs sleep before falling through to the
643
+ // post-loop abort block below.
644
+ while (!completed && !backstopFired && (Date.now() - startTime) < timeoutMs) {
577
645
  watchdog.touch();
578
646
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
579
647
 
@@ -729,6 +797,26 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
729
797
  || newAssistant || reasoningActivity || settleActivity;
730
798
  if (progressed) { lastProgressAt = Date.now(); }
731
799
 
800
+ // v4.6.2 PR2 amendment 2 (controller live smoke + debug trace): the
801
+ // backstop disarms only on SUBSTANTIVE activity — output, reasoning,
802
+ // or tool motion (the spec's "first token/reasoning/tool_use").
803
+ // messageActivity/newAssistant are excluded: OpenCode creates an empty
804
+ // assistant placeholder on prompt ACCEPTANCE, which is precisely the
805
+ // accepted-but-not-serving bookkeeping the backstop must not trust.
806
+ // `progressed` itself (and every stall/idle consumer of it above) is
807
+ // deliberately untouched — this is a narrower, backstop-only signal.
808
+ const substantiveActivity = outputGrew || toolActivity || resultActivity
809
+ || reasoningActivity || settleActivity;
810
+
811
+ // No-output backstop: one tick per poll. Fired is terminal — break the
812
+ // loop; the post-loop block below mirrors the timeout path.
813
+ if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
814
+ backstopFired = true;
815
+ sessionError = noOutputBackstopReason();
816
+ logger.warn('No-output backstop fired', { taskId, backstopMs: noOutputBackstopMs });
817
+ break;
818
+ }
819
+
732
820
  // B53: a wedged tool call (tool_use emitted, result never arrives) otherwise
733
821
  // burns the full --timeout with zero output — the stable-poll idle gate above
734
822
  // requires mirror.output.length > 0, which a pre-text wedge never satisfies.
@@ -832,7 +920,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
832
920
  });
833
921
 
834
922
  // Handle timeout
835
- if (!completed && !aborted && (Date.now() - startTime) >= timeoutMs) {
923
+ // v4.6.2 PR2 fix wave: `!backstopFired` the backstop's own break can
924
+ // land after the post-break poll tail (getMessages + mirror processing
925
+ // already inside that iteration) has ALSO crossed timeoutMs when the two
926
+ // thresholds are configured close together, so this block must yield
927
+ // once the backstop already ended the leg. Exactly one terminal-timing
928
+ // signal per leg: statusFromResult() (src/utils/result-schema.js) checks
929
+ // timedOut BEFORE error, so a leg carrying both would misreport as an
930
+ // ordinary 'timeout' instead of the distinctly-named backstop reason.
931
+ if (!completed && !aborted && !backstopFired && (Date.now() - startTime) >= timeoutMs) {
836
932
  timedOut = true;
837
933
  logger.warn('Task timed out', { taskId, elapsed: Date.now() - startTime });
838
934
 
@@ -846,6 +942,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
846
942
  }
847
943
  }
848
944
 
945
+ // Backstop fired: abort the OpenCode session exactly like the timeout path
946
+ // (the agent keeps running otherwise). The leg's error already carries the
947
+ // NO_OUTPUT_BACKSTOP reason; no separate degrade machinery — the ordinary
948
+ // dead-leg path (SL-2 retry, sink announcement, exit codes) inherits it.
949
+ if (backstopFired && !completed && !aborted) {
950
+ try {
951
+ const { abortSession } = require('./opencode-client');
952
+ await abortSession(client, sessionId, ...dirArgs);
953
+ logger.info('Session aborted after no-output backstop', { taskId, sessionId });
954
+ } catch (abortErr) {
955
+ logger.warn('Failed to abort session after backstop', { error: abortErr.message });
956
+ }
957
+ }
958
+
849
959
  watchdog.cancel();
850
960
  if (uninstallSignals) { uninstallSignals(); }
851
961
 
@@ -185,6 +185,7 @@ function buildCouncilStatusPayload(project, taskId) {
185
185
  legsTotal, legsComplete, elapsed: elapsedOf(run),
186
186
  exitCode: run.exitCode !== undefined ? run.exitCode : null,
187
187
  version: RUNNING_VERSION,
188
+ degrades: run.degrades || [],
188
189
  };
189
190
  if (usageLegs.length) { payload.usage = rollupWaveUsage(usageLegs); }
190
191
  if (allLegIds.length) {
@@ -557,6 +557,27 @@ function buildServerOptions(options = {}) {
557
557
  : (options.model ? [options.model] : []);
558
558
  config.provider = buildProviderModels(resolvedForProvider);
559
559
 
560
+ // v4.6.2 PR1 (spec §4, D1/D2): a host-form ANTHROPIC_BASE_URL is correct
561
+ // for Anthropic SDKs (they append /v1) and fatal for OpenCode's
562
+ // direct-anthropic provider (it appends /messages -> 404). Carry the
563
+ // normalized full-prefix form as a provider-config override — config-level,
564
+ // no process env is written anywhere. AMICUS_BASE_URL_NORMALIZE=0 disables.
565
+ // Merge order keeps any existing options.baseURL authoritative (M-5 lesson:
566
+ // never clobber a user-authored value with a derived one).
567
+ const { resolveBaseUrlOverride, announceBaseUrlNormalizationOnce } = require('./utils/base-url-classify');
568
+ const baseUrlEnv = options._env || process.env;
569
+ const anthropicBaseUrl = resolveBaseUrlOverride(baseUrlEnv);
570
+ if (anthropicBaseUrl) {
571
+ if (!Object.prototype.hasOwnProperty.call(config.provider, 'anthropic')) {
572
+ config.provider.anthropic = { models: {} };
573
+ }
574
+ config.provider.anthropic.options = {
575
+ baseURL: anthropicBaseUrl,
576
+ ...(config.provider.anthropic.options || {}),
577
+ };
578
+ announceBaseUrlNormalizationOnce(baseUrlEnv.ANTHROPIC_BASE_URL, anthropicBaseUrl, options._noticeDeps);
579
+ }
580
+
560
581
  // Register custom 'chat' agent: reads auto-approved, writes/bash require permission
561
582
  const chatAgent = {
562
583
  description: 'Conversational agent — reads are auto-approved, writes and commands require permission',
@@ -22,6 +22,9 @@ const SESSION_STATUS = {
22
22
  /** Canonical session dir name — new sessions are written here. */
23
23
  const SESSIONS_DIR = 'amicus_sessions';
24
24
 
25
+ /** Subagent sessions nest one level under their parent taskId dir. */
26
+ const SUBAGENTS_DIR = 'subagents';
27
+
25
28
  /**
26
29
  * Get the canonical session directory path for a task (used for WRITES).
27
30
  * Spec Reference: §8.1 Session directory structure
@@ -256,7 +259,7 @@ function saveSummary(projectDir, taskId, summary) {
256
259
  * // Returns: '/path/to/project/.claude/amicus_sessions/abc123/subagents/subagent-xyz'
257
260
  */
258
261
  function getSubagentDir(projectDir, parentTaskId, subagentId) {
259
- return path.join(getSessionDir(projectDir, parentTaskId), 'subagents', subagentId);
262
+ return path.join(getSessionDir(projectDir, parentTaskId), SUBAGENTS_DIR, subagentId);
260
263
  }
261
264
 
262
265
  /**
@@ -355,7 +358,7 @@ function getSubagentSession(projectDir, parentTaskId, subagentId) {
355
358
  * @returns {object[]} Array of sub-agent metadata
356
359
  */
357
360
  function listSubagents(projectDir, parentTaskId, filter = {}) {
358
- const subagentsDir = path.join(getSessionDir(projectDir, parentTaskId), 'subagents');
361
+ const subagentsDir = path.join(getSessionDir(projectDir, parentTaskId), SUBAGENTS_DIR);
359
362
 
360
363
  if (!fs.existsSync(subagentsDir)) {
361
364
  return [];
@@ -412,6 +415,7 @@ module.exports = {
412
415
  getSessionDir,
413
416
  resolveExistingSessionDir,
414
417
  SESSIONS_DIR,
418
+ SUBAGENTS_DIR,
415
419
  SESSION_STATUS,
416
420
  // Sub-agent functions
417
421
  getSubagentDir,
@@ -74,7 +74,7 @@ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
74
74
  * Adds `.reason` (alias of buildRunResult's `.error`) and `.legId` so the
75
75
  * fallback loop reads a stable shape without re-deriving them.
76
76
  */
77
- async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce }) {
77
+ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce, noOutputBackstopMs }) {
78
78
  const { IdleWatchdog } = require('../utils/idle-watchdog');
79
79
  const { markAborted } = require('../utils/session-abort');
80
80
  const { runHeadless } = require('../headless');
@@ -121,7 +121,7 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
121
121
  result = await runHeadless(
122
122
  leg.model, systemPrompt, userMessage, legId, project,
123
123
  timeoutMs, agent || 'build',
124
- { client, server, watchdog, summaryLength, reasoning, nonce: foldNonce, directory }
124
+ { client, server, watchdog, summaryLength, reasoning, nonce: foldNonce, directory, noOutputBackstopMs }
125
125
  );
126
126
  } catch (err) {
127
127
  result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
@@ -267,7 +267,7 @@ async function runFanout(options) {
267
267
  timeoutMs, agent: options.agent, client, server,
268
268
  summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
269
269
  foldNonce, directory: options.directory, follow,
270
- fallback: options.fallback, catalog: options.catalog,
270
+ fallback: options.fallback, catalog: options.catalog, noOutputBackstopMs: options.noOutputBackstopMs,
271
271
  });
272
272
  }));
273
273
  } finally {
@@ -0,0 +1,119 @@
1
+ // src/sidecar/models-probe.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module models-probe
6
+ * v4.6.2 PR3 (spec §6, D5): `models --check --live` probe tier. Presence in
7
+ * the catalog is not proof of service — a stored alias can point at a model
8
+ * id the catalog still lists but the provider no longer actually serves (the
9
+ * v4.6.1 `gemini` incident: stored `google/gemini-3.1-flash-lite-preview`,
10
+ * catalog-live, silently dead). This module is the check that would have
11
+ * caught it: probe every STORED alias with one ordinary engine leg — real
12
+ * session dir, real spend-ledger row (D5) — on a single quiet fanout wave,
13
+ * and classify each leg served / accepted-but-silent / error.
14
+ *
15
+ * Never called without `--live`; the spend gate lives in the CLI layer
16
+ * (src/sidecar/models.js), not here — this module always spends when called.
17
+ */
18
+
19
+ /** Probe backstop override (spec D5) — a fixed constant, NOT env-configurable;
20
+ * the env knob (AMICUS_NO_OUTPUT_BACKSTOP_MS) stays the ordinary 120s leg default. */
21
+ const PROBE_WINDOW_MS = 30000;
22
+
23
+ /** Fixed tiny prompt — a probe leg only needs to prove the model answers at all. */
24
+ const PROBE_PROMPT = 'Reply with exactly: OK';
25
+
26
+ /**
27
+ * Classify one leg run-document (buildRunResult shape, src/utils/result-
28
+ * schema.js) per the plan's Global Constraints classification contract.
29
+ * Precedence matters: 'complete' wins outright; otherwise a NO_OUTPUT_
30
+ * BACKSTOP error (PR2's silent-leg detector, armed here at PROBE_WINDOW_MS
31
+ * instead of its 120s default) is the one specific error shape that means
32
+ * "the model accepted the request and never produced a token" rather than an
33
+ * ordinary routing/auth/timeout failure.
34
+ * @param {{status?:string, error?:string|null}} leg
35
+ * @returns {'served'|'accepted-but-silent'|'error'}
36
+ */
37
+ function classifyLeg(leg) {
38
+ if (leg.status === 'complete') { return 'served'; }
39
+ if (typeof leg.error === 'string' && /^NO_OUTPUT_BACKSTOP:/.test(leg.error)) { return 'accepted-but-silent'; }
40
+ return 'error';
41
+ }
42
+
43
+ /**
44
+ * Stored (user-config) aliases only — the `--live` probe's scope (spec §6):
45
+ * defaults/curated-route rows follow the catalog by construction and have no
46
+ * "was it actually served" question for a live probe to answer. Exported so
47
+ * the CLI's cap pre-check (models.js) and this module share one predicate.
48
+ * @param {Array<{source:string}>} sources collectAliasSources() output
49
+ * @returns {Array<{alias:string,model:string,source:string}>}
50
+ */
51
+ function selectStoredAliases(sources) {
52
+ return sources.filter(s => s.source === 'user-config');
53
+ }
54
+
55
+ /**
56
+ * Probe every STORED alias with one ordinary engine leg (real session dirs,
57
+ * real spend rows — D5) on one quiet fanout wave. Returns per-alias outcomes;
58
+ * never called without --live (the spend gate lives in the CLI layer).
59
+ * @param {{project?:string}} opts
60
+ * @param {{runFanout?:Function, collectAliasSources?:Function}} [deps]
61
+ * @returns {Promise<{results:Array<{alias:string,target:string,outcome:'served'|'accepted-but-silent'|'error',detail:string|null,cost:number|null}>, waveId:string|null}>}
62
+ */
63
+ async function probeStoredAliases(opts = {}, deps = {}) {
64
+ const collectAliasSources = deps.collectAliasSources || require('../utils/alias-audit').collectAliasSources;
65
+ const runFanout = deps.runFanout || require('./fanout').runFanout;
66
+
67
+ const stored = selectStoredAliases(collectAliasSources());
68
+ if (stored.length === 0) { return { results: [], waveId: null }; }
69
+
70
+ // runFanout's `models` is the same comma-separated STRING the CLI --models
71
+ // flag takes (validateFanoutModels -> parseModelsList splits it back apart)
72
+ // — NOT an array; see council/run-launch.js's launchWave for the identical
73
+ // `.join(',')` seam. An array here would parse to [] and fail the whole
74
+ // wave with BAD_ARGS.
75
+ const { wave, errorDoc } = await runFanout({
76
+ models: stored.map(s => s.model).join(','),
77
+ prompt: PROBE_PROMPT,
78
+ quiet: true,
79
+ noOutputBackstopMs: PROBE_WINDOW_MS,
80
+ timeout: 2, // minutes — the overall ceiling behind the backstop
81
+ project: opts.project,
82
+ });
83
+
84
+ // Final-review blocker 1: two classes of wave never reach leg-creation at
85
+ // all — the budget preflight refusing before any session exists (failPre ->
86
+ // {wave: null, errorDoc}) or the shared server failing to start (errorWave ->
87
+ // {legs: [], error: message}) — so EVERY stored alias would otherwise hit
88
+ // the `legs[i] || {}` fallback and fabricate a generic `detail: null` row,
89
+ // which the CLI's `${head} — ${r.detail}` template renders as the literal
90
+ // string "null", masking the real reason (worse with `quiet: true`, which
91
+ // suppresses every other print this failure would normally surface on).
92
+ // Both failure classes carry a real message; thread it onto every row that
93
+ // has no leg of its own to explain itself.
94
+ const waveFailure = errorDoc ? errorDoc.message : ((wave && wave.error) || null);
95
+
96
+ // Positional zip, not a model-id lookup: deriveLegIds (fanout.js) assigns
97
+ // legs 1:1 in --models order, and two stored aliases may legitimately share
98
+ // one target model, so a leg's own identity can't disambiguate which alias
99
+ // it answers for — only its index can.
100
+ const legs = (wave && wave.legs) || [];
101
+ const results = stored.map((s, i) => {
102
+ const leg = legs[i] || {};
103
+ const outcome = classifyLeg(leg);
104
+ const cost = (leg.usage && leg.usage.cost && typeof leg.usage.cost.amount === 'number')
105
+ ? leg.usage.cost.amount
106
+ : null;
107
+ return {
108
+ alias: s.alias,
109
+ target: s.model,
110
+ outcome,
111
+ detail: leg.error || waveFailure || null,
112
+ cost,
113
+ };
114
+ });
115
+
116
+ return { results, waveId: (wave && wave.waveId) || null };
117
+ }
118
+
119
+ module.exports = { probeStoredAliases, selectStoredAliases, PROBE_WINDOW_MS, PROBE_PROMPT };