bareloop 0.34.0 → 0.36.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 +58 -0
- package/bareloop.context.md +276 -20
- package/package.json +1 -1
- package/src/authorrun.js +3 -2
- package/src/bundlerun.js +17 -12
- package/src/draftspend.js +197 -0
- package/src/exits.js +10 -1
- package/src/index.js +3 -3
- 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/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorroutes.js +84 -14
- package/src/panel/authorsession.js +490 -125
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1194 -213
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +964 -79
- package/src/panel/settingsroutes.js +52 -2
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- package/src/ralph.js +4 -1
- 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/source.js +43 -4
- package/src/text.js +20 -0
- package/src/u-patient.js +3 -2
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +192 -65
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +141 -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/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorroutes.d.ts +23 -1
- package/types/panel/authorsession.d.ts +110 -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 +266 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +12 -0
- package/types/ralph.d.ts +19 -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/source.d.ts +13 -5
- package/types/text.d.ts +19 -0
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
package/src/text.js
CHANGED
|
@@ -308,3 +308,23 @@ export function stampRaw(raw, { cause = null, reason = null }) {
|
|
|
308
308
|
raw.reason = reason === null || reason === undefined ? null : redactSecrets(reason);
|
|
309
309
|
return raw;
|
|
310
310
|
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* BA-21 — the WRITE side of the pricing-provenance signal (read side: `rateProvenance` /
|
|
314
|
+
* `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
|
|
315
|
+
* `pricing` on every metering payload; both metering callbacks below forward it onto the
|
|
316
|
+
* spine through THIS one helper, because two sites spelling one rule are two instruments
|
|
317
|
+
* (the ripgrep fix that landed in ci.yml but not publish.yml).
|
|
318
|
+
*
|
|
319
|
+
* Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
|
|
320
|
+
* the one place that decides what counts as a guess. And an ABSENT provenance stays
|
|
321
|
+
* absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
|
|
322
|
+
* in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
|
|
323
|
+
* invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
|
|
324
|
+
* priced", which is a different fact from "nobody told us".
|
|
325
|
+
* @param {any} arg an `onLlmResult` / `onTurn` payload
|
|
326
|
+
* @returns {{rateSource?: string|null}} the field to spread into the spine record
|
|
327
|
+
*/
|
|
328
|
+
export function rateSourceFields(arg) {
|
|
329
|
+
return arg && typeof arg === 'object' && 'rateSource' in arg ? { rateSource: arg.rateSource } : {};
|
|
330
|
+
}
|
package/src/u-patient.js
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// mechanical preparation of a tree, and both callers print what it did.
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import {
|
|
19
|
-
rmSync, existsSync,
|
|
19
|
+
rmSync, existsSync,
|
|
20
20
|
} from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
+
import { moveFile } from './movefile.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Reset a patient to its frozen seed and drop its `.litectx` store.
|
|
@@ -68,6 +69,6 @@ export function moveStaleGateAudit(wd, spineDir, runid) {
|
|
|
68
69
|
const staleAudit = join(wd, 'gate-audit.jsonl');
|
|
69
70
|
if (!existsSync(staleAudit)) return null;
|
|
70
71
|
const preFile = join(spineDir, `pre-${runid}-gate-audit.jsonl`);
|
|
71
|
-
|
|
72
|
+
moveFile(staleAudit, preFile);
|
|
72
73
|
return preFile;
|
|
73
74
|
}
|
package/src/u-readout.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// leads; the leg is what this process actually bought, and dropping it would trade
|
|
17
17
|
// one blindness for the other.
|
|
18
18
|
|
|
19
|
+
import { legsOf } from './legs.js';
|
|
20
|
+
|
|
19
21
|
/** minutes, at the precision the readout has ever claimed (~90%, hamr's ruling) */
|
|
20
22
|
const min = (/** @type {number} */ ms) => (ms / 60_000).toFixed(1);
|
|
21
23
|
|
|
@@ -336,10 +338,16 @@ export function doorLines({ rerun, accept, pause }) {
|
|
|
336
338
|
*/
|
|
337
339
|
export function deathAtOf({ watchdogAt, events } = {}) {
|
|
338
340
|
const list = Array.isArray(events) ? events : [];
|
|
341
|
+
// P5-R: a resumed run is one spine of several legs. "Did this run end itself" is a question about its
|
|
342
|
+
// LATEST leg: an earlier leg's job-end says nothing about how the leg now being read stopped, and a
|
|
343
|
+
// watchdog note dated before this leg began is a different leg's note.
|
|
344
|
+
const last = legsOf(list).at(-1) ?? null;
|
|
345
|
+
const scope = last ? last.records : list;
|
|
339
346
|
// a landed terminal is the run dating its own stop — nothing outside it is better
|
|
340
347
|
// evidence about a run that was never killed
|
|
341
|
-
if (
|
|
348
|
+
if (scope.some((e) => e !== null && typeof e === 'object' && /** @type {any} */ (e).type === 'job-end')) return null;
|
|
342
349
|
const at = typeof watchdogAt === 'string' ? Date.parse(watchdogAt) : NaN;
|
|
350
|
+
if (last && last.leg > 1 && last.startMs !== null && Number.isFinite(at) && at < last.startMs) return null;
|
|
343
351
|
// an unreadable stamp is UNKNOWN, never a NaN handed to a fold (F6): the caller's
|
|
344
352
|
// own default is the honest fallback
|
|
345
353
|
return Number.isFinite(at) ? at : null;
|
package/src/u-watchdog.mjs
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
//
|
|
44
44
|
// Usage:
|
|
45
45
|
// node src/u-watchdog.mjs --spine <path> --pid <n>
|
|
46
|
-
// [--stale-ms N] [--wall-ms N] [--grace-ms N] [--dead-ms N] [--poll-ms N] [--term-grace-ms N]
|
|
46
|
+
// [--stale-ms N] [--wall-ms N] [--grace-ms N] [--dead-ms N] [--poll-ms N] [--term-grace-ms N] [--report <path>]
|
|
47
47
|
import { statSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
48
48
|
|
|
49
49
|
const arg = (/** @type {string} */ n, /** @type {string|null} */ dflt = null) => {
|
|
@@ -215,7 +215,9 @@ function stop(/** @type {string} */ reason, /** @type {string} */ judgement, /**
|
|
|
215
215
|
// not a guard (F70: the instrument carrying the failure mode it exists to catch). The
|
|
216
216
|
// kill below runs whether or not this succeeded; a failure is named on stderr so a
|
|
217
217
|
// missing marker is never read as "the guard never fired".
|
|
218
|
-
|
|
218
|
+
// a RESUMED leg writes its own note beside the run's one spine (P5-R: `<spine>.leg<N>.watchdog.json`), so a
|
|
219
|
+
// later leg never overwrites the earlier leg's evidence; the default is the file every run has always had
|
|
220
|
+
const reportPath = arg('report') ?? `${spine}.watchdog.json`;
|
|
219
221
|
const tmpPath = `${reportPath}.tmp`;
|
|
220
222
|
try {
|
|
221
223
|
writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`);
|
package/src/userrun.js
CHANGED
|
@@ -24,11 +24,13 @@
|
|
|
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,
|
|
27
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, 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
|
+
import { commitWork, removeWorktree } from './worktree.js';
|
|
33
|
+
import { moveFile } from './movefile.js';
|
|
32
34
|
import { runJob } from './run.js';
|
|
33
35
|
import { moneyWithDraft } from './replay.js';
|
|
34
36
|
import { jobSpecHash, resolveWorkerModel, validateJob } from './job.js';
|
|
@@ -72,10 +74,13 @@ import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
|
|
|
72
74
|
// the cold reset, shared with the battery drivers so "cold" has one spelling
|
|
73
75
|
import { coldReset, moveStaleGateAudit } from './u-patient.js';
|
|
74
76
|
// PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
|
|
75
|
-
import { appendRun, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
|
|
77
|
+
import { appendRun, appendLegStart, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
|
|
78
|
+
// P5-R — a resumed run is the SAME run: one id, one spine file, a `leg-resume` marker inside. `legsOf` is the
|
|
79
|
+
// one owner of where a leg starts and ends; every reader below asks it.
|
|
80
|
+
import { legsOf, parseSpineText, LEG_RESUME, watchdogNotePath, stopFilePath } from './legs.js';
|
|
76
81
|
import { keysForDoor } from './keysfile.js';
|
|
77
82
|
import { parseJsonl } from './replayio.js';
|
|
78
|
-
import { claimRun, monthlyRefusalText, legSpend } from './monthly.js';
|
|
83
|
+
import { claimRun, monthlyRefusalText, legSpend, chainSpend } from './monthly.js';
|
|
79
84
|
import { ConfigError } from './config.js';
|
|
80
85
|
import { commandFor } from './invoke.js';
|
|
81
86
|
import { applyConfiguredKey, judgeRatesFor, keyNameFor, priceReadout, ratesFor, rowsForHome } from './providerrows.js';
|
|
@@ -265,6 +270,9 @@ class ExitSignal extends Error {
|
|
|
265
270
|
* @property {string} [workdir] the patient's tree
|
|
266
271
|
* @property {string} [seed] the patient's frozen seed commit
|
|
267
272
|
* @property {string} [spineName] the spine directory name, beside `workdir`
|
|
273
|
+
* @property {string} [into] the source door's own directory (`source.json`, and the spine directory beside it).
|
|
274
|
+
* Default `dirname(workdir)` — the copied-tree layout, where the tree is `<into>/tree`. A repo job on a WORKTREE
|
|
275
|
+
* (P6 item 1) has its tree inside the person's repo, so its `into` is passed explicitly.
|
|
268
276
|
* @property {string|null} [model] the raw `--model` tier name (`sonnet`/`haiku`), or null for the default
|
|
269
277
|
* @property {string|null} [readShim] the raw `--read-shim` arm name, or null for the default
|
|
270
278
|
* @property {string|null} [scout] the raw `--scout` value (`on`/`off`), or null for the default
|
|
@@ -296,6 +304,7 @@ class ExitSignal extends Error {
|
|
|
296
304
|
* @property {string} workdir
|
|
297
305
|
* @property {string} seed
|
|
298
306
|
* @property {string} spineName
|
|
307
|
+
* @property {string} [into] see `RunOpts.into`
|
|
299
308
|
* @property {string|null} model
|
|
300
309
|
* @property {string|null} readShim
|
|
301
310
|
* @property {string|null} scout
|
|
@@ -360,14 +369,17 @@ async function execute(ctx) {
|
|
|
360
369
|
// owner of "a claimed run that never began" — every exit between the claim and the spine's first
|
|
361
370
|
// record (a refused resume patient, a throwing prepareTree, a git/reset failure...) passes there.
|
|
362
371
|
// 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} */
|
|
372
|
+
/** @type {{ runid: string, spine: string, home: string|undefined, leg: number }|null} */
|
|
364
373
|
let claimedRow = null;
|
|
374
|
+
// P5-R: has this leg written its first record (the `leg-resume` marker on a resume)? A resumed leg's spine
|
|
375
|
+
// file already exists, so "the file exists" no longer says the leg began.
|
|
376
|
+
let legBegun = false;
|
|
365
377
|
// Release the claimed row (a `released` entry folds it out of the list, like a monthly refusal): the run
|
|
366
378
|
// spent nothing and must not stay a ghost that keeps the month reading "at least". A failed release write
|
|
367
379
|
// never masks the original exit.
|
|
368
380
|
const releaseClaimedRow = () => {
|
|
369
381
|
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 */ }
|
|
382
|
+
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
383
|
};
|
|
372
384
|
try {
|
|
373
385
|
const spec = ctx.spec;
|
|
@@ -654,7 +666,10 @@ async function execute(ctx) {
|
|
|
654
666
|
* both the RESUME reader below and the live run further down need them. Two spellings of
|
|
655
667
|
* one path is how `--resume` comes to read a different directory than the run writes. */
|
|
656
668
|
const wd = resolve(WORKDIR);
|
|
657
|
-
|
|
669
|
+
/** the source door's own directory: where `source.json` sits and the spine directory goes. The copied-tree layout
|
|
670
|
+
* has the tree at `<into>/tree`, so `dirname(wd)`; a worktree run passes it (its tree is inside the person's repo). */
|
|
671
|
+
const into = ctx.into !== undefined ? resolve(ctx.into) : dirname(wd);
|
|
672
|
+
const spineDir = ctx.bundle ? ctx.bundle.runDir : join(into, target.spine);
|
|
658
673
|
/** `--resume` takes the runid (the conventional `<spine dir>/u-<runid>.jsonl`) or an
|
|
659
674
|
* explicit path — the path form is what makes these gates testable without touching
|
|
660
675
|
* an operator's real spine directory. */
|
|
@@ -662,6 +677,13 @@ async function execute(ctx) {
|
|
|
662
677
|
: (RESUME.includes('/') || RESUME.endsWith('.jsonl') ? resolve(RESUME) : join(spineDir, `u-${RESUME}.jsonl`));
|
|
663
678
|
/** @type {any} */
|
|
664
679
|
let dead = null;
|
|
680
|
+
/** 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[]} */
|
|
681
|
+
let deadLegs = [];
|
|
682
|
+
/** every record of the halted run's one file (all its legs) @type {any[]} */
|
|
683
|
+
let deadAllEvents = [];
|
|
684
|
+
let deadFromLegNo = 1;
|
|
685
|
+
/** @type {string} */
|
|
686
|
+
let deadAfter = 'died';
|
|
665
687
|
/** F102 — the decision this checkpoint HOLDS: an answer the person already gave, on
|
|
666
688
|
* a leg that stopped before a single round was bought for it. Null on every ordinary
|
|
667
689
|
* resume. It is applied directly and the ask is never re-rendered — the whole of the
|
|
@@ -676,23 +698,34 @@ async function execute(ctx) {
|
|
|
676
698
|
if (!existsSync(deadSpineFile)) die(`--resume: no spine at ${deadSpineFile} — a resume continues a run that happened, and this one left no log`);
|
|
677
699
|
let raw = '';
|
|
678
700
|
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
|
-
|
|
701
|
+
// ONE parser for the spine's text (src/legs.js `parseSpineText`): a kill can land mid-append, so a broken
|
|
702
|
+
// LAST line is the failure being modelled; and a resumed run's own file carries exactly one torn line
|
|
703
|
+
// directly before each `leg-resume` marker (the resume never edits the torn bytes, it starts a fresh
|
|
704
|
+
// line). Both are tolerated and NAMED. A broken line anywhere else is a corrupt log, and reconstructing
|
|
705
|
+
// from one would invent a history.
|
|
706
|
+
const parsedDead = parseSpineText(raw);
|
|
707
|
+
for (const t of parsedDead.tolerated) {
|
|
708
|
+
err(t.why === 'tail'
|
|
709
|
+
? `--resume: ignoring a truncated final line in ${deadSpineFile} (a kill mid-append — the expected shape)`
|
|
710
|
+
: `--resume: ignoring a truncated line ${t.line} in ${deadSpineFile} (a kill mid-append, left isolated by the next leg's marker)`);
|
|
711
|
+
}
|
|
712
|
+
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`);
|
|
713
|
+
/** every record of the run's one file (all legs) */
|
|
714
|
+
deadAllEvents = parsedDead.records;
|
|
715
|
+
const allDeadEvents = deadAllEvents;
|
|
716
|
+
/** the legs already on the file, and the leg THIS resume will be (P5-R) */
|
|
717
|
+
deadLegs = legsOf(allDeadEvents);
|
|
718
|
+
// the leg a resume continues FROM is the latest leg that got as far as a `job-start`: a leg that died or
|
|
719
|
+
// was refused before runJob wrote one (a $0 stop) left no checkpoint, and the one behind it still stands
|
|
720
|
+
const fromLeg = deadLegs.findLast((l) => l.jobStart !== null) ?? deadLegs.at(-1);
|
|
721
|
+
/** the records `readResume` and every gate below read: that leg's own window, which carries its own declared fold */
|
|
722
|
+
const deadEvents = fromLeg ? fromLeg.records : allDeadEvents;
|
|
723
|
+
deadFromLegNo = fromLeg ? fromLeg.leg : 1;
|
|
692
724
|
// is the dead run actually dead? Two processes on one patient is unrecoverable; a
|
|
693
725
|
// false refusal costs one sentence. run-u's own pid is not on its spine, so the
|
|
694
726
|
// watchdog's report or the run-list row is the pid there is — and its absence is not evidence of life.
|
|
695
|
-
|
|
727
|
+
// the watchdog's note for the leg being continued from (a resumed leg has its own note file)
|
|
728
|
+
const wdFile = watchdogNotePath(deadSpineFile, deadFromLegNo);
|
|
696
729
|
/** @type {any} */
|
|
697
730
|
let watchdog = null;
|
|
698
731
|
if (existsSync(wdFile)) { try { watchdog = JSON.parse(readFileSync(wdFile, 'utf8')); } catch { /* an unreadable report is not evidence of life */ } }
|
|
@@ -704,6 +737,7 @@ async function execute(ctx) {
|
|
|
704
737
|
/** @type {number|undefined} */
|
|
705
738
|
let rowPid;
|
|
706
739
|
if (cfgOn) { try { rowPid = readRunList({ home: cfgHome }).rows.findLast((r) => r.spine === deadSpineFile)?.pid; } catch { /* an unreadable list is not evidence of life */ } }
|
|
740
|
+
// 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
741
|
const alivePid = [watchdog?.pid, rowPid].find((p) => Number.isInteger(p) && isLiveRunner(p));
|
|
708
742
|
if (alivePid !== undefined) {
|
|
709
743
|
die(`--resume: pid ${alivePid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
|
|
@@ -715,6 +749,8 @@ async function execute(ctx) {
|
|
|
715
749
|
// closes was measured, not imagined (a report dated after a pause bills the human's
|
|
716
750
|
// deciding time to the run's wall and can zero the remainder outright).
|
|
717
751
|
const deathAt = deathAtOf({ watchdogAt: watchdog?.at, events: deadEvents });
|
|
752
|
+
// how the leg being continued from ended, for the marker the new leg opens with (`'died'`: no terminal recorded)
|
|
753
|
+
deadAfter = fromLeg?.outcome ?? 'died';
|
|
718
754
|
dead = readResume(deadEvents, {
|
|
719
755
|
direct: true,
|
|
720
756
|
resumableOutcomes: RESUMABLE_HALTS,
|
|
@@ -792,13 +828,10 @@ async function execute(ctx) {
|
|
|
792
828
|
if (!existsSync(doorSpineFile)) die(`--door: no spine at ${doorSpineFile} — a door belongs to a run that happened, and this one left no log`);
|
|
793
829
|
let raw = '';
|
|
794
830
|
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
|
-
});
|
|
831
|
+
// the same parser as the resume reader: a resumed run's file carries one torn line before each marker
|
|
832
|
+
const parsedDoor = parseSpineText(raw);
|
|
833
|
+
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`);
|
|
834
|
+
doorEvents = parsedDoor.records;
|
|
802
835
|
const start = doorEvents.find((/** @type {any} */ e) => e?.type === 'job-start');
|
|
803
836
|
if (!start) die(`--door: ${doorSpineFile} carries no job-start — that is not a bareloop run's spine`);
|
|
804
837
|
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 +853,11 @@ async function execute(ctx) {
|
|
|
820
853
|
err(` - or revise the goal/spec in ${SPEC_DESC} — a spec edit, so the hash changes and you sign the new one.`);
|
|
821
854
|
throw new ExitSignal(2);
|
|
822
855
|
}
|
|
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
|
-
};
|
|
856
|
+
// the chain's spend, on ONE basis per shape (`chainSpend`, src/monthly.js): a resumed run's several legs are
|
|
857
|
+
// summed by their own spend, never a later terminal's chain total on top of the earlier legs. A FLOOR stays a
|
|
858
|
+
// floor across the door (F6): an absent figure is never "complete at $0".
|
|
859
|
+
const chain = chainSpend(doorEvents);
|
|
860
|
+
doorPrior = { spentUsd: chain.usd, spendComplete: chain.complete };
|
|
831
861
|
}
|
|
832
862
|
|
|
833
863
|
/** is this resume the one a PERSON has to answer? Read off the recorded terminal,
|
|
@@ -1303,10 +1333,10 @@ async function execute(ctx) {
|
|
|
1303
1333
|
// ── THE PAUSE DOOR (2026-08-17) — answered HERE, and it launches nothing.
|
|
1304
1334
|
//
|
|
1305
1335
|
// 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
|
|
1336
|
+
// keep a checkpoint is to leave it alone. This runner appends one LEG per resume to the run's one spine file,
|
|
1307
1337
|
// and a leg that returns before drafting emits no `plan-accepted` and no `step-end` —
|
|
1308
1338
|
// which is exactly what `readStepCheckpoint` reads. So launching a run to say "not
|
|
1309
|
-
// now" would
|
|
1339
|
+
// now" would add a LEG whose own checkpoint is empty, and an operator who
|
|
1310
1340
|
// later resumed that runid would re-draft and re-pay for every step the paused leg
|
|
1311
1341
|
// already finished. The checkpoint that matters is the one already on disk.
|
|
1312
1342
|
//
|
|
@@ -1488,14 +1518,23 @@ async function execute(ctx) {
|
|
|
1488
1518
|
// closed at its floor, with a note, once its process is gone). No limit set = no claim (the row is listed as
|
|
1489
1519
|
// ever). An unreadable config.json, or a claim row that cannot be written, refuses too — a broken instrument
|
|
1490
1520
|
// never silently reads "no limit".
|
|
1491
|
-
|
|
1492
|
-
|
|
1521
|
+
// P5-R (hamr 2026-10-02: "one run, one id, one file"): a RESUME is the same run — the runid and the spine file
|
|
1522
|
+
// are the halted run's own, and the new leg appends to it. Never a new id, never a second file.
|
|
1523
|
+
const resuming = dead !== null && deadSpineFile !== null;
|
|
1524
|
+
const runid = resuming
|
|
1525
|
+
? (ctx.bundle?.runid ?? basename(/** @type {string} */ (deadSpineFile)).replace(/\.jsonl$/, '').replace(/^u-/, ''))
|
|
1526
|
+
: (ctx.bundle?.runid ?? Date.now().toString(36));
|
|
1527
|
+
const spineFile = resuming ? /** @type {string} */ (deadSpineFile)
|
|
1528
|
+
: (ctx.bundle ? join(spineDir, 'spine.jsonl') : join(spineDir, `u-${runid}.jsonl`));
|
|
1529
|
+
/** which leg of the run this process is (1 = a fresh run) */
|
|
1530
|
+
const LEG = resuming ? deadLegs.length + 1 : 1;
|
|
1493
1531
|
const foldedUsd = dead ? dead.restart.priorSpentUsd : (doorPrior?.spentUsd ?? 0);
|
|
1494
1532
|
const legCapUsd = Math.max(0, spec.budgetUsd - (typeof foldedUsd === 'number' && Number.isFinite(foldedUsd) ? foldedUsd : 0));
|
|
1495
1533
|
// every row carries its runner's pid and the leg cap, limit set or not: a limit set LATER still holds it
|
|
1496
1534
|
/** @type {import('./runlist.js').RunRow} */
|
|
1497
1535
|
const runRow = {
|
|
1498
|
-
|
|
1536
|
+
// 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)
|
|
1537
|
+
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
1538
|
};
|
|
1500
1539
|
let claimed = false;
|
|
1501
1540
|
{
|
|
@@ -1503,9 +1542,9 @@ async function execute(ctx) {
|
|
|
1503
1542
|
let refusal = null;
|
|
1504
1543
|
try {
|
|
1505
1544
|
if (cfgOn) {
|
|
1506
|
-
const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome });
|
|
1545
|
+
const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome, leg: LEG });
|
|
1507
1546
|
claimed = got.claimed;
|
|
1508
|
-
if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome };
|
|
1547
|
+
if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome, leg: LEG };
|
|
1509
1548
|
refusal = monthlyRefusalText(got.room);
|
|
1510
1549
|
}
|
|
1511
1550
|
} catch (e) {
|
|
@@ -1531,11 +1570,10 @@ async function execute(ctx) {
|
|
|
1531
1570
|
|
|
1532
1571
|
// PANEL-BUILD.md P1 — one row in the run list, BEFORE any paid call (this
|
|
1533
1572
|
// 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,
|
|
1573
|
+
// preceded this point already gated on). `runid` is fresh on a cold start
|
|
1574
|
+
// and the HALTED run's own on a resume (P5-R: a resume is the same run), so on a resume
|
|
1575
|
+
// `appendRun`'s runid-dedup is what keeps it ONE row and the leg's claim is the
|
|
1576
|
+
// `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
1577
|
// already-signed run: caught and named loudly, never rethrown.
|
|
1540
1578
|
// `deps.runlistHome` — the same injectable test seam `ctx.deps` already
|
|
1541
1579
|
// carries everything else through (env/out/err/provider/…): a caller that
|
|
@@ -1544,10 +1582,13 @@ async function execute(ctx) {
|
|
|
1544
1582
|
// When the monthly claim above already appended this run's row, there is nothing to add.
|
|
1545
1583
|
try {
|
|
1546
1584
|
if (!claimed) {
|
|
1585
|
+
// a RESUMED run is already listed: its row stays ONE row and this leg's claim is a `leg-start` beside it
|
|
1586
|
+
// (the row is added only when the list has none)
|
|
1547
1587
|
appendRun(runRow, { home: deps.runlistHome });
|
|
1588
|
+
if (LEG > 1) appendLegStart({ type: 'leg-start', runid, leg: LEG, pid: process.pid, capUsd: legCapUsd, at: new Date().toISOString() }, { home: deps.runlistHome });
|
|
1548
1589
|
// no limit set: the row is appended here, not by a claim — mark it so the execute catch releases it
|
|
1549
1590
|
// 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 };
|
|
1591
|
+
claimedRow = { runid, spine: spineFile, home: deps.runlistHome, leg: LEG };
|
|
1551
1592
|
}
|
|
1552
1593
|
} catch (/** @type {any} */ e) {
|
|
1553
1594
|
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 +1743,30 @@ async function execute(ctx) {
|
|
|
1702
1743
|
// here (not inline at the `runJob` call below) so this pre-run reading lands
|
|
1703
1744
|
// on the run's own spine in the SAME seq order everything else does, rather
|
|
1704
1745
|
// than a second, disconnected spine writer.
|
|
1705
|
-
|
|
1746
|
+
// P5-R: a RESUME continues the halted run's own file. `seq` is monotonic per SPINE, so the emitter starts
|
|
1747
|
+
// from the highest `seq` already in it; a kill can have left a torn line with no trailing newline, so ONE
|
|
1748
|
+
// `\n` is written first and the torn bytes stay exactly as they are, alone on their own line (the spine is
|
|
1749
|
+
// append-only forever: nothing here edits or truncates an existing byte).
|
|
1750
|
+
let startSeq = 0;
|
|
1751
|
+
if (resuming) {
|
|
1752
|
+
for (const e of deadAllEvents) if (typeof e?.seq === 'number' && Number.isFinite(e.seq) && e.seq > startSeq) startSeq = e.seq;
|
|
1753
|
+
const fd = openSync(spineFile, 'r');
|
|
1754
|
+
try {
|
|
1755
|
+
const size = fstatSync(fd).size;
|
|
1756
|
+
if (size > 0) {
|
|
1757
|
+
const last = Buffer.alloc(1);
|
|
1758
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
1759
|
+
if (last[0] !== 0x0a) appendFileSync(spineFile, '\n');
|
|
1760
|
+
}
|
|
1761
|
+
} finally { closeSync(fd); }
|
|
1762
|
+
}
|
|
1763
|
+
const emit = makeSpine(spineFile, { startSeq });
|
|
1764
|
+
// …and the leg's FIRST record is its marker, written BEFORE the outside watchdog is spawned: the watchdog
|
|
1765
|
+
// judges a run dead by its spine file going quiet, and a file untouched since the last leg would read as stale.
|
|
1766
|
+
if (resuming) {
|
|
1767
|
+
emit(LEG_RESUME, { leg: LEG, after: deadAfter, at: new Date().toISOString() });
|
|
1768
|
+
legBegun = true;
|
|
1769
|
+
}
|
|
1706
1770
|
|
|
1707
1771
|
// PRD item 33/M2, call site 3(a) — a source-door manifest, when this patient
|
|
1708
1772
|
// was PREPARED through `prepareSource` (`scripts/prep-source.mjs`), sits
|
|
@@ -1712,7 +1776,7 @@ async function execute(ctx) {
|
|
|
1712
1776
|
// manifest — that absence IS "repo jobs untouched", never an invented
|
|
1713
1777
|
// destination for a job that declared none. A manifest that EXISTS but
|
|
1714
1778
|
// cannot be read or parsed is a named $0 stop, never a silent skip.
|
|
1715
|
-
const sourceManifest = await readSourceManifest(
|
|
1779
|
+
const sourceManifest = await readSourceManifest(into);
|
|
1716
1780
|
if (sourceManifest.stop !== null) {
|
|
1717
1781
|
emit('destination-refused', { code: sourceManifest.code, detail: sourceManifest.stop });
|
|
1718
1782
|
emit('run-end', { outcome: 'escalated' });
|
|
@@ -1731,7 +1795,7 @@ async function execute(ctx) {
|
|
|
1731
1795
|
// against `wd` alone let a destination at `<into>/profile.md` (inside the
|
|
1732
1796
|
// run's own scratch area, outside the frozen tree, but still bareloop's
|
|
1733
1797
|
// own scratch plumbing no destination should land in) slip through clean.
|
|
1734
|
-
const dp = await proveDestination(frontDoor.destination, { into
|
|
1798
|
+
const dp = await proveDestination(frontDoor.destination, { into });
|
|
1735
1799
|
if (dp.stop !== null) {
|
|
1736
1800
|
emit('destination-refused', { code: dp.code, detail: dp.stop });
|
|
1737
1801
|
emit('run-end', { outcome: 'escalated' });
|
|
@@ -1792,6 +1856,8 @@ async function execute(ctx) {
|
|
|
1792
1856
|
// time on exactly the run it is watching (F70's shape).
|
|
1793
1857
|
...(RESUME_WALL_MS === null ? [] : ['--wall-ms', String(RESUME_WALL_MS)]),
|
|
1794
1858
|
'--grace-ms', String(worstCloseSilenceMs),
|
|
1859
|
+
// this leg's own note file (leg 1 keeps the name every run has always had)
|
|
1860
|
+
'--report', watchdogNotePath(spineFile, LEG),
|
|
1795
1861
|
], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
1796
1862
|
// A spawn failure arrives as an EVENT, and an unhandled 'error' on a ChildProcess is
|
|
1797
1863
|
// an uncaught exception — i.e. a guard that could not start would END the paid run it
|
|
@@ -1828,9 +1894,16 @@ async function execute(ctx) {
|
|
|
1828
1894
|
lagDue = now + LAG_POLL_MS;
|
|
1829
1895
|
}, LAG_POLL_MS);
|
|
1830
1896
|
|
|
1897
|
+
// P5 item 5 — the STOP request file (`<spine>.stop`, src/legs.js). A request belongs to ONE leg: one left on
|
|
1898
|
+
// disk by an earlier leg (a click that raced that leg's end) is cleared HERE, before this leg's first step, so
|
|
1899
|
+
// a resume is never stopped at once by a request it did not receive. The engine also consumes the file when it
|
|
1900
|
+
// honours it, and the `finally` below clears whatever is left when the leg ends.
|
|
1901
|
+
const stopFile = stopFilePath(spineFile);
|
|
1902
|
+
try { rmSync(stopFile, { force: true }); } catch { /* best-effort — a stop surface must never kill the run */ }
|
|
1831
1903
|
let outcome;
|
|
1832
1904
|
try {
|
|
1833
1905
|
outcome = await runJob(spec, {
|
|
1906
|
+
stopFile,
|
|
1834
1907
|
// PRD item 27/M3 — the SAME `emit` the pre-run close-timing reading above
|
|
1835
1908
|
// used (never a second `makeSpine(spineFile)` here: two independent
|
|
1836
1909
|
// emitters against one file would both start their seq counter at 0 and
|
|
@@ -1919,6 +1992,7 @@ async function execute(ctx) {
|
|
|
1919
1992
|
// the guard outlives the run only by accident, never by design
|
|
1920
1993
|
try { watchdog.kill('SIGKILL'); } catch { /* already gone */ }
|
|
1921
1994
|
clearInterval(lagTimer);
|
|
1995
|
+
try { rmSync(stopFile, { force: true }); } catch { /* best-effort */ }
|
|
1922
1996
|
}
|
|
1923
1997
|
const legMs = Date.now() - started;
|
|
1924
1998
|
|
|
@@ -1926,7 +2000,11 @@ async function execute(ctx) {
|
|
|
1926
2000
|
// honest. No classification into pass/fail buckets — one run classifies nothing.
|
|
1927
2001
|
const raw = readFileSync(spineFile, 'utf8');
|
|
1928
2002
|
// tolerant: a torn/garbled line is skipped, not a crash (the shipped bundle-run fix, now the one engine read)
|
|
1929
|
-
const
|
|
2003
|
+
const allEvents = parseJsonl(spineFile).records;
|
|
2004
|
+
// P5-R: the readout below is about THIS leg — its own rounds, its own halts, its own terminal. A resumed run's
|
|
2005
|
+
// earlier legs are on the same file, and reading them here would count their rounds, tokens and halts again
|
|
2006
|
+
// (the run's chain-wide figures are the folded ones the lines below already print).
|
|
2007
|
+
const events = legsOf(allEvents).at(-1)?.records ?? allEvents;
|
|
1930
2008
|
const je = events.findLast((e) => e.type === 'job-end');
|
|
1931
2009
|
// The claim's WRITE-BACK (hamr 2026-09-30): when the run ends it settles its own claim on the monthly
|
|
1932
2010
|
// limit — its final spend, attributed to itself. Best-effort: a run that dies before here is settled
|
|
@@ -1934,7 +2012,7 @@ async function execute(ctx) {
|
|
|
1934
2012
|
if (je) {
|
|
1935
2013
|
try {
|
|
1936
2014
|
const fin = legSpend(events);
|
|
1937
|
-
appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString() }, { home: cfgHome });
|
|
2015
|
+
appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString(), ...(LEG > 1 ? { leg: LEG } : {}) }, { home: cfgHome });
|
|
1938
2016
|
} 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
2017
|
}
|
|
1940
2018
|
// the ONE spelling of the text-side scan (src/validate.js) — a hand-rolled copy
|
|
@@ -1946,8 +2024,50 @@ async function execute(ctx) {
|
|
|
1946
2024
|
|
|
1947
2025
|
const auditSrc = join(wd, 'gate-audit.jsonl');
|
|
1948
2026
|
let auditFile = null;
|
|
1949
|
-
|
|
1950
|
-
|
|
2027
|
+
// THIS leg's own audit rows — the readout's `writes` and BEHAVIOUR lines are about this leg, read from the
|
|
2028
|
+
// leg's own tree file before it is filed
|
|
2029
|
+
/** @type {any[]} */
|
|
2030
|
+
let audit = [];
|
|
2031
|
+
if (existsSync(auditSrc)) {
|
|
2032
|
+
const legAuditText = readFileSync(auditSrc, 'utf8');
|
|
2033
|
+
audit = legAuditText.trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
2034
|
+
auditFile = ctx.bundle ? join(spineDir, 'gate-audit.jsonl') : join(spineDir, `u-${runid}-gate-audit.jsonl`);
|
|
2035
|
+
// P5-R: a resumed leg ADDS to the run's one audit file (a rename would overwrite the earlier legs' rows)
|
|
2036
|
+
if (LEG > 1 && existsSync(auditFile)) {
|
|
2037
|
+
const prior = readFileSync(auditFile, 'utf8');
|
|
2038
|
+
appendFileSync(auditFile, `${prior.length > 0 && !prior.endsWith('\n') ? '\n' : ''}${legAuditText}`);
|
|
2039
|
+
unlinkSync(auditSrc);
|
|
2040
|
+
} else moveFile(auditSrc, auditFile);
|
|
2041
|
+
}
|
|
2042
|
+
// ── P6 item 1: a repo job on a WORKTREE in the person's own repo. GREEN: the run's work is committed on its work
|
|
2043
|
+
// branch and the worktree folder removed (an already-green is a green: a cold one has nothing to commit, a resumed one may hold uncommitted work) — the branch stays, and the merge stays the person's. Anything else
|
|
2044
|
+
// (stopped, capped, died, red): the folder stays so Resume works, and the Ended block names it. A green that opened a
|
|
2045
|
+
// review door is committed too (the branch always holds the work) but keeps its folder: an accept at the door re-runs
|
|
2046
|
+
// the mechanical stages against that tree.
|
|
2047
|
+
// It runs HERE, right after the gate-audit move, so a later readout step that throws can never skip the commit.
|
|
2048
|
+
const doorHere = events.findLast((e) => e.type === 'review-door') ?? null;
|
|
2049
|
+
/** @type {string | null} */
|
|
2050
|
+
let worktreeLine = null;
|
|
2051
|
+
const wtManifest = sourceManifest.present ? sourceManifest.manifest : null;
|
|
2052
|
+
if (typeof wtManifest?.worktree === 'string' && typeof wtManifest?.repo === 'string') {
|
|
2053
|
+
if (outcome === 'green' || outcome === 'already-green') {
|
|
2054
|
+
const wb = events.findLast((e) => e.type === 'work-branch' && typeof e.branch === 'string') ?? null;
|
|
2055
|
+
try {
|
|
2056
|
+
// the branch ALWAYS holds a green's work (the panel tells the person to merge it); only the FOLDER waits on a door
|
|
2057
|
+
const fin = commitWork(wd, `bareloop: ${spec.job} (run ${runid})`);
|
|
2058
|
+
// an already-green COLD run never made a work branch (the close passed before any work): nothing to name
|
|
2059
|
+
const done = fin.committed ? `final commit ${fin.sha}` : (wb ? 'nothing left to commit' : 'nothing was changed (the close already passed)');
|
|
2060
|
+
const onBranch = wb?.branch ?? 'the work branch';
|
|
2061
|
+
if (doorHere) worktreeLine = `\nWORKTREE ${done}${wb || fin.committed ? ` on ${onBranch}` : ''}; the worktree folder is KEPT for the review door — ${wd}`;
|
|
2062
|
+
else {
|
|
2063
|
+
const gone = removeWorktree(wtManifest.repo, wd);
|
|
2064
|
+
worktreeLine = `\nWORKTREE ${done}${wb || fin.committed ? ` on ${onBranch}` : ''}; ${gone ? 'the worktree folder is removed' : `the worktree folder could not be removed — ${wd}`}`;
|
|
2065
|
+
}
|
|
2066
|
+
} catch (e) {
|
|
2067
|
+
worktreeLine = `\nWORKTREE the final commit failed, so the folder is KEPT: ${wd} (${redactSecrets(String(/** @type {Error} */ (e).message).split('\n')[0])})`;
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
1951
2071
|
const writes = audit.filter((e) => e.decision === 'allow' && (e.action?.type === 'write' || e.action?.type === 'edit'));
|
|
1952
2072
|
|
|
1953
2073
|
out(`\noutcome ${outcome}`);
|
|
@@ -2096,7 +2216,7 @@ async function execute(ctx) {
|
|
|
2096
2216
|
// as "contained". D3 rework: `destination` is a DIRECTORY now, and a job
|
|
2097
2217
|
// may produce more than one file — `copyOut` delivers every non-empty file
|
|
2098
2218
|
// under `output/`, each under its own dated name, and returns the list.
|
|
2099
|
-
const co = await copyOut({ tree: wd, into
|
|
2219
|
+
const co = await copyOut({ tree: wd, into, destination: frontDoor.destination });
|
|
2100
2220
|
if (co.stop === null) {
|
|
2101
2221
|
// `f.path`, never `frontDoor.destination` — each file lands under its
|
|
2102
2222
|
// OWN dated name (M2b fix 4: `profile-2026-09-12.md`, `-2` the same
|
|
@@ -2150,9 +2270,8 @@ async function execute(ctx) {
|
|
|
2150
2270
|
// FOLDED across a resume, like every other money and time number this readout
|
|
2151
2271
|
// prints: the row records what the RUN cost, never what this leg cost
|
|
2152
2272
|
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
|
|
2273
|
+
// the ROUND count is not part of the declared fold — a resumed leg's `job-start` declares
|
|
2274
|
+
// 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
2275
|
// reads as exact is dishonest; report the floor WITH its completeness)
|
|
2157
2276
|
rounds: legRounds,
|
|
2158
2277
|
roundsComplete: !dead,
|
|
@@ -2183,7 +2302,6 @@ async function execute(ctx) {
|
|
|
2183
2302
|
// ENDED and its verdict is minted — this readout offers the three doors over it and
|
|
2184
2303
|
// changes nothing about the outcome printed above. Read off the run's own record
|
|
2185
2304
|
// (never off a flag), so a green that opened no door prints exactly what it always did.
|
|
2186
|
-
const doorHere = events.findLast((e) => e.type === 'review-door') ?? null;
|
|
2187
2305
|
if (doorHere) {
|
|
2188
2306
|
out('');
|
|
2189
2307
|
for (const l of reviewDoorPackage({
|
|
@@ -2283,6 +2401,7 @@ async function execute(ctx) {
|
|
|
2283
2401
|
out('MEMORY-CACHE no record (run ended before its summary)');
|
|
2284
2402
|
}
|
|
2285
2403
|
}
|
|
2404
|
+
if (worktreeLine !== null) out(worktreeLine);
|
|
2286
2405
|
out(`\nspine ${spineFile}`);
|
|
2287
2406
|
out(`patient left AS THE RUN LEFT IT (read it before the next run resets to the seed)`);
|
|
2288
2407
|
|
|
@@ -2292,7 +2411,7 @@ async function execute(ctx) {
|
|
|
2292
2411
|
// owns no spine, so its row is released (folded out of the list, like a monthly refusal) rather than
|
|
2293
2412
|
// left as a ghost that keeps the month reading "at least". Once the spine exists the run is a real,
|
|
2294
2413
|
// 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();
|
|
2414
|
+
if (claimedRow && (claimedRow.leg > 1 ? !legBegun : !existsSync(claimedRow.spine))) releaseClaimedRow();
|
|
2296
2415
|
if (e instanceof ExitSignal) return e.code;
|
|
2297
2416
|
throw e;
|
|
2298
2417
|
}
|
|
@@ -2312,6 +2431,7 @@ function buildCtx(mode, opts) {
|
|
|
2312
2431
|
workdir: /** @type {string} */ (opts.workdir),
|
|
2313
2432
|
seed: /** @type {string} */ (opts.seed),
|
|
2314
2433
|
spineName: /** @type {string} */ (opts.spineName),
|
|
2434
|
+
...(opts.into === undefined ? {} : { into: opts.into }),
|
|
2315
2435
|
model: opts.model ?? null,
|
|
2316
2436
|
readShim: opts.readShim ?? null,
|
|
2317
2437
|
scout: opts.scout ?? null,
|
|
@@ -2408,7 +2528,7 @@ export async function main(argv, deps = {}) {
|
|
|
2408
2528
|
die(`give one of --job <key> (one of: ${Object.keys(JOBS).join(', ')}) or --spec <path to resolved-spec.json> `
|
|
2409
2529
|
+ '— a run has to name a job one of the two ways.');
|
|
2410
2530
|
}
|
|
2411
|
-
/** @type {{spec: string, workdir: string, spine: string, seed: string}} */
|
|
2531
|
+
/** @type {{spec: string, workdir: string, spine: string, seed: string, into?: string}} */
|
|
2412
2532
|
let target;
|
|
2413
2533
|
/** the spec's own path, wherever it was found — a `jobs/` file for `--job`,
|
|
2414
2534
|
* an arbitrary `resolved-spec.json` for `--spec`. */
|
|
@@ -2507,9 +2627,14 @@ export async function main(argv, deps = {}) {
|
|
|
2507
2627
|
if (typeof seed !== 'string' || !seed) {
|
|
2508
2628
|
die(`--spec ${specPath}: ${into}/source.json carries no seed commit — not a prepared copy this runner can trust`);
|
|
2509
2629
|
}
|
|
2510
|
-
|
|
2630
|
+
// P6 item 1: a repo job's tree is a WORKTREE in the person's own repo, recorded in the manifest; every other prepared
|
|
2631
|
+
// copy keeps its tree at `<into>/tree`
|
|
2632
|
+
const worktreeRun = typeof manifestRead.manifest.worktree === 'string' && manifestRead.manifest.worktree !== '';
|
|
2633
|
+
const specWorkdir = worktreeRun ? manifestRead.manifest.worktree : join(into, 'tree');
|
|
2511
2634
|
if (!existsSync(specWorkdir) || !existsSync(join(specWorkdir, '.git'))) {
|
|
2512
|
-
die(
|
|
2635
|
+
die(worktreeRun
|
|
2636
|
+
? `--spec ${specPath}: the job's worktree is gone (${specWorkdir} has no .git) — nothing to run against; start the job again from the page`
|
|
2637
|
+
: `--spec ${specPath}: the prepared copy's tree is gone (${specWorkdir} has no .git) — nothing to run against; re-prepare the source`);
|
|
2513
2638
|
}
|
|
2514
2639
|
target = {
|
|
2515
2640
|
// display-only in --spec mode (SPEC_DESC below is what actually prints)
|
|
@@ -2520,6 +2645,7 @@ export async function main(argv, deps = {}) {
|
|
|
2520
2645
|
// is, just computed instead of typed by a developer.
|
|
2521
2646
|
spine: `${spec.job}-bareloop`,
|
|
2522
2647
|
seed,
|
|
2648
|
+
into,
|
|
2523
2649
|
};
|
|
2524
2650
|
SELECTOR = `--spec ${specPath}`;
|
|
2525
2651
|
SPEC_DESC = specPath;
|
|
@@ -2533,6 +2659,7 @@ export async function main(argv, deps = {}) {
|
|
|
2533
2659
|
workdir: target.workdir,
|
|
2534
2660
|
seed: target.seed,
|
|
2535
2661
|
spineName: target.spine,
|
|
2662
|
+
...(target.into === undefined ? {} : { into: target.into }),
|
|
2536
2663
|
model: argFlag('model'),
|
|
2537
2664
|
readShim: argFlag('read-shim'),
|
|
2538
2665
|
scout: argFlag('scout'),
|