bareloop 0.34.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 +31 -0
- package/bareloop.context.md +241 -13
- 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 +7 -7
- package/src/job.js +37 -0
- package/src/judged.js +2 -2
- package/src/kinds.js +2 -1
- package/src/ledger.js +101 -5
- package/src/legs.js +171 -0
- package/src/monthly.js +111 -72
- package/src/panel/authorroutes.js +84 -14
- 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 +839 -70
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- 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 +75 -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 +138 -57
- package/types/draftspend.d.ts +53 -0
- package/types/job.d.ts +25 -0
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +23 -18
- package/types/panel/authorroutes.d.ts +23 -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 +12 -0
- 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 +52 -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
|
|
@@ -2434,7 +2427,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2434
2427
|
* `iteration` mirrors `roundIteration` exactly, undefined included: the bound
|
|
2435
2428
|
* cannot know a label the run never set, and inventing one here would be the
|
|
2436
2429
|
* only place in this file that claims to.
|
|
2437
|
-
* @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}
|
|
2438
2431
|
*/
|
|
2439
2432
|
let attemptBounded;
|
|
2440
2433
|
/**
|
|
@@ -2658,6 +2651,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2658
2651
|
// completes no round and returns from no step, which are the only two places
|
|
2659
2652
|
// `clock.expired()` is otherwise consulted). Same clock object as every other
|
|
2660
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
|
+
};
|
|
2661
2662
|
const stallWatch = createStallWatch({
|
|
2662
2663
|
onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
|
|
2663
2664
|
expired: () => clock.expired(),
|
|
@@ -2740,7 +2741,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2740
2741
|
// step loop after a step returns (between steps), the head of a step's attempt
|
|
2741
2742
|
// (a step that would BEGIN past the deadline is never funded), and the head of
|
|
2742
2743
|
// a close-fix iteration (W-2 — the run stops on the verdict already minted).
|
|
2743
|
-
|
|
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()) {
|
|
2744
2755
|
attemptBounded = { iteration: roundIteration, cause: 'wall', reason: null };
|
|
2745
2756
|
emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
|
|
2746
2757
|
self.stop();
|
|
@@ -2777,7 +2788,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2777
2788
|
// bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
|
|
2778
2789
|
// message before re-running for exactly this reason; do the same here.
|
|
2779
2790
|
const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
|
|
2780
|
-
|
|
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;
|
|
2781
2794
|
} catch (e) {
|
|
2782
2795
|
throw categorize(e).err;
|
|
2783
2796
|
}
|
|
@@ -2786,6 +2799,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2786
2799
|
let r;
|
|
2787
2800
|
try {
|
|
2788
2801
|
r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
2802
|
+
throwIfStopped();
|
|
2789
2803
|
} catch (e) {
|
|
2790
2804
|
throw categorize(e).err;
|
|
2791
2805
|
}
|
|
@@ -2803,6 +2817,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2803
2817
|
emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason });
|
|
2804
2818
|
return r;
|
|
2805
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
|
+
}
|
|
2806
2831
|
// the remaining error-return taxonomy (one map, same doctrine as native's):
|
|
2807
2832
|
// halt → cap-halt, API truncation → provider-red, anything else is wiring
|
|
2808
2833
|
if (r.error) {
|
|
@@ -3690,6 +3715,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
3690
3715
|
// (attempts used, steps done); its absence means the deadline landed inside a call
|
|
3691
3716
|
// instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
|
|
3692
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
|
+
}
|
|
3693
3723
|
if (cat === 'wall-halt') {
|
|
3694
3724
|
emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
3695
3725
|
planExecuted();
|
|
@@ -4053,6 +4083,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
4053
4083
|
.filter((s) => s.values.length > 0)
|
|
4054
4084
|
.map((s) => `${s.stage} ${s.values.join(' → ')}`);
|
|
4055
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();
|
|
4056
4089
|
await w.ask([
|
|
4057
4090
|
'The job\'s final verification is failing. Fix the repository so it passes.',
|
|
4058
4091
|
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
@@ -4060,6 +4093,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
4060
4093
|
!gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
|
|
4061
4094
|
gap && `Previous attempt failed the verification:\n${gap}`,
|
|
4062
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),
|
|
4063
4097
|
rootInj && rootInj.note,
|
|
4064
4098
|
].filter(Boolean).join('\n\n'));
|
|
4065
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
|
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';
|
package/src/replayio.js
CHANGED
|
@@ -87,7 +87,10 @@ export function resolveSiblings(spinePath) {
|
|
|
87
87
|
const base = basename(spinePath);
|
|
88
88
|
const stem = base.replace(/\.jsonl$/, '');
|
|
89
89
|
const runId = stem.startsWith('u-') ? stem.slice(2) : stem;
|
|
90
|
-
|
|
90
|
+
let auditPath = join(dir, `${stem}-gate-audit.jsonl`);
|
|
91
|
+
// a bundle run (`<bundle>/runs/<runid>/spine.jsonl`) keeps its tool log beside the spine as plain `gate-audit.jsonl`
|
|
92
|
+
const bundleAudit = join(dir, 'gate-audit.jsonl');
|
|
93
|
+
if (base === 'spine.jsonl' && !existsSync(auditPath) && existsSync(bundleAudit)) auditPath = bundleAudit;
|
|
91
94
|
return { runId, auditPath: existsSync(auditPath) ? auditPath : null };
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -96,7 +99,7 @@ export function resolveSiblings(spinePath) {
|
|
|
96
99
|
* hand both to {@link replayRun}. Read-only, $0, mints no verdict.
|
|
97
100
|
* @param {string} spinePath absolute or cwd-relative path to a `.jsonl` spine
|
|
98
101
|
* @param {{preParsedSpine?: {records: any[], skipped: number}, skipAudit?: boolean,
|
|
99
|
-
* auditPathOverride?: string|null}} [opts]
|
|
102
|
+
* auditPathOverride?: string|string[]|null}} [opts]
|
|
100
103
|
* `preParsedSpine`: avoids a second parse of the same file when the caller
|
|
101
104
|
* ({@link listSpines}) already read it once to run {@link looksLikeSpine}.
|
|
102
105
|
* `skipAudit` (PR #23 review item 5, 2026-08-26, carried over): a
|
|
@@ -120,13 +123,17 @@ export function replayOne(spinePath, opts = {}) {
|
|
|
120
123
|
const { runId, auditPath: resolvedAuditPath } = resolveSiblings(spinePath);
|
|
121
124
|
const auditPath = auditPathOverride !== undefined ? auditPathOverride : resolvedAuditPath;
|
|
122
125
|
const spine = preParsedSpine ?? parseJsonl(spinePath);
|
|
123
|
-
|
|
126
|
+
// P5-R: a RESUMED run still being written has TWO homes for its tool log — the finished legs' file and the
|
|
127
|
+
// live leg's file in the patient tree — so a caller may hand every path the run's log is in
|
|
128
|
+
const auditPaths = Array.isArray(auditPath) ? auditPath : (auditPath ? [auditPath] : []);
|
|
129
|
+
const audit = { records: /** @type {any[]} */ ([]), skipped: 0 };
|
|
130
|
+
if (!skipAudit) for (const ap of auditPaths) { const part = parseJsonl(ap); audit.records.push(...part.records); audit.skipped += part.skipped; }
|
|
124
131
|
// `auditAvailable`: true only when a sidecar was actually FOUND AND READ.
|
|
125
132
|
// `skipAudit` (a deliberate cheap directory listing) and "no sidecar on
|
|
126
133
|
// disk" both mean this call never learned the real tool-call count, so
|
|
127
134
|
// both report unknown, never a coincidental 0 (doctrine: unknown reported
|
|
128
135
|
// as unknown, never rendered as zero — see `replayRun`'s own doc).
|
|
129
|
-
const auditAvailable = !skipAudit &&
|
|
136
|
+
const auditAvailable = !skipAudit && auditPaths.length > 0;
|
|
130
137
|
const summary = replayRun(spine.records, audit.records, { runId, auditAvailable });
|
|
131
138
|
summary.skipped += spine.skipped + audit.skipped;
|
|
132
139
|
return summary;
|