amicus 4.6.1 → 4.6.2
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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +2 -2
- package/docs/ROADMAP.md +7 -2
- package/docs/configuration.md +13 -9
- package/docs/council.md +7 -2
- package/docs/troubleshooting.md +49 -20
- package/docs/usage.md +21 -1
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +71 -0
- package/electron/workspace-ui/workspace-app.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +9 -10
- package/electron/workspace-ui/workspace-seats.js +117 -0
- package/electron/workspace-ui/workspace-verbs.js +1 -0
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +1 -1
- package/schemas/alias-audit.schema.json +6 -1
- package/schemas/council-run.schema.json +14 -0
- package/src/cli-handlers-doctor.js +16 -4
- package/src/cli.js +4 -0
- package/src/council/run-chair.js +49 -3
- package/src/headless.js +119 -9
- package/src/mcp-council-awareness.js +1 -0
- package/src/opencode-client.js +21 -0
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/models-probe.js +119 -0
- package/src/sidecar/models.js +81 -6
- package/src/utils/alias-audit.js +52 -1
- package/src/utils/base-url-classify.js +74 -0
- package/src/utils/council-presets.js +6 -2
- package/src/utils/curated-models.js +29 -10
- package/src/utils/doctor-base-url-check.js +41 -0
- package/src/utils/model-fetcher.js +1 -0
- package/src/utils/model-tiers.js +28 -7
- package/src/utils/no-output-backstop.js +48 -0
- package/src/utils/result-schema.js +29 -2
- package/src/workspace/live-normalize.js +1 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — seats panel painter (v4.4 §5). D8 extraction (Task 1,
|
|
3
|
+
* v4.6.2 PR4): moved verbatim out of workspace-panels.js, which was pressed
|
|
4
|
+
* up against the 300-line size gate — this file is where Task 2 adds
|
|
5
|
+
* dead-seat rows. Loads immediately before workspace-panels.js (index.html),
|
|
6
|
+
* which keeps a thin delegate; reads `window.AmicusApp` at CALL time, same
|
|
7
|
+
* discipline as every sibling renderer file (workspace-app.js boots last and
|
|
8
|
+
* owns `state`).
|
|
9
|
+
*
|
|
10
|
+
* Task 2 ("dead-seat rows"): `state.detail.run` and `state.detail.verdict`
|
|
11
|
+
* are the raw run.json/verdict.json docs (src/workspace/run-detail.js —
|
|
12
|
+
* `getRunDetail` returns them wholesale, unfiltered), so `run.degrades` and
|
|
13
|
+
* `verdict.seatLoss` are already on `state.detail` today; no data-layer
|
|
14
|
+
* threading was needed. Only the derivation (window.AmicusLive.deadSeats,
|
|
15
|
+
* live-model.js) and this file's painting are new.
|
|
16
|
+
*
|
|
17
|
+
* NOTE (scope, matches the plan's file list): renderSeatsPanel() (below) is reached from
|
|
18
|
+
* renderDetail() — called both from openRun() (a fresh run open) and from the blind toggle
|
|
19
|
+
* (workspace-app.js:197-227), which must repaint dead rows too so the mask flip reaches
|
|
20
|
+
* them. A dead-leg/dead-wave degrade is checkpointed to run.json as soon as Stage 1's
|
|
21
|
+
* once-only retry pass resolves for that seat — which can be well before the rest of the run
|
|
22
|
+
* reaches a terminal status, so a seat CAN be "announced dead" in the data while the run is
|
|
23
|
+
* still live-polling.
|
|
24
|
+
*
|
|
25
|
+
* HISTORY: dead rows first shipped gated on terminal status (Task 2's fix wave, task
|
|
26
|
+
* review, controller ruling) — appended ONLY when window.AmicusLive.TERMINAL_STATUSES
|
|
27
|
+
* matched d.run.status, the same predicate startLiveLoop() uses at workspace-verbs.js:69 to
|
|
28
|
+
* decide whether a run is even worth polling. Reason at the time: renderDetail()'s
|
|
29
|
+
* unconditional V.startLiveLoop() call (workspace-app.js:151) schedules a
|
|
30
|
+
* setTimeout(tick, 0) on any still-running run; that tick's first resolution repainted
|
|
31
|
+
* #seats-body via applyLive's direct renderSeats() call (workspace-verbs.js:130), whose own
|
|
32
|
+
* leaver-removal (workspace-render.js:220-222) immediately deleted the `dead:`-keyed row
|
|
33
|
+
* renderSeatsPanel had just appended — a one-frame flash-then-vanish that read as a glitch,
|
|
34
|
+
* not a feature — so gating on terminal simply hid dead rows until no further tick could
|
|
35
|
+
* un-paint them. PR4b (Christian's mid-poll ruling on PR 102) replaced that gate with tick
|
|
36
|
+
* re-append: applyLive() now calls appendDeadRows() (below) immediately after every
|
|
37
|
+
* renderSeats() repaint (workspace-verbs.js:130-131), restoring the row the SAME tick that
|
|
38
|
+
* just wiped it instead of leaving it hidden. renderSeatsPanel() below no longer checks
|
|
39
|
+
* TERMINAL_STATUSES at all — dead rows paint unconditionally, on a live run or a done one.
|
|
40
|
+
*/
|
|
41
|
+
(function () {
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
function renderSeatsPanel() {
|
|
45
|
+
var A = window.AmicusApp;
|
|
46
|
+
var d = A.state.detail;
|
|
47
|
+
var seats = window.AmicusLive.seatsFromRunStats(d.derived.cost.rows);
|
|
48
|
+
var tbody = A.$('seats-body');
|
|
49
|
+
window.AmicusRender.renderSeats(tbody, seats, A.state.blind, A.labelOf);
|
|
50
|
+
var seatLoss = d.verdict && d.verdict.seatLoss;
|
|
51
|
+
var dead = window.AmicusLive.deadSeats(d.run.degrades, seatLoss, seats);
|
|
52
|
+
renderDeadSeatRows(tbody, dead, A.state.blind, A.labelOf);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Paints the dead-seat rows appended after live rows. Deliberately NOT
|
|
57
|
+
* folded into workspace-render.js's renderSeats (287/300 — must not grow)
|
|
58
|
+
* and NOT run through its keyed diff: dead rows carry no per-tick-changing
|
|
59
|
+
* field, so a full rebuild every call is correct and cheap, and renderSeats
|
|
60
|
+
* just above already self-cleans any PRIOR dead row as an unrecognized
|
|
61
|
+
* `data-key` (its own seen-set only knows about the live `seats` it was
|
|
62
|
+
* just given), so nothing here needs to track dead rows across calls.
|
|
63
|
+
*
|
|
64
|
+
* Cells route through window.AmicusLive.seatCells(...) — the SAME function
|
|
65
|
+
* live rows use — so name masking (and every other column's blank/em-dash
|
|
66
|
+
* convention) matches exactly, not a reimplementation. Two overrides after
|
|
67
|
+
* the call: index 0 (name, blind-ON-and-unlabeled dead seats only — see the
|
|
68
|
+
* comment at that line) and index 6 (cost). seatCells would dash() a
|
|
69
|
+
* missing costDisplay to '—', indistinguishable from a seat that ran but
|
|
70
|
+
* whose cost is merely unmeasured (see cost-unknown-display.test.js) — a
|
|
71
|
+
* dead seat has no cost concept at all, so that cell renders empty instead
|
|
72
|
+
* (D6: "no cost cell").
|
|
73
|
+
*/
|
|
74
|
+
function renderDeadSeatRows(tbody, dead, blindOn, labelOf) {
|
|
75
|
+
(dead || []).forEach(function (seat) {
|
|
76
|
+
var cells = window.AmicusLive.seatCells(
|
|
77
|
+
{ model: seat.model, status: seat.statusText, stalled: false }, blindOn, labelOf);
|
|
78
|
+
// Fix wave 2 (smoke-caught, GUI smoke on real degraded run 12c96b6b): dead seats never
|
|
79
|
+
// produce a review, so state.labelByModel (built from the run's names derivation — models
|
|
80
|
+
// that DID review) never carries them; seatCells' own `blindOn && label ? label : alias`
|
|
81
|
+
// fallback is LOAD-BEARING for LIVE rows (RN-9/F36, live-model.js) and stays untouched, but
|
|
82
|
+
// for a dead seat that fallback leaks the raw model name under blind — precisely the seat
|
|
83
|
+
// blind mode most needs to hide. Placeholder ONLY when blind is on AND no label resolved;
|
|
84
|
+
// a label that DOES resolve (possible in principle) still wins via seatCells' own cell.
|
|
85
|
+
if (blindOn && !(labelOf && labelOf(seat.model))) { cells[0] = '(masked)'; }
|
|
86
|
+
cells[6] = '';
|
|
87
|
+
var row = window.AmicusRender.el('tr',
|
|
88
|
+
{ className: 'seat-dead', dataset: { key: 'dead:' + seat.model } },
|
|
89
|
+
cells.map(function (c, i) {
|
|
90
|
+
return window.AmicusRender.el('td',
|
|
91
|
+
{ className: i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '') }, [c]);
|
|
92
|
+
}));
|
|
93
|
+
tbody.appendChild(row);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Live-tick twin of renderSeatsPanel's dead block (PR4b, Christian's mid-poll
|
|
99
|
+
* ruling on PR 102): applyLive's renderSeats repaint wipes dead:-keyed rows
|
|
100
|
+
* (leaver-removal), so every tick re-appends from the tick's own payload.
|
|
101
|
+
* seatLoss comes from state.detail (absent mid-run — the critic's own
|
|
102
|
+
* dead-leg degrade covers it live; the terminal refresh unions the rest).
|
|
103
|
+
*/
|
|
104
|
+
function appendDeadRows(live) {
|
|
105
|
+
var A = window.AmicusApp;
|
|
106
|
+
var d = A.state.detail;
|
|
107
|
+
var seatLoss = d && d.verdict ? d.verdict.seatLoss : null;
|
|
108
|
+
var dead = window.AmicusLive.deadSeats(live.degrades, seatLoss, live.seats || []);
|
|
109
|
+
renderDeadSeatRows(A.$('seats-body'), dead, A.state.blind, A.labelOf);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
window.AmicusSeats = {
|
|
113
|
+
renderSeatsPanel: renderSeatsPanel,
|
|
114
|
+
renderDeadSeatRows: renderDeadSeatRows,
|
|
115
|
+
appendDeadRows: appendDeadRows,
|
|
116
|
+
};
|
|
117
|
+
})();
|
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
// `live.ok` is true, so this is simply "always paint," empty roster included.
|
|
129
129
|
if (live.seats) {
|
|
130
130
|
R.renderSeats(A.$('seats-body'), live.seats, A.state.blind, A.labelOf);
|
|
131
|
+
window.AmicusSeats.appendDeadRows(live);
|
|
131
132
|
}
|
|
132
133
|
// F42: state.detail can be swapped/absent under a tick — never deref .derived unguarded.
|
|
133
134
|
var derived = A.state.detail && A.state.detail.derived ? A.state.detail.derived : null;
|
|
@@ -112,6 +112,12 @@ mark { background: var(--gold-soft); color: var(--gold-400); border-radius: var(
|
|
|
112
112
|
.table td { border-bottom: var(--bd); padding: var(--space-3) var(--space-4); }
|
|
113
113
|
.table td.num, .table th.num { text-align: right; font-family: var(--font-mono); }
|
|
114
114
|
.stalled-flag { color: var(--warn); }
|
|
115
|
+
/* Task 2 (v4.6.2 PR4, "dead-seat rows"): an announced-dead seat's row —
|
|
116
|
+
muted like every other "nothing to see here" surface (.empty-note,
|
|
117
|
+
.chip.aborted, .stage.skipped, .rail-project all key off --text-3), never
|
|
118
|
+
the warn/danger colors those two-line notes use — a dead seat is a fact
|
|
119
|
+
already fully explained in the banner/verdict, not a NEW alarm here. */
|
|
120
|
+
tr.seat-dead td { color: var(--text-3); }
|
|
115
121
|
|
|
116
122
|
/* ---- matrix tier rows (report-html light-ground pairs, as token vars) -- */
|
|
117
123
|
.matrix-wrap { overflow-x: auto; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.6.
|
|
3
|
+
"version": "4.6.2",
|
|
4
4
|
"mcpName": "io.github.BourbonDog/amicus",
|
|
5
5
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
6
6
|
"keywords": [
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"staleCount": { "type": "number" },
|
|
13
13
|
"stale": { "type": "array", "items": { "type": "object" } },
|
|
14
14
|
"gatewayFindingsCount": { "type": "number" },
|
|
15
|
-
"gatewayFindings": { "type": "array", "items": { "type": "object" } }
|
|
15
|
+
"gatewayFindings": { "type": "array", "items": { "type": "object" } },
|
|
16
|
+
"driftedCount": { "type": "number" },
|
|
17
|
+
"drifted": { "type": "array", "items": { "type": "object" } },
|
|
18
|
+
"probeCount": { "type": "number" },
|
|
19
|
+
"probe": { "type": "array", "items": { "type": "object" } },
|
|
20
|
+
"probeSkipped": { "type": ["string", "null"] }
|
|
16
21
|
}
|
|
17
22
|
}
|
|
@@ -102,6 +102,20 @@
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
},
|
|
105
|
+
"chairAttempts": {
|
|
106
|
+
"description": "v4.6.2 PR5 (LC-5): one entry per resolved chair fallback-walk attempt (ch1/ch2/ch3 — the ch4 VERDICT-line repair is not an attempt, see classifyChairAttempt), checkpointed incrementally so a mid-walk kill preserves the attempts already resolved. Additive; absent on chairless (cost-skipped) runs and on every pre-PR5 run.",
|
|
107
|
+
"type": "array",
|
|
108
|
+
"items": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"required": ["waveId", "model", "outcome"],
|
|
111
|
+
"properties": {
|
|
112
|
+
"waveId": { "type": "string" },
|
|
113
|
+
"model": { "type": "string" },
|
|
114
|
+
"outcome": { "enum": ["completed", "error", "timeout", "no-output"] },
|
|
115
|
+
"reason": { "type": ["string", "null"] }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
105
119
|
"debate": {
|
|
106
120
|
"type": "object",
|
|
107
121
|
"properties": {
|
|
@@ -12,6 +12,8 @@ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
|
|
|
12
12
|
// local-providers check body (v4.2 §4.7 C8) — split out to keep this file
|
|
13
13
|
// under the gate (mirrors the engineCheck/mcpChecks split above).
|
|
14
14
|
const localProvidersCheck = require('./utils/doctor-local-providers-check');
|
|
15
|
+
// v4.6.2 PR1 (spec §4) — the 'anthropic-base-url' check body.
|
|
16
|
+
const baseUrlCheck = require('./utils/doctor-base-url-check');
|
|
15
17
|
|
|
16
18
|
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
|
|
17
19
|
|
|
@@ -38,6 +40,7 @@ function realDeps() {
|
|
|
38
40
|
readCache: () => require('./utils/model-catalog').readCache(),
|
|
39
41
|
collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
|
|
40
42
|
findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
|
|
43
|
+
findDriftedStoredAliases: (s, c) => require('./utils/alias-audit').findDriftedStoredAliases(s, c),
|
|
41
44
|
hasOpencodeBinary: () => {
|
|
42
45
|
// Single source of truth shared with the runtime server-start guard.
|
|
43
46
|
const { ensureNodeModulesBinInPath, hasOpencodeBinary } = require('./utils/path-setup');
|
|
@@ -148,12 +151,21 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
148
151
|
checks.push(guard('aliases', 'Model aliases', () => {
|
|
149
152
|
const cache = d.readCache();
|
|
150
153
|
const catalog = (cache && cache.models) || [];
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
const sources = d.collectAliasSources();
|
|
155
|
+
const stale = d.findStaleAliases(sources, catalog);
|
|
156
|
+
const drifted = d.findDriftedStoredAliases(sources, catalog);
|
|
157
|
+
if (stale.length === 0 && drifted.length === 0) {
|
|
158
|
+
return { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null };
|
|
159
|
+
}
|
|
160
|
+
const parts = [];
|
|
161
|
+
if (stale.length) { parts.push(`${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`); }
|
|
162
|
+
if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map(s => s.alias).join(', ')}`); }
|
|
163
|
+
return { id: 'aliases', name: 'Model aliases', status: 'warn', message: parts.join('; '), hint: 'amicus models --check' };
|
|
155
164
|
}));
|
|
156
165
|
|
|
166
|
+
checks.push(guard('anthropic-base-url', 'ANTHROPIC_BASE_URL',
|
|
167
|
+
() => baseUrlCheck.evaluateAnthropicBaseUrl(d)));
|
|
168
|
+
|
|
157
169
|
checks.push(guard('opencode-bin', 'OpenCode binary', () => (
|
|
158
170
|
d.hasOpencodeBinary()
|
|
159
171
|
? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
|
package/src/cli.js
CHANGED
|
@@ -149,6 +149,7 @@ const BOOLEAN_FLAGS = [
|
|
|
149
149
|
'md', // council report: emit Markdown (default)
|
|
150
150
|
'fix', // doctor: self-heal fixable checks in place (#56)
|
|
151
151
|
'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
|
|
152
|
+
'live', // models --check: opt-in probe of stored aliases with real engine legs (v4.6.2 PR3, spec §6)
|
|
152
153
|
'render', // council verdict: also refresh report.html next to the decided verdict
|
|
153
154
|
'claude', // init: register for Claude Code only (Task 15)
|
|
154
155
|
'desktop', // init: register for Claude Desktop only (Task 15)
|
|
@@ -501,6 +502,9 @@ Options for 'models':
|
|
|
501
502
|
--strict With --check: also exit non-zero on curated
|
|
502
503
|
per-gateway drift (stale/divergent direct or
|
|
503
504
|
openrouter forms). Informational without it.
|
|
505
|
+
--live With --check: probe every stored alias with one real
|
|
506
|
+
engine leg (spends) — served / accepted-but-silent /
|
|
507
|
+
error. Requires --check.
|
|
504
508
|
--json Machine-readable output
|
|
505
509
|
`,
|
|
506
510
|
list: `
|
package/src/council/run-chair.js
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
@@ -102,9 +144,11 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
|
|
|
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
146
|
let attempt = await attemptChair(o.chair, `${o.runId}-ch1`);
|
|
147
|
+
recordAttempt(attempt, `${o.runId}-ch1`, o.chair);
|
|
105
148
|
if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
|
|
106
149
|
if (!attempt.leg && !overBudget()) {
|
|
107
150
|
attempt = await attemptChair(o.chair, `${o.runId}-ch2`);
|
|
151
|
+
recordAttempt(attempt, `${o.runId}-ch2`, o.chair);
|
|
108
152
|
if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
|
|
109
153
|
}
|
|
110
154
|
if (attempt.leg) { actualChair = o.chair; }
|
|
@@ -114,6 +158,7 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
|
|
|
114
158
|
const fallback = pickFallbackChair(statsRows, o.models, o.chair);
|
|
115
159
|
if (fallback) {
|
|
116
160
|
attempt = await attemptChair(fallback, `${o.runId}-ch3`);
|
|
161
|
+
recordAttempt(attempt, `${o.runId}-ch3`, fallback);
|
|
117
162
|
if (isAbortExit(attempt.exitCode) || isSignalled()) { return bail(attempt.exitCode || isSignalled()); }
|
|
118
163
|
if (attempt.leg) { actualChair = fallback; }
|
|
119
164
|
}
|
|
@@ -160,7 +205,8 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
|
|
|
160
205
|
what: 'the council has no chair synthesis',
|
|
161
206
|
why: chairLeg
|
|
162
207
|
? 'the chair ran but its output carried no parseable VERDICT: line'
|
|
163
|
-
:
|
|
208
|
+
: `no chair leg completed after the fallback walk — ${chairAttempts.map(a =>
|
|
209
|
+
`${a.waveId.split('-').pop()} ${a.model}: ${a.reason || a.outcome}`).join(' · ')}`,
|
|
164
210
|
effect: 'the verdict is written with overallVerdict null; will exit degraded (2)',
|
|
165
211
|
});
|
|
166
212
|
}
|
|
@@ -170,4 +216,4 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
|
|
|
170
216
|
};
|
|
171
217
|
}
|
|
172
218
|
|
|
173
|
-
module.exports = { runChair, pickFallbackChair };
|
|
219
|
+
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
|
-
//
|
|
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
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/opencode-client.js
CHANGED
|
@@ -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',
|
|
@@ -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 };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -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 {
|