amicus 4.4.0 → 4.4.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 +32 -0
- package/README.md +3 -1
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/main.js +4 -1
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-ui/live-model.js +12 -1
- package/electron/workspace-ui/md-lite.js +52 -8
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +14 -3
- package/electron/workspace-ui/workspace-render.js +7 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run.schema.json +20 -0
- package/schemas/progress.schema.json +12 -0
- package/schemas/spend.schema.json +52 -4
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-watch.js +11 -0
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +24 -1
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +64 -11
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +29 -1
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +132 -111
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +44 -46
- package/src/council/tally.js +10 -0
- package/src/headless.js +175 -6
- package/src/observe/council-legs.js +60 -3
- package/src/observe/live-doc.js +18 -1
- package/src/observe/watch-render.js +4 -1
- package/src/sidecar/child-sessions.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +6 -0
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +61 -70
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/spend-query.js +17 -5
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +39 -1
- package/src/utils/pricing.js +26 -10
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +22 -1
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- package/src/workspace/run-detail.js +7 -1
|
@@ -35,7 +35,15 @@ function parseJudgeOutput(text, { labels, findingIds }) {
|
|
|
35
35
|
const errors = [];
|
|
36
36
|
const known = new Set(labels);
|
|
37
37
|
const flat = [];
|
|
38
|
-
|
|
38
|
+
// ⚠️ v4.4.1 FINAL-REVIEW C. `JSON.parse('null')` SUCCEEDS — it returns null and
|
|
39
|
+
// throws nothing — so a body of literal `null` sailed past the catch above and
|
|
40
|
+
// `parsed.ranking` threw `TypeError: Cannot read properties of null`. parseDebateDefense
|
|
41
|
+
// (:129) and parseRevote (:167) below already carried this `!parsed` guard; the judge
|
|
42
|
+
// path and findings.js's validateFindings did not, which made it an asymmetry among
|
|
43
|
+
// five consumers of one extractor rather than a new rule. Guarded on BOTH derefs so
|
|
44
|
+
// a `null` body reports exactly what a keyless `{}` body already reported —
|
|
45
|
+
// BAD_RANKING + BAD_ADJUDICATIONS — and no new error code enters a repair prompt.
|
|
46
|
+
if (!parsed || !Array.isArray(parsed.ranking) || parsed.ranking.length === 0) {
|
|
39
47
|
errors.push({ code: 'BAD_RANKING', detail: 'ranking must be a non-empty array of review labels' });
|
|
40
48
|
} else {
|
|
41
49
|
for (const slot of parsed.ranking) {
|
|
@@ -52,7 +60,7 @@ function parseJudgeOutput(text, { labels, findingIds }) {
|
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
const knownIds = new Set(findingIds);
|
|
55
|
-
if (!Array.isArray(parsed.adjudications)) {
|
|
63
|
+
if (!parsed || !Array.isArray(parsed.adjudications)) { // see the `!parsed` note above
|
|
56
64
|
errors.push({ code: 'BAD_ADJUDICATIONS', detail: 'adjudications must be an array' });
|
|
57
65
|
} else {
|
|
58
66
|
for (const a of parsed.adjudications) {
|
package/src/council/report.js
CHANGED
|
@@ -114,15 +114,26 @@ function renderMd(m) {
|
|
|
114
114
|
for (const s of m.streetCred) { out.push(`| ${s.model} | ${fmtNum(s.peersOnly)} | ${fmtNum(s.withSelf)} |`); }
|
|
115
115
|
|
|
116
116
|
out.push('\n## Findings by tier\n');
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
// LC-10 fast-follow (review minor M3): m.findings can legitimately be EMPTY
|
|
118
|
+
// (every seat honestly reported nothing) — TIER_ORDER's four groups are then
|
|
119
|
+
// all empty too, and the loop below emits nothing, leaving this heading with
|
|
120
|
+
// no content beneath it before '## Cost'. Same heading-over-nothing class
|
|
121
|
+
// Task 3 closed in the Stage-2 prompts (buildJudgeBundle/buildChairPacket),
|
|
122
|
+
// human-facing here rather than model-facing. State the clean bench instead
|
|
123
|
+
// of leaving the heading to dangle.
|
|
124
|
+
if (!m.findings.length) {
|
|
125
|
+
out.push('_No findings were raised on this bench — a clean review is a valid review._\n');
|
|
126
|
+
} else {
|
|
127
|
+
for (const t of TIER_ORDER) {
|
|
128
|
+
const group = m.findings.filter(f => f.tier === t);
|
|
129
|
+
if (!group.length) { continue; }
|
|
130
|
+
out.push(`### ${t}`);
|
|
131
|
+
for (const f of group) {
|
|
132
|
+
const dec = f.decision ? ` — ${f.decision}${f.applied ? ' (applied)' : ''}` : '';
|
|
133
|
+
out.push(`- **${f.id}** (${f.severity}, raiser ${f.raiser}) — a${f.basis.a}/d${f.basis.d}/n${f.basis.n}${dec}`);
|
|
134
|
+
}
|
|
135
|
+
out.push('');
|
|
124
136
|
}
|
|
125
|
-
out.push('');
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
// Defensive: never emit the heading unless at least one grouping has
|
|
@@ -40,13 +40,26 @@ function worseConformance(a, b) {
|
|
|
40
40
|
* leg doc yields durationMs/usage null (never invent a value). `model` (the
|
|
41
41
|
* council alias) overrides leg.model (the resolved executable id) so ledger
|
|
42
42
|
* rows join meta.models by exact string (ledger.js:20-24).
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ LC-11 / review F1: `findingsUnverified` and `repairRefused` are the same
|
|
45
|
+
* class of fact as `conformance` and ride the same row. They are the two halves
|
|
46
|
+
* of the repair contract's outcome: `findingsUnverified` marks a 'repaired' seat
|
|
47
|
+
* whose contract could NOT be checked (the original block was absent or
|
|
48
|
+
* unparseable, so there was no finding count to compare), and `repairRefused`
|
|
49
|
+
* ({code, detail}) marks the stronger case — the contract WAS checked and broken,
|
|
50
|
+
* which is otherwise indistinguishable from a seat that never emitted JSON at
|
|
51
|
+
* all. Both are additive and present only when set, so a run without either is
|
|
52
|
+
* byte-for-byte unchanged.
|
|
43
53
|
*/
|
|
44
|
-
function buildRunStatsEntry({ leg, model, role, wasChair, conformance
|
|
54
|
+
function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsUnverified,
|
|
55
|
+
repairRefused }) {
|
|
45
56
|
return {
|
|
46
57
|
model: model !== undefined ? model : (leg ? leg.model : null),
|
|
47
58
|
role,
|
|
48
59
|
wasChair: !!wasChair,
|
|
49
60
|
conformance: conformance || 'clean',
|
|
61
|
+
...(findingsUnverified ? { findingsUnverified: true } : {}),
|
|
62
|
+
...(repairRefused ? { repairRefused } : {}),
|
|
50
63
|
status: leg ? leg.status : 'error',
|
|
51
64
|
durationMs: leg && typeof leg.durationMs === 'number' ? leg.durationMs : null,
|
|
52
65
|
usage: (leg && leg.usage) || null,
|
|
@@ -139,6 +152,7 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
|
|
|
139
152
|
const rankings = okJudges.map(j => ({ judge: j.judge, order: j.order }));
|
|
140
153
|
const runStats = reviews.map(r => buildRunStatsEntry({
|
|
141
154
|
leg: r.leg, model: r.model, role: r.role, wasChair: false, conformance: r.conformance,
|
|
155
|
+
findingsUnverified: r.findingsUnverified, repairRefused: r.repairRefused,
|
|
142
156
|
}));
|
|
143
157
|
if (claudeReview) {
|
|
144
158
|
meta.models.push(CLAUDE_SEAT); // last, mirroring its review-N+1 label
|
|
@@ -176,7 +190,34 @@ function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
|
|
|
176
190
|
return verdict;
|
|
177
191
|
}
|
|
178
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Build the chair packet and persist it as `chair-packet.md`. Lifted verbatim
|
|
195
|
+
* out of run.js for the 300-line gate (v4.4.1 Task 0.5) — same composition,
|
|
196
|
+
* same debate addendum, same file write.
|
|
197
|
+
* @param {{runDir: string, reviews: Array, claudeReview: object|null,
|
|
198
|
+
* tallyInput: object, record: object, debateOutcomes: Array|null, date: string}} args
|
|
199
|
+
* `tallyInput`/`record` are the DEBATED ones when --debate ran, the
|
|
200
|
+
* provisional pair otherwise (run.js keeps that sequencing).
|
|
201
|
+
* @returns {string} the packet text (run.js hands it straight to runChair)
|
|
202
|
+
*/
|
|
203
|
+
function buildChairPacketFile({ runDir, reviews, claudeReview, tallyInput, record, debateOutcomes, date }) {
|
|
204
|
+
const { buildChairPacket } = require('./briefings-stage2');
|
|
205
|
+
const { buildDebateAddendum } = require('./briefings-debate');
|
|
206
|
+
const packet = buildChairPacket({
|
|
207
|
+
// §4.4: the chair sees Claude's de-anonymized review like any other; it casts
|
|
208
|
+
// no rankings/adjudications, so it appears ONLY as one more review block.
|
|
209
|
+
reviews: reviews.map(r => ({ model: r.model, text: r.text }))
|
|
210
|
+
.concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
|
|
211
|
+
rankings: tallyInput.rankings,
|
|
212
|
+
adjudications: tallyInput.adjudications,
|
|
213
|
+
tierCounts: record.tierCounts, date,
|
|
214
|
+
}) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
|
|
215
|
+
fs.writeFileSync(path.join(runDir, 'chair-packet.md'), packet, { mode: 0o600 });
|
|
216
|
+
return packet;
|
|
217
|
+
}
|
|
218
|
+
|
|
179
219
|
module.exports = {
|
|
180
220
|
buildRunStatsEntry, worseConformance, buildTallyInput, writeTallyFiles, writeVerdictFiles,
|
|
221
|
+
buildChairPacketFile,
|
|
181
222
|
preflightClaudeReview, labelClaudeReview, claudeRunStatsRow, CLAUDE_SEAT,
|
|
182
223
|
};
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
* UNDER-REPORTING, not "continuing in the presence of uncertainty" — and
|
|
25
25
|
* nothing here converts uncertainty into a fabricated number in either
|
|
26
26
|
* direction (no rounding unknown up to a guess, no rounding it down to zero).
|
|
27
|
+
*
|
|
28
|
+
* v4.4.1 CA-6 completes that posture at the EXIT CODE (see inexactUnderCeiling
|
|
29
|
+
* below): a ceiling never blocks, but a run under a ceiling no longer exits 0
|
|
30
|
+
* while publishing a total it knows is only a floor.
|
|
27
31
|
*/
|
|
28
32
|
|
|
29
33
|
const { sumWaveUsage } = require('../utils/pricing');
|
|
@@ -40,7 +44,7 @@ const { sumWaveUsage } = require('../utils/pricing');
|
|
|
40
44
|
* @returns {{spendState:Function, spent:Function, overBudget:Function,
|
|
41
45
|
* remainingBudget:Function, noticeUnknownSpend:Function, usageBlock:Function,
|
|
42
46
|
* addWave:Function, reserveBudget:Function, releaseBudget:Function,
|
|
43
|
-
* noteBudgetRefusal:Function, budgetRefusals:Function}}
|
|
47
|
+
* noteBudgetRefusal:Function, budgetRefusals:Function, inexactUnderCeiling:Function}}
|
|
44
48
|
*/
|
|
45
49
|
function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
|
|
46
50
|
const legs = allLegs || [];
|
|
@@ -155,10 +159,20 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
|
|
|
155
159
|
};
|
|
156
160
|
const budgetRefusals = () => refusals.slice();
|
|
157
161
|
|
|
158
|
-
|
|
162
|
+
// ⚠️ v4.4.1 CA-3: a plain `noticed` boolean announced the FIRST unknown leg and
|
|
163
|
+
// silently swallowed every one created afterwards (Stage 2, repairs, debate,
|
|
164
|
+
// chair) — run.json kept the correct final count, so the data was right and
|
|
165
|
+
// only the announcement was wrong, which is exactly the failure mode the
|
|
166
|
+
// fail-loud posture exists to prevent. Track the count that was last announced
|
|
167
|
+
// instead, so a GROWING total re-announces while an unchanged one stays quiet:
|
|
168
|
+
// the notice keeps its "once per new fact" character without going silent on
|
|
169
|
+
// the later stages. (The alternative — deferring every notice to finalize() —
|
|
170
|
+
// was rejected: the notice exists to inform a decision still in flight.)
|
|
171
|
+
let noticedAt = -1;
|
|
159
172
|
/**
|
|
160
|
-
* One prominent, un-missable notice
|
|
161
|
-
*
|
|
173
|
+
* One prominent, un-missable notice each time the incomplete total GROWS (see
|
|
174
|
+
* `noticedAt` above; re-calling it with nothing new is silent) — for EITHER
|
|
175
|
+
* reason, which are different statements and are worded differently:
|
|
162
176
|
* - `unknownLegs` — the leg reported no usage at all.
|
|
163
177
|
* - `subtreeUnknownLegs` — the leg's own cost is known, but it spawned a
|
|
164
178
|
* subagent whose CHILD session is billed separately and whose spend the
|
|
@@ -174,8 +188,9 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
|
|
|
174
188
|
*/
|
|
175
189
|
const noticeUnknownSpend = () => {
|
|
176
190
|
const s = spendState();
|
|
177
|
-
|
|
178
|
-
|
|
191
|
+
const n = s.unknownLegs + s.subtreeUnknownLegs;
|
|
192
|
+
if (n === 0 || n === noticedAt) { return; }
|
|
193
|
+
noticedAt = n;
|
|
179
194
|
const ceiling = hasCeiling ? ` or the $${maxCost} --max-cost ceiling` : '';
|
|
180
195
|
const parts = [];
|
|
181
196
|
if (s.unknownLegs > 0) {
|
|
@@ -185,10 +200,27 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
|
|
|
185
200
|
parts.push(`${s.subtreeUnknownLegs} council leg(s) spawned a subagent whose CHILD session spend `
|
|
186
201
|
+ 'is billed separately and could NOT be determined');
|
|
187
202
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
203
|
+
// v4.4.1 A3: the counts are CUMULATIVE, and re-announcing a grown total
|
|
204
|
+
// ("1 council leg(s)…" then "4 council leg(s)…") reads as two separate
|
|
205
|
+
// findings that a reader can reasonably add up to five. "so far this run"
|
|
206
|
+
// says once, for both clauses, that each number is a running total.
|
|
207
|
+
emit(`Notice: so far this run, ${parts.join('; and ')} and is NOT included in the `
|
|
208
|
+
+ `$${s.known.toFixed(4)} total${ceiling}. Real spend is HIGHER than reported — this total `
|
|
209
|
+
+ 'is at least, not exactly, what was spent. See run.json usage (unknownLegs / '
|
|
210
|
+
+ 'subtreeUnknownLegs), or `amicus spend --json` (sourceMix.unknown).'
|
|
211
|
+
// v4.4.1 CA-6: with a ceiling set, an inexact total is not a clean run — say
|
|
212
|
+
// so where the uncertainty is announced, so exit 2 is never a surprise.
|
|
213
|
+
//
|
|
214
|
+
// ⚠️ Review F2: hedged to "is on track to" rather than "will". This notice
|
|
215
|
+
// fires from noticeUnknownSpend(), which run.js calls immediately BEFORE
|
|
216
|
+
// the overBudget() check — so a run whose KNOWN spend also crosses the
|
|
217
|
+
// ceiling right here prints this sentence and then exits 1
|
|
218
|
+
// (COST_EXCEEDED), not 2. A signalled run exits 130/143. "Will" would be a
|
|
219
|
+
// guarantee this code cannot make; "is on track to" states the tendency
|
|
220
|
+
// that holds in the common case without promising an outcome decided
|
|
221
|
+
// later, downstream of this call.
|
|
222
|
+
+ (hasCeiling ? ' Because a ceiling is set and this total is inexact, the run is on track '
|
|
223
|
+
+ 'to exit degraded (2); the ceiling itself still never halts a run.' : '') + '\n');
|
|
192
224
|
};
|
|
193
225
|
|
|
194
226
|
/**
|
|
@@ -217,8 +249,29 @@ function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
|
|
|
217
249
|
};
|
|
218
250
|
};
|
|
219
251
|
|
|
252
|
+
/**
|
|
253
|
+
* v4.4.1 CA-6 (OWNER RULING, 2026-07-26). Is a `--max-cost` ceiling in force
|
|
254
|
+
* over a total we already know to be incomplete?
|
|
255
|
+
*
|
|
256
|
+
* `--max-cost` bounds KNOWN spend: an unknown leg contributes nothing to it and
|
|
257
|
+
* never halts a run — see `overBudget` above, which this deliberately does NOT
|
|
258
|
+
* touch, and which must keep ignoring unknown legs. That policy is right and it
|
|
259
|
+
* stays. What was wrong was the REPORT. A run could exit 0 — read by every
|
|
260
|
+
* script and every human as "clean, and inside your ceiling" — while knowingly
|
|
261
|
+
* publishing a floor: `council-wsgate02` really spent $0.9859 against a $0.75
|
|
262
|
+
* ceiling (131%) while amicus believed $0.3720, and exited 0.
|
|
263
|
+
*
|
|
264
|
+
* So the exit code degrades to 2, through the SAME `degraded` channel
|
|
265
|
+
* `noteBudgetRefusal` already uses for a shrunken bench: the run finished, its
|
|
266
|
+
* answer is good, and the cost figure underneath it is not the whole bill.
|
|
267
|
+
* With NO ceiling there is nothing to be inexact against and the exit code is
|
|
268
|
+
* untouched — an unpriced leg on an unbounded run is a fact, not a degradation.
|
|
269
|
+
* @returns {boolean}
|
|
270
|
+
*/
|
|
271
|
+
const inexactUnderCeiling = () => hasCeiling && !usageBlock().costExact;
|
|
272
|
+
|
|
220
273
|
return { spendState, spent, overBudget, remainingBudget, noticeUnknownSpend, usageBlock,
|
|
221
|
-
addWave, reserveBudget, releaseBudget, noteBudgetRefusal, budgetRefusals };
|
|
274
|
+
addWave, reserveBudget, releaseBudget, noteBudgetRefusal, budgetRefusals, inexactUnderCeiling };
|
|
222
275
|
}
|
|
223
276
|
|
|
224
277
|
module.exports = { createBudget };
|
package/src/council/run-chair.js
CHANGED
|
@@ -129,7 +129,10 @@ async function runChair(ctx, { packet, degraded, statsFn, isSignalled }) {
|
|
|
129
129
|
if (chairText && !overallVerdict && !overBudget()) {
|
|
130
130
|
runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
|
|
131
131
|
const repair = await launchers.launchSolo({
|
|
132
|
-
|
|
132
|
+
// ⚠️ LC-12: the synthesis rides along. The chair leg SUCCEEDED — only the
|
|
133
|
+
// VERDICT line is missing — so a fresh repair session that cannot see the
|
|
134
|
+
// synthesis is picking a verdict on an artifact it has never read.
|
|
135
|
+
model: actualChair, prompt: stage2.buildChairRepairPrompt({ synthesis: chairText }),
|
|
133
136
|
project: o.runDir, waveId: `${o.runId}-ch4`,
|
|
134
137
|
timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
|
|
135
138
|
noCostGate: o.noCostGate,
|
|
@@ -95,7 +95,8 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
|
|
|
95
95
|
runState.appendStageWave(ctx.o.runDir, 'debate-defense', repairId);
|
|
96
96
|
const res2 = await ctx.launchers.launchSolo({
|
|
97
97
|
...legOpts(ctx, repairId), model: raiser,
|
|
98
|
-
|
|
98
|
+
// ⚠️ LC-12: a repair solo is a fresh session — the defense that failed rides along.
|
|
99
|
+
prompt: dbrief.buildDefenseRepairPrompt({ errors: parsed.errors, defense: leg.summary }),
|
|
99
100
|
});
|
|
100
101
|
ctx.addWave(res2.wave);
|
|
101
102
|
if (isAbortExit(res2.exitCode)) { return { raiser, aborted: res2.exitCode }; }
|
|
@@ -144,7 +145,8 @@ async function runRevoteWave(ctx, judges, bundleFindings) {
|
|
|
144
145
|
const repairId = `${waveId}-${judge}r`;
|
|
145
146
|
runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
|
|
146
147
|
const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
|
|
147
|
-
|
|
148
|
+
// ⚠️ LC-12: ditto — the re-vote output being repaired rides with its errors.
|
|
149
|
+
prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors, revote: leg.summary }) });
|
|
148
150
|
ctx.addWave(r2.wave);
|
|
149
151
|
if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
|
|
150
152
|
const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/council/run-finalize.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/run-finalize
|
|
6
|
+
* The TERMINAL half of run.js's finalize(): the whole exit-code vocabulary
|
|
7
|
+
* (signal → code, code → status, and the degradation that resolves the run's
|
|
8
|
+
* FINAL code), the last-chance unknown-spend notice, run.json's terminal
|
|
9
|
+
* checkpoint, the run-terminal event and the on-complete hook. Extracted from
|
|
10
|
+
* run.js for the 300-line size gate (v4.4.1 fix wave) — run.js keeps the half
|
|
11
|
+
* that must stay in the closure (uninstalling signals and releasing the run's
|
|
12
|
+
* shared server).
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ BOOKKEEPING MUST NEVER SINK A RUN THAT ALREADY FINISHED. run.js documents
|
|
15
|
+
* "Never rejects for run errors: always resolves {exitCode, run}", but every
|
|
16
|
+
* `return finalize(…)` in it is a bare `return` of a promise — which, by async
|
|
17
|
+
* semantics, does NOT route through the enclosing catch. So a throw from the
|
|
18
|
+
* terminal checkpoint (an unwritable run dir, a full disk) escaped runCouncil as
|
|
19
|
+
* a REJECTION, past its own contract, and past run.js's `catch`. Worse, the
|
|
20
|
+
* throw landed between the server release and the caller, so the caller had no
|
|
21
|
+
* result to act on. Everything here is therefore guarded: a failure is announced
|
|
22
|
+
* on stderr and the run still resolves with a document whose terminal fields are
|
|
23
|
+
* authoritative.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { emitRunTerminal } = require('../observe/events');
|
|
27
|
+
const { fireCouncilOnComplete } = require('../observe/on-complete');
|
|
28
|
+
const runState = require('./run-state');
|
|
29
|
+
|
|
30
|
+
/** Abort signal → the run's exit code. Lives here with the rest of the exit-code
|
|
31
|
+
* vocabulary; re-exported from ./run (its long-standing public home). */
|
|
32
|
+
const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGBREAK: 143 };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* run.json status for a council exit code (spec §4 degradation table).
|
|
36
|
+
* @param {number} code @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function statusForExit(code) {
|
|
39
|
+
return (code === 130 || code === 143) ? 'aborted'
|
|
40
|
+
: code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The run's FINAL exit code, and the ONE place a would-be-clean run degrades.
|
|
45
|
+
*
|
|
46
|
+
* Precedence, highest first:
|
|
47
|
+
* 1. a signal — an aborted run reports how it was killed, nothing else;
|
|
48
|
+
* 2. a non-zero code the driver already decided (1 = error, 2 = degraded) —
|
|
49
|
+
* never re-labelled, because a failure is not a degradation;
|
|
50
|
+
* 3. `degraded.value` — the flag a shrunken bench, a thin cross-review, a dead
|
|
51
|
+
* debate leg and (v4.4.1 CA-6) an inexact total under a `--max-cost`
|
|
52
|
+
* ceiling all already set. 0 becomes 2.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ CA-6 is wired here rather than at the ceiling itself ON PURPOSE. The
|
|
55
|
+
* standing owner ruling — "I don't want hitting a ceiling to stop us from
|
|
56
|
+
* solving real problems" — is absolute: `overBudget()` still trips on KNOWN
|
|
57
|
+
* spend only and a ceiling still never blocks a run. What changes is only what
|
|
58
|
+
* the run CLAIMS on the way out. Exit 0 means "clean, and inside your ceiling";
|
|
59
|
+
* a run that published a total it knows is a floor has not earned that.
|
|
60
|
+
*
|
|
61
|
+
* @param {{signalled: number|null, exitCode: number, degraded?: {value: boolean},
|
|
62
|
+
* inexactUnderCeiling?: () => boolean}} args
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
function resolveTerminalExit({ signalled, exitCode, degraded, inexactUnderCeiling }) {
|
|
66
|
+
if (signalled) { return signalled; }
|
|
67
|
+
if (degraded && inexactUnderCeiling && inexactUnderCeiling()) { degraded.value = true; }
|
|
68
|
+
return (exitCode === 0 && degraded && degraded.value) ? 2 : exitCode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write the run's terminal record and fire its terminal observers.
|
|
73
|
+
*
|
|
74
|
+
* @param {{o: object, code: number, error?: object|null,
|
|
75
|
+
* noticeUnknownSpend: Function, usageBlock: Function,
|
|
76
|
+
* deps?: {fireOnCompleteFn?: Function, write?: Function}}} args
|
|
77
|
+
* @returns {Promise<object>} the run document — always usable, even when the
|
|
78
|
+
* write failed (the on-disk doc merged under the authoritative terminal fields).
|
|
79
|
+
*/
|
|
80
|
+
async function writeRunTerminal({ o, code, error, noticeUnknownSpend, usageBlock, deps = {} }) {
|
|
81
|
+
const status = statusForExit(code);
|
|
82
|
+
const terminal = { status, exitCode: code, error: error || null };
|
|
83
|
+
try {
|
|
84
|
+
noticeUnknownSpend(); // v4.4: never finish a run silently short (run-budget.js)
|
|
85
|
+
const run = runState.checkpoint(o.runDir, {
|
|
86
|
+
...terminal, usage: usageBlock(), completedAt: new Date().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
emitRunTerminal(o.runDir, o.runId, status, code, o.follow);
|
|
89
|
+
await (deps.fireOnCompleteFn || fireCouncilOnComplete)(o.onComplete, run,
|
|
90
|
+
{ runId: o.runId, runDir: o.runDir, exitCode: code, project: o.project }, o.onCompleteDeps);
|
|
91
|
+
return run;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const write = deps.write || ((s) => process.stderr.write(s));
|
|
94
|
+
write(`Notice: council run bookkeeping failed at finalize: ${err.message}. The run itself `
|
|
95
|
+
+ `finished with exit ${code} (${status}); run.json may be incomplete.\n`);
|
|
96
|
+
let onDisk = {};
|
|
97
|
+
try { onDisk = runState.readRun(o.runDir) || {}; } catch { /* unreadable too */ }
|
|
98
|
+
return { runId: o.runId, ...onDisk, ...terminal };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { statusForExit, resolveTerminalExit, writeRunTerminal, SIGNAL_EXIT };
|
|
@@ -17,6 +17,20 @@
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Did a launch exit because a SIGNAL killed it (130 = SIGINT, 143 = SIGTERM)
|
|
22
|
+
* rather than because the work failed? Every stage loop short-circuits on this
|
|
23
|
+
* instead of treating the wave as a normal failure.
|
|
24
|
+
*
|
|
25
|
+
* It lives HERE, with the module that produces those exit codes, because every
|
|
26
|
+
* stage loop needs it — including run-stage2.js. Defining it in run-stages.js and
|
|
27
|
+
* importing it back from its own child made the two mutually circular, which is
|
|
28
|
+
* why runStage2 could not be re-exported from run-stages.js (v4.4.1 review F5).
|
|
29
|
+
* @param {number} code
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function isAbortExit(code) { return code === 130 || code === 143; }
|
|
33
|
+
|
|
20
34
|
/**
|
|
21
35
|
* @param {{fanoutFn?: Function, remainingBudget?: () => number|null,
|
|
22
36
|
* reserveBudget?: (waveId: string, estimate: number) => boolean,
|
|
@@ -32,6 +46,11 @@ const path = require('path');
|
|
|
32
46
|
* synchronously, with the estimate it just computed; see run-budget.js.
|
|
33
47
|
* onBudgetRefusal: notified when the ceiling refuses a wave, so a seat that
|
|
34
48
|
* never launched can never vanish silently.
|
|
49
|
+
* sharedServer (v4.4.1 Task 0.5): a GETTER returning the run's single
|
|
50
|
+
* {serverClient, server} pair, or null. A getter (not a value) because run.js
|
|
51
|
+
* builds the launchers before it acquires the server — see ./run-server for
|
|
52
|
+
* why one server per run, and why `_scratch` isolation survives it. Returning
|
|
53
|
+
* null leaves the transport call byte-identical: the wave owns its own server.
|
|
35
54
|
* @returns {{launchWave: Function, launchSolo: Function}}
|
|
36
55
|
*/
|
|
37
56
|
function createLaunchers(deps = {}) {
|
|
@@ -39,6 +58,7 @@ function createLaunchers(deps = {}) {
|
|
|
39
58
|
const remainingBudget = deps.remainingBudget || null;
|
|
40
59
|
const reserveBudget = deps.reserveBudget || null;
|
|
41
60
|
const onBudgetRefusal = deps.onBudgetRefusal || null;
|
|
61
|
+
const sharedServer = deps.sharedServer || null;
|
|
42
62
|
|
|
43
63
|
/**
|
|
44
64
|
* @param {{models: string[], prompt: string, project: string, waveId: string,
|
|
@@ -61,8 +81,14 @@ function createLaunchers(deps = {}) {
|
|
|
61
81
|
// provider or no ceiling, so the transport call is byte-identical for
|
|
62
82
|
// non-council callers and for `--max-cost`-less runs.
|
|
63
83
|
const remaining = remainingBudget ? remainingBudget() : null;
|
|
84
|
+
// v4.4.1 Task 0.5: every launch in the run rides the SAME OpenCode server.
|
|
85
|
+
// NOT `client` — that key is fanout's client TYPE string; the SDK client is
|
|
86
|
+
// `serverClient` (see the seam comment in fanout.js). Absent → the wave
|
|
87
|
+
// starts and closes its own server, exactly as before.
|
|
88
|
+
const shared = sharedServer ? sharedServer() : null;
|
|
64
89
|
const { wave, exitCode, errorDoc } = await fanoutFn({
|
|
65
90
|
...(typeof remaining === 'number' ? { maxCost: remaining } : {}),
|
|
91
|
+
...(shared ? { serverClient: shared.serverClient, server: shared.server } : {}),
|
|
66
92
|
// v4.4 cost-council finding 1: `maxCost` above is a READ taken before the
|
|
67
93
|
// transport resolved routing; a concurrently launching sibling can claim
|
|
68
94
|
// part of that allowance in the meantime. This is the CLAIM that settles
|
|
@@ -173,4 +199,6 @@ function materializeDebate(runDir, legs, prefix) {
|
|
|
173
199
|
return out;
|
|
174
200
|
}
|
|
175
201
|
|
|
176
|
-
module.exports = {
|
|
202
|
+
module.exports = {
|
|
203
|
+
createLaunchers, materializeReviews, materializeDebate, sanitizeName, isAbortExit,
|
|
204
|
+
};
|