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
package/src/userrun.js
CHANGED
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
// green ONE job end to end. That green GRADUATES the bridge: the plan the agent
|
|
25
25
|
// authored is preserved from the spine as a reusable artifact, and the next run of
|
|
26
26
|
// this shape reuses and fine-tunes it rather than starting cold.
|
|
27
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, rmSync, readdirSync } from 'node:fs';
|
|
27
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, rmSync, readdirSync, openSync, readSync, closeSync, fstatSync, unlinkSync } from 'node:fs';
|
|
28
28
|
import { createRequire } from 'node:module';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
import { execFileSync, spawn } from 'node:child_process';
|
|
31
|
-
import { join, resolve, dirname } from 'node:path';
|
|
31
|
+
import { join, resolve, dirname, basename } from 'node:path';
|
|
32
32
|
import { runJob } from './run.js';
|
|
33
33
|
import { moneyWithDraft } from './replay.js';
|
|
34
34
|
import { jobSpecHash, resolveWorkerModel, validateJob } from './job.js';
|
|
@@ -72,10 +72,13 @@ import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
|
|
|
72
72
|
// the cold reset, shared with the battery drivers so "cold" has one spelling
|
|
73
73
|
import { coldReset, moveStaleGateAudit } from './u-patient.js';
|
|
74
74
|
// PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
|
|
75
|
-
import { appendRun, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
|
|
75
|
+
import { appendRun, appendLegStart, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
|
|
76
|
+
// P5-R — a resumed run is the SAME run: one id, one spine file, a `leg-resume` marker inside. `legsOf` is the
|
|
77
|
+
// one owner of where a leg starts and ends; every reader below asks it.
|
|
78
|
+
import { legsOf, parseSpineText, LEG_RESUME, watchdogNotePath, stopFilePath } from './legs.js';
|
|
76
79
|
import { keysForDoor } from './keysfile.js';
|
|
77
80
|
import { parseJsonl } from './replayio.js';
|
|
78
|
-
import { claimRun, monthlyRefusalText, legSpend } from './monthly.js';
|
|
81
|
+
import { claimRun, monthlyRefusalText, legSpend, chainSpend } from './monthly.js';
|
|
79
82
|
import { ConfigError } from './config.js';
|
|
80
83
|
import { commandFor } from './invoke.js';
|
|
81
84
|
import { applyConfiguredKey, judgeRatesFor, keyNameFor, priceReadout, ratesFor, rowsForHome } from './providerrows.js';
|
|
@@ -360,14 +363,17 @@ async function execute(ctx) {
|
|
|
360
363
|
// owner of "a claimed run that never began" — every exit between the claim and the spine's first
|
|
361
364
|
// record (a refused resume patient, a throwing prepareTree, a git/reset failure...) passes there.
|
|
362
365
|
// Set on the no-limit path too, once the row is appended there (the same "listed, no spine yet" state).
|
|
363
|
-
/** @type {{ runid: string, spine: string, home: string|undefined }|null} */
|
|
366
|
+
/** @type {{ runid: string, spine: string, home: string|undefined, leg: number }|null} */
|
|
364
367
|
let claimedRow = null;
|
|
368
|
+
// P5-R: has this leg written its first record (the `leg-resume` marker on a resume)? A resumed leg's spine
|
|
369
|
+
// file already exists, so "the file exists" no longer says the leg began.
|
|
370
|
+
let legBegun = false;
|
|
365
371
|
// Release the claimed row (a `released` entry folds it out of the list, like a monthly refusal): the run
|
|
366
372
|
// spent nothing and must not stay a ghost that keeps the month reading "at least". A failed release write
|
|
367
373
|
// never masks the original exit.
|
|
368
374
|
const releaseClaimedRow = () => {
|
|
369
375
|
if (!claimedRow) return;
|
|
370
|
-
try { appendRunEvent({ runid: claimedRow.runid, type: 'released', by: claimedRow.runid, reason: 'not started', at: new Date().toISOString() }, { home: claimedRow.home }); } catch { /* the exit stands; a stranded row of a dead process is closed by the next run */ }
|
|
376
|
+
try { appendRunEvent({ runid: claimedRow.runid, type: 'released', by: claimedRow.runid, reason: 'not started', at: new Date().toISOString(), ...(claimedRow.leg > 1 ? { leg: claimedRow.leg } : {}) }, { home: claimedRow.home }); } catch { /* the exit stands; a stranded row of a dead process is closed by the next run */ }
|
|
371
377
|
};
|
|
372
378
|
try {
|
|
373
379
|
const spec = ctx.spec;
|
|
@@ -662,6 +668,13 @@ async function execute(ctx) {
|
|
|
662
668
|
: (RESUME.includes('/') || RESUME.endsWith('.jsonl') ? resolve(RESUME) : join(spineDir, `u-${RESUME}.jsonl`));
|
|
663
669
|
/** @type {any} */
|
|
664
670
|
let dead = null;
|
|
671
|
+
/** P5-R — the legs the halted run's file already holds, the leg a resume continues from, and how that leg ended @type {import('./legs.js').Leg[]} */
|
|
672
|
+
let deadLegs = [];
|
|
673
|
+
/** every record of the halted run's one file (all its legs) @type {any[]} */
|
|
674
|
+
let deadAllEvents = [];
|
|
675
|
+
let deadFromLegNo = 1;
|
|
676
|
+
/** @type {string} */
|
|
677
|
+
let deadAfter = 'died';
|
|
665
678
|
/** F102 — the decision this checkpoint HOLDS: an answer the person already gave, on
|
|
666
679
|
* a leg that stopped before a single round was bought for it. Null on every ordinary
|
|
667
680
|
* resume. It is applied directly and the ask is never re-rendered — the whole of the
|
|
@@ -676,23 +689,34 @@ async function execute(ctx) {
|
|
|
676
689
|
if (!existsSync(deadSpineFile)) die(`--resume: no spine at ${deadSpineFile} — a resume continues a run that happened, and this one left no log`);
|
|
677
690
|
let raw = '';
|
|
678
691
|
try { raw = readFileSync(deadSpineFile, 'utf8'); } catch (e) { die(`--resume: cannot read ${deadSpineFile}: ${e.message}`); }
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
+
// ONE parser for the spine's text (src/legs.js `parseSpineText`): a kill can land mid-append, so a broken
|
|
693
|
+
// LAST line is the failure being modelled; and a resumed run's own file carries exactly one torn line
|
|
694
|
+
// directly before each `leg-resume` marker (the resume never edits the torn bytes, it starts a fresh
|
|
695
|
+
// line). Both are tolerated and NAMED. A broken line anywhere else is a corrupt log, and reconstructing
|
|
696
|
+
// from one would invent a history.
|
|
697
|
+
const parsedDead = parseSpineText(raw);
|
|
698
|
+
for (const t of parsedDead.tolerated) {
|
|
699
|
+
err(t.why === 'tail'
|
|
700
|
+
? `--resume: ignoring a truncated final line in ${deadSpineFile} (a kill mid-append — the expected shape)`
|
|
701
|
+
: `--resume: ignoring a truncated line ${t.line} in ${deadSpineFile} (a kill mid-append, left isolated by the next leg's marker)`);
|
|
702
|
+
}
|
|
703
|
+
if (parsedDead.corrupt) die(`--resume: ${deadSpineFile} is corrupt at line ${parsedDead.corrupt.line} (${parsedDead.corrupt.message}) — a reconstruction from a damaged log would invent a history`);
|
|
704
|
+
/** every record of the run's one file (all legs) */
|
|
705
|
+
deadAllEvents = parsedDead.records;
|
|
706
|
+
const allDeadEvents = deadAllEvents;
|
|
707
|
+
/** the legs already on the file, and the leg THIS resume will be (P5-R) */
|
|
708
|
+
deadLegs = legsOf(allDeadEvents);
|
|
709
|
+
// the leg a resume continues FROM is the latest leg that got as far as a `job-start`: a leg that died or
|
|
710
|
+
// was refused before runJob wrote one (a $0 stop) left no checkpoint, and the one behind it still stands
|
|
711
|
+
const fromLeg = deadLegs.findLast((l) => l.jobStart !== null) ?? deadLegs.at(-1);
|
|
712
|
+
/** the records `readResume` and every gate below read: that leg's own window, which carries its own declared fold */
|
|
713
|
+
const deadEvents = fromLeg ? fromLeg.records : allDeadEvents;
|
|
714
|
+
deadFromLegNo = fromLeg ? fromLeg.leg : 1;
|
|
692
715
|
// is the dead run actually dead? Two processes on one patient is unrecoverable; a
|
|
693
716
|
// false refusal costs one sentence. run-u's own pid is not on its spine, so the
|
|
694
717
|
// watchdog's report or the run-list row is the pid there is — and its absence is not evidence of life.
|
|
695
|
-
|
|
718
|
+
// the watchdog's note for the leg being continued from (a resumed leg has its own note file)
|
|
719
|
+
const wdFile = watchdogNotePath(deadSpineFile, deadFromLegNo);
|
|
696
720
|
/** @type {any} */
|
|
697
721
|
let watchdog = null;
|
|
698
722
|
if (existsSync(wdFile)) { try { watchdog = JSON.parse(readFileSync(wdFile, 'utf8')); } catch { /* an unreadable report is not evidence of life */ } }
|
|
@@ -704,6 +728,7 @@ async function execute(ctx) {
|
|
|
704
728
|
/** @type {number|undefined} */
|
|
705
729
|
let rowPid;
|
|
706
730
|
if (cfgOn) { try { rowPid = readRunList({ home: cfgHome }).rows.findLast((r) => r.spine === deadSpineFile)?.pid; } catch { /* an unreadable list is not evidence of life */ } }
|
|
731
|
+
// a run's row carries its LATEST leg's pid (the run list folds `leg-start`); any earlier leg's note pid is checked too
|
|
707
732
|
const alivePid = [watchdog?.pid, rowPid].find((p) => Number.isInteger(p) && isLiveRunner(p));
|
|
708
733
|
if (alivePid !== undefined) {
|
|
709
734
|
die(`--resume: pid ${alivePid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
|
|
@@ -715,6 +740,8 @@ async function execute(ctx) {
|
|
|
715
740
|
// closes was measured, not imagined (a report dated after a pause bills the human's
|
|
716
741
|
// deciding time to the run's wall and can zero the remainder outright).
|
|
717
742
|
const deathAt = deathAtOf({ watchdogAt: watchdog?.at, events: deadEvents });
|
|
743
|
+
// how the leg being continued from ended, for the marker the new leg opens with (`'died'`: no terminal recorded)
|
|
744
|
+
deadAfter = fromLeg?.outcome ?? 'died';
|
|
718
745
|
dead = readResume(deadEvents, {
|
|
719
746
|
direct: true,
|
|
720
747
|
resumableOutcomes: RESUMABLE_HALTS,
|
|
@@ -792,13 +819,10 @@ async function execute(ctx) {
|
|
|
792
819
|
if (!existsSync(doorSpineFile)) die(`--door: no spine at ${doorSpineFile} — a door belongs to a run that happened, and this one left no log`);
|
|
793
820
|
let raw = '';
|
|
794
821
|
try { raw = readFileSync(doorSpineFile, 'utf8'); } catch (e) { die(`--door: cannot read ${doorSpineFile}: ${e.message}`); }
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
die(`--door: ${doorSpineFile} is corrupt at line ${i + 1} (${e.message}) — a decision made on a damaged record is a decision about a run nobody can read`);
|
|
800
|
-
}
|
|
801
|
-
});
|
|
822
|
+
// the same parser as the resume reader: a resumed run's file carries one torn line before each marker
|
|
823
|
+
const parsedDoor = parseSpineText(raw);
|
|
824
|
+
if (parsedDoor.corrupt) die(`--door: ${doorSpineFile} is corrupt at line ${parsedDoor.corrupt.line} (${parsedDoor.corrupt.message}) — a decision made on a damaged record is a decision about a run nobody can read`);
|
|
825
|
+
doorEvents = parsedDoor.records;
|
|
802
826
|
const start = doorEvents.find((/** @type {any} */ e) => e?.type === 'job-start');
|
|
803
827
|
if (!start) die(`--door: ${doorSpineFile} carries no job-start — that is not a bareloop run's spine`);
|
|
804
828
|
if (start.job !== spec.job) die(`--door: that spine is job "${start.job}", not "${spec.job}" — a door is answered against the spec that was signed for it`);
|
|
@@ -820,14 +844,11 @@ async function execute(ctx) {
|
|
|
820
844
|
err(` - or revise the goal/spec in ${SPEC_DESC} — a spec edit, so the hash changes and you sign the new one.`);
|
|
821
845
|
throw new ExitSignal(2);
|
|
822
846
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
// at $0" — completeness is only trusted when the number it completes is known
|
|
829
|
-
spendComplete: spentKnown && je?.spendComplete !== false,
|
|
830
|
-
};
|
|
847
|
+
// the chain's spend, on ONE basis per shape (`chainSpend`, src/monthly.js): a resumed run's several legs are
|
|
848
|
+
// summed by their own spend, never a later terminal's chain total on top of the earlier legs. A FLOOR stays a
|
|
849
|
+
// floor across the door (F6): an absent figure is never "complete at $0".
|
|
850
|
+
const chain = chainSpend(doorEvents);
|
|
851
|
+
doorPrior = { spentUsd: chain.usd, spendComplete: chain.complete };
|
|
831
852
|
}
|
|
832
853
|
|
|
833
854
|
/** is this resume the one a PERSON has to answer? Read off the recorded terminal,
|
|
@@ -1303,10 +1324,10 @@ async function execute(ctx) {
|
|
|
1303
1324
|
// ── THE PAUSE DOOR (2026-08-17) — answered HERE, and it launches nothing.
|
|
1304
1325
|
//
|
|
1305
1326
|
// hamr's ruling replaced cancel with a pause that can resume, and the honest way to
|
|
1306
|
-
// keep a checkpoint is to leave it alone. This runner
|
|
1327
|
+
// keep a checkpoint is to leave it alone. This runner appends one LEG per resume to the run's one spine file,
|
|
1307
1328
|
// and a leg that returns before drafting emits no `plan-accepted` and no `step-end` —
|
|
1308
1329
|
// which is exactly what `readStepCheckpoint` reads. So launching a run to say "not
|
|
1309
|
-
// now" would
|
|
1330
|
+
// now" would add a LEG whose own checkpoint is empty, and an operator who
|
|
1310
1331
|
// later resumed that runid would re-draft and re-pay for every step the paused leg
|
|
1311
1332
|
// already finished. The checkpoint that matters is the one already on disk.
|
|
1312
1333
|
//
|
|
@@ -1488,14 +1509,23 @@ async function execute(ctx) {
|
|
|
1488
1509
|
// closed at its floor, with a note, once its process is gone). No limit set = no claim (the row is listed as
|
|
1489
1510
|
// ever). An unreadable config.json, or a claim row that cannot be written, refuses too — a broken instrument
|
|
1490
1511
|
// never silently reads "no limit".
|
|
1491
|
-
|
|
1492
|
-
|
|
1512
|
+
// P5-R (hamr 2026-10-02: "one run, one id, one file"): a RESUME is the same run — the runid and the spine file
|
|
1513
|
+
// are the halted run's own, and the new leg appends to it. Never a new id, never a second file.
|
|
1514
|
+
const resuming = dead !== null && deadSpineFile !== null;
|
|
1515
|
+
const runid = resuming
|
|
1516
|
+
? (ctx.bundle?.runid ?? basename(/** @type {string} */ (deadSpineFile)).replace(/\.jsonl$/, '').replace(/^u-/, ''))
|
|
1517
|
+
: (ctx.bundle?.runid ?? Date.now().toString(36));
|
|
1518
|
+
const spineFile = resuming ? /** @type {string} */ (deadSpineFile)
|
|
1519
|
+
: (ctx.bundle ? join(spineDir, 'spine.jsonl') : join(spineDir, `u-${runid}.jsonl`));
|
|
1520
|
+
/** which leg of the run this process is (1 = a fresh run) */
|
|
1521
|
+
const LEG = resuming ? deadLegs.length + 1 : 1;
|
|
1493
1522
|
const foldedUsd = dead ? dead.restart.priorSpentUsd : (doorPrior?.spentUsd ?? 0);
|
|
1494
1523
|
const legCapUsd = Math.max(0, spec.budgetUsd - (typeof foldedUsd === 'number' && Number.isFinite(foldedUsd) ? foldedUsd : 0));
|
|
1495
1524
|
// every row carries its runner's pid and the leg cap, limit set or not: a limit set LATER still holds it
|
|
1496
1525
|
/** @type {import('./runlist.js').RunRow} */
|
|
1497
1526
|
const runRow = {
|
|
1498
|
-
|
|
1527
|
+
// a RESUMED run keeps its own start stamp (the row is listed only if the list has none; it is the run's, not the leg's)
|
|
1528
|
+
at: resuming ? (deadLegs[0]?.start?.ts ?? new Date().toISOString()) : new Date().toISOString(), runid, job: spec.job, spine: spineFile, patient: wd, via: ctx.bundle ? 'bundle' : 'run-u', pid: process.pid, capUsd: legCapUsd,
|
|
1499
1529
|
};
|
|
1500
1530
|
let claimed = false;
|
|
1501
1531
|
{
|
|
@@ -1503,9 +1533,9 @@ async function execute(ctx) {
|
|
|
1503
1533
|
let refusal = null;
|
|
1504
1534
|
try {
|
|
1505
1535
|
if (cfgOn) {
|
|
1506
|
-
const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome });
|
|
1536
|
+
const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome, leg: LEG });
|
|
1507
1537
|
claimed = got.claimed;
|
|
1508
|
-
if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome };
|
|
1538
|
+
if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome, leg: LEG };
|
|
1509
1539
|
refusal = monthlyRefusalText(got.room);
|
|
1510
1540
|
}
|
|
1511
1541
|
} catch (e) {
|
|
@@ -1531,11 +1561,10 @@ async function execute(ctx) {
|
|
|
1531
1561
|
|
|
1532
1562
|
// PANEL-BUILD.md P1 — one row in the run list, BEFORE any paid call (this
|
|
1533
1563
|
// leg's own `runJob` further down, and BEFORE the provider-key checks that
|
|
1534
|
-
// preceded this point already gated on). `runid` is fresh
|
|
1535
|
-
//
|
|
1536
|
-
// runid-dedup
|
|
1537
|
-
//
|
|
1538
|
-
// in-process test). A list-append failure must never block a real,
|
|
1564
|
+
// preceded this point already gated on). `runid` is fresh on a cold start
|
|
1565
|
+
// and the HALTED run's own on a resume (P5-R: a resume is the same run), so on a resume
|
|
1566
|
+
// `appendRun`'s runid-dedup is what keeps it ONE row and the leg's claim is the
|
|
1567
|
+
// `leg-start` beside it; on a cold start it never fires (a retried in-process test). A list-append failure must never block a real,
|
|
1539
1568
|
// already-signed run: caught and named loudly, never rethrown.
|
|
1540
1569
|
// `deps.runlistHome` — the same injectable test seam `ctx.deps` already
|
|
1541
1570
|
// carries everything else through (env/out/err/provider/…): a caller that
|
|
@@ -1544,10 +1573,13 @@ async function execute(ctx) {
|
|
|
1544
1573
|
// When the monthly claim above already appended this run's row, there is nothing to add.
|
|
1545
1574
|
try {
|
|
1546
1575
|
if (!claimed) {
|
|
1576
|
+
// a RESUMED run is already listed: its row stays ONE row and this leg's claim is a `leg-start` beside it
|
|
1577
|
+
// (the row is added only when the list has none)
|
|
1547
1578
|
appendRun(runRow, { home: deps.runlistHome });
|
|
1579
|
+
if (LEG > 1) appendLegStart({ type: 'leg-start', runid, leg: LEG, pid: process.pid, capUsd: legCapUsd, at: new Date().toISOString() }, { home: deps.runlistHome });
|
|
1548
1580
|
// no limit set: the row is appended here, not by a claim — mark it so the execute catch releases it
|
|
1549
1581
|
// the same way if the run exits before its spine has a first record (it lives in `deps.runlistHome`)
|
|
1550
|
-
claimedRow = { runid, spine: spineFile, home: deps.runlistHome };
|
|
1582
|
+
claimedRow = { runid, spine: spineFile, home: deps.runlistHome, leg: LEG };
|
|
1551
1583
|
}
|
|
1552
1584
|
} catch (/** @type {any} */ e) {
|
|
1553
1585
|
err(`WARNING: could not add this run to ~/.config/bareloop/runs.jsonl (${e.message}) — the run continues; the panel's list will be missing this row.`);
|
|
@@ -1702,7 +1734,30 @@ async function execute(ctx) {
|
|
|
1702
1734
|
// here (not inline at the `runJob` call below) so this pre-run reading lands
|
|
1703
1735
|
// on the run's own spine in the SAME seq order everything else does, rather
|
|
1704
1736
|
// than a second, disconnected spine writer.
|
|
1705
|
-
|
|
1737
|
+
// P5-R: a RESUME continues the halted run's own file. `seq` is monotonic per SPINE, so the emitter starts
|
|
1738
|
+
// from the highest `seq` already in it; a kill can have left a torn line with no trailing newline, so ONE
|
|
1739
|
+
// `\n` is written first and the torn bytes stay exactly as they are, alone on their own line (the spine is
|
|
1740
|
+
// append-only forever: nothing here edits or truncates an existing byte).
|
|
1741
|
+
let startSeq = 0;
|
|
1742
|
+
if (resuming) {
|
|
1743
|
+
for (const e of deadAllEvents) if (typeof e?.seq === 'number' && Number.isFinite(e.seq) && e.seq > startSeq) startSeq = e.seq;
|
|
1744
|
+
const fd = openSync(spineFile, 'r');
|
|
1745
|
+
try {
|
|
1746
|
+
const size = fstatSync(fd).size;
|
|
1747
|
+
if (size > 0) {
|
|
1748
|
+
const last = Buffer.alloc(1);
|
|
1749
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
1750
|
+
if (last[0] !== 0x0a) appendFileSync(spineFile, '\n');
|
|
1751
|
+
}
|
|
1752
|
+
} finally { closeSync(fd); }
|
|
1753
|
+
}
|
|
1754
|
+
const emit = makeSpine(spineFile, { startSeq });
|
|
1755
|
+
// …and the leg's FIRST record is its marker, written BEFORE the outside watchdog is spawned: the watchdog
|
|
1756
|
+
// judges a run dead by its spine file going quiet, and a file untouched since the last leg would read as stale.
|
|
1757
|
+
if (resuming) {
|
|
1758
|
+
emit(LEG_RESUME, { leg: LEG, after: deadAfter, at: new Date().toISOString() });
|
|
1759
|
+
legBegun = true;
|
|
1760
|
+
}
|
|
1706
1761
|
|
|
1707
1762
|
// PRD item 33/M2, call site 3(a) — a source-door manifest, when this patient
|
|
1708
1763
|
// was PREPARED through `prepareSource` (`scripts/prep-source.mjs`), sits
|
|
@@ -1792,6 +1847,8 @@ async function execute(ctx) {
|
|
|
1792
1847
|
// time on exactly the run it is watching (F70's shape).
|
|
1793
1848
|
...(RESUME_WALL_MS === null ? [] : ['--wall-ms', String(RESUME_WALL_MS)]),
|
|
1794
1849
|
'--grace-ms', String(worstCloseSilenceMs),
|
|
1850
|
+
// this leg's own note file (leg 1 keeps the name every run has always had)
|
|
1851
|
+
'--report', watchdogNotePath(spineFile, LEG),
|
|
1795
1852
|
], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
1796
1853
|
// A spawn failure arrives as an EVENT, and an unhandled 'error' on a ChildProcess is
|
|
1797
1854
|
// an uncaught exception — i.e. a guard that could not start would END the paid run it
|
|
@@ -1828,9 +1885,16 @@ async function execute(ctx) {
|
|
|
1828
1885
|
lagDue = now + LAG_POLL_MS;
|
|
1829
1886
|
}, LAG_POLL_MS);
|
|
1830
1887
|
|
|
1888
|
+
// P5 item 5 — the STOP request file (`<spine>.stop`, src/legs.js). A request belongs to ONE leg: one left on
|
|
1889
|
+
// disk by an earlier leg (a click that raced that leg's end) is cleared HERE, before this leg's first step, so
|
|
1890
|
+
// a resume is never stopped at once by a request it did not receive. The engine also consumes the file when it
|
|
1891
|
+
// honours it, and the `finally` below clears whatever is left when the leg ends.
|
|
1892
|
+
const stopFile = stopFilePath(spineFile);
|
|
1893
|
+
try { rmSync(stopFile, { force: true }); } catch { /* best-effort — a stop surface must never kill the run */ }
|
|
1831
1894
|
let outcome;
|
|
1832
1895
|
try {
|
|
1833
1896
|
outcome = await runJob(spec, {
|
|
1897
|
+
stopFile,
|
|
1834
1898
|
// PRD item 27/M3 — the SAME `emit` the pre-run close-timing reading above
|
|
1835
1899
|
// used (never a second `makeSpine(spineFile)` here: two independent
|
|
1836
1900
|
// emitters against one file would both start their seq counter at 0 and
|
|
@@ -1919,6 +1983,7 @@ async function execute(ctx) {
|
|
|
1919
1983
|
// the guard outlives the run only by accident, never by design
|
|
1920
1984
|
try { watchdog.kill('SIGKILL'); } catch { /* already gone */ }
|
|
1921
1985
|
clearInterval(lagTimer);
|
|
1986
|
+
try { rmSync(stopFile, { force: true }); } catch { /* best-effort */ }
|
|
1922
1987
|
}
|
|
1923
1988
|
const legMs = Date.now() - started;
|
|
1924
1989
|
|
|
@@ -1926,7 +1991,11 @@ async function execute(ctx) {
|
|
|
1926
1991
|
// honest. No classification into pass/fail buckets — one run classifies nothing.
|
|
1927
1992
|
const raw = readFileSync(spineFile, 'utf8');
|
|
1928
1993
|
// tolerant: a torn/garbled line is skipped, not a crash (the shipped bundle-run fix, now the one engine read)
|
|
1929
|
-
const
|
|
1994
|
+
const allEvents = parseJsonl(spineFile).records;
|
|
1995
|
+
// P5-R: the readout below is about THIS leg — its own rounds, its own halts, its own terminal. A resumed run's
|
|
1996
|
+
// earlier legs are on the same file, and reading them here would count their rounds, tokens and halts again
|
|
1997
|
+
// (the run's chain-wide figures are the folded ones the lines below already print).
|
|
1998
|
+
const events = legsOf(allEvents).at(-1)?.records ?? allEvents;
|
|
1930
1999
|
const je = events.findLast((e) => e.type === 'job-end');
|
|
1931
2000
|
// The claim's WRITE-BACK (hamr 2026-09-30): when the run ends it settles its own claim on the monthly
|
|
1932
2001
|
// limit — its final spend, attributed to itself. Best-effort: a run that dies before here is settled
|
|
@@ -1934,7 +2003,7 @@ async function execute(ctx) {
|
|
|
1934
2003
|
if (je) {
|
|
1935
2004
|
try {
|
|
1936
2005
|
const fin = legSpend(events);
|
|
1937
|
-
appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString() }, { home: cfgHome });
|
|
2006
|
+
appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString(), ...(LEG > 1 ? { leg: LEG } : {}) }, { home: cfgHome });
|
|
1938
2007
|
} catch (/** @type {any} */ e) { err(`WARNING: could not settle this run's claim in ~/.config/bareloop/runs.jsonl (${e.message}) — the next run settles it once this process is gone.`); }
|
|
1939
2008
|
}
|
|
1940
2009
|
// the ONE spelling of the text-side scan (src/validate.js) — a hand-rolled copy
|
|
@@ -1946,8 +2015,21 @@ async function execute(ctx) {
|
|
|
1946
2015
|
|
|
1947
2016
|
const auditSrc = join(wd, 'gate-audit.jsonl');
|
|
1948
2017
|
let auditFile = null;
|
|
1949
|
-
|
|
1950
|
-
|
|
2018
|
+
// THIS leg's own audit rows — the readout's `writes` and BEHAVIOUR lines are about this leg, read from the
|
|
2019
|
+
// leg's own tree file before it is filed
|
|
2020
|
+
/** @type {any[]} */
|
|
2021
|
+
let audit = [];
|
|
2022
|
+
if (existsSync(auditSrc)) {
|
|
2023
|
+
const legAuditText = readFileSync(auditSrc, 'utf8');
|
|
2024
|
+
audit = legAuditText.trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
2025
|
+
auditFile = ctx.bundle ? join(spineDir, 'gate-audit.jsonl') : join(spineDir, `u-${runid}-gate-audit.jsonl`);
|
|
2026
|
+
// P5-R: a resumed leg ADDS to the run's one audit file (a rename would overwrite the earlier legs' rows)
|
|
2027
|
+
if (LEG > 1 && existsSync(auditFile)) {
|
|
2028
|
+
const prior = readFileSync(auditFile, 'utf8');
|
|
2029
|
+
appendFileSync(auditFile, `${prior.length > 0 && !prior.endsWith('\n') ? '\n' : ''}${legAuditText}`);
|
|
2030
|
+
unlinkSync(auditSrc);
|
|
2031
|
+
} else renameSync(auditSrc, auditFile);
|
|
2032
|
+
}
|
|
1951
2033
|
const writes = audit.filter((e) => e.decision === 'allow' && (e.action?.type === 'write' || e.action?.type === 'edit'));
|
|
1952
2034
|
|
|
1953
2035
|
out(`\noutcome ${outcome}`);
|
|
@@ -2150,9 +2232,8 @@ async function execute(ctx) {
|
|
|
2150
2232
|
// FOLDED across a resume, like every other money and time number this readout
|
|
2151
2233
|
// prints: the row records what the RUN cost, never what this leg cost
|
|
2152
2234
|
wallMs: legMs + (dead ? dead.restart.priorWallMs : 0),
|
|
2153
|
-
// the ROUND count
|
|
2154
|
-
//
|
|
2155
|
-
// this is a FLOOR, and it says so the way the money above does (F6: a floor that
|
|
2235
|
+
// the ROUND count is not part of the declared fold — a resumed leg's `job-start` declares
|
|
2236
|
+
// money and wall but never rounds — so on a resume this is THIS LEG's count, a FLOOR, and it says so the way the money above does (F6: a floor that
|
|
2156
2237
|
// reads as exact is dishonest; report the floor WITH its completeness)
|
|
2157
2238
|
rounds: legRounds,
|
|
2158
2239
|
roundsComplete: !dead,
|
|
@@ -2292,7 +2373,7 @@ async function execute(ctx) {
|
|
|
2292
2373
|
// owns no spine, so its row is released (folded out of the list, like a monthly refusal) rather than
|
|
2293
2374
|
// left as a ghost that keeps the month reading "at least". Once the spine exists the run is a real,
|
|
2294
2375
|
// listed run and ends by its own job-end. A failed release write never masks the original exit.
|
|
2295
|
-
if (claimedRow && !existsSync(claimedRow.spine)) releaseClaimedRow();
|
|
2376
|
+
if (claimedRow && (claimedRow.leg > 1 ? !legBegun : !existsSync(claimedRow.spine))) releaseClaimedRow();
|
|
2296
2377
|
if (e instanceof ExitSignal) return e.code;
|
|
2297
2378
|
throw e;
|
|
2298
2379
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} DraftSpend
|
|
3
|
+
* @property {string} sessionId
|
|
4
|
+
* @property {number} spentUsd the known floor of the session's drafting spend
|
|
5
|
+
* @property {boolean} spendComplete false = `spentUsd` is a floor ("at least")
|
|
6
|
+
* @property {string|null} provider the worker's provider shape (`anthropic-api` | `openai-api` | ...)
|
|
7
|
+
* @property {string|null} baseUrl
|
|
8
|
+
* @property {string|null} model
|
|
9
|
+
* @property {string} startedAt ISO, the first metered call
|
|
10
|
+
* @property {string} updatedAt ISO, the latest metered call
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* (Re)write `<dir>/draft-spend.json` atomically (tmp + rename).
|
|
14
|
+
* @param {string} dir the session's folder
|
|
15
|
+
* @param {DraftSpend} rec
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function writeDraftSpend(dir: string, rec: DraftSpend): void;
|
|
19
|
+
/**
|
|
20
|
+
* Every session folder's drafting spend that never became a run.
|
|
21
|
+
* @param {{ home?: string, sessionsRoot?: string }} [opts] `home` = the bareloop config dir (as everywhere in src/monthly.js); the sessions live in `<home>/panel-sessions` unless `sessionsRoot` names them
|
|
22
|
+
* @returns {DraftSpend[]}
|
|
23
|
+
*/
|
|
24
|
+
export function readOrphanDraftSpends(opts?: {
|
|
25
|
+
home?: string;
|
|
26
|
+
sessionsRoot?: string;
|
|
27
|
+
}): DraftSpend[];
|
|
28
|
+
export const DRAFT_SPEND_FILE: "draft-spend.json";
|
|
29
|
+
export type DraftSpend = {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
/**
|
|
32
|
+
* the known floor of the session's drafting spend
|
|
33
|
+
*/
|
|
34
|
+
spentUsd: number;
|
|
35
|
+
/**
|
|
36
|
+
* false = `spentUsd` is a floor ("at least")
|
|
37
|
+
*/
|
|
38
|
+
spendComplete: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* the worker's provider shape (`anthropic-api` | `openai-api` | ...)
|
|
41
|
+
*/
|
|
42
|
+
provider: string | null;
|
|
43
|
+
baseUrl: string | null;
|
|
44
|
+
model: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* ISO, the first metered call
|
|
47
|
+
*/
|
|
48
|
+
startedAt: string;
|
|
49
|
+
/**
|
|
50
|
+
* ISO, the latest metered call
|
|
51
|
+
*/
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
};
|
package/types/job.d.ts
CHANGED
|
@@ -93,6 +93,17 @@ export function checkMenu(close: any[]): {
|
|
|
93
93
|
* @returns {string} sha256 hex
|
|
94
94
|
*/
|
|
95
95
|
export function jobSpecHash(job: object): string;
|
|
96
|
+
/**
|
|
97
|
+
* The identity of a WORKFLOW (Reuse workflow, hamr 2026-10-03): sha256 over the signed spec WITHOUT the fields a
|
|
98
|
+
* reuse may change ({@link REUSE_OPEN_SPEC_FIELDS}) and the worker identity ({@link WORKFLOW_KEY_IDENTITY_FIELDS}). Two runs
|
|
99
|
+
* share a workflow key iff they ran the same goal, checks, guardrails and close — whatever source they ran on, wherever they wrote, whatever caps they had.
|
|
100
|
+
* It is NOT a signature: {@link jobSpecHash} (which covers the caps and the fence) stays what the person signs, and
|
|
101
|
+
* a reuse lands on a NEW jobSpecHash with the SAME workflowKey. Same never-throws contract as `jobSpecHash`, and
|
|
102
|
+
* the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key.
|
|
103
|
+
* @param {object} job
|
|
104
|
+
* @returns {string} sha256 hex
|
|
105
|
+
*/
|
|
106
|
+
export function workflowKey(job: object): string;
|
|
96
107
|
/**
|
|
97
108
|
* Does an approval record cover this exact spec version? Pure predicate — the
|
|
98
109
|
* N2 runner refuses to run without it; nothing here writes or prompts. The
|
|
@@ -259,6 +270,20 @@ export const PROVIDERS: readonly string[];
|
|
|
259
270
|
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
260
271
|
* not duplicated here). */
|
|
261
272
|
export const CONDITION_KEYS: readonly string[];
|
|
273
|
+
/**
|
|
274
|
+
* The fields a "Reuse workflow" run may change: where the work lands (the write fence, `writeScope`, and a
|
|
275
|
+
* `destination`/`source` where a spec ever carries them) and the two ceilings. Everything else in a signed spec is
|
|
276
|
+
* the WORKFLOW — goal, checks, guardrails, close (the worker model is the third kind, {@link WORKFLOW_KEY_IDENTITY_FIELDS}). ONE list, shared by {@link workflowKey} and by the panel's
|
|
277
|
+
* reuse (so what the key ignores is exactly what the person may edit, never two spellings).
|
|
278
|
+
*/
|
|
279
|
+
export const REUSE_OPEN_SPEC_FIELDS: readonly string[];
|
|
280
|
+
/**
|
|
281
|
+
* The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
|
|
282
|
+
* chosen in Settings carries its provider and baseUrl with it). They stay inside {@link jobSpecHash}, so what the person
|
|
283
|
+
* signs still covers the model the run will use. Kept apart from {@link REUSE_OPEN_SPEC_FIELDS} on purpose: those are the
|
|
284
|
+
* fields the page's boxes edit directly; these three change together, as one Settings row.
|
|
285
|
+
*/
|
|
286
|
+
export const WORKFLOW_KEY_IDENTITY_FIELDS: readonly string[];
|
|
262
287
|
/**
|
|
263
288
|
* — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
|
|
264
289
|
* `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
|
package/types/ledger.d.ts
CHANGED
|
@@ -95,6 +95,50 @@ export function floorsFromRecords(records: any[]): {
|
|
|
95
95
|
spendFloorUsd: number | null;
|
|
96
96
|
wallFloorMs: number | null;
|
|
97
97
|
};
|
|
98
|
+
/**
|
|
99
|
+
* One run's own spend, off its spine records: this LEG's figure (never the chain fold),
|
|
100
|
+
* plus the drafting spend the run was signed with (counted once — on the first leg only).
|
|
101
|
+
* @param {any[]} records ONE leg's records
|
|
102
|
+
* @param {{ withDraft?: boolean }} [o] `withDraft: false` leaves the drafting spend out (a caller folding the CHAIN's own spend, which never held it)
|
|
103
|
+
* @returns {{ usd: number, complete: boolean }}
|
|
104
|
+
*/
|
|
105
|
+
export function legSpend(records: any[], { withDraft }?: {
|
|
106
|
+
withDraft?: boolean;
|
|
107
|
+
}): {
|
|
108
|
+
usd: number;
|
|
109
|
+
complete: boolean;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* A WHOLE RUN's own spend (P5-R: a resumed run is one spine of several legs): the sum of each leg's own
|
|
113
|
+
* figure ({@link legSpend}), never the declared chain fold. ONE basis — every leg's own rounds — so a leg is
|
|
114
|
+
* counted once: the last leg's `job-end.spentUsd` also carries the earlier legs, and adding it to the
|
|
115
|
+
* earlier legs' own figures would count them twice. A leg with no terminal (it died) is a floor, so the run
|
|
116
|
+
* is `complete: false`. A spine with no marker is one leg and reads exactly as {@link legSpend} always did.
|
|
117
|
+
* @param {any[]} records one spine's records
|
|
118
|
+
* @param {{ withDraft?: boolean }} [o]
|
|
119
|
+
* @returns {{ usd: number, complete: boolean, lastLegUsd: number }} `lastLegUsd`: the latest leg's own share (what a claim still held for it is measured against)
|
|
120
|
+
*/
|
|
121
|
+
export function runSpend(records: any[], o?: {
|
|
122
|
+
withDraft?: boolean;
|
|
123
|
+
}): {
|
|
124
|
+
usd: number;
|
|
125
|
+
complete: boolean;
|
|
126
|
+
lastLegUsd: number;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* What a FINISHED run's whole chain spent, for a caller that folds it forward (the review door's rerun opens a
|
|
130
|
+
* new run under the same signed ceiling). One basis per shape: a spine of ONE leg is read by its terminal's own
|
|
131
|
+
* chain total (`job-end.spentUsd`, which already carries whatever fold that leg inherited — what every door has
|
|
132
|
+
* always read); a resumed run's several legs are the first leg's own inherited fold (spend the file does not
|
|
133
|
+
* hold) plus each leg's OWN spend, so a later leg's terminal total — which also carries the earlier legs — is
|
|
134
|
+
* never added on top of them. Not finite / no terminal = `usd: 0, complete: false` (never an exact-looking 0).
|
|
135
|
+
* @param {any[]} records one spine's records
|
|
136
|
+
* @returns {{ usd: number, complete: boolean }}
|
|
137
|
+
*/
|
|
138
|
+
export function chainSpend(records: any[]): {
|
|
139
|
+
usd: number;
|
|
140
|
+
complete: boolean;
|
|
141
|
+
};
|
|
98
142
|
/** severity order, worst-first (the fold's display order; frequency ranks within) */
|
|
99
143
|
export const LEDGER_CLASSES: readonly string[];
|
|
100
144
|
/**
|
package/types/legs.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split one spine's parsed records into its legs. Pure.
|
|
3
|
+
* @param {any[]} events one spine's parsed records, in file order
|
|
4
|
+
* @returns {Leg[]} always at least one leg for a non-empty list; `[]` for an empty one
|
|
5
|
+
*/
|
|
6
|
+
export function legsOf(events: any[]): Leg[];
|
|
7
|
+
/**
|
|
8
|
+
* The legs' WORKING time, gap excluded (hamr 2026-10-02, 2A: only working time charges the wall). Each leg
|
|
9
|
+
* counts first record → last record; the quiet time between one leg's end and the next leg's marker is
|
|
10
|
+
* nobody's. `null` = unknown (no leg carries two parseable stamps) — never 0. A leg whose stamps are
|
|
11
|
+
* unreadable adds nothing, so the figure is a FLOOR, `complete: false`.
|
|
12
|
+
* @param {Leg[]} legs
|
|
13
|
+
* @returns {{ ms: number|null, complete: boolean }}
|
|
14
|
+
*/
|
|
15
|
+
export function legsWallMs(legs: Leg[]): {
|
|
16
|
+
ms: number | null;
|
|
17
|
+
complete: boolean;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Parse a spine's TEXT, tolerant exactly where the writer leaves damage. A kill mid-append leaves a torn
|
|
21
|
+
* line; a resume never edits it (the spine is append-only forever) — it writes one `\n` and starts a fresh
|
|
22
|
+
* line, so the torn bytes end up alone on their own line, immediately BEFORE a `leg-resume` marker. That
|
|
23
|
+
* line, and the file's own last two lines (the older tail rule: a kill mid-append of the file's final
|
|
24
|
+
* record), are tolerated and named. An unparseable line anywhere else is `corrupt`: a reconstruction from
|
|
25
|
+
* it would invent a history.
|
|
26
|
+
* @param {string} raw
|
|
27
|
+
* @returns {{ records: any[], tolerated: {line: number, why: 'tail'|'before-leg-resume'}[], corrupt: {line: number, message: string}|null }}
|
|
28
|
+
* `line` is 1-based. Only the FIRST corrupt line is reported.
|
|
29
|
+
*/
|
|
30
|
+
export function parseSpineText(raw: string): {
|
|
31
|
+
records: any[];
|
|
32
|
+
tolerated: {
|
|
33
|
+
line: number;
|
|
34
|
+
why: "tail" | "before-leg-resume";
|
|
35
|
+
}[];
|
|
36
|
+
corrupt: {
|
|
37
|
+
line: number;
|
|
38
|
+
message: string;
|
|
39
|
+
} | null;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Where leg `leg`'s watchdog kill note lives. Leg 1 keeps the name every run has always had
|
|
43
|
+
* (`<spine>.watchdog.json`); a resumed leg's note sits beside it (`<spine>.leg<N>.watchdog.json`) so one
|
|
44
|
+
* leg never overwrites another's evidence of how it died.
|
|
45
|
+
* @param {string} spineFile
|
|
46
|
+
* @param {number} leg 1-based
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function watchdogNotePath(spineFile: string, leg: number): string;
|
|
50
|
+
/**
|
|
51
|
+
* P5 item 5 (Stop) — where a run's STOP REQUEST lives: a file beside its spine. The panel's stop route writes it;
|
|
52
|
+
* the engine reads it at the between-steps seam (src/planrun.js) and consumes it. One spelling, here, so the route
|
|
53
|
+
* and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
|
|
54
|
+
* when the leg ends, and again at the start of the next leg (src/userrun.js).
|
|
55
|
+
* @param {string} spineFile
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function stopFilePath(spineFile: string): string;
|
|
59
|
+
/** The marker record's `type`. */
|
|
60
|
+
export const LEG_RESUME: "leg-resume";
|
|
61
|
+
export type Leg = {
|
|
62
|
+
/**
|
|
63
|
+
* 1-based position in file order (the marker's own `leg` field is never trusted over the order)
|
|
64
|
+
*/
|
|
65
|
+
leg: number;
|
|
66
|
+
/**
|
|
67
|
+
* the leg's first record (the `leg-resume` marker for leg 2+; the file's first record for leg 1)
|
|
68
|
+
*/
|
|
69
|
+
start: any | null;
|
|
70
|
+
/**
|
|
71
|
+
* the leg's last record
|
|
72
|
+
*/
|
|
73
|
+
end: any | null;
|
|
74
|
+
/**
|
|
75
|
+
* every record of the leg, in file order, marker included
|
|
76
|
+
*/
|
|
77
|
+
records: any[];
|
|
78
|
+
/**
|
|
79
|
+
* the leg's own `job-start` (null: it ended before runJob wrote one)
|
|
80
|
+
*/
|
|
81
|
+
jobStart: any | null;
|
|
82
|
+
/**
|
|
83
|
+
* the leg's own LAST `job-end` (null: it died, or it is still running)
|
|
84
|
+
*/
|
|
85
|
+
jobEnd: any | null;
|
|
86
|
+
/**
|
|
87
|
+
* the leg's terminal: its `job-end` outcome, else a `run-end` outcome (a $0 refusal), else null
|
|
88
|
+
*/
|
|
89
|
+
outcome: string | null;
|
|
90
|
+
/**
|
|
91
|
+
* leg 1: null. Leg 2+: how the PREVIOUS leg ended, as the marker declares it
|
|
92
|
+
* (else derived from that leg's own terminal), `'died'` when it recorded none
|
|
93
|
+
*/
|
|
94
|
+
after: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* `Date.parse` of the start record's `ts`
|
|
97
|
+
*/
|
|
98
|
+
startMs: number | null;
|
|
99
|
+
/**
|
|
100
|
+
* `Date.parse` of the end record's `ts`
|
|
101
|
+
*/
|
|
102
|
+
endMs: number | null;
|
|
103
|
+
};
|