amicus 4.3.0 → 4.4.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 +32 -0
- package/README.md +4 -3
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +27 -0
- package/electron/preload-workspace.js +40 -0
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +101 -0
- package/electron/workspace-ui/md-lite.js +119 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +212 -0
- package/electron/workspace-ui/workspace-panels.js +226 -0
- package/electron/workspace-ui/workspace-render.js +271 -0
- package/electron/workspace-ui/workspace-verbs.js +247 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +14 -0
- package/schemas/progress.schema.json +14 -1
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +32 -5
- package/src/cli-handlers-watch.js +37 -10
- package/src/council/briefings.js +35 -2
- package/src/council/run-budget.js +224 -0
- package/src/council/run-launch.js +44 -6
- package/src/council/run-stages.js +17 -3
- package/src/council/run.js +12 -11
- package/src/headless.js +347 -14
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +183 -0
- package/src/observe/live-doc.js +21 -3
- package/src/observe/watch-render.js +19 -0
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +198 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg.js +23 -1
- package/src/sidecar/fanout.js +4 -11
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +21 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/path-fence.js +82 -0
- package/src/utils/pricing.js +98 -9
- package/src/workspace/artifact-guard.js +187 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +95 -0
- package/src/workspace/live-normalize.js +156 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +223 -0
- package/src/workspace/run-scan.js +148 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// src/council/run-budget.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/run-budget
|
|
6
|
+
* The council driver's budget position (v4.4). Split out of src/council/run.js
|
|
7
|
+
* to stay under the 300-line size gate; it is pure except for the one stderr
|
|
8
|
+
* notice, and every member is injectable/observable so run.js's behaviour is
|
|
9
|
+
* unchanged by the move.
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS EXISTS AS ITS OWN CONCEPT. Before v4.4 the driver had a single
|
|
12
|
+
* `spent()` that mapped a null cost amount to `0` and discarded
|
|
13
|
+
* `sumWaveUsage`'s `unpricedLegs`/`source` entirely. Unknown spend was
|
|
14
|
+
* therefore invisible to `--max-cost` AND to every reader of run.json:
|
|
15
|
+
* `council-wsgate02` really spent $0.9859 against a $0.75 ceiling — a 131%
|
|
16
|
+
* overrun — while `spent()` believed $0.3720 and never emitted COST_EXCEEDED
|
|
17
|
+
* (.superpowers/sdd/v44/zero-usage-diagnosis.md §0/§9).
|
|
18
|
+
*
|
|
19
|
+
* OWNER'S RULING (Christian, v4.4): fail LOUD, not fail CLOSED —
|
|
20
|
+
* "I don't want hitting a ceiling to stop us from solving real problems."
|
|
21
|
+
* So an unknown-cost leg must NOT halt a run and must NOT by itself trip the
|
|
22
|
+
* ceiling; the ceiling trips on KNOWN spend only. The uncertainty is instead
|
|
23
|
+
* made impossible to miss. The failure mode being eliminated is SILENT
|
|
24
|
+
* UNDER-REPORTING, not "continuing in the presence of uncertainty" — and
|
|
25
|
+
* nothing here converts uncertainty into a fabricated number in either
|
|
26
|
+
* direction (no rounding unknown up to a guess, no rounding it down to zero).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const { sumWaveUsage } = require('../utils/pricing');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {Array<object>} [opts.allLegs] live array the driver pushes every wave's legs into
|
|
34
|
+
* @param {number|null|undefined} opts.maxCost the `--max-cost` ceiling, if any
|
|
35
|
+
* @param {string} [opts.runDir] run directory; when given, budget refusals are
|
|
36
|
+
* checkpointed into run.json so the record outlives the stderr notice
|
|
37
|
+
* @param {{value: boolean}} [opts.degraded] the driver's degrade flag; a budget
|
|
38
|
+
* refusal sets it so the run can never exit 0 with a silently shrunken bench
|
|
39
|
+
* @param {(s:string)=>void} [opts.write] stderr writer seam (defaults to process.stderr)
|
|
40
|
+
* @returns {{spendState:Function, spent:Function, overBudget:Function,
|
|
41
|
+
* remainingBudget:Function, noticeUnknownSpend:Function, usageBlock:Function,
|
|
42
|
+
* addWave:Function, reserveBudget:Function, releaseBudget:Function,
|
|
43
|
+
* noteBudgetRefusal:Function, budgetRefusals:Function}}
|
|
44
|
+
*/
|
|
45
|
+
function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
|
|
46
|
+
const legs = allLegs || [];
|
|
47
|
+
const emit = write || ((s) => process.stderr.write(s));
|
|
48
|
+
const hasCeiling = maxCost !== null && maxCost !== undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The run's position, split into what we KNOW was spent and how many legs we
|
|
52
|
+
* cannot price at all. `known` is the sum of resolved amounts only — a leg
|
|
53
|
+
* whose cost is `unknown` contributes nothing, because inventing a number for
|
|
54
|
+
* it would be a fabrication. `unknownLegs` is what makes that omission
|
|
55
|
+
* visible instead of silent: it is the count the run summary, the envelope,
|
|
56
|
+
* `amicus spend` and the GUI all read to say "this total is a floor".
|
|
57
|
+
*/
|
|
58
|
+
const spendState = () => {
|
|
59
|
+
const c = sumWaveUsage(legs).cost;
|
|
60
|
+
return {
|
|
61
|
+
known: typeof c.amount === 'number' ? c.amount : 0,
|
|
62
|
+
unknownLegs: c.unpricedLegs || 0,
|
|
63
|
+
subtreeUnknownLegs: c.subtreeUnknownLegs || 0,
|
|
64
|
+
cost: c,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const spent = () => spendState().known;
|
|
69
|
+
|
|
70
|
+
/** Trips on KNOWN spend only — see the ruling in the module docblock. */
|
|
71
|
+
const overBudget = () => hasCeiling && spent() >= maxCost;
|
|
72
|
+
|
|
73
|
+
// ---- Concurrency-safe reservations (v4.4 cost-council finding 1) ----------
|
|
74
|
+
/**
|
|
75
|
+
* THE DEFECT. `launchStage1()` builds the seat wave and the critic wave
|
|
76
|
+
* concurrently under one `Promise.all`, and each launcher called
|
|
77
|
+
* `remainingBudget()` BEFORE either wave's legs had been appended to `legs`.
|
|
78
|
+
* Both therefore observed the full, unreduced allowance, and both could pass
|
|
79
|
+
* a soft gate that — taken together — exceeded `--max-cost`. Only run.js's
|
|
80
|
+
* post-Stage-1 `overBudget()` noticed, by which point the money was committed.
|
|
81
|
+
* A read is not a claim.
|
|
82
|
+
*
|
|
83
|
+
* THE FIX. `reserveBudget` is a SYNCHRONOUS read-and-claim. Synchronicity is
|
|
84
|
+
* the entire guarantee: the JS event loop cannot interleave two callers inside
|
|
85
|
+
* a synchronous function, so a second concurrent wave necessarily observes the
|
|
86
|
+
* first wave's claim. No mutex, no async barrier, nothing to deadlock.
|
|
87
|
+
*
|
|
88
|
+
* WHY NOT SPLIT THE QUOTA. Handing each concurrent wave a fixed share is also
|
|
89
|
+
* safe but is strictly MORE refusing than the ceiling requires: three cheap
|
|
90
|
+
* seats plus one expensive critic can fit a ceiling that no proportional split
|
|
91
|
+
* admits. The owner's standing ruling is fail LOUD, not fail CLOSED — so the
|
|
92
|
+
* gate must refuse only what genuinely does not fit. First claim wins; a wave
|
|
93
|
+
* refused here does not stop the run (see noteBudgetRefusal).
|
|
94
|
+
*/
|
|
95
|
+
const reservations = new Map(); // waveId -> pre-flight estimate ($)
|
|
96
|
+
const reserved = () => { let t = 0; for (const v of reservations.values()) { t += v; } return t; };
|
|
97
|
+
|
|
98
|
+
/** Ceiling minus known spend MINUS outstanding reservations, floored at 0;
|
|
99
|
+
* null when no ceiling is set. Threaded into each wave's fanout pre-flight
|
|
100
|
+
* estimate as its starting allowance (run-launch.js). */
|
|
101
|
+
const remainingBudget = () => (hasCeiling ? Math.max(maxCost - spent() - reserved(), 0) : null);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Atomically claim `estimate` against the allowance no sibling wave has taken.
|
|
105
|
+
* @returns {boolean} false ONLY when the estimate genuinely does not fit.
|
|
106
|
+
* A $0 / unpriced / non-numeric estimate always fits: unknown cost must
|
|
107
|
+
* never halt a run (the same ruling that keeps it out of `overBudget`).
|
|
108
|
+
*/
|
|
109
|
+
const reserveBudget = (waveId, estimate) => {
|
|
110
|
+
if (!hasCeiling) { return true; }
|
|
111
|
+
const est = (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) ? estimate : 0;
|
|
112
|
+
if (est > remainingBudget()) { return false; }
|
|
113
|
+
reservations.set(waveId, (reservations.get(waveId) || 0) + est);
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Drop a wave's outstanding claim (its real spend now speaks for it). */
|
|
118
|
+
const releaseBudget = (waveId) => { reservations.delete(waveId); };
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Record a finished wave: its ESTIMATE stops counting and its MEASURED legs
|
|
122
|
+
* start counting, in one synchronous step so no concurrent launcher can ever
|
|
123
|
+
* observe a moment where the wave counts twice or not at all.
|
|
124
|
+
*/
|
|
125
|
+
const addWave = (wave) => {
|
|
126
|
+
if (!wave) { return; }
|
|
127
|
+
if (wave.waveId) { releaseBudget(wave.waveId); }
|
|
128
|
+
if (Array.isArray(wave.legs)) { legs.push(...wave.legs); }
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A wave the ceiling refused. POLICY (the owner's ruling applied to
|
|
133
|
+
* concurrency): the run CONTINUES with a partial bench — it never rolls back
|
|
134
|
+
* waves already launched (that would destroy paid work) and never aborts
|
|
135
|
+
* (that is fail-closed). What it must never do is lose the seat SILENTLY, so
|
|
136
|
+
* every refusal is announced on stderr, kept on run.json, and degrades the
|
|
137
|
+
* run's exit code. Stage 1's existing quorum gate still refuses to call a
|
|
138
|
+
* bench of fewer than two reviews a council.
|
|
139
|
+
*/
|
|
140
|
+
const refusals = [];
|
|
141
|
+
const noteBudgetRefusal = (info) => {
|
|
142
|
+
const rec = { waveId: (info && info.waveId) || null, models: (info && info.models) || [],
|
|
143
|
+
reason: 'max-cost', at: new Date().toISOString() };
|
|
144
|
+
refusals.push(rec);
|
|
145
|
+
if (degraded) { degraded.value = true; }
|
|
146
|
+
emit(`Notice: the $${maxCost} --max-cost ceiling refused wave ${rec.waveId} `
|
|
147
|
+
+ `(${rec.models.join(', ') || 'no models'}) — those seats DID NOT LAUNCH and are missing from `
|
|
148
|
+
+ 'this council. The run continues with the bench that did launch and will exit degraded (2). '
|
|
149
|
+
+ 'Raise --max-cost, or pass --no-cost-gate, to seat them.\n');
|
|
150
|
+
if (runDir) {
|
|
151
|
+
// Never let bookkeeping sink a run that is otherwise fine.
|
|
152
|
+
try { require('./run-state').checkpoint(runDir, { budgetRefusals: refusals.slice() }); }
|
|
153
|
+
catch (e) { emit(`Notice: could not record the budget refusal in run.json: ${e.message}\n`); }
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const budgetRefusals = () => refusals.slice();
|
|
157
|
+
|
|
158
|
+
let noticed = false;
|
|
159
|
+
/**
|
|
160
|
+
* One prominent, un-missable notice per run when the total is incomplete — for
|
|
161
|
+
* EITHER reason, which are different statements and are worded differently:
|
|
162
|
+
* - `unknownLegs` — the leg reported no usage at all.
|
|
163
|
+
* - `subtreeUnknownLegs` — the leg's own cost is known, but it spawned a
|
|
164
|
+
* subagent whose CHILD session is billed separately and whose spend the
|
|
165
|
+
* walk could NOT account for. This is the one that made
|
|
166
|
+
* `council-wsgate01` report `costExact: true` while $0.0215 short — 100%
|
|
167
|
+
* of that gap was one `explore` child session.
|
|
168
|
+
*
|
|
169
|
+
* v4.4.1 CA-1: child sessions are now enumerated and their measured spend IS
|
|
170
|
+
* attributed (`cost.subtreeCost`), so this second bucket has narrowed to the
|
|
171
|
+
* subtrees the walk genuinely could not price. It is deliberately still a
|
|
172
|
+
* separate statement from an unpriced leg: "we could not see this leg at all"
|
|
173
|
+
* and "we saw this leg but not what it spawned" are different facts.
|
|
174
|
+
*/
|
|
175
|
+
const noticeUnknownSpend = () => {
|
|
176
|
+
const s = spendState();
|
|
177
|
+
if ((s.unknownLegs === 0 && s.subtreeUnknownLegs === 0) || noticed) { return; }
|
|
178
|
+
noticed = true;
|
|
179
|
+
const ceiling = hasCeiling ? ` or the $${maxCost} --max-cost ceiling` : '';
|
|
180
|
+
const parts = [];
|
|
181
|
+
if (s.unknownLegs > 0) {
|
|
182
|
+
parts.push(`${s.unknownLegs} council leg(s) reported NO usage — their cost is UNKNOWN`);
|
|
183
|
+
}
|
|
184
|
+
if (s.subtreeUnknownLegs > 0) {
|
|
185
|
+
parts.push(`${s.subtreeUnknownLegs} council leg(s) spawned a subagent whose CHILD session spend `
|
|
186
|
+
+ 'is billed separately and could NOT be determined');
|
|
187
|
+
}
|
|
188
|
+
emit(`Notice: ${parts.join('; and ')} and is NOT included in the $${s.known.toFixed(4)} `
|
|
189
|
+
+ `total${ceiling}. Real spend is HIGHER than reported — this total is at least, not exactly, `
|
|
190
|
+
+ 'what was spent. See run.json usage (unknownLegs / subtreeUnknownLegs), or '
|
|
191
|
+
+ '`amicus spend --json` (sourceMix.unknown).\n');
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The run summary's `usage` block. `costExact`/`unknownLegs` sit at the TOP of
|
|
196
|
+
* it on purpose: `cost.unpricedLegs` always carried the count, but every reader
|
|
197
|
+
* of `usage` looked one level up and saw only a number that read as
|
|
198
|
+
* authoritative — the silent under-report the diagnosis measured at 62.3% on
|
|
199
|
+
* council-wsgate02. Consumers: src/workspace/run-detail.js costPanel (the GUI
|
|
200
|
+
* gauge + total), src/cli-handlers-council-run.js renderRunHuman, and the
|
|
201
|
+
* `--json` manifest, which emits run.json verbatim.
|
|
202
|
+
*/
|
|
203
|
+
const usageBlock = () => {
|
|
204
|
+
const s = spendState();
|
|
205
|
+
return {
|
|
206
|
+
cost: s.cost,
|
|
207
|
+
unknownLegs: s.unknownLegs,
|
|
208
|
+
subtreeUnknownLegs: s.subtreeUnknownLegs,
|
|
209
|
+
// v4.4 Task 2: `costExact` used to be `unknownLegs === 0`, which asks "did
|
|
210
|
+
// every leg report tokens" — a statement about observation coverage of each
|
|
211
|
+
// leg's OWN session, NOT about whether the total is complete. That is how
|
|
212
|
+
// `council-wsgate01` asserted exactness while $0.0215 short: all 7 legs were
|
|
213
|
+
// `source: 'reported'`, and 100% of the gap was one unattributed `explore`
|
|
214
|
+
// child session. costExact must mean "this is the whole bill", so it now
|
|
215
|
+
// requires BOTH: every leg observed, AND no leg with an unattributed subtree.
|
|
216
|
+
costExact: s.unknownLegs === 0 && s.subtreeUnknownLegs === 0,
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return { spendState, spent, overBudget, remainingBudget, noticeUnknownSpend, usageBlock,
|
|
221
|
+
addWave, reserveBudget, releaseBudget, noteBudgetRefusal, budgetRefusals };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { createBudget };
|
|
@@ -18,11 +18,27 @@ const fs = require('fs');
|
|
|
18
18
|
const path = require('path');
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* @param {{fanoutFn?: Function
|
|
21
|
+
* @param {{fanoutFn?: Function, remainingBudget?: () => number|null,
|
|
22
|
+
* reserveBudget?: (waveId: string, estimate: number) => boolean,
|
|
23
|
+
* onBudgetRefusal?: (info: {waveId, models, message}) => void}} [deps]
|
|
24
|
+
* fanoutFn: test seam; default = real runFanout.
|
|
25
|
+
* remainingBudget (v4.4): supplies the council's REMAINING `--max-cost`
|
|
26
|
+
* allowance (ceiling − known spend − outstanding reservations) at launch time,
|
|
27
|
+
* threaded into the fanout pre-flight estimate gate. Omitted (or returning
|
|
28
|
+
* null) leaves `maxCost` off the transport call entirely, exactly as before.
|
|
29
|
+
* reserveBudget (v4.4 cost-council finding 1): the ATOMIC claim. Reading the
|
|
30
|
+
* remaining allowance is not enough when two waves launch concurrently — both
|
|
31
|
+
* read the same unreduced number and both pass. The transport calls this once,
|
|
32
|
+
* synchronously, with the estimate it just computed; see run-budget.js.
|
|
33
|
+
* onBudgetRefusal: notified when the ceiling refuses a wave, so a seat that
|
|
34
|
+
* never launched can never vanish silently.
|
|
22
35
|
* @returns {{launchWave: Function, launchSolo: Function}}
|
|
23
36
|
*/
|
|
24
37
|
function createLaunchers(deps = {}) {
|
|
25
38
|
const fanoutFn = deps.fanoutFn || require('../sidecar/fanout').runFanout;
|
|
39
|
+
const remainingBudget = deps.remainingBudget || null;
|
|
40
|
+
const reserveBudget = deps.reserveBudget || null;
|
|
41
|
+
const onBudgetRefusal = deps.onBudgetRefusal || null;
|
|
26
42
|
|
|
27
43
|
/**
|
|
28
44
|
* @param {{models: string[], prompt: string, project: string, waveId: string,
|
|
@@ -37,7 +53,21 @@ function createLaunchers(deps = {}) {
|
|
|
37
53
|
*/
|
|
38
54
|
async function launchWave(opts) {
|
|
39
55
|
fs.mkdirSync(opts.project, { recursive: true });
|
|
40
|
-
|
|
56
|
+
// v4.4: arm fanout's SOFT total-$ ceiling with the council's remaining
|
|
57
|
+
// allowance. Previously omitted, so fanout fell back to `cfg.maxCost` (a key
|
|
58
|
+
// src/utils/config.js never defines) and the pre-flight estimate gate was
|
|
59
|
+
// inert for every council run — run.js's post-hoc check was the only ceiling,
|
|
60
|
+
// and it can only refuse after the money is gone. Left OFF when there is no
|
|
61
|
+
// provider or no ceiling, so the transport call is byte-identical for
|
|
62
|
+
// non-council callers and for `--max-cost`-less runs.
|
|
63
|
+
const remaining = remainingBudget ? remainingBudget() : null;
|
|
64
|
+
const { wave, exitCode, errorDoc } = await fanoutFn({
|
|
65
|
+
...(typeof remaining === 'number' ? { maxCost: remaining } : {}),
|
|
66
|
+
// v4.4 cost-council finding 1: `maxCost` above is a READ taken before the
|
|
67
|
+
// transport resolved routing; a concurrently launching sibling can claim
|
|
68
|
+
// part of that allowance in the meantime. This is the CLAIM that settles
|
|
69
|
+
// it — synchronous by contract, so two callers can never interleave.
|
|
70
|
+
...(reserveBudget ? { reserveBudget: (est) => reserveBudget(opts.waveId, est) } : {}),
|
|
41
71
|
models: opts.models.join(','),
|
|
42
72
|
prompt: opts.prompt,
|
|
43
73
|
promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
|
|
@@ -73,17 +103,25 @@ function createLaunchers(deps = {}) {
|
|
|
73
103
|
directory: opts.project,
|
|
74
104
|
noMcp: true,
|
|
75
105
|
});
|
|
76
|
-
|
|
106
|
+
// A ceiling refusal returns `wave: null`, which the council driver's
|
|
107
|
+
// addWave() treats as a no-op — so before v4.4 the seats simply vanished
|
|
108
|
+
// from the bench with nothing on stdout, stderr or run.json to say so. A
|
|
109
|
+
// partial bench is an acceptable outcome; an UNANNOUNCED one is not.
|
|
110
|
+
if (onBudgetRefusal && errorDoc && errorDoc.code === 'BUDGET_EXCEEDED') {
|
|
111
|
+
onBudgetRefusal({ waveId: opts.waveId, models: opts.models.slice(), message: errorDoc.message });
|
|
112
|
+
}
|
|
113
|
+
return { wave, exitCode, errorDoc: errorDoc || null };
|
|
77
114
|
}
|
|
78
115
|
|
|
79
116
|
/**
|
|
80
117
|
* One-model launch (critic/lens legs, repairs, the chair) as a 1-leg wave.
|
|
81
|
-
* @returns {Promise<{wave: object|null, exitCode: number, leg: object|null
|
|
118
|
+
* @returns {Promise<{wave: object|null, exitCode: number, leg: object|null,
|
|
119
|
+
* errorDoc: object|null}>}
|
|
82
120
|
*/
|
|
83
121
|
async function launchSolo(opts) {
|
|
84
|
-
const { wave, exitCode } = await launchWave({ ...opts, models: [opts.model] });
|
|
122
|
+
const { wave, exitCode, errorDoc } = await launchWave({ ...opts, models: [opts.model] });
|
|
85
123
|
const leg = (wave && Array.isArray(wave.legs) && wave.legs[0]) || null;
|
|
86
|
-
return { wave, exitCode, leg };
|
|
124
|
+
return { wave, exitCode, leg, errorDoc };
|
|
87
125
|
}
|
|
88
126
|
|
|
89
127
|
return { launchWave, launchSolo };
|
|
@@ -119,13 +119,25 @@ async function runStage1(ctx) {
|
|
|
119
119
|
let conformance = 'clean';
|
|
120
120
|
let res = validateFindings(m.text);
|
|
121
121
|
let attempts = 0;
|
|
122
|
+
// ⚠️ LC-6: the text the repair prompt must carry. A repair solo is a FRESH
|
|
123
|
+
// session — it has no memory of the review turn — so shipping only
|
|
124
|
+
// res.errors asked the model to correct something it had never seen. Two
|
|
125
|
+
// paid models refused ("I don't have a previous review to correct") and one
|
|
126
|
+
// fabricated a finding, which reached tally.json and the chair's verdict.
|
|
127
|
+
// Tracked rather than pinned to m.text so `repairing` and `res.errors`
|
|
128
|
+
// always describe the SAME artifact: on attempt 2 the errors came from
|
|
129
|
+
// validating attempt 1's output, so attempt 1's output is what is being
|
|
130
|
+
// repaired. An empty/dead repair leg leaves it on the last real text
|
|
131
|
+
// (there is no newer artifact to name).
|
|
132
|
+
let repairing = m.text;
|
|
122
133
|
while (!res.ok && attempts < 2 && !ctx.overBudget()) {
|
|
123
134
|
attempts += 1;
|
|
124
135
|
repairSeq += 1;
|
|
125
136
|
const waveId = `${o.runId}-p${repairSeq}`;
|
|
126
137
|
runState.appendStageWave(o.runDir, 'stage1', waveId);
|
|
127
138
|
const solo = await ctx.launchers.launchSolo({
|
|
128
|
-
model: m.modelInput,
|
|
139
|
+
model: m.modelInput,
|
|
140
|
+
prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors, review: repairing }),
|
|
129
141
|
project: o.runDir, waveId, timeout: o.timeout,
|
|
130
142
|
gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
|
|
131
143
|
councilRunId: o.runId, councilName: o.councilName,
|
|
@@ -133,7 +145,9 @@ async function runStage1(ctx) {
|
|
|
133
145
|
});
|
|
134
146
|
ctx.addWave(solo.wave);
|
|
135
147
|
if (isAbortExit(solo.exitCode)) { return { aborted: solo.exitCode, reviews, deadLegs }; }
|
|
136
|
-
|
|
148
|
+
const repaired = (solo.leg && solo.leg.summary) || '';
|
|
149
|
+
if (repaired.trim()) { repairing = repaired; }
|
|
150
|
+
res = validateFindings(repaired);
|
|
137
151
|
if (res.ok) { conformance = 'repaired'; }
|
|
138
152
|
}
|
|
139
153
|
if (!res.ok) { conformance = 'unstructured'; }
|
|
@@ -225,4 +239,4 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
|
|
|
225
239
|
return { aborted: null, judgeResults };
|
|
226
240
|
}
|
|
227
241
|
|
|
228
|
-
module.exports = { runStage1, runStage2, isAbortExit, slug };
|
|
242
|
+
module.exports = { runStage1, runStage2, isAbortExit, slug, roleFor };
|
package/src/council/run.js
CHANGED
|
@@ -31,7 +31,7 @@ const runDebateMod = require('./run-debate');
|
|
|
31
31
|
const { buildDebateAddendum } = require('./briefings-debate');
|
|
32
32
|
const { decorateRecord } = require('./debate');
|
|
33
33
|
const asm = require('./run-assemble');
|
|
34
|
-
const {
|
|
34
|
+
const { createBudget } = require('./run-budget');
|
|
35
35
|
const { emitRunStarted, emitRunTerminal, emitStageStarted, emitStageTerminal } = require('../observe/events');
|
|
36
36
|
const { fireCouncilOnComplete } = require('../observe/on-complete');
|
|
37
37
|
|
|
@@ -52,20 +52,20 @@ async function runCouncil(options, deps = {}) {
|
|
|
52
52
|
const o = { critic: null, lenses: null, maxCost: null, debate: false, claudeReviewFile: null,
|
|
53
53
|
noCostGate: false, councilName: null, ...options };
|
|
54
54
|
o.follow = o.follow ? require('../observe/follow').createFollowPrinter({ json: o.json }) : null; // Task 13: stderr mirror
|
|
55
|
-
const launchers = deps.launchers || createLaunchers();
|
|
56
55
|
const appendRunFn = deps.appendRunFn || require('./ledger').appendRun;
|
|
57
56
|
const statsFn = deps.statsFn || require('./ledger').deriveReliability;
|
|
58
57
|
const installSignals = deps.installSignalAbortFn
|
|
59
58
|
|| require('../utils/session-abort').installSignalAbort;
|
|
60
59
|
const now = () => new Date().toISOString();
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
// v4.4: the whole budget position lives in ./run-budget — its docblock carries the "fail LOUD,
|
|
62
|
+
// not CLOSED" ruling, why reserveBudget (not merely remainingBudget) is what holds the ceiling
|
|
63
|
+
// across Stage-1's CONCURRENT launches, why addWave must release-and-account atomically, and
|
|
64
|
+
// why a refused wave sets `degraded` (a shrunken bench never exits 0) rather than aborting.
|
|
65
|
+
const degraded = { value: false };
|
|
66
|
+
const { addWave, overBudget, remainingBudget, noticeUnknownSpend, usageBlock, reserveBudget,
|
|
67
|
+
noteBudgetRefusal } = createBudget({ maxCost: o.maxCost, runDir: o.runDir, degraded });
|
|
68
|
+
const launchers = deps.launchers || createLaunchers({ remainingBudget, reserveBudget, onBudgetRefusal: noteBudgetRefusal });
|
|
69
69
|
|
|
70
70
|
runState.initRun(o.runDir, {
|
|
71
71
|
schemaVersion: 2, type: 'council-run', runId: o.runId, status: 'running', stages: [],
|
|
@@ -89,15 +89,15 @@ async function runCouncil(options, deps = {}) {
|
|
|
89
89
|
},
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
const degraded = { value: false };
|
|
93
92
|
const finalize = async (exitCode, error) => {
|
|
94
93
|
uninstall();
|
|
95
94
|
const code = signalled || exitCode;
|
|
96
95
|
const status = (code === 130 || code === 143) ? 'aborted'
|
|
97
96
|
: code === 0 ? 'complete' : code === 1 ? 'error' : 'partial';
|
|
97
|
+
noticeUnknownSpend(); // v4.4: never finish a run silently short (run-budget.js)
|
|
98
98
|
const run = runState.checkpoint(o.runDir, {
|
|
99
99
|
status, exitCode: code, error: error || null,
|
|
100
|
-
usage:
|
|
100
|
+
usage: usageBlock(),
|
|
101
101
|
completedAt: now(),
|
|
102
102
|
});
|
|
103
103
|
emitRunTerminal(o.runDir, o.runId, status, code, o.follow);
|
|
@@ -144,6 +144,7 @@ async function runCouncil(options, deps = {}) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// ---- Cost gate: Stage 2 is a paid launch; no tally exists yet (spec §4) ----
|
|
147
|
+
noticeUnknownSpend(); // v4.4: warn EARLY on a long run, not only at finalize
|
|
147
148
|
if (overBudget()) {
|
|
148
149
|
return finalize(1, {
|
|
149
150
|
code: 'COST_EXCEEDED',
|