amicus 4.5.4 → 4.6.1
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 +113 -0
- package/README.md +1 -1
- package/commands/council.md +1 -1
- package/docs/DISTRIBUTION.md +38 -11
- package/docs/ROADMAP.md +40 -8
- package/docs/publishing.md +1 -1
- 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/skills/second-opinion/MODEL-NOTES.md +182 -35
- 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-launch.js +4 -0
- package/src/council/run-retry-notes.js +74 -0
- package/src/council/run-retry.js +280 -0
- package/src/council/run-server.js +24 -7
- package/src/council/run-stage2.js +10 -2
- package/src/council/run-stages.js +59 -27
- package/src/council/run.js +39 -67
- package/src/council/verdict.js +81 -8
- package/src/mcp-council-bench.js +45 -0
- package/src/mcp-council-run.js +11 -28
- package/src/mcp-server.js +22 -3
- package/src/mcp-tools.js +13 -1
- package/src/utils/degrade.js +69 -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 +20 -14
- package/src/utils/result-schema.js +6 -2
- package/src/utils/session-index-tmp-sweep.js +2 -1
- package/src/utils/update-notice.js +171 -0
- package/src/workspace/run-scan.js +5 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module council/run-retry-notes
|
|
5
|
+
* Pure note-builders for the SL-2 Stage-1 retry pass (split out of
|
|
6
|
+
* run-retry.js for the 300-line gate — same rationale as run-stage2.js
|
|
7
|
+
* splitting off run-stages.js, v4.4.1 Task 2). No I/O, no ctx: each function
|
|
8
|
+
* takes plain data and returns a still-dead note ready for
|
|
9
|
+
* `ctx.degrade.note(...)` (D5 final-failure granularity, spec §5). The heal
|
|
10
|
+
* note is built inline in run-retry.js's orchestrator (it is the one place
|
|
11
|
+
* that decides recovery, and stays small).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** D-effect parity: still-dead leg notes reuse today's count phrasing, with the
|
|
15
|
+
* FIRST attempt's counts — the why carries the retry story (spec §5). */
|
|
16
|
+
const legEffect = (counts) =>
|
|
17
|
+
`${counts.reviewed} of ${counts.total} seats reviewed; `
|
|
18
|
+
+ 'the run continues with the bench that did and will exit degraded (2)';
|
|
19
|
+
|
|
20
|
+
/** Wave-origin, retry wave died wholesale (D5 wave granularity). */
|
|
21
|
+
function waveStillDeadNote(w, unit) {
|
|
22
|
+
return { channel: 'dead-wave',
|
|
23
|
+
what: `Stage-1 wave ${w.waveId} (${(w.models || []).join(', ') || 'no models'}) produced NO legs`,
|
|
24
|
+
// Coordinator-review MINOR-7c: a falsy w.reason must not render as the
|
|
25
|
+
// literal string "undefined" in the why text.
|
|
26
|
+
why: `${w.reason || 'no reason recorded'}; the once-only retry wave also produced no legs`,
|
|
27
|
+
effect: 'Those seats are NOT in this council. The run continues with the bench that did '
|
|
28
|
+
+ 'launch and will exit degraded (2)',
|
|
29
|
+
data: { waveId: w.waveId, models: w.models, reason: w.reason, retryWaveId: unit.waveId } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Leg-origin, retry wave died wholesale (bench-batch case). */
|
|
33
|
+
function srcLegStillDeadNote(leg, unit, counts) {
|
|
34
|
+
const seat = leg.modelInput || leg.model;
|
|
35
|
+
return { channel: 'dead-leg', what: `seat ${seat} did not review`,
|
|
36
|
+
why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output; `
|
|
37
|
+
+ 'its once-only retry wave produced no legs',
|
|
38
|
+
effect: legEffect(counts),
|
|
39
|
+
data: { seat, status: leg.status, reason: leg.error || null, retryWaveId: unit.waveId } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Either origin, the retry produced legs but THIS seat's retry leg died. */
|
|
43
|
+
function retryLegStillDeadNote(seat, ff, retryLeg, unit, counts) {
|
|
44
|
+
const why = ff && ff.class === 'wave'
|
|
45
|
+
? `its first wave ${ff.waveId} produced no legs (${ff.reason}); `
|
|
46
|
+
+ `its once-only retry leg ended '${retryLeg.status}' with no usable output`
|
|
47
|
+
: `the leg ended '${ff ? ff.status : 'unknown'}'${ff && ff.reason ? `: ${ff.reason}` : ''} `
|
|
48
|
+
+ `with no usable output; its once-only retry also ended '${retryLeg.status}'`;
|
|
49
|
+
return { channel: 'dead-leg', what: `seat ${seat} did not review`, why,
|
|
50
|
+
effect: legEffect(counts),
|
|
51
|
+
data: { seat, status: retryLeg.status, reason: retryLeg.error || null,
|
|
52
|
+
firstFailure: ff, retryWaveId: unit.waveId } };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* CRITICAL fix (coordinator review): a launched seat can be missing a leg
|
|
57
|
+
* record ENTIRELY from the retry response — a partial wave return (unit
|
|
58
|
+
* models [a,b], the wave comes back with only a's leg). This is distinct
|
|
59
|
+
* from `retryLegStillDeadNote` (the seat's retry leg came back but was
|
|
60
|
+
* unusable) — here there is no retry-attempt status/error to report at all,
|
|
61
|
+
* only the ORIGINAL first-failure fact plus the fact that nothing came back
|
|
62
|
+
* this time.
|
|
63
|
+
*/
|
|
64
|
+
function missingLegStillDeadNote(seat, ff, unit, counts) {
|
|
65
|
+
const fact = ff && ff.class === 'wave'
|
|
66
|
+
? `its first wave ${ff.waveId} produced no legs (${ff.reason})`
|
|
67
|
+
: `the leg ended '${ff ? ff.status : 'unknown'}'${ff && ff.reason ? `: ${ff.reason}` : ''} with no usable output`;
|
|
68
|
+
return { channel: 'dead-leg', what: `seat ${seat} did not review`,
|
|
69
|
+
why: `${fact}; its once-only retry produced no leg for this seat`,
|
|
70
|
+
effect: legEffect(counts),
|
|
71
|
+
data: { seat, status: null, reason: null, firstFailure: ff, retryWaveId: unit.waveId } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote };
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module council/run-retry
|
|
5
|
+
* SL-2 (spec: docs/superpowers/specs/2026-08-03-sl2-stage1-retry-design.md):
|
|
6
|
+
* the Stage-1 once-only retry pass. A sub-wave that died before its legs
|
|
7
|
+
* existed, or a leg that ended with no usable output, is relaunched exactly
|
|
8
|
+
* once — serially, after every surviving launch settled — and the outcome is
|
|
9
|
+
* announced in the one voice: a `stage1-retry` HEAL per recovered seat; the
|
|
10
|
+
* ordinary dead-wave/dead-leg degrade, noted by the CALLER (run-stages.js),
|
|
11
|
+
* when the retry also died. This module emits heals only — it never notes a
|
|
12
|
+
* degrade and never touches `degraded.value`, so the sink invariant holds by
|
|
13
|
+
* construction. No retry of a retry: the pass consumes first-attempt losses
|
|
14
|
+
* only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const briefings = require('./briefings');
|
|
18
|
+
const { materializeReviews, isAbortExit } = require('./run-launch');
|
|
19
|
+
const runState = require('./run-state');
|
|
20
|
+
const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
|
|
21
|
+
= require('./run-retry-notes');
|
|
22
|
+
|
|
23
|
+
/** 1-based lens index for a loss, from the waveId convention or the model. */
|
|
24
|
+
function lensIndexOf(o, waveId, model) {
|
|
25
|
+
const m = /-l(\d+)$/.exec(waveId || '');
|
|
26
|
+
if (m) { return Number(m[1]); }
|
|
27
|
+
const i = (o.models || []).indexOf(model);
|
|
28
|
+
return i === -1 ? null : i + 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Dedup helper (Task-4 review hardening): the same seat can arrive twice in
|
|
33
|
+
* one grouping pass — two dead legs naming it, or a dead wave and a dead leg
|
|
34
|
+
* both naming it. One seat must still mean ONE `firstFailures` entry — first
|
|
35
|
+
* occurrence wins — while every SOURCE record is kept regardless (srcWaves/
|
|
36
|
+
* srcLegs are the audit trail and are never deduped). The critic unit's
|
|
37
|
+
* `.models` is fixed at creation (there is only ever one critic seat), so
|
|
38
|
+
* only bench/lens units grow `.models` here — the critic call sites pass
|
|
39
|
+
* `trackModel: false`.
|
|
40
|
+
*/
|
|
41
|
+
function recordFailure(unit, seat, ff, trackModel = true) {
|
|
42
|
+
if (unit.firstFailures.some(f => f.seat === seat)) { return; }
|
|
43
|
+
unit.firstFailures.push(ff);
|
|
44
|
+
if (trackModel) { unit.models.push(seat); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Group Stage-1 losses into retry units. Pure — no I/O.
|
|
49
|
+
* Bench losses (a dead bench wave's models + dead bench legs) collapse into
|
|
50
|
+
* ONE retry wave; the critic and each lens retry as solos (their briefings
|
|
51
|
+
* differ). Stable order: bench, critic, lenses ascending. The critic matches
|
|
52
|
+
* on EITHER carrier — waveId convention or model — mirroring
|
|
53
|
+
* verdict.js summarizeSeatLoss.
|
|
54
|
+
*/
|
|
55
|
+
function groupStage1Losses(o, deadWaves = [], deadLegs = []) {
|
|
56
|
+
const isCriticWave = (w) =>
|
|
57
|
+
w.waveId === `${o.runId}-c1` || (!!o.critic && (w.models || []).includes(o.critic));
|
|
58
|
+
const bench = { unit: 'bench', waveId: `${o.runId}-s1r1`, retryOfWaveId: `${o.runId}-s1`,
|
|
59
|
+
models: [], firstFailures: [], srcWaves: [], srcLegs: [] };
|
|
60
|
+
const lensUnits = new Map(); // lensIndex (number, or null for unmappable) -> unit
|
|
61
|
+
const criticUnit = { unit: 'critic', waveId: `${o.runId}-c1r1`, retryOfWaveId: `${o.runId}-c1`,
|
|
62
|
+
models: o.critic ? [o.critic] : [], firstFailures: [], srcWaves: [], srcLegs: [] };
|
|
63
|
+
|
|
64
|
+
const lensUnitFor = (i) => {
|
|
65
|
+
if (!lensUnits.has(i)) {
|
|
66
|
+
// Task-4 review hardening: an unmappable loss (lensIndexOf resolved
|
|
67
|
+
// neither the waveId convention nor a model-roster membership) must
|
|
68
|
+
// still be GROUPABLE — dropping it here would let it vanish before the
|
|
69
|
+
// orchestrator ever sees it — but must not manufacture a fake
|
|
70
|
+
// `-lnullr1` waveId. The orchestrator refuses to launch any unit with
|
|
71
|
+
// `lensIndex === null` and routes its sources to skipped instead.
|
|
72
|
+
lensUnits.set(i, i === null
|
|
73
|
+
? { unit: 'lens', lensIndex: null, waveId: null, retryOfWaveId: null,
|
|
74
|
+
models: [], firstFailures: [], srcWaves: [], srcLegs: [] }
|
|
75
|
+
: { unit: 'lens', lensIndex: i, waveId: `${o.runId}-l${i}r1`,
|
|
76
|
+
retryOfWaveId: `${o.runId}-l${i}`, models: [], firstFailures: [], srcWaves: [], srcLegs: [] });
|
|
77
|
+
}
|
|
78
|
+
return lensUnits.get(i);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
for (const w of deadWaves) {
|
|
82
|
+
const models = w.models || [];
|
|
83
|
+
if (o.lenses) {
|
|
84
|
+
const u = lensUnitFor(lensIndexOf(o, w.waveId, models[0]));
|
|
85
|
+
u.srcWaves.push(w);
|
|
86
|
+
models.forEach(seat => recordFailure(u, seat, { seat, class: 'wave', waveId: w.waveId, reason: w.reason }));
|
|
87
|
+
} else if (isCriticWave(w)) {
|
|
88
|
+
criticUnit.srcWaves.push(w);
|
|
89
|
+
recordFailure(criticUnit, o.critic,
|
|
90
|
+
{ seat: o.critic, class: 'wave', waveId: w.waveId, reason: w.reason }, false);
|
|
91
|
+
} else {
|
|
92
|
+
bench.srcWaves.push(w);
|
|
93
|
+
models.forEach(seat => recordFailure(bench, seat, { seat, class: 'wave', waveId: w.waveId, reason: w.reason }));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const leg of deadLegs) {
|
|
97
|
+
const seat = leg.modelInput || leg.model;
|
|
98
|
+
const ff = { seat, class: 'leg', status: leg.status, reason: leg.error || null };
|
|
99
|
+
if (o.lenses) {
|
|
100
|
+
const u = lensUnitFor(lensIndexOf(o, null, seat));
|
|
101
|
+
u.srcLegs.push(leg);
|
|
102
|
+
recordFailure(u, seat, ff);
|
|
103
|
+
} else if (o.critic && seat === o.critic) {
|
|
104
|
+
criticUnit.srcLegs.push(leg);
|
|
105
|
+
recordFailure(criticUnit, seat, ff, false);
|
|
106
|
+
} else {
|
|
107
|
+
bench.srcLegs.push(leg);
|
|
108
|
+
recordFailure(bench, seat, ff);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const out = [];
|
|
113
|
+
// Task-4 review hardening: gate on whether the unit received any SOURCE
|
|
114
|
+
// record, not on firstFailures.length — a zero-model dead wave contributes
|
|
115
|
+
// a srcWaves entry but nothing to firstFailures/models (nothing for the
|
|
116
|
+
// `.forEach` above to iterate), and must still surface here so the
|
|
117
|
+
// orchestrator can route it to skipped instead of it vanishing silently.
|
|
118
|
+
if (bench.srcWaves.length > 0 || bench.srcLegs.length > 0) { out.push(bench); }
|
|
119
|
+
if (criticUnit.srcWaves.length > 0 || criticUnit.srcLegs.length > 0) { out.push(criticUnit); }
|
|
120
|
+
// Coordinator-review MINOR-7a: null sorts LAST (Infinity), not first (0) —
|
|
121
|
+
// an unmappable loss is not "lens index 0"; it should not perturb the
|
|
122
|
+
// ascending order of the real, well-indexed lens retries.
|
|
123
|
+
out.push(...[...lensUnits.values()].sort((a, b) => (a.lensIndex ?? Infinity) - (b.lensIndex ?? Infinity)));
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The briefing a retry unit re-issues — same builders Stage 1 used. */
|
|
128
|
+
function briefingFor(o, unit) {
|
|
129
|
+
if (unit.unit === 'critic') { return briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }); }
|
|
130
|
+
if (unit.unit === 'lens') {
|
|
131
|
+
return briefings.buildLensBriefing({ lens: o.lenses[unit.lensIndex - 1], briefing: o.briefing, date: o.date });
|
|
132
|
+
}
|
|
133
|
+
return briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The retry pass. Serial by design (spec D-order: bench, critic, lenses) —
|
|
138
|
+
* the per-wave-fallback path, where waves actually die, is exactly where
|
|
139
|
+
* concurrent relaunches would race the same server start again.
|
|
140
|
+
*/
|
|
141
|
+
async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts = { reviewed: 0, total: 0 } } = {}) {
|
|
142
|
+
const { o, launchers } = ctx;
|
|
143
|
+
const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [],
|
|
144
|
+
stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [] };
|
|
145
|
+
|
|
146
|
+
for (const unit of groupStage1Losses(o, deadWaves, deadLegs)) {
|
|
147
|
+
// Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
|
|
148
|
+
// unmappable lens loss (no carrier resolved an index), a lens index
|
|
149
|
+
// outside the run's actual lens roster (coordinator-review MINOR-7b: a
|
|
150
|
+
// malformed waveId like "...-l99" must not become an out-of-range
|
|
151
|
+
// `o.lenses[98]` access inside briefingFor), or a unit whose sources
|
|
152
|
+
// named zero models — is never launched. Its sources fall back to the
|
|
153
|
+
// ordinary skipped-loss path so the caller's normal degrade notes still
|
|
154
|
+
// fire; being unmappable is not an exemption from the record.
|
|
155
|
+
const lensOutOfRange = unit.unit === 'lens' && unit.lensIndex !== null
|
|
156
|
+
&& (unit.lensIndex < 1 || unit.lensIndex > (o.lenses || []).length);
|
|
157
|
+
if (unit.lensIndex === null || lensOutOfRange || unit.models.length === 0) {
|
|
158
|
+
out.skippedDeadWaves.push(...unit.srcWaves);
|
|
159
|
+
out.skippedDeadLegs.push(...unit.srcLegs);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (ctx.overBudget()) { // D7: skip silently — the loss is already announced by the caller
|
|
163
|
+
out.skippedDeadWaves.push(...unit.srcWaves);
|
|
164
|
+
out.skippedDeadLegs.push(...unit.srcLegs);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
runState.appendStageWave(o.runDir, 'stage1', unit.waveId); // BEFORE launch: abort cascade
|
|
168
|
+
const common = { project: o.runDir, timeout: o.timeout, gateway: o.gateway,
|
|
169
|
+
noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
170
|
+
councilRunId: o.runId, councilName: o.councilName,
|
|
171
|
+
fallback: o.fallback, catalog: o.catalog,
|
|
172
|
+
waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit) };
|
|
173
|
+
// Dispatch by UNIT TYPE, not model count (spec §4: bench is always a wave —
|
|
174
|
+
// even down to its last surviving seat — critic/lens are always solos).
|
|
175
|
+
// A model-count proxy (`models.length === 1`) is wrong for a bench unit
|
|
176
|
+
// that lost exactly one seat: it would route that retry through
|
|
177
|
+
// launchSolo, which no bench caller wires up.
|
|
178
|
+
const res = unit.unit === 'bench'
|
|
179
|
+
? await launchers.launchWave({ ...common, models: unit.models.slice() })
|
|
180
|
+
: await launchers.launchSolo({ ...common, model: unit.models[0] });
|
|
181
|
+
ctx.addWave(res.wave); // reservation released + measured legs counted (run-budget)
|
|
182
|
+
if (isAbortExit(res.exitCode)) { out.aborted = res.exitCode; return out; }
|
|
183
|
+
|
|
184
|
+
const legs = (res.wave && Array.isArray(res.wave.legs)) ? res.wave.legs : [];
|
|
185
|
+
if (legs.length === 0) {
|
|
186
|
+
// The retry wave itself died wholesale — final failure keeps each
|
|
187
|
+
// source's granularity (D5): wave-origin stays a dead-wave, leg-origin
|
|
188
|
+
// stays a dead-leg, both enriched with the retry fact. Coordinator-
|
|
189
|
+
// review MINOR-4: emitted ONCE per SEAT — the grouping dedup (Task-4
|
|
190
|
+
// hardening) keeps BOTH src records when a seat arrives via a srcWave
|
|
191
|
+
// AND a srcLeg (or via two srcLegs), so without this a single lost
|
|
192
|
+
// seat could be announced twice. Waves are processed first (mirrors
|
|
193
|
+
// the "wave wins" precedent from the grouping-level dedup); a seat
|
|
194
|
+
// already covered by its wave's note is skipped when its srcLeg is
|
|
195
|
+
// reached.
|
|
196
|
+
const notedSeats = new Set();
|
|
197
|
+
for (const w of unit.srcWaves) {
|
|
198
|
+
out.stillDeadNotes.push(waveStillDeadNote(w, unit));
|
|
199
|
+
out.stillDeadWaves.push(w);
|
|
200
|
+
for (const m of (w.models || [])) { notedSeats.add(m); }
|
|
201
|
+
}
|
|
202
|
+
for (const l of unit.srcLegs) {
|
|
203
|
+
const seat = l.modelInput || l.model;
|
|
204
|
+
if (notedSeats.has(seat)) { continue; }
|
|
205
|
+
notedSeats.add(seat);
|
|
206
|
+
out.stillDeadNotes.push(srcLegStillDeadNote(l, unit, counts));
|
|
207
|
+
out.stillDeadLegs.push(l);
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const usable = new Set(materializeReviews(o.runDir, legs).map(m => m.leg));
|
|
212
|
+
const seenSeats = new Set(legs.map(leg => leg.modelInput || leg.model));
|
|
213
|
+
const lostWaveSeats = new Map(); // waveId -> seats still lost from a wave-origin
|
|
214
|
+
for (const leg of legs) {
|
|
215
|
+
const seat = leg.modelInput || leg.model;
|
|
216
|
+
const ff = unit.firstFailures.find(f => f.seat === seat) || null;
|
|
217
|
+
// SL-2 fix-wave: a retry response should only ever name seats THIS unit
|
|
218
|
+
// launched for (unit.models is built in lockstep with firstFailures via
|
|
219
|
+
// groupStage1Losses's recordFailure) — but if a leg turns up for a seat
|
|
220
|
+
// with no firstFailures entry, that seat never lost its seat in the
|
|
221
|
+
// first place. Skip it entirely: no heal (it would fabricate an "ended
|
|
222
|
+
// 'unknown'" why for a seat that never failed) and no still-dead note —
|
|
223
|
+
// the seat's first-attempt review stands untouched, rather than this
|
|
224
|
+
// stray leg doubling it into a duplicate bench entry alongside the real one.
|
|
225
|
+
if (!ff) { continue; }
|
|
226
|
+
if (usable.has(leg)) {
|
|
227
|
+
out.recoveredLegs.push(leg);
|
|
228
|
+
ctx.degrade.note({ channel: 'stage1-retry', kind: 'heal',
|
|
229
|
+
what: `seat ${seat} reviewed on retry`,
|
|
230
|
+
why: ff && ff.class === 'wave'
|
|
231
|
+
? `its first wave ${ff.waveId} produced no legs (${ff.reason}) and was relaunched once`
|
|
232
|
+
: `its first leg ended '${ff ? ff.status : 'unknown'}' with no usable output and was relaunched once`,
|
|
233
|
+
effect: 'The seat is in this council; nothing was lost',
|
|
234
|
+
data: { seat, retryWaveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, firstFailure: ff } });
|
|
235
|
+
} else {
|
|
236
|
+
out.stillDeadNotes.push(retryLegStillDeadNote(seat, ff, leg, unit, counts));
|
|
237
|
+
if (ff && ff.class === 'wave') {
|
|
238
|
+
if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
|
|
239
|
+
lostWaveSeats.get(ff.waveId).push(seat);
|
|
240
|
+
} else {
|
|
241
|
+
const src = unit.srcLegs.find(l => (l.modelInput || l.model) === seat);
|
|
242
|
+
if (src) { out.stillDeadLegs.push(src); }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// CRITICAL fix (coordinator review): the loop above only visits seats
|
|
247
|
+
// that came back WITH a leg record. A partial wave return (unit models
|
|
248
|
+
// [a,b], the wave comes back with only a's leg) leaves 'b' invisible to
|
|
249
|
+
// that loop entirely — no heal, no still-dead note, no skip: it would
|
|
250
|
+
// vanish from every array. Reconcile against the full launched-seat set
|
|
251
|
+
// (the union of unit.models, every srcWave's models, and every srcLeg's
|
|
252
|
+
// seat — not just unit.models alone, so this holds even if some future
|
|
253
|
+
// change to the grouping made unit.models an incomplete union) so every
|
|
254
|
+
// launched seat lands in exactly one of recovered / still-dead / skipped.
|
|
255
|
+
const launchedSeats = new Set(unit.models);
|
|
256
|
+
for (const w of unit.srcWaves) { for (const m of (w.models || [])) { launchedSeats.add(m); } }
|
|
257
|
+
for (const l of unit.srcLegs) { launchedSeats.add(l.modelInput || l.model); }
|
|
258
|
+
for (const seat of launchedSeats) {
|
|
259
|
+
if (seenSeats.has(seat)) { continue; } // already handled above (healed or still-dead)
|
|
260
|
+
const ff = unit.firstFailures.find(f => f.seat === seat) || null;
|
|
261
|
+
out.stillDeadNotes.push(missingLegStillDeadNote(seat, ff, unit, counts));
|
|
262
|
+
if (ff && ff.class === 'wave') {
|
|
263
|
+
if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
|
|
264
|
+
lostWaveSeats.get(ff.waveId).push(seat);
|
|
265
|
+
} else {
|
|
266
|
+
const src = unit.srcLegs.find(l => (l.modelInput || l.model) === seat);
|
|
267
|
+
if (src) { out.stillDeadLegs.push(src); }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Wave-origin seats still lost: the return-contract wave entry carries only
|
|
271
|
+
// the still-lost subset (a partially healed wave is not wholly dead).
|
|
272
|
+
for (const w of unit.srcWaves) {
|
|
273
|
+
const lost = lostWaveSeats.get(w.waveId) || [];
|
|
274
|
+
if (lost.length > 0) { out.stillDeadWaves.push({ ...w, models: lost }); }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = { groupStage1Losses, retryStage1Losses };
|
|
@@ -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
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
const { validateFindings, countAttemptedFindings } = require('./findings');
|
|
21
21
|
const briefings = require('./briefings');
|
|
22
22
|
const { materializeReviews, isAbortExit } = require('./run-launch');
|
|
23
|
+
const { retryStage1Losses } = require('./run-retry');
|
|
23
24
|
const runState = require('./run-state');
|
|
24
25
|
const { runStage2 } = require('./run-stage2');
|
|
25
26
|
|
|
@@ -107,25 +108,6 @@ async function launchStage1(ctx) {
|
|
|
107
108
|
return { aborted, legs, deadWaves };
|
|
108
109
|
}
|
|
109
110
|
|
|
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
111
|
/** Role of a seat by its input alias. */
|
|
130
112
|
function roleFor(o, alias) {
|
|
131
113
|
if (o.lenses) {
|
|
@@ -151,11 +133,56 @@ async function runStage1(ctx) {
|
|
|
151
133
|
const { o } = ctx;
|
|
152
134
|
const { aborted, legs, deadWaves } = await launchStage1(ctx);
|
|
153
135
|
if (aborted) { return { aborted, reviews: [], deadLegs: [], deadWaves: [], degraded: false }; }
|
|
154
|
-
reportDeadStage1Waves(deadWaves);
|
|
155
136
|
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const
|
|
137
|
+
const firstPass = materializeReviews(o.runDir, legs);
|
|
138
|
+
const alive0 = new Set(firstPass.map(m => m.leg));
|
|
139
|
+
const deadLegs0 = legs.filter(l => !alive0.has(l));
|
|
140
|
+
|
|
141
|
+
// SL-2: one retry BEFORE anything is recorded lost — the sink never
|
|
142
|
+
// un-flips, so a degrade for a seat the retry saves must never fire at all.
|
|
143
|
+
const retry = await retryStage1Losses(ctx, { deadWaves, deadLegs: deadLegs0,
|
|
144
|
+
counts: { reviewed: firstPass.length, total: legs.length } });
|
|
145
|
+
if (retry.aborted) {
|
|
146
|
+
// Final whole-branch review: same bug class as the post-retry-repair
|
|
147
|
+
// abort fixed ~87 lines below ("Must be the post-retry set") — subtract
|
|
148
|
+
// whatever retry.recoveredLegs already healed before this abort landed.
|
|
149
|
+
const healed = new Set(retry.recoveredLegs.map(l => l.modelInput || l.model));
|
|
150
|
+
return { aborted: retry.aborted, reviews: [], degraded: false,
|
|
151
|
+
deadLegs: deadLegs0.filter(l => !healed.has(l.modelInput || l.model)),
|
|
152
|
+
deadWaves: deadWaves.map(w => ({ ...w, models: (w.models || []).filter(m => !healed.has(m)) })).filter(w => w.models.length > 0) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const d of retry.skippedDeadWaves) {
|
|
156
|
+
ctx.degrade.note({
|
|
157
|
+
channel: 'dead-wave',
|
|
158
|
+
what: `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
|
|
159
|
+
why: d.reason,
|
|
160
|
+
effect: 'Those seats are NOT in this council. The run continues with the bench that did '
|
|
161
|
+
+ 'launch and will exit degraded (2)',
|
|
162
|
+
data: { waveId: d.waveId, models: d.models, reason: d.reason },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
for (const leg of retry.skippedDeadLegs) {
|
|
166
|
+
ctx.degrade.note({
|
|
167
|
+
channel: 'dead-leg',
|
|
168
|
+
what: `seat ${leg.modelInput || leg.model} did not review`,
|
|
169
|
+
why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output`,
|
|
170
|
+
effect: `${firstPass.length} of ${legs.length} seats reviewed; `
|
|
171
|
+
+ 'the run continues with the bench that did and will exit degraded (2)',
|
|
172
|
+
data: { seat: leg.modelInput || leg.model, status: leg.status, reason: leg.error || null },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
for (const rec of retry.stillDeadNotes) { ctx.degrade.note(rec); }
|
|
176
|
+
|
|
177
|
+
// Invariant this merge relies on: retry.recoveredLegs only ever names seats
|
|
178
|
+
// that actually lost their seat on the first pass (run-retry.js's recovery
|
|
179
|
+
// loop drops any leg for a seat with no firstFailures entry) — so `legs`
|
|
180
|
+
// and `recoveredLegs` can never both carry a leg for the same seat here.
|
|
181
|
+
// materializeReviews re-writing an already-materialized recovered leg's
|
|
182
|
+
// review-*.md a second time is accepted as an idempotent no-op, not a bug.
|
|
183
|
+
const materialized = materializeReviews(o.runDir, [...legs, ...retry.recoveredLegs]);
|
|
184
|
+
const stillDeadLegs = [...retry.skippedDeadLegs, ...retry.stillDeadLegs];
|
|
185
|
+
const stillDeadWaves = [...retry.skippedDeadWaves, ...retry.stillDeadWaves];
|
|
159
186
|
|
|
160
187
|
const reviews = [];
|
|
161
188
|
let repairSeq = 0;
|
|
@@ -204,7 +231,12 @@ async function runStage1(ctx) {
|
|
|
204
231
|
});
|
|
205
232
|
ctx.addWave(solo.wave);
|
|
206
233
|
if (isAbortExit(solo.exitCode)) {
|
|
207
|
-
|
|
234
|
+
// SL-2 fix-wave: this used to read the pre-retry `deadWaves` binding —
|
|
235
|
+
// run.js persists this return into stage-1 state before the abort
|
|
236
|
+
// short-circuit, so a heal-then-abort run was recording seats as dead
|
|
237
|
+
// that had actually reviewed on retry. Must be the post-retry set,
|
|
238
|
+
// same as the normal-completion return below.
|
|
239
|
+
return { aborted: solo.exitCode, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves, degraded: false };
|
|
208
240
|
}
|
|
209
241
|
const repaired = (solo.leg && solo.leg.summary) || '';
|
|
210
242
|
if (repaired.trim()) { repairing = repaired; }
|
|
@@ -253,8 +285,8 @@ async function runStage1(ctx) {
|
|
|
253
285
|
...(repairRefused ? { repairRefused } : {}),
|
|
254
286
|
});
|
|
255
287
|
}
|
|
256
|
-
return { aborted: null, reviews, deadLegs, deadWaves,
|
|
257
|
-
degraded:
|
|
288
|
+
return { aborted: null, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves,
|
|
289
|
+
degraded: stillDeadLegs.length > 0 || stillDeadWaves.length > 0 };
|
|
258
290
|
}
|
|
259
291
|
|
|
260
292
|
// runStage2 lives in ./run-stage2.js (300-line gate) but is re-exported here so
|
|
@@ -263,4 +295,4 @@ async function runStage1(ctx) {
|
|
|
263
295
|
// module that produces the exit codes — so the child no longer imports from its
|
|
264
296
|
// parent (v4.4.1 review F5). isAbortExit is still re-exported for run-chair.js
|
|
265
297
|
// and run-debate.js, which have always taken it from here.
|
|
266
|
-
module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor
|
|
298
|
+
module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor };
|