bareloop 0.10.0 → 0.12.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 +510 -0
- package/README.md +18 -7
- package/bareloop.context.md +456 -19
- package/package.json +3 -3
- package/src/authorflow.js +290 -49
- package/src/authoring.js +258 -46
- package/src/authorjob.js +390 -32
- package/src/authorscout.js +13 -1
- package/src/bridges.js +238 -11
- package/src/calibrate.js +597 -0
- package/src/cardauthor.js +479 -0
- package/src/clock.js +21 -3
- package/src/declaredclose.js +188 -13
- package/src/index.js +90 -7
- package/src/job.js +28 -9
- package/src/judged.js +765 -0
- package/src/kinds.js +578 -13
- package/src/ledger.js +24 -1
- package/src/planrun.js +517 -14
- package/src/ralph.js +9 -0
- package/src/reuse.js +514 -96
- package/src/reviewdoor.js +246 -0
- package/src/root.js +1 -1
- package/src/run.js +81 -8
- package/src/selection.js +23 -7
- package/types/authorflow.d.ts +233 -21
- package/types/authoring.d.ts +99 -39
- package/types/authorjob.d.ts +44 -6
- package/types/authorscout.d.ts +7 -1
- package/types/bridges.d.ts +95 -1
- package/types/calibrate.d.ts +191 -0
- package/types/cardauthor.d.ts +352 -0
- package/types/clock.d.ts +5 -2
- package/types/declaredclose.d.ts +93 -6
- package/types/index.d.ts +11 -7
- package/types/job.d.ts +22 -4
- package/types/judged.d.ts +280 -0
- package/types/kinds.d.ts +227 -8
- package/types/planrun.d.ts +73 -3
- package/types/reuse.d.ts +218 -3
- package/types/reviewdoor.d.ts +81 -0
- package/types/run.d.ts +60 -2
package/src/reuse.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Layer 3 — the D7 ENVELOPE and the REUSE RUNNER (design record
|
|
2
|
-
// docs/
|
|
2
|
+
// docs/02-features/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
|
|
3
3
|
//
|
|
4
4
|
// hamr's sentence is the whole specification of this file, verbatim:
|
|
5
5
|
//
|
|
@@ -46,14 +46,20 @@ import { existsSync } from 'node:fs';
|
|
|
46
46
|
import { join } from 'node:path';
|
|
47
47
|
import { createHash } from 'node:crypto';
|
|
48
48
|
import { createRequire } from 'node:module';
|
|
49
|
-
import { loadRegistry, saveBridge, appendGreen, appendRed, mintBridge, registryExists } from './bridges.js';
|
|
49
|
+
import { loadRegistry, loadBridge, saveBridge, appendGreen, appendRed, mintBridge, registryExists, reuseEligibility, recordDoor, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
|
|
50
50
|
import { renderListing, selectionPrompt } from './selection.js';
|
|
51
51
|
import { closeStagesOf } from './plan.js';
|
|
52
52
|
import { MIN_WALL_MS, jobSpecHash } from './job.js';
|
|
53
53
|
import { isObj, isNonEmptyString } from './validate.js';
|
|
54
54
|
import { readGrade } from './trend.js';
|
|
55
|
+
|
|
56
|
+
/** the paused-run TERMINAL and its spine record share one name (`runPlan` returns
|
|
57
|
+
* it and `runJob` writes it onto `job-end`), spelled once in `declaredclose.js`
|
|
58
|
+
* beside the close-verdict word it translates, so the emitters, the checkpoint
|
|
59
|
+
* list, and the TTL gate below cannot drift from each other */
|
|
60
|
+
import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
|
|
55
61
|
import { extractArtifact, priceOf } from './text.js';
|
|
56
|
-
import { runJob as shippedRunJob } from './run.js';
|
|
62
|
+
import { runJob as shippedRunJob, ACCOUNTED_ROUND_TYPES } from './run.js';
|
|
57
63
|
|
|
58
64
|
const require = createRequire(import.meta.url);
|
|
59
65
|
const { Loop } = require('bare-agent');
|
|
@@ -84,6 +90,34 @@ const { Loop } = require('bare-agent');
|
|
|
84
90
|
*/
|
|
85
91
|
export const REUSE_GRADED_RED = Object.freeze(['escalated']);
|
|
86
92
|
|
|
93
|
+
/**
|
|
94
|
+
* WHICH TERMINALS ARE A CHECKPOINT — the list a runner hands `readResume` as
|
|
95
|
+
* `resumableOutcomes`, minted HERE rather than in whichever script happens to
|
|
96
|
+
* launch a run.
|
|
97
|
+
*
|
|
98
|
+
* `readResume` deliberately takes it as a parameter and defaults to empty, and
|
|
99
|
+
* that stays true: the reuse loop's own semantics depend on the empty default.
|
|
100
|
+
* What did not exist was a canonical ANSWER, so `scripts/run-u.mjs` kept its own
|
|
101
|
+
* copy — and the exported bundle (PRD v1.44 §2: a thin runner with bareloop as a
|
|
102
|
+
* dependency) would have had to keep a third. The same reasoning that put the
|
|
103
|
+
* pause TTL in the library (OPEN-2, hamr, 2026-08-13) puts this here.
|
|
104
|
+
*
|
|
105
|
+
* Every entry is a stop that left WORK ON DISK and an allowance unspent — money,
|
|
106
|
+
* time, a self-healed stall, and now a person who has not answered yet. A verdict
|
|
107
|
+
* already rendered is never on this list: `green`/`escalated` are graded rows, and
|
|
108
|
+
* re-buying one would pay twice for an answer already in hand. (The signer's own
|
|
109
|
+
* `hitl-cancel` terminal used to be the other example; the door was deleted on
|
|
110
|
+
* 2026-08-17 — a person who does not want to carry on pauses, and the checkpoint
|
|
111
|
+
* they keep is retired by the TTL rather than by a terminal.)
|
|
112
|
+
*
|
|
113
|
+
* The human half is SPLICED IN from `declaredclose.js` rather than spelled again,
|
|
114
|
+
* because the try loop below reads the two halves differently and a second copy is
|
|
115
|
+
* how they would come to disagree about which member is which: the first three are
|
|
116
|
+
* allowances the loop's remaining tries are meant to spend against, and the human
|
|
117
|
+
* ones are the stop no further try can change (see `hardStop`).
|
|
118
|
+
*/
|
|
119
|
+
export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', ...HUMAN_CHECKPOINTS]);
|
|
120
|
+
|
|
87
121
|
/**
|
|
88
122
|
* v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
|
|
89
123
|
* close-stage names, in the same order (`bridges.loadGate` rule 2). One predicate,
|
|
@@ -304,7 +338,8 @@ export function reuseSpecHash(resolved) {
|
|
|
304
338
|
* @param {boolean} [opts.forceCold]
|
|
305
339
|
* @param {string[]} [opts.exclude] names already tried this run
|
|
306
340
|
* @returns {Promise<any>} `{ choice, reason, called, refused, forcedCold, red, costUsd,
|
|
307
|
-
* spendComplete, candidates, listing }`
|
|
341
|
+
* spendComplete, candidates, quarantineSkips, listing }` — `quarantineSkips` names every
|
|
342
|
+
* workflow held back for want of a signer's accept, with its reason (module 6)
|
|
308
343
|
*/
|
|
309
344
|
export async function selectBridge({ registry, job, ask, provider, pinned = null, shortlist = null, forceCold = false, exclude = [] }) {
|
|
310
345
|
const r = /** @type {any} */ (registry);
|
|
@@ -312,22 +347,48 @@ export async function selectBridge({ registry, job, ask, provider, pinned = null
|
|
|
312
347
|
const skipReds = Array.isArray(r?.reds) ? r.reds : [];
|
|
313
348
|
const excluded = new Set(exclude);
|
|
314
349
|
const shortSet = Array.isArray(shortlist) ? new Set(shortlist) : null;
|
|
315
|
-
const
|
|
350
|
+
const offered = all.filter((/** @type {any} */ b) => !excluded.has(b.name) && (!shortSet || shortSet.has(b.name)));
|
|
351
|
+
|
|
352
|
+
// SOFTGREEN MODULE 6 — a HELD workflow is not offered, and the skip is stated.
|
|
353
|
+
// Its green was rendered by the judged floor and no signer has accepted it yet,
|
|
354
|
+
// so it has earned no reuse; handing it to the picker would spend the credit
|
|
355
|
+
// before it was granted. The reason travels TWO ways — into the listing the
|
|
356
|
+
// picker reads (as a distinct note, never dressed as an unreadable file) and out
|
|
357
|
+
// to the caller — because a shelf that quietly shrinks teaches the operator that
|
|
358
|
+
// the registry is empty when it is merely waiting on them.
|
|
359
|
+
/** @type {{name: string|null, reason: string}[]} */
|
|
360
|
+
const quarantineSkips = [];
|
|
361
|
+
const candidates = offered.filter((/** @type {any} */ b) => {
|
|
362
|
+
const e = reuseEligibility(b);
|
|
363
|
+
if (!e.ok) quarantineSkips.push({ name: isNonEmptyString(b.name) ? b.name : null, reason: e.reason });
|
|
364
|
+
return e.ok;
|
|
365
|
+
});
|
|
316
366
|
|
|
317
367
|
/** the shape every return here holds, so a caller never branches on field presence */
|
|
318
|
-
const base = { choice: null, reason: '', called: false, refused: false, forcedCold: false, red: null, costUsd: 0, spendComplete: true, candidates: candidates.map((/** @type {any} */ b) => b.name), listing: null, pinned };
|
|
368
|
+
const base = { choice: null, reason: '', called: false, refused: false, forcedCold: false, red: null, costUsd: 0, spendComplete: true, candidates: candidates.map((/** @type {any} */ b) => b.name), quarantineSkips, listing: null, pinned };
|
|
319
369
|
|
|
320
370
|
if (forceCold) {
|
|
321
371
|
return { ...base, forcedCold: true, reason: 'the operator forced a cold draft — no workflow was offered and no selection call was made' };
|
|
322
372
|
}
|
|
323
373
|
if (candidates.length === 0) {
|
|
324
|
-
return {
|
|
374
|
+
return {
|
|
375
|
+
...base,
|
|
376
|
+
// "nothing to reuse" and "everything is waiting on YOU" are different facts,
|
|
377
|
+
// and the empty-shelf sentence must not be said about the second
|
|
378
|
+
reason: quarantineSkips.length
|
|
379
|
+
? `every workflow this run could have started from is HELD: ${quarantineSkips.map((s) => `${s.name} (${s.reason})`).join('; ')}`
|
|
380
|
+
: (exclude.length || shortSet ? 'no workflow remains to offer (every candidate was excluded or outside the shortlist)' : 'the registry holds no reusable workflow'),
|
|
381
|
+
};
|
|
325
382
|
}
|
|
326
383
|
|
|
327
384
|
// the listing the MODEL reads is the listing a human reads — same function, same text,
|
|
328
385
|
// and the skip reds travel with it so a picker is never handed a set shorter than the
|
|
329
386
|
// directory without being told
|
|
330
|
-
const listing = renderListing({
|
|
387
|
+
const listing = renderListing({
|
|
388
|
+
ok: skipReds.length === 0 && quarantineSkips.length === 0,
|
|
389
|
+
reds: [...skipReds, ...quarantineSkips.map((s) => ({ code: QUARANTINED_CODE, path: s.name, detail: s.reason }))],
|
|
390
|
+
bridges: candidates,
|
|
391
|
+
});
|
|
331
392
|
const prompt = selectionPrompt(listing, ask, pinned);
|
|
332
393
|
|
|
333
394
|
let result;
|
|
@@ -418,13 +479,24 @@ function readTry(events) {
|
|
|
418
479
|
};
|
|
419
480
|
}
|
|
420
481
|
|
|
421
|
-
/** the
|
|
422
|
-
*
|
|
423
|
-
* try loop and the resume reader classify through this — two
|
|
424
|
-
* of answer was that" is how a casualty ends up demoting a
|
|
425
|
-
*
|
|
482
|
+
/** the verdict read of a runJob outcome, in ONE place: a green, the one GRADED red
|
|
483
|
+
* that demotes (D6), a CHECKPOINT waiting on a person, or a casualty that keeps its
|
|
484
|
+
* own name. Both the live try loop and the resume reader classify through this — two
|
|
485
|
+
* spellings of "what kind of answer was that" is how a casualty ends up demoting a
|
|
486
|
+
* recipe.
|
|
487
|
+
*
|
|
488
|
+
* `checkpoint` is its own name rather than a fourth flavour of casualty (2026-08-17).
|
|
489
|
+
* A casualty is a run that DIED — a transport fault, a governance cut, a broken close
|
|
490
|
+
* — and a reader that sees one is being told the attempt is over. A human checkpoint
|
|
491
|
+
* is the opposite fact in every respect: nothing failed, nothing is discarded, the
|
|
492
|
+
* work and the money are exactly where they were, and the allowance still unspent is
|
|
493
|
+
* a person's answer. It behaves like neither a red nor a casualty everywhere it
|
|
494
|
+
* matters already (`hardStop` ends the loop on it, the box is left untouched), so the
|
|
495
|
+
* ROW saying "casualty" was the one place the machinery still mis-described itself.
|
|
496
|
+
* @param {string} outcome @returns {'green'|'red'|'checkpoint'|'casualty'} */
|
|
426
497
|
function verdictClassOf(outcome) {
|
|
427
498
|
if (outcome === 'green' || outcome === 'already-green') return 'green';
|
|
499
|
+
if (HUMAN_CHECKPOINTS.includes(outcome)) return 'checkpoint';
|
|
428
500
|
return REUSE_GRADED_RED.includes(outcome) ? 'red' : 'casualty';
|
|
429
501
|
}
|
|
430
502
|
|
|
@@ -522,6 +594,46 @@ function readWorkBranch(seen) {
|
|
|
522
594
|
return null;
|
|
523
595
|
}
|
|
524
596
|
|
|
597
|
+
/**
|
|
598
|
+
* F102 — the SIGNER'S ANSWER this window received and never paid for.
|
|
599
|
+
*
|
|
600
|
+
* Two records, two questions, and the pairing is the whole reading:
|
|
601
|
+
* `human-decision` — a decision reached this leg (with the person's own
|
|
602
|
+
* words, and when THEY said them)
|
|
603
|
+
* `human-decision-spent` — work was bought for it (a fix round, an accept that
|
|
604
|
+
* greened, a pause honoured)
|
|
605
|
+
*
|
|
606
|
+
* A decision with no spend after it is still owed to the person. That is the state
|
|
607
|
+
* F102 found unrepresentable: the resumed leg re-ran the close, arrived at the human
|
|
608
|
+
* stage and asked the byte-identical question, because "a decision is pending" and
|
|
609
|
+
* "nobody has looked yet" were the same empty checkpoint.
|
|
610
|
+
*
|
|
611
|
+
* The LAST unspent one wins — a leg emits at most one, and a window that reaches
|
|
612
|
+
* across a resume must act on the newest. `receivedAt` prefers the carried stamp (the
|
|
613
|
+
* moment the PERSON answered, forwarded leg to leg) and falls back to the record's own
|
|
614
|
+
* `ts`, which is that same moment on the leg that first heard it.
|
|
615
|
+
* @param {any[]} seen the window's own events, in file order
|
|
616
|
+
* @returns {{decision: string, text: string|null, receivedAt: string|null, source: string|null}|null}
|
|
617
|
+
*/
|
|
618
|
+
function readPendingDecision(seen) {
|
|
619
|
+
for (let i = seen.length - 1; i >= 0; i -= 1) {
|
|
620
|
+
const e = seen[i];
|
|
621
|
+
if (!isObj(e)) continue;
|
|
622
|
+
// a spend seen while walking backwards settles everything older: the decision it
|
|
623
|
+
// paid for is the newest one there is, and nothing before it can still be owed
|
|
624
|
+
if (e.type === 'human-decision-spent') return null;
|
|
625
|
+
if (e.type !== 'human-decision' || !isNonEmptyString(e.decision)) continue;
|
|
626
|
+
const at = isNonEmptyString(e.receivedAt) ? String(e.receivedAt) : (isNonEmptyString(e.ts) ? String(e.ts) : null);
|
|
627
|
+
return {
|
|
628
|
+
decision: String(e.decision),
|
|
629
|
+
text: typeof e.text === 'string' ? e.text : null,
|
|
630
|
+
receivedAt: at,
|
|
631
|
+
source: isNonEmptyString(e.source) ? String(e.source) : null,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
525
637
|
/**
|
|
526
638
|
* The dead leg's CLOSE GRADES, in order — the trend baselines a resumed leg
|
|
527
639
|
* inherits (PRD v1.46 §3, and hamr's approved #2: *a resumed run's trend judges the
|
|
@@ -608,8 +720,11 @@ function readGradeSeed(seen) {
|
|
|
608
720
|
* 4. **Which envelope this spine belongs to** — the `reuse-start` record's own
|
|
609
721
|
* approval hash, so a resume can prove it is continuing the SAME signed run.
|
|
610
722
|
*
|
|
611
|
-
* **F45 governs the arithmetic here.** A window's spend is summed from
|
|
612
|
-
* ONLY — the
|
|
723
|
+
* **F45 governs the arithmetic here.** A window's spend is summed from
|
|
724
|
+
* `ACCOUNTED_ROUND_TYPES` ONLY — the ledger's OWN list (src/run.js), imported rather
|
|
725
|
+
* than re-spelled, so a record `runJob` charges and this reader has never heard of
|
|
726
|
+
* cannot silently widen a resumed run's ceiling by exactly that amount. It was one
|
|
727
|
+
* event (`worker-round`) until the close itself could spend (`judge-round`). The selection calls
|
|
613
728
|
* are real money and are counted, but as the RUN's cost and never as a try's: a
|
|
614
729
|
* picker's tokens are not a worker's, and attributing them to a try would misstate
|
|
615
730
|
* both the fold and the row. `worker-turn` (native attribution, cost null by design)
|
|
@@ -770,7 +885,7 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
|
|
|
770
885
|
// a priced worker round outside every try window: nothing in this runner emits
|
|
771
886
|
// one, so it is a writer this reader does not model. It is COUNTED and reported
|
|
772
887
|
// rather than dropped — the blind-instrument class is what F45 is about.
|
|
773
|
-
if (ev.type
|
|
888
|
+
if (ACCOUNTED_ROUND_TYPES.includes(ev.type)) {
|
|
774
889
|
strayRounds += 1;
|
|
775
890
|
if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) strayCostUsd += ev.costUsd;
|
|
776
891
|
else selectionComplete = false;
|
|
@@ -778,8 +893,11 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
|
|
|
778
893
|
continue;
|
|
779
894
|
}
|
|
780
895
|
open.seen.push(ev);
|
|
781
|
-
if (ev.type
|
|
782
|
-
// the
|
|
896
|
+
if (ACCOUNTED_ROUND_TYPES.includes(ev.type)) {
|
|
897
|
+
// the events the live ledger accounts (src/run.js), read from ITS OWN list rather
|
|
898
|
+
// than re-spelled here: the fold is what stops a resume silently widening a signed
|
|
899
|
+
// budget, so a record the ledger charges and this reader does not know about is a
|
|
900
|
+
// ceiling that grows by exactly the close's own spend (`judge-round`, softgreen)
|
|
783
901
|
if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) open.roundsUsd += ev.costUsd;
|
|
784
902
|
else open.roundsComplete = false;
|
|
785
903
|
} else if (ev.type === 'job-end') {
|
|
@@ -967,6 +1085,25 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
|
|
|
967
1085
|
priorRounds: open.declaredRounds !== null && typeof windowRounds === 'number'
|
|
968
1086
|
? open.declaredRounds + windowRounds
|
|
969
1087
|
: null,
|
|
1088
|
+
// F102 — the DECISION this leg was given and never paid for. A person's
|
|
1089
|
+
// answer is state, and state that lives only in a process dies with it: the
|
|
1090
|
+
// paused run `msx7a3rj` took the rerun door, stopped with `iterationsUsed: 0`,
|
|
1091
|
+
// and its resume asked the byte-identical question again because the
|
|
1092
|
+
// checkpoint could not tell "pending" from "nobody has looked yet".
|
|
1093
|
+
//
|
|
1094
|
+
// The two records are the run's own (`human-decision` / `human-decision-spent`,
|
|
1095
|
+
// src/planrun.js) and the reading is the plain one: the last decision received
|
|
1096
|
+
// with nothing after it that bought it. Deliberately NOT re-derived from a
|
|
1097
|
+
// terminal or a phase — the writer says both facts, and a reader inventing a
|
|
1098
|
+
// third rule is how two instruments come to disagree.
|
|
1099
|
+
//
|
|
1100
|
+
// THIS WINDOW ONLY, unlike the plan seed and the work branch beside it. Those
|
|
1101
|
+
// are facts about the TREE and survive an attempt that died before recording
|
|
1102
|
+
// anything; a decision is a fact about a PERSON and about the tree they were
|
|
1103
|
+
// shown, and reaching back into an abandoned attempt to re-apply one could
|
|
1104
|
+
// hand a leg an answer given about a tree two attempts ago. Stated as the
|
|
1105
|
+
// narrower rule rather than discovered as a mis-applied accept.
|
|
1106
|
+
pendingDecision: readPendingDecision(open.seen),
|
|
970
1107
|
};
|
|
971
1108
|
if (open.bridge) tried.push(open.bridge);
|
|
972
1109
|
}
|
|
@@ -1023,6 +1160,81 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
|
|
|
1023
1160
|
};
|
|
1024
1161
|
}
|
|
1025
1162
|
|
|
1163
|
+
/**
|
|
1164
|
+
* THE PAUSE TTL (2026-08-12 §2): a hitl checkpoint is kept for 60 days.
|
|
1165
|
+
*
|
|
1166
|
+
* OPEN-2, RULED (hamr, in-turn, 2026-08-13): **the TTL lives in the LIBRARY**, so
|
|
1167
|
+
* the exported bundle (a headless runner with bareloop as a dependency, PRD v1.44
|
|
1168
|
+
* §2) inherits it instead of every runner re-implementing it. A script-only TTL
|
|
1169
|
+
* would vanish on export — the one place the rule is most needed and least
|
|
1170
|
+
* likely to be re-typed.
|
|
1171
|
+
*/
|
|
1172
|
+
export const PAUSE_TTL_MS = 60 * 24 * 60 * 60_000;
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* The AGE gate for a paused checkpoint — the sibling of `resumeTreeGate`, and it
|
|
1176
|
+
* asks the other question a 60-day-old checkpoint raises. The tree gate asks
|
|
1177
|
+
* *is this still the tree the run left?*; this asks *is this still a decision
|
|
1178
|
+
* anyone is waiting on?*
|
|
1179
|
+
*
|
|
1180
|
+
* THREE things it deliberately does NOT do:
|
|
1181
|
+
*
|
|
1182
|
+
* - it does not widen any other resume rule. The 2026-08-12 ruling is about a
|
|
1183
|
+
* person deciding, so `applies: false` on every other terminal: a cap-halt
|
|
1184
|
+
* checkpoint is a different stop under a different allowance, and quietly
|
|
1185
|
+
* ageing it out would be a governance change nobody ruled.
|
|
1186
|
+
* - it does not treat an unreadable stamp as young (F6). A checkpoint that
|
|
1187
|
+
* cannot say WHEN it paused has an unknown age, and unknown is refused rather
|
|
1188
|
+
* than rounded down to fresh — the direction that fails safe.
|
|
1189
|
+
* - it does not skip silently. An expired checkpoint comes back as a refusal
|
|
1190
|
+
* that NAMES the age and the TTL, because the operator's next move (start
|
|
1191
|
+
* fresh) depends on knowing which of the two it hit.
|
|
1192
|
+
*
|
|
1193
|
+
* The clock is injected for the same reason `runPlan`'s is: a rule measured in
|
|
1194
|
+
* months cannot otherwise be tested at all.
|
|
1195
|
+
*
|
|
1196
|
+
* @param {any[]} events the paused run's own spine, parsed, in file order
|
|
1197
|
+
* @param {{now?: () => number, ttlMs?: number}} [opts]
|
|
1198
|
+
* @returns {{ok: boolean, applies: boolean, ageMs: number|null, ttlMs: number, pausedAt: string|null, detail: string|null}}
|
|
1199
|
+
*/
|
|
1200
|
+
export function checkpointAgeGate(events, { now = Date.now, ttlMs = PAUSE_TTL_MS } = {}) {
|
|
1201
|
+
const list = Array.isArray(events) ? events.filter(isObj) : [];
|
|
1202
|
+
const end = list.filter((e) => e.type === 'job-end').at(-1) ?? null;
|
|
1203
|
+
const base = { applies: false, ageMs: null, ttlMs, pausedAt: null, detail: null };
|
|
1204
|
+
if (end?.outcome !== HITL_PAUSE) return { ...base, ok: true };
|
|
1205
|
+
|
|
1206
|
+
// the PAUSE RECORD's own timestamp, and its terminal's as the fallback: the
|
|
1207
|
+
// pause is what the human is answering, so its stamp is when the waiting began
|
|
1208
|
+
const pause = list.filter((e) => e.type === HITL_PAUSE).at(-1) ?? end;
|
|
1209
|
+
const pausedAt = typeof pause.ts === 'string' ? pause.ts : null;
|
|
1210
|
+
const atMs = pausedAt === null ? NaN : Date.parse(pausedAt);
|
|
1211
|
+
if (!Number.isFinite(atMs)) {
|
|
1212
|
+
return {
|
|
1213
|
+
...base,
|
|
1214
|
+
ok: false,
|
|
1215
|
+
applies: true,
|
|
1216
|
+
detail: 'this checkpoint does not record when it paused, so its age is unknown — and an unknown age is not a '
|
|
1217
|
+
+ `young one (F6). The TTL is ${Math.round(ttlMs / 86_400_000)} days; start a fresh run rather than resuming a `
|
|
1218
|
+
+ 'checkpoint nothing can date.',
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
const ageMs = Math.max(0, now() - atMs);
|
|
1222
|
+
const days = (/** @type {number} */ ms) => Math.floor(ms / 86_400_000);
|
|
1223
|
+
if (ageMs > ttlMs) {
|
|
1224
|
+
return {
|
|
1225
|
+
...base,
|
|
1226
|
+
ok: false,
|
|
1227
|
+
applies: true,
|
|
1228
|
+
ageMs,
|
|
1229
|
+
pausedAt,
|
|
1230
|
+
detail: `this run paused ${days(ageMs)} days ago and the pause TTL is ${days(ttlMs)} days — the tree, the `
|
|
1231
|
+
+ 'toolchain and the job itself have had two months to move, so the evidence the signer would be answering '
|
|
1232
|
+
+ 'about is no longer the evidence the run produced. Start a fresh run.',
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
return { ...base, ok: true, applies: true, ageMs, pausedAt };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1026
1238
|
/**
|
|
1027
1239
|
* The PATIENT gate for a resume, which is deliberately NOT the fresh-launch one.
|
|
1028
1240
|
*
|
|
@@ -1374,8 +1586,35 @@ export async function runReuse(opts) {
|
|
|
1374
1586
|
runid: `${runid}-t${n}`, patient, at: new Date(now()).toISOString(),
|
|
1375
1587
|
costUsd: read.spentUsd, spendComplete: read.spendComplete, wallMs, rounds,
|
|
1376
1588
|
...(trySpecHash ? { specHash: trySpecHash } : {}),
|
|
1589
|
+
// the CLASS this run was signed under, carried to the one place a green is
|
|
1590
|
+
// built (softgreen module 6). It decides whether the minted version is HELD;
|
|
1591
|
+
// `appendRed` ignores it, because a red minted nothing to hold.
|
|
1592
|
+
verdictType: job.verdictType,
|
|
1377
1593
|
};
|
|
1378
|
-
if (
|
|
1594
|
+
if (outcome === 'already-green') {
|
|
1595
|
+
// A GREEN THAT PREDATES THE RUN mints nothing, and the guard keys on the
|
|
1596
|
+
// TERMINAL rather than on the absence of a plan. `already-green` means the
|
|
1597
|
+
// close was satisfied at the precheck — before this leg wrote a byte — so
|
|
1598
|
+
// whatever plan is on the spine did not produce it, and minting a version
|
|
1599
|
+
// from one would be learning credit for work that did not happen.
|
|
1600
|
+
//
|
|
1601
|
+
// It used to be covered by accident: a cold already-green never drafts, so
|
|
1602
|
+
// `read.plan` was null and the branch below caught it. N4 broke that
|
|
1603
|
+
// accident — a hitl try that PAUSED and came back with the signer's
|
|
1604
|
+
// `accept` lands `already-green` with its predecessor's `plan-accepted`
|
|
1605
|
+
// sitting in the same try window. A rule with no wired detector is prose.
|
|
1606
|
+
bridgeWrites.push({
|
|
1607
|
+
name,
|
|
1608
|
+
action: 'none',
|
|
1609
|
+
file: null,
|
|
1610
|
+
reds: [{
|
|
1611
|
+
code: 'green-predates-run',
|
|
1612
|
+
path: 'outcome',
|
|
1613
|
+
detail: 'the close was already satisfied before this run did anything (already-green) — nothing here earned '
|
|
1614
|
+
+ 'a version, and a plan on the spine did not produce this green',
|
|
1615
|
+
}],
|
|
1616
|
+
});
|
|
1617
|
+
} else if (verdictClass === 'green' && read.plan === null) {
|
|
1379
1618
|
// a green with no plan on the spine cannot mint a version — the artifact that
|
|
1380
1619
|
// inherits is the one that RAN, and there is nothing here to inherit. Reported,
|
|
1381
1620
|
// never faked with a placeholder.
|
|
@@ -1388,7 +1627,33 @@ export async function runReuse(opts) {
|
|
|
1388
1627
|
closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
|
|
1389
1628
|
toolsUsed: [...new Set((read.plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
|
|
1390
1629
|
};
|
|
1391
|
-
|
|
1630
|
+
// ONE spelling of what a green writes, shared with the U runner (2B):
|
|
1631
|
+
// `writeGreenRow` builds the write record and saves it; this leg still owns
|
|
1632
|
+
// the reporting, exactly as `commit` did when the logic lived in here.
|
|
1633
|
+
const w = writeGreenRow({ registryDir, bridge, meta, record: { ...record, plan: read.plan } });
|
|
1634
|
+
bridgeWrites.push(w);
|
|
1635
|
+
emit('bridge-write', w);
|
|
1636
|
+
row.bridgeWrite = w;
|
|
1637
|
+
} else if (HUMAN_CHECKPOINTS.includes(outcome)) {
|
|
1638
|
+
// A PAUSE IS NOT A RESULT, so the box gets nothing — not a casualty row, not a red,
|
|
1639
|
+
// and the workflow is left exactly as it was. The recipe has not been judged: the
|
|
1640
|
+
// close is not FINISHED rendering its verdict, and the answer that would finish it
|
|
1641
|
+
// is a person's. Filing it as history would put a row on the entry that reads as a
|
|
1642
|
+
// run this workflow failed to carry, and the signer's `accept` moments later would
|
|
1643
|
+
// leave that row standing as evidence against a workflow that in fact greened.
|
|
1644
|
+
// Recorded as an explicit no-write for the already-green branch's reason: a reader
|
|
1645
|
+
// never has to branch on absence.
|
|
1646
|
+
bridgeWrites.push({
|
|
1647
|
+
name,
|
|
1648
|
+
action: 'none',
|
|
1649
|
+
file: null,
|
|
1650
|
+
reds: [{
|
|
1651
|
+
code: 'run-paused-for-decision',
|
|
1652
|
+
path: 'outcome',
|
|
1653
|
+
detail: `the run is waiting on a person (${outcome}) — nothing has been judged yet, so there is no result to `
|
|
1654
|
+
+ 'file against this workflow. The row is written by whatever the resumed run finally earns.',
|
|
1655
|
+
}],
|
|
1656
|
+
});
|
|
1392
1657
|
} else if (bridge) {
|
|
1393
1658
|
// R1: the recipe is NOT edited by a red. `outcome` rides in verbatim — a casualty
|
|
1394
1659
|
// keeps its own name, and only the literal 'red' demotes (D6).
|
|
@@ -1402,89 +1667,18 @@ export async function runReuse(opts) {
|
|
|
1402
1667
|
return row;
|
|
1403
1668
|
};
|
|
1404
1669
|
|
|
1405
|
-
/** save a bridge result and record the write, whatever produced it
|
|
1670
|
+
/** save a bridge result and record the write, whatever produced it — the record
|
|
1671
|
+
* itself is built by `commitWrite` (the one spelling of the write-record shape,
|
|
1672
|
+
* shared with `writeGreenRow`); this closure only adds the reporting
|
|
1406
1673
|
* @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name @param {string} action
|
|
1407
1674
|
* @param {object} [extra] fields that make the write's own story visible on the record */
|
|
1408
1675
|
const commit = (r, name, action, extra = {}) => {
|
|
1409
|
-
|
|
1410
|
-
const w = { name, action: 'none', file: null, reds: r.reds, ...extra };
|
|
1411
|
-
bridgeWrites.push(w);
|
|
1412
|
-
emit('bridge-write', w);
|
|
1413
|
-
return w;
|
|
1414
|
-
}
|
|
1415
|
-
const s = saveBridge(registryDir, r.bridge);
|
|
1416
|
-
const w = { name: r.bridge.name, action: s.ok ? action : 'none', file: s.file, reds: s.reds, ...extra };
|
|
1676
|
+
const w = commitWrite(registryDir, r, name, action, extra);
|
|
1417
1677
|
bridgeWrites.push(w);
|
|
1418
1678
|
emit('bridge-write', w);
|
|
1419
1679
|
return w;
|
|
1420
1680
|
};
|
|
1421
1681
|
|
|
1422
|
-
/** a refused write, recorded exactly like a committed one so a reader never has to
|
|
1423
|
-
* branch on presence @param {string} name @param {any} red @param {object} [extra] */
|
|
1424
|
-
const refuseWrite = (name, red, extra = {}) => {
|
|
1425
|
-
const w = { name, action: 'none', file: null, reds: [red], ...extra };
|
|
1426
|
-
bridgeWrites.push(w);
|
|
1427
|
-
emit('bridge-write', w);
|
|
1428
|
-
return w;
|
|
1429
|
-
};
|
|
1430
|
-
|
|
1431
|
-
/**
|
|
1432
|
-
* KNOWN LIMIT, named rather than discovered later: `appendGreen` carries the entry's
|
|
1433
|
-
* LABEL (`goal`, `closeStageNames`, `toolsUsed`) forward untouched, so a new version
|
|
1434
|
-
* whose plan uses a verb the founding green did not is not reflected in `toolsUsed`.
|
|
1435
|
-
* The load gate reads that field, so the gate's verb check goes slightly stale across
|
|
1436
|
-
* versions. It is not a safety hole — the gate only ever gets more permissive there,
|
|
1437
|
-
* and the TWEAKED plan still passes the full `validatePlan` against the job's signed
|
|
1438
|
-
* menu at draft time, which is the check that actually decides. Fixing it properly
|
|
1439
|
-
* means `appendGreen` taking a label update, which is module 1's contract and hamr's
|
|
1440
|
-
* call, not something to change from the caller.
|
|
1441
|
-
* @param {any|null} bridge @param {any} meta @param {any} record
|
|
1442
|
-
*/
|
|
1443
|
-
function writeGreen(bridge, meta, record) {
|
|
1444
|
-
// the BRIDGE leg needs no shape check: `runJob`'s own load gate already proved this
|
|
1445
|
-
// entry is the same kind of recipe as this job, at the door, before a token was spent
|
|
1446
|
-
// (a mismatch never gets here — it returns `recipe-stale`).
|
|
1447
|
-
if (bridge) return commit(appendGreen(bridge, record), bridge.name, 'appendGreen');
|
|
1448
|
-
|
|
1449
|
-
// the COLD leg's green. `mintBridge` is for a name nobody holds; a name that already
|
|
1450
|
-
// holds greens APPENDS, because a cold run of a job that already has a bridge is
|
|
1451
|
-
// another version of that workflow, and overwriting it would destroy the green that
|
|
1452
|
-
// minted it.
|
|
1453
|
-
//
|
|
1454
|
-
// But the name is the JOB's slug, and a job's CLOSE can be re-signed under that same
|
|
1455
|
-
// slug. Appending across two close shapes does two wrong things at once: it counts a
|
|
1456
|
-
// green another close rendered toward this entry's distinct-patient status (a false
|
|
1457
|
-
// `proven`), and it hands the load gate — which matches on exactly these stage names
|
|
1458
|
-
// — a plan that satisfied a different verification. So the shape is checked here with
|
|
1459
|
-
// the gate's own predicate, and a mismatch neither appends NOR discards the green: it
|
|
1460
|
-
// forks to a deterministic derived name, with the fork ON the record.
|
|
1461
|
-
const stageNames = Array.isArray(meta.closeStageNames) ? meta.closeStageNames : [];
|
|
1462
|
-
const entries = loadRegistry(registryDir).bridges;
|
|
1463
|
-
const existing = entries.find((/** @type {any} */ b) => b.name === meta.name);
|
|
1464
|
-
if (existing && sameCloseShape(existing, stageNames)) return commit(appendGreen(existing, record), meta.name, 'appendGreen');
|
|
1465
|
-
|
|
1466
|
-
const forked = existing !== undefined;
|
|
1467
|
-
const target = forked ? shapeForkName(meta.name, stageNames) : meta.name;
|
|
1468
|
-
/** the fork's own story, carried on every write record it produces */
|
|
1469
|
-
const mark = forked ? { shapeForked: true, forkedFrom: meta.name, closeStageNames: [...stageNames] } : {};
|
|
1470
|
-
const held = forked ? entries.find((/** @type {any} */ b) => b.name === target) : undefined;
|
|
1471
|
-
if (held) {
|
|
1472
|
-
// the derived name is derived FROM the shape, so a readable entry there matches by
|
|
1473
|
-
// construction; a mismatch would mean two different closes hashed the same, and
|
|
1474
|
-
// that is refused rather than appended blind
|
|
1475
|
-
return sameCloseShape(held, stageNames)
|
|
1476
|
-
? commit(appendGreen(held, record), target, 'appendGreen-shape-forked', mark)
|
|
1477
|
-
: refuseWrite(target, { code: 'shape-fork-collision', path: `${target}.json`, detail: `the derived name for close [${stageNames.join(' → ')}] is already held by an entry of a different shape — refusing to append this green to a recipe another close greened` }, mark);
|
|
1478
|
-
}
|
|
1479
|
-
// A file of that name that `loadRegistry` could not READ is not an absent entry — it
|
|
1480
|
-
// is an entry nobody can see, and minting over it destroys whatever greens it held.
|
|
1481
|
-
// `loadRegistry` skips-and-reports for that reason; the mint must not undo the skip.
|
|
1482
|
-
if (existsSync(join(registryDir, `${target}.json`))) {
|
|
1483
|
-
return refuseWrite(target, { code: 'mint-collision', path: `${target}.json`, detail: 'a file of this name exists but could not be read as a bridge — refusing to mint over it, because whatever greens it holds are not recoverable once overwritten. Repair or move it, then re-run.' }, mark);
|
|
1484
|
-
}
|
|
1485
|
-
return commit(mintBridge({ ...meta, name: target }, record), target, forked ? 'mint-shape-forked' : 'mint', mark);
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
1682
|
/**
|
|
1489
1683
|
* The stops no further try can change. A spec nobody signed, a spec that will not
|
|
1490
1684
|
* validate, and a degraded primitive are properties of the RUN, not of the recipe — the
|
|
@@ -1495,6 +1689,31 @@ export async function runReuse(opts) {
|
|
|
1495
1689
|
* @param {any} row @returns {any|null} the terminal, or null to carry on
|
|
1496
1690
|
*/
|
|
1497
1691
|
const hardStop = (row) => {
|
|
1692
|
+
// THE HUMAN HALF, read before the table and off the LIBRARY's own list: a run
|
|
1693
|
+
// waiting on a person is not one of the stops below, which are properties of the
|
|
1694
|
+
// run that would reproduce for $0. This one is the opposite — a try that did real
|
|
1695
|
+
// work and reached the one stage a machine cannot render. Buying the next try (and
|
|
1696
|
+
// then the cold leg) would spend the envelope re-asking a question already put to
|
|
1697
|
+
// someone who has not replied yet, and the second attempt could not be graded any
|
|
1698
|
+
// sooner than the first. The allowance still unspent is a person, so the loop stops
|
|
1699
|
+
// and hands the run back; the checkpoint on disk is untouched and resumable, which
|
|
1700
|
+
// is the runner script's half (N4 §1.6, PAUSE_TTL_MS).
|
|
1701
|
+
if (HUMAN_CHECKPOINTS.includes(row.runOutcome)) {
|
|
1702
|
+
return done(row.runOutcome, {
|
|
1703
|
+
category: row.runOutcome,
|
|
1704
|
+
decision: 'The run reached a stage only a person can render, and it is waiting on that answer. Every further '
|
|
1705
|
+
+ 'try and the cold leg would run the same job to the same question, so the loop stops here rather than '
|
|
1706
|
+
+ 'buying attempts nobody can grade yet. Nothing is lost: the run kept everything it did, and it resumes '
|
|
1707
|
+
+ 'from this checkpoint once the decision is in.',
|
|
1708
|
+
options: [
|
|
1709
|
+
'read the evidence package the pause recorded, then answer it and resume this run',
|
|
1710
|
+
'leave it: an unanswered checkpoint expires on its own once the pause TTL is up',
|
|
1711
|
+
],
|
|
1712
|
+
// NOT re-filed, for `interpreter-red`'s reason: the pause already escalated from
|
|
1713
|
+
// its own site inside the run, under this same name and with the evidence package
|
|
1714
|
+
// attached. A second copy here would count one person's pending decision twice.
|
|
1715
|
+
}, false);
|
|
1716
|
+
}
|
|
1498
1717
|
const decisions = {
|
|
1499
1718
|
'unapproved-spec': {
|
|
1500
1719
|
decision: `No approval record matches the per-try spec (hash ${trySpecHash}). Tightening the caps with an envelope makes a NEW spec version, and a new version is signed, not inherited — nothing was run.`,
|
|
@@ -1720,7 +1939,10 @@ export async function runReuse(opts) {
|
|
|
1720
1939
|
});
|
|
1721
1940
|
selection.push({ n, ...sel, listing: undefined });
|
|
1722
1941
|
account(sel.costUsd, sel.spendComplete);
|
|
1723
|
-
|
|
1942
|
+
// `quarantineSkips` rides the spine beside the candidates for the same reason it rides
|
|
1943
|
+
// the listing: a shelf that quietly shrank is indistinguishable from an empty one, and
|
|
1944
|
+
// "held, waiting on you" is the fact the operator needs to see (softgreen module 6)
|
|
1945
|
+
emit('selection-result', { n, choice: sel.choice, reason: sel.reason, called: sel.called, refused: sel.refused, costUsd: sel.costUsd, candidates: sel.candidates, quarantineSkips: sel.quarantineSkips });
|
|
1724
1946
|
|
|
1725
1947
|
if (sel.refused) {
|
|
1726
1948
|
return done('selection-refused', {
|
|
@@ -1774,3 +1996,199 @@ export async function runReuse(opts) {
|
|
|
1774
1996
|
+ ` close ${t.closeReached ? 'reached' : 'NEVER REACHED'}`).join('\n'),
|
|
1775
1997
|
});
|
|
1776
1998
|
}
|
|
1999
|
+
|
|
2000
|
+
// ── the GREEN's registry write, shared by both runners ──────────────────────
|
|
2001
|
+
|
|
2002
|
+
/** save a bridge result and BUILD the write record — the ONE spelling of the
|
|
2003
|
+
* write-record shape (`{name, action, file, reds, …extra}`), used by `writeGreenRow`
|
|
2004
|
+
* below AND by `runReuse`'s own red/casualty `commit`, so a field added to one can
|
|
2005
|
+
* never silently miss the other. Reporting (push + emit) stays each caller's.
|
|
2006
|
+
* @param {string} registryDir @param {{ok: boolean, reds: any[], bridge: any}} r
|
|
2007
|
+
* @param {string|null} name @param {string} action @param {object} [extra] */
|
|
2008
|
+
function commitWrite(registryDir, r, name, action, extra = {}) {
|
|
2009
|
+
if (!r.ok) return { name, action: 'none', file: null, reds: r.reds, ...extra };
|
|
2010
|
+
const s = saveBridge(registryDir, r.bridge);
|
|
2011
|
+
return { name: r.bridge.name, action: s.ok ? action : 'none', file: s.file, reds: s.reds, ...extra };
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/**
|
|
2015
|
+
* WHAT A GREEN WRITES, in ONE place. Lifted verbatim out of `runReuse`'s own
|
|
2016
|
+
* closure when the U runner needed it too (2B) — two spellings of "what a green
|
|
2017
|
+
* writes" would be the two-transforms class applied to the ledger itself, which is
|
|
2018
|
+
* the failure `greenParts` exists one level down to prevent.
|
|
2019
|
+
*
|
|
2020
|
+
* Two legs, and the split is which caller already holds an entry:
|
|
2021
|
+
* - A BRIDGE IN HAND (the reuse leg) APPENDS. No shape check is needed: `runJob`'s
|
|
2022
|
+
* load gate already proved at the door, before a token, that this entry is the
|
|
2023
|
+
* same kind of recipe as this job (a mismatch returns `recipe-stale` and never
|
|
2024
|
+
* gets here).
|
|
2025
|
+
* - NO BRIDGE (the cold leg, and every U run) mints — unless a name already holds
|
|
2026
|
+
* greens, in which case it APPENDS, because a cold run of a job that already has
|
|
2027
|
+
* a bridge is another version of that workflow and overwriting it would destroy
|
|
2028
|
+
* the green that minted it.
|
|
2029
|
+
*
|
|
2030
|
+
* But the name is the JOB's slug, and a job's CLOSE can be re-signed under that same
|
|
2031
|
+
* slug. Appending across two close shapes does two wrong things at once: it counts a
|
|
2032
|
+
* green another close rendered toward this entry's distinct-patient status (a false
|
|
2033
|
+
* `proven`), and it hands the load gate — which matches on exactly these stage names
|
|
2034
|
+
* — a plan that satisfied a different verification. So the shape is checked with the
|
|
2035
|
+
* gate's own predicate, and a mismatch neither appends NOR discards the green: it
|
|
2036
|
+
* forks to a deterministic derived name, with the fork ON the record.
|
|
2037
|
+
*
|
|
2038
|
+
* THE HOLD IS NOT DECIDED HERE. `quarantined` comes off `greenParts` reading the
|
|
2039
|
+
* class the run was signed under (`record.verdictType`, module 6), so a caller that
|
|
2040
|
+
* forgets to pass it mints an UNHELD green rather than a held one — which is why
|
|
2041
|
+
* both callers pass the spec's own field and never a literal.
|
|
2042
|
+
*
|
|
2043
|
+
* KNOWN LIMIT, named rather than discovered later: `appendGreen` carries the entry's
|
|
2044
|
+
* LABEL (`goal`, `closeStageNames`, `toolsUsed`) forward untouched, so a new version
|
|
2045
|
+
* whose plan uses a verb the founding green did not is not reflected in `toolsUsed`.
|
|
2046
|
+
* The load gate reads that field, so the gate's verb check goes slightly stale across
|
|
2047
|
+
* versions. It is not a safety hole — the gate only ever gets more permissive there,
|
|
2048
|
+
* and the TWEAKED plan still passes the full `validatePlan` against the job's signed
|
|
2049
|
+
* menu at draft time, which is the check that actually decides. Fixing it properly
|
|
2050
|
+
* means `appendGreen` taking a label update, which is module 1's contract and hamr's
|
|
2051
|
+
* call, not something to change from the caller.
|
|
2052
|
+
*
|
|
2053
|
+
* Returns the WRITE RECORD both runners report — never throws, and a refusal is a
|
|
2054
|
+
* record with `action: 'none'` and named reds rather than an absence a reader has to
|
|
2055
|
+
* branch on.
|
|
2056
|
+
* @param {{registryDir: string, bridge?: any|null, meta: any, record: any}} o
|
|
2057
|
+
* @returns {{name: string|null, action: string, file: string|null, reds: any[], [k: string]: any}}
|
|
2058
|
+
*/
|
|
2059
|
+
export function writeGreenRow({ registryDir, bridge = null, meta, record }) {
|
|
2060
|
+
/** the shared record builder, with this write's registry bound in
|
|
2061
|
+
* @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name
|
|
2062
|
+
* @param {string} action @param {object} [extra] */
|
|
2063
|
+
const commit = (r, name, action, extra = {}) => commitWrite(registryDir, r, name, action, extra);
|
|
2064
|
+
/** a refused write, recorded exactly like a committed one @param {string} name
|
|
2065
|
+
* @param {any} red @param {object} [extra] */
|
|
2066
|
+
const refuseWrite = (name, red, extra = {}) => ({ name, action: 'none', file: null, reds: [red], ...extra });
|
|
2067
|
+
|
|
2068
|
+
if (bridge) return commit(appendGreen(bridge, record), bridge.name, 'appendGreen');
|
|
2069
|
+
|
|
2070
|
+
const stageNames = Array.isArray(meta.closeStageNames) ? meta.closeStageNames : [];
|
|
2071
|
+
const entries = loadRegistry(registryDir).bridges;
|
|
2072
|
+
const existing = entries.find((/** @type {any} */ b) => b.name === meta.name);
|
|
2073
|
+
if (existing && sameCloseShape(existing, stageNames)) return commit(appendGreen(existing, record), meta.name, 'appendGreen');
|
|
2074
|
+
|
|
2075
|
+
const forked = existing !== undefined;
|
|
2076
|
+
const target = forked ? shapeForkName(meta.name, stageNames) : meta.name;
|
|
2077
|
+
/** the fork's own story, carried on every write record it produces */
|
|
2078
|
+
const mark = forked ? { shapeForked: true, forkedFrom: meta.name, closeStageNames: [...stageNames] } : {};
|
|
2079
|
+
const held = forked ? entries.find((/** @type {any} */ b) => b.name === target) : undefined;
|
|
2080
|
+
if (held) {
|
|
2081
|
+
// the derived name is derived FROM the shape, so a readable entry there matches by
|
|
2082
|
+
// construction; a mismatch would mean two different closes hashed the same, and
|
|
2083
|
+
// that is refused rather than appended blind
|
|
2084
|
+
return sameCloseShape(held, stageNames)
|
|
2085
|
+
? commit(appendGreen(held, record), target, 'appendGreen-shape-forked', mark)
|
|
2086
|
+
: refuseWrite(target, { code: 'shape-fork-collision', path: `${target}.json`, detail: `the derived name for close [${stageNames.join(' → ')}] is already held by an entry of a different shape — refusing to append this green to a recipe another close greened` }, mark);
|
|
2087
|
+
}
|
|
2088
|
+
// A file of that name that `loadRegistry` could not READ is not an absent entry — it
|
|
2089
|
+
// is an entry nobody can see, and minting over it destroys whatever greens it held.
|
|
2090
|
+
// `loadRegistry` skips-and-reports for that reason; the mint must not undo the skip.
|
|
2091
|
+
if (existsSync(join(registryDir, `${target}.json`))) {
|
|
2092
|
+
return refuseWrite(target, { code: 'mint-collision', path: `${target}.json`, detail: 'a file of this name exists but could not be read as a bridge — refusing to mint over it, because whatever greens it holds are not recoverable once overwritten. Repair or move it, then re-run.' }, mark);
|
|
2093
|
+
}
|
|
2094
|
+
return commit(mintBridge({ ...meta, name: target }, record), target, forked ? 'mint-shape-forked' : 'mint', mark);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* THE U RUNNER'S TERMINAL WRITE (2B) — does the run that just ended earn a registry
|
|
2099
|
+
* row, and if so, write it.
|
|
2100
|
+
*
|
|
2101
|
+
* `scripts/run-u.mjs` has always kept standalone bridge FILES and never a registry,
|
|
2102
|
+
* so its review door could describe a held learning credit it had no row to release:
|
|
2103
|
+
* `--decide accept` reached `recordDoor`, found no green row for the runid and died
|
|
2104
|
+
* `no-row-for-run`. This is the row, and STORAGE IS ALL IT IS — selection, promotion
|
|
2105
|
+
* and reuse execution stay parked (layer-3-reuse). Nothing here reads a row back.
|
|
2106
|
+
*
|
|
2107
|
+
* FOUR REFUSALS, each one a guard that already existed somewhere and is kept:
|
|
2108
|
+
* - NO REGISTRY, no row. The registry path is operator-supplied and never
|
|
2109
|
+
* conjured; without one the door says, in words, that nothing was released.
|
|
2110
|
+
* - ALREADY-GREEN mints nothing. The close was satisfied before this run did
|
|
2111
|
+
* anything, so nothing here earned a version — the reuse-credit leak block, from
|
|
2112
|
+
* the writing side rather than the door's.
|
|
2113
|
+
* - A GREEN WITH NO PLAN mints nothing. The artifact that inherits is the one that
|
|
2114
|
+
* RAN, and there is none (R1).
|
|
2115
|
+
* - A CLASS WHOSE CREDIT IS NOT HELD mints nothing HERE. That is `green` today, and
|
|
2116
|
+
* it is main's behaviour kept rather than a new rule: this runner has never
|
|
2117
|
+
* written a registry row for a green-class run, and widening what enters the
|
|
2118
|
+
* registry is a reuse-rung question, not a side effect of fixing a door. The
|
|
2119
|
+
* predicate is `quarantinesCredit` — the ONE spelling of "is this class held" —
|
|
2120
|
+
* so the row this runner mints and the row a door releases can never disagree
|
|
2121
|
+
* about which classes they are talking about.
|
|
2122
|
+
*
|
|
2123
|
+
* The row is born HELD for exactly the reason any judged green is: `greenParts` reads
|
|
2124
|
+
* `verdictType` off the record. Nothing here sets the flag.
|
|
2125
|
+
* @param {{registryDir: string|null|undefined, job: any, name: string|null,
|
|
2126
|
+
* outcome: string, plan: any, record: any}} o
|
|
2127
|
+
* @returns {{minted: boolean, reason: string|null, write: any|null}}
|
|
2128
|
+
*/
|
|
2129
|
+
export function writeRunGreenRow({ registryDir, job, name, outcome, plan, record }) {
|
|
2130
|
+
if (!isNonEmptyString(registryDir)) return { minted: false, reason: 'no-registry', write: null };
|
|
2131
|
+
if (outcome !== 'green') {
|
|
2132
|
+
return { minted: false, reason: outcome === 'already-green' ? 'green-predates-run' : 'not-green', write: null };
|
|
2133
|
+
}
|
|
2134
|
+
if (!isObj(plan)) return { minted: false, reason: 'no-plan-executed', write: null };
|
|
2135
|
+
if (!quarantinesCredit(job?.verdictType)) return { minted: false, reason: 'credit-not-held', write: null };
|
|
2136
|
+
|
|
2137
|
+
const target = isNonEmptyString(name) ? name : job.job;
|
|
2138
|
+
const meta = {
|
|
2139
|
+
name: target,
|
|
2140
|
+
goal: job.goal,
|
|
2141
|
+
specHash: record?.specHash ?? null,
|
|
2142
|
+
closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
|
|
2143
|
+
toolsUsed: [...new Set((plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
|
|
2144
|
+
};
|
|
2145
|
+
const write = writeGreenRow({
|
|
2146
|
+
registryDir,
|
|
2147
|
+
bridge: null,
|
|
2148
|
+
meta,
|
|
2149
|
+
// the class the run was SIGNED under, from the spec — never a literal, so the
|
|
2150
|
+
// hold and the class can never drift apart
|
|
2151
|
+
record: { ...record, plan, verdictType: job.verdictType },
|
|
2152
|
+
});
|
|
2153
|
+
return { minted: write.action !== 'none', reason: write.action === 'none' ? 'write-refused' : null, write };
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// ── the REVIEW DOOR's registry half (softgreen module 6) ────────────────────
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* Apply a signer's door decision to the workflow a run wrote its row against:
|
|
2160
|
+
* record the disposition, and — on `accept` over a HELD judged green — release the
|
|
2161
|
+
* learning credit that green has been carrying unspent (PRD v1.71 §3).
|
|
2162
|
+
*
|
|
2163
|
+
* This is the ONE seam between a person's answer and the box. It is deliberately
|
|
2164
|
+
* separate from `runReuse`: the door opens AFTER the run has ended (a green run is
|
|
2165
|
+
* over, non-blocking, and the person may answer minutes or days later), so the
|
|
2166
|
+
* decision cannot ride the run's own return path. The runner calls this with the
|
|
2167
|
+
* runid it printed at the door.
|
|
2168
|
+
*
|
|
2169
|
+
* Never throws and never conjures: a missing registry, a missing entry, a
|
|
2170
|
+
* malformed one and a runid with no green row all come back as named reds. The
|
|
2171
|
+
* loop's verdict is untouched in every branch — this writes a disposition, never a
|
|
2172
|
+
* result.
|
|
2173
|
+
*
|
|
2174
|
+
* @param {object} opts
|
|
2175
|
+
* @param {string} opts.registryDir the operator-supplied registry directory
|
|
2176
|
+
* @param {string} opts.name the workflow the run wrote to
|
|
2177
|
+
* @param {string} opts.runid the RUN's id, exactly as the bridge row records it
|
|
2178
|
+
* @param {string} opts.decision one of the three doors
|
|
2179
|
+
* @param {string|null} [opts.at] when the person answered
|
|
2180
|
+
* @returns {{ok: boolean, reds: any[], released: boolean, file: string|null}}
|
|
2181
|
+
*/
|
|
2182
|
+
export function applyDoorDecision({ registryDir, name, runid, decision, at = null }) {
|
|
2183
|
+
if (!isNonEmptyString(registryDir) || !registryExists(registryDir)) {
|
|
2184
|
+
return { ok: false, reds: [{ code: 'registry-missing', path: String(registryDir), detail: 'the registry path is operator-supplied and is never conjured' }], released: false, file: null };
|
|
2185
|
+
}
|
|
2186
|
+
if (!isNonEmptyString(name)) return { ok: false, reds: [{ code: 'invalid-value', path: 'name', detail: 'the workflow this run wrote its row against' }], released: false, file: null };
|
|
2187
|
+
const file = join(registryDir, `${name}.json`);
|
|
2188
|
+
const loaded = loadBridge(file);
|
|
2189
|
+
if (!loaded.ok) return { ok: false, reds: loaded.reds, released: false, file: null };
|
|
2190
|
+
const r = recordDoor(loaded.bridge, { runid, decision, at });
|
|
2191
|
+
if (!r.ok) return { ok: false, reds: r.reds, released: false, file: null };
|
|
2192
|
+
const s = saveBridge(registryDir, r.bridge);
|
|
2193
|
+
return { ok: s.ok, reds: s.reds, released: s.ok && r.released, file: s.file };
|
|
2194
|
+
}
|