faberun 0.9.0 → 0.11.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/README.md +193 -31
- package/package.json +1 -1
- package/src/campaign/chain.mjs +19 -0
- package/src/cli.mjs +4 -3
- package/src/contract/final-verification.mjs +41 -10
- package/src/contract/task-packet.mjs +12 -3
- package/src/engine/dispatch.mjs +17 -0
- package/src/engine/lifecycle.mjs +31 -60
- package/src/engine/scheduler.mjs +199 -26
- package/src/engine/settle-judge.mjs +141 -0
- package/src/engine/settle.mjs +1 -1
- package/src/engine/verify.mjs +57 -4
- package/src/harnesses/protocol.mjs +46 -10
- package/src/host/preflight.mjs +19 -0
- package/src/repo/integrate.mjs +6 -1
- package/src/report/render.mjs +58 -58
- package/src/web/index.html +3 -3
- package/src/web/server.mjs +2 -1
|
@@ -498,6 +498,50 @@ export function isVerdictCandidate(text) {
|
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
/**
|
|
502
|
+
* A judge's final message can carry its whole verdict on one line with no
|
|
503
|
+
* newline before the JSON: `Confirmed. Now write the verdict JSON.{"verdict":
|
|
504
|
+
* "fail", ...}`, measured 2026-09-17 at 5662 characters. A line-boundary-only
|
|
505
|
+
* suffix scan never finds it, extractJson returns null, isVerdictCandidate
|
|
506
|
+
* returns false, and a correct, evidenced verdict is discarded as
|
|
507
|
+
* `judge_unavailable`. The candidate starts tried here are every `{` and `[`
|
|
508
|
+
* offset, not just line starts, so a JSON object glued directly to prose is
|
|
509
|
+
* still reachable.
|
|
510
|
+
*/
|
|
511
|
+
const SUFFIX_CANDIDATE_LIMIT = 200;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The last parseable suffix of `trimmed`, trying every `{`/`[` offset from the
|
|
515
|
+
* end backwards. Bounded to the last `SUFFIX_CANDIDATE_LIMIT` offsets: without
|
|
516
|
+
* a cap this is one `JSON.parse` per bracket character, quadratic on a
|
|
517
|
+
* pathological message built mostly of brackets. 200 is comfortably above the
|
|
518
|
+
* bracket count of any real verdict object (the measured 5662-character
|
|
519
|
+
* message resolves on one of its first few tries, since its outermost `{` is
|
|
520
|
+
* also its first) and keeps every call to at most 200 parses regardless of
|
|
521
|
+
* message length.
|
|
522
|
+
*
|
|
523
|
+
* @param {string} trimmed
|
|
524
|
+
* @returns {string|null}
|
|
525
|
+
*/
|
|
526
|
+
function suffixJsonCandidate(trimmed) {
|
|
527
|
+
const offsets = [];
|
|
528
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
529
|
+
const char = trimmed[index];
|
|
530
|
+
if (char === "{" || char === "[") offsets.push(index);
|
|
531
|
+
}
|
|
532
|
+
const floor = Math.max(0, offsets.length - SUFFIX_CANDIDATE_LIMIT);
|
|
533
|
+
for (let index = offsets.length - 1; index >= floor; index -= 1) {
|
|
534
|
+
const candidate = trimmed.slice(offsets[index]);
|
|
535
|
+
try {
|
|
536
|
+
JSON.parse(candidate);
|
|
537
|
+
return candidate;
|
|
538
|
+
} catch {
|
|
539
|
+
// This suffix is not JSON; keep trying earlier bracket offsets.
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
501
545
|
/**
|
|
502
546
|
* Extract a JSON value from a provider response that may carry prose, taking
|
|
503
547
|
* the last parseable suffix or fenced JSON block.
|
|
@@ -514,16 +558,8 @@ export function extractJson(value) {
|
|
|
514
558
|
} catch {
|
|
515
559
|
// Not JSON as a whole: fall through to the suffix and fenced-block scans below.
|
|
516
560
|
}
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
const candidate = lines.slice(index).join("\n").trim();
|
|
520
|
-
try {
|
|
521
|
-
JSON.parse(candidate);
|
|
522
|
-
return candidate;
|
|
523
|
-
} catch {
|
|
524
|
-
// This suffix is not JSON; keep trying earlier line boundaries.
|
|
525
|
-
}
|
|
526
|
-
}
|
|
561
|
+
const suffix = suffixJsonCandidate(trimmed);
|
|
562
|
+
if (suffix !== null) return suffix;
|
|
527
563
|
const blocks = [...value.matchAll(/```json\s*([\s\S]*?)```/giu)];
|
|
528
564
|
for (const block of blocks.reverse()) {
|
|
529
565
|
const candidate = block[1].trim();
|
package/src/host/preflight.mjs
CHANGED
|
@@ -254,6 +254,22 @@ export function declaredVerificationCommands(contract) {
|
|
|
254
254
|
return [...commands.values()];
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Environment names removed before spawning a timing candidate. The probe
|
|
259
|
+
* only measures how long a command takes; a subtraction of a named few, not
|
|
260
|
+
* an allowlist, so PATH, HOME and every ordinary variable the command needs
|
|
261
|
+
* to run at all still pass through unchanged.
|
|
262
|
+
*/
|
|
263
|
+
const SIDE_EFFECT_ENV_KEYS = [
|
|
264
|
+
NOTIFY_BIN_ENV, // a measurement must not notify a human
|
|
265
|
+
"FABERUN_CODEX_BIN", // could redirect the timed command at a live, paid codex binary instead of this repository's own fixtures
|
|
266
|
+
"FABERUN_CLAUDE_BIN", // same, for the claude harness
|
|
267
|
+
"FABERUN_AGY_BIN", // same, for the agy harness
|
|
268
|
+
"FABERUN_DSH_BIN", // same, for the dsh harness
|
|
269
|
+
"FABERUN_ZCODE_BIN", // same, for the zcode harness
|
|
270
|
+
"FABERUN_EXEC_JSONL_BIN", // same, for the exec-jsonl harness
|
|
271
|
+
];
|
|
272
|
+
|
|
257
273
|
/**
|
|
258
274
|
* Run every declared verification command once and report what it actually
|
|
259
275
|
* costs against the timeout the contract gives it.
|
|
@@ -275,6 +291,8 @@ export function declaredVerificationCommands(contract) {
|
|
|
275
291
|
export function timeVerificationCommands(contract, probes = {}) {
|
|
276
292
|
const now = probes.now ?? (() => Date.now());
|
|
277
293
|
const run = probes.run ?? spawnSync;
|
|
294
|
+
const env = { ...process.env };
|
|
295
|
+
for (const key of SIDE_EFFECT_ENV_KEYS) delete env[key];
|
|
278
296
|
return declaredVerificationCommands(contract).map((command) => {
|
|
279
297
|
const label = command.argv.join(" ");
|
|
280
298
|
const name = `verification timing · ${label}`;
|
|
@@ -288,6 +306,7 @@ export function timeVerificationCommands(contract, probes = {}) {
|
|
|
288
306
|
timeout: ceilingSec * 1_000,
|
|
289
307
|
stdio: "ignore",
|
|
290
308
|
encoding: "utf8",
|
|
309
|
+
env,
|
|
291
310
|
});
|
|
292
311
|
const seconds = (now() - startedAt) / 1_000;
|
|
293
312
|
const measured = `${seconds.toFixed(1)}s measured against ${command.timeoutSec}s declared`;
|
package/src/repo/integrate.mjs
CHANGED
|
@@ -503,7 +503,7 @@ export function promoteRun({ repo, runId, landBranch, runHead, baseSha, finalVer
|
|
|
503
503
|
if (!head) throw refusal(`refusing to promote ${runId}: the run ref is unavailable`, "run_ref_missing");
|
|
504
504
|
const checkedOutAt = worktreeCheckedOutAt(repo, landBranch);
|
|
505
505
|
if (checkedOutAt) {
|
|
506
|
-
throw refusal(`refusing to promote ${landBranch}: it is checked out in ${checkedOutAt}`, "land_branch_checked_out");
|
|
506
|
+
throw refusal(`refusing to promote ${landBranch}: it is checked out in ${checkedOutAt}; ${landBranchCheckedOutRemedy(checkedOutAt)}`, "land_branch_checked_out");
|
|
507
507
|
}
|
|
508
508
|
const branchRef = `refs/heads/${landBranch}`;
|
|
509
509
|
const current = gitHead(repo, branchRef);
|
|
@@ -540,6 +540,11 @@ function refusal(message, code) {
|
|
|
540
540
|
return Object.assign(new Error(message), { code });
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
+
/** @param {string} checkedOutAt @returns {string} */
|
|
544
|
+
function landBranchCheckedOutRemedy(checkedOutAt) {
|
|
545
|
+
return `detach the checkout at ${checkedOutAt} or run the coordinator from a worktree`;
|
|
546
|
+
}
|
|
547
|
+
|
|
543
548
|
/** @param {string} path @returns {string} */
|
|
544
549
|
function readTextFile(path) {
|
|
545
550
|
return readFileSync(path, "utf8");
|
package/src/report/render.mjs
CHANGED
|
@@ -26,7 +26,7 @@ const POINTER_ATTENTION_CHARS = 80;
|
|
|
26
26
|
/** @typedef {{costUsd: number|null, costProvenance: CostProvenance, inputTokens: number, outputTokens: number, cacheReadInputTokens: number, pricedInvocations: number, unpricedInvocations: number}} RoleUsage */
|
|
27
27
|
/** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
|
|
28
28
|
/** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
|
|
29
|
-
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
|
|
29
|
+
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
|
|
30
30
|
/** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
|
|
31
31
|
|
|
32
32
|
/** The glyph each terminal state prints in a status table. */
|
|
@@ -43,13 +43,11 @@ export const MARK = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* `status <run-dir>`:
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* `
|
|
50
|
-
*
|
|
51
|
-
* used only for the two facts the payload does not carry: throwing on an
|
|
52
|
-
* invalid persisted snapshot, and `controllerStatus`'s lock read.
|
|
46
|
+
* `status <run-dir>`: needs-you, now, nodes, cost, in the dashboard's order
|
|
47
|
+
* (TECH-SPEC lean, section 4's last paragraph). Every cell comes from the same
|
|
48
|
+
* payload `status --json` emits (`buildStatusPayload`); `nodes` and
|
|
49
|
+
* `identityWarnings` from `loadRun` cover only what the payload does not:
|
|
50
|
+
* throwing on an invalid snapshot, and `controllerStatus`'s lock read.
|
|
53
51
|
*
|
|
54
52
|
* @param {string} runDir
|
|
55
53
|
* @returns {string}
|
|
@@ -75,7 +73,7 @@ export function renderStatus(runDir) {
|
|
|
75
73
|
const widths = [3, 24, 9, 3, 28, 8, 6, 6, 6, 10, 9, MAX_NOTE_LENGTH];
|
|
76
74
|
/** @type {(cells: unknown[]) => string} */
|
|
77
75
|
const row = (cells) => cells.map((cell, i) => fit(String(cell ?? ""), widths[i])).join(" ");
|
|
78
|
-
lines.push("```", row(["", "NODE", "STATE", "TRY", "RUNTIME", "ELAPSED", "IN", "CACHE", "OUT", "USD", "
|
|
76
|
+
lines.push("```", row(["", "NODE", "STATE", "TRY", "RUNTIME", "ELAPSED", "IN", "CACHE", "OUT", "USD", "GATE", "NOTE"]), row(widths.map((width) => "-".repeat(width))));
|
|
79
77
|
for (const node of payload.nodes) {
|
|
80
78
|
lines.push(row([
|
|
81
79
|
MARK[node.status] ?? "[?]",
|
|
@@ -88,7 +86,7 @@ export function renderStatus(runDir) {
|
|
|
88
86
|
compactTokens(node.usage?.cacheReadInputTokens),
|
|
89
87
|
compactTokens(node.usage?.outputTokens),
|
|
90
88
|
compactCost(node.costUsd),
|
|
91
|
-
node.
|
|
89
|
+
node.gateOutcome ?? "-",
|
|
92
90
|
node.note ?? "-",
|
|
93
91
|
]));
|
|
94
92
|
}
|
|
@@ -98,12 +96,10 @@ export function renderStatus(runDir) {
|
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
/**
|
|
101
|
-
* The node the operator should look at right now, formatted the
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* appends which one, `k/n`, and its argv, so a minutes-long suite is not
|
|
106
|
-
* silent between ticks.
|
|
99
|
+
* The node the operator should look at right now, formatted like the
|
|
100
|
+
* dashboard's now strip (TECH-SPEC lean, section 4, item 1): elapsed time and
|
|
101
|
+
* cost so far, or idle once every node has settled. A node awaiting a
|
|
102
|
+
* controller verification command appends which one, `k/n`, and its argv.
|
|
107
103
|
*
|
|
108
104
|
* @param {StatusPayload} payload
|
|
109
105
|
* @param {number} now epoch ms
|
|
@@ -114,7 +110,8 @@ function nowLine(payload, now) {
|
|
|
114
110
|
if (active) {
|
|
115
111
|
const progress = active.verificationProgress;
|
|
116
112
|
const verification = progress ? ` · verification ${progress.index}/${progress.total} · ${progress.argv}` : "";
|
|
117
|
-
|
|
113
|
+
const candidate = !progress && active.executionPhase === "candidate" ? " · candidate verification" : "";
|
|
114
|
+
return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}${verification}${candidate}`;
|
|
118
115
|
}
|
|
119
116
|
const allTerminal = payload.nodes.every((node) => SUCCESS.has(node.status));
|
|
120
117
|
return allTerminal ? `now: idle · run done · ${compactCost(payload.usage.costUsd)}` : "now: idle";
|
|
@@ -134,6 +131,27 @@ function verificationProgress(node) {
|
|
|
134
131
|
return verification?.progress ?? null;
|
|
135
132
|
}
|
|
136
133
|
|
|
134
|
+
/** Set by `verifyCandidateWorkspace` (engine/verify.mjs) for the run of the sealed candidate's own re-verification pass. @param {NodeSnapshot} node @returns {boolean} */
|
|
135
|
+
function candidateVerificationActive(node) {
|
|
136
|
+
const verification = /** @type {{candidate?: boolean}|null|undefined} */ (node.verification);
|
|
137
|
+
return verification?.candidate === true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `candidate` while re-verifying the sealed candidate, `verification` mid-attempt-verification, else the persisted phase; the two never overlap. @param {NodeSnapshot} node @returns {string|null} */
|
|
141
|
+
function executionPhaseOf(node) {
|
|
142
|
+
if (candidateVerificationActive(node)) return "candidate";
|
|
143
|
+
if (verificationProgress(node)) return "verification";
|
|
144
|
+
return node.phase;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What the gate decided (independent of `verdict`: an advisory review or a below-threshold fail verdict still accepts the node, engine/review.mjs `applyJudgeResult`). @param {NodeSnapshot} node @returns {"passed"|"rejected"|null} */
|
|
148
|
+
export function gateOutcome(node) {
|
|
149
|
+
if (!node.gate) return null;
|
|
150
|
+
if (SUCCESS.has(node.status)) return "passed";
|
|
151
|
+
if (node.status === "failed" || node.status === "exhausted") return "rejected";
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
137
155
|
/**
|
|
138
156
|
* A node's wall-clock elapsed time: `startedAt` to `updatedAt` once it has
|
|
139
157
|
* settled into a terminal state, `startedAt` to `now` while it is still
|
|
@@ -216,7 +234,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
216
234
|
// A running controller verification command does not move
|
|
217
235
|
// `node.phase` (it stays `worker`, the phase that dispatched it), so
|
|
218
236
|
// the surface that shows it is computed here rather than persisted.
|
|
219
|
-
executionPhase:
|
|
237
|
+
executionPhase: executionPhaseOf(node),
|
|
220
238
|
runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
|
|
221
239
|
workerRuntime: workerRuntimeLabel(node),
|
|
222
240
|
continuation: continuationMode(node),
|
|
@@ -227,6 +245,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
227
245
|
usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
|
|
228
246
|
costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
|
|
229
247
|
verdict: node.gate?.verdict ?? null,
|
|
248
|
+
gateOutcome: gateOutcome(node),
|
|
230
249
|
pendingHandoff: pendingHandoff(node),
|
|
231
250
|
note: statusNote(node),
|
|
232
251
|
scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
|
|
@@ -256,16 +275,11 @@ function activeStatusNode(nodes) {
|
|
|
256
275
|
}
|
|
257
276
|
|
|
258
277
|
/**
|
|
259
|
-
* The bounded pointer record written to `.runs/status.json
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* no clock and only jq's builtin `now` can compute an age from a live clock,
|
|
265
|
-
* and neither path needs a `date` process to read an integer. `elapsedSec`
|
|
266
|
-
* is likewise precomputed here (as of `generatedAt`, not live) so the
|
|
267
|
-
* statusline never has to subtract two timestamps to show it — it just
|
|
268
|
-
* prints the integer, whichever reader it is.
|
|
278
|
+
* The bounded pointer record written to `.runs/status.json`, for a quick
|
|
279
|
+
* ambient read without opening the per-run status.json (bounded like the old
|
|
280
|
+
* heartbeat.json). `generatedAt` is unix seconds, not ISO, and `elapsedSec` is
|
|
281
|
+
* precomputed from it, since the no-jq statusline fallback has no clock to
|
|
282
|
+
* subtract two timestamps with.
|
|
269
283
|
*
|
|
270
284
|
* @param {JsonObject} payload the per-run status.json payload
|
|
271
285
|
* @param {NodeSnapshot[]} nodes
|
|
@@ -340,10 +354,7 @@ export function writeStatusArtifacts(runDir, runsDir, contract, states) {
|
|
|
340
354
|
*/
|
|
341
355
|
export function controllerStatus(runDir, nodes) {
|
|
342
356
|
const lock = readLock(runDir);
|
|
343
|
-
// The heartbeat's `at`
|
|
344
|
-
// one are distinguishable on disk. Before this it was hard-coded null here
|
|
345
|
-
// and computed from node updates only once the lock was already stale, which
|
|
346
|
-
// is why all 47 recorded status.json files reported a null lastTick.
|
|
357
|
+
// The heartbeat's `at` distinguishes a working controller from a dead one on disk.
|
|
347
358
|
const heartbeat = readHeartbeat(runDir);
|
|
348
359
|
const lastTick = typeof heartbeat?.at === "string" ? heartbeat.at : null;
|
|
349
360
|
if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) {
|
|
@@ -473,14 +484,11 @@ export function renderFindings(runDir) {
|
|
|
473
484
|
}
|
|
474
485
|
|
|
475
486
|
/**
|
|
476
|
-
* A node the worker itself stopped on, rendered as the repair it asks for
|
|
477
|
-
*
|
|
478
|
-
* `
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
* where three nodes were blocked and the command printed one line saying so.
|
|
482
|
-
* The question is already structured, so the repair is mechanical: put the
|
|
483
|
-
* named paths in the packet's `readFiles` and take a new run id.
|
|
487
|
+
* A node the worker itself stopped on, rendered as the repair it asks for:
|
|
488
|
+
* `findings` used to answer only gate exhaustion, so a run whose nodes all
|
|
489
|
+
* stopped on `blocked_context` reported nothing to act on (measured on a
|
|
490
|
+
* four-node campaign with three nodes blocked). The repair is mechanical: put
|
|
491
|
+
* the named paths in the packet's `readFiles` and take a new run id.
|
|
484
492
|
*
|
|
485
493
|
* @param {NodeSnapshot} node
|
|
486
494
|
* @returns {string|null}
|
|
@@ -553,16 +561,11 @@ function continuationMode(node) {
|
|
|
553
561
|
}
|
|
554
562
|
|
|
555
563
|
/**
|
|
556
|
-
* Who produced this node's work, for the RUNTIME column
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* were five different harnesses rendered as though three of them had never
|
|
562
|
-
* run (measured 2026-09-13). The invocation ledger keeps both roles, so the
|
|
563
|
-
* label is derived from it: the worker that ran, falling back to the live
|
|
564
|
-
* runtime for a node that has not dispatched one yet. The judge is not lost —
|
|
565
|
-
* it owns the VERDICT column, and `nowLine` still names whatever is running.
|
|
564
|
+
* Who produced this node's work, for the RUNTIME column: `state.runtime` is
|
|
565
|
+
* the last runtime *dispatched*, and the judge dispatch overwrites the
|
|
566
|
+
* worker's (measured 2026-09-13: a six-harness campaign rendered as though
|
|
567
|
+
* three workers never ran), so this reads the invocation ledger for the
|
|
568
|
+
* worker that ran, falling back to the live runtime if none dispatched yet.
|
|
566
569
|
*
|
|
567
570
|
* @param {NodeSnapshot} node
|
|
568
571
|
* @returns {string|null}
|
|
@@ -633,7 +636,7 @@ function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
|
|
|
633
636
|
export function statusNote(node) {
|
|
634
637
|
const scope = scopeFindingsNote(node.scopeFindings);
|
|
635
638
|
const review = reviewNote(node);
|
|
636
|
-
const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? node.phase;
|
|
639
|
+
const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? (candidateVerificationActive(node) ? "candidate" : node.phase);
|
|
637
640
|
const note = boundedNote([review, detail]);
|
|
638
641
|
if (!scope) return note;
|
|
639
642
|
return boundedNote([scope, note]);
|
|
@@ -657,13 +660,10 @@ function nodeNote(node) {
|
|
|
657
660
|
/** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
|
|
658
661
|
|
|
659
662
|
/**
|
|
660
|
-
* Per-role usage from the invocation ledger
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
* its `costUsd` stays null rather than summing the known half and understating
|
|
665
|
-
* it. A role with no invocation is `none`. Token totals are always carried, so
|
|
666
|
-
* a role the provider would not price still reads as work.
|
|
663
|
+
* Per-role usage from the invocation ledger, with provenance for whether the
|
|
664
|
+
* cost total is honest: `priced` (all invocations costed), `partial`/`unpriced`
|
|
665
|
+
* (some/none costed, so `costUsd` stays null rather than understating), or
|
|
666
|
+
* `none` (no invocation). Token totals are always carried.
|
|
667
667
|
*
|
|
668
668
|
* @param {NodeSnapshot[]} nodes
|
|
669
669
|
* @returns {{worker: RoleUsage, judge: RoleUsage}}
|
package/src/web/index.html
CHANGED
|
@@ -185,18 +185,18 @@ function tabBodyHtml(tab, detail) {
|
|
|
185
185
|
function drawerHtml(drawer, tab) {
|
|
186
186
|
if (!drawer) return `<section id="drawer"><h2>Run drawer</h2><div class="card" style="padding:16px 18px"><p class="empty">select a run above</p></div></section>`;
|
|
187
187
|
const rows = drawer.nodes.map((node) => `<tr class="clickable ${node.id === drawer.selectedNodeId ? "selected" : ""}" data-node="${esc(node.id)}">
|
|
188
|
-
<td>${pill(node.status)}</td>
|
|
188
|
+
<td>${pill(node.status)}${node.executionPhase === "candidate" ? ` <span class="dim">· candidate verification</span>` : node.executionPhase === "verification" ? ` <span class="dim">· verification</span>` : ""}</td>
|
|
189
189
|
<td class="mono">${esc(node.id)}</td>
|
|
190
190
|
<td class="num">${node.attempt}</td>
|
|
191
191
|
<td class="mono">${esc(node.model ?? "–")}</td>
|
|
192
192
|
<td class="num">${fmtDur(node.elapsedMs)}</td>
|
|
193
193
|
<td class="num">${fmtUsd(node.costUsd)}</td>
|
|
194
|
-
<td>${node.
|
|
194
|
+
<td>${node.gateOutcome ? pill(node.gateOutcome) : ""}</td>
|
|
195
195
|
<td class="goal" title="${esc(node.note ?? "")}">${esc(node.note ?? "")}</td>
|
|
196
196
|
</tr>`).join("");
|
|
197
197
|
const tabs = TABS.map(([key, label]) => `<button data-tab="${key}" class="${tab === key ? "active" : ""}">${esc(label)}</button>`).join("");
|
|
198
198
|
return `<section id="drawer"><h2>Run drawer · ${esc(drawer.runId)}</h2><div class="card" style="padding:16px 18px">
|
|
199
|
-
<table><thead><tr><th>state</th><th>node</th><th>try</th><th>model</th><th>elapsed</th><th>USD</th><th>
|
|
199
|
+
<table><thead><tr><th>state</th><th>node</th><th>try</th><th>model</th><th>elapsed</th><th>USD</th><th>gate</th><th>note</th></tr></thead>
|
|
200
200
|
<tbody>${rows}</tbody></table>
|
|
201
201
|
<div class="tabs">${tabs}</div>
|
|
202
202
|
<div id="tabbody">${tabBodyHtml(tab, drawer.detail)}</div>
|
package/src/web/server.mjs
CHANGED
|
@@ -294,7 +294,8 @@ function nodeRow(runDir, statusNode) {
|
|
|
294
294
|
model: node.runtime?.model ?? null,
|
|
295
295
|
elapsedMs: elapsedMsBetween(node.startedAt, closed ? node.updatedAt : null),
|
|
296
296
|
costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
|
|
297
|
-
|
|
297
|
+
gateOutcome: statusNode.gateOutcome ?? null,
|
|
298
|
+
executionPhase: statusNode.executionPhase ?? null,
|
|
298
299
|
note: statusNode.note ?? null,
|
|
299
300
|
errorCode: statusNode.errorCode ?? null,
|
|
300
301
|
blockedBy: statusNode.blockedBy ?? [],
|