bareloop 0.29.0 → 0.30.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 +50 -0
- package/bareloop.context.md +22 -0
- package/package.json +1 -1
- package/src/panel/index.html +268 -22
- package/src/panel/server.js +62 -6
- package/src/replay.js +48 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,56 @@ All notable changes to bareloop are documented here. Format:
|
|
|
5
5
|
[SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
|
|
6
6
|
feature lands, **patch** = docs, fixes, scaffolding.
|
|
7
7
|
|
|
8
|
+
## [0.30.0] — 2026-09-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Panel step map draws the retry loop** — a multi-attempt step (and the post-step fix loop)
|
|
13
|
+
now renders its "try N" curve and label on the map, alongside a pulsing amber dot on the
|
|
14
|
+
live-running box (same geometry as `design/panel-mockup.html`).
|
|
15
|
+
- **Runs list and open run poll live** — a single poll owner refreshes the Runs list every
|
|
16
|
+
tick and the open run's own detail while it is still live (`▶`), stopping once it dies
|
|
17
|
+
(`?`) or gets a real `✓`/`✗` verdict; polling pauses on a hidden tab and catches up on
|
|
18
|
+
return. Re-renders are skipped when the fetched payload is unchanged, and scroll position
|
|
19
|
+
is preserved across poll-driven rebuilds.
|
|
20
|
+
- `scripts/replay-live.mjs` (**dev-only, not shipped** — excluded from the published
|
|
21
|
+
package) paces an archived spine into a live-looking run for panel development and
|
|
22
|
+
screenshot proof, without touching the source spine or `~/.config/bareloop`.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **Job tab field order**: Check type, Model, `$` cap / Time cap, then the rest — moved per
|
|
27
|
+
hamr's ruling.
|
|
28
|
+
- Job tab no longer shows a separate Job name field; the run's own card title already
|
|
29
|
+
carries it.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- The retry loop's "try N" curve and label now stay inside their own step box at every
|
|
34
|
+
width, including narrow/mobile widths (previously could render past the box's left edge
|
|
35
|
+
at widths like 350px).
|
|
36
|
+
- An open (still-running) attempt on a live run now reads `▶` everywhere — map, part cards,
|
|
37
|
+
and Audit tab — never the died `?`, which is reserved for a genuinely died attempt.
|
|
38
|
+
- The `[▶]` glyph now pulses for a live run everywhere it appears (runs list rows,
|
|
39
|
+
Workflows, the run header).
|
|
40
|
+
- A running step's own in-progress attempt no longer leaks into a phantom fix-loop part on
|
|
41
|
+
the map.
|
|
42
|
+
- A live fix-loop attempt whose window is still open now reports its outcome as unknown
|
|
43
|
+
rather than a fabricated red, so the map correctly shows it as running (with its pulsing
|
|
44
|
+
dot) instead of prematurely stopped.
|
|
45
|
+
- A live run's Audit tab and tool-call/cache summary now fall back to the during-run
|
|
46
|
+
tree/worktree gate-audit path, so tool calls are visible while a run is still in progress,
|
|
47
|
+
not only after it finishes.
|
|
48
|
+
|
|
49
|
+
### Notes
|
|
50
|
+
|
|
51
|
+
- F195 (open): `/api/runs` re-replays every archived spine on every call (~457ms CPU at 250
|
|
52
|
+
rows, no per-row cache). Mitigated in this release by throttling the Runs list poll to
|
|
53
|
+
10s; the root cause is still open.
|
|
54
|
+
- F196 (parked): a finished bundle run's tool log can be unreadable due to a sidecar naming
|
|
55
|
+
mismatch between `spine.jsonl` and its renamed gate-audit sidecar. Not fixed in this
|
|
56
|
+
release.
|
|
57
|
+
|
|
8
58
|
## [0.29.0] — 2026-09-26
|
|
9
59
|
|
|
10
60
|
### Added
|
package/bareloop.context.md
CHANGED
|
@@ -3243,6 +3243,28 @@ interviews of their own. All three are dispatched by name only: `bareloop run-u
|
|
|
3243
3243
|
its own. Chat/Settings/authoring (P3/P4) are not built yet; the page says so rather than
|
|
3244
3244
|
hiding the gap.
|
|
3245
3245
|
|
|
3246
|
+
- **Panel P2 (live run view, `docs/product/PANEL-BUILD.md` Addendum 2026-09-27)** → the page
|
|
3247
|
+
polls, it does not push (no SSE/websocket). One `setInterval` (2s) drives two throttled
|
|
3248
|
+
fetches: the OPEN run's own detail (`GET /api/runs/:runid`) refetches every 2s while its
|
|
3249
|
+
glyph is live `▶`, and stops the instant it reaches a real `✓`/`✗` or dies `?`; the left
|
|
3250
|
+
Runs list refetches on a separate 10s throttle inside the same tick (`RUNS_LIST_POLL_MS`,
|
|
3251
|
+
lowered from the original 2s per F195 — `listRuns()` re-replays every archived spine on
|
|
3252
|
+
every call, ~457ms CPU at 250 rows, open/escalated), except the tick right after the open
|
|
3253
|
+
run stops being live, which forces one immediate list refresh so that row's glyph doesn't
|
|
3254
|
+
wait up to 10s. Both fetches skip re-rendering when the payload's JSON is byte-identical to
|
|
3255
|
+
the last one, restore each pane's scroll position across a poll-driven rebuild, and pause
|
|
3256
|
+
while the tab is hidden (`document.hidden`), catching up immediately on
|
|
3257
|
+
`visibilitychange`. The Audit tab does **not** join this loop — it refetches only when
|
|
3258
|
+
opened/switched to. A live run's gate-audit sidecar (before `run-u`/`bareloop run`'s
|
|
3259
|
+
end-of-run rename) is read from its DURING-RUN path (`<row.patient>/gate-audit.jsonl`) via
|
|
3260
|
+
`resolveAuditPathForRow` (`src/panel/server.js`), the one shared owner the Run tab's tools
|
|
3261
|
+
summary and the Audit tab both read through — tried only while the run's own spine has no
|
|
3262
|
+
`job-end` yet, so a finished run never risks reading a stale leftover file at that path.
|
|
3263
|
+
`scripts/replay-live.mjs <sourceSpine> <outDir> [--speed N]` is a dev-only, unshipped ($0,
|
|
3264
|
+
no provider call) instrument that paces a real archived spine's records back out under a
|
|
3265
|
+
new runid so the panel can be exercised against a "live" run without a paid run — it is not
|
|
3266
|
+
a substitute for watching a real run start-to-end, which stays a separately authorized step.
|
|
3267
|
+
|
|
3246
3268
|
- **`bareloop run-u <flags…>`** (PANEL-BUILD.md P0 task 2/4) → the person-path run flow
|
|
3247
3269
|
(the JOBS-table/`--spec` runner, resume, the review door — `docs/logs/FINDINGS.md`'s
|
|
3248
3270
|
U-mode). `src/cli.js`'s `run-u` dispatch hands `rest` straight to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bareloop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/panel/index.html
CHANGED
|
@@ -132,7 +132,22 @@
|
|
|
132
132
|
.dot::before{content:"[·]";color:var(--grey-dot);}
|
|
133
133
|
.dot.green::before{content:"[✓]";color:var(--green);}
|
|
134
134
|
.dot.red::before{content:"[✗]";color:var(--red);}
|
|
135
|
-
|
|
135
|
+
/* Panel P2 defect 1 (hamr-watched run mujjtrvd, 2026-09-27): the [▶] glyph
|
|
136
|
+
itself pulses wherever a LIVE run shows it (runs list rows, the
|
|
137
|
+
Workflows job row when its represented run is live, the run header) —
|
|
138
|
+
`.dot.amber` is minted ONLY for glyph "▶" (glyphForOutcome,
|
|
139
|
+
src/panel/server.js: outcome null -> "▶", never for a died [?] or a
|
|
140
|
+
finished [✓]/[✗] run), so scoping the animation to this one selector
|
|
141
|
+
already excludes every other state, no extra "is it live" check needed
|
|
142
|
+
here. Same rhythm as the map box's own running-part dot (this file,
|
|
143
|
+
`buildMapSvg`'s `s.state === "running"` branch): opacity 1 -> 0.3 -> 1,
|
|
144
|
+
1.2s, matching design/panel-mockup.html's pulsing amber dot exactly —
|
|
145
|
+
one fixed rhythm, never re-derived per call site. */
|
|
146
|
+
@keyframes pulse-glyph{0%,100%{opacity:1;}50%{opacity:0.3;}}
|
|
147
|
+
.dot.amber::before{content:"[▶]";color:var(--amber);animation:pulse-glyph 1.2s ease-in-out infinite;}
|
|
148
|
+
@media (prefers-reduced-motion: reduce){
|
|
149
|
+
.dot.amber::before{animation:none;}
|
|
150
|
+
}
|
|
136
151
|
.dot.grey::before{content:"[·]";color:var(--grey-dot);}
|
|
137
152
|
/* died (hamr's ruling B, 2026-09-25): no ending was ever recorded — a
|
|
138
153
|
DIFFERENT fact from a real [✗] close/arbiter "no", so it gets its own
|
|
@@ -320,7 +335,7 @@
|
|
|
320
335
|
<button role="tab" id="tab-runs" aria-controls="panel-runs" aria-selected="false" data-testid="left-tab-runs">Runs</button>
|
|
321
336
|
</div>
|
|
322
337
|
|
|
323
|
-
<div class="pane-body">
|
|
338
|
+
<div class="pane-body" id="left-pane-body">
|
|
324
339
|
|
|
325
340
|
<section id="panel-chat" class="tabpanel active" role="tabpanel" aria-labelledby="tab-chat">
|
|
326
341
|
<div class="chat-thread">
|
|
@@ -361,7 +376,7 @@
|
|
|
361
376
|
<button role="tab" id="tab-details" aria-controls="panel-details" aria-selected="false" data-testid="right-tab-details">Job</button>
|
|
362
377
|
</div>
|
|
363
378
|
|
|
364
|
-
<div class="pane-body">
|
|
379
|
+
<div class="pane-body" id="right-pane-body">
|
|
365
380
|
|
|
366
381
|
<section id="panel-run" class="tabpanel active" role="tabpanel" aria-labelledby="tab-run">
|
|
367
382
|
<div id="run-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run from Workflows or History</div>
|
|
@@ -412,7 +427,10 @@
|
|
|
412
427
|
<h4 id="details-title">Job card — signed</h4>
|
|
413
428
|
<div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
|
|
414
429
|
<div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
|
|
415
|
-
<div class="field
|
|
430
|
+
<div class="field-row">
|
|
431
|
+
<div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
|
|
432
|
+
<div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
|
|
433
|
+
</div>
|
|
416
434
|
<div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
|
|
417
435
|
<div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
|
|
418
436
|
<div class="field"><label>Success</label><div class="ro-value" id="details-success">—</div></div>
|
|
@@ -420,10 +438,6 @@
|
|
|
420
438
|
<div class="field"><label>Source</label><div class="ro-value" id="details-source">—</div></div>
|
|
421
439
|
<div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
|
|
422
440
|
<div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
|
|
423
|
-
<div class="field-row">
|
|
424
|
-
<div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
|
|
425
|
-
<div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
|
|
426
|
-
</div>
|
|
427
441
|
<div class="hint" id="job-note"></div>
|
|
428
442
|
</div>
|
|
429
443
|
</section>
|
|
@@ -496,14 +510,27 @@
|
|
|
496
510
|
// "attempt 1 [✗] · attempt 2 [✓]" — appended to the box's own title text
|
|
497
511
|
// (the existing wrapTitleLines 2-line wrap handles overflow the same way
|
|
498
512
|
// it already does for a long step id; no third renderer, no new box shape).
|
|
499
|
-
|
|
500
|
-
|
|
513
|
+
// item 1 (2026-09-27, panel P2 defect): an attempt's own outcome is null
|
|
514
|
+
// in exactly two situations — its window is still open on a LIVE run (the
|
|
515
|
+
// box carrying it is the run's own live/`running` part), or the run DIED
|
|
516
|
+
// before that window ever closed. hamr's glyph ruling reserves "?" for
|
|
517
|
+
// DIED only; a live open attempt reads the same "▶" the run glyph itself
|
|
518
|
+
// uses for "in progress / unresolved". `boxIsLive` is the ONE place that
|
|
519
|
+
// context is decided (every caller passes its own box's `state ===
|
|
520
|
+
// "running"` — never re-derives the rule itself), so a finished or died
|
|
521
|
+
// run's attempts (boxIsLive always false there) render exactly as before.
|
|
522
|
+
function attemptGlyph(outcome, boxIsLive){
|
|
523
|
+
if(outcome === "green") return "✓";
|
|
524
|
+
if(outcome === null || outcome === undefined) return boxIsLive ? "▶" : "?";
|
|
525
|
+
return "✗";
|
|
526
|
+
}
|
|
527
|
+
function attemptsInlineText(attempts, boxIsLive){
|
|
501
528
|
if(!attempts || !attempts.length) return "";
|
|
502
|
-
return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome); }).join(" · ");
|
|
529
|
+
return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome, boxIsLive); }).join(" · ");
|
|
503
530
|
}
|
|
504
531
|
function stepTitleText(idx, s){
|
|
505
532
|
var base = s.noNumber ? s.title : (idx + 1) + ' ' + s.title;
|
|
506
|
-
var at = attemptsInlineText(s.attempts);
|
|
533
|
+
var at = attemptsInlineText(s.attempts, s.state === "running");
|
|
507
534
|
return at ? base + ' — ' + at : base;
|
|
508
535
|
}
|
|
509
536
|
// item 5 (F197): a box's step NUMBER must count only over boxes without
|
|
@@ -550,9 +577,10 @@
|
|
|
550
577
|
// reads the part's own outcome.
|
|
551
578
|
function partResultGlyph(part, box){
|
|
552
579
|
if(partHasNoVerdict(part)) return null;
|
|
553
|
-
|
|
580
|
+
var boxIsLive = box.state === "running";
|
|
581
|
+
if(box.attempts.length > 1) return box.attempts.map(function(a){ return attemptGlyph(a.outcome, boxIsLive); }).join("");
|
|
554
582
|
var outcome = (part.outcome !== null && part.outcome !== undefined) ? part.outcome : (box.attempts[0] ? box.attempts[0].outcome : null);
|
|
555
|
-
return attemptGlyph(outcome);
|
|
583
|
+
return attemptGlyph(outcome, boxIsLive);
|
|
556
584
|
}
|
|
557
585
|
function buildOrderedBoxes(parts, isLive){
|
|
558
586
|
var list = Array.isArray(parts) ? parts : [];
|
|
@@ -607,6 +635,23 @@
|
|
|
607
635
|
var line2 = words.slice(i).join(' ');
|
|
608
636
|
return line2 ? [line1, line2] : [line1];
|
|
609
637
|
}
|
|
638
|
+
// ONE shared rule for "does this box draw a retry loop, and what try number" — a multi-attempt
|
|
639
|
+
// box (a re-run step, or the fix loop; `run` on an old-shape spine falls in here too, naturally,
|
|
640
|
+
// never special-cased) gets a dashed self-loop labelled with its final try number; anything
|
|
641
|
+
// else (incl. every no-verdict kind, whose attempts is already forced to [] upstream) gets 0.
|
|
642
|
+
function boxRetryTry(box){
|
|
643
|
+
var attempts = Array.isArray(box.attempts) ? box.attempts : [];
|
|
644
|
+
return attempts.length > 1 ? attempts.length : 0;
|
|
645
|
+
}
|
|
646
|
+
// item 2 (2026-09-27): the ONE place a retry loop's x-coordinate (curve
|
|
647
|
+
// start/control/end, and the "try N" label) is kept inside the box that
|
|
648
|
+
// owns it — MIN_BOX_W (100) is comfortably wider than 2*RETRY_INSET, so
|
|
649
|
+
// this never inverts the clamp range.
|
|
650
|
+
var RETRY_INSET = 4;
|
|
651
|
+
function clampRetryX(v, boxX, boxW){
|
|
652
|
+
var lo = boxX + RETRY_INSET, hi = boxX + boxW - RETRY_INSET;
|
|
653
|
+
return Math.min(hi, Math.max(lo, v));
|
|
654
|
+
}
|
|
610
655
|
function buildStepMapSVG(steps, availWidth){
|
|
611
656
|
var n = steps.length;
|
|
612
657
|
var naturalW = naturalBoxWidth(steps);
|
|
@@ -626,6 +671,8 @@
|
|
|
626
671
|
'<defs>' +
|
|
627
672
|
'<marker id="' + uid + '-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
|
|
628
673
|
'<path d="M0,0 L6,3 L0,6 z" fill="var(--text-dim)"></path></marker>' +
|
|
674
|
+
'<marker id="' + uid + '-arrow-grey" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
|
|
675
|
+
'<path d="M0,0 L6,3 L0,6 z" fill="var(--text-faint)"></path></marker>' +
|
|
629
676
|
'</defs>';
|
|
630
677
|
steps.forEach(function(s, idx){
|
|
631
678
|
var row = Math.floor(idx / perRow);
|
|
@@ -636,6 +683,8 @@
|
|
|
636
683
|
var y = 30 + row * rowH;
|
|
637
684
|
var colors = stepMapColors(s.state);
|
|
638
685
|
var lines = titleLines[idx];
|
|
686
|
+
var nextRow = idx < n - 1 ? Math.floor((idx + 1) / perRow) : null;
|
|
687
|
+
var hasDrop = nextRow !== null && nextRow !== row;
|
|
639
688
|
var stateY = y + boxH - 18;
|
|
640
689
|
var partIdxAttr = typeof s.partIndex === "number" ? (' data-part-index="' + s.partIndex + '" style="cursor:pointer;"') : '';
|
|
641
690
|
body += '<rect x="' + x + '" y="' + y + '" width="' + boxW + '" height="' + boxH +
|
|
@@ -655,8 +704,44 @@
|
|
|
655
704
|
}
|
|
656
705
|
body += '<text x="' + (x + boxW / 2) + '" y="' + stateY +
|
|
657
706
|
'" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(s.state) + '</text>';
|
|
707
|
+
// P2 (2026-09-27 ruling): a running box gets the mockup's pulsing
|
|
708
|
+
// amber dot (design/panel-mockup.html ~line 1648) — same fixed color
|
|
709
|
+
// and animation, never re-derived from the state's own stroke color.
|
|
710
|
+
if(s.state === "running"){
|
|
711
|
+
body += '<circle cx="' + (x + 20) + '" cy="' + (y + 12) + '" r="4" fill="#b8860b">' +
|
|
712
|
+
'<animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"></animate></circle>';
|
|
713
|
+
}
|
|
714
|
+
var retryTry = boxRetryTry(s);
|
|
715
|
+
if(retryTry){
|
|
716
|
+
// dashed self-loop under the box, same proportions as the design mockup's curve,
|
|
717
|
+
// scaled to this box's own width. When this same box also carries the row-to-row
|
|
718
|
+
// snake drop (which leaves from dead-center, x=cx), shift the loop's start away from
|
|
719
|
+
// center by a fixed pixel offset so the two never begin at the same point.
|
|
720
|
+
// item 2 (2026-09-27): at a narrow box width, subtracting that same
|
|
721
|
+
// fixed pixel shift from proportionally-small x-fractions (e.g. the
|
|
722
|
+
// curve's own endpoint at 2.8% of boxW) can push the coordinate past
|
|
723
|
+
// the box's own left edge — the fixed shift was sized for desktop
|
|
724
|
+
// widths and never scaled down with boxW. clampRetryX is the ONE
|
|
725
|
+
// place every retry x-coordinate (curve start/control/end AND the
|
|
726
|
+
// "try N" label) is kept inside [x + RETRY_INSET, x + boxW -
|
|
727
|
+
// RETRY_INSET] — never re-clamped ad hoc per coordinate — so the
|
|
728
|
+
// whole loop and its label always stay inside the box that owns it,
|
|
729
|
+
// at every width, while keeping the mockup's proportions untouched
|
|
730
|
+
// wherever they already fit (every desktop-width box already fits).
|
|
731
|
+
var retryShift = hasDrop ? 14 : 0;
|
|
732
|
+
var bottom = y + boxH;
|
|
733
|
+
var sx = clampRetryX(x + boxW * 0.50 - retryShift, x, boxW);
|
|
734
|
+
var sy = bottom + 5;
|
|
735
|
+
var c1x = clampRetryX(x + boxW * 0.278 - retryShift, x, boxW), c1y = bottom + 30;
|
|
736
|
+
var c2x = clampRetryX(x + boxW * 0.167 - retryShift, x, boxW), c2y = bottom + 30;
|
|
737
|
+
var ex = clampRetryX(x + boxW * 0.028 - retryShift, x, boxW), ey = bottom + 2;
|
|
738
|
+
var labelX = clampRetryX(x + boxW * 0.267 - retryShift, x, boxW);
|
|
739
|
+
body += '<path d="M ' + sx + ' ' + sy + ' C ' + c1x + ' ' + c1y + ', ' + c2x + ' ' + c2y + ', ' + ex + ' ' + ey +
|
|
740
|
+
'" fill="none" stroke="var(--text-faint)" stroke-width="1.5" stroke-dasharray="3,3" marker-end="url(#' + uid + '-arrow-grey)"></path>';
|
|
741
|
+
body += '<text x="' + labelX + '" y="' + (bottom + 32) +
|
|
742
|
+
'" text-anchor="middle" font-size="10" fill="var(--text-faint)">try ' + retryTry + '</text>';
|
|
743
|
+
}
|
|
658
744
|
if(idx < n - 1){
|
|
659
|
-
var nextRow = Math.floor((idx + 1) / perRow);
|
|
660
745
|
if(nextRow === row){
|
|
661
746
|
var midY = y + boxH / 2;
|
|
662
747
|
if(!rtl){
|
|
@@ -684,6 +769,7 @@
|
|
|
684
769
|
'<span><span class="dot red"></span>stopped</span>' +
|
|
685
770
|
'<span><span class="dot grey"></span>waiting</span>' +
|
|
686
771
|
'<span><span class="dot magenta"></span>died</span>' +
|
|
772
|
+
'<span>⤾ dashed = retry</span>' +
|
|
687
773
|
'</div>';
|
|
688
774
|
}
|
|
689
775
|
var lastSteps = null;
|
|
@@ -1498,7 +1584,7 @@
|
|
|
1498
1584
|
|
|
1499
1585
|
// build item C: Audit tab attempt row -> its rounds (server-built,
|
|
1500
1586
|
// lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
|
|
1501
|
-
function buildAttemptsList(container, partIndex, attempts){
|
|
1587
|
+
function buildAttemptsList(container, partIndex, attempts, boxIsLive){
|
|
1502
1588
|
container.innerHTML = "";
|
|
1503
1589
|
attempts.forEach(function(a){
|
|
1504
1590
|
var row = document.createElement("div");
|
|
@@ -1506,7 +1592,7 @@
|
|
|
1506
1592
|
row.setAttribute("data-testid", "audit-attempt-" + partIndex + "-" + a.n);
|
|
1507
1593
|
row.innerHTML =
|
|
1508
1594
|
'<button class="btn small attempt-toggle" type="button" aria-expanded="false">' +
|
|
1509
|
-
'attempt ' + a.n + ' ' + attemptGlyph(a.outcome) +
|
|
1595
|
+
'attempt ' + a.n + ' ' + attemptGlyph(a.outcome, boxIsLive) +
|
|
1510
1596
|
'</button>' +
|
|
1511
1597
|
'<div class="rounds-list" hidden></div>';
|
|
1512
1598
|
container.appendChild(row);
|
|
@@ -1699,7 +1785,7 @@
|
|
|
1699
1785
|
var attemptsWrap = document.createElement("div");
|
|
1700
1786
|
attemptsWrap.className = "attempts-list";
|
|
1701
1787
|
body.appendChild(attemptsWrap);
|
|
1702
|
-
buildAttemptsList(attemptsWrap, idx, attempts);
|
|
1788
|
+
buildAttemptsList(attemptsWrap, idx, attempts, box.state === "running");
|
|
1703
1789
|
} else {
|
|
1704
1790
|
// parts with NO sub-loop (scout, plan, replan, judge, run, or a
|
|
1705
1791
|
// single-attempt step) expand STRAIGHT to their rounds table.
|
|
@@ -1830,7 +1916,6 @@
|
|
|
1830
1916
|
var verdictEl = document.getElementById("details-verdict");
|
|
1831
1917
|
if(job.checkTypeTitle){ verdictEl.title = job.checkTypeTitle; } else { verdictEl.removeAttribute("title"); }
|
|
1832
1918
|
document.getElementById("details-model").textContent = job.model;
|
|
1833
|
-
document.getElementById("details-jobname").textContent = job.job;
|
|
1834
1919
|
var descField = document.getElementById("details-description-field");
|
|
1835
1920
|
if(job.description){ descField.hidden = false; document.getElementById("details-description").textContent = job.description; }
|
|
1836
1921
|
else { descField.hidden = true; }
|
|
@@ -1849,23 +1934,182 @@
|
|
|
1849
1934
|
|
|
1850
1935
|
// ---- selection + loading ----
|
|
1851
1936
|
var currentRunid = null;
|
|
1937
|
+
// P2: guards a stale in-flight response (detail/audit/job, OR the poll
|
|
1938
|
+
// loop's own run-detail refetch) from overwriting whatever run is
|
|
1939
|
+
// actually selected by the time it resolves — bumped on every selectRun
|
|
1940
|
+
// call, checked before any of those responses touch the DOM.
|
|
1941
|
+
var selectToken = 0;
|
|
1942
|
+
// P2: true while the OPEN run's own detail poll should keep firing (set
|
|
1943
|
+
// from the run's own `glyph`/`died` fields, same rule `renderRun` already
|
|
1944
|
+
// uses for `isLive` — never re-derived a second way).
|
|
1945
|
+
var runIsLive = false;
|
|
1852
1946
|
function selectRun(runid, rowEl, siblingSelector){
|
|
1853
1947
|
clearSelection();
|
|
1854
1948
|
document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
|
|
1855
1949
|
if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
|
|
1856
1950
|
currentRunid = runid;
|
|
1857
1951
|
lastJobToolsList = null;
|
|
1858
|
-
|
|
1952
|
+
lastRunDetailSig = null;
|
|
1953
|
+
runIsLive = false;
|
|
1954
|
+
var myToken = ++selectToken;
|
|
1955
|
+
getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
|
|
1956
|
+
if(myToken !== selectToken) return; // a different run was selected before this resolved
|
|
1957
|
+
lastRunDetailSig = sig(detail);
|
|
1958
|
+
runIsLive = detail.glyph === "▶" && !detail.died;
|
|
1959
|
+
renderRun(detail);
|
|
1960
|
+
}).catch(function(e){
|
|
1961
|
+
if(myToken !== selectToken) return;
|
|
1859
1962
|
document.getElementById("run-empty").hidden = false;
|
|
1860
1963
|
document.getElementById("run-empty").textContent = "failed to load run: " + e.message;
|
|
1861
1964
|
document.getElementById("run-content").hidden = true;
|
|
1862
1965
|
});
|
|
1863
|
-
getJSON("/api/runs/" + encodeURIComponent(runid) + "/audit").then(
|
|
1864
|
-
|
|
1966
|
+
getJSON("/api/runs/" + encodeURIComponent(runid) + "/audit").then(function(result){
|
|
1967
|
+
if(myToken !== selectToken) return;
|
|
1968
|
+
renderAudit(result);
|
|
1969
|
+
}).catch(function(){ if(myToken === selectToken) renderAudit(null); });
|
|
1970
|
+
getJSON("/api/runs/" + encodeURIComponent(runid) + "/job").then(function(job){
|
|
1971
|
+
if(myToken !== selectToken) return;
|
|
1972
|
+
renderJob(job);
|
|
1973
|
+
}).catch(function(){ if(myToken === selectToken) renderJob(null); });
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Q2 (2026-09-27 ruling): the Audit tab does NOT live-refresh on the 2s
|
|
1977
|
+
// poll — it re-fetches only when the person opens/switches to it.
|
|
1978
|
+
document.getElementById("tab-audit").addEventListener("click", function(){
|
|
1979
|
+
if(!currentRunid) return;
|
|
1980
|
+
var myToken = selectToken;
|
|
1981
|
+
getJSON("/api/runs/" + encodeURIComponent(currentRunid) + "/audit").then(function(result){
|
|
1982
|
+
if(myToken !== selectToken) return;
|
|
1983
|
+
renderAudit(result);
|
|
1984
|
+
}).catch(function(){});
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
// ---- P2: live polling — ONE owner for both the left Runs list and the
|
|
1988
|
+
// open live run's map/cards/counters/summary (hamr's ruling, 2026-09-27:
|
|
1989
|
+
// 2s interval for the open run's own detail; the open run's own detail
|
|
1990
|
+
// refetch stops once it is no longer live; the Audit tab is wired
|
|
1991
|
+
// separately above and never joins this loop). A single `setInterval`
|
|
1992
|
+
// drives both fetches — never two independent timers that could race
|
|
1993
|
+
// each other. ----
|
|
1994
|
+
//
|
|
1995
|
+
// F195 (docs/logs/FINDINGS.md): /api/runs re-replays every spine on
|
|
1996
|
+
// every call (~457ms CPU at 250 rows). hamr's ruling B, 2026-09-27:
|
|
1997
|
+
// "refresh less often, up to 30s at most, choose the lesser when
|
|
1998
|
+
// possible" — the Runs list polls every 10s (RUNS_LIST_POLL_MS) instead
|
|
1999
|
+
// of every 2s tick; the open run's own detail is unaffected. The 2s
|
|
2000
|
+
// `pollTick` still fires the list check every tick, but `refreshRunsList`
|
|
2001
|
+
// itself skips the fetch unless RUNS_LIST_POLL_MS has elapsed since the
|
|
2002
|
+
// last one — one timer, one owner, just a throttle inside it. The one
|
|
2003
|
+
// exception: the instant the open run's own poll sees it stop being live
|
|
2004
|
+
// (a real verdict or died), `pollTick` forces ONE immediate list refresh
|
|
2005
|
+
// so that run's left-list glyph flips right away instead of waiting up
|
|
2006
|
+
// to 10s.
|
|
2007
|
+
var POLL_MS = 2000;
|
|
2008
|
+
var RUNS_LIST_POLL_MS = 10000;
|
|
2009
|
+
var pollTimer = null;
|
|
2010
|
+
var lastRunsSig = null;
|
|
2011
|
+
var lastRunDetailSig = null;
|
|
2012
|
+
var lastRunsListRefreshAt = 0;
|
|
2013
|
+
var pollFailStreak = 0;
|
|
2014
|
+
|
|
2015
|
+
function sig(obj){
|
|
2016
|
+
try { return JSON.stringify(obj); } catch(e){ return null; }
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// Saves/restores scrollTop of the given element ids around a re-render —
|
|
2020
|
+
// both `.pane-body` scroll containers keep their offset across a poll-
|
|
2021
|
+
// driven rebuild instead of snapping back to the top.
|
|
2022
|
+
function withScrollPreserved(ids, fn){
|
|
2023
|
+
var saved = ids.map(function(id){
|
|
2024
|
+
var el = document.getElementById(id);
|
|
2025
|
+
return el ? el.scrollTop : null;
|
|
2026
|
+
});
|
|
2027
|
+
fn();
|
|
2028
|
+
ids.forEach(function(id, i){
|
|
2029
|
+
if(saved[i] === null) return;
|
|
2030
|
+
var el = document.getElementById(id);
|
|
2031
|
+
if(el) el.scrollTop = saved[i];
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function refreshRunsList(force){
|
|
2036
|
+
var now = Date.now();
|
|
2037
|
+
if(!force && (now - lastRunsListRefreshAt) < RUNS_LIST_POLL_MS) return Promise.resolve();
|
|
2038
|
+
lastRunsListRefreshAt = now;
|
|
2039
|
+
return getJSON("/api/runs").then(function(result){
|
|
2040
|
+
pollFailStreak = 0;
|
|
2041
|
+
var runs = result.runs || [];
|
|
2042
|
+
var next = sig(runs);
|
|
2043
|
+
if(next === lastRunsSig) return; // unchanged payload — never rebuild the DOM for nothing
|
|
2044
|
+
lastRunsSig = next;
|
|
2045
|
+
withScrollPreserved(["left-pane-body"], function(){
|
|
2046
|
+
// setItems re-runs the SAME filter/search state and view mode
|
|
2047
|
+
// (runsFilterFn/runsApplyFn close over runsViewMode and the filter
|
|
2048
|
+
// bar's own state) — a poll tick never resets either.
|
|
2049
|
+
runsFilterBar.setItems(runs);
|
|
2050
|
+
});
|
|
2051
|
+
}).catch(function(){
|
|
2052
|
+
pollFailStreak++;
|
|
2053
|
+
// Keep the last good render; a transient failure is invisible. Only a
|
|
2054
|
+
// PERSISTENT failure (3+ ticks, ~6s) gets a quiet, non-alarming note —
|
|
2055
|
+
// the page itself is never wiped.
|
|
2056
|
+
if(pollFailStreak === 3){
|
|
2057
|
+
var countEl = document.getElementById("runs-filter-count");
|
|
2058
|
+
if(countEl) countEl.textContent = "runs list: fetch failing, showing last known data";
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
1865
2061
|
}
|
|
1866
2062
|
|
|
2063
|
+
function refreshOpenRun(){
|
|
2064
|
+
if(!currentRunid || !runIsLive) return Promise.resolve();
|
|
2065
|
+
var runid = currentRunid;
|
|
2066
|
+
var myToken = selectToken;
|
|
2067
|
+
return getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
|
|
2068
|
+
// stale guard: a different run was selected (or re-selected) while
|
|
2069
|
+
// this request was in flight — never let it overwrite what's open now.
|
|
2070
|
+
if(myToken !== selectToken || runid !== currentRunid) return;
|
|
2071
|
+
var next = sig(detail);
|
|
2072
|
+
var changed = next !== lastRunDetailSig;
|
|
2073
|
+
lastRunDetailSig = next;
|
|
2074
|
+
runIsLive = detail.glyph === "▶" && !detail.died;
|
|
2075
|
+
if(changed){
|
|
2076
|
+
withScrollPreserved(["right-pane-body"], function(){ renderRun(detail); });
|
|
2077
|
+
}
|
|
2078
|
+
}).catch(function(){ /* keep last good render; retry next tick */ });
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function pollTick(forceList){
|
|
2082
|
+
if(document.hidden) return;
|
|
2083
|
+
refreshRunsList(forceList);
|
|
2084
|
+
var wasLive = runIsLive;
|
|
2085
|
+
return refreshOpenRun().then(function(){
|
|
2086
|
+
// the open run just stopped being live (real verdict or died) —
|
|
2087
|
+
// don't make the left-list glyph wait up to RUNS_LIST_POLL_MS to
|
|
2088
|
+
// catch up; force one immediate refresh.
|
|
2089
|
+
if(wasLive && !runIsLive) refreshRunsList(true);
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function startPolling(){
|
|
2094
|
+
if(pollTimer) return;
|
|
2095
|
+
pollTimer = setInterval(pollTick, POLL_MS);
|
|
2096
|
+
}
|
|
2097
|
+
function stopPolling(){
|
|
2098
|
+
if(!pollTimer) return;
|
|
2099
|
+
clearInterval(pollTimer);
|
|
2100
|
+
pollTimer = null;
|
|
2101
|
+
}
|
|
2102
|
+
// Pause while the tab is hidden (cheap, standard); resume — with an
|
|
2103
|
+
// immediate catch-up tick, forcing BOTH fetches regardless of the list's
|
|
2104
|
+
// 10s throttle — on return, rather than waiting a full 2s (or up to 10s
|
|
2105
|
+
// for the list).
|
|
2106
|
+
document.addEventListener("visibilitychange", function(){
|
|
2107
|
+
if(!document.hidden) pollTick(true);
|
|
2108
|
+
});
|
|
2109
|
+
|
|
1867
2110
|
getJSON("/api/runs").then(function(result){
|
|
1868
2111
|
var runs = result.runs || [];
|
|
2112
|
+
lastRunsSig = sig(runs);
|
|
1869
2113
|
runsFilterBar.setItems(runs);
|
|
1870
2114
|
if(runs.length && !runs[0].fileMissing){
|
|
1871
2115
|
document.getElementById("tab-runs").click();
|
|
@@ -1877,9 +2121,11 @@
|
|
|
1877
2121
|
document.getElementById("tab-run").click();
|
|
1878
2122
|
runsFilterBar.refresh();
|
|
1879
2123
|
}
|
|
2124
|
+
startPolling();
|
|
1880
2125
|
}).catch(function(e){
|
|
1881
2126
|
document.getElementById("wf-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
|
|
1882
2127
|
document.getElementById("history-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
|
|
2128
|
+
startPolling(); // still start — a transient initial failure shouldn't block all future polling
|
|
1883
2129
|
});
|
|
1884
2130
|
|
|
1885
2131
|
// theme toggle
|
package/src/panel/server.js
CHANGED
|
@@ -556,7 +556,7 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
556
556
|
// `replayRun` (a single spine record, never audit-sidecar-sourced, so it
|
|
557
557
|
// carries no contamination risk); `null` when the spine carries no
|
|
558
558
|
// `memory-cache` record at all (never armed on this run).
|
|
559
|
-
behaviour: scopedBehaviour(row
|
|
559
|
+
behaviour: scopedBehaviour(row, rawSpineRecords),
|
|
560
560
|
memoryCache: summary.memoryCache,
|
|
561
561
|
};
|
|
562
562
|
}
|
|
@@ -633,6 +633,55 @@ function makeRoundLookup(spineRecords) {
|
|
|
633
633
|
};
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
+
/**
|
|
637
|
+
* One owner for "where does THIS run's gate-audit sidecar live on disk right
|
|
638
|
+
* now" — used by both {@link scopedBehaviour} (Run tab) and {@link
|
|
639
|
+
* getRunAudit} (Audit tab), so the two can never disagree. Two cases:
|
|
640
|
+
* (a) the FINISHED convention ({@link resolveSiblings}: `<spine-stem>-
|
|
641
|
+
* gate-audit.jsonl` beside the spine) — `run-u`'s end-of-run rename
|
|
642
|
+
* (`src/userrun.js:1727-1729`) and every existing archived-run test
|
|
643
|
+
* already reads this shape;
|
|
644
|
+
* (b) LIVE fallback (panel P2 defect 3, hamr-watched run mujjtrvd,
|
|
645
|
+
* 2026-09-27): while a run has no `job-end` yet, its sidecar is still
|
|
646
|
+
* sitting at its DURING-RUN path, `<row.patient>/gate-audit.jsonl` —
|
|
647
|
+
* `run-u` writes it at `wd` (`src/userrun.js:1727`'s `auditSrc`) and
|
|
648
|
+
* `bareloop run`/`via:'bundle'` writes it at the worktree
|
|
649
|
+
* (`src/cli.js:448`'s `auditSrc`); `row.patient` already carries
|
|
650
|
+
* exactly that path for both (`src/userrun.js:1355`, `src/cli.js:418`),
|
|
651
|
+
* so this one fallback covers both without knowing which via wrote it.
|
|
652
|
+
* Only tried when (a) found nothing AND this run's own spine carries no
|
|
653
|
+
* `job-end` yet, so a finished run never pays the extra stat and never
|
|
654
|
+
* risks reading a stale leftover file ((a)'s rename already moved it
|
|
655
|
+
* away by the time job-end lands). `row.patient` is `null` on a
|
|
656
|
+
* `backfill` row (never a live run) and on some pre-cutoff `run-u` rows
|
|
657
|
+
* — both correctly fall through to `null` here.
|
|
658
|
+
* Callers still scope the rows they read from this file by this run's own
|
|
659
|
+
* job-start..job-end ts window ({@link runAuditWindow}) — a shared tree/
|
|
660
|
+
* worktree gate-audit file can carry another run's rows too (this file's own
|
|
661
|
+
* header comment on `runAuditWindow`), live or finished.
|
|
662
|
+
*
|
|
663
|
+
* NOT handled here (a separate, pre-existing gap, out of this fix's scope):
|
|
664
|
+
* a FINISHED `via:'bundle'` run's sidecar is renamed to a bare
|
|
665
|
+
* `gate-audit.jsonl` inside its own `runs/<runid>/` dir (`src/cli.js:449`),
|
|
666
|
+
* but its spine is named `spine.jsonl`, so {@link resolveSiblings} looks for
|
|
667
|
+
* `spine-gate-audit.jsonl` and never finds it — a finished bundle run reads
|
|
668
|
+
* `no-sidecar`/`toolLogSaved:false` even though its tool log really was
|
|
669
|
+
* saved. Reported, not fixed here (a naming mismatch in `src/cli.js`'s own
|
|
670
|
+
* rename target, not a panel-server read-path bug).
|
|
671
|
+
* @param {{spine: string, patient: string|null}} row
|
|
672
|
+
* @param {any[]} spineRecords
|
|
673
|
+
* @returns {string|null}
|
|
674
|
+
*/
|
|
675
|
+
function resolveAuditPathForRow(row, spineRecords) {
|
|
676
|
+
const { auditPath } = resolveSiblings(row.spine);
|
|
677
|
+
if (auditPath) return auditPath;
|
|
678
|
+
const hasJobEnd = spineRecords.some((r) => r && r.type === 'job-end');
|
|
679
|
+
if (hasJobEnd) return null;
|
|
680
|
+
if (typeof row.patient !== 'string' || row.patient.length === 0) return null;
|
|
681
|
+
const live = join(row.patient, 'gate-audit.jsonl');
|
|
682
|
+
return existsSync(live) ? live : null;
|
|
683
|
+
}
|
|
684
|
+
|
|
636
685
|
/**
|
|
637
686
|
* `runBehaviour`, fed only the gate-audit rows inside this run's own ts
|
|
638
687
|
* window (see {@link runAuditWindow}) — the panel-side fix for the same
|
|
@@ -641,12 +690,14 @@ function makeRoundLookup(spineRecords) {
|
|
|
641
690
|
* can never disagree with each other about which rows belong to this run.
|
|
642
691
|
* `null` when no gate-audit sidecar exists at all (never a fake all-zero
|
|
643
692
|
* object — same rule `replayRun`'s own `auditAvailable` already follows).
|
|
644
|
-
*
|
|
693
|
+
* `auditPath` resolution (finished sibling, or the live during-run fallback)
|
|
694
|
+
* is {@link resolveAuditPathForRow} — the one shared owner.
|
|
695
|
+
* @param {{spine: string, patient: string|null}} row
|
|
645
696
|
* @param {any[]} spineRecords
|
|
646
697
|
* @returns {ReturnType<typeof runBehaviour>|null}
|
|
647
698
|
*/
|
|
648
|
-
function scopedBehaviour(
|
|
649
|
-
const
|
|
699
|
+
function scopedBehaviour(row, spineRecords) {
|
|
700
|
+
const auditPath = resolveAuditPathForRow(row, spineRecords);
|
|
650
701
|
if (!auditPath || !existsSync(auditPath)) return null;
|
|
651
702
|
const { records } = parseJsonl(auditPath);
|
|
652
703
|
const { startTs, endTs } = runAuditWindow(spineRecords);
|
|
@@ -803,13 +854,18 @@ export function getRunAudit(runid, opts = {}) {
|
|
|
803
854
|
runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
|
|
804
855
|
};
|
|
805
856
|
}
|
|
806
|
-
const {
|
|
857
|
+
const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
|
|
858
|
+
// {@link resolveAuditPathForRow} needs `spineRecords` (to know whether this
|
|
859
|
+
// run has reached `job-end` yet) before it can decide whether the LIVE
|
|
860
|
+
// during-run fallback is even worth trying — so the spine is parsed once,
|
|
861
|
+
// here, before the sidecar path is resolved (moved up from right after the
|
|
862
|
+
// old `resolveSiblings`-only check this replaces).
|
|
863
|
+
const auditPath = resolveAuditPathForRow(row, spineRecords);
|
|
807
864
|
if (!auditPath || !existsSync(auditPath)) {
|
|
808
865
|
return {
|
|
809
866
|
runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
|
|
810
867
|
};
|
|
811
868
|
}
|
|
812
|
-
const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
|
|
813
869
|
const { startTs, endTs } = runAuditWindow(spineRecords);
|
|
814
870
|
const roundOf = makeRoundLookup(spineRecords);
|
|
815
871
|
const roundRecords = sortedRoundRecords(spineRecords);
|
package/src/replay.js
CHANGED
|
@@ -799,8 +799,40 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
799
799
|
// plan run whose close was satisfied on the first outer-close precheck
|
|
800
800
|
// never enters a fix loop, and this field must read exactly as it did
|
|
801
801
|
// before this addition for that run).
|
|
802
|
+
// F196 (2026-09-27, live-observed on mujjtrvd): a step still RUNNING (no
|
|
803
|
+
// `step-end` on the spine yet) has its own in-flight micro-loop
|
|
804
|
+
// (`ralph({judge,...})`, src/planrun.js) that emits its OWN
|
|
805
|
+
// `iteration-start` — e.g. step 2's attempt 1 on mujjtrvd (seq 71,
|
|
806
|
+
// `strict-clean-whole-src`'s own step-start at seq 69, no step-end). The
|
|
807
|
+
// old `lastStepEndSeq` (the LAST *ended* step's own boundary — seq 68,
|
|
808
|
+
// step 1 `type-checks-jsdoc`'s step-end) left that still-running step's
|
|
809
|
+
// seq range wide open, so its iteration-start leaked in below as a fake
|
|
810
|
+
// fix-loop attempt: the Run tab showed step 2 as `[done]` with 0
|
|
811
|
+
// calls/`?` (`end` never found for it in the `steps` map) plus a phantom
|
|
812
|
+
// `FIX [running]` box after it. An un-ended step is its own right
|
|
813
|
+
// boundary: the fix loop can only start once the run's LAST-STARTED step
|
|
814
|
+
// (`steps[steps.length-1]` — `steps` preserves spine order, so this is
|
|
815
|
+
// the step with the highest `stepStartSeq` across the whole file,
|
|
816
|
+
// including every concatenated leg of a resumed chain) has reached its
|
|
817
|
+
// own step-end.
|
|
818
|
+
//
|
|
819
|
+
// Deliberately checks only the LAST step, never "every step" (measured
|
|
820
|
+
// and rejected: a first pass checking `steps.some(s => s.stepEndSeq ===
|
|
821
|
+
// Infinity)` broke a genuinely FINISHED resumed-chain archive,
|
|
822
|
+
// litectx-u-bareloop/reuse-msc6w93z.jsonl — its first leg's
|
|
823
|
+
// `fix-index-js-strict` attempt (step-start seq 34) crashed with no
|
|
824
|
+
// step-end before the chain resumed and re-ran that same step from seq
|
|
825
|
+
// 77 onward to a real step-end at seq 115; the file's LAST step
|
|
826
|
+
// (`achieve-strict-typecheck`, step-end seq 611) did end, and this run's
|
|
827
|
+
// real fixLoop must still be read). Checking only the last-started step
|
|
828
|
+
// preserves that file's output unchanged while still nulling fixLoop on
|
|
829
|
+
// mujjtrvd (last step `strict-clean-whole-src` never step-ends in the
|
|
830
|
+
// mid-run slice) and on every other archived spine this fix was diffed
|
|
831
|
+
// against that turned out to be a truncated/killed file with no
|
|
832
|
+
// `job-end` at all (its last step-start likewise never step-ended) —
|
|
833
|
+
// see the F196 commit's report for the full before/after list.
|
|
802
834
|
let fixLoop = null;
|
|
803
|
-
if (timelineKind === 'steps' && stepStarts.length > 0) {
|
|
835
|
+
if (timelineKind === 'steps' && stepStarts.length > 0 && steps[steps.length - 1].stepEndSeq !== Infinity) {
|
|
804
836
|
const lastStepEndSeq = stepEnds.reduce((max, se) => (typeof se.seq === 'number' && se.seq > max ? se.seq : max), -Infinity);
|
|
805
837
|
const outerClosesAfter = spine
|
|
806
838
|
.filter((e) => e.type === 'outer-close' && typeof e.seq === 'number' && e.seq > lastStepEndSeq)
|
|
@@ -869,7 +901,21 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
869
901
|
iteration: typeof is.iteration === 'number' ? is.iteration : null,
|
|
870
902
|
verdict,
|
|
871
903
|
stages: end && end.type === 'close-verdict' && Array.isArray(end.stages) ? end.stages : null,
|
|
872
|
-
|
|
904
|
+
// `end === null` means this iteration's own window hasn't closed
|
|
905
|
+
// yet (no close-verdict/run-end/escalation boundary has landed on
|
|
906
|
+
// the spine after its iteration-start) — a LIVE run reading this
|
|
907
|
+
// mid-iteration, never a finished one (a finished spine's last
|
|
908
|
+
// iteration always has a boundary). Fabricating 'red' here (as
|
|
909
|
+
// `verdict === null` used to fall through to) reads a still-running
|
|
910
|
+
// fix-loop attempt as an already-failed one; `null` (the same
|
|
911
|
+
// "unresolved" value every other in-progress outcome in this file
|
|
912
|
+
// uses) is the honest read. Fixed here, the one place this outcome
|
|
913
|
+
// is minted, rather than in the panel's `partBoxState` (which reads
|
|
914
|
+
// this field but has no ordering info to tell "genuinely red" from
|
|
915
|
+
// "still open" apart if this field itself already lies).
|
|
916
|
+
outcome: end
|
|
917
|
+
? /** @type {'green'|'red'} */ (verdict === 'satisfied' || verdict === 'green' || verdict === 'already-green' ? 'green' : 'red')
|
|
918
|
+
: /** @type {null} */ (null),
|
|
873
919
|
...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
|
|
874
920
|
windowStartSeq: startSeq,
|
|
875
921
|
windowEndSeq: endSeq,
|