bareloop 0.33.0 → 0.35.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 +47 -0
- package/bareloop.context.md +263 -27
- package/package.json +1 -1
- package/src/bundlerun.js +13 -4
- package/src/draftspend.js +82 -0
- package/src/exits.js +10 -1
- package/src/interviewrun.js +10 -9
- package/src/job.js +39 -2
- package/src/judged.js +25 -5
- package/src/kinds.js +2 -0
- package/src/ledger.js +104 -9
- package/src/legs.js +171 -0
- package/src/monthly.js +133 -87
- package/src/panel/authorroutes.js +95 -15
- package/src/panel/authorsession.js +408 -113
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1071 -184
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +855 -81
- package/src/panel/status.js +51 -0
- package/src/planrun.js +63 -31
- package/src/providerrows.js +21 -2
- package/src/providers.js +4 -4
- package/src/ralph.js +3 -0
- package/src/replay.js +139 -19
- package/src/replayio.js +11 -4
- package/src/reuse.js +20 -3
- package/src/run.js +6 -3
- package/src/runlist.js +90 -7
- package/src/text.js +20 -0
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +166 -65
- package/types/draftspend.d.ts +53 -0
- package/types/job.d.ts +25 -0
- package/types/judged.d.ts +5 -2
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +39 -22
- package/types/panel/authorroutes.d.ts +38 -1
- package/types/panel/authorsession.d.ts +109 -2
- package/types/panel/importroutes.d.ts +129 -0
- package/types/panel/importrun.d.ts +77 -0
- package/types/panel/runroutes.d.ts +32 -0
- package/types/panel/server.d.ts +255 -2
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +14 -1
- package/types/providers.d.ts +1 -2
- package/types/replay.d.ts +25 -1
- package/types/replayio.d.ts +2 -2
- package/types/reuse.d.ts +5 -0
- package/types/run.d.ts +6 -2
- package/types/runlist.d.ts +66 -2
- package/types/text.d.ts +19 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// PANEL status words — ONE code-owned table (sign -> word) and ONE writer of "which word does this run wear".
|
|
2
|
+
// hamr's ruling 2026-10-04 (option A): every surface (run cards, expanded run rows, the right-side header, imported
|
|
3
|
+
// rows) reads `status` from the server; the page never maps an outcome to a word itself.
|
|
4
|
+
//
|
|
5
|
+
// [▶] running · [·] waiting · [✓] passed · [✗] failed · [✗] capped · [✗] stopped · [?] died
|
|
6
|
+
//
|
|
7
|
+
// Outcome -> word (every outcome the panel knows; anything not listed is `failed`):
|
|
8
|
+
// green, already-green, satisfied -> passed
|
|
9
|
+
// no job-end, runner alive (or starting) -> running
|
|
10
|
+
// no job-end, runner gone (died) -> died
|
|
11
|
+
// cap-halt, wall-halt -> capped (money cap / time cap)
|
|
12
|
+
// escalated + money-halt record, escalated + wall-halt -> capped
|
|
13
|
+
// stopped (the person pressed Stop) -> stopped
|
|
14
|
+
// plan-red, check-red, step-red, escalated (strikes / provider / other), close-red, provider-red, step-stalled,
|
|
15
|
+
// and every other outcome (refusals, instrument stops) -> failed
|
|
16
|
+
// `waiting` is the step-level sign for a step that has not started; no run outcome maps to it.
|
|
17
|
+
|
|
18
|
+
/** the sentence a passed run carries as its reason (the Ended block's `line`) */
|
|
19
|
+
export const GOAL_MET_LINE = 'goal met';
|
|
20
|
+
|
|
21
|
+
/** @typedef {'running'|'waiting'|'passed'|'failed'|'capped'|'stopped'|'died'} StatusKey */
|
|
22
|
+
|
|
23
|
+
/** the table: key -> sign + word */
|
|
24
|
+
export const STATUS = Object.freeze({
|
|
25
|
+
running: Object.freeze({ sign: '▶', word: 'running' }),
|
|
26
|
+
waiting: Object.freeze({ sign: '·', word: 'waiting' }),
|
|
27
|
+
passed: Object.freeze({ sign: '✓', word: 'passed' }),
|
|
28
|
+
failed: Object.freeze({ sign: '✗', word: 'failed' }),
|
|
29
|
+
capped: Object.freeze({ sign: '✗', word: 'capped' }),
|
|
30
|
+
stopped: Object.freeze({ sign: '✗', word: 'stopped' }),
|
|
31
|
+
died: Object.freeze({ sign: '?', word: 'died' }),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The status of one run.
|
|
36
|
+
* @param {{outcome: string|null|undefined, died?: boolean, category?: string|null, moneyHalt?: boolean}} o
|
|
37
|
+
* `category` is the last escalation's category; `moneyHalt` is whether the spine carries a `money-halt` record
|
|
38
|
+
* @returns {{key: StatusKey, sign: string, word: string}}
|
|
39
|
+
*/
|
|
40
|
+
export function statusFor(o) {
|
|
41
|
+
/** @type {StatusKey} */
|
|
42
|
+
let key = 'failed';
|
|
43
|
+
const out = o.outcome;
|
|
44
|
+
if (o.died) key = 'died';
|
|
45
|
+
else if (out === null || out === undefined) key = 'running';
|
|
46
|
+
else if (out === 'green' || out === 'already-green' || out === 'satisfied') key = 'passed';
|
|
47
|
+
else if (out === 'cap-halt' || out === 'wall-halt') key = 'capped';
|
|
48
|
+
else if (out === 'stopped') key = 'stopped';
|
|
49
|
+
else if (out === 'escalated' && ((o.category === 'cap-halt' && o.moneyHalt) || o.category === 'wall-halt')) key = 'capped';
|
|
50
|
+
return { key, ...STATUS[key] };
|
|
51
|
+
}
|
package/src/planrun.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// which putting either in fs.deny would silently contradict.
|
|
22
22
|
|
|
23
23
|
import { createRequire } from 'node:module';
|
|
24
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs';
|
|
25
25
|
import { createHash } from 'node:crypto';
|
|
26
26
|
import { join, resolve, relative } from 'node:path';
|
|
27
27
|
import { Gate } from 'bareguard';
|
|
@@ -39,8 +39,9 @@ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, str
|
|
|
39
39
|
import { createReadShim, readShimArm, readShimStrategy } from './readshim.js';
|
|
40
40
|
import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
|
|
41
41
|
import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
|
|
42
|
-
import { extractArtifact } from './text.js';
|
|
42
|
+
import { extractArtifact, rateSourceFields } from './text.js';
|
|
43
43
|
import { defaultJudgeLoop } from './judged.js';
|
|
44
|
+
export { rateSourceFields };
|
|
44
45
|
import { createClock, isWallTimeout } from './clock.js';
|
|
45
46
|
import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
|
|
46
47
|
import {
|
|
@@ -536,25 +537,8 @@ export const FORBIDDEN_WRITE_SEGMENT_PATTERN = /"path":"(?:(?:[^"\\]|\\.)*\/)?(?
|
|
|
536
537
|
*/
|
|
537
538
|
export const BOUND_REASON_MAX = 200;
|
|
538
539
|
|
|
539
|
-
/**
|
|
540
|
-
|
|
541
|
-
* `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
|
|
542
|
-
* `pricing` on every metering payload; both metering callbacks below forward it onto the
|
|
543
|
-
* spine through THIS one helper, because two sites spelling one rule are two instruments
|
|
544
|
-
* (the ripgrep fix that landed in ci.yml but not publish.yml).
|
|
545
|
-
*
|
|
546
|
-
* Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
|
|
547
|
-
* the one place that decides what counts as a guess. And an ABSENT provenance stays
|
|
548
|
-
* absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
|
|
549
|
-
* in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
|
|
550
|
-
* invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
|
|
551
|
-
* priced", which is a different fact from "nobody told us".
|
|
552
|
-
* @param {any} arg an `onLlmResult` / `onTurn` payload
|
|
553
|
-
* @returns {{rateSource?: string|null}} the field to spread into the spine record
|
|
554
|
-
*/
|
|
555
|
-
export function rateSourceFields(arg) {
|
|
556
|
-
return arg && typeof arg === 'object' && 'rateSource' in arg ? { rateSource: arg.rateSource } : {};
|
|
557
|
-
}
|
|
540
|
+
/** the recorded reason for a step attempt whose round was cut at the output cap (F122 2A b) */
|
|
541
|
+
export const TRUNCATED_REASON = 'output cut at the token limit (max_tokens)';
|
|
558
542
|
|
|
559
543
|
/**
|
|
560
544
|
* What the NEXT attempt is told about the bound that cut the previous one.
|
|
@@ -589,6 +573,9 @@ export function rateSourceFields(arg) {
|
|
|
589
573
|
*/
|
|
590
574
|
export function boundedNote(bounded, rounds) {
|
|
591
575
|
if (!bounded) return null;
|
|
576
|
+
if (bounded.cause === 'truncated') {
|
|
577
|
+
return `Your previous attempt was CUT OFF: ${TRUNCATED_REASON}. That response was discarded.`;
|
|
578
|
+
}
|
|
592
579
|
if (bounded.cause !== 'denied') {
|
|
593
580
|
return `Your previous attempt was CUT OFF after ${rounds} tool rounds. `
|
|
594
581
|
+ 'Reading is bounded; writing is not. Form a hypothesis EARLY and make the change.';
|
|
@@ -982,12 +969,18 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
982
969
|
* passes `false` so the three `--resume`-naming readouts below (`MONEY_OPTIONS`,
|
|
983
970
|
* the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
|
|
984
971
|
* in v1" instead of naming a flag that would fail if typed.
|
|
972
|
+
* @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
|
|
973
|
+
* (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of a step worker
|
|
974
|
+
* (the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
|
|
975
|
+
* the file, end the Loop after that round, and end the leg `stopped` (a checkpoint outcome, filed
|
|
976
|
+
* the way a mid-step cap-halt is; resume re-enters that step). Nothing is in flight at that
|
|
977
|
+
* point, so the spend is exact. `null` = no stop surface (the default).
|
|
985
978
|
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
986
979
|
* 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
|
|
987
|
-
* 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
|
|
980
|
+
* 'branch-red' | 'cap-halt' | 'wall-halt' | 'stopped' | 'provider-red' | 'interpreter-red' |
|
|
988
981
|
* 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
989
982
|
*/
|
|
990
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
983
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null }) {
|
|
991
984
|
// MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
|
|
992
985
|
// every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
|
|
993
986
|
// worker) — one accumulator closed over by all of them, because the shim's ledger
|
|
@@ -1708,11 +1701,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1708
1701
|
stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
|
|
1709
1702
|
costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
|
|
1710
1703
|
// BA-21 provenance, through the SAME helper the worker rounds use — this record
|
|
1711
|
-
// is on `spendProvenance`'s read list (src/ledger.js)
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
1714
|
-
// (src/kinds.js `onJudgeCost({...})`) does not carry `rateSource` today, so it
|
|
1715
|
-
// still reads UNKNOWN — correctly — until that payload forwards it too.
|
|
1704
|
+
// is on `spendProvenance`'s read list (src/ledger.js). The judge cost payload
|
|
1705
|
+
// (src/kinds.js `onJudgeCost`) forwards the field when the judge Loop carried it
|
|
1706
|
+
// (`defaultJudgeLoop`); a loop that metered no rate leaves the key absent (UNKNOWN).
|
|
1716
1707
|
...rateSourceFields(c),
|
|
1717
1708
|
}),
|
|
1718
1709
|
})
|
|
@@ -2436,7 +2427,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2436
2427
|
* `iteration` mirrors `roundIteration` exactly, undefined included: the bound
|
|
2437
2428
|
* cannot know a label the run never set, and inventing one here would be the
|
|
2438
2429
|
* only place in this file that claims to.
|
|
2439
|
-
* @type {{iteration: number|string|undefined, cause: 'rounds'|'wall'|'denied', reason: string|null}|undefined}
|
|
2430
|
+
* @type {{iteration: number|string|undefined, cause: 'rounds'|'wall'|'denied'|'truncated', reason: string|null}|undefined}
|
|
2440
2431
|
*/
|
|
2441
2432
|
let attemptBounded;
|
|
2442
2433
|
/**
|
|
@@ -2660,6 +2651,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2660
2651
|
// completes no round and returns from no step, which are the only two places
|
|
2661
2652
|
// `clock.expired()` is otherwise consulted). Same clock object as every other
|
|
2662
2653
|
// reader — never a second time source.
|
|
2654
|
+
/** P5 item 5 — set by the round seam when the person's stop request was honoured on this worker */
|
|
2655
|
+
let stopHit = false;
|
|
2656
|
+
const throwIfStopped = () => {
|
|
2657
|
+
if (!stopHit) return;
|
|
2658
|
+
const err = /** @type {CategorizedError} */ (new Error('stopped at the person\'s request (a stop request was honoured at a round boundary)'));
|
|
2659
|
+
err.category = 'stopped';
|
|
2660
|
+
throw err;
|
|
2661
|
+
};
|
|
2663
2662
|
const stallWatch = createStallWatch({
|
|
2664
2663
|
onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
|
|
2665
2664
|
expired: () => clock.expired(),
|
|
@@ -2742,7 +2741,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2742
2741
|
// step loop after a step returns (between steps), the head of a step's attempt
|
|
2743
2742
|
// (a step that would BEGIN past the deadline is never funded), and the head of
|
|
2744
2743
|
// a close-fix iteration (W-2 — the run stops on the verdict already minted).
|
|
2745
|
-
|
|
2744
|
+
// P5 item 5 — the person's STOP, read at THIS seam: the round boundary, where the money cap binds
|
|
2745
|
+
// (nothing in flight, the round just metered). It is the ONE owner of the stop request. The file is
|
|
2746
|
+
// consumed here; `self.stop()` ends the Loop after this round exactly as a bound does; `ask`/`askFrom`
|
|
2747
|
+
// then throw category `stopped`, which the step loop files as the checkpoint outcome `stopped` the way a
|
|
2748
|
+
// mid-step cap-halt is filed. Step workers only (scout/plan/fix never read it).
|
|
2749
|
+
if (stopFile !== null && !stopHit && phase.startsWith('step:') && existsSync(stopFile)) {
|
|
2750
|
+
try { unlinkSync(stopFile); } catch { /* already consumed */ }
|
|
2751
|
+
stopHit = true;
|
|
2752
|
+
emit('stop-requested', { phase, step: phase.slice('step:'.length), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume re-enters this step' });
|
|
2753
|
+
self.stop();
|
|
2754
|
+
} else if (clock.expired()) {
|
|
2746
2755
|
attemptBounded = { iteration: roundIteration, cause: 'wall', reason: null };
|
|
2747
2756
|
emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
|
|
2748
2757
|
self.stop();
|
|
@@ -2779,7 +2788,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2779
2788
|
// bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
|
|
2780
2789
|
// message before re-running for exactly this reason; do the same here.
|
|
2781
2790
|
const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
|
|
2782
|
-
|
|
2791
|
+
const r = await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
2792
|
+
throwIfStopped();
|
|
2793
|
+
return r;
|
|
2783
2794
|
} catch (e) {
|
|
2784
2795
|
throw categorize(e).err;
|
|
2785
2796
|
}
|
|
@@ -2788,6 +2799,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2788
2799
|
let r;
|
|
2789
2800
|
try {
|
|
2790
2801
|
r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
2802
|
+
throwIfStopped();
|
|
2791
2803
|
} catch (e) {
|
|
2792
2804
|
throw categorize(e).err;
|
|
2793
2805
|
}
|
|
@@ -2805,6 +2817,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2805
2817
|
emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason });
|
|
2806
2818
|
return r;
|
|
2807
2819
|
}
|
|
2820
|
+
// F122 "2A" half (b), hamr 2026-10-05: a STEP or FIX worker round cut at max_tokens
|
|
2821
|
+
// is a FAILED WORKER ATTEMPT, not transport. Same lane as the deny streak above: the
|
|
2822
|
+
// cut round's output is discarded (bare-agent already dropped it), its spend is booked
|
|
2823
|
+
// by the per-round meter, the tree is judged as it stands, and the step's strike
|
|
2824
|
+
// ladder (or the fix governor) applies unchanged (hamr ruling A extended it to `fix`
|
|
2825
|
+
// after run muux1x96). Scout, drafter, replan keep the transport route below.
|
|
2826
|
+
if (r.error && r.error.startsWith('truncated:') && (phase.startsWith('step:') || phase === 'fix')) {
|
|
2827
|
+
attemptBounded = { iteration: roundIteration, cause: 'truncated', reason: TRUNCATED_REASON };
|
|
2828
|
+
emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason: TRUNCATED_REASON, stopReason: r.stopReason ?? null });
|
|
2829
|
+
return r;
|
|
2830
|
+
}
|
|
2808
2831
|
// the remaining error-return taxonomy (one map, same doctrine as native's):
|
|
2809
2832
|
// halt → cap-halt, API truncation → provider-red, anything else is wiring
|
|
2810
2833
|
if (r.error) {
|
|
@@ -3692,6 +3715,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
3692
3715
|
// (attempts used, steps done); its absence means the deadline landed inside a call
|
|
3693
3716
|
// instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
|
|
3694
3717
|
// on exactly that field, so conflating the two would report an unknown as exact.
|
|
3718
|
+
// P5 item 5 — the person stopped the leg at a round boundary inside this step: a checkpoint, not a verdict
|
|
3719
|
+
if (cat === 'stopped') {
|
|
3720
|
+
planExecuted();
|
|
3721
|
+
return 'stopped';
|
|
3722
|
+
}
|
|
3695
3723
|
if (cat === 'wall-halt') {
|
|
3696
3724
|
emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
3697
3725
|
planExecuted();
|
|
@@ -4055,6 +4083,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
4055
4083
|
.filter((s) => s.values.length > 0)
|
|
4056
4084
|
.map((s) => `${s.stage} ${s.values.join(' → ')}`);
|
|
4057
4085
|
const factsLine = [checksFact, ...stageFacts].filter(Boolean).join(' · ');
|
|
4086
|
+
// same iteration numbering as the step loop: `w.setIteration(iteration)` above
|
|
4087
|
+
// stamps the bound, so "the attempt just before this one" is `iteration - 1`
|
|
4088
|
+
const bound = w.wasBounded();
|
|
4058
4089
|
await w.ask([
|
|
4059
4090
|
'The job\'s final verification is failing. Fix the repository so it passes.',
|
|
4060
4091
|
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
@@ -4062,6 +4093,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
4062
4093
|
!gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
|
|
4063
4094
|
gap && `Previous attempt failed the verification:\n${gap}`,
|
|
4064
4095
|
factsLine && `The close's own numbers so far, oldest first (facts only — no advice):\n${factsLine}`,
|
|
4096
|
+
bound?.iteration === iteration - 1 && boundedNote(bound, maxStepRounds),
|
|
4065
4097
|
rootInj && rootInj.note,
|
|
4066
4098
|
].filter(Boolean).join('\n\n'));
|
|
4067
4099
|
};
|
package/src/providerrows.js
CHANGED
|
@@ -134,6 +134,23 @@ export function endpointOf(provider, baseUrl) {
|
|
|
134
134
|
} catch { return raw.replace(/\/+$/, ''); }
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* A job's provider in plain words for a refusal line: the host its Base URL points at (else the
|
|
139
|
+
* shape's own default host, else the shape's label), plus the model when the spec names one.
|
|
140
|
+
* Never a key, a path or a status code.
|
|
141
|
+
* @param {{provider?: string, baseUrl?: string|null, model?: string|null}} spec
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
export function providerInWords(spec) {
|
|
145
|
+
const provider = String(spec?.provider ?? '');
|
|
146
|
+
const url = endpointOf(provider, spec?.baseUrl ?? null);
|
|
147
|
+
let host = '';
|
|
148
|
+
try { host = new URL(url).host; } catch { host = ''; }
|
|
149
|
+
if (host === '') host = SHAPES.find((s) => s.id === provider)?.label ?? provider;
|
|
150
|
+
const model = typeof spec?.model === 'string' ? spec.model.trim() : '';
|
|
151
|
+
return model !== '' ? `${host}, ${model}` : host;
|
|
152
|
+
}
|
|
153
|
+
|
|
137
154
|
/**
|
|
138
155
|
* The URL a row talks to, for display ('' = the vendor's own host, which has no URL to show).
|
|
139
156
|
* @param {KeyRow} row
|
|
@@ -236,12 +253,14 @@ function perMoney(n) {
|
|
|
236
253
|
|
|
237
254
|
/**
|
|
238
255
|
* The one readout line for a resolved customer price, preview and run tail alike:
|
|
239
|
-
* `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`.
|
|
256
|
+
* `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`. A 0 / 0 price is accepted (the price
|
|
257
|
+
* is the customer's) but the line says so: every round then books $0, so spend and limits read $0.
|
|
240
258
|
* @param {{ inPerM: number, outPerM: number, envName: string }} price a {@link ratesFor} result
|
|
241
259
|
* @returns {string}
|
|
242
260
|
*/
|
|
243
261
|
export function priceReadout(price) {
|
|
244
|
-
|
|
262
|
+
const zero = price.inPerM === 0 && price.outPerM === 0 ? ' — WARNING: this price is $0, so spend and limits read $0' : '';
|
|
263
|
+
return `yours: in ${perMoney(price.inPerM)} / out ${perMoney(price.outPerM)} per 1M tokens (${price.envName} row)${zero}`;
|
|
245
264
|
}
|
|
246
265
|
|
|
247
266
|
/**
|
package/src/providers.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// `provider` name to the bare-agent constructor it drives, the env var
|
|
3
3
|
// carrying its key, and the per-tier model table — including any
|
|
4
4
|
// provider-specific request-key gating. Before this module existed, both
|
|
5
|
-
// runners constructed `AnthropicProvider` BY NAME (`
|
|
6
|
-
// `
|
|
5
|
+
// runners constructed `AnthropicProvider` BY NAME (the old `buildProviders` in
|
|
6
|
+
// `src/cli.js` and `scripts/run-u.mjs`'s top-level wiring, both since replaced
|
|
7
|
+
// by `buildRunnerProviders` below) and any second
|
|
7
8
|
// provider would have meant scattering `if (provider === …)` across both.
|
|
8
9
|
//
|
|
9
10
|
// hamr's ruling (PRD 30.7, 2026-09-09): `deepseek-chat` is THE secondary
|
|
@@ -265,8 +266,7 @@ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
|
|
|
265
266
|
|
|
266
267
|
/**
|
|
267
268
|
* Build the `{provider, providerFor, judgeProvider}` triple both runners
|
|
268
|
-
* need (`src/
|
|
269
|
-
* wiring) — ONE seam instead of two copies of the same construction and
|
|
269
|
+
* need (`src/authorrun.js` and `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction and
|
|
270
270
|
* tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
|
|
271
271
|
* (the caller runs `resolveWorkerModel` itself, exactly as both runners did
|
|
272
272
|
* before this factory existed — that decision stays where each runner's own
|
package/src/ralph.js
CHANGED
|
@@ -653,6 +653,9 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
|
|
|
653
653
|
['raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
|
|
654
654
|
'revise the goal/spec so the work fits the time (same re-approval)',
|
|
655
655
|
'abandon the task']],
|
|
656
|
+
// P5 item 5 — the PERSON asked to stop at a round boundary: nothing failed, nothing is discarded.
|
|
657
|
+
'stopped': ['You stopped the run. Nothing is discarded: the work on disk stands, and a resume re-enters this step.',
|
|
658
|
+
['resume (the same run continues)', 'abandon the task']],
|
|
656
659
|
// F66 — the STALL WATCHDOG gave up: no round completed for the stall window,
|
|
657
660
|
// three times over, and reissuing the call did not recover it. It borrows both
|
|
658
661
|
// siblings' rules. Like `step-variance`, the caller reads this category and
|
package/src/replay.js
CHANGED
|
@@ -103,7 +103,8 @@
|
|
|
103
103
|
|
|
104
104
|
import { runBehaviour, formatBehaviour } from './behaviour.js';
|
|
105
105
|
import { shortSha } from './codeversion.js';
|
|
106
|
-
import { SPEND_RECORD_TYPES } from './ledger.js';
|
|
106
|
+
import { SPEND_RECORD_TYPES, chainSpend } from './ledger.js';
|
|
107
|
+
import { legsOf } from './legs.js';
|
|
107
108
|
|
|
108
109
|
/**
|
|
109
110
|
* @param {unknown} e
|
|
@@ -167,6 +168,24 @@ export function auditWindow(jobStart, jobEnd) {
|
|
|
167
168
|
};
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* One window PER LEG of a (possibly resumed) run (P5-R) — the ONE owner the panel and {@link replayRun} share. A
|
|
173
|
+
* run with no `leg-resume` marker is one leg and this is `[auditWindow(jobStart, jobEnd)]`, exactly as before. A
|
|
174
|
+
* resumed run's quiet time between two legs is nobody's, so a row another run wrote into a shared sidecar during it
|
|
175
|
+
* is outside every window; a leg that recorded no terminal (it died) ends where the next leg begins.
|
|
176
|
+
* @param {any[]} spineRecords
|
|
177
|
+
* @returns {{startTs: number, endTs: number}[]}
|
|
178
|
+
*/
|
|
179
|
+
export function auditWindowsOf(spineRecords) {
|
|
180
|
+
const legs = legsOf(spineRecords);
|
|
181
|
+
if (legs.length <= 1) {
|
|
182
|
+
const jobStart = spineRecords.find((e) => e && e.type === 'job-start') ?? null;
|
|
183
|
+
const jobEnd = legs.at(-1)?.jobEnd ?? null;
|
|
184
|
+
return [auditWindow(jobStart, jobEnd)];
|
|
185
|
+
}
|
|
186
|
+
return legs.map((l, i) => auditWindow(l.jobStart ?? l.start, l.jobEnd ?? (legs[i + 1]?.start ? { ts: legs[i + 1].start.ts } : null)));
|
|
187
|
+
}
|
|
188
|
+
|
|
170
189
|
/**
|
|
171
190
|
* Trim a string to `max` chars with an explicit `…[+N chars]` marker —
|
|
172
191
|
* NEVER a silent truncation, and never a trailing ellipsis alone
|
|
@@ -422,8 +441,14 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
422
441
|
else skipped += 1;
|
|
423
442
|
}
|
|
424
443
|
|
|
444
|
+
// P5-R: a resumed run is ONE spine of several legs (src/legs.js `legsOf`, the one owner of the boundaries).
|
|
445
|
+
// `jobStart` is the run's FIRST (its start, its drafting fold); `latestStart` the latest leg's (the caps it ran
|
|
446
|
+
// under now); `jobEnd` the run's terminal — the LAST leg's, never an earlier leg's. A spine with no marker is
|
|
447
|
+
// one leg and every figure below reads exactly as it always did.
|
|
448
|
+
const legs = legsOf(spine);
|
|
425
449
|
const jobStart = spine.find((e) => e.type === 'job-start') ?? null;
|
|
426
|
-
const
|
|
450
|
+
const latestStart = legs.at(-1)?.jobStart ?? jobStart;
|
|
451
|
+
const jobEnd = legs.at(-1)?.jobEnd ?? null;
|
|
427
452
|
|
|
428
453
|
// F195 fix: a gate-audit sidecar CAN be shared across several runs whose
|
|
429
454
|
// spines happen to reuse the same filename (measured on a real archived
|
|
@@ -441,10 +466,10 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
441
466
|
// (`src/panel/server.js`) calls this same {@link auditWindow} rather than
|
|
442
467
|
// keeping its own copy.
|
|
443
468
|
{
|
|
444
|
-
const
|
|
469
|
+
const windows = auditWindowsOf(spine);
|
|
445
470
|
audit = audit.filter((a) => {
|
|
446
471
|
const t = parseTs(a.ts);
|
|
447
|
-
return t !== null && t >= startTs && t <= endTs;
|
|
472
|
+
return t !== null && windows.some((w) => t >= w.startTs && t <= w.endTs);
|
|
448
473
|
});
|
|
449
474
|
}
|
|
450
475
|
|
|
@@ -460,8 +485,12 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
460
485
|
// and the wall-halt sites (src/planrun.js:2990,3208-3209).
|
|
461
486
|
const stopReason = isGreen ? null : buildStopReason(lastEscalation, outcome);
|
|
462
487
|
|
|
463
|
-
|
|
464
|
-
|
|
488
|
+
// MONEY, one basis (P5-R): a one-leg spine reads its terminal's own total as it always did; a resumed run's
|
|
489
|
+
// legs are summed by each leg's own spend (`chainSpend`), never the last terminal's chain total on top of them
|
|
490
|
+
const chain = legs.length > 1 && jobEnd ? chainSpend(spine) : null;
|
|
491
|
+
const spentUsd = chain ? chain.usd
|
|
492
|
+
: (jobEnd && typeof jobEnd.spentUsd === 'number' && Number.isFinite(jobEnd.spentUsd) ? jobEnd.spentUsd : null);
|
|
493
|
+
const spendComplete = chain ? chain.complete : (jobEnd ? jobEnd.spendComplete === true && spentUsd !== null : false);
|
|
465
494
|
|
|
466
495
|
// draftSpentUsd (hamr's ruling 2026-09-28, "one cap covers drafting +
|
|
467
496
|
// run") — the AUTHORING pipeline's own spend on this job before it was
|
|
@@ -480,9 +509,28 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
480
509
|
// exact then, the field simply didn't exist yet to say so.
|
|
481
510
|
const draftSpendComplete = draftSpentUsd === null ? null
|
|
482
511
|
: (jobStart.draftSpendComplete !== false);
|
|
483
|
-
const floorReasonList = floorReasons(spine, jobStart, spendComplete);
|
|
484
|
-
|
|
485
|
-
|
|
512
|
+
const floorReasonList = floorReasons(spine, legs.map((l) => l.jobStart).find((j) => j?.priorSpendComplete === false) ?? jobStart, spendComplete);
|
|
513
|
+
|
|
514
|
+
// WALL: the sum of each leg's own working window, the gap between legs excluded (hamr 2026-10-02, 2A). A
|
|
515
|
+
// leg that died has no terminal, so its window ends at its last record (a floor); the LATEST leg without a
|
|
516
|
+
// terminal is unknown, as a one-leg run without one always was.
|
|
517
|
+
/** @param {import('./legs.js').Leg} l @param {boolean} isLast */
|
|
518
|
+
const legWallMs = (l, isLast) => {
|
|
519
|
+
const a = parseTs((l.jobStart ?? l.start)?.ts);
|
|
520
|
+
const b = l.jobEnd ? parseTs(l.jobEnd.ts) : (isLast ? null : parseTs(l.end?.ts));
|
|
521
|
+
return windowWallMs(a, b);
|
|
522
|
+
};
|
|
523
|
+
const wallMs = legs.length > 1
|
|
524
|
+
? (() => {
|
|
525
|
+
let sum = 0;
|
|
526
|
+
for (let i = 0; i < legs.length; i += 1) {
|
|
527
|
+
const w = legWallMs(legs[i], i === legs.length - 1);
|
|
528
|
+
if (w === null) return null;
|
|
529
|
+
sum += w;
|
|
530
|
+
}
|
|
531
|
+
return sum;
|
|
532
|
+
})()
|
|
533
|
+
: windowWallMs(parseTs(jobStart?.ts), parseTs(jobEnd?.ts));
|
|
486
534
|
|
|
487
535
|
// chainClock: the SIGNED wall, read off the run's own clock record —
|
|
488
536
|
// src/planrun.js:1203 (`wall-clock`) or `emitWallHalt` (src/planrun.js:1269)
|
|
@@ -556,7 +604,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
556
604
|
// record at all (resumed before any plan reload was needed); keying
|
|
557
605
|
// "resumed" on resume-seed alone would have printed `resumed: no` on
|
|
558
606
|
// genuinely resumed runs and mis-explained their spend.
|
|
559
|
-
const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
|
|
607
|
+
const resumed = legs.length > 1 || (!!jobStart && 'priorSpentUsd' in jobStart);
|
|
560
608
|
|
|
561
609
|
// resumeSeed: the DETAIL fields (phase/completed/skipping/divergence) —
|
|
562
610
|
// present only on the narrower resume-seed record; `resumed` above stays
|
|
@@ -582,7 +630,8 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
582
630
|
const totalRoundsInFile = spendRecords.length;
|
|
583
631
|
const unpricedRoundsInFile = spendRecords.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
|
|
584
632
|
const summedRounds = spendRecords.reduce((acc, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? acc + r.costUsd : acc), 0);
|
|
585
|
-
|
|
633
|
+
// (a resumed run's terminal carries only its LATEST leg's engagement — its file's figure is the sum of the rounds)
|
|
634
|
+
const hasEngagementField = legs.length <= 1 && jobEnd && typeof jobEnd.engagementSpentUsd === 'number' && Number.isFinite(jobEnd.engagementSpentUsd);
|
|
586
635
|
const thisFileSpend = {
|
|
587
636
|
value: unpricedRoundsInFile > 0 ? null : (hasEngagementField ? jobEnd.engagementSpentUsd : summedRounds),
|
|
588
637
|
source: /** @type {'engagementSpentUsd'|'summed rounds'} */ (hasEngagementField ? 'engagementSpentUsd' : 'summed rounds'),
|
|
@@ -664,18 +713,38 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
664
713
|
|
|
665
714
|
const usedEndSeqs = new Set();
|
|
666
715
|
const idOccurrence = new Map();
|
|
716
|
+
/** @type {Map<string, {stopReason: string|null, legIdx: number, tryNumber: number}>} */
|
|
717
|
+
const prevOccurrence = new Map();
|
|
667
718
|
|
|
668
719
|
const steps = stepStarts.map((ss) => {
|
|
669
720
|
const id = ss.step;
|
|
670
721
|
const startSeq = typeof ss.seq === 'number' ? ss.seq : -Infinity;
|
|
671
722
|
const startTs = parseTs(ss.ts);
|
|
672
723
|
|
|
724
|
+
// P5-R: a step belongs to ONE leg. Its `step-end` is the first one inside ITS OWN leg (never a later leg's
|
|
725
|
+
// `step-end` for the same id), and a step its leg's halt cut off ends where that leg ends — otherwise its
|
|
726
|
+
// window runs across the idle gap to the next leg and counts the gap as its time.
|
|
727
|
+
const myLegIdx = legs.length > 1 ? legs.reduce((acc, l, i) => (typeof l.start?.seq === 'number' && startSeq >= l.start.seq ? i : acc), 0) : 0;
|
|
728
|
+
const myLeg = legs.length > 1 ? legs[myLegIdx] : null;
|
|
729
|
+
const legEndSeq = legs.length > 1 && myLegIdx < legs.length - 1 && typeof legs[myLegIdx + 1].start?.seq === 'number' ? legs[myLegIdx + 1].start.seq : Infinity;
|
|
673
730
|
const end = stepEnds
|
|
674
|
-
.filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && !usedEndSeqs.has(se.seq))
|
|
731
|
+
.filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && se.seq < legEndSeq && !usedEndSeqs.has(se.seq))
|
|
675
732
|
.sort((a, b) => a.seq - b.seq)[0] ?? null;
|
|
676
733
|
if (end) usedEndSeqs.add(end.seq);
|
|
677
|
-
const
|
|
678
|
-
const
|
|
734
|
+
const cutByLeg = !end && myLeg !== null && Number.isFinite(legEndSeq);
|
|
735
|
+
const endSeq = end && typeof end.seq === 'number' ? end.seq : (cutByLeg ? legEndSeq : Infinity);
|
|
736
|
+
const endTs = end ? parseTs(end.ts) : (cutByLeg ? parseTs(myLeg?.end?.ts) : null);
|
|
737
|
+
// why this leg stopped the step (code-owned words); null for a step that ended itself or is still running
|
|
738
|
+
/** @type {string|null} */
|
|
739
|
+
let stopReason = null;
|
|
740
|
+
if (!end && myLeg !== null) {
|
|
741
|
+
const o = myLeg.outcome;
|
|
742
|
+
const isLastLeg = myLegIdx === legs.length - 1;
|
|
743
|
+
if (cutByLeg || (isLastLeg && o !== null && o !== 'green' && o !== 'already-green')) {
|
|
744
|
+
stopReason = o === 'cap-halt' ? 'money cap' : o === 'wall-halt' ? 'time cap' : o === 'provider-red' ? 'provider failed'
|
|
745
|
+
: o === 'step-stalled' ? 'step stalled' : o === 'stopped' ? 'you stopped it' : o === null ? 'died' : o;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
679
748
|
|
|
680
749
|
const phaseTag = `step:${id}`;
|
|
681
750
|
// `judge-round` (a close's paid seam) carries no `.phase` at all
|
|
@@ -753,10 +822,17 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
753
822
|
}
|
|
754
823
|
|
|
755
824
|
idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
|
|
825
|
+
// the SAME step continuing across a leg boundary (the previous occurrence of this id was cut off by its leg's
|
|
826
|
+
// halt, and this one starts in a later leg) is "continued", never a retry; `tryNumber` counts real retries only
|
|
827
|
+
const prevOcc = prevOccurrence.get(id) ?? null;
|
|
828
|
+
const continued = myLeg !== null && prevOcc !== null && prevOcc.stopReason !== null && prevOcc.legIdx < myLegIdx;
|
|
829
|
+
const tryNumber = prevOcc === null ? 1 : (continued ? prevOcc.tryNumber : prevOcc.tryNumber + 1);
|
|
830
|
+
prevOccurrence.set(id, { stopReason, legIdx: myLegIdx, tryNumber });
|
|
756
831
|
|
|
757
832
|
return {
|
|
758
833
|
id,
|
|
759
834
|
occurrence: idOccurrence.get(id),
|
|
835
|
+
...(myLeg === null ? {} : { stopReason, continued, tryNumber }),
|
|
760
836
|
outcome: end ? (end.outcome ?? null) : null,
|
|
761
837
|
...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
|
|
762
838
|
checks: { passed, failed },
|
|
@@ -1241,7 +1317,10 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1241
1317
|
spentUsd: step.spentUsd,
|
|
1242
1318
|
unpricedRounds: step.unpricedRounds,
|
|
1243
1319
|
outcome: step.outcome,
|
|
1244
|
-
|
|
1320
|
+
// P5-R (resumed runs only): why its leg's halt cut this step off, whether it is the same step continuing
|
|
1321
|
+
// from the leg before, and its retry count not counting continuations
|
|
1322
|
+
...(step.stopReason === undefined ? {} : { stopReason: step.stopReason, continued: step.continued, tryNumber: step.tryNumber }),
|
|
1323
|
+
attempts: step.stopReason ? stepAttempts.map((a) => (a.outcome === null || a.outcome === undefined ? { ...a, outcome: 'stopped' } : a)) : stepAttempts,
|
|
1245
1324
|
});
|
|
1246
1325
|
}
|
|
1247
1326
|
|
|
@@ -1321,6 +1400,32 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1321
1400
|
}
|
|
1322
1401
|
}
|
|
1323
1402
|
|
|
1403
|
+
// P5-R: which LEG each part belongs to, and where a resume picked up — only for a resumed run (a spine with
|
|
1404
|
+
// no marker keeps its parts byte-for-byte). A step part is dated by its own step-start; a replan by its
|
|
1405
|
+
// marker; every other part by the end of its last attempt (its own window's far edge), falling back to the
|
|
1406
|
+
// near edge, then to leg 1. `resumedNext` marks the part a leg ENDED at: the next part is the leg after.
|
|
1407
|
+
if (legs.length > 1) {
|
|
1408
|
+
/** @param {number|null|undefined} seq */
|
|
1409
|
+
const legOfSeq = (seq) => {
|
|
1410
|
+
let n = 1;
|
|
1411
|
+
if (typeof seq !== 'number' || !Number.isFinite(seq)) return n;
|
|
1412
|
+
for (const l of legs) if (typeof l.start?.seq === 'number' && seq >= l.start.seq) n = l.leg;
|
|
1413
|
+
return n;
|
|
1414
|
+
};
|
|
1415
|
+
for (const p of parts) {
|
|
1416
|
+
const atts = Array.isArray(p.attempts) ? p.attempts : [];
|
|
1417
|
+
const finiteEnds = atts.map((a) => a.endSeq).filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
1418
|
+
const firstStart = atts.length ? atts[0].startSeq : null;
|
|
1419
|
+
if (p.kind === 'step') p.leg = legOfSeq(firstStart);
|
|
1420
|
+
else if (p.kind === 'replan' && typeof p.markerSeq === 'number') p.leg = legOfSeq(p.markerSeq);
|
|
1421
|
+
else if (finiteEnds.length) p.leg = legOfSeq(Math.max(...finiteEnds));
|
|
1422
|
+
else p.leg = legOfSeq(firstStart);
|
|
1423
|
+
}
|
|
1424
|
+
// legs never go backwards along the part list (a part is dated by file order)
|
|
1425
|
+
for (let i = 1; i < parts.length; i += 1) if (parts[i].leg < parts[i - 1].leg) parts[i].leg = parts[i - 1].leg;
|
|
1426
|
+
for (let i = 0; i < parts.length - 1; i += 1) if (parts[i + 1].leg > parts[i].leg) parts[i].resumedNext = true;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1324
1429
|
return {
|
|
1325
1430
|
runId,
|
|
1326
1431
|
job: jobStart?.job ?? null,
|
|
@@ -1359,8 +1464,9 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1359
1464
|
// escalation happen at all"). `summarizeForAllLine`'s `reason` column
|
|
1360
1465
|
// needs the latter question answered unconditionally.
|
|
1361
1466
|
lastEscalation,
|
|
1362
|
-
|
|
1363
|
-
|
|
1467
|
+
// the caps and the spec the run's LATEST leg ran under (a resume may run under a re-signed hash)
|
|
1468
|
+
budgetUsd: latestStart && typeof latestStart.budgetUsd === 'number' && Number.isFinite(latestStart.budgetUsd) ? latestStart.budgetUsd : null,
|
|
1469
|
+
specHash: typeof latestStart?.specHash === 'string' ? latestStart.specHash : null,
|
|
1364
1470
|
branch,
|
|
1365
1471
|
outcome,
|
|
1366
1472
|
stopReason,
|
|
@@ -1375,6 +1481,17 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1375
1481
|
chainClock,
|
|
1376
1482
|
resumed,
|
|
1377
1483
|
resumeSeed,
|
|
1484
|
+
// P5-R — the run's legs in order (always at least one for a spine with records): the panel's Audit divider and
|
|
1485
|
+
// map connector read these, never re-derive a boundary. `startSeq`/`endSeq` bound the leg on the one `seq` series.
|
|
1486
|
+
legs: legs.map((l) => ({
|
|
1487
|
+
leg: l.leg,
|
|
1488
|
+
after: l.after,
|
|
1489
|
+
outcome: l.outcome,
|
|
1490
|
+
startTs: typeof l.start?.ts === 'string' ? l.start.ts : null,
|
|
1491
|
+
endTs: typeof l.end?.ts === 'string' ? l.end.ts : null,
|
|
1492
|
+
startSeq: typeof l.start?.seq === 'number' ? l.start.seq : null,
|
|
1493
|
+
endSeq: typeof l.end?.seq === 'number' ? l.end.seq : null,
|
|
1494
|
+
})),
|
|
1378
1495
|
thisFileSpend,
|
|
1379
1496
|
// spendMismatch: a NON-resumed run's own `job-end.spentUsd` IS this
|
|
1380
1497
|
// file's whole spend (no fold to explain a gap) — so on a cold run,
|
|
@@ -1684,7 +1801,10 @@ export function formatReplay(summary) {
|
|
|
1684
1801
|
? `this file unknown (${tf.unpricedRounds} of ${tf.totalRounds} rounds unpriced; ${tf.source})`
|
|
1685
1802
|
: `this file ${money(tf.value)} (${tf.totalRounds} round${tf.totalRounds === 1 ? '' : 's'}, all priced; ${tf.source})`;
|
|
1686
1803
|
let spentLine;
|
|
1687
|
-
if (summary.
|
|
1804
|
+
if (summary.legs.length > 1) {
|
|
1805
|
+
// one run, several legs on ONE file: one figure (each leg's own spend, summed), never two bases side by side
|
|
1806
|
+
spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} over ${summary.legs.length} legs of one run · of ${money2(summary.budgetUsd)} signed`;
|
|
1807
|
+
} else if (summary.resumed) {
|
|
1688
1808
|
spentLine = `spent: ${money(summary.spentUsd)} chain total (job-end; prior legs folded) · ${thisFileText} · of ${money2(summary.budgetUsd)} signed`;
|
|
1689
1809
|
} else {
|
|
1690
1810
|
spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} of ${money2(summary.budgetUsd)} signed`;
|
|
@@ -1705,7 +1825,7 @@ export function formatReplay(summary) {
|
|
|
1705
1825
|
? ` · floor because: ${summary.floorReasons.join(', ')}`
|
|
1706
1826
|
: ' · floor (reason not in spine)';
|
|
1707
1827
|
}
|
|
1708
|
-
spentLine += ` · wall ${duration(summary.wallMs)} (this file)`;
|
|
1828
|
+
spentLine += ` · wall ${duration(summary.wallMs)} (${summary.legs.length > 1 ? 'working time, gaps between legs excluded' : 'this file'})`;
|
|
1709
1829
|
if (summary.chainClock) {
|
|
1710
1830
|
const { elapsedMs, requestedMs, source } = summary.chainClock;
|
|
1711
1831
|
const signed = typeof requestedMs === 'number' ? `${duration(requestedMs)} signed` : 'no wall signed';
|