ruvnet-brain 4.3.35 → 4.3.36
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/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/coverage-integrity.mjs +58 -2
- package/scripts/build-bundle.mjs +27 -1
- package/scripts/code-release-corpus.mjs +276 -0
- package/scripts/corpus-candidate.mjs +19 -3
- package/scripts/corpus-coverage-sidecar.mjs +202 -0
- package/scripts/corpus-currency.mjs +71 -0
- package/scripts/corpus-dispatch-decision.mjs +138 -0
- package/scripts/corpus-next-seed.mjs +69 -12
- package/scripts/corpus-reconcile.mjs +340 -61
- package/scripts/corpus-store-failure.mjs +82 -0
- package/scripts/corpus-watchdog.mjs +334 -0
- package/scripts/fixture-denominator.mjs +72 -0
- package/scripts/knowledge-input-digest.mjs +163 -0
- package/scripts/oracle/repo-recall.mjs +142 -21
- package/scripts/public-verification-inputs.mjs +18 -2
- package/scripts/rehearse-corpus-pipeline.mjs +350 -20
- package/scripts/release.mjs +156 -18
- package/scripts/retrieval-canary.mjs +42 -20
- package/scripts/source-coverage.mjs +19 -3
|
@@ -16,11 +16,15 @@ import { buildCoverage, observeSourceUniverse, renderMarkdown } from './source-c
|
|
|
16
16
|
import { promoteArtifactSet } from '../kb/incremental-refresh.mjs';
|
|
17
17
|
import { rebuildCorpusAggregates } from './corpus-aggregates.mjs';
|
|
18
18
|
import { assertCapabilityOnlyStore, isCapabilityOnly, CAPABILITY_RETIRED_SUFFIXES } from '../kb/capability-only.mjs';
|
|
19
|
-
import { fileIdentity } from '../plugin/scripts/coverage-integrity.mjs';
|
|
19
|
+
import { eligibleRepositoryStanding, fileIdentity, validateCoverageLedger } from '../plugin/scripts/coverage-integrity.mjs';
|
|
20
|
+
import {
|
|
21
|
+
CORPUS_QA_FAILED_EXIT, FAILURE_CLASS, StoreWorkerError, degradedBound, degradedPublication, failureReason, isRetryable,
|
|
22
|
+
} from './corpus-store-failure.mjs';
|
|
20
23
|
import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
|
|
21
24
|
import { storeRoot } from '../kb/store-root.mjs';
|
|
22
25
|
import { captureGistSources } from './gist-receipts.mjs';
|
|
23
26
|
import { projectSourceStore, RUNTIME_LEDGER_KIND } from './rvf-generation.mjs';
|
|
27
|
+
import { compareKnowledgeInputs, fromCoverage as knowledgeFromCoverage, fromSeed as knowledgeFromSeed } from './knowledge-input-digest.mjs';
|
|
24
28
|
|
|
25
29
|
export { rebuildCorpusAggregates };
|
|
26
30
|
|
|
@@ -128,6 +132,10 @@ function filesNamed(root, wanted) {
|
|
|
128
132
|
// corpus-seed.yml can re-run seed extraction ONCE from the committed bootstrap in the same job.
|
|
129
133
|
export const SEED_LEDGER_SCHEMA_VERSION = 2;
|
|
130
134
|
export const SEED_LEDGER_INCOMPATIBLE_EXIT = 3;
|
|
135
|
+
// ADR-0091 D5 + D10: the generation was built and SEALED with carried/missing stores, but degraded
|
|
136
|
+
// publication is not yet allowed (no soaked tolerant-validator transition). It must not be published;
|
|
137
|
+
// the next night re-plans the carried stores automatically because their sourceCommit still differs.
|
|
138
|
+
export const DEGRADED_UNPUBLISHED_EXIT = 4;
|
|
131
139
|
|
|
132
140
|
export class SeedLedgerIncompatibleError extends Error {
|
|
133
141
|
constructor(reason) {
|
|
@@ -291,7 +299,7 @@ async function measureFreshness({ closingObservation, observation }) {
|
|
|
291
299
|
* generation; `latest` is never substituted, and an exhausted partial generation is never accepted.
|
|
292
300
|
*/
|
|
293
301
|
export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = null, observe, build,
|
|
294
|
-
readLedger: currentLedger, execute, prune, rebuild, preflight = null, closingObservation = null } = {}) {
|
|
302
|
+
readLedger: currentLedger, execute, prune, rebuild, preflight = null, closingObservation = null, unchanged = null } = {}) {
|
|
295
303
|
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10
|
|
296
304
|
|| [observe, build, currentLedger, execute, prune, rebuild].some((fn) => typeof fn !== 'function')) {
|
|
297
305
|
fail('bounded acquisition configuration is invalid');
|
|
@@ -299,15 +307,33 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
|
|
|
299
307
|
// ONE discovery pass. This observation is the sealed manifest every later step consumes; it is never
|
|
300
308
|
// re-taken, so upstream churn cannot restart or invalidate the generation.
|
|
301
309
|
const observation = await observe();
|
|
310
|
+
// NO-CHANGE, DECIDED BEFORE ANYTHING IS BUILT (2026-09-29 nightly redesign). When every knowledge
|
|
311
|
+
// input equals the seed's (scripts/knowledge-input-digest.mjs), the night ends here: no gist
|
|
312
|
+
// preflight, no clone, no embedding, no aggregate rebuild, nothing sealed or published.
|
|
313
|
+
if (typeof unchanged === 'function') {
|
|
314
|
+
const knowledgeInput = await unchanged(observation);
|
|
315
|
+
if (knowledgeInput?.unchanged === true) {
|
|
316
|
+
return { noChange: true, observation, attempts: [], consistencyModel: CONSISTENCY_MODEL, knowledgeInput };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
302
319
|
// Validate/fetch the source most likely to fail late (gist detail/raw access) before any expensive
|
|
303
320
|
// repository clone and embedding work. Its verified bodies are the existing capture cache consumed
|
|
304
321
|
// by the later aggregate build, so preflight does not double-fetch or weaken source binding.
|
|
305
322
|
const preflightResult = typeof preflight === 'function' ? await preflight(observation) : null;
|
|
306
323
|
const attempts = [];
|
|
324
|
+
// ADR-0091 D5: stores whose refresh FAILED this generation, keyed by folded store name, with what
|
|
325
|
+
// they became -- { carry } | { failure } | { integrity }. They are never re-executed by a later
|
|
326
|
+
// attempt of this loop and never count as remaining/unresolved: before D5 one stuck store cost all
|
|
327
|
+
// three attempts (each re-running a ~22-minute aggregate rebuild) and then failed the night anyway.
|
|
328
|
+
// The loop now re-attempts only for what it was built for: a gist revision that moved mid-fetch.
|
|
329
|
+
const storeOutcomes = {};
|
|
330
|
+
const recorded = (store) => Object.hasOwn(storeOutcomes, String(store || '').toLowerCase());
|
|
307
331
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
308
|
-
const coverage = await build(observation);
|
|
309
|
-
const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir });
|
|
332
|
+
const coverage = await build(observation, storeOutcomes);
|
|
333
|
+
const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir }).filter((item) => !recorded(item.store));
|
|
310
334
|
const reconciliation = await execute(plan, attempt);
|
|
335
|
+
recordStoreOutcomes(storeOutcomes, reconciliation);
|
|
336
|
+
assertIsolatedFailures({ coverage, storeOutcomes });
|
|
311
337
|
const pruning = await prune(coverage, attempt);
|
|
312
338
|
let aggregates;
|
|
313
339
|
try {
|
|
@@ -327,14 +353,17 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
|
|
|
327
353
|
// row still pinned the PRE-rebuild ruv-gists digest and build-bundle refused the candidate with
|
|
328
354
|
// "coverage row gist:... was measured against different ruv-gists RVF bytes than this corpus
|
|
329
355
|
// carries", 56 minutes into an otherwise complete run.
|
|
330
|
-
const settled = await build(observation);
|
|
331
|
-
const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir })
|
|
332
|
-
|
|
356
|
+
const settled = await build(observation, storeOutcomes);
|
|
357
|
+
const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir })
|
|
358
|
+
.filter((item) => !recorded(item.store));
|
|
359
|
+
const unresolved = settled.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT'
|
|
360
|
+
&& !(row.kind === 'repository' && recorded(row.artifact?.store)));
|
|
333
361
|
attempts.push({ attempt, plan, ...reconciliation, ...pruning, ...aggregates,
|
|
334
362
|
remainingArtifacts: remaining.length, unresolvedSources: unresolved.length });
|
|
335
363
|
if (!remaining.length && !unresolved.length) {
|
|
336
364
|
return {
|
|
337
365
|
observation, coverage: settled, attempts, consistencyModel: CONSISTENCY_MODEL,
|
|
366
|
+
degraded: degradedSummary(storeOutcomes),
|
|
338
367
|
freshness: await measureFreshness({ closingObservation, observation }),
|
|
339
368
|
};
|
|
340
369
|
}
|
|
@@ -345,6 +374,45 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
|
|
|
345
374
|
+ 'eligible source(s) remain unresolved against the sealed manifest');
|
|
346
375
|
}
|
|
347
376
|
|
|
377
|
+
function recordStoreOutcomes(storeOutcomes, reconciliation) {
|
|
378
|
+
for (const { store, carry } of reconciliation?.carried || []) storeOutcomes[store.toLowerCase()] = { carry };
|
|
379
|
+
for (const { store, failure } of reconciliation?.missing || []) storeOutcomes[store.toLowerCase()] = { failure };
|
|
380
|
+
for (const { store, integrity } of reconciliation?.integrityFailures || []) storeOutcomes[store.toLowerCase()] = { integrity };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function degradedSummary(storeOutcomes) {
|
|
384
|
+
const entries = Object.entries(storeOutcomes || {}).sort(([a], [b]) => a.localeCompare(b));
|
|
385
|
+
return {
|
|
386
|
+
carried: entries.filter(([, outcome]) => outcome.carry).map(([store, outcome]) => ({ store, ...outcome.carry })),
|
|
387
|
+
missing: entries.filter(([, outcome]) => outcome.failure).map(([store, outcome]) => ({ store, ...outcome.failure })),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Fail the generation the moment isolation stops being the right answer, BEFORE pruning and the
|
|
393
|
+
* ~22-minute aggregate rebuild are spent on it:
|
|
394
|
+
* - any integrity failure (carried bytes that no longer match the seed ledger, or a same-commit
|
|
395
|
+
* rebuild that failed) -- such a row is FAILED, and FAILED is never shippable, so no bound can
|
|
396
|
+
* admit it;
|
|
397
|
+
* - more carried + missing stores than max(3, 5% of eligible) -- the failure is systemic, and
|
|
398
|
+
* publishing around it would hide a forge or network regression.
|
|
399
|
+
*/
|
|
400
|
+
export function assertIsolatedFailures({ coverage, storeOutcomes }) {
|
|
401
|
+
const outcomes = Object.entries(storeOutcomes || {});
|
|
402
|
+
const integrity = outcomes.filter(([, outcome]) => outcome.integrity);
|
|
403
|
+
if (integrity.length) {
|
|
404
|
+
fail(`integrity failure in ${integrity.length} store(s): `
|
|
405
|
+
+ `${integrity.map(([store, outcome]) => `${store} (${outcome.integrity})`).join('; ')} -- the generation fails`);
|
|
406
|
+
}
|
|
407
|
+
const eligible = (coverage?.rows || []).filter((row) => row.kind === 'repository' && row.disposition === 'eligible').length;
|
|
408
|
+
const isolated = outcomes.length;
|
|
409
|
+
const bound = degradedBound(eligible);
|
|
410
|
+
if (isolated > bound) {
|
|
411
|
+
fail(`systemic failure: ${isolated} of ${eligible} eligible store(s) failed to refresh `
|
|
412
|
+
+ `(${outcomes.map(([store]) => store).join(', ')}); the bound is max(3, 5% of eligible) = ${bound} -- the generation fails`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
348
416
|
/**
|
|
349
417
|
* The ONE reader of an acquisition result's per-attempt history (ADR-0091 D1).
|
|
350
418
|
*
|
|
@@ -369,6 +437,12 @@ export function summarizeReconciliation(reconciliation) {
|
|
|
369
437
|
refreshed: across('refreshed'),
|
|
370
438
|
pruned: across('pruned'),
|
|
371
439
|
rebuilt: across('rebuilt'),
|
|
440
|
+
// ADR-0091 D5: the stores this generation carries (STALE) or lacks (MISSING) after an isolated
|
|
441
|
+
// refresh failure. Empty lists on an all-CURRENT generation.
|
|
442
|
+
degraded: {
|
|
443
|
+
carried: reconciliation.degraded?.carried || [],
|
|
444
|
+
missing: reconciliation.degraded?.missing || [],
|
|
445
|
+
},
|
|
372
446
|
};
|
|
373
447
|
}
|
|
374
448
|
|
|
@@ -385,7 +459,7 @@ function checked(run, command, args, options = {}) {
|
|
|
385
459
|
return result;
|
|
386
460
|
}
|
|
387
461
|
|
|
388
|
-
function defaultRunAsync(command, args, options = {}) {
|
|
462
|
+
export function defaultRunAsync(command, args, options = {}) {
|
|
389
463
|
return new Promise((resolve) => {
|
|
390
464
|
const inherited = options.stdio === 'inherit';
|
|
391
465
|
const child = spawn(command, args, { ...options, encoding: undefined,
|
|
@@ -401,15 +475,6 @@ function defaultRunAsync(command, args, options = {}) {
|
|
|
401
475
|
});
|
|
402
476
|
}
|
|
403
477
|
|
|
404
|
-
async function checkedAsync(run, command, args, options = {}) {
|
|
405
|
-
const result = await run(command, args, options) || {};
|
|
406
|
-
if (result.error || result.status !== 0) {
|
|
407
|
-
const detail = String(result.stderr || result.stdout || result.error?.message || `exit ${result.status}`).trim();
|
|
408
|
-
fail(`${command} ${args.join(' ')} failed${detail ? ` (${detail})` : ''}`);
|
|
409
|
-
}
|
|
410
|
-
return result;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
478
|
const storeArtifacts = (store) => STORE_ARTIFACT_SUFFIXES.map((suffix) => `${store}${suffix}`);
|
|
414
479
|
|
|
415
480
|
function writeJsonAtomic(file, value) {
|
|
@@ -505,6 +570,81 @@ function validateWorkerOutput({ output, item }) {
|
|
|
505
570
|
return { ...payload, receiptSha256: crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'), output };
|
|
506
571
|
}
|
|
507
572
|
|
|
573
|
+
// ADR-0091 D5: the commit date of the bytes a carried store keeps, when the PREVIOUS generation's
|
|
574
|
+
// sealed coverage proves it. A row whose observed upstream SHA IS the carried commit dates that exact
|
|
575
|
+
// commit; a row that was itself carried passes its own carriedCommittedAt on. Anything else -- no
|
|
576
|
+
// prior coverage (the bootstrap lineage), an invalid one, a different commit -- is null, never
|
|
577
|
+
// estimated (D7.1 reads this for `oldestCarried`).
|
|
578
|
+
export function readPriorCoverage(assetsDir) {
|
|
579
|
+
const file = path.join(path.resolve(assetsDir || ''), 'CORPUS-COVERAGE.json');
|
|
580
|
+
if (!fs.existsSync(file)) return null;
|
|
581
|
+
try {
|
|
582
|
+
const coverage = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
583
|
+
return coverage?.kind === 'ruvnet-brain-corpus-coverage' && validateCoverageLedger(coverage).valid ? coverage : null;
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function carriedCommittedAt({ priorCoverage, store, sourceCommit }) {
|
|
590
|
+
const folded = String(store || '').toLowerCase();
|
|
591
|
+
const commit = String(sourceCommit || '').toLowerCase();
|
|
592
|
+
const row = (priorCoverage?.rows || []).find((candidate) => candidate?.kind === 'repository'
|
|
593
|
+
&& String(candidate?.artifact?.store || '').toLowerCase() === folded);
|
|
594
|
+
const iso = (value) => (typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null);
|
|
595
|
+
if (!row || !HEX40.test(commit)) return null;
|
|
596
|
+
if (String(row.upstream?.sha || '').toLowerCase() === commit) return iso(row.upstream?.committedAt);
|
|
597
|
+
if (String(row.carry?.carriedSourceCommit || '').toLowerCase() === commit) return iso(row.carry?.carriedCommittedAt);
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ADR-0091 D5: what a store whose refresh FAILED becomes. planReconciliation's byte check never
|
|
602
|
+
// covers a store it plans (it runs only when sourceCommit already equals upstream), so the seed
|
|
603
|
+
// bytes are re-hashed HERE before they may stand in for the missed refresh. The ledger binds one file
|
|
604
|
+
// per store (the .big.rvf: file, bytes, sha256); the rest of the family must be present as regular
|
|
605
|
+
// files. The seed archive itself was digest-verified on download (assertBootstrapIdentity).
|
|
606
|
+
export function dispositionForFailedStore({ assetsDir, ledger, item, attempts, reason, priorCoverage = null }) {
|
|
607
|
+
const assets = path.resolve(assetsDir || '');
|
|
608
|
+
const generation = Object.entries(ledger?.stores || {})
|
|
609
|
+
.find(([name]) => name.toLowerCase() === item.store.toLowerCase())?.[1] || null;
|
|
610
|
+
if (!generation) return { store: item.store, failure: { reason, attempts } };
|
|
611
|
+
const carried = String(generation.sourceCommit || '').toLowerCase();
|
|
612
|
+
if (!HEX40.test(carried)) {
|
|
613
|
+
return { store: item.store, integrity: 'carried bytes have no exact 40-hex ledger sourceCommit to carry' };
|
|
614
|
+
}
|
|
615
|
+
if (carried === item.upstreamSha) {
|
|
616
|
+
// Planned for a policy or receipt reason at the SAME commit (a capability-only clean rebuild, or
|
|
617
|
+
// seed bytes that already failed the receipt check): the seed bytes are exactly what the rebuild
|
|
618
|
+
// was meant to replace, so they cannot stand in for it.
|
|
619
|
+
return { store: item.store, integrity: 'the refresh was a same-commit rebuild the seed bytes cannot stand in for' };
|
|
620
|
+
}
|
|
621
|
+
const regular = (name) => {
|
|
622
|
+
try { const stat = fs.lstatSync(path.join(assets, name)); return stat.isFile() && !stat.isSymbolicLink(); }
|
|
623
|
+
catch { return false; }
|
|
624
|
+
};
|
|
625
|
+
const rvf = `${item.store}.big.rvf`;
|
|
626
|
+
const bound = generation.file === rvf && regular(rvf)
|
|
627
|
+
&& generation.bytes === fs.statSync(path.join(assets, rvf)).size
|
|
628
|
+
&& generation.sha256 === sha256File(path.join(assets, rvf))
|
|
629
|
+
&& REQUIRED_STORE_ARTIFACT_SUFFIXES.every((suffix) => regular(`${item.store}${suffix}`));
|
|
630
|
+
if (!bound) return { store: item.store, integrity: 'carried bytes differ from the seed generation ledger' };
|
|
631
|
+
return { store: item.store, carry: {
|
|
632
|
+
reason,
|
|
633
|
+
carriedSourceCommit: carried,
|
|
634
|
+
missedUpstream: item.upstreamSha,
|
|
635
|
+
attempts,
|
|
636
|
+
carriedCommittedAt: carriedCommittedAt({ priorCoverage, store: item.store, sourceCommit: carried }),
|
|
637
|
+
} };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Only a TRANSIENT failure is retried, exactly once, in a fresh directory: `<store>-retry1`. The first
|
|
641
|
+
// attempt's directory is never reused -- a clone or a half-written worker output from the failed
|
|
642
|
+
// attempt must not be mistaken for the retry's own (the pre-D5 path collided).
|
|
643
|
+
export function workerRootFor(workspace, store, retry) {
|
|
644
|
+
return path.join(workspace, 'workers', retry === 0 ? store : `${store}-retry${retry}`);
|
|
645
|
+
}
|
|
646
|
+
export const MAX_TRANSIENT_RETRIES = 1;
|
|
647
|
+
|
|
508
648
|
export async function executeReconciliation({
|
|
509
649
|
plan,
|
|
510
650
|
assetsDir,
|
|
@@ -513,6 +653,8 @@ export async function executeReconciliation({
|
|
|
513
653
|
run = defaultRunAsync,
|
|
514
654
|
concurrency = 5,
|
|
515
655
|
signal,
|
|
656
|
+
priorCoverage = null,
|
|
657
|
+
log = (line) => console.log(line),
|
|
516
658
|
}) {
|
|
517
659
|
if (!Array.isArray(plan) || !Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 10) {
|
|
518
660
|
fail('reconciliation plan or worker concurrency is invalid');
|
|
@@ -532,50 +674,94 @@ export async function executeReconciliation({
|
|
|
532
674
|
const lowerStores = orderedPlan.map(({ store }) => store.toLowerCase());
|
|
533
675
|
if (new Set(lowerStores).size !== lowerStores.length) fail('reconciliation plan has duplicate or case-fold-colliding stores');
|
|
534
676
|
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
// those orphaned lanes could then surface as an unhandled rejection, or simply keep doing
|
|
541
|
-
// unnecessary work after the round was already lost. Now: the first failure aborts the shared
|
|
542
|
-
// signal, every lane observes it (both at its own loop-top and via the signal threaded into every
|
|
543
|
-
// child-process spawn below) and returns promptly, and `Promise.all` -- which no lane's promise
|
|
544
|
-
// ever rejects out of directly -- only resolves once every lane has actually stopped. Only then do
|
|
545
|
-
// we throw the FIRST real error (an aborted sibling's own error is discarded, never overwrites it).
|
|
546
|
-
// An externally supplied `signal` (a caller discarding this whole round) aborts the same
|
|
547
|
-
// controller, so both cancellation paths join through the one place.
|
|
677
|
+
// ADR-0091 D5: ONE store failing no longer aborts the round. Before D5 the first worker error
|
|
678
|
+
// aborted every sibling (2 of 9 corpus runs died that way: one deterministic `ruvector` QA miss
|
|
679
|
+
// threw away 93 other stores' refreshes). Now each store's failure is recorded and its lane moves
|
|
680
|
+
// on; the shared AbortController below exists ONLY for an externally supplied `signal` -- a caller
|
|
681
|
+
// discarding the whole round -- which still stops and joins every lane (required proof 4).
|
|
548
682
|
const controller = new AbortController();
|
|
549
683
|
if (signal) {
|
|
550
684
|
if (signal.aborted) controller.abort(signal.reason);
|
|
551
685
|
else signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
|
|
552
686
|
}
|
|
553
687
|
|
|
554
|
-
const
|
|
688
|
+
const stage = async (item, name, classify, command, args, options = {}) => {
|
|
689
|
+
const result = await run(command, args, { ...options, signal: controller.signal }) || {};
|
|
690
|
+
if (controller.signal.aborted) throw abortError(controller.signal);
|
|
691
|
+
if (result.error || result.status !== 0) {
|
|
692
|
+
const detail = String(result.stderr || result.stdout || result.error?.message || `exit ${result.status}`).trim().slice(0, 400);
|
|
693
|
+
throw new StoreWorkerError({ store: item.store, stage: name, failureClass: classify(result), detail });
|
|
694
|
+
}
|
|
695
|
+
return result;
|
|
696
|
+
};
|
|
697
|
+
const transient = () => FAILURE_CLASS.TRANSIENT;
|
|
698
|
+
// forge-refresh's exit status is the structured reason: CORPUS_QA_FAILED_EXIT means corpus-qa refused
|
|
699
|
+
// the candidate (deterministic, never retried); a spawn error is runner I/O (transient); any other
|
|
700
|
+
// non-zero exit is a build failure (not retried: a retry is a full re-embed with no reason to differ).
|
|
701
|
+
const forgeClass = (result) => (result.status === CORPUS_QA_FAILED_EXIT ? FAILURE_CLASS.QA
|
|
702
|
+
: result.error ? FAILURE_CLASS.TRANSIENT : FAILURE_CLASS.BUILD);
|
|
703
|
+
|
|
704
|
+
const worker = async (item, retry) => {
|
|
555
705
|
if (controller.signal.aborted) throw abortError(controller.signal);
|
|
556
706
|
if (!SAFE_STORE.test(item.store) || !HEX40.test(item.upstreamSha) || !repositorySlug(item.url)) {
|
|
557
|
-
|
|
707
|
+
throw new StoreWorkerError({ store: item?.store || item?.name || 'unknown store', stage: 'plan item',
|
|
708
|
+
failureClass: FAILURE_CLASS.INTEGRITY, detail: 'unsafe reconciliation item' });
|
|
709
|
+
}
|
|
710
|
+
const workerRoot = workerRootFor(workspace, item.store, retry);
|
|
711
|
+
if (fs.existsSync(workerRoot)) {
|
|
712
|
+
throw new StoreWorkerError({ store: item.store, stage: 'worker directory', failureClass: FAILURE_CLASS.INTEGRITY,
|
|
713
|
+
detail: 'a fresh worker directory already exists' });
|
|
558
714
|
}
|
|
559
|
-
const workerRoot = path.join(workspace, 'workers', item.store);
|
|
560
715
|
const cloneDir = path.join(workerRoot, 'clone');
|
|
561
716
|
const output = path.join(workerRoot, 'assets');
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
717
|
+
try {
|
|
718
|
+
fs.mkdirSync(workerRoot, { recursive: true });
|
|
719
|
+
seedWorkerAssets({ assets, output, store: item.store, ledger: canonicalLedger, source: canonicalSource });
|
|
720
|
+
} catch (error) {
|
|
721
|
+
// An errno (disk, file table) is runner I/O; our own refusal is an integrity failure.
|
|
722
|
+
throw new StoreWorkerError({ store: item.store, stage: 'worker seed copy',
|
|
723
|
+
failureClass: typeof error?.code === 'string' ? FAILURE_CLASS.TRANSIENT : FAILURE_CLASS.INTEGRITY, detail: error.message });
|
|
724
|
+
}
|
|
725
|
+
await stage(item, 'git clone', transient, 'git', ['clone', '--no-checkout', '--filter=blob:none', item.url, cloneDir]);
|
|
726
|
+
await stage(item, 'git fetch', transient, 'git', ['-C', cloneDir, 'fetch', '--depth=1', 'origin', item.upstreamSha]);
|
|
727
|
+
await stage(item, 'git checkout', transient, 'git', ['-C', cloneDir, 'checkout', '--detach', 'FETCH_HEAD']);
|
|
728
|
+
const head = await stage(item, 'git rev-parse', transient, 'git', ['-C', cloneDir, 'rev-parse', 'HEAD']);
|
|
568
729
|
if (String(head.stdout || '').trim().toLowerCase() !== item.upstreamSha) {
|
|
569
|
-
|
|
730
|
+
throw new StoreWorkerError({ store: item.store, stage: 'exact-sha checkout', failureClass: FAILURE_CLASS.INTEGRITY,
|
|
731
|
+
detail: 'fresh clone did not resolve the exact upstream SHA' });
|
|
570
732
|
}
|
|
571
|
-
await
|
|
733
|
+
await stage(item, 'forge-refresh', forgeClass, process.execPath, [forge, '--repo', cloneDir, '--out', output, '--name', item.store,
|
|
572
734
|
...(FULL_HINTS[item.store] ? ['--full', FULL_HINTS[item.store]] : []),
|
|
573
735
|
...(KEEP_DIRS[item.store] ? ['--keep', KEEP_DIRS[item.store]] : []),
|
|
574
|
-
], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' }
|
|
575
|
-
|
|
736
|
+
], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' } });
|
|
737
|
+
try {
|
|
738
|
+
return validateWorkerOutput({ output, item });
|
|
739
|
+
} catch (error) {
|
|
740
|
+
throw new StoreWorkerError({ store: item.store, stage: 'worker output validation', failureClass: FAILURE_CLASS.INTEGRITY,
|
|
741
|
+
detail: error.message });
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const runStore = async (item) => {
|
|
746
|
+
let lastError = null;
|
|
747
|
+
let attempts = 0;
|
|
748
|
+
for (let retry = 0; retry <= MAX_TRANSIENT_RETRIES; retry += 1) {
|
|
749
|
+
attempts += 1;
|
|
750
|
+
try {
|
|
751
|
+
return { ok: true, attempts, result: await worker(item, retry) };
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (controller.signal.aborted) throw error;
|
|
754
|
+
lastError = error;
|
|
755
|
+
const retrying = isRetryable(error) && retry < MAX_TRANSIENT_RETRIES;
|
|
756
|
+
log(`[corpus-reconcile] ${item.store}: attempt ${attempts} failed -- ${error.message}`
|
|
757
|
+
+ (retrying ? '; retrying once in a fresh worker directory' : '; not retried'));
|
|
758
|
+
if (!retrying) break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return { ok: false, attempts, error: lastError };
|
|
576
762
|
};
|
|
577
763
|
|
|
578
|
-
const
|
|
764
|
+
const outcomes = new Array(orderedPlan.length);
|
|
579
765
|
let next = 0;
|
|
580
766
|
let firstError = null;
|
|
581
767
|
await Promise.all(Array.from({ length: Math.min(concurrency, orderedPlan.length) }, async () => {
|
|
@@ -583,17 +769,35 @@ export async function executeReconciliation({
|
|
|
583
769
|
if (controller.signal.aborted) return;
|
|
584
770
|
const index = next++;
|
|
585
771
|
try {
|
|
586
|
-
|
|
772
|
+
outcomes[index] = await runStore(orderedPlan[index]);
|
|
587
773
|
} catch (error) {
|
|
774
|
+
// Only an external cancellation reaches here; every lane is already observing the same signal.
|
|
588
775
|
if (!firstError) firstError = error;
|
|
589
|
-
controller.abort(error);
|
|
590
776
|
return;
|
|
591
777
|
}
|
|
592
778
|
}
|
|
593
779
|
}));
|
|
594
780
|
if (!firstError && controller.signal.aborted) firstError = abortError(controller.signal);
|
|
595
781
|
if (firstError) throw firstError;
|
|
596
|
-
|
|
782
|
+
|
|
783
|
+
const carried = [];
|
|
784
|
+
const missing = [];
|
|
785
|
+
const integrityFailures = [];
|
|
786
|
+
outcomes.forEach((outcome, index) => {
|
|
787
|
+
if (!outcome || outcome.ok) return;
|
|
788
|
+
const disposition = dispositionForFailedStore({ assetsDir: assets, ledger: canonicalLedger, item: orderedPlan[index],
|
|
789
|
+
attempts: outcome.attempts, reason: failureReason(outcome.error), priorCoverage });
|
|
790
|
+
if (disposition.carry) carried.push(disposition);
|
|
791
|
+
else if (disposition.failure) missing.push(disposition);
|
|
792
|
+
else integrityFailures.push(disposition);
|
|
793
|
+
log(`[corpus-reconcile] ${orderedPlan[index].store}: ${disposition.carry ? 'CARRIED at its verified seed bytes (STALE)'
|
|
794
|
+
: disposition.failure ? 'MISSING (no prior bytes)' : `INTEGRITY FAILURE (${disposition.integrity})`}`);
|
|
795
|
+
});
|
|
796
|
+
// The merge reads SUCCESSFUL results only. `outcomes` is indexed by plan position, so a failed
|
|
797
|
+
// store leaves a slot with no worker result; the pre-D5 merge read `result.files` off every slot.
|
|
798
|
+
const results = outcomes.filter((outcome) => outcome?.ok).map((outcome) => outcome.result);
|
|
799
|
+
const failed = { carried, missing, integrityFailures };
|
|
800
|
+
if (!results.length) return { refreshed: [], workers: [], ...failed };
|
|
597
801
|
|
|
598
802
|
const merge = path.join(workspace, 'merge-candidate');
|
|
599
803
|
fs.mkdirSync(merge);
|
|
@@ -629,7 +833,7 @@ export async function executeReconciliation({
|
|
|
629
833
|
assertCapabilityOnlyStore(assets, store);
|
|
630
834
|
}
|
|
631
835
|
return { refreshed: results.map(({ store }) => store),
|
|
632
|
-
workers: results.map(({ output: _output, ...receipt }) => receipt) };
|
|
836
|
+
workers: results.map(({ output: _output, ...receipt }) => receipt), ...failed };
|
|
633
837
|
}
|
|
634
838
|
|
|
635
839
|
// syncCorpusInputs — Step 3 (2026-09-13): this used to ALSO sync public-prose inputs
|
|
@@ -677,7 +881,8 @@ async function observeSourceOnly({ owner, assetsDir }) {
|
|
|
677
881
|
export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, workspaceDir,
|
|
678
882
|
root = DEFAULT_ROOT, maxAttempts = 3, closingObservation = null,
|
|
679
883
|
observe = null,
|
|
680
|
-
build = (observation) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation
|
|
884
|
+
build = (observation, storeOutcomes = null) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation,
|
|
885
|
+
storeOutcomes }),
|
|
681
886
|
readLedger = () => readJson(path.join(path.resolve(assetsDir || ''), 'RVF-GENERATIONS.json'),
|
|
682
887
|
'RVF generation ledger'),
|
|
683
888
|
execute = executeReconciliation,
|
|
@@ -696,6 +901,14 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
|
|
|
696
901
|
rebuild = (coverage, observation, _attempt, capturedGists) => rebuildCorpusAggregates({
|
|
697
902
|
assetsDir, observation, coverage, root, cache: capturedGists,
|
|
698
903
|
}),
|
|
904
|
+
// The seed's knowledge inputs come from the evidence it carries (read FIRST: a seed without it --
|
|
905
|
+
// the pre-contract bootstrap -- always builds, and costs no second coverage measurement); tonight's
|
|
906
|
+
// from the coverage `build` measures off the sealed observation, plus this checkout's public prose.
|
|
907
|
+
unchanged = async (observation) => {
|
|
908
|
+
const seed = knowledgeFromSeed(assetsDir);
|
|
909
|
+
if (!seed) return { unchanged: false, reason: 'the seed carries no knowledge-input evidence' };
|
|
910
|
+
return compareKnowledgeInputs({ seed, tonight: await knowledgeFromCoverage(await build(observation, {}), root) });
|
|
911
|
+
},
|
|
699
912
|
} = {}) {
|
|
700
913
|
if (!assetsDir || !workspaceDir) fail('stable reconciliation requires explicit assets and workspace directories');
|
|
701
914
|
const workspace = path.resolve(workspaceDir || '');
|
|
@@ -704,6 +917,8 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
|
|
|
704
917
|
assertPathNotOverlapping('reconciliation workspace directory', workspace, forbidden);
|
|
705
918
|
assertPathNotOverlapping('reconciliation workspace directory', workspace,
|
|
706
919
|
[{ label: 'the assets directory', dir: assetsDir }]);
|
|
920
|
+
// Read ONCE, before anything is rebuilt: the seed's own sealed coverage dates a carried store's bytes.
|
|
921
|
+
const priorCoverage = readPriorCoverage(assetsDir);
|
|
707
922
|
return acquireSealedGeneration({
|
|
708
923
|
maxAttempts,
|
|
709
924
|
closingObservation,
|
|
@@ -712,11 +927,12 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
|
|
|
712
927
|
build,
|
|
713
928
|
readLedger,
|
|
714
929
|
execute: (plan, attempt) => execute({
|
|
715
|
-
plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root,
|
|
930
|
+
plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root, priorCoverage,
|
|
716
931
|
}),
|
|
717
932
|
prune,
|
|
718
933
|
rebuild,
|
|
719
934
|
preflight,
|
|
935
|
+
unchanged,
|
|
720
936
|
});
|
|
721
937
|
}
|
|
722
938
|
|
|
@@ -738,6 +954,8 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
738
954
|
accuracyTimeoutMs = null,
|
|
739
955
|
prepare = prepareCorpusCandidate } = {}) {
|
|
740
956
|
const finalized = await reconcile({ owner, assetsDir, workspaceDir, root, maxAttempts });
|
|
957
|
+
// Nothing the corpus is built from changed since the seed: nothing to normalize, seal or measure.
|
|
958
|
+
if (finalized?.noChange === true) return { reconciliation: finalized, noChange: true, updaters: null, candidate: null };
|
|
741
959
|
// Every shipped repository store needs a complete updater entry, and a seed that predates the
|
|
742
960
|
// convention leaves inherited stores without one -- measured 2026-09-15: 100 of 194 repository
|
|
743
961
|
// stores, none of them refreshed that run, which build-bundle rightly refused to ship. Normalize
|
|
@@ -803,8 +1021,7 @@ export function prepareCorpusCandidate({
|
|
|
803
1021
|
if (!coverage || coverage.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)) {
|
|
804
1022
|
fail('prepareCorpusCandidate requires an already-measured coverage object; it never re-observes live sources');
|
|
805
1023
|
}
|
|
806
|
-
const
|
|
807
|
-
if (blockers.length) fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT`);
|
|
1024
|
+
const degraded = assessCandidateCoverage(coverage);
|
|
808
1025
|
assertPathNotOverlapping('candidate output directory', candidate, forbiddenOutputRoots(sourceRoot));
|
|
809
1026
|
const buildScript = path.join(sourceRoot, 'scripts', 'build-bundle.mjs');
|
|
810
1027
|
const receiptScript = path.join(sourceRoot, 'scripts', 'corpus-candidate.mjs');
|
|
@@ -881,10 +1098,12 @@ export function prepareCorpusCandidate({
|
|
|
881
1098
|
// THE BLOCKING RETRIEVAL GATE (ADR-086 amendment 2026-09-15). Same placement and same discipline
|
|
882
1099
|
// as the C3 run above — the EXTRACTED final archive through the customer query path — but this is
|
|
883
1100
|
// the measurement that can refuse a candidate. It asks the 194 frozen human questions, one per
|
|
884
|
-
// repository, and fails on any error
|
|
885
|
-
// exact-file Hit@5
|
|
1101
|
+
// repository, and fails on any error or any repository that returns nothing of its own. The
|
|
1102
|
+
// exact-file Hit@5 floor is RECORDED in the report and never fails the CLI (ADR-0091 D7.6).
|
|
1103
|
+
// `--coverage` is this candidate's sealed observation: a fixture repository with no row in it is
|
|
1104
|
+
// retired (D7.2) instead of asked a question it has no store to answer.
|
|
886
1105
|
const recallReportFile = `${bundleFile}.recall.json`;
|
|
887
|
-
checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile],
|
|
1106
|
+
checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile, '--coverage', policy],
|
|
888
1107
|
{ stdio: 'inherit' });
|
|
889
1108
|
// The candidate receipt is derived ENTIRELY from the sealed bundle's own bytes plus the detached,
|
|
890
1109
|
// digest-bound reports — the separate assets/policy directory used to build it is no longer an
|
|
@@ -894,17 +1113,50 @@ export function prepareCorpusCandidate({
|
|
|
894
1113
|
: [];
|
|
895
1114
|
checked(run, process.execPath, [receiptScript, '--bundle', bundleFile,
|
|
896
1115
|
'--receipt', receipt, '--builder-source-sha', builderSha,
|
|
897
|
-
'--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile,
|
|
1116
|
+
'--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile, '--coverage', policy,
|
|
898
1117
|
...bootstrapArgs], { stdio: 'inherit' });
|
|
899
1118
|
checked(run, process.execPath, [receiptScript, '--verify', '--bundle', bundleFile,
|
|
900
1119
|
'--receipt', receipt, '--accuracy-report', accuracyReportFile,
|
|
901
|
-
'--recall-report', recallReportFile], { stdio: 'inherit' });
|
|
1120
|
+
'--recall-report', recallReportFile, '--coverage', policy], { stdio: 'inherit' });
|
|
902
1121
|
return {
|
|
903
1122
|
bundleFile, receiptFile: receipt, coverageFile: policy,
|
|
904
|
-
accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile,
|
|
1123
|
+
accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile, degraded,
|
|
905
1124
|
};
|
|
906
1125
|
}
|
|
907
1126
|
|
|
1127
|
+
/**
|
|
1128
|
+
* ADR-0091 D5 -- the gate that replaced "every eligible row is CURRENT". An eligible row passes when
|
|
1129
|
+
* it is CURRENT, or when it is a repository row the shipped validator itself accepts
|
|
1130
|
+
* (eligibleRepositoryStanding: STALE with a verified `carry`, MISSING with a `failure`). Everything
|
|
1131
|
+
* else -- a STALE/MISSING row with no record, FAILED, UNVERIFIED, any non-CURRENT gist -- still fails
|
|
1132
|
+
* closed, and so does a count of carried + missing stores above max(3, 5% of eligible).
|
|
1133
|
+
*/
|
|
1134
|
+
export function assessCandidateCoverage(coverage) {
|
|
1135
|
+
const eligible = coverage.rows.filter((row) => row.disposition === 'eligible');
|
|
1136
|
+
const carried = [];
|
|
1137
|
+
const missing = [];
|
|
1138
|
+
const blockers = [];
|
|
1139
|
+
for (const row of eligible) {
|
|
1140
|
+
const standing = row.kind === 'repository' ? eligibleRepositoryStanding(row)
|
|
1141
|
+
: row.status === 'CURRENT' && row.carry === undefined && row.failure === undefined ? 'shipped' : null;
|
|
1142
|
+
if (standing === null) blockers.push(row);
|
|
1143
|
+
else if (row.carry) carried.push({ store: row.artifact.store, ...row.carry });
|
|
1144
|
+
else if (row.failure) missing.push({ store: row.artifact.store, ...row.failure });
|
|
1145
|
+
}
|
|
1146
|
+
if (blockers.length) {
|
|
1147
|
+
fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT and carry no verified carry/failure record `
|
|
1148
|
+
+ `(${blockers.slice(0, 5).map((row) => `${row.artifact?.store || row.key}:${row.status}`).join(', ')}`
|
|
1149
|
+
+ `${blockers.length > 5 ? ', ...' : ''})`);
|
|
1150
|
+
}
|
|
1151
|
+
const repositories = eligible.filter((row) => row.kind === 'repository').length;
|
|
1152
|
+
const bound = degradedBound(repositories);
|
|
1153
|
+
if (carried.length + missing.length > bound) {
|
|
1154
|
+
fail(`degraded coverage: ${carried.length} carried + ${missing.length} missing store(s) exceed `
|
|
1155
|
+
+ `max(3, 5% of ${repositories} eligible) = ${bound}`);
|
|
1156
|
+
}
|
|
1157
|
+
return { carried, missing, bound, eligibleRepositories: repositories };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
908
1160
|
function arg(argv, name, fallback = null) {
|
|
909
1161
|
const index = argv.indexOf(name);
|
|
910
1162
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
@@ -936,7 +1188,11 @@ export async function main(argv = process.argv.slice(2), {
|
|
|
936
1188
|
? Number(arg(argv, '--accuracy-sample-per-partition')) : null;
|
|
937
1189
|
const accuracyTimeoutMs = arg(argv, '--accuracy-timeout-ms') ? Number(arg(argv, '--accuracy-timeout-ms')) : null;
|
|
938
1190
|
|
|
939
|
-
|
|
1191
|
+
// `--no-change-out <file>`: always written (true or false) once reconciliation returns, so
|
|
1192
|
+
// corpus-seed.yml never has to infer a no-change night from a missing file.
|
|
1193
|
+
const noChangeOut = arg(argv, '--no-change-out');
|
|
1194
|
+
// The argv this main() was HANDED, never process.argv: an injected invocation must mean what it says.
|
|
1195
|
+
const bootstrap = assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: argv.includes('--allow-pinned-seed-tag') });
|
|
940
1196
|
if (fs.existsSync(assetsDir) && fs.readdirSync(assetsDir).length) fail(`bootstrap assets directory is not empty (${assetsDir})`);
|
|
941
1197
|
fs.mkdirSync(path.dirname(assetsDir), { recursive: true });
|
|
942
1198
|
const extractParent = fs.mkdtempSync(path.join(path.dirname(assetsDir), '.corpus-seed-extract-'));
|
|
@@ -958,12 +1214,35 @@ export async function main(argv = process.argv.slice(2), {
|
|
|
958
1214
|
fs.rmSync(extractParent, { recursive: true, force: true });
|
|
959
1215
|
syncCorpusInputs({ root, assetsDir });
|
|
960
1216
|
const bootstrapIdentity = { tag: bootstrap.tag, sha256: bootstrap.sha256, privateFenceEvidence: seedPrivateFenceEvidence(assetsDir) };
|
|
961
|
-
const { reconciliation, candidate } = await reconcileAndPrepare({
|
|
1217
|
+
const { reconciliation, candidate, noChange = false } = await reconcileAndPrepare({
|
|
962
1218
|
assetsDir, workspaceDir, root, owner, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
963
1219
|
accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
|
|
964
1220
|
});
|
|
1221
|
+
if (noChangeOut) {
|
|
1222
|
+
fs.mkdirSync(path.dirname(path.resolve(noChangeOut)), { recursive: true });
|
|
1223
|
+
fs.writeFileSync(path.resolve(noChangeOut), `${JSON.stringify({
|
|
1224
|
+
noChange: noChange === true,
|
|
1225
|
+
knowledgeInputSha256: noChange === true ? reconciliation?.knowledgeInput?.tonightSha256 ?? null : null,
|
|
1226
|
+
observationSha256: reconciliation?.observation?.observationSha256 ?? null,
|
|
1227
|
+
})}\n`);
|
|
1228
|
+
}
|
|
1229
|
+
if (noChange === true) {
|
|
1230
|
+
stdout.write(`${JSON.stringify({ ok: true, noChange: true, seedTag, seedSha256,
|
|
1231
|
+
knowledgeInput: reconciliation?.knowledgeInput ?? null }, null, 2)}\n`);
|
|
1232
|
+
return 0;
|
|
1233
|
+
}
|
|
965
1234
|
const { plan } = summarizeReconciliation(reconciliation);
|
|
966
|
-
|
|
1235
|
+
const degraded = candidate.degraded || { carried: [], missing: [] };
|
|
1236
|
+
const isDegraded = degraded.carried.length + degraded.missing.length > 0;
|
|
1237
|
+
const publication = isDegraded ? degradedPublication() : { allowed: true, reason: 'every eligible row is CURRENT' };
|
|
1238
|
+
stdout.write(`${JSON.stringify({ ok: publication.allowed, seedTag, seedSha256, plan, reconciliation, ...candidate,
|
|
1239
|
+
degraded: { ...degraded, publishable: publication.allowed, reason: publication.reason } }, null, 2)}\n`);
|
|
1240
|
+
if (!publication.allowed) {
|
|
1241
|
+
stderr.write(`::warning title=Degraded corpus generation sealed, not published::${degraded.carried.length} carried `
|
|
1242
|
+
+ `(${degraded.carried.map((row) => row.store).join(', ') || 'none'}), ${degraded.missing.length} missing `
|
|
1243
|
+
+ `(${degraded.missing.map((row) => row.store).join(', ') || 'none'}); ${publication.reason}\n`);
|
|
1244
|
+
return DEGRADED_UNPUBLISHED_EXIT;
|
|
1245
|
+
}
|
|
967
1246
|
return 0;
|
|
968
1247
|
}
|
|
969
1248
|
|