amicus 4.5.4 → 4.6.0
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 +71 -0
- package/README.md +1 -1
- package/commands/council.md +1 -1
- package/docs/DISTRIBUTION.md +38 -11
- package/docs/usage.md +1 -1
- package/package.json +3 -2
- package/schemas/council-run.schema.json +20 -0
- package/schemas/council-verdict.schema.json +20 -0
- package/schemas/doctor.schema.json +23 -1
- package/src/cli-council-run-render.js +51 -0
- package/src/cli-handlers-council-run.js +45 -44
- package/src/cli-handlers-council.js +9 -3
- package/src/cli-handlers-doctor.js +16 -37
- package/src/cli-handlers-watch.js +1 -1
- package/src/cli.js +1 -1
- package/src/council/ledger.js +5 -1
- package/src/council/report-html.js +16 -1
- package/src/council/report.js +25 -1
- package/src/council/run-assemble.js +25 -7
- package/src/council/run-budget.js +14 -8
- package/src/council/run-chair.js +21 -4
- package/src/council/run-debate-stage.js +115 -0
- package/src/council/run-degrade.js +44 -0
- package/src/council/run-finalize.js +18 -3
- package/src/council/run-server.js +24 -7
- package/src/council/run-stage2.js +10 -2
- package/src/council/run-stages.js +23 -21
- package/src/council/run.js +39 -67
- package/src/council/verdict.js +74 -8
- package/src/mcp-council-bench.js +45 -0
- package/src/mcp-council-run.js +11 -28
- package/src/mcp-server.js +5 -1
- package/src/mcp-tools.js +8 -0
- package/src/utils/degrade.js +68 -0
- package/src/utils/doctor-degrade.js +51 -0
- package/src/utils/doctor-electron-mcp-check.js +64 -5
- package/src/utils/doctor-engine-check.js +14 -3
- package/src/utils/doctor-mcp-checks.js +10 -3
- package/src/utils/known-flags.js +2 -1
- package/src/utils/remediation-hints.js +5 -3
- package/src/utils/result-schema.js +6 -2
- package/src/utils/session-index-tmp-sweep.js +2 -1
- package/src/workspace/run-scan.js +5 -1
|
@@ -11,6 +11,7 @@ const { formatCost } = require('../utils/pricing');
|
|
|
11
11
|
const { formatDuration } = require('../utils/format-duration');
|
|
12
12
|
const { TIER_ORDER, SYMBOL } = require('./report');
|
|
13
13
|
const { tokenCss } = require('../design/tokens');
|
|
14
|
+
const { formatDegrade } = require('../utils/degrade');
|
|
14
15
|
|
|
15
16
|
const TIER_VAR = {
|
|
16
17
|
Disputed: 'var(--tier-disputed)',
|
|
@@ -52,9 +53,23 @@ function renderHtml(m) {
|
|
|
52
53
|
const costRows = m.cost.rows.map(r =>
|
|
53
54
|
`<tr><td>${esc(r.model)}</td><td>${esc(r.status)}</td><td>${dur(r.durationMs)}</td>` +
|
|
54
55
|
`<td>${esc(formatCost(r.cost))}</td></tr>`).join('');
|
|
56
|
+
// v4.6 Plan 2: 'What was lost' rows — m.degrades is absent on hand-built
|
|
57
|
+
// models (same tolerance as m.debate below); ONE voice (Plan 1's
|
|
58
|
+
// formatDegrade) rendered into the row, not a second HTML dialect.
|
|
59
|
+
const lostRows = (m.degrades || []).map(d =>
|
|
60
|
+
`<tr><td>${esc(d.channel)}</td><td>${esc(formatDegrade(d).trimEnd())}</td></tr>`).join('');
|
|
55
61
|
const meta = [h.date, h.chair ? `chair: ${h.chair}` : null, `council: ${h.council.join(', ')}`,
|
|
56
62
|
h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).map(esc).join(' · ');
|
|
57
63
|
|
|
64
|
+
// Heading-over-nothing, same guard idiom as debateSection below: absent or
|
|
65
|
+
// empty degrades ⇒ no section at all, so a clean verdict's HTML stays
|
|
66
|
+
// byte-identical to before this section existed. Losses are headline news,
|
|
67
|
+
// so the section sits directly after the Verdict-summary table (report.js's
|
|
68
|
+
// renderMd mirrors this placement immediately after the tier loop).
|
|
69
|
+
const lostSection = lostRows
|
|
70
|
+
? `<h2>What was lost</h2><table><tr><th>Channel</th><th>Notice</th></tr>${lostRows}</table>`
|
|
71
|
+
: '';
|
|
72
|
+
|
|
58
73
|
// m.debate is absent on hand-built models (tests/council/report.test.js calls
|
|
59
74
|
// renderHtml directly with no debate key) — the guard must tolerate that, and
|
|
60
75
|
// absent/empty ⇒ no section at all so a no-debate report stays byte-identical
|
|
@@ -113,7 +128,7 @@ td.c { text-align: center; }
|
|
|
113
128
|
<h1>Council Report — ${esc(h.runType)} (${esc(h.runId)})</h1>
|
|
114
129
|
<p class="meta">${meta}</p>
|
|
115
130
|
<h2>Verdict summary</h2>
|
|
116
|
-
<table><tr><th>Tier</th><th>Count</th></tr>${tierRows}</table
|
|
131
|
+
<table><tr><th>Tier</th><th>Count</th></tr>${tierRows}</table>${lostSection}
|
|
117
132
|
<h2>Adjudication matrix</h2>
|
|
118
133
|
<table><tr><th>Finding</th><th>Sev</th><th>Raiser</th>${judgeHead}<th>Tier</th><th>Decision</th></tr>${matrixRows}</table>
|
|
119
134
|
<p class="legend">✓ agree · ✗ dispute · – neutral · <sup>*</sup> raiser's own vote</p>
|
package/src/council/report.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const { formatCost, sumWaveUsage } = require('../utils/pricing');
|
|
13
13
|
const { formatDuration } = require('../utils/format-duration');
|
|
14
|
+
const { formatDegrade } = require('../utils/degrade');
|
|
14
15
|
|
|
15
16
|
const TIER_ORDER = ['Disputed', 'Contested', 'Confirmed', 'Singleton'];
|
|
16
17
|
const SYMBOL = { agree: '✓', dispute: '✗', neutral: '–' };
|
|
@@ -65,8 +66,14 @@ function toModel(verdict, wave) {
|
|
|
65
66
|
.map(f => ({ id: f.id, previousTier: f.debate.previousTier, tier: f.tier })),
|
|
66
67
|
};
|
|
67
68
|
const runStats = verdict.runStats || [];
|
|
69
|
+
// Cost-row role tag (Plan 2 final review F1): #83 gave judges their own
|
|
70
|
+
// runStats row, so a bench model can now appear twice (seat + judge),
|
|
71
|
+
// indistinguishable by `model` alone. Tag ONLY judge rows — old verdicts have
|
|
72
|
+
// no judge rows at all, so chair/critic/lens/seat rows stay byte-identical to
|
|
73
|
+
// their historical rendering.
|
|
68
74
|
const costRows = runStats.map(r => ({
|
|
69
|
-
model: r.
|
|
75
|
+
model: r.role === 'judge' ? `${r.model} (judge)` : r.model,
|
|
76
|
+
status: r.status, durationMs: r.durationMs,
|
|
70
77
|
cost: r.usage && r.usage.cost ? r.usage.cost : null,
|
|
71
78
|
}));
|
|
72
79
|
const total = (wave && wave.usage && wave.usage.cost) ? wave.usage.cost : sumWaveUsage(runStats).cost;
|
|
@@ -78,6 +85,13 @@ function toModel(verdict, wave) {
|
|
|
78
85
|
tierCounts: verdict.tierCounts || { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 },
|
|
79
86
|
judges, findings, debate,
|
|
80
87
|
streetCred: verdict.streetCred || [],
|
|
88
|
+
// v4.6 Plan 2: additive and OPTIONAL on the verdict (verdict.js only sets
|
|
89
|
+
// it when the run actually degraded), so a clean verdict's model — and
|
|
90
|
+
// therefore its rendered report — is byte-for-byte unchanged.
|
|
91
|
+
// Plan 2 final review F2: LOSSES ONLY — a heal is announced on stderr/run.json but
|
|
92
|
+
// is not a loss (spec D4, §8), so it must never render under "What was
|
|
93
|
+
// lost". deriveSeatLoss (verdict.js) applies the same kind !== 'heal' filter.
|
|
94
|
+
degrades: (verdict.degrades || []).filter(d => d.kind !== 'heal'),
|
|
81
95
|
cost: { rows: costRows, total },
|
|
82
96
|
};
|
|
83
97
|
}
|
|
@@ -97,6 +111,16 @@ function renderMd(m) {
|
|
|
97
111
|
out.push('| Tier | Count |\n|---|---|');
|
|
98
112
|
for (const t of TIER_ORDER) { out.push(`| ${t} | ${m.tierCounts[t]} |`); }
|
|
99
113
|
|
|
114
|
+
// Heading-over-nothing: emitted ONLY when the run actually degraded, so a
|
|
115
|
+
// clean verdict's report stays byte-identical to before this section
|
|
116
|
+
// existed. Losses are headline news, so they sit directly under the
|
|
117
|
+
// summary, before the reader reaches the adjudication detail.
|
|
118
|
+
if (m.degrades.length) {
|
|
119
|
+
out.push('\n## What was lost\n');
|
|
120
|
+
// ONE voice (Plan 1's formatDegrade) — the report must not grow a dialect.
|
|
121
|
+
for (const d of m.degrades) { out.push(`- ${formatDegrade(d).trimEnd()}`); }
|
|
122
|
+
}
|
|
123
|
+
|
|
100
124
|
out.push('\n## Adjudication matrix\n');
|
|
101
125
|
out.push(`| Finding | Sev | Raiser | ${m.judges.join(' | ')} | Tier | Decision |`);
|
|
102
126
|
out.push(`|---|---|---|${m.judges.map(() => '---').join('|')}|---|---|`);
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
const fs = require('fs');
|
|
20
20
|
const path = require('path');
|
|
21
21
|
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
22
|
-
const { buildVerdict, summarizeSeatLoss, writeVerdictAtomic } = require('./verdict');
|
|
22
|
+
const { buildVerdict, summarizeSeatLoss, deriveSeatLoss, writeVerdictAtomic } = require('./verdict');
|
|
23
23
|
const { buildReport } = require('./report');
|
|
24
24
|
const { validateFindings } = require('./findings');
|
|
25
25
|
const { toGlobalFindings } = require('./anonymize');
|
|
@@ -160,6 +160,16 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
|
|
|
160
160
|
findings.push(...claudeReview.globalFindings);
|
|
161
161
|
runStats.push(claudeRunStatsRow());
|
|
162
162
|
}
|
|
163
|
+
// #83 (v4.6 Plan 2): Stage-2 judge legs are ~38% of a run's cost and had no
|
|
164
|
+
// runStats row at all — per-leg cost was unattributable from the artifact.
|
|
165
|
+
// One row per judge, attributing the judge's ORIGINAL Stage-2 wave leg (never
|
|
166
|
+
// a repair solo's — run-stage2.js mirrors Stage-1's convention there); a judge
|
|
167
|
+
// whose wave leg died still gets an honest error row.
|
|
168
|
+
for (const j of (judgeResults || [])) {
|
|
169
|
+
runStats.push(buildRunStatsEntry({
|
|
170
|
+
leg: j.leg, model: j.judge, role: 'judge', conformance: j.conformance,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
163
173
|
if (chairStats) { runStats.push(chairStats); }
|
|
164
174
|
return { meta, findings, adjudications, rankings, runStats };
|
|
165
175
|
}
|
|
@@ -176,14 +186,22 @@ function writeTallyFiles({ runDir, tallyInput, record }) {
|
|
|
176
186
|
* Undecided verdict + deterministic report. Sets the nullable overallVerdict
|
|
177
187
|
* (council family v2, Plan A) on buildVerdict's output — independent of
|
|
178
188
|
* buildVerdict's own signature.
|
|
189
|
+
* @param {{runDir: string, record: object, overallVerdict?: (string|null),
|
|
190
|
+
* chairText?: string, critic?: string, deadWaves?: Array<object>,
|
|
191
|
+
* degrades?: Array<object>}} o `degrades` (v4.6 Plan 2), when present, is
|
|
192
|
+
* both carried onto the verdict and used to DERIVE `seatLoss` (deriveSeatLoss)
|
|
193
|
+
* in preference to summarizing it from `deadWaves` (summarizeSeatLoss).
|
|
179
194
|
* @returns {object} the verdict written to disk
|
|
180
195
|
*/
|
|
181
|
-
function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves }) {
|
|
182
|
-
// v4.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
const
|
|
196
|
+
function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves, degrades }) {
|
|
197
|
+
// v4.6 Plan 2 (spec D3): when the sink's records are available they are the
|
|
198
|
+
// single source of truth — seatLoss derives from them so it can never
|
|
199
|
+
// disagree with degrades[]. deadWaves remains the fallback for direct
|
|
200
|
+
// callers that predate the sink (their tests pass unedited).
|
|
201
|
+
const seatLoss = degrades
|
|
202
|
+
? deriveSeatLoss({ runId: record.meta.runId, critic, degrades })
|
|
203
|
+
: summarizeSeatLoss({ runId: record.meta.runId, critic, deadWaves });
|
|
204
|
+
const verdict = buildVerdict(record, [], { seatLoss, degrades });
|
|
187
205
|
verdict.overallVerdict = (overallVerdict === undefined) ? null : overallVerdict;
|
|
188
206
|
writeVerdictAtomic(path.join(runDir, 'verdict.json'), verdict);
|
|
189
207
|
const html = buildReport({ verdict }, { format: 'html' });
|
|
@@ -38,15 +38,17 @@ const { sumWaveUsage } = require('../utils/pricing');
|
|
|
38
38
|
* @param {number|null|undefined} opts.maxCost the `--max-cost` ceiling, if any
|
|
39
39
|
* @param {string} [opts.runDir] run directory; when given, budget refusals are
|
|
40
40
|
* checkpointed into run.json so the record outlives the stderr notice
|
|
41
|
-
* @param {{
|
|
42
|
-
* refusal
|
|
41
|
+
* @param {{note: Function}} opts.degrade the council degrade sink; a budget
|
|
42
|
+
* refusal announces through it so the run can never exit 0 with a silently
|
|
43
|
+
* shrunken bench. Required, not optional — noteBudgetRefusal dereferences it
|
|
44
|
+
* unconditionally.
|
|
43
45
|
* @param {(s:string)=>void} [opts.write] stderr writer seam (defaults to process.stderr)
|
|
44
46
|
* @returns {{spendState:Function, spent:Function, overBudget:Function,
|
|
45
47
|
* remainingBudget:Function, noticeUnknownSpend:Function, usageBlock:Function,
|
|
46
48
|
* addWave:Function, reserveBudget:Function, releaseBudget:Function,
|
|
47
49
|
* noteBudgetRefusal:Function, budgetRefusals:Function, inexactUnderCeiling:Function}}
|
|
48
50
|
*/
|
|
49
|
-
function createBudget({ allLegs, maxCost, runDir,
|
|
51
|
+
function createBudget({ allLegs, maxCost, runDir, degrade, write }) {
|
|
50
52
|
const legs = allLegs || [];
|
|
51
53
|
const emit = write || ((s) => process.stderr.write(s));
|
|
52
54
|
const hasCeiling = maxCost !== null && maxCost !== undefined;
|
|
@@ -146,11 +148,15 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
|
|
|
146
148
|
const rec = { waveId: (info && info.waveId) || null, models: (info && info.models) || [],
|
|
147
149
|
reason: 'max-cost', at: new Date().toISOString() };
|
|
148
150
|
refusals.push(rec);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
const message = info && info.message;
|
|
152
|
+
degrade.note({
|
|
153
|
+
channel: 'budget-refusal',
|
|
154
|
+
what: `wave ${rec.waveId} (${rec.models.join(', ') || 'no models'}) — those seats DID NOT `
|
|
155
|
+
+ 'LAUNCH and are missing from this council',
|
|
156
|
+
why: `the $${maxCost} --max-cost ceiling refused it${message ? `: ${message}` : ''}`,
|
|
157
|
+
effect: 'The run continues with the bench that did launch and will exit degraded (2)',
|
|
158
|
+
remedy: 'Raise --max-cost, or pass --no-cost-gate, to seat them',
|
|
159
|
+
});
|
|
154
160
|
if (runDir) {
|
|
155
161
|
// Never let bookkeeping sink a run that is otherwise fine.
|
|
156
162
|
try { require('./run-state').checkpoint(runDir, { budgetRefusals: refusals.slice() }); }
|
package/src/council/run-chair.js
CHANGED
|
@@ -46,13 +46,13 @@ function pickFallbackChair(statsRows, bench, failedChair) {
|
|
|
46
46
|
* Chair chain (attempt → retry → ledger-promoted fallback → give up) plus the
|
|
47
47
|
* single VERDICT-line repair re-prompt.
|
|
48
48
|
* @param {object} ctx run.js's {o, launchers, addWave, overBudget, scratchDir}
|
|
49
|
-
* @param {{packet: string,
|
|
49
|
+
* @param {{packet: string, degrade: {note: Function}, statsFn: Function,
|
|
50
50
|
* isSignalled: function(): (number|null)}} args
|
|
51
51
|
* @returns {Promise<{aborted: number|null, chairLeg: object|null,
|
|
52
52
|
* actualChair: string|null, chairText: string|null,
|
|
53
53
|
* chairConformance: string, overallVerdict: string|null}>}
|
|
54
54
|
*/
|
|
55
|
-
async function runChair(ctx, { packet,
|
|
55
|
+
async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
|
|
56
56
|
const { o, launchers, addWave, overBudget } = ctx;
|
|
57
57
|
const now = () => new Date().toISOString();
|
|
58
58
|
const bail = (code) => ({
|
|
@@ -81,11 +81,19 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
|
|
|
81
81
|
|
|
82
82
|
let chairLeg = null;
|
|
83
83
|
let actualChair = null;
|
|
84
|
+
let skippedForCost = false;
|
|
84
85
|
if (overBudget()) {
|
|
85
86
|
// Ceiling hit after the tally is computable: skip the chair, write the
|
|
86
87
|
// verdict with overallVerdict null, exit 2 (spec §4 degradation table).
|
|
87
88
|
// Never abort in-flight legs for cost — this only stops NEW launches.
|
|
88
|
-
|
|
89
|
+
skippedForCost = true;
|
|
90
|
+
degrade.note({
|
|
91
|
+
channel: 'chair-skipped-cost-ceiling',
|
|
92
|
+
what: 'the chair did not run',
|
|
93
|
+
why: 'the --max-cost ceiling was reached before the chair could launch',
|
|
94
|
+
effect: 'the verdict is written with no chair synthesis and overallVerdict null; will exit degraded (2)',
|
|
95
|
+
remedy: 'raise --max-cost, or re-run the chair alone against the existing tally',
|
|
96
|
+
});
|
|
89
97
|
runState.updateStage(o.runDir, 'chair', { status: 'skipped', completedAt: now() });
|
|
90
98
|
emitStageTerminal(o.runDir, o.runId, 'chair', 'skipped', null, o.follow);
|
|
91
99
|
} else {
|
|
@@ -146,7 +154,16 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
|
|
|
146
154
|
// A completed chair whose verdict never parsed is 'unstructured' even when
|
|
147
155
|
// the repair was skipped (e.g. the chair leg itself tripped --max-cost).
|
|
148
156
|
if (chairText && !overallVerdict) { chairConformance = 'unstructured'; }
|
|
149
|
-
if (!chairLeg || !overallVerdict) {
|
|
157
|
+
if (!skippedForCost && (!chairLeg || !overallVerdict)) {
|
|
158
|
+
degrade.note({
|
|
159
|
+
channel: 'chair-failed',
|
|
160
|
+
what: 'the council has no chair synthesis',
|
|
161
|
+
why: chairLeg
|
|
162
|
+
? 'the chair ran but its output carried no parseable VERDICT: line'
|
|
163
|
+
: 'no chair leg completed, including after the fallback chain',
|
|
164
|
+
effect: 'the verdict is written with overallVerdict null; will exit degraded (2)',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
150
167
|
|
|
151
168
|
return {
|
|
152
169
|
aborted: null, chairLeg, actualChair, chairText, chairConformance, overallVerdict,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/council/run-debate-stage.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/run-debate-stage
|
|
6
|
+
* Debate orchestration, extracted from run.js (v4.6 Plan 1 Task 1) so that file
|
|
7
|
+
* could come off the 300-line size-gate cliff. Logic moved verbatim; the only
|
|
8
|
+
* change is the explicit parameter object replacing closure access — PLUS one
|
|
9
|
+
* necessary deviation the plan's block-boundary audit missed: the original
|
|
10
|
+
* line here was `return finalize(dbg.aborted);` (spec §5.7, a signal arriving
|
|
11
|
+
* mid-debate). `finalize` is a run.js-local closure bound to session/server
|
|
12
|
+
* teardown state (uninstall, the shared OpenCode server) that cannot be
|
|
13
|
+
* reconstructed in a separate module without duplicating that single-close-site
|
|
14
|
+
* guarantee. Mirroring the SAME convention run.js already uses for Stage 1/2
|
|
15
|
+
* (`runStage1`/`runStage2` return an `aborted` field; run.js itself decides to
|
|
16
|
+
* call `finalize`), this module returns `{ ...the five bindings, aborted:
|
|
17
|
+
* dbg.aborted }` on that one path instead, and run.js's call site now carries
|
|
18
|
+
* a `if (debateAborted) { return finalize(debateAborted); }` guard, exactly
|
|
19
|
+
* paralleling its existing `if (signalled || s1.aborted) { ... }` /
|
|
20
|
+
* `if (signalled || s2.aborted) { ... }` lines. Verified against
|
|
21
|
+
* tests/council/run-debate.test.js's "abort mid-debate (defense wave
|
|
22
|
+
* signalled) → finalize aborted, NO tally-final, NO ledger" end-to-end case,
|
|
23
|
+
* which exercises exactly this path through runCouncil and continues to pass
|
|
24
|
+
* unedited.
|
|
25
|
+
*/
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const runState = require('./run-state');
|
|
29
|
+
const runDebateMod = require('./run-debate');
|
|
30
|
+
const { tally } = require('./tally');
|
|
31
|
+
const { emitStageStarted, emitStageTerminal } = require('../observe/events');
|
|
32
|
+
|
|
33
|
+
const now = () => new Date().toISOString();
|
|
34
|
+
|
|
35
|
+
async function runDebateStage(ctx, { provisional, provisionalInput, overBudget }) {
|
|
36
|
+
const { o } = ctx;
|
|
37
|
+
let debatedInput = provisionalInput, debatedRecord = provisional;
|
|
38
|
+
let debateOutcomes = null, debateFindings = null;
|
|
39
|
+
let debateSummary = o.debate ? { enabled: true, outcome: 'nothing-to-debate',
|
|
40
|
+
contested: 0, disputed: 0, defended: 0, amended: 0, withdrawn: 0, noResponse: 0,
|
|
41
|
+
revoteJudges: 0, revoteApplied: 0, verdictChanges: 0 } : null;
|
|
42
|
+
if (o.debate) {
|
|
43
|
+
// spec §5.1: the provisional tally is ALSO an audit artifact, not just a stage
|
|
44
|
+
// checkpoint — no ledger append, written before any debate leg launches.
|
|
45
|
+
fs.writeFileSync(path.join(o.runDir, 'tally-provisional.json'), JSON.stringify(provisional, null, 2), { mode: 0o600 });
|
|
46
|
+
runState.updateStage(o.runDir, 'tally-provisional', { status: 'complete', startedAt: now(), completedAt: now() });
|
|
47
|
+
emitStageStarted(o.runDir, o.runId, 'tally-provisional', null, o.follow);
|
|
48
|
+
emitStageTerminal(o.runDir, o.runId, 'tally-provisional', 'complete', null, o.follow);
|
|
49
|
+
const worthDebating = !runDebateMod.nothingToDebate(provisional);
|
|
50
|
+
if (worthDebating && !overBudget()) {
|
|
51
|
+
runState.updateStage(o.runDir, 'debate-defense', { status: 'running', startedAt: now(), project: ctx.scratchDir });
|
|
52
|
+
emitStageStarted(o.runDir, o.runId, 'debate-defense', null, o.follow);
|
|
53
|
+
const dbg = await runDebateMod.runDebate(ctx, { provisionalRecord: provisional, tallyInput: provisionalInput });
|
|
54
|
+
// A signal mid-debate aborts finalization: no tally-final, no ledger (spec §5.7). Close
|
|
55
|
+
// the summary FIRST — the writer contract requires a valid `outcome` whenever the key exists.
|
|
56
|
+
if (dbg.aborted) {
|
|
57
|
+
runState.checkpoint(o.runDir, { debate: { ...debateSummary, outcome: 'ran',
|
|
58
|
+
contested: dbg.contested, disputed: dbg.disputed } });
|
|
59
|
+
// See module docblock: `finalize` lives in run.js, not here. Return the
|
|
60
|
+
// signal (mirroring runStage1/runStage2's `aborted` field) instead of
|
|
61
|
+
// calling it directly — run.js's call site finalizes on our behalf.
|
|
62
|
+
return { debatedInput, debatedRecord, debateOutcomes, debateFindings, debateSummary, aborted: dbg.aborted };
|
|
63
|
+
}
|
|
64
|
+
runState.updateStage(o.runDir, 'debate-defense', { status: 'complete', completedAt: now() });
|
|
65
|
+
emitStageTerminal(o.runDir, o.runId, 'debate-defense', 'complete', null, o.follow);
|
|
66
|
+
// run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
|
|
67
|
+
// knows whether the wave launched. Never advertise a `-rv` id here: a skipped
|
|
68
|
+
// re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
|
|
69
|
+
// Mirror run-chair.js's 'skipped' convention (no startedAt) when nothing was
|
|
70
|
+
// defended/amended or the cost ceiling skipped it — 'complete' would report
|
|
71
|
+
// work that never happened.
|
|
72
|
+
runState.updateStage(o.runDir, 'debate-revote', dbg.revoteLaunched
|
|
73
|
+
? { status: 'complete', completedAt: now() } : { status: 'skipped', completedAt: now() });
|
|
74
|
+
// debate-revote-TERMINAL only — run-debate.js owns the START (spec §4.2 /
|
|
75
|
+
// v4.3 Task 7 B3 note): only it knows the `-rv` waveId when launched.
|
|
76
|
+
emitStageTerminal(o.runDir, o.runId, 'debate-revote',
|
|
77
|
+
dbg.revoteLaunched ? 'complete' : 'skipped', dbg.revoteLaunched ? `${o.runId}-rv` : null, o.follow);
|
|
78
|
+
({ debatedInput, debateFindings, debateSummary } = dbg);
|
|
79
|
+
debatedRecord = tally(debatedInput);
|
|
80
|
+
// Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
|
|
81
|
+
// list must be normalized to null here — otherwise the packet-assembly
|
|
82
|
+
// ternary below still calls buildDebateAddendum({outcomes: []}), which
|
|
83
|
+
// emits a bare "--- Debate round outcomes ---" heading with nothing
|
|
84
|
+
// under it (same defect class ee447b6 fixed on the report renderer).
|
|
85
|
+
debateOutcomes = (dbg.addendumOutcomes && dbg.addendumOutcomes.length > 0)
|
|
86
|
+
? dbg.addendumOutcomes : null;
|
|
87
|
+
// Dead/unstructured defense, partial/fully-dead re-vote or a cost-ceiling re-vote skip
|
|
88
|
+
// each degrade the run → exit 2 (spec §5.7), same channel as a dead Stage-1 leg.
|
|
89
|
+
if (dbg.degraded) {
|
|
90
|
+
ctx.degrade.note({
|
|
91
|
+
channel: 'debate-degraded',
|
|
92
|
+
what: 'the debate round did not complete cleanly',
|
|
93
|
+
why: 'one or more defense or re-vote legs died or returned unstructured output',
|
|
94
|
+
effect: 'affected findings keep their provisional tier; will exit degraded (2)',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} else if (worthDebating) {
|
|
98
|
+
// Budget gone before the defense wave launched, but there WAS something to debate — the
|
|
99
|
+
// other cost-ceiling branch (spec §5.7). Over budget AND nothing to debate stays the latter.
|
|
100
|
+
debateSummary.outcome = 'skipped-cost-ceiling';
|
|
101
|
+
ctx.degrade.note({
|
|
102
|
+
channel: 'debate-degraded',
|
|
103
|
+
what: 'the debate round did not run',
|
|
104
|
+
why: 'the --max-cost ceiling was reached before the defense wave could launch',
|
|
105
|
+
effect: 'contested findings were not debated and keep their provisional tier; will exit degraded (2)',
|
|
106
|
+
remedy: 'raise --max-cost to let the debate round run',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
runState.checkpoint(o.runDir, { debate: debateSummary });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { debatedInput, debatedRecord, debateOutcomes, debateFindings, debateSummary };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { runDebateStage };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module council/run-degrade
|
|
5
|
+
* THE CHOKE POINT. The only place in the council runtime permitted to set
|
|
6
|
+
* `degraded.value`. Announcing is a side effect of degrading, which is what
|
|
7
|
+
* makes "every degrade is announced" true by construction rather than by
|
|
8
|
+
* discipline — see tests/council/degrade-invariant.test.js, which fails if
|
|
9
|
+
* anyone writes `degraded.value = true` anywhere else.
|
|
10
|
+
*/
|
|
11
|
+
const { makeDegrade, formatDegrade } = require('../utils/degrade');
|
|
12
|
+
|
|
13
|
+
function createDegradeSink({ runDir, degraded, write }) {
|
|
14
|
+
const emit = write || ((s) => process.stderr.write(s));
|
|
15
|
+
const records = [];
|
|
16
|
+
|
|
17
|
+
// One level, no re-entry. Without this rule a disk-full condition becomes an
|
|
18
|
+
// unbounded loop of degrades about failing to record degrades (spec §7).
|
|
19
|
+
const safeEmit = (s) => { try { emit(s); } catch { /* EPIPE etc — never mask the run */ } };
|
|
20
|
+
|
|
21
|
+
function note(input) {
|
|
22
|
+
let record;
|
|
23
|
+
try {
|
|
24
|
+
record = makeDegrade(input);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
record = makeDegrade({
|
|
27
|
+
channel: 'internal',
|
|
28
|
+
what: `a degrade on channel '${input && input.channel}' could not be recorded`,
|
|
29
|
+
why: (err && err.message) || 'unknown error',
|
|
30
|
+
effect: 'the run still degrades; the original detail is lost',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
records.push(record);
|
|
34
|
+
safeEmit(formatDegrade(record));
|
|
35
|
+
try {
|
|
36
|
+
require('./run-state').checkpoint(runDir, { degrades: records.slice() });
|
|
37
|
+
} catch { /* precedent: run-budget.js:156 — a degrade that cannot be persisted is still announced */ }
|
|
38
|
+
if (record.kind === 'degrade' && degraded) { degraded.value = true; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { note, all: () => records.slice() };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { createDegradeSink };
|
|
@@ -59,12 +59,27 @@ function statusForExit(code) {
|
|
|
59
59
|
* a run that published a total it knows is a floor has not earned that.
|
|
60
60
|
*
|
|
61
61
|
* @param {{signalled: number|null, exitCode: number, degraded?: {value: boolean},
|
|
62
|
-
* inexactUnderCeiling?: () => boolean}} args
|
|
62
|
+
* degrade?: {note: Function}, inexactUnderCeiling?: () => boolean}} args
|
|
63
63
|
* @returns {number}
|
|
64
64
|
*/
|
|
65
|
-
function resolveTerminalExit({ signalled, exitCode, degraded, inexactUnderCeiling }) {
|
|
65
|
+
function resolveTerminalExit({ signalled, exitCode, degraded, degrade, inexactUnderCeiling }) {
|
|
66
66
|
if (signalled) { return signalled; }
|
|
67
|
-
|
|
67
|
+
// Final-review F1: the inexactness itself is still announced on EVERY path by
|
|
68
|
+
// noticeUnknownSpend() at writeRunTerminal, below — that is unconditional and
|
|
69
|
+
// untouched. What is gated here is only the CLAIM this record's effect text
|
|
70
|
+
// makes, "the run exits degraded (2)", which is true on exactly the codes that
|
|
71
|
+
// end up 2 (a clean 0 about to flip, or an already-degraded 2). On exit 1
|
|
72
|
+
// (quorum/internal error) or an abort/signal code the run does NOT exit 2, so
|
|
73
|
+
// noting that sentence there would be false.
|
|
74
|
+
if (degrade && inexactUnderCeiling && inexactUnderCeiling() && (exitCode === 0 || exitCode === 2)) {
|
|
75
|
+
// Late channel (spec §6 rule 1): fires AFTER verdict assembly, so this record is run.json-only by design — it can never appear on verdict.degrades[].
|
|
76
|
+
degrade.note({
|
|
77
|
+
channel: 'inexact-under-ceiling',
|
|
78
|
+
what: 'the run total is a lower bound, not an exact figure',
|
|
79
|
+
why: 'one or more legs reported no usage, so their cost is unknown',
|
|
80
|
+
effect: '--max-cost bounded only KNOWN spend; the run exits degraded (2)',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
68
83
|
return (exitCode === 0 && degraded && degraded.value) ? 2 : exitCode;
|
|
69
84
|
}
|
|
70
85
|
|
|
@@ -145,7 +145,12 @@ async function resolveRunServerModels(o, deps = {}) {
|
|
|
145
145
|
* cannot clobber `budgetRefusals[]` or anything else already on the document.
|
|
146
146
|
* Verified, not assumed — `tests/council/run-state.test.js` pins it.
|
|
147
147
|
*
|
|
148
|
-
* @param {object} o the council run's resolved options
|
|
148
|
+
* @param {object} o the council run's resolved options (carries `degrade`, the
|
|
149
|
+
* council sink, for the `sharedServerUnavailable` announcement below). `o.degrade`
|
|
150
|
+
* is expected to be set on that path — run.js threads it — but the call below is
|
|
151
|
+
* still guarded rather than assumed: a throw from this module would escape
|
|
152
|
+
* runCouncil past its "never rejects for run errors" contract (see this file's
|
|
153
|
+
* own docblock), which is worse than a missed note.
|
|
149
154
|
* @param {object} patch a single top-level run.json key
|
|
150
155
|
* @param {string} what the field name, for the failure log
|
|
151
156
|
*/
|
|
@@ -155,6 +160,21 @@ function recordServerFate(o, patch, what) {
|
|
|
155
160
|
catch (writeErr) {
|
|
156
161
|
logger.warn(`Could not record ${what} on run.json`, { runId: o.runId, error: writeErr.message });
|
|
157
162
|
}
|
|
163
|
+
if (what === 'sharedServerUnavailable') {
|
|
164
|
+
const unavailable = patch.sharedServerUnavailable;
|
|
165
|
+
const reason = typeof unavailable === 'string' ? unavailable
|
|
166
|
+
: (unavailable && unavailable.error) || 'unknown error';
|
|
167
|
+
if (o.degrade) {
|
|
168
|
+
o.degrade.note({
|
|
169
|
+
channel: 'shared-server-unavailable',
|
|
170
|
+
kind: 'degrade',
|
|
171
|
+
what: 'could not start a shared OpenCode server',
|
|
172
|
+
why: reason,
|
|
173
|
+
effect: 'each wave will start its own, which is the configuration that races; the run '
|
|
174
|
+
+ 'will exit degraded (2)',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
/**
|
|
@@ -222,14 +242,11 @@ async function acquireRunServer(o, deps = {}) {
|
|
|
222
242
|
// DURABLE: it lands on the run's own record, next to `budgetRefusals[]`,
|
|
223
243
|
// for the same reason — a silent partial is the failure mode this whole
|
|
224
244
|
// release exists to remove.
|
|
225
|
-
const
|
|
226
|
-
recordServerFate(o, { sharedServerUnavailable:
|
|
245
|
+
const serverFailure = { error: err.message, at: new Date().toISOString() };
|
|
246
|
+
recordServerFate(o, { sharedServerUnavailable: serverFailure }, 'sharedServerUnavailable');
|
|
227
247
|
logger.warn('Shared OpenCode server unavailable — falling back to one server per wave', {
|
|
228
248
|
runId: o.runId, error: err.message,
|
|
229
249
|
});
|
|
230
|
-
process.stderr.write(
|
|
231
|
-
`Notice: could not start a shared OpenCode server (${err.message}); each wave will start `
|
|
232
|
-
+ 'its own, which is the configuration that races. Expect degraded results.\n');
|
|
233
250
|
return null;
|
|
234
251
|
}
|
|
235
252
|
}
|
|
@@ -245,4 +262,4 @@ async function releaseRunServer(shared) {
|
|
|
245
262
|
try { await shared.server.close(); } catch { /* best-effort: the run is over */ }
|
|
246
263
|
}
|
|
247
264
|
|
|
248
|
-
module.exports = { acquireRunServer, releaseRunServer, resolveRunServerModels };
|
|
265
|
+
module.exports = { acquireRunServer, releaseRunServer, resolveRunServerModels, recordServerFate };
|
|
@@ -106,11 +106,19 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
|
|
|
106
106
|
}
|
|
107
107
|
if (!parsed.ok) {
|
|
108
108
|
judgeResults.push({ judge, ok: false, order: null, adjudications: null,
|
|
109
|
-
conformance: leg.status === 'complete' ? 'unstructured' : 'clean'
|
|
109
|
+
conformance: leg.status === 'complete' ? 'unstructured' : 'clean',
|
|
110
|
+
// #83 (v4.6 Plan 2): the judge's ORIGINAL Stage-2 wave leg, mirroring
|
|
111
|
+
// Stage-1's convention (reviews carry the original wave leg even when a
|
|
112
|
+
// repair ran — repairs are separately recorded via appendStageWave).
|
|
113
|
+
// A repair solo's leg is NOT preferred here: attributing it instead
|
|
114
|
+
// would leave every non-repaired (the common case) judge with a false
|
|
115
|
+
// `status: 'error'` row — worse than the missing row #83 complained about.
|
|
116
|
+
leg: leg || null });
|
|
110
117
|
continue;
|
|
111
118
|
}
|
|
112
119
|
const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
|
|
113
|
-
judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance
|
|
120
|
+
judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance,
|
|
121
|
+
leg: leg || null });
|
|
114
122
|
}
|
|
115
123
|
return { aborted: null, judgeResults };
|
|
116
124
|
}
|
|
@@ -107,25 +107,6 @@ async function launchStage1(ctx) {
|
|
|
107
107
|
return { aborted, legs, deadWaves };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/**
|
|
111
|
-
* Announce Stage-1 sub-waves that never produced a leg.
|
|
112
|
-
*
|
|
113
|
-
* POLICY (the standing "never fail closed on availability" ruling, applied the
|
|
114
|
-
* same way run-budget.js applies it to cost): the run CONTINUES with the bench
|
|
115
|
-
* that did launch. What it must never do is lose the seats SILENTLY — so every
|
|
116
|
-
* dead wave is announced on stderr, kept on run.json's stage entry (run.js) and
|
|
117
|
-
* degrades the run's exit code to 2.
|
|
118
|
-
* @param {Array<{waveId: string, models: string[], reason: string}>} deadWaves
|
|
119
|
-
* @param {(s: string) => void} [write] stderr seam
|
|
120
|
-
*/
|
|
121
|
-
function reportDeadStage1Waves(deadWaves, write = (s) => process.stderr.write(s)) {
|
|
122
|
-
for (const d of deadWaves) {
|
|
123
|
-
write(`Notice: Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs `
|
|
124
|
-
+ `— ${d.reason}. Those seats are NOT in this council. The run continues with the bench that `
|
|
125
|
-
+ 'did launch and will exit degraded (2).\n');
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
110
|
/** Role of a seat by its input alias. */
|
|
130
111
|
function roleFor(o, alias) {
|
|
131
112
|
if (o.lenses) {
|
|
@@ -151,12 +132,33 @@ async function runStage1(ctx) {
|
|
|
151
132
|
const { o } = ctx;
|
|
152
133
|
const { aborted, legs, deadWaves } = await launchStage1(ctx);
|
|
153
134
|
if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false }; }
|
|
154
|
-
|
|
135
|
+
|
|
136
|
+
for (const d of deadWaves) {
|
|
137
|
+
ctx.degrade.note({
|
|
138
|
+
channel: 'dead-wave',
|
|
139
|
+
what: `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
|
|
140
|
+
why: d.reason,
|
|
141
|
+
effect: 'Those seats are NOT in this council. The run continues with the bench that did '
|
|
142
|
+
+ 'launch and will exit degraded (2)',
|
|
143
|
+
data: { waveId: d.waveId, models: d.models, reason: d.reason },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
155
146
|
|
|
156
147
|
const materialized = materializeReviews(o.runDir, legs);
|
|
157
148
|
const alive = new Set(materialized.map(m => m.leg));
|
|
158
149
|
const deadLegs = legs.filter(l => !alive.has(l));
|
|
159
150
|
|
|
151
|
+
for (const leg of deadLegs) {
|
|
152
|
+
ctx.degrade.note({
|
|
153
|
+
channel: 'dead-leg',
|
|
154
|
+
what: `seat ${leg.modelInput || leg.model} did not review`,
|
|
155
|
+
why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output`,
|
|
156
|
+
effect: `${materialized.length} of ${legs.length} seats reviewed; `
|
|
157
|
+
+ 'the run continues with the bench that did and will exit degraded (2)',
|
|
158
|
+
data: { seat: leg.modelInput || leg.model, status: leg.status, reason: leg.error || null },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
160
162
|
const reviews = [];
|
|
161
163
|
let repairSeq = 0;
|
|
162
164
|
for (const m of materialized) {
|
|
@@ -263,4 +265,4 @@ async function runStage1(ctx) {
|
|
|
263
265
|
// module that produces the exit codes — so the child no longer imports from its
|
|
264
266
|
// parent (v4.4.1 review F5). isAbortExit is still re-exported for run-chair.js
|
|
265
267
|
// and run-debate.js, which have always taken it from here.
|
|
266
|
-
module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor
|
|
268
|
+
module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor };
|