amicus 4.3.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 +64 -0
- package/README.md +6 -3
- 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/ipc-workspace.js +283 -0
- package/electron/main.js +31 -1
- package/electron/preload-workspace.js +40 -0
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +112 -0
- package/electron/workspace-ui/md-lite.js +163 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +249 -0
- package/electron/workspace-ui/workspace-panels.js +237 -0
- package/electron/workspace-ui/workspace-render.js +277 -0
- package/electron/workspace-ui/workspace-verbs.js +293 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/progress.schema.json +26 -1
- package/schemas/spend.schema.json +52 -4
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +50 -5
- package/src/cli-handlers-watch.js +48 -10
- 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 +59 -3
- 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 +277 -0
- 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 +73 -7
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +148 -113
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +52 -53
- package/src/council/tally.js +10 -0
- package/src/headless.js +519 -17
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +240 -0
- package/src/observe/live-doc.js +39 -4
- package/src/observe/watch-render.js +23 -1
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +197 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +29 -1
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +65 -81
- 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/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +33 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +120 -0
- package/src/utils/pricing.js +114 -9
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +208 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +124 -0
- package/src/workspace/live-normalize.js +169 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +229 -0
- package/src/workspace/run-scan.js +148 -0
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
* 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.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const { sumWaveUsage } = require('../utils/pricing');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {Array<object>} [opts.allLegs] live array the driver pushes every wave's legs into
|
|
38
|
+
* @param {number|null|undefined} opts.maxCost the `--max-cost` ceiling, if any
|
|
39
|
+
* @param {string} [opts.runDir] run directory; when given, budget refusals are
|
|
40
|
+
* checkpointed into run.json so the record outlives the stderr notice
|
|
41
|
+
* @param {{value: boolean}} [opts.degraded] the driver's degrade flag; a budget
|
|
42
|
+
* refusal sets it so the run can never exit 0 with a silently shrunken bench
|
|
43
|
+
* @param {(s:string)=>void} [opts.write] stderr writer seam (defaults to process.stderr)
|
|
44
|
+
* @returns {{spendState:Function, spent:Function, overBudget:Function,
|
|
45
|
+
* remainingBudget:Function, noticeUnknownSpend:Function, usageBlock:Function,
|
|
46
|
+
* addWave:Function, reserveBudget:Function, releaseBudget:Function,
|
|
47
|
+
* noteBudgetRefusal:Function, budgetRefusals:Function, inexactUnderCeiling:Function}}
|
|
48
|
+
*/
|
|
49
|
+
function createBudget({ allLegs, maxCost, runDir, degraded, write }) {
|
|
50
|
+
const legs = allLegs || [];
|
|
51
|
+
const emit = write || ((s) => process.stderr.write(s));
|
|
52
|
+
const hasCeiling = maxCost !== null && maxCost !== undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The run's position, split into what we KNOW was spent and how many legs we
|
|
56
|
+
* cannot price at all. `known` is the sum of resolved amounts only — a leg
|
|
57
|
+
* whose cost is `unknown` contributes nothing, because inventing a number for
|
|
58
|
+
* it would be a fabrication. `unknownLegs` is what makes that omission
|
|
59
|
+
* visible instead of silent: it is the count the run summary, the envelope,
|
|
60
|
+
* `amicus spend` and the GUI all read to say "this total is a floor".
|
|
61
|
+
*/
|
|
62
|
+
const spendState = () => {
|
|
63
|
+
const c = sumWaveUsage(legs).cost;
|
|
64
|
+
return {
|
|
65
|
+
known: typeof c.amount === 'number' ? c.amount : 0,
|
|
66
|
+
unknownLegs: c.unpricedLegs || 0,
|
|
67
|
+
subtreeUnknownLegs: c.subtreeUnknownLegs || 0,
|
|
68
|
+
cost: c,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const spent = () => spendState().known;
|
|
73
|
+
|
|
74
|
+
/** Trips on KNOWN spend only — see the ruling in the module docblock. */
|
|
75
|
+
const overBudget = () => hasCeiling && spent() >= maxCost;
|
|
76
|
+
|
|
77
|
+
// ---- Concurrency-safe reservations (v4.4 cost-council finding 1) ----------
|
|
78
|
+
/**
|
|
79
|
+
* THE DEFECT. `launchStage1()` builds the seat wave and the critic wave
|
|
80
|
+
* concurrently under one `Promise.all`, and each launcher called
|
|
81
|
+
* `remainingBudget()` BEFORE either wave's legs had been appended to `legs`.
|
|
82
|
+
* Both therefore observed the full, unreduced allowance, and both could pass
|
|
83
|
+
* a soft gate that — taken together — exceeded `--max-cost`. Only run.js's
|
|
84
|
+
* post-Stage-1 `overBudget()` noticed, by which point the money was committed.
|
|
85
|
+
* A read is not a claim.
|
|
86
|
+
*
|
|
87
|
+
* THE FIX. `reserveBudget` is a SYNCHRONOUS read-and-claim. Synchronicity is
|
|
88
|
+
* the entire guarantee: the JS event loop cannot interleave two callers inside
|
|
89
|
+
* a synchronous function, so a second concurrent wave necessarily observes the
|
|
90
|
+
* first wave's claim. No mutex, no async barrier, nothing to deadlock.
|
|
91
|
+
*
|
|
92
|
+
* WHY NOT SPLIT THE QUOTA. Handing each concurrent wave a fixed share is also
|
|
93
|
+
* safe but is strictly MORE refusing than the ceiling requires: three cheap
|
|
94
|
+
* seats plus one expensive critic can fit a ceiling that no proportional split
|
|
95
|
+
* admits. The owner's standing ruling is fail LOUD, not fail CLOSED — so the
|
|
96
|
+
* gate must refuse only what genuinely does not fit. First claim wins; a wave
|
|
97
|
+
* refused here does not stop the run (see noteBudgetRefusal).
|
|
98
|
+
*/
|
|
99
|
+
const reservations = new Map(); // waveId -> pre-flight estimate ($)
|
|
100
|
+
const reserved = () => { let t = 0; for (const v of reservations.values()) { t += v; } return t; };
|
|
101
|
+
|
|
102
|
+
/** Ceiling minus known spend MINUS outstanding reservations, floored at 0;
|
|
103
|
+
* null when no ceiling is set. Threaded into each wave's fanout pre-flight
|
|
104
|
+
* estimate as its starting allowance (run-launch.js). */
|
|
105
|
+
const remainingBudget = () => (hasCeiling ? Math.max(maxCost - spent() - reserved(), 0) : null);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Atomically claim `estimate` against the allowance no sibling wave has taken.
|
|
109
|
+
* @returns {boolean} false ONLY when the estimate genuinely does not fit.
|
|
110
|
+
* A $0 / unpriced / non-numeric estimate always fits: unknown cost must
|
|
111
|
+
* never halt a run (the same ruling that keeps it out of `overBudget`).
|
|
112
|
+
*/
|
|
113
|
+
const reserveBudget = (waveId, estimate) => {
|
|
114
|
+
if (!hasCeiling) { return true; }
|
|
115
|
+
const est = (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) ? estimate : 0;
|
|
116
|
+
if (est > remainingBudget()) { return false; }
|
|
117
|
+
reservations.set(waveId, (reservations.get(waveId) || 0) + est);
|
|
118
|
+
return true;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Drop a wave's outstanding claim (its real spend now speaks for it). */
|
|
122
|
+
const releaseBudget = (waveId) => { reservations.delete(waveId); };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Record a finished wave: its ESTIMATE stops counting and its MEASURED legs
|
|
126
|
+
* start counting, in one synchronous step so no concurrent launcher can ever
|
|
127
|
+
* observe a moment where the wave counts twice or not at all.
|
|
128
|
+
*/
|
|
129
|
+
const addWave = (wave) => {
|
|
130
|
+
if (!wave) { return; }
|
|
131
|
+
if (wave.waveId) { releaseBudget(wave.waveId); }
|
|
132
|
+
if (Array.isArray(wave.legs)) { legs.push(...wave.legs); }
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A wave the ceiling refused. POLICY (the owner's ruling applied to
|
|
137
|
+
* concurrency): the run CONTINUES with a partial bench — it never rolls back
|
|
138
|
+
* waves already launched (that would destroy paid work) and never aborts
|
|
139
|
+
* (that is fail-closed). What it must never do is lose the seat SILENTLY, so
|
|
140
|
+
* every refusal is announced on stderr, kept on run.json, and degrades the
|
|
141
|
+
* run's exit code. Stage 1's existing quorum gate still refuses to call a
|
|
142
|
+
* bench of fewer than two reviews a council.
|
|
143
|
+
*/
|
|
144
|
+
const refusals = [];
|
|
145
|
+
const noteBudgetRefusal = (info) => {
|
|
146
|
+
const rec = { waveId: (info && info.waveId) || null, models: (info && info.models) || [],
|
|
147
|
+
reason: 'max-cost', at: new Date().toISOString() };
|
|
148
|
+
refusals.push(rec);
|
|
149
|
+
if (degraded) { degraded.value = true; }
|
|
150
|
+
emit(`Notice: the $${maxCost} --max-cost ceiling refused wave ${rec.waveId} `
|
|
151
|
+
+ `(${rec.models.join(', ') || 'no models'}) — those seats DID NOT LAUNCH and are missing from `
|
|
152
|
+
+ 'this council. The run continues with the bench that did launch and will exit degraded (2). '
|
|
153
|
+
+ 'Raise --max-cost, or pass --no-cost-gate, to seat them.\n');
|
|
154
|
+
if (runDir) {
|
|
155
|
+
// Never let bookkeeping sink a run that is otherwise fine.
|
|
156
|
+
try { require('./run-state').checkpoint(runDir, { budgetRefusals: refusals.slice() }); }
|
|
157
|
+
catch (e) { emit(`Notice: could not record the budget refusal in run.json: ${e.message}\n`); }
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const budgetRefusals = () => refusals.slice();
|
|
161
|
+
|
|
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;
|
|
172
|
+
/**
|
|
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:
|
|
176
|
+
* - `unknownLegs` — the leg reported no usage at all.
|
|
177
|
+
* - `subtreeUnknownLegs` — the leg's own cost is known, but it spawned a
|
|
178
|
+
* subagent whose CHILD session is billed separately and whose spend the
|
|
179
|
+
* walk could NOT account for. This is the one that made
|
|
180
|
+
* `council-wsgate01` report `costExact: true` while $0.0215 short — 100%
|
|
181
|
+
* of that gap was one `explore` child session.
|
|
182
|
+
*
|
|
183
|
+
* v4.4.1 CA-1: child sessions are now enumerated and their measured spend IS
|
|
184
|
+
* attributed (`cost.subtreeCost`), so this second bucket has narrowed to the
|
|
185
|
+
* subtrees the walk genuinely could not price. It is deliberately still a
|
|
186
|
+
* separate statement from an unpriced leg: "we could not see this leg at all"
|
|
187
|
+
* and "we saw this leg but not what it spawned" are different facts.
|
|
188
|
+
*/
|
|
189
|
+
const noticeUnknownSpend = () => {
|
|
190
|
+
const s = spendState();
|
|
191
|
+
const n = s.unknownLegs + s.subtreeUnknownLegs;
|
|
192
|
+
if (n === 0 || n === noticedAt) { return; }
|
|
193
|
+
noticedAt = n;
|
|
194
|
+
const ceiling = hasCeiling ? ` or the $${maxCost} --max-cost ceiling` : '';
|
|
195
|
+
const parts = [];
|
|
196
|
+
if (s.unknownLegs > 0) {
|
|
197
|
+
parts.push(`${s.unknownLegs} council leg(s) reported NO usage — their cost is UNKNOWN`);
|
|
198
|
+
}
|
|
199
|
+
if (s.subtreeUnknownLegs > 0) {
|
|
200
|
+
parts.push(`${s.subtreeUnknownLegs} council leg(s) spawned a subagent whose CHILD session spend `
|
|
201
|
+
+ 'is billed separately and could NOT be determined');
|
|
202
|
+
}
|
|
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');
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The run summary's `usage` block. `costExact`/`unknownLegs` sit at the TOP of
|
|
228
|
+
* it on purpose: `cost.unpricedLegs` always carried the count, but every reader
|
|
229
|
+
* of `usage` looked one level up and saw only a number that read as
|
|
230
|
+
* authoritative — the silent under-report the diagnosis measured at 62.3% on
|
|
231
|
+
* council-wsgate02. Consumers: src/workspace/run-detail.js costPanel (the GUI
|
|
232
|
+
* gauge + total), src/cli-handlers-council-run.js renderRunHuman, and the
|
|
233
|
+
* `--json` manifest, which emits run.json verbatim.
|
|
234
|
+
*/
|
|
235
|
+
const usageBlock = () => {
|
|
236
|
+
const s = spendState();
|
|
237
|
+
return {
|
|
238
|
+
cost: s.cost,
|
|
239
|
+
unknownLegs: s.unknownLegs,
|
|
240
|
+
subtreeUnknownLegs: s.subtreeUnknownLegs,
|
|
241
|
+
// v4.4 Task 2: `costExact` used to be `unknownLegs === 0`, which asks "did
|
|
242
|
+
// every leg report tokens" — a statement about observation coverage of each
|
|
243
|
+
// leg's OWN session, NOT about whether the total is complete. That is how
|
|
244
|
+
// `council-wsgate01` asserted exactness while $0.0215 short: all 7 legs were
|
|
245
|
+
// `source: 'reported'`, and 100% of the gap was one unattributed `explore`
|
|
246
|
+
// child session. costExact must mean "this is the whole bill", so it now
|
|
247
|
+
// requires BOTH: every leg observed, AND no leg with an unattributed subtree.
|
|
248
|
+
costExact: s.unknownLegs === 0 && s.subtreeUnknownLegs === 0,
|
|
249
|
+
};
|
|
250
|
+
};
|
|
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
|
+
|
|
273
|
+
return { spendState, spent, overBudget, remainingBudget, noticeUnknownSpend, usageBlock,
|
|
274
|
+
addWave, reserveBudget, releaseBudget, noteBudgetRefusal, budgetRefusals, inexactUnderCeiling };
|
|
275
|
+
}
|
|
276
|
+
|
|
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 };
|
|
@@ -18,11 +18,47 @@ const fs = require('fs');
|
|
|
18
18
|
const path = require('path');
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
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
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{fanoutFn?: Function, remainingBudget?: () => number|null,
|
|
36
|
+
* reserveBudget?: (waveId: string, estimate: number) => boolean,
|
|
37
|
+
* onBudgetRefusal?: (info: {waveId, models, message}) => void}} [deps]
|
|
38
|
+
* fanoutFn: test seam; default = real runFanout.
|
|
39
|
+
* remainingBudget (v4.4): supplies the council's REMAINING `--max-cost`
|
|
40
|
+
* allowance (ceiling − known spend − outstanding reservations) at launch time,
|
|
41
|
+
* threaded into the fanout pre-flight estimate gate. Omitted (or returning
|
|
42
|
+
* null) leaves `maxCost` off the transport call entirely, exactly as before.
|
|
43
|
+
* reserveBudget (v4.4 cost-council finding 1): the ATOMIC claim. Reading the
|
|
44
|
+
* remaining allowance is not enough when two waves launch concurrently — both
|
|
45
|
+
* read the same unreduced number and both pass. The transport calls this once,
|
|
46
|
+
* synchronously, with the estimate it just computed; see run-budget.js.
|
|
47
|
+
* onBudgetRefusal: notified when the ceiling refuses a wave, so a seat that
|
|
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.
|
|
22
54
|
* @returns {{launchWave: Function, launchSolo: Function}}
|
|
23
55
|
*/
|
|
24
56
|
function createLaunchers(deps = {}) {
|
|
25
57
|
const fanoutFn = deps.fanoutFn || require('../sidecar/fanout').runFanout;
|
|
58
|
+
const remainingBudget = deps.remainingBudget || null;
|
|
59
|
+
const reserveBudget = deps.reserveBudget || null;
|
|
60
|
+
const onBudgetRefusal = deps.onBudgetRefusal || null;
|
|
61
|
+
const sharedServer = deps.sharedServer || null;
|
|
26
62
|
|
|
27
63
|
/**
|
|
28
64
|
* @param {{models: string[], prompt: string, project: string, waveId: string,
|
|
@@ -37,7 +73,27 @@ function createLaunchers(deps = {}) {
|
|
|
37
73
|
*/
|
|
38
74
|
async function launchWave(opts) {
|
|
39
75
|
fs.mkdirSync(opts.project, { recursive: true });
|
|
40
|
-
|
|
76
|
+
// v4.4: arm fanout's SOFT total-$ ceiling with the council's remaining
|
|
77
|
+
// allowance. Previously omitted, so fanout fell back to `cfg.maxCost` (a key
|
|
78
|
+
// src/utils/config.js never defines) and the pre-flight estimate gate was
|
|
79
|
+
// inert for every council run — run.js's post-hoc check was the only ceiling,
|
|
80
|
+
// and it can only refuse after the money is gone. Left OFF when there is no
|
|
81
|
+
// provider or no ceiling, so the transport call is byte-identical for
|
|
82
|
+
// non-council callers and for `--max-cost`-less runs.
|
|
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;
|
|
89
|
+
const { wave, exitCode, errorDoc } = await fanoutFn({
|
|
90
|
+
...(typeof remaining === 'number' ? { maxCost: remaining } : {}),
|
|
91
|
+
...(shared ? { serverClient: shared.serverClient, server: shared.server } : {}),
|
|
92
|
+
// v4.4 cost-council finding 1: `maxCost` above is a READ taken before the
|
|
93
|
+
// transport resolved routing; a concurrently launching sibling can claim
|
|
94
|
+
// part of that allowance in the meantime. This is the CLAIM that settles
|
|
95
|
+
// it — synchronous by contract, so two callers can never interleave.
|
|
96
|
+
...(reserveBudget ? { reserveBudget: (est) => reserveBudget(opts.waveId, est) } : {}),
|
|
41
97
|
models: opts.models.join(','),
|
|
42
98
|
prompt: opts.prompt,
|
|
43
99
|
promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
|
|
@@ -73,17 +129,25 @@ function createLaunchers(deps = {}) {
|
|
|
73
129
|
directory: opts.project,
|
|
74
130
|
noMcp: true,
|
|
75
131
|
});
|
|
76
|
-
|
|
132
|
+
// A ceiling refusal returns `wave: null`, which the council driver's
|
|
133
|
+
// addWave() treats as a no-op — so before v4.4 the seats simply vanished
|
|
134
|
+
// from the bench with nothing on stdout, stderr or run.json to say so. A
|
|
135
|
+
// partial bench is an acceptable outcome; an UNANNOUNCED one is not.
|
|
136
|
+
if (onBudgetRefusal && errorDoc && errorDoc.code === 'BUDGET_EXCEEDED') {
|
|
137
|
+
onBudgetRefusal({ waveId: opts.waveId, models: opts.models.slice(), message: errorDoc.message });
|
|
138
|
+
}
|
|
139
|
+
return { wave, exitCode, errorDoc: errorDoc || null };
|
|
77
140
|
}
|
|
78
141
|
|
|
79
142
|
/**
|
|
80
143
|
* 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
|
|
144
|
+
* @returns {Promise<{wave: object|null, exitCode: number, leg: object|null,
|
|
145
|
+
* errorDoc: object|null}>}
|
|
82
146
|
*/
|
|
83
147
|
async function launchSolo(opts) {
|
|
84
|
-
const { wave, exitCode } = await launchWave({ ...opts, models: [opts.model] });
|
|
148
|
+
const { wave, exitCode, errorDoc } = await launchWave({ ...opts, models: [opts.model] });
|
|
85
149
|
const leg = (wave && Array.isArray(wave.legs) && wave.legs[0]) || null;
|
|
86
|
-
return { wave, exitCode, leg };
|
|
150
|
+
return { wave, exitCode, leg, errorDoc };
|
|
87
151
|
}
|
|
88
152
|
|
|
89
153
|
return { launchWave, launchSolo };
|
|
@@ -135,4 +199,6 @@ function materializeDebate(runDir, legs, prefix) {
|
|
|
135
199
|
return out;
|
|
136
200
|
}
|
|
137
201
|
|
|
138
|
-
module.exports = {
|
|
202
|
+
module.exports = {
|
|
203
|
+
createLaunchers, materializeReviews, materializeDebate, sanitizeName, isAbortExit,
|
|
204
|
+
};
|