amicus 4.9.1 → 4.9.2
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 +57 -0
- package/README.md +1 -1
- package/docs/usage.md +1 -1
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +10 -0
- package/src/council/run-retry-window.js +62 -0
- package/src/council/run-retry.js +7 -10
- package/src/council/run-stage2.js +47 -3
- package/src/council/tally.js +12 -0
- package/src/council/verdict-seats-reviewed.js +60 -0
- package/src/council/verdict.js +23 -0
- package/src/headless.js +90 -9
- package/src/utils/degrade.js +8 -0
- package/src/utils/session-status.js +73 -0
- package/src/utils/ttft.js +17 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.2",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Christian Wagner"
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,63 @@
|
|
|
3
3
|
All notable changes to Amicus are documented here. Format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
|
|
5
5
|
|
|
6
|
+
## [4.9.2] - 2026-08-27
|
|
7
|
+
|
|
8
|
+
*The instrument existed; nothing could read it.*
|
|
9
|
+
|
|
10
|
+
Issue #202 deferred its retry-policy decision to evidence: "v4.9 W13 records per-leg
|
|
11
|
+
time-to-first-token in `runStats`, so the next rev can derive this from observation instead of
|
|
12
|
+
argument." That probe had never reported a value into any artifact CI uploads — `tally.js`'s
|
|
13
|
+
hand-maintained allowlist stripped it one hop before `tally.json`. Reading it changed the
|
|
14
|
+
diagnosis: first tokens on the CI egress are a continuous heavy tail (8.0 s to 384.2 s, no gap),
|
|
15
|
+
not an upstream that accepts and never serves. This release fixes the instrument, then the
|
|
16
|
+
kill switches that were set inside that tail.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **`ttftMs` survives the runStats re-projection (#202).** `tally.js :: tally` re-projects every
|
|
21
|
+
row through a hand-maintained allowlist that never named the field, and `verdict.js` copies that
|
|
22
|
+
array verbatim — so the W13 probe emitted correctly into `tally-input.json` and was destroyed
|
|
23
|
+
before `tally.json` and `verdict.json`, the only artifacts CI uploads. MEASURED, run
|
|
24
|
+
33030485388: 11 of 12 rows carried it going in, 0 of 12 coming out. `tally.js` becomes the fifth
|
|
25
|
+
emit gate and the fourth importer of the shared `isMeasuredTtft` predicate. A drift pin now fails
|
|
26
|
+
for ANY future `buildRunStatsEntry` key the allowlist is not taught to carry.
|
|
27
|
+
- **A zero-output leg now names its cause (#202).** `getSessionStatus` was called only inside
|
|
28
|
+
`if (mirror.output.length > 0)` — a gate a leg that produced nothing never satisfies — so the one
|
|
29
|
+
leg needing diagnosis was the only one that never asked. A bounded, non-throwing read now runs at
|
|
30
|
+
the two backstop firing sites (a living leg makes no extra call) and appends `busy` /`idle`/
|
|
31
|
+
`retry` with the upstream message. `busy` means provider-side, `idle` means engine-side, `retry`
|
|
32
|
+
names the cause; none is suppressed. Untrusted provider text is sanitized.
|
|
33
|
+
- **Dead Stage-2 judge legs are announced (#202).** A dead judge leg still binds to its seat, so it
|
|
34
|
+
was neither `orphan` nor `unbound` and Stage 2 had no third case: it fell through into
|
|
35
|
+
`judgeResults` unremarked. Run 32956900910 shipped a four-column adjudication matrix that two of
|
|
36
|
+
its four judges never voted in, with no degrade recorded. New `stage2-judge` channel.
|
|
37
|
+
- **B53 no longer kills healthy, billing legs.** `TOOL_CALL_STALL_MS` was 180 s, condemned by this
|
|
38
|
+
repo's own measurement — a real 190.6 s `task` call recorded in `headless.js`, taken on a
|
|
39
|
+
developer machine. That measurement had corrected its neighbour (the settle deferral) and left
|
|
40
|
+
its own subject alone. Now 300 s, with CI overriding to 480 s.
|
|
41
|
+
- **The Stage-1 retry backstop no longer ties with the leg timeout.** `min(2 * backstop, legCap)`
|
|
42
|
+
made the deadlines equal whenever `2 * backstop >= legCap`; the backstop won only by the poll
|
|
43
|
+
loop's ordering. It now clamps strictly below, so a retry death keeps its named diagnosis
|
|
44
|
+
instead of degrading to a generic `timeout`.
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **`verdict.json` publishes `seatsReviewed {reviewed, of}`.** `deriveSeatLoss` returns null when
|
|
49
|
+
no `--critic` was requested, and CI requests none, so seat loss was structurally absent from
|
|
50
|
+
every CI verdict while a two-seat bench published a four-model street-cred table. Derived from
|
|
51
|
+
`runStats`, counting the bench roles `buildSeats` mints (`seat`, `critic`, `lens:<slug>`).
|
|
52
|
+
Surfaced in the check-run title and the sticky comment footer.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **A dead Stage-2 judge now degrades the run, so it exits 2.** Previously a half-adjudicated
|
|
57
|
+
verdict could exit 0. This changes CI signal: runs that passed before will now report degraded
|
|
58
|
+
when a judge dies.
|
|
59
|
+
- **CI council caps.** Per-leg `--timeout` 10 -> 16 min and `timeout-minutes` 45 -> 75, to give the
|
|
60
|
+
tool-stall detector real reach (120 s -> 480 s of a leg). The job cap covers a worst case of
|
|
61
|
+
four leg caps; that figure is a floor, since Stage-2 repairs are serial.
|
|
62
|
+
|
|
6
63
|
## [4.9.1] - 2026-08-27
|
|
7
64
|
|
|
8
65
|
*A silent provider failure, and the unservable model ids it produced.*
|
package/README.md
CHANGED
package/docs/usage.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.2",
|
|
4
4
|
"mcpName": "io.github.BourbonDog/amicus",
|
|
5
5
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
6
6
|
"keywords": [
|
|
@@ -229,6 +229,16 @@
|
|
|
229
229
|
"criticSeated"
|
|
230
230
|
]
|
|
231
231
|
},
|
|
232
|
+
"seatsReviewed": {
|
|
233
|
+
"type": "object",
|
|
234
|
+
"description": "#202, optional. How much of the BENCH actually reviewed, derived from runStats: `of` counts every BENCH-role row — `seat`, `critic`, or `lens:<slug>`, exactly the roles seats.js :: buildSeats mints (one per bench seat POST-retry, so a healed seat is counted once and its first attempt is `role:'superseded'`), and `reviewed` counts those whose leg completed. Judges, chair and repair rows are not bench seats and are excluded. EMIT-WHEN-SET: a record with no bench rows carries no key at all, because `0 of 0` would read as a measurement of an empty bench rather than as the absence it is. WHY IT EXISTS: the sibling `seatLoss` above is present only when --critic was requested, and CI runs none — so seat loss was structurally absent from every CI verdict while a two-seat bench published a four-model street-cred table whose dead seats rendered `n/a`, indistinguishable from the legend's neutral (MEASURED, run 4424218c). No `additionalProperties: false` at the top level of this schema means an additive field was always accepted here; this documents the shape rather than changing what is accepted.",
|
|
235
|
+
"properties": {
|
|
236
|
+
"reviewed": { "type": "integer", "minimum": 0, "description": "Bench seats whose leg completed." },
|
|
237
|
+
"of": { "type": "integer", "minimum": 1, "description": "Bench seats benched, post-retry." }
|
|
238
|
+
},
|
|
239
|
+
"required": ["reviewed", "of"],
|
|
240
|
+
"additionalProperties": false
|
|
241
|
+
},
|
|
232
242
|
"degrades": {
|
|
233
243
|
"description": "v4.6 Plan 2: what this run lost — copied verbatim from the sink at verdict assembly. Additive; absent when the sink recorded nothing. v4.9 widened kind with 'info': an announcement that is neither a loss nor a recovery (e.g. a task run's ledger-skipped note) — carried here without degrading the run.",
|
|
234
244
|
"type": "array",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module council/run-retry-window
|
|
3
|
+
* The Stage-1 retry's no-output window: how long a RELAUNCHED leg may stay
|
|
4
|
+
* silent before the backstop kills it.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ EXTRACTED, not shaved (release Constraint 6, and the 300-line gate that put
|
|
7
|
+
* `verdict-seat-loss.js` in its own leaf): #219's correction took run-retry.js to
|
|
8
|
+
* 314/300. Its own module also makes the property directly testable.
|
|
9
|
+
*
|
|
10
|
+
* SL-2 Task 5 (#129): a retry re-runs the SAME model under the SAME conditions,
|
|
11
|
+
* so a latency failure is structurally unhealable — double the window rather
|
|
12
|
+
* than repeat it. ⚠️ #135 C0 took the base 240s -> 600s; deliberate, see
|
|
13
|
+
* CHANGELOG (council A1, PR #182).
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ CLAMPED STRICTLY BELOW the leg timeout, not TO it (#219, council gpt
|
|
16
|
+
* major). `Math.min(2 * backstop, legTimeoutMs)` made the two deadlines EQUAL
|
|
17
|
+
* whenever `2 * backstop >= legTimeoutMs` — exactly CI today (2 x 480000 ===
|
|
18
|
+
* 960000 === `--timeout 16`). The backstop still won, but only by epsilon and
|
|
19
|
+
* only because the poll loop tests its deadline BEFORE sleeping, so the final
|
|
20
|
+
* poll lands just past the wall. That is an undocumented accident of loop order;
|
|
21
|
+
* if it ever lost, the leg would die a generic `timeout` and throw away the
|
|
22
|
+
* named NO_OUTPUT_BACKSTOP diagnosis this clamp exists to preserve.
|
|
23
|
+
*
|
|
24
|
+
* A PROPORTIONAL headroom, not a fixed subtraction: a constant large enough to
|
|
25
|
+
* beat a poll cycle (seconds) would drive a small leg cap to zero or negative,
|
|
26
|
+
* and `ms <= 0` is the documented DISABLE hatch — silently disabling the backstop
|
|
27
|
+
* is far worse than the race it fixes. 5% of any realistic leg cap clears the 2 s
|
|
28
|
+
* poll interval by a wide margin.
|
|
29
|
+
*
|
|
30
|
+
* `2 * 0 === 0` still disables, because `Math.min(0, anything positive) === 0`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {number} baseBackstopMs the first attempt's resolved no-output window
|
|
37
|
+
* @param {number} legTimeoutMs the per-leg hard cap ((o.timeout || 15) * 60_000)
|
|
38
|
+
* @returns {number} the retry's window: doubled, clamped strictly below the cap
|
|
39
|
+
*/
|
|
40
|
+
function retryBackstopMs(baseBackstopMs, legTimeoutMs) {
|
|
41
|
+
// ⚠️ NOT floored at the first attempt's window, and #219 round 2 (glm) asked
|
|
42
|
+
// for exactly that — correctly observing that when `legTimeoutMs <= 2 * base`
|
|
43
|
+
// the retry window comes out slightly SHORTER than the attempt it exists to
|
|
44
|
+
// give room to (480000/480000 -> 456000). The observation is right; the remedy
|
|
45
|
+
// is worse than what it fixes, MEASURED across all three regimes:
|
|
46
|
+
//
|
|
47
|
+
// regime first(effective) unfloored floored unfloored gives
|
|
48
|
+
// cap = 2x base 480000 912000 912000 NAMED backstop
|
|
49
|
+
// cap = base 480000 456000 480000 NAMED backstop
|
|
50
|
+
// cap < base (t=3) 180000 171000 180000 NAMED backstop
|
|
51
|
+
//
|
|
52
|
+
// Flooring pins the window ONTO the leg cap in both degenerate regimes, which
|
|
53
|
+
// is the tie the headroom exists to break — so the leg dies a generic
|
|
54
|
+
// `timeout` and the named diagnosis is lost. That diagnosis is this module's
|
|
55
|
+
// entire purpose. The unfloored cost is bounded at 5% of the window (24 s at
|
|
56
|
+
// CI scale, 9 s at `--timeout 3`), and it is paid only where the leg cap
|
|
57
|
+
// already dominates the backstop. Trading ≤5% of one retry's patience for a
|
|
58
|
+
// named cause on every retry death is the right side of that trade.
|
|
59
|
+
return Math.min(2 * baseBackstopMs, Math.floor(legTimeoutMs * 0.95));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { retryBackstopMs };
|
package/src/council/run-retry.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const { materializeReviews, isAbortExit } = require('./run-launch');
|
|
18
18
|
const runState = require('./run-state');
|
|
19
19
|
const { resolveNoOutputBackstopMs } = require('../utils/no-output-backstop');
|
|
20
|
+
const { retryBackstopMs } = require('./run-retry-window');
|
|
20
21
|
const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
|
|
21
22
|
= require('./run-retry-notes');
|
|
22
23
|
// briefingFor + bindRetryWave live in ./run-retry-launch (v4.8 T-A2 split); the pad/bind core it wraps is stage1-bind.js :: bindPaddedWave (SI-27).
|
|
@@ -54,17 +55,13 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [],
|
|
|
54
55
|
const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [], twins,
|
|
55
56
|
stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
|
|
56
57
|
stillDeadRetryLegs: [], seatOf: new Map(), orphanLegs: [], attemptedSeats: new Set() };
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// the
|
|
60
|
-
// silently becoming an ordinary timeout at a low --timeout. 2*0 === 0 keeps
|
|
61
|
-
// the disable hatch. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
|
|
62
|
-
// ⚠️ #135 C0 took this 240s -> 600s; deliberate, see CHANGELOG (council A1, PR #182).
|
|
58
|
+
// The retry window: doubled and clamped strictly below the leg timeout.
|
|
59
|
+
// Reasoning (and #219's correction) lives in ./run-retry-window — extracted
|
|
60
|
+
// for the 300-line gate. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
|
|
63
61
|
const legTimeoutMs = (o.timeout || 15) * 60 * 1000;
|
|
64
|
-
const escalatedBackstopMs =
|
|
65
|
-
|
|
66
|
-
legTimeoutMs
|
|
67
|
-
);
|
|
62
|
+
const escalatedBackstopMs = retryBackstopMs(
|
|
63
|
+
Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs(),
|
|
64
|
+
legTimeoutMs);
|
|
68
65
|
|
|
69
66
|
for (const unit of groupStage1Losses(o, deadWaves, deadLegs, seatOf, twins)) {
|
|
70
67
|
// Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
|
|
@@ -23,6 +23,9 @@ const stage2 = require('./briefings-stage2');
|
|
|
23
23
|
const { parseJudgeOutput } = require('./parse-stage2');
|
|
24
24
|
const { sanitizeName, isAbortExit } = require('./run-launch');
|
|
25
25
|
const runState = require('./run-state');
|
|
26
|
+
// #219 (council, glm minor): `leg.error` is UNTRUSTED provider text. The house
|
|
27
|
+
// sanitizer — one sanitizer, one dialect (utils/text-sanitize.js).
|
|
28
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
26
29
|
const { buildRunStatsEntry } = require('./run-assemble');
|
|
27
30
|
// v4.8 PR3 Task 4: seat binding. artifactName is NOT re-exported from
|
|
28
31
|
// run-launch.js (its exports stop at sanitizeName/isAbortExit), so it comes
|
|
@@ -169,9 +172,16 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
|
|
|
169
172
|
fs.writeFileSync(path.join(o.runDir, name), leg.summary, { mode: 0o600 });
|
|
170
173
|
}
|
|
171
174
|
let conformance = 'clean';
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
// #202: ONE predicate for "this judge never answered at all", shared by the
|
|
176
|
+
// DEAD_LEG classification below and by the degrade it now raises. Spelling it
|
|
177
|
+
// twice is how the two would drift into disagreeing about which judges died —
|
|
178
|
+
// and note it is NOT `leg.status !== 'complete'`: a leg that completes with an
|
|
179
|
+
// EMPTY summary produced nothing either, and the DEAD_LEG arm has always
|
|
180
|
+
// treated it that way.
|
|
181
|
+
const legDied = !(leg.status === 'complete' && leg.summary);
|
|
182
|
+
let parsed = legDied
|
|
183
|
+
? { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] }
|
|
184
|
+
: parseJudgeOutput(leg.summary, parseCtx);
|
|
175
185
|
let attempts = 0;
|
|
176
186
|
// ⚠️ LC-12: the judging text the repair prompt must carry, tracked exactly like
|
|
177
187
|
// Stage-1's `repairing` so `judging` and `parsed.errors` always describe the SAME
|
|
@@ -216,6 +226,40 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
|
|
|
216
226
|
if (parsed.ok) { conformance = 'repaired'; }
|
|
217
227
|
}
|
|
218
228
|
if (!parsed.ok) {
|
|
229
|
+
// #202: THE MISSING THIRD CASE. A dead judge leg still comes back as a leg
|
|
230
|
+
// object, so bindPaddedWave binds it — it is neither `orphan` nor
|
|
231
|
+
// `unbound`, and stage 2 had no case for it. It fell through into
|
|
232
|
+
// judgeResults with `ok:false` and vanished: MEASURED on CI run
|
|
233
|
+
// 32956900910 (wave 9d8029c8-s2), where glm and qwen judges died at +300s
|
|
234
|
+
// with zero tokens and run.json recorded no degrade at all, while the
|
|
235
|
+
// verdict shipped a four-column adjudication matrix two of them never
|
|
236
|
+
// voted in. The one net that might have caught it, `thin-cross-review`,
|
|
237
|
+
// fires only at `usableJudges < 2`; that run had exactly 2 of 4.
|
|
238
|
+
//
|
|
239
|
+
// ⚠️ Emitted with the default kind ('degrade'), so run-degrade.js's sink
|
|
240
|
+
// sets `degraded.value` and the run exits 2. That is a deliberate
|
|
241
|
+
// behaviour change (owner's call): before it, a half-adjudicated verdict
|
|
242
|
+
// could exit 0, and W11 only exited 2 because of an unrelated
|
|
243
|
+
// cost-accounting degrade. An unparseable-but-ANSWERED judge is a
|
|
244
|
+
// different fact and is deliberately excluded — it already darkens the
|
|
245
|
+
// seat's row via `conformance: 'unstructured'`, and it is repairable.
|
|
246
|
+
if (legDied) {
|
|
247
|
+
ctx.degrade.note({
|
|
248
|
+
channel: 'stage2-judge',
|
|
249
|
+
what: `judge ${judge} did not adjudicate`,
|
|
250
|
+
// #219: `why` is PROSE — it renders into run.json, the report and the
|
|
251
|
+
// sticky PR comment — so the provider's text is collapsed to one
|
|
252
|
+
// bounded line. `data.reason` below stays VERBATIM on purpose: it is
|
|
253
|
+
// the machine surface, it is JSON (nothing to inject), and truncating
|
|
254
|
+
// it would cost exactly the fidelity a reader opens run.json for.
|
|
255
|
+
why: `its Stage-2 leg ended '${leg.status}'`
|
|
256
|
+
+ (leg.error ? `: ${collapseExcerpt(leg.error, 200)}` : ''),
|
|
257
|
+
effect: `the cross-review was adjudicated by fewer than the ${judges.length} judges the `
|
|
258
|
+
+ 'bench implies; the run continues and will exit degraded (2)',
|
|
259
|
+
data: { judge, seat: seat ? seat.id : null, waveId: `${o.runId}-s2`,
|
|
260
|
+
status: leg.status, reason: leg.error || null },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
219
263
|
judgeResults.push({ judge, seat, ok: false, order: null, orderSeats: null, adjudications: null,
|
|
220
264
|
conformance: leg.status === 'complete' ? 'unstructured' : 'clean',
|
|
221
265
|
// #83 (v4.6 Plan 2): the judge's ORIGINAL Stage-2 wave leg, mirroring
|
package/src/council/tally.js
CHANGED
|
@@ -6,6 +6,11 @@ const { peersOf, unattributedPeerDrops } = require('./peer-split');
|
|
|
6
6
|
// the seat-keying in it — release Constraint 6 is EXTRACT, never shave).
|
|
7
7
|
// computeStreetCred is re-exported below, so no existing import path moved.
|
|
8
8
|
const { computeStreetCred } = require('./street-cred');
|
|
9
|
+
// #202: the TTFT probe's LAST emit gate — a RE-PROJECTION, so omitting the field
|
|
10
|
+
// here destroyed one already produced rather than failing to produce it. Through
|
|
11
|
+
// v4.9.1 utils/ttft.js's docblock enumerated only the four PRODUCER gates and
|
|
12
|
+
// stopped one short of this one; it now names all five.
|
|
13
|
+
const { isMeasuredTtft } = require('../utils/ttft');
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
|
|
@@ -181,6 +186,13 @@ function tally(input) {
|
|
|
181
186
|
...(r.seat ? { seat: r.seat } : {}),
|
|
182
187
|
status: r.status || 'unknown',
|
|
183
188
|
durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
|
|
189
|
+
// #202: emit-when-VALID, in buildRunStatsEntry's own slot (between
|
|
190
|
+
// durationMs and usage) so G7b's key-order invariant holds for a row that
|
|
191
|
+
// carries it. NOT `durationMs`'s null-coercion above: a null here would be
|
|
192
|
+
// read as a measurement, and absence must keep its one meaning — "no
|
|
193
|
+
// substantive tick was ever observed". The shared predicate is imported
|
|
194
|
+
// rather than hand-spelled; this file has no require-free pin.
|
|
195
|
+
...(isMeasuredTtft(r.ttftMs) ? { ttftMs: r.ttftMs } : {}),
|
|
184
196
|
usage: r.usage || null,
|
|
185
197
|
})),
|
|
186
198
|
tierCounts: countTiers(outFindings),
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module council/verdict-seats-reviewed
|
|
3
|
+
* #202: the bench-seat census for verdict.json, as a spreadable fragment.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ EXTRACTED, not shaved — release Constraint 6, and the same 300-line gate
|
|
6
|
+
* that put `verdict-seat-loss.js` in its own leaf: adding this to verdict.js
|
|
7
|
+
* took that file to 313/300. It could not join verdict-seat-loss.js either —
|
|
8
|
+
* that module is pinned to export EXACTLY its two functions.
|
|
9
|
+
*
|
|
10
|
+
* This is the ONE place that decides what "a bench seat" means, so the
|
|
11
|
+
* emit-when-set rule and the role filter cannot drift apart. `of` is every
|
|
12
|
+
* `role:'seat'` row — one per bench seat POST-retry, so a healed seat counts
|
|
13
|
+
* once while its first attempt is `role:'superseded'`; judges, chair and
|
|
14
|
+
* repairs are not bench seats. `reviewed` is those whose leg completed: a
|
|
15
|
+
* `timeout` is not a review any more than an `error` is.
|
|
16
|
+
*
|
|
17
|
+
* A LEAF: it requires nothing, matching its seat-loss sibling.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Array<object>|undefined} runStats
|
|
24
|
+
* @returns {{seatsReviewed?: {reviewed: number, of: number}}}
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Is this runStats row a BENCH seat — something that was asked to review?
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ These are exactly the three roles `seats.js :: buildSeats` mints, and that
|
|
30
|
+
* is the point: it is the producer, so this mirrors it rather than guessing.
|
|
31
|
+
* `role === 'seat'` alone (#219) counted ZERO on a `--lenses` run, where every
|
|
32
|
+
* seat carries `lens:<slug>` — so emit-when-set silently omitted the census from
|
|
33
|
+
* the runs using the richest bench. A critic counts too: it is an adversarial
|
|
34
|
+
* seat, but it reviews.
|
|
35
|
+
*
|
|
36
|
+
* An ALLOWLIST, not a denylist of judge/chair/repair/superseded: a new
|
|
37
|
+
* non-bench role added later must not silently inflate the denominator.
|
|
38
|
+
*/
|
|
39
|
+
function isBenchRole(role) {
|
|
40
|
+
return role === 'seat' || role === 'critic'
|
|
41
|
+
|| (typeof role === 'string' && role.startsWith('lens:'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function seatsReviewedOf(runStats) {
|
|
45
|
+
// ⚠️ `Array.isArray`, NOT `runStats || []`. buildVerdict is reachable on
|
|
46
|
+
// externally-supplied records that never touched tally() in-process — the MCP
|
|
47
|
+
// `record` param of mcp-tools.js :: amicus_verdict is `z.record(z.any())`,
|
|
48
|
+
// fully permissive — and this file's own tests hand it `runStats: {}`. A
|
|
49
|
+
// truthy non-array sails past `||` and throws on `.filter`, turning a missing
|
|
50
|
+
// census into a crashed verdict build. The closed-literal comment further down
|
|
51
|
+
// makes the same argument about the same caller.
|
|
52
|
+
const seats = (Array.isArray(runStats) ? runStats : []).filter(r => r && isBenchRole(r.role));
|
|
53
|
+
if (seats.length === 0) { return {}; }
|
|
54
|
+
return { seatsReviewed: {
|
|
55
|
+
reviewed: seats.filter(r => r.status === 'complete').length,
|
|
56
|
+
of: seats.length,
|
|
57
|
+
} };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { seatsReviewedOf };
|
package/src/council/verdict.js
CHANGED
|
@@ -13,6 +13,10 @@ const { summarizeSeatLoss, deriveSeatLoss } = require('./verdict-seat-loss');
|
|
|
13
13
|
// opts.overallVerdict, null in every Stage-4 manual path).
|
|
14
14
|
const VERDICT_SCHEMA_VERSION = 2;
|
|
15
15
|
|
|
16
|
+
// #202: the bench-seat census leaf (the 300-line gate — same reason
|
|
17
|
+
// verdict-seat-loss.js is its own module).
|
|
18
|
+
const { seatsReviewedOf } = require('./verdict-seats-reviewed');
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
21
|
* Merge a tally record with Claude's Stage-4 decisions into the verdict record.
|
|
18
22
|
* @param {object} record tally() output
|
|
@@ -123,6 +127,25 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
123
127
|
// Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
|
|
124
128
|
// was requested, so its absence never has to be interpreted.
|
|
125
129
|
...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
|
|
130
|
+
// #202: how much of the bench actually reviewed. DERIVED here rather than
|
|
131
|
+
// passed in, because every caller that could pass it already has the same
|
|
132
|
+
// `runStats` this reads — and a parameter is one more thing a rebuild path
|
|
133
|
+
// can forget (the `intent` key needed a SECOND carrier for exactly that).
|
|
134
|
+
//
|
|
135
|
+
// Its sibling `seatLoss` cannot serve: `deriveSeatLoss` returns null when no
|
|
136
|
+
// `--critic` was requested, and CI runs `CRITIC: ''` — so seat loss is
|
|
137
|
+
// STRUCTURALLY absent from every CI verdict. MEASURED on run 4424218c, a
|
|
138
|
+
// two-seat bench that published a four-model street-cred table with the dead
|
|
139
|
+
// seats rendered `n/a`, indistinguishable from the legend's "neutral".
|
|
140
|
+
//
|
|
141
|
+
// Counts the BENCH roles buildSeats mints — `seat`, `critic` and `lens:<slug>`
|
|
142
|
+
// (#219 r2: this said "`role:'seat'` ONLY" after the filter was widened, and
|
|
143
|
+
// two seats caught the stale sentence). One row per bench seat POST-retry, so a
|
|
144
|
+
// healed seat is counted once (its first attempt is `role:'superseded'`), and
|
|
145
|
+
// judges/chair/repairs are not bench seats. Emit-when-set — a record with no
|
|
146
|
+
// bench rows carries no key, because `0 of 0` would read as a measurement of
|
|
147
|
+
// an empty bench rather than as the absence it is.
|
|
148
|
+
...seatsReviewedOf(record.runStats),
|
|
126
149
|
// v4.6 Plan 2 (spec §4): the canonical what-was-lost surface. Additive and
|
|
127
150
|
// OPTIONAL — present only when the run actually degraded, so a clean run's
|
|
128
151
|
// verdict is byte-for-byte unchanged. schemaVersion stays 2 (the v4.5.2
|
package/src/headless.js
CHANGED
|
@@ -25,6 +25,8 @@ const { envNumber } = require('./utils/env-num');
|
|
|
25
25
|
const { engineErrorForSession } = require('./utils/engine-log');
|
|
26
26
|
// v4.9 W10 (#133 piece 3): the standing engine version-skew record, if any.
|
|
27
27
|
const { currentEngineSkew, formatSkewSuffix } = require('./utils/engine-skew');
|
|
28
|
+
// #202: the session-status clause on a death report (see utils/session-status.js).
|
|
29
|
+
const { formatSessionStatusSuffix } = require('./utils/session-status');
|
|
28
30
|
// v4.9 W13 Task A (PR #207 round 3, B3): the one honesty predicate every ttftMs
|
|
29
31
|
// emit gate shares — see src/utils/ttft.js for why `typeof` was not it.
|
|
30
32
|
const { isMeasuredTtft } = require('./utils/ttft');
|
|
@@ -85,7 +87,18 @@ const STABLE_FINISHED_POLLS = Number(process.env.AMICUS_STABLE_FINISHED_POLLS) |
|
|
|
85
87
|
const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30; // ~60s at 2s — no completion signal
|
|
86
88
|
const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
|
|
87
89
|
const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
|
|
88
|
-
|
|
90
|
+
// B53: wedged tool call w/ no progress.
|
|
91
|
+
// ⚠️ 180000 -> 300000 (#219, council glm minor). 180 s was condemned by this
|
|
92
|
+
// file's OWN measurement — the 190.6 s `task` call recorded below, taken on a
|
|
93
|
+
// developer machine, not on CI. #202 widened only the CI override and left every
|
|
94
|
+
// local and library consumer on the number the evidence had already disproved.
|
|
95
|
+
// 300000 matches the sibling constant that same measurement set (USAGE/settle
|
|
96
|
+
// deferral below), so one measurement now governs both windows it bears on.
|
|
97
|
+
const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 300000;
|
|
98
|
+
// #202: budget for the ONE session-status read on a death report. Short on
|
|
99
|
+
// purpose — this runs on a leg already known to be dying, so the report must not
|
|
100
|
+
// wait on the same engine that just failed to produce anything.
|
|
101
|
+
const STATUS_PROBE_MS = 5000;
|
|
89
102
|
/**
|
|
90
103
|
* v4.4 B1 — bounded post-loop usage reconciliation. The fold-marker (:~540) and
|
|
91
104
|
* SDK-idle (:~568) fast paths break WITHOUT requiring `info.time.completed`, but
|
|
@@ -120,9 +133,11 @@ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT
|
|
|
120
133
|
* unbounded wait is not an option.
|
|
121
134
|
*
|
|
122
135
|
* WHY 5 MINUTES. The measured duration of the real subagent call that exposed
|
|
123
|
-
* this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) —
|
|
124
|
-
* than B53's 180 s TOOL_CALL_STALL_MS, so anything at that scale would kill
|
|
125
|
-
* healthy `task` leg 10 s short of its answer.
|
|
136
|
+
* this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — which was longer
|
|
137
|
+
* than B53's THEN-180 s TOOL_CALL_STALL_MS, so anything at that scale would kill
|
|
138
|
+
* a healthy `task` leg 10 s short of its answer. (#219 finally moved B53 itself
|
|
139
|
+
* to 300 s for this exact reason; for four releases this measurement corrected
|
|
140
|
+
* the neighbour and left its own subject alone.) 300 s clears the measured case
|
|
126
141
|
* with margin and still lands far inside the 15-minute default `--timeout`.
|
|
127
142
|
* Set to 0 to disable the deferral entirely (pre-v4.4 behaviour).
|
|
128
143
|
*
|
|
@@ -228,7 +243,7 @@ function withTimeout(promise, ms, label) {
|
|
|
228
243
|
* engineSkew?: {server: string, installed: string}|null}} args
|
|
229
244
|
* @returns {string}
|
|
230
245
|
*/
|
|
231
|
-
function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew }) {
|
|
246
|
+
function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew, sessionStatus }) {
|
|
232
247
|
const observed = 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
|
|
233
248
|
+ `${Math.round(ms / 1000)}s — `
|
|
234
249
|
+ (fromEnv
|
|
@@ -237,7 +252,11 @@ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSke
|
|
|
237
252
|
// Append-only: absent/empty excerpt ⇒ the string above, unchanged byte for byte.
|
|
238
253
|
const quoted = engineLogExcerpt ? `${observed} — engine log: ${engineLogExcerpt}` : observed;
|
|
239
254
|
// Append-only for the same reason: no skew ⇒ formatSkewSuffix returns ''.
|
|
240
|
-
|
|
255
|
+
// #202 adds a THIRD clause on the same terms, and LAST so both clauses above
|
|
256
|
+
// stay byte-stable: no status (or an unreadable one) ⇒ '' — see
|
|
257
|
+
// utils/session-status.js. With none of the three the string is byte-for-byte
|
|
258
|
+
// what it was before any of them existed.
|
|
259
|
+
return `${quoted}${formatSkewSuffix(engineSkew)}${formatSessionStatusSuffix(sessionStatus)}`;
|
|
241
260
|
}
|
|
242
261
|
|
|
243
262
|
/**
|
|
@@ -259,6 +278,45 @@ function engineErrorExcerptSafe(sessionId, engineLogOptions) {
|
|
|
259
278
|
}
|
|
260
279
|
}
|
|
261
280
|
|
|
281
|
+
/**
|
|
282
|
+
* #202: read the engine's session status FOR A DEATH REPORT — best-effort and
|
|
283
|
+
* bounded, with the same "never become the failure it reports on" discipline as
|
|
284
|
+
* `engineErrorExcerptSafe` above, and one more constraint that read does not
|
|
285
|
+
* have: this one does I/O against the very engine that just failed to produce
|
|
286
|
+
* anything, so it must also be unable to HANG. Both belts are load-bearing and
|
|
287
|
+
* both are pinned (S-W4 rejects, S-W5 hangs).
|
|
288
|
+
*
|
|
289
|
+
* A failed probe returns `null`, which `formatSessionStatusSuffix` renders as
|
|
290
|
+
* '' — so a leg whose status could not be read carries the byte-for-byte reason
|
|
291
|
+
* string it carried before #202, rather than a clause claiming nothing was
|
|
292
|
+
* happening. Absence keeps its one meaning.
|
|
293
|
+
*
|
|
294
|
+
* `readStatus` is a parameter rather than a module import because
|
|
295
|
+
* `getSessionStatus` is destructured inside runHeadless from the injectable
|
|
296
|
+
* client module — taking it here keeps this helper pure and directly testable.
|
|
297
|
+
* @returns {Promise<object|null>}
|
|
298
|
+
*/
|
|
299
|
+
async function sessionStatusSafe(readStatus, client, sessionId, dirArgs, ms) {
|
|
300
|
+
// `!(ms > 0)` covers 0 (the documented disable), negatives and NaN — and it is
|
|
301
|
+
// why 0 is never handed to withTimeout, which would read it as UNBOUNDED.
|
|
302
|
+
if (typeof readStatus !== 'function' || !sessionId || !(ms > 0)) { return null; }
|
|
303
|
+
try {
|
|
304
|
+
return await withTimeout(
|
|
305
|
+
readStatus(client, sessionId, ...(dirArgs || [])), ms, 'getSessionStatus(death-report)');
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// #219 (council, deepseek minor): returning null is right for the REPORT —
|
|
308
|
+
// absence keeps its one meaning — but it made a probe that timed out on a
|
|
309
|
+
// loaded engine indistinguishable from a leg whose engine reported nothing,
|
|
310
|
+
// i.e. a silent revert to pre-#202 behaviour. The engine log is where that
|
|
311
|
+
// belongs: the death report stays byte-identical, and the degradation
|
|
312
|
+
// becomes diagnosable instead of invisible.
|
|
313
|
+
logger.debug('session-status probe failed; the death report will carry no session clause', {
|
|
314
|
+
sessionId, error: err && err.message,
|
|
315
|
+
});
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
262
320
|
/**
|
|
263
321
|
* Wait for the OpenCode server to be ready using SDK health check
|
|
264
322
|
*/
|
|
@@ -616,6 +674,19 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
616
674
|
// "time since the leg asked for output" (see the block comment above), so
|
|
617
675
|
// the TTFT probe below measures from exactly the instant the backstop
|
|
618
676
|
// starts counting — the two can never disagree about when the wait began.
|
|
677
|
+
// #202: resolved HERE, not with the poll-loop options below. The pre-send
|
|
678
|
+
// firing site calls noOutputBackstopReason() upstream of that block, so a
|
|
679
|
+
// later `const` put this in its TDZ and the death report became
|
|
680
|
+
// "Cannot access 'statusProbeMs' before initialization" — the exact class of
|
|
681
|
+
// silent-diagnosis loss #202 exists to remove. Pinned by S-W6.
|
|
682
|
+
// ⚠️ `=== undefined`, not `||` (#219 round 2, deepseek): 0 is a meaningful
|
|
683
|
+
// value in this codebase's convention (usageSettlePolls,
|
|
684
|
+
// AMICUS_NO_OUTPUT_BACKSTOP_MS) and must survive injection. It cannot be
|
|
685
|
+
// FORWARDED as 0 though — withTimeout reads `ms <= 0` as NO timeout, so an
|
|
686
|
+
// honest-looking 0 would make this probe unbounded on a leg already known to
|
|
687
|
+
// be dying. sessionStatusSafe therefore SKIPS on a non-positive window.
|
|
688
|
+
const statusProbeMs = options.statusProbeMs === undefined
|
|
689
|
+
? STATUS_PROBE_MS : options.statusProbeMs;
|
|
619
690
|
const outputClockStartedAt = Date.now();
|
|
620
691
|
const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: outputClockStartedAt });
|
|
621
692
|
let backstopFired = false;
|
|
@@ -642,10 +713,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
642
713
|
// was fixed mid-run, or one that belongs to another server this process
|
|
643
714
|
// also talks to, cannot ride out on this death report. The read is a Map
|
|
644
715
|
// lookup and does no I/O, so unlike the log read it needs no guard.
|
|
645
|
-
|
|
716
|
+
//
|
|
717
|
+
// #202 (piece 4): the closure is now ASYNC, because the third clause costs
|
|
718
|
+
// one bounded HTTP call. The engine's session status is asked for at
|
|
719
|
+
// :~1045 only when `mirror.output.length > 0` — a gate a zero-output leg
|
|
720
|
+
// never satisfies — so the leg that most needs diagnosing was the only one
|
|
721
|
+
// that never asked, and every silent death reported a window with no cause.
|
|
722
|
+
// Asked for HERE instead, at the two firing sites and nowhere else, so a
|
|
723
|
+
// living leg still makes no extra call. The read is best-effort and
|
|
724
|
+
// bounded: `sessionStatusSafe` can neither throw nor hang (S-W4/S-W5).
|
|
725
|
+
const noOutputBackstopReason = async () => formatNoOutputBackstopReason({
|
|
646
726
|
ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
|
|
647
727
|
engineLogExcerpt: engineErrorExcerptSafe(sessionId, options._engineLog),
|
|
648
728
|
engineSkew: currentEngineSkew(client),
|
|
729
|
+
sessionStatus: await sessionStatusSafe(getSessionStatus, client, sessionId, dirArgs, statusProbeMs),
|
|
649
730
|
});
|
|
650
731
|
|
|
651
732
|
// Send prompt asynchronously (returns immediately, we poll for results) —
|
|
@@ -696,7 +777,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
696
777
|
// skipped entirely on this path — see the while-condition and the
|
|
697
778
|
// backstop-abort block further down).
|
|
698
779
|
if (backstopFired) {
|
|
699
|
-
sessionError = noOutputBackstopReason();
|
|
780
|
+
sessionError = await noOutputBackstopReason();
|
|
700
781
|
}
|
|
701
782
|
|
|
702
783
|
// Hard provider failure detected at the client boundary (#37): a non-2xx /
|
|
@@ -1065,7 +1146,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1065
1146
|
// loop; the post-loop block below mirrors the timeout path.
|
|
1066
1147
|
if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
|
|
1067
1148
|
backstopFired = true;
|
|
1068
|
-
sessionError = noOutputBackstopReason();
|
|
1149
|
+
sessionError = await noOutputBackstopReason();
|
|
1069
1150
|
logger.warn('No-output backstop fired', { taskId, backstopMs: noOutputBackstopMs });
|
|
1070
1151
|
break;
|
|
1071
1152
|
}
|
package/src/utils/degrade.js
CHANGED
|
@@ -24,6 +24,14 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
24
24
|
// a leg that DID match a slot but whose join key names no judge the wave launched.
|
|
25
25
|
// Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
|
|
26
26
|
'seat-unbound',
|
|
27
|
+
// #202: a Stage-2 JUDGE leg that came back dead — bound to its seat, so
|
|
28
|
+
// neither `seat-unbound` nor an orphan, and until now it had no channel at all
|
|
29
|
+
// and no case in run-stage2.js. Deliberately its own channel rather than
|
|
30
|
+
// `dead-leg`: that one is the Stage-1 BENCH roster's, feeds the retry pass and
|
|
31
|
+
// the seat-loss surface, and a judge death reused on it would be counted as a
|
|
32
|
+
// lost reviewer by consumers that only ever meant seats (verdict-seat-loss.js
|
|
33
|
+
// already gates the Stage-2 notes out of `seat-unbound` for the same reason).
|
|
34
|
+
'stage2-judge',
|
|
27
35
|
'internal',
|
|
28
36
|
// doctor channels
|
|
29
37
|
'doctor-check-failed', 'doctor-fix',
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/session-status
|
|
3
|
+
* #202: render the engine's SESSION STATUS as a clause on a leg's death report.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ The JSDoc leads this file, ahead of `'use strict'`, matching
|
|
6
|
+
* `utils/ttft.js` / `utils/text-sanitize.js` / `utils/engine-skew.js`:
|
|
7
|
+
* `scripts/generate-docs.js` only reads a block comment that starts at byte
|
|
8
|
+
* zero, so a `// path` line above it would leave this module's CLAUDE.md row
|
|
9
|
+
* blank.
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS EXISTS. `headless.js` asks the engine for session status only inside
|
|
12
|
+
* `if (mirror.output.length > 0)` — a gate a zero-output leg never satisfies. So
|
|
13
|
+
* the one leg that needs diagnosing is precisely the one that never asks, and
|
|
14
|
+
* every silent death was reported as "no output in Ns" with no cause attached.
|
|
15
|
+
* The pinned SDK publishes `SessionStatus` as
|
|
16
|
+
* `{type:'idle'} | {type:'retry', attempt, message, next} | {type:'busy'}`, and
|
|
17
|
+
* the `retry` arm carries the upstream error verbatim.
|
|
18
|
+
*
|
|
19
|
+
* NONE of the three types is suppressed as uninteresting — they point in
|
|
20
|
+
* DIFFERENT directions, and which one comes back is the discrimination #202
|
|
21
|
+
* spent six CI runs failing to make by argument:
|
|
22
|
+
* · `busy` — the engine is still waiting on the provider ⇒ provider-side.
|
|
23
|
+
* · `idle` — the engine believes it is DONE having produced nothing ⇒
|
|
24
|
+
* engine-side, which is the shape #133 turned out to be.
|
|
25
|
+
* · `retry` — the engine is re-attempting, and says why ⇒ the named cause.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ APPEND-ONLY, exactly like `engine-skew.js :: formatSkewSuffix`: no status
|
|
28
|
+
* (or an unusable one) returns `''`, so a reason string built without one is
|
|
29
|
+
* byte-for-byte what it was before this module existed, and
|
|
30
|
+
* `sidecar/models-probe.js`'s `/^NO_OUTPUT_BACKSTOP:/` classification — a PREFIX
|
|
31
|
+
* test — is unaffected either way.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ `message` is UNTRUSTED third-party text: it originates at the provider,
|
|
34
|
+
* lands in run.json, and on CI is rendered into a sticky PR comment. It goes
|
|
35
|
+
* through the house sanitizer (`text-sanitize.js :: collapseExcerpt`) at a short
|
|
36
|
+
* cap rather than being trusted to the workflow's downstream sed rules — one
|
|
37
|
+
* sanitizer, one dialect, per that module's own ruling.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
const { collapseExcerpt } = require('./text-sanitize');
|
|
43
|
+
|
|
44
|
+
/** Short cap: this is a clause on a one-line death report, not a log dump. */
|
|
45
|
+
const MAX_STATUS_MESSAGE_CHARS = 200;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The death-report clause for an engine session status.
|
|
49
|
+
* @param {*} status - an SDK SessionStatus, or anything at all
|
|
50
|
+
* @returns {string} ` (session: …)`, or '' when nothing usable was observed
|
|
51
|
+
*/
|
|
52
|
+
function formatSessionStatusSuffix(status) {
|
|
53
|
+
if (!status || typeof status !== 'object') { return ''; }
|
|
54
|
+
// A non-string `type` is DROPPED rather than coerced: `String({})` renders
|
|
55
|
+
// '[object Object]', which would read as an observation rather than as the
|
|
56
|
+
// absence it actually is.
|
|
57
|
+
if (typeof status.type !== 'string') { return ''; }
|
|
58
|
+
// ⚠️ CLASSIFY on the RAW value, RENDER the sanitized one (#219 round 2,
|
|
59
|
+
// deepseek). Branching on the sanitized type let the sanitizer's own
|
|
60
|
+
// normalisation decide the arm — anything collapsing to 'retry' took the retry
|
|
61
|
+
// path — so a future SDK identifier could be misclassified by a function whose
|
|
62
|
+
// job is display, not semantics. Only the exact published identifier routes.
|
|
63
|
+
const type = collapseExcerpt(status.type, 40);
|
|
64
|
+
if (!type) { return ''; }
|
|
65
|
+
// An unrecognised type is still reported. A future SDK arm must not read as
|
|
66
|
+
// "no status was observed" — that silence is what this clause removes.
|
|
67
|
+
if (status.type !== 'retry') { return ` (session: ${type})`; }
|
|
68
|
+
const attempt = Number.isFinite(status.attempt) ? ` attempt ${status.attempt}` : '';
|
|
69
|
+
const raw = collapseExcerpt(status.message, MAX_STATUS_MESSAGE_CHARS);
|
|
70
|
+
return ` (session: retry${attempt}${raw ? ` — ${raw}` : ''})`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { formatSessionStatusSuffix, MAX_STATUS_MESSAGE_CHARS };
|
package/src/utils/ttft.js
CHANGED
|
@@ -9,12 +9,23 @@
|
|
|
9
9
|
* blank the way `utils/result-schema.js`'s already is.
|
|
10
10
|
*
|
|
11
11
|
* `ttftMs` is produced once — in `src/headless.js`'s poll loop, as a
|
|
12
|
-
* `Date.now()` delta — and then passes
|
|
12
|
+
* `Date.now()` delta — and then passes five EMIT GATES on its way to a
|
|
13
13
|
* document: `headless.js`'s three returns, `sidecar/fanout-leg.js`'s leg patch,
|
|
14
|
-
* `utils/result-schema.js :: buildRunResult`,
|
|
15
|
-
* `council/run-stats-entry.js :: buildRunStatsEntry
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* `utils/result-schema.js :: buildRunResult`,
|
|
15
|
+
* `council/run-stats-entry.js :: buildRunStatsEntry`, and
|
|
16
|
+
* `council/tally.js :: tally`'s runStats re-projection. Every gate used to spell
|
|
17
|
+
* its own `typeof x === 'number'` test, which is five chances to disagree and
|
|
18
|
+
* five ways to publish a value both schemas forbid.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ The fifth gate is different in KIND from the four above it, which is why it
|
|
21
|
+
* was missed for a release (#202). Those four are PRODUCERS — each writes the
|
|
22
|
+
* field onto a document it is building. `tally.js` is a RE-PROJECTION: it copies
|
|
23
|
+
* an already-built row through a hand-maintained allowlist, so omitting the field
|
|
24
|
+
* there does not fail to produce it, it DESTROYS one already produced. Between
|
|
25
|
+
* v4.9.0 and v4.9.1 that is exactly what happened — the probe wrote real values
|
|
26
|
+
* into tally-input.json and every one was stripped before tally.json and
|
|
27
|
+
* verdict.json, the only run artifacts CI uploads (MEASURED, run 33030485388:
|
|
28
|
+
* 11 of 12 rows carried it going in, 0 of 12 coming out).
|
|
18
29
|
*
|
|
19
30
|
* ⚠️ `typeof` is not the schema's contract. `schemas/run.schema.json` and
|
|
20
31
|
* `schemas/council-tally.schema.json` both declare this field
|
|
@@ -35,7 +46,7 @@
|
|
|
35
46
|
* already means "no honest measurement was made", and a dishonest number is
|
|
36
47
|
* exactly that. `0` itself stays a real, emittable measurement.
|
|
37
48
|
*
|
|
38
|
-
* ⚠️
|
|
49
|
+
* ⚠️ Four of the five gates import this. The one that does not,
|
|
39
50
|
* `council/run-stats-entry.js`, is pinned REQUIRE-FREE (P3,
|
|
40
51
|
* tests/council/run-stats-entry.test.js — the pin fires on the character
|
|
41
52
|
* sequence anywhere in that file, comments included) so require-free consumers
|