bareloop 0.27.0 → 0.29.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/CHANGELOG.md +114 -0
- package/bareloop.context.md +245 -33
- package/package.json +1 -1
- package/src/authorreadout.js +323 -0
- package/src/authorrun.js +1345 -0
- package/src/behaviour.js +5 -2
- package/src/cli.js +190 -8
- package/src/index.js +13 -1
- package/src/interviewrun.js +842 -0
- package/src/panel/index.html +1910 -0
- package/src/panel/server.js +1527 -0
- package/src/planrun.js +1 -1
- package/src/replay.js +632 -13
- package/src/replayio.js +159 -0
- package/src/reuse.js +1 -1
- package/src/run.js +1 -1
- package/src/runlist.js +281 -0
- package/src/u-patient.js +73 -0
- package/src/u-readout.js +451 -0
- package/src/userrun.js +2299 -0
- package/types/authorreadout.d.ts +172 -0
- package/types/authorrun.d.ts +37 -0
- package/types/behaviour.d.ts +5 -3
- package/types/cli.d.ts +2 -1
- package/types/index.d.ts +2 -1
- package/types/interviewrun.d.ts +46 -0
- package/types/panel/server.d.ts +265 -0
- package/types/planrun.d.ts +2 -2
- package/types/replay.d.ts +104 -6
- package/types/replayio.d.ts +93 -0
- package/types/reuse.d.ts +2 -2
- package/types/run.d.ts +2 -2
- package/types/runlist.d.ts +112 -0
- package/types/u-patient.d.ts +39 -0
- package/types/u-readout.d.ts +247 -0
- package/types/userrun.d.ts +141 -0
package/src/replay.js
CHANGED
|
@@ -128,6 +128,45 @@ function parseTs(ts) {
|
|
|
128
128
|
return Number.isFinite(n) ? n : null;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* `[startTs, endTs]` (epoch ms; `endTs` possibly `Infinity`) bounding the
|
|
133
|
+
* gate-audit rows that actually belong to ONE run's own spine. A sidecar
|
|
134
|
+
* file can be SHARED across several runs whose spines happen to reuse the
|
|
135
|
+
* same filename (measured directly on a real archived patient —
|
|
136
|
+
* pulselog-person-live-2, run mu2p83go: its sidecar carries rows from 7
|
|
137
|
+
* distinct `run_id`s spanning 06:56Z..13:25Z on one day, but this run's own
|
|
138
|
+
* `job-start`..`job-end` window is 13:19:40Z..13:25:48Z; every row before
|
|
139
|
+
* that window belongs to an earlier, unrelated run sharing the sidecar
|
|
140
|
+
* path). No `step`/`run_id` field on a gate-audit row is a reliable enough
|
|
141
|
+
* key on its own (see this file's header) — bounding by `ts` against this
|
|
142
|
+
* run's own `job-start`/`job-end` is the only honest scope.
|
|
143
|
+
*
|
|
144
|
+
* `startTs` is this run's own `job-start.ts` (`-Infinity` when absent —
|
|
145
|
+
* never a guessed lower bound). `endTs` is `job-end.ts` when a `job-end`
|
|
146
|
+
* was actually reached, else `Infinity` (a died/still-running run — never
|
|
147
|
+
* clamped to the spine's own last record's `ts`, which would wrongly
|
|
148
|
+
* exclude a real tool call the gate logged just AFTER the spine's own last
|
|
149
|
+
* record landed: spine and gate-audit are written by different seams and
|
|
150
|
+
* are not guaranteed to interleave exactly).
|
|
151
|
+
*
|
|
152
|
+
* ONE shared owner for this rule (F195): `replayRun` (this file) uses it to
|
|
153
|
+
* scope every audit row it hands to `runBehaviour`, so `bareloop replay`
|
|
154
|
+
* and every caller of `replayRun`/`replayOne` (including the panel,
|
|
155
|
+
* `src/panel/server.js`) read the same, honest figure — never two places
|
|
156
|
+
* independently re-deriving (and risking drifting from) the same rule.
|
|
157
|
+
* @param {{ts?: string}|null} [jobStart]
|
|
158
|
+
* @param {{ts?: string}|null} [jobEnd]
|
|
159
|
+
* @returns {{startTs: number, endTs: number}}
|
|
160
|
+
*/
|
|
161
|
+
export function auditWindow(jobStart, jobEnd) {
|
|
162
|
+
const startTs = jobStart ? parseTs(jobStart.ts) : null;
|
|
163
|
+
const endTs = jobEnd ? parseTs(jobEnd.ts) : null;
|
|
164
|
+
return {
|
|
165
|
+
startTs: startTs !== null ? startTs : -Infinity,
|
|
166
|
+
endTs: endTs !== null ? endTs : Infinity,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
131
170
|
/**
|
|
132
171
|
* Trim a string to `max` chars with an explicit `…[+N chars]` marker —
|
|
133
172
|
* NEVER a silent truncation, and never a trailing ellipsis alone
|
|
@@ -356,10 +395,18 @@ function resolveClose(spine) {
|
|
|
356
395
|
*
|
|
357
396
|
* @param {any[]} spineEvents parsed records from `u-<id>.jsonl`
|
|
358
397
|
* @param {any[]} [auditEvents] parsed records from the sibling `-gate-audit.jsonl`
|
|
359
|
-
* @param {{runId?: string|null}} [opts] `runId`:
|
|
360
|
-
* carries no run-id field of its own — see the
|
|
398
|
+
* @param {{runId?: string|null, auditAvailable?: boolean}} [opts] `runId`:
|
|
399
|
+
* caller-supplied (the spine carries no run-id field of its own — see the
|
|
400
|
+
* file header). `auditAvailable` (default `true`, back-compat: every
|
|
401
|
+
* direct caller in this codebase that hands a literal `[]` here means a
|
|
402
|
+
* REAL, known-empty audit, not "unknown") — set `false` only when the
|
|
403
|
+
* caller could not even determine whether a gate-audit sidecar exists (no
|
|
404
|
+
* file found on disk, or deliberately not read). When `false`, every
|
|
405
|
+
* tool-call figure this function derives (`behaviour`, each occurrence's
|
|
406
|
+
* `toolCalls`) reads `null` (unknown), never `0` — doctrine: unknown is
|
|
407
|
+
* reported as unknown, never rendered as zero.
|
|
361
408
|
*/
|
|
362
|
-
export function replayRun(spineEvents, auditEvents = [], { runId = null } = {}) {
|
|
409
|
+
export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAvailable = true } = {}) {
|
|
363
410
|
let skipped = 0;
|
|
364
411
|
|
|
365
412
|
/** @type {any[]} */
|
|
@@ -369,7 +416,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
369
416
|
else skipped += 1;
|
|
370
417
|
}
|
|
371
418
|
/** @type {any[]} */
|
|
372
|
-
|
|
419
|
+
let audit = [];
|
|
373
420
|
for (const e of Array.isArray(auditEvents) ? auditEvents : []) {
|
|
374
421
|
if (isRecord(e)) audit.push(e);
|
|
375
422
|
else skipped += 1;
|
|
@@ -377,6 +424,30 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
377
424
|
|
|
378
425
|
const jobStart = spine.find((e) => e.type === 'job-start') ?? null;
|
|
379
426
|
const jobEnd = spine.findLast((e) => e.type === 'job-end') ?? null;
|
|
427
|
+
|
|
428
|
+
// F195 fix: a gate-audit sidecar CAN be shared across several runs whose
|
|
429
|
+
// spines happen to reuse the same filename (measured on a real archived
|
|
430
|
+
// patient — pulselog-person-live-2's mu2p83go: its sidecar carries rows
|
|
431
|
+
// from 7 distinct run_ids spanning 06:56Z..13:25Z on one day, while this
|
|
432
|
+
// run's own job-start..job-end window is only 13:19:40Z..13:25:48Z).
|
|
433
|
+
// Scope `audit` to THIS run's own window (see {@link auditWindow}'s own
|
|
434
|
+
// doc) before any downstream use — the top-level `behaviour` field below
|
|
435
|
+
// (previously fed the raw, unscoped array: `bareloop replay` printed 142
|
|
436
|
+
// tool calls on mu2p83go where the real figure for this run alone is 82)
|
|
437
|
+
// AND every per-occurrence/per-attempt window `buildOccurrenceMetrics`
|
|
438
|
+
// computes further down, which is a strict subset of this run's own
|
|
439
|
+
// window anyway and so is unaffected in the common (single-owner sidecar)
|
|
440
|
+
// case. This is now the ONE place that applies the rule — the panel
|
|
441
|
+
// (`src/panel/server.js`) calls this same {@link auditWindow} rather than
|
|
442
|
+
// keeping its own copy.
|
|
443
|
+
{
|
|
444
|
+
const { startTs, endTs } = auditWindow(jobStart, jobEnd);
|
|
445
|
+
audit = audit.filter((a) => {
|
|
446
|
+
const t = parseTs(a.ts);
|
|
447
|
+
return t !== null && t >= startTs && t <= endTs;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
380
451
|
const outcome = jobEnd ? (jobEnd.outcome ?? null) : null;
|
|
381
452
|
const isGreen = outcome === 'green' || outcome === 'already-green';
|
|
382
453
|
|
|
@@ -537,7 +608,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
537
608
|
const { spentUsd, unpricedRounds } = windowSpend(roundsInWindow);
|
|
538
609
|
return {
|
|
539
610
|
rounds: roundsInWindow.length,
|
|
540
|
-
toolCalls: runBehaviour(windowed).totalCalls,
|
|
611
|
+
toolCalls: auditAvailable ? runBehaviour(windowed).totalCalls : null,
|
|
541
612
|
wallMs: windowWallMs(startTs, endTs),
|
|
542
613
|
spentUsd,
|
|
543
614
|
unpricedRounds,
|
|
@@ -546,6 +617,33 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
546
617
|
};
|
|
547
618
|
}
|
|
548
619
|
|
|
620
|
+
// ── SCOUT+PLAN (panel build item 1, 2026-09-26): a PLAN run's own
|
|
621
|
+
// pre-step drafting work (`phase:'scout'` and `phase:'plan'` worker rounds,
|
|
622
|
+
// src/planrun.js:2735,2515) — the OTHER half of the same invisibility
|
|
623
|
+
// fixLoop above closes: before this, only the plan's own steps ever showed
|
|
624
|
+
// on the Run tab, so a run's first ~10 rounds (drafting materials, scouting
|
|
625
|
+
// the repo, validating the plan) had no box of their own at all. Scoped to
|
|
626
|
+
// seq/ts strictly BEFORE the first step-start (the only place scout/plan
|
|
627
|
+
// rounds occur on a steps-shape run — measured on mu2p83go: scout×8 + plan×1
|
|
628
|
+
// all precede the run's one step-start at seq 34). `null` when this run has
|
|
629
|
+
// no steps (nothing to be "pre-" to) or no scout/plan rounds at all (an
|
|
630
|
+
// older archived spine, or a run resumed past drafting).
|
|
631
|
+
let scoutPlan = null;
|
|
632
|
+
if (stepStarts.length > 0) {
|
|
633
|
+
const firstStepStart = stepStarts.reduce((first, ss) => (typeof ss.seq === 'number' && (first === null || ss.seq < first.seq) ? ss : first), /** @type {any} */ (null));
|
|
634
|
+
const firstSeq = firstStepStart && typeof firstStepStart.seq === 'number' ? firstStepStart.seq : Infinity;
|
|
635
|
+
const firstTs = firstStepStart ? parseTs(firstStepStart.ts) : null;
|
|
636
|
+
const roundsInWindow = spendRecords.filter((r) => (r.phase === 'scout' || r.phase === 'plan') && typeof r.seq === 'number' && r.seq < firstSeq);
|
|
637
|
+
if (roundsInWindow.length > 0) {
|
|
638
|
+
const jobStartTs = parseTs(jobStart?.ts);
|
|
639
|
+
scoutPlan = {
|
|
640
|
+
scoutRounds: roundsInWindow.filter((r) => r.phase === 'scout').length,
|
|
641
|
+
planRounds: roundsInWindow.filter((r) => r.phase === 'plan').length,
|
|
642
|
+
...buildOccurrenceMetrics(-Infinity, jobStartTs, firstSeq, firstTs, roundsInWindow),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
549
647
|
const usedEndSeqs = new Set();
|
|
550
648
|
const idOccurrence = new Map();
|
|
551
649
|
|
|
@@ -574,14 +672,66 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
574
672
|
let passed = 0;
|
|
575
673
|
let failed = 0;
|
|
576
674
|
let treeChanged = false;
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
675
|
+
// `attempts` (panel item 4, 2026-09-25): one entry per exit-eval that
|
|
676
|
+
// actually ran inside this step's own window — an "attempt" being one
|
|
677
|
+
// iteration of the step's own micro-loop (worker works, then the step's
|
|
678
|
+
// check runs, src/planrun.js's `ralph({judge,...})`). An attempt's own
|
|
679
|
+
// outcome is GREEN only when EVERY result the exit-eval carries passed
|
|
680
|
+
// (not just `check-passes` — `tree-changed` failing is also a real
|
|
681
|
+
// attempt failure); an exit-eval with an EMPTY `results` array is
|
|
682
|
+
// reported RED, never a fabricated green (nothing passed). Ordered by
|
|
683
|
+
// `seq` (the array is already built in spine order, but sorted
|
|
684
|
+
// defensively since a caller could hand in an out-of-order array).
|
|
685
|
+
/** @type {Array<{n: number, iteration: number|null, outcome: 'green'|'red'}>} */
|
|
686
|
+
const attempts = [];
|
|
687
|
+
// `attemptWindows` (panel item 5, 2026-09-25): the SAME per-attempt list
|
|
688
|
+
// as `attempts` above, plus each attempt's own seq/ts sub-window
|
|
689
|
+
// (`(prevBoundarySeq, thisExitEvalSeq]`, first attempt's lower bound is
|
|
690
|
+
// the step's own `startSeq`/`startTs`) — never a second windowing pass:
|
|
691
|
+
// this is the ONE loop that decides attempt boundaries; `attempts` is
|
|
692
|
+
// just this array's public-facing projection. Consumed by the
|
|
693
|
+
// `/api/runs/:runid/rounds` endpoint (`src/panel/server.js`) to slice
|
|
694
|
+
// this attempt's own rounds/tool-calls out of the full spine+gate-audit,
|
|
695
|
+
// never recomputed a second way there either.
|
|
696
|
+
/** @type {Array<{n: number, iteration: number|null, outcome: 'green'|'red', startSeq: number, endSeq: number, startTs: number|null, endTs: number|null, exitEvalDetail: string|null}>} */
|
|
697
|
+
const attemptWindows = [];
|
|
698
|
+
const stepExitEvals = exitEvals
|
|
699
|
+
.filter((ee) => ee.step === id && typeof ee.seq === 'number' && ee.seq > startSeq && ee.seq < endSeq)
|
|
700
|
+
.sort((a, b) => a.seq - b.seq);
|
|
701
|
+
let prevBoundSeq = startSeq;
|
|
702
|
+
let prevBoundTs = startTs;
|
|
703
|
+
for (const ee of stepExitEvals) {
|
|
704
|
+
const results = Array.isArray(ee.results) ? ee.results.filter(isRecord) : [];
|
|
705
|
+
for (const r of results) {
|
|
582
706
|
if (r.type === 'check-passes') { if (r.pass) passed += 1; else failed += 1; }
|
|
583
707
|
if (r.type === 'tree-changed' && r.pass) treeChanged = true;
|
|
584
708
|
}
|
|
709
|
+
const allPass = results.length > 0 && results.every((r) => r.pass === true);
|
|
710
|
+
const eeSeq = typeof ee.seq === 'number' ? ee.seq : prevBoundSeq;
|
|
711
|
+
const eeTs = parseTs(ee.ts);
|
|
712
|
+
// the failing (or, on an all-pass attempt, the first) result's own
|
|
713
|
+
// `detail` — the closest thing a step-level exit-eval carries to a
|
|
714
|
+
// "gap line" (the outer close's own `gap` field has no per-step
|
|
715
|
+
// equivalent; see the file header's `close-verdict` doc).
|
|
716
|
+
const failing = results.find((r) => r.pass !== true);
|
|
717
|
+
const detailSource = failing ?? results[0] ?? null;
|
|
718
|
+
attempts.push({
|
|
719
|
+
n: attempts.length + 1,
|
|
720
|
+
iteration: typeof ee.iteration === 'number' ? ee.iteration : null,
|
|
721
|
+
outcome: allPass ? 'green' : 'red',
|
|
722
|
+
});
|
|
723
|
+
attemptWindows.push({
|
|
724
|
+
n: attemptWindows.length + 1,
|
|
725
|
+
iteration: typeof ee.iteration === 'number' ? ee.iteration : null,
|
|
726
|
+
outcome: allPass ? 'green' : 'red',
|
|
727
|
+
startSeq: prevBoundSeq,
|
|
728
|
+
endSeq: eeSeq,
|
|
729
|
+
startTs: prevBoundTs,
|
|
730
|
+
endTs: eeTs,
|
|
731
|
+
exitEvalDetail: detailSource && typeof detailSource.detail === 'string' ? detailSource.detail : null,
|
|
732
|
+
});
|
|
733
|
+
prevBoundSeq = eeSeq;
|
|
734
|
+
prevBoundTs = eeTs ?? prevBoundTs;
|
|
585
735
|
}
|
|
586
736
|
|
|
587
737
|
idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
|
|
@@ -593,6 +743,19 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
593
743
|
...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
|
|
594
744
|
checks: { passed, failed },
|
|
595
745
|
treeChanged,
|
|
746
|
+
attempts,
|
|
747
|
+
attemptWindows,
|
|
748
|
+
stepStartSeq: startSeq,
|
|
749
|
+
stepEndSeq: endSeq,
|
|
750
|
+
// `stepStartTs`/`stepEndTs` (panel item 2 follow-up, F195/F-audit-step):
|
|
751
|
+
// this step's own ts bounds, the same `startTs`/`endTs` already
|
|
752
|
+
// computed above for `buildOccurrenceMetrics` — exposed (never a
|
|
753
|
+
// second windowing pass) so a ts-keyed lookup (the Audit tab's `step`
|
|
754
|
+
// column) can place a row without re-deriving step boundaries a
|
|
755
|
+
// second way. `stepEndTs` is `null` when this occurrence never
|
|
756
|
+
// reached its own `step-end` (a died/still-running/escalated step).
|
|
757
|
+
stepStartTs: startTs,
|
|
758
|
+
stepEndTs: endTs,
|
|
596
759
|
};
|
|
597
760
|
});
|
|
598
761
|
|
|
@@ -604,6 +767,131 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
604
767
|
const timelineKind = /** @type {'steps'|'iterations'} */ (stepStarts.length > 0 ? 'steps' : (iterationStarts.length > 0 ? 'iterations' : 'steps'));
|
|
605
768
|
const boundaries = spine.filter((e) => e.type === 'close-verdict' || e.type === 'run-end' || e.type === 'escalation');
|
|
606
769
|
|
|
770
|
+
// ── FIX LOOP (panel build item 1, 2026-09-26): a PLAN run (timelineKind
|
|
771
|
+
// 'steps') can carry a SECOND loop after every step already ended — the
|
|
772
|
+
// operator's real close runs an `outer-close` PRECHECK, and when that
|
|
773
|
+
// comes back non-green a `fix-loop` (src/planrun.js:3539's `runCloseStages`
|
|
774
|
+
// loop) re-runs the close against fresh worker rounds (`phase:'fix'`)
|
|
775
|
+
// until it's satisfied or the run stops. Before this, every one of those
|
|
776
|
+
// rounds/iterations was invisible on the Run tab: `steps` only carries the
|
|
777
|
+
// plan's OWN steps, and the `iterations` timeline above is built ONLY when
|
|
778
|
+
// `timelineKind==='iterations'` (no step-start at all) — a plan run with a
|
|
779
|
+
// fix loop has BOTH a step-start AND post-step iteration-start records,
|
|
780
|
+
// and the latter were simply never read.
|
|
781
|
+
//
|
|
782
|
+
// Measured on the real archived run this was built against (mu2p83go):
|
|
783
|
+
// step-end at seq 61, then `outer-close` (needs_revision) at seq 62,
|
|
784
|
+
// `fix-loop`/run-start/iteration-start 1 at seq 63-65, two more
|
|
785
|
+
// needs_revision iterations, then iteration 3 satisfied at seq 108,
|
|
786
|
+
// run-end/job-end at 109/112. `fixLoop.attempts` reports this as 4
|
|
787
|
+
// attempts: the outer-close precheck first (attempt 1, red), then the
|
|
788
|
+
// fix-loop's own 3 iterations (2 red, 1 green) — the SAME "✗ ✗ ✗ ✓"
|
|
789
|
+
// sequence a person reading the raw spine by hand gets.
|
|
790
|
+
//
|
|
791
|
+
// Scoped by seq strictly AFTER the last step-end this spine reached (never
|
|
792
|
+
// before — the step's OWN micro-loop, src/planrun.js's `ralph({judge,...})`,
|
|
793
|
+
// also emits `iteration-start`/`close-verdict` and must not be double
|
|
794
|
+
// counted here; that loop is already fully captured by each step's own
|
|
795
|
+
// `attempts`/`attemptWindows` above, keyed off `exit-eval`, a completely
|
|
796
|
+
// separate emit type). `null` (never an empty-but-present object) when
|
|
797
|
+
// this run has no steps at all, OR no outer-close/fix-loop iteration
|
|
798
|
+
// exists after the last step ended (the overwhelmingly common case: a
|
|
799
|
+
// plan run whose close was satisfied on the first outer-close precheck
|
|
800
|
+
// never enters a fix loop, and this field must read exactly as it did
|
|
801
|
+
// before this addition for that run).
|
|
802
|
+
let fixLoop = null;
|
|
803
|
+
if (timelineKind === 'steps' && stepStarts.length > 0) {
|
|
804
|
+
const lastStepEndSeq = stepEnds.reduce((max, se) => (typeof se.seq === 'number' && se.seq > max ? se.seq : max), -Infinity);
|
|
805
|
+
const outerClosesAfter = spine
|
|
806
|
+
.filter((e) => e.type === 'outer-close' && typeof e.seq === 'number' && e.seq > lastStepEndSeq)
|
|
807
|
+
.sort((a, b) => a.seq - b.seq);
|
|
808
|
+
const iterationStartsAfter = iterationStarts
|
|
809
|
+
.filter((e) => typeof e.seq === 'number' && e.seq > lastStepEndSeq)
|
|
810
|
+
.sort((a, b) => a.seq - b.seq);
|
|
811
|
+
|
|
812
|
+
if (outerClosesAfter.length > 0 || iterationStartsAfter.length > 0) {
|
|
813
|
+
/** @type {any[]} */
|
|
814
|
+
const attempts = [];
|
|
815
|
+
|
|
816
|
+
// The outer-close precheck(s): each is a single point-in-time verdict,
|
|
817
|
+
// never its own round-producing window on its own (the rounds that led
|
|
818
|
+
// to it already belong to the LAST STEP, already reported there) —
|
|
819
|
+
// except the (rare) gap between step-end and the precheck itself,
|
|
820
|
+
// which this window still honestly captures rather than assuming zero.
|
|
821
|
+
let prevSeq = lastStepEndSeq;
|
|
822
|
+
let prevTs = stepEnds.length ? (stepEnds.reduce((latest, se) => {
|
|
823
|
+
const t = parseTs(se.ts);
|
|
824
|
+
return typeof se.seq === 'number' && se.seq === lastStepEndSeq && t !== null ? t : latest;
|
|
825
|
+
}, /** @type {number|null} */ (null))) : null;
|
|
826
|
+
for (const oc of outerClosesAfter) {
|
|
827
|
+
const seq = typeof oc.seq === 'number' ? oc.seq : Infinity;
|
|
828
|
+
const ts = parseTs(oc.ts);
|
|
829
|
+
const roundsInWindow = spendRecords.filter((r) => typeof r.seq === 'number' && r.seq > prevSeq && r.seq < seq);
|
|
830
|
+
const verdict = oc.verdict ?? null;
|
|
831
|
+
attempts.push({
|
|
832
|
+
n: attempts.length + 1,
|
|
833
|
+
source: /** @type {'outer-close'} */ ('outer-close'),
|
|
834
|
+
iteration: null,
|
|
835
|
+
verdict,
|
|
836
|
+
stages: Array.isArray(oc.stages) ? oc.stages : null,
|
|
837
|
+
outcome: /** @type {'green'|'red'} */ (verdict === 'satisfied' || verdict === 'green' || verdict === 'already-green' ? 'green' : 'red'),
|
|
838
|
+
...buildOccurrenceMetrics(prevSeq, prevTs, seq, ts, roundsInWindow),
|
|
839
|
+
windowStartSeq: prevSeq,
|
|
840
|
+
windowEndSeq: seq,
|
|
841
|
+
windowStartTs: prevTs,
|
|
842
|
+
windowEndTs: ts,
|
|
843
|
+
});
|
|
844
|
+
prevSeq = seq;
|
|
845
|
+
prevTs = ts;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// The fix-loop's own iterations: same windowing rule the `iterations`
|
|
849
|
+
// timeline above already uses (iteration-start .. next close-verdict/
|
|
850
|
+
// run-end/escalation), scoped to `> lastStepEndSeq` so the step's own
|
|
851
|
+
// micro-loop iterations never leak in.
|
|
852
|
+
for (const is of iterationStartsAfter) {
|
|
853
|
+
const startSeq = typeof is.seq === 'number' ? is.seq : -Infinity;
|
|
854
|
+
const startTs = parseTs(is.ts);
|
|
855
|
+
const end = boundaries
|
|
856
|
+
.filter((b) => typeof b.seq === 'number' && b.seq > startSeq)
|
|
857
|
+
.sort((a, b) => a.seq - b.seq)[0] ?? null;
|
|
858
|
+
const endSeq = end && typeof end.seq === 'number' ? end.seq : Infinity;
|
|
859
|
+
const endTs = end ? parseTs(end.ts) : null;
|
|
860
|
+
const roundsInWindow = spendRecords.filter((r) => typeof r.seq === 'number' && r.seq > startSeq && r.seq < endSeq);
|
|
861
|
+
const verdict = end
|
|
862
|
+
? (end.type === 'close-verdict' ? (end.verdict ?? null)
|
|
863
|
+
: end.type === 'run-end' ? (end.outcome ?? null)
|
|
864
|
+
: end.type === 'escalation' ? (end.category ?? null) : null)
|
|
865
|
+
: null;
|
|
866
|
+
attempts.push({
|
|
867
|
+
n: attempts.length + 1,
|
|
868
|
+
source: /** @type {'iteration'} */ ('iteration'),
|
|
869
|
+
iteration: typeof is.iteration === 'number' ? is.iteration : null,
|
|
870
|
+
verdict,
|
|
871
|
+
stages: end && end.type === 'close-verdict' && Array.isArray(end.stages) ? end.stages : null,
|
|
872
|
+
outcome: /** @type {'green'|'red'} */ (verdict === 'satisfied' || verdict === 'green' || verdict === 'already-green' ? 'green' : 'red'),
|
|
873
|
+
...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
|
|
874
|
+
windowStartSeq: startSeq,
|
|
875
|
+
windowEndSeq: endSeq,
|
|
876
|
+
windowStartTs: startTs,
|
|
877
|
+
windowEndTs: endTs,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const allToolsKnown = attempts.every((a) => typeof a.toolCalls === 'number');
|
|
882
|
+
const allWallKnown = attempts.every((a) => typeof a.wallMs === 'number');
|
|
883
|
+
const unpricedRounds = attempts.reduce((acc, a) => acc + a.unpricedRounds, 0);
|
|
884
|
+
fixLoop = {
|
|
885
|
+
attempts,
|
|
886
|
+
rounds: attempts.reduce((acc, a) => acc + a.rounds, 0),
|
|
887
|
+
toolCalls: allToolsKnown ? attempts.reduce((acc, a) => acc + (a.toolCalls ?? 0), 0) : null,
|
|
888
|
+
wallMs: allWallKnown ? attempts.reduce((acc, a) => acc + (a.wallMs ?? 0), 0) : null,
|
|
889
|
+
spentUsd: unpricedRounds > 0 ? null : attempts.reduce((acc, a) => acc + (a.spentUsd ?? 0), 0),
|
|
890
|
+
unpricedRounds,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
607
895
|
const iterations = timelineKind === 'iterations' ? iterationStarts.map((is) => {
|
|
608
896
|
const startSeq = typeof is.seq === 'number' ? is.seq : -Infinity;
|
|
609
897
|
const startTs = parseTs(is.ts);
|
|
@@ -634,6 +922,13 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
634
922
|
? { verdict: end.verdict ?? null, stages: Array.isArray(end.stages) ? end.stages : null }
|
|
635
923
|
: null,
|
|
636
924
|
...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
|
|
925
|
+
// exposed for panel item 5's `/api/runs/:runid/rounds` endpoint, which
|
|
926
|
+
// slices this iteration's own rounds/tool-calls the same way a plan
|
|
927
|
+
// step's `attemptWindows` does — never a second windowing pass.
|
|
928
|
+
windowStartSeq: startSeq,
|
|
929
|
+
windowEndSeq: endSeq,
|
|
930
|
+
windowStartTs: startTs,
|
|
931
|
+
windowEndTs: endTs,
|
|
637
932
|
};
|
|
638
933
|
}) : [];
|
|
639
934
|
|
|
@@ -668,6 +963,280 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
668
963
|
const shown = new Set([last, ...before]);
|
|
669
964
|
const escalationOutsideWindow = lastEscalation && !shown.has(lastEscalation) ? lastEscalation : null;
|
|
670
965
|
|
|
966
|
+
// ── PARTS (panel build item 6, 2026-09-26): ONE ordered list of this run's
|
|
967
|
+
// own high-level pieces — scout, plan, each step occurrence in run order,
|
|
968
|
+
// a replan window sitting between two steps, the fix loop, and (synthetic
|
|
969
|
+
// only — no real archive carries this yet) a leftover judge-round bucket.
|
|
970
|
+
// Computed ONCE here, server-side, so the Run tab's map+cards and the
|
|
971
|
+
// Audit tab's groups read off the exact same list and can never disagree
|
|
972
|
+
// on order or counts. A run with no phase data at all (no step-start: the
|
|
973
|
+
// pre-phase archive shape, or the older loop-tier `iterations` shape,
|
|
974
|
+
// neither of which carries a scout/plan/step split of its own) collapses
|
|
975
|
+
// to one box, `{kind:'run'}` — matching the build spec's "old runs with no
|
|
976
|
+
// phase data = one box 'run'".
|
|
977
|
+
/**
|
|
978
|
+
* @param {number|null} startTs
|
|
979
|
+
* @param {number|null} endTs
|
|
980
|
+
*/
|
|
981
|
+
function partBehaviour(startTs, endTs) {
|
|
982
|
+
if (!auditAvailable) return null;
|
|
983
|
+
const lo = startTs ?? -Infinity;
|
|
984
|
+
const hi = endTs ?? Infinity;
|
|
985
|
+
const windowed = audit.filter((a) => {
|
|
986
|
+
const t = parseTs(a.ts);
|
|
987
|
+
return t !== null && t >= lo && t <= hi;
|
|
988
|
+
});
|
|
989
|
+
return runBehaviour(windowed);
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* One synthetic "whole part" attempt — used by every part kind that has no
|
|
993
|
+
* attempt/iteration sub-loop of its own (scout, plan, replan, judge, run):
|
|
994
|
+
* the Audit tab expands these straight to their rounds table (build spec
|
|
995
|
+
* item C), never through a fake attempt list.
|
|
996
|
+
* @param {number} startSeq
|
|
997
|
+
* @param {number|null} startTs
|
|
998
|
+
* @param {number} endSeq
|
|
999
|
+
* @param {number|null} endTs
|
|
1000
|
+
*/
|
|
1001
|
+
function wholePartAttempt(startSeq, startTs, endSeq, endTs) {
|
|
1002
|
+
return [{
|
|
1003
|
+
n: 1, outcome: /** @type {null} */ (null), verdict: null, stages: null, detail: null, startSeq, endSeq, startTs, endTs,
|
|
1004
|
+
}];
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** @type {Array<any>} */
|
|
1008
|
+
const parts = [];
|
|
1009
|
+
|
|
1010
|
+
if (stepStarts.length === 0) {
|
|
1011
|
+
const jobStartTs = parseTs(jobStart?.ts);
|
|
1012
|
+
const jobEndTs = parseTs(jobEnd?.ts);
|
|
1013
|
+
const b = partBehaviour(jobStartTs, jobEndTs);
|
|
1014
|
+
parts.push({
|
|
1015
|
+
id: 'run',
|
|
1016
|
+
kind: 'run',
|
|
1017
|
+
label: 'run',
|
|
1018
|
+
number: null,
|
|
1019
|
+
occurrence: null,
|
|
1020
|
+
rounds: spendRecords.length,
|
|
1021
|
+
toolCalls: b ? b.totalCalls : null,
|
|
1022
|
+
byTool: b ? b.byTool : null,
|
|
1023
|
+
blocked: b ? b.denied : null,
|
|
1024
|
+
wallMs,
|
|
1025
|
+
spentUsd: thisFileSpend.value,
|
|
1026
|
+
unpricedRounds: thisFileSpend.unpricedRounds,
|
|
1027
|
+
outcome,
|
|
1028
|
+
attempts: wholePartAttempt(-Infinity, jobStartTs, Infinity, jobEndTs),
|
|
1029
|
+
});
|
|
1030
|
+
} else {
|
|
1031
|
+
const firstStepStart = stepStarts.reduce((first, ss) => (typeof ss.seq === 'number' && (first === null || ss.seq < first.seq) ? ss : first), /** @type {any} */ (null));
|
|
1032
|
+
const firstSeq = firstStepStart && typeof firstStepStart.seq === 'number' ? firstStepStart.seq : Infinity;
|
|
1033
|
+
const firstTs = firstStepStart ? parseTs(firstStepStart.ts) : null;
|
|
1034
|
+
const jobStartTs = parseTs(jobStart?.ts);
|
|
1035
|
+
|
|
1036
|
+
const scoutRoundsArr = spendRecords.filter((r) => r.phase === 'scout' && typeof r.seq === 'number' && r.seq < firstSeq);
|
|
1037
|
+
const planRoundsInitialArr = spendRecords.filter((r) => r.phase === 'plan' && typeof r.seq === 'number' && r.seq < firstSeq);
|
|
1038
|
+
// `scoutLastSeq` is the ONE seq boundary shared by both windows below —
|
|
1039
|
+
// scout's own upper bound and plan's own lower bound — so a round can
|
|
1040
|
+
// never land in both (or neither): each is a real spend record's own
|
|
1041
|
+
// `seq`, never a placeholder Infinity that would make the two windows
|
|
1042
|
+
// overlap (a `phase-lookup` consumer keying purely off seq needs this).
|
|
1043
|
+
const scoutLastSeq = scoutRoundsArr.length ? scoutRoundsArr[scoutRoundsArr.length - 1].seq : -Infinity;
|
|
1044
|
+
const scoutTsHi = planRoundsInitialArr.length ? parseTs(planRoundsInitialArr[0].ts) : firstTs;
|
|
1045
|
+
|
|
1046
|
+
if (scoutRoundsArr.length > 0) {
|
|
1047
|
+
const { spentUsd, unpricedRounds } = windowSpend(scoutRoundsArr);
|
|
1048
|
+
const b = partBehaviour(jobStartTs, scoutTsHi);
|
|
1049
|
+
parts.push({
|
|
1050
|
+
id: 'scout',
|
|
1051
|
+
kind: 'scout',
|
|
1052
|
+
label: 'scout',
|
|
1053
|
+
number: null,
|
|
1054
|
+
occurrence: null,
|
|
1055
|
+
rounds: scoutRoundsArr.length,
|
|
1056
|
+
toolCalls: b ? b.totalCalls : null,
|
|
1057
|
+
byTool: b ? b.byTool : null,
|
|
1058
|
+
blocked: b ? b.denied : null,
|
|
1059
|
+
wallMs: windowWallMs(jobStartTs, scoutTsHi),
|
|
1060
|
+
spentUsd,
|
|
1061
|
+
unpricedRounds,
|
|
1062
|
+
outcome: null,
|
|
1063
|
+
attempts: wholePartAttempt(-Infinity, jobStartTs, scoutLastSeq, scoutTsHi),
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
if (planRoundsInitialArr.length > 0) {
|
|
1067
|
+
const { spentUsd, unpricedRounds } = windowSpend(planRoundsInitialArr);
|
|
1068
|
+
const b = partBehaviour(scoutTsHi, firstTs);
|
|
1069
|
+
parts.push({
|
|
1070
|
+
id: 'plan',
|
|
1071
|
+
kind: 'plan',
|
|
1072
|
+
label: 'plan',
|
|
1073
|
+
number: null,
|
|
1074
|
+
occurrence: null,
|
|
1075
|
+
rounds: planRoundsInitialArr.length,
|
|
1076
|
+
toolCalls: b ? b.totalCalls : null,
|
|
1077
|
+
byTool: b ? b.byTool : null,
|
|
1078
|
+
blocked: b ? b.denied : null,
|
|
1079
|
+
wallMs: windowWallMs(scoutTsHi, firstTs),
|
|
1080
|
+
spentUsd,
|
|
1081
|
+
unpricedRounds,
|
|
1082
|
+
outcome: null,
|
|
1083
|
+
attempts: wholePartAttempt(scoutLastSeq, scoutTsHi, firstSeq, firstTs),
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// replans (panel build item 6): a `materials` record stamped
|
|
1088
|
+
// `phase:'replan'` between two consecutive steps marks a redraft — its
|
|
1089
|
+
// own worker rounds carry `phase:'plan'` (measured on a real archived
|
|
1090
|
+
// run, bareagent-u-bareloop/u-mshcpdg4.jsonl: `materials{phase:replan}`
|
|
1091
|
+
// at seq 72, then a `worker-round{phase:plan}` at seq 73, then the next
|
|
1092
|
+
// step's own `step-start` at seq 76 — the identical shape the run's
|
|
1093
|
+
// FIRST scout/plan window uses, just sitting later in the spine).
|
|
1094
|
+
const replanMarkers = spine
|
|
1095
|
+
.filter((e) => e.type === 'materials' && e.phase === 'replan' && typeof e.seq === 'number' && e.seq > firstSeq)
|
|
1096
|
+
.sort((a, b) => a.seq - b.seq);
|
|
1097
|
+
|
|
1098
|
+
let stepIdx = 0;
|
|
1099
|
+
for (const step of steps) {
|
|
1100
|
+
stepIdx += 1;
|
|
1101
|
+
// a replan marker sitting between the PREVIOUS step's own end and
|
|
1102
|
+
// THIS step's own start belongs here, immediately before this step's
|
|
1103
|
+
// box — matching run order.
|
|
1104
|
+
const prevStep = stepIdx > 1 ? steps[stepIdx - 2] : null;
|
|
1105
|
+
const windowLo = prevStep ? prevStep.stepEndSeq : firstSeq;
|
|
1106
|
+
const marker = replanMarkers.find((m) => typeof m.seq === 'number' && m.seq > windowLo && m.seq < step.stepStartSeq);
|
|
1107
|
+
if (marker) {
|
|
1108
|
+
const markerSeq = marker.seq;
|
|
1109
|
+
const planRoundsHere = spendRecords.filter((r) => r.phase === 'plan' && typeof r.seq === 'number' && r.seq > windowLo && r.seq < step.stepStartSeq);
|
|
1110
|
+
const loTs = prevStep ? prevStep.stepEndTs : firstTs;
|
|
1111
|
+
const hiTs = step.stepStartTs;
|
|
1112
|
+
const { spentUsd, unpricedRounds } = windowSpend(planRoundsHere);
|
|
1113
|
+
const b = partBehaviour(loTs, hiTs);
|
|
1114
|
+
parts.push({
|
|
1115
|
+
id: `replan:${parts.filter((p) => p.kind === 'replan').length + 1}`,
|
|
1116
|
+
kind: 'replan',
|
|
1117
|
+
label: 'replan',
|
|
1118
|
+
number: null,
|
|
1119
|
+
occurrence: null,
|
|
1120
|
+
rounds: planRoundsHere.length,
|
|
1121
|
+
toolCalls: b ? b.totalCalls : null,
|
|
1122
|
+
byTool: b ? b.byTool : null,
|
|
1123
|
+
blocked: b ? b.denied : null,
|
|
1124
|
+
wallMs: windowWallMs(loTs, hiTs),
|
|
1125
|
+
spentUsd,
|
|
1126
|
+
unpricedRounds,
|
|
1127
|
+
outcome: null,
|
|
1128
|
+
attempts: wholePartAttempt(windowLo, loTs, step.stepStartSeq, hiTs),
|
|
1129
|
+
markerSeq,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const stepBehaviour = partBehaviour(step.stepStartTs, step.stepEndTs);
|
|
1134
|
+
// a step that died before its own first exit-eval ever ran (e.g. a
|
|
1135
|
+
// crash mid-attempt — measured on the real archived
|
|
1136
|
+
// spines-poc-openai/poc-p2ocuxj8.jsonl, whose 2nd step crashed with no
|
|
1137
|
+
// check ever firing) carries an EMPTY `attemptWindows` — fall back to
|
|
1138
|
+
// one synthetic whole-step attempt spanning the step's own bounds so
|
|
1139
|
+
// the Audit tab still has something to expand into a rounds table,
|
|
1140
|
+
// same as a scout/plan/replan/judge/run part already does.
|
|
1141
|
+
const stepAttempts = step.attemptWindows.length > 0
|
|
1142
|
+
? step.attemptWindows.map((aw) => ({
|
|
1143
|
+
n: aw.n, outcome: aw.outcome, verdict: null, stages: null, detail: aw.exitEvalDetail, startSeq: aw.startSeq, endSeq: aw.endSeq, startTs: aw.startTs, endTs: aw.endTs,
|
|
1144
|
+
}))
|
|
1145
|
+
: wholePartAttempt(step.stepStartSeq, step.stepStartTs, step.stepEndSeq, step.stepEndTs);
|
|
1146
|
+
parts.push({
|
|
1147
|
+
id: `step:${step.id}:${step.occurrence}`,
|
|
1148
|
+
kind: 'step',
|
|
1149
|
+
label: step.id,
|
|
1150
|
+
number: stepIdx,
|
|
1151
|
+
occurrence: step.occurrence,
|
|
1152
|
+
rounds: step.rounds,
|
|
1153
|
+
toolCalls: step.toolCalls,
|
|
1154
|
+
byTool: stepBehaviour ? stepBehaviour.byTool : null,
|
|
1155
|
+
blocked: stepBehaviour ? stepBehaviour.denied : null,
|
|
1156
|
+
wallMs: step.wallMs,
|
|
1157
|
+
spentUsd: step.spentUsd,
|
|
1158
|
+
unpricedRounds: step.unpricedRounds,
|
|
1159
|
+
outcome: step.outcome,
|
|
1160
|
+
attempts: stepAttempts,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (fixLoop) {
|
|
1165
|
+
const firstA = fixLoop.attempts[0];
|
|
1166
|
+
const lastA = fixLoop.attempts[fixLoop.attempts.length - 1];
|
|
1167
|
+
const fixBehaviour = partBehaviour(firstA ? firstA.windowStartTs : null, lastA ? lastA.windowEndTs : null);
|
|
1168
|
+
parts.push({
|
|
1169
|
+
id: 'fix',
|
|
1170
|
+
kind: 'fix',
|
|
1171
|
+
label: 'fix',
|
|
1172
|
+
number: null,
|
|
1173
|
+
occurrence: null,
|
|
1174
|
+
rounds: fixLoop.rounds,
|
|
1175
|
+
toolCalls: fixLoop.toolCalls,
|
|
1176
|
+
byTool: fixBehaviour ? fixBehaviour.byTool : null,
|
|
1177
|
+
blocked: fixBehaviour ? fixBehaviour.denied : null,
|
|
1178
|
+
wallMs: fixLoop.wallMs,
|
|
1179
|
+
spentUsd: fixLoop.spentUsd,
|
|
1180
|
+
unpricedRounds: fixLoop.unpricedRounds,
|
|
1181
|
+
outcome: fixLoop.attempts.length ? fixLoop.attempts[fixLoop.attempts.length - 1].outcome : null,
|
|
1182
|
+
attempts: fixLoop.attempts.map((a) => ({
|
|
1183
|
+
n: a.n, outcome: a.outcome, verdict: a.verdict, stages: a.stages, detail: null, startSeq: a.windowStartSeq, endSeq: a.windowEndSeq, startTs: a.windowStartTs, endTs: a.windowEndTs,
|
|
1184
|
+
})),
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// judge (panel build item 6): a bare `judge-round` spend record — a
|
|
1189
|
+
// close's own paid seam (`onJudgeCost`, src/planrun.js:1333) — that
|
|
1190
|
+
// falls OUTSIDE every window already built above (every step, every
|
|
1191
|
+
// replan, the fix loop). No real archived run measured against this
|
|
1192
|
+
// build carries one: every judge-round seen so far already lands inside
|
|
1193
|
+
// a step or fix-loop window. Kept SYNTHETIC-ONLY (a fixture test builds
|
|
1194
|
+
// one by hand) so a future soft-green judged close still gets its own
|
|
1195
|
+
// box instead of silently vanishing into "unaccounted for".
|
|
1196
|
+
const consumedSeqs = new Set();
|
|
1197
|
+
for (const p of parts) {
|
|
1198
|
+
for (const a of p.attempts) {
|
|
1199
|
+
if (typeof a.startSeq === 'number' && typeof a.endSeq === 'number') {
|
|
1200
|
+
for (const r of spendRecords) {
|
|
1201
|
+
if (typeof r.seq === 'number' && r.seq > a.startSeq && r.seq <= a.endSeq) consumedSeqs.add(r.seq);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const leftoverJudgeRounds = spendRecords.filter((r) => r.type === 'judge-round' && typeof r.seq === 'number' && !consumedSeqs.has(r.seq));
|
|
1207
|
+
if (leftoverJudgeRounds.length > 0) {
|
|
1208
|
+
const jTsLo = parseTs(leftoverJudgeRounds[0].ts);
|
|
1209
|
+
const jTsHi = parseTs(leftoverJudgeRounds[leftoverJudgeRounds.length - 1].ts);
|
|
1210
|
+
const { spentUsd, unpricedRounds } = windowSpend(leftoverJudgeRounds);
|
|
1211
|
+
const b = partBehaviour(jTsLo, jTsHi);
|
|
1212
|
+
parts.push({
|
|
1213
|
+
id: 'judge',
|
|
1214
|
+
kind: 'judge',
|
|
1215
|
+
label: 'judge',
|
|
1216
|
+
number: null,
|
|
1217
|
+
occurrence: null,
|
|
1218
|
+
rounds: leftoverJudgeRounds.length,
|
|
1219
|
+
toolCalls: b ? b.totalCalls : null,
|
|
1220
|
+
byTool: b ? b.byTool : null,
|
|
1221
|
+
blocked: b ? b.denied : null,
|
|
1222
|
+
wallMs: windowWallMs(jTsLo, jTsHi),
|
|
1223
|
+
spentUsd,
|
|
1224
|
+
unpricedRounds,
|
|
1225
|
+
outcome: null,
|
|
1226
|
+
// startSeq is one less than the first leftover round's own seq (an
|
|
1227
|
+
// exclusive lower bound, matching every other part's convention) so
|
|
1228
|
+
// that round itself still falls INSIDE this window rather than being
|
|
1229
|
+
// excluded by its own boundary.
|
|
1230
|
+
attempts: wholePartAttempt(
|
|
1231
|
+
leftoverJudgeRounds[0].seq - 1,
|
|
1232
|
+
jTsLo,
|
|
1233
|
+
leftoverJudgeRounds[leftoverJudgeRounds.length - 1].seq,
|
|
1234
|
+
jTsHi,
|
|
1235
|
+
),
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
671
1240
|
return {
|
|
672
1241
|
runId,
|
|
673
1242
|
job: jobStart?.job ?? null,
|
|
@@ -680,6 +1249,11 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
680
1249
|
// neither, and this reader reports that honestly rather than guessing.
|
|
681
1250
|
verdictType: typeof jobStart?.verdictType === 'string' ? jobStart.verdictType : null,
|
|
682
1251
|
model: typeof jobStart?.model === 'string' ? jobStart.model : null,
|
|
1252
|
+
// PRD item 28 / F156's own field (src/run.js:368) — WHICH provider ran
|
|
1253
|
+
// this model (e.g. `openai-api` for a DeepSeek run). `null` on any spine
|
|
1254
|
+
// older than that landing, or a native/clipipe call path with no such
|
|
1255
|
+
// binding — never fabricated, never defaulted to "anthropic-api".
|
|
1256
|
+
provider: typeof jobStart?.provider === 'string' ? jobStart.provider : null,
|
|
683
1257
|
// F118 (parked-half landed): `job-start.code` — absent on any spine older
|
|
684
1258
|
// than that landing (this run predates the field entirely, distinct from
|
|
685
1259
|
// `sha` being null because the run happened to run from an npm install
|
|
@@ -727,12 +1301,28 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
|
|
|
727
1301
|
? { spentUsd, thisFileUsd: thisFileSpend.value, diffUsd: spentUsd - thisFileSpend.value }
|
|
728
1302
|
: null,
|
|
729
1303
|
timelineKind,
|
|
1304
|
+
// scoutPlan (panel build item 1, 2026-09-26): this run's own pre-step
|
|
1305
|
+
// scout/plan rounds — see the field's own construction comment above.
|
|
1306
|
+
// `null` on every run with no steps, or no scout/plan rounds recorded.
|
|
1307
|
+
scoutPlan,
|
|
730
1308
|
steps,
|
|
731
1309
|
iterations,
|
|
1310
|
+
// fixLoop (panel build item 1, 2026-09-26): the post-step outer-close
|
|
1311
|
+
// precheck + fix-loop iterations a PLAN run can carry after its last
|
|
1312
|
+
// step-end — see the field's own construction comment above. `null` on
|
|
1313
|
+
// every run with no such loop (the common case) or with no steps at all.
|
|
1314
|
+
fixLoop,
|
|
1315
|
+
// parts (panel build item 6, 2026-09-26): the ONE ordered part list —
|
|
1316
|
+
// see its own construction comment above. Drives the Run tab's map+cards
|
|
1317
|
+
// and the Audit tab's groups; never a second computation of run order.
|
|
1318
|
+
parts,
|
|
732
1319
|
replans,
|
|
733
1320
|
close,
|
|
734
1321
|
ending: { record: last, before, escalation: escalationOutsideWindow },
|
|
735
|
-
|
|
1322
|
+
// `null` (never a 0-call object) when the caller could not even
|
|
1323
|
+
// determine whether a gate-audit sidecar exists — see the `auditAvailable`
|
|
1324
|
+
// param doc above.
|
|
1325
|
+
behaviour: auditAvailable ? runBehaviour(audit) : null,
|
|
736
1326
|
memoryCache,
|
|
737
1327
|
skipped,
|
|
738
1328
|
};
|
|
@@ -761,6 +1351,18 @@ function rowMoney(row) {
|
|
|
761
1351
|
: money(row.spentUsd);
|
|
762
1352
|
}
|
|
763
1353
|
|
|
1354
|
+
/**
|
|
1355
|
+
* The `tools` column for one occurrence/iteration row: `null` (no
|
|
1356
|
+
* gate-audit sidecar available for this run) reads `unknown tools` — never
|
|
1357
|
+
* `0 tools`, which would silently discard real tool-call activity the
|
|
1358
|
+
* sidecar just wasn't there to count. A real zero-call occurrence still
|
|
1359
|
+
* reads `0 tools` honestly.
|
|
1360
|
+
* @param {number|null} n
|
|
1361
|
+
*/
|
|
1362
|
+
function toolsCell(n) {
|
|
1363
|
+
return n === null ? 'unknown tools' : `${n} tool${n === 1 ? '' : 's'}`;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
764
1366
|
/** @param {number|null} ms */
|
|
765
1367
|
function duration(ms) {
|
|
766
1368
|
if (ms === null) return 'unknown';
|
|
@@ -1018,7 +1620,7 @@ export function formatReplay(summary) {
|
|
|
1018
1620
|
`iteration ${it.iteration ?? '?'}`,
|
|
1019
1621
|
resultWord(it.verdict),
|
|
1020
1622
|
`${it.rounds} round${it.rounds === 1 ? '' : 's'}`,
|
|
1021
|
-
|
|
1623
|
+
toolsCell(it.toolCalls),
|
|
1022
1624
|
duration(it.wallMs),
|
|
1023
1625
|
rowMoney(it),
|
|
1024
1626
|
]);
|
|
@@ -1048,7 +1650,7 @@ export function formatReplay(summary) {
|
|
|
1048
1650
|
`${s.id}${s.occurrence > 1 ? ` (${ordinal(s.occurrence)})` : ''}`,
|
|
1049
1651
|
resultWord(s.outcome),
|
|
1050
1652
|
`${s.rounds} round${s.rounds === 1 ? '' : 's'}`,
|
|
1051
|
-
|
|
1653
|
+
toolsCell(s.toolCalls),
|
|
1052
1654
|
`${s.checks.passed}/${s.checks.failed}`,
|
|
1053
1655
|
s.treeChanged ? 'changed' : 'unchanged',
|
|
1054
1656
|
duration(s.wallMs),
|
|
@@ -1067,6 +1669,23 @@ export function formatReplay(summary) {
|
|
|
1067
1669
|
}
|
|
1068
1670
|
}
|
|
1069
1671
|
|
|
1672
|
+
// FIX LOOP (panel build item 1, 2026-09-26): printed ONLY when this run
|
|
1673
|
+
// actually carries one (see `replayRun`'s own construction comment) —
|
|
1674
|
+
// additive, never replacing any existing line above, so a run with no
|
|
1675
|
+
// fix loop prints byte-identical to before this addition.
|
|
1676
|
+
if (summary.fixLoop) {
|
|
1677
|
+
lines.push('');
|
|
1678
|
+
lines.push('FIX LOOP (post-step outer-close precheck + fix-loop iterations)');
|
|
1679
|
+
const seq = summary.fixLoop.attempts.map((a) => (a.source === 'outer-close' ? 'precheck' : `iter ${a.iteration ?? '?'}`) + (a.outcome === 'green' ? ' ✓' : ' ✗')).join(' · ');
|
|
1680
|
+
lines.push(` ${seq}`);
|
|
1681
|
+
lines.push(` ${summary.fixLoop.rounds} round${summary.fixLoop.rounds === 1 ? '' : 's'} · ${toolsCell(summary.fixLoop.toolCalls)} · ${duration(summary.fixLoop.wallMs)} · ${rowMoney(summary.fixLoop)}`);
|
|
1682
|
+
}
|
|
1683
|
+
if (summary.scoutPlan) {
|
|
1684
|
+
lines.push('');
|
|
1685
|
+
lines.push('SCOUT+PLAN (this run\'s own pre-step drafting)');
|
|
1686
|
+
lines.push(` ${summary.scoutPlan.scoutRounds} scout round${summary.scoutPlan.scoutRounds === 1 ? '' : 's'} + ${summary.scoutPlan.planRounds} plan round${summary.scoutPlan.planRounds === 1 ? '' : 's'} · ${toolsCell(summary.scoutPlan.toolCalls)} · ${duration(summary.scoutPlan.wallMs)} · ${rowMoney(summary.scoutPlan)}`);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1070
1689
|
lines.push('');
|
|
1071
1690
|
lines.push('CLOSE');
|
|
1072
1691
|
lines.push(` ${closeLine(summary.close)}`);
|