ruvnet-brain 4.7.15 → 4.7.17
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 +2 -2
- package/bin/install.mjs +38 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/grok-hooks.json +1 -1
- package/plugin/hooks/hook-contracts.json +36 -18
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/continuity-hook-policy.mjs +9 -9
- package/plugin/scripts/coverage-integrity.mjs +9 -3
- package/plugin/scripts/grok-hook-adapter.mjs +7 -3
- package/plugin/scripts/hook-failures.mjs +76 -8
- package/plugin/scripts/learn-capture.mjs +74 -9
- package/plugin/scripts/learning-store.mjs +16 -6
- package/plugin/scripts/nightly-scheduler.mjs +67 -12
- package/plugin/scripts/turn-outcome-capture.mjs +17 -17
- package/scripts/approved-runtime.mjs +6 -2
- package/scripts/corpus-canary.mjs +14 -8
- package/scripts/corpus-dispatch-decision.mjs +56 -2
- package/scripts/corpus-next-seed.mjs +5 -1
- package/scripts/corpus-reconcile.mjs +39 -4
- package/scripts/corpus-watchdog.mjs +115 -9
- package/scripts/gist-receipts.mjs +10 -2
- package/scripts/release.mjs +11 -4
- package/scripts/source-coverage.mjs +77 -19
- package/scripts/transient-gh.mjs +150 -0
|
@@ -25,6 +25,7 @@ import { storeRoot } from '../kb/store-root.mjs';
|
|
|
25
25
|
import { captureGistSources } from './gist-receipts.mjs';
|
|
26
26
|
import { projectSourceStore, RUNTIME_LEDGER_KIND } from './rvf-generation.mjs';
|
|
27
27
|
import { compareKnowledgeInputs, fromCoverage as knowledgeFromCoverage, fromSeed as knowledgeFromSeed } from './knowledge-input-digest.mjs';
|
|
28
|
+
import { defaultBaseDelayMs } from './transient-gh.mjs';
|
|
28
29
|
|
|
29
30
|
export { rebuildCorpusAggregates };
|
|
30
31
|
|
|
@@ -352,8 +353,10 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
|
|
|
352
353
|
const settled = await build(observation, storeOutcomes);
|
|
353
354
|
const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir })
|
|
354
355
|
.filter((item) => !recorded(item.store));
|
|
356
|
+
// A repository row the shipped validator accepts WITH a carry -- a transiently unreadable source pinned at its
|
|
357
|
+
// seed commit (source-coverage.mjs) -- is resolved for this generation, exactly like a recorded store outcome.
|
|
355
358
|
const unresolved = settled.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT'
|
|
356
|
-
&& !(row.kind === 'repository' && recorded(row.artifact?.store)));
|
|
359
|
+
&& !(row.kind === 'repository' && (recorded(row.artifact?.store) || (row.carry && eligibleRepositoryStanding(row) === 'shipped'))));
|
|
357
360
|
attempts.push({ attempt, plan, ...reconciliation, ...pruning, ...aggregates,
|
|
358
361
|
remainingArtifacts: remaining.length, unresolvedSources: unresolved.length });
|
|
359
362
|
if (!remaining.length && !unresolved.length) {
|
|
@@ -635,11 +638,13 @@ export function dispositionForFailedStore({ assetsDir, ledger, item, attempts, r
|
|
|
635
638
|
|
|
636
639
|
// Only a TRANSIENT failure is retried, exactly once, in a fresh directory: `<store>-retry1`. The first
|
|
637
640
|
// attempt's directory is never reused -- a clone or a half-written worker output from the failed
|
|
638
|
-
// attempt must not be mistaken for the retry's own (the pre-D5 path collided).
|
|
641
|
+
// attempt must not be mistaken for the retry's own (the pre-D5 path collided). The retry waits
|
|
642
|
+
// TRANSIENT_RETRY_DELAY_MS first: an immediate re-clone into the same reset connection learns nothing.
|
|
639
643
|
export function workerRootFor(workspace, store, retry) {
|
|
640
644
|
return path.join(workspace, 'workers', retry === 0 ? store : `${store}-retry${retry}`);
|
|
641
645
|
}
|
|
642
646
|
export const MAX_TRANSIENT_RETRIES = 1;
|
|
647
|
+
export const TRANSIENT_RETRY_DELAY_MS = 30_000;
|
|
643
648
|
|
|
644
649
|
export async function executeReconciliation({
|
|
645
650
|
plan,
|
|
@@ -651,6 +656,8 @@ export async function executeReconciliation({
|
|
|
651
656
|
signal,
|
|
652
657
|
priorCoverage = null,
|
|
653
658
|
log = (line) => console.log(line),
|
|
659
|
+
retryDelayMs = defaultBaseDelayMs(TRANSIENT_RETRY_DELAY_MS),
|
|
660
|
+
sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
654
661
|
}) {
|
|
655
662
|
if (!Array.isArray(plan) || !Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 10) {
|
|
656
663
|
fail('reconciliation plan or worker concurrency is invalid');
|
|
@@ -750,8 +757,10 @@ export async function executeReconciliation({
|
|
|
750
757
|
lastError = error;
|
|
751
758
|
const retrying = isRetryable(error) && retry < MAX_TRANSIENT_RETRIES;
|
|
752
759
|
log(`[corpus-reconcile] ${item.store}: attempt ${attempts} failed -- ${error.message}`
|
|
753
|
-
+ (retrying ?
|
|
760
|
+
+ (retrying ? `; retrying once in a fresh worker directory after ${retryDelayMs / 1000}s` : '; not retried'));
|
|
754
761
|
if (!retrying) break;
|
|
762
|
+
await sleep(retryDelayMs);
|
|
763
|
+
if (controller.signal.aborted) throw abortError(controller.signal);
|
|
755
764
|
}
|
|
756
765
|
}
|
|
757
766
|
return { ok: false, attempts, error: lastError };
|
|
@@ -871,7 +880,33 @@ async function observeSourceOnly({ owner, assetsDir }) {
|
|
|
871
880
|
const externalFile = path.join(assets, 'external-sources.json');
|
|
872
881
|
const policy = fs.existsSync(externalFile) ? readJson(externalFile, 'external source policy') : { sources: [] };
|
|
873
882
|
if (!Array.isArray(policy.sources)) fail('external source policy has no sources array');
|
|
874
|
-
|
|
883
|
+
const ledgerFile = path.join(assets, 'RVF-GENERATIONS.json');
|
|
884
|
+
const ledger = fs.existsSync(ledgerFile) ? readJson(ledgerFile, 'RVF generation ledger') : { stores: {} };
|
|
885
|
+
return observeSourceUniverse({ owner, externalSources: policy.sources,
|
|
886
|
+
carriedCommits: transientCarryCommits({ ledger, priorCoverage: readPriorCoverage(assets) }) });
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The seed's own commits, per store: what an external source unreadable tonight is pinned at (one night, STALE
|
|
891
|
+
* with a carry) instead of failing the whole observation. Read from the seed ledger, never invented -- and a store
|
|
892
|
+
* the SEED's sealed coverage already shows carried this way is left out, so the second consecutive night fails
|
|
893
|
+
* for that source exactly as before rather than carrying it forever.
|
|
894
|
+
*/
|
|
895
|
+
export function transientCarryCommits({ ledger, priorCoverage = null }) {
|
|
896
|
+
// No sealed coverage to read history from (the bootstrap lineage, or an invalid sidecar) = no carry at all:
|
|
897
|
+
// the allowance exists only where the previous night can prove it was not already spent.
|
|
898
|
+
if (!Array.isArray(priorCoverage?.rows)) return {};
|
|
899
|
+
// ANY carried seed row spends it -- a failed refresh last night and an unreadable upstream tonight must not
|
|
900
|
+
// alternate forever either.
|
|
901
|
+
const alreadyCarried = new Set(priorCoverage.rows
|
|
902
|
+
.filter((row) => row?.kind === 'repository' && row.carry)
|
|
903
|
+
.map((row) => String(row.artifact?.store || '').toLowerCase()));
|
|
904
|
+
return Object.fromEntries(Object.entries(ledger?.stores || {})
|
|
905
|
+
.filter(([store, row]) => HEX40.test(String(row?.sourceCommit || '').toLowerCase()) && !alreadyCarried.has(store.toLowerCase()))
|
|
906
|
+
.map(([store, row]) => {
|
|
907
|
+
const sourceCommit = String(row.sourceCommit).toLowerCase();
|
|
908
|
+
return [store.toLowerCase(), { sourceCommit, committedAt: carriedCommittedAt({ priorCoverage, store, sourceCommit }) }];
|
|
909
|
+
}));
|
|
875
910
|
}
|
|
876
911
|
|
|
877
912
|
export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, workspaceDir,
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
// WARNING a superseded or degraded night, a stand-down tonight, an unknown outcome, a long run
|
|
23
23
|
// GREEN none of the above
|
|
24
24
|
//
|
|
25
|
+
// THE FIRST LINE IS THE NIGHT'S VERDICT (2026-10-20, summarizeNight): what the night did -- published,
|
|
26
|
+
// no-change, degraded with the stores named, failed with the stage named, superseded, canary-rejected, still
|
|
27
|
+
// running, stood down -- in Eastern time, result first, so the owner never learns of a break from a page.
|
|
28
|
+
//
|
|
25
29
|
// Exit code is the contract (same as scripts/github-health-watch.mjs): 0 = GREEN or WARNING,
|
|
26
30
|
// 1 = RED or the evidence could not be gathered. corpus-watchdog.yml runs on a genuine `schedule:`,
|
|
27
31
|
// so its red conclusion fires ntfy-alerts.yml's workflow_run listener — no secret needed here.
|
|
@@ -43,6 +47,7 @@ import { CODE_TAG_PATTERN, isCorpusReleaseTag } from './release-channel-kind.mjs
|
|
|
43
47
|
import { AGGREGATE_ASSET, SIGNING_PUBLIC_KEY_FILE } from './approved-runtime.mjs';
|
|
44
48
|
import { COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET, verifyCoverageSidecar } from './corpus-coverage-sidecar.mjs';
|
|
45
49
|
import { parseCorpusGeneration } from './corpus-promotion.mjs';
|
|
50
|
+
import { retryTransientResult } from './transient-gh.mjs';
|
|
46
51
|
|
|
47
52
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
53
|
export const RED = 'RED';
|
|
@@ -255,7 +260,9 @@ export function judgeCorpusHealth(input, now) {
|
|
|
255
260
|
// ── Gathering (read-only gh). Everything above is pure; everything below talks to GitHub. ──────────
|
|
256
261
|
|
|
257
262
|
function defaultGh(args) {
|
|
258
|
-
|
|
263
|
+
// A transient gh failure is retried, bounded (scripts/transient-gh.mjs); the judge must not page RED on weather.
|
|
264
|
+
const result = retryTransientResult(() => spawnSync(process.env.RUVNET_GH_COMMAND || 'gh', args,
|
|
265
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 5 * 60_000 }));
|
|
259
266
|
if (result.error || result.status !== 0) {
|
|
260
267
|
throw new Error(`gh ${args.slice(0, 3).join(' ')} failed: ${String(result.error?.message || result.stderr || `exit ${result.status}`).trim().slice(0, 300)}`);
|
|
261
268
|
}
|
|
@@ -320,17 +327,25 @@ function corpusRunEvidence({ gh, repo, run, scratch }) {
|
|
|
320
327
|
}
|
|
321
328
|
}
|
|
322
329
|
|
|
330
|
+
/**
|
|
331
|
+
* The protected-release corpus runs created since `since` (ms), each with its outcome evidence. Shared with the
|
|
332
|
+
* dispatcher's retry slot (scripts/corpus-dispatch-decision.mjs --retry-slot), so both read one definition of
|
|
333
|
+
* "tonight's runs" and one classifier.
|
|
334
|
+
*/
|
|
335
|
+
export function gatherCorpusRuns({ repo, since, gh = defaultGh, scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-watchdog-')) }) {
|
|
336
|
+
return JSON.parse(gh(['run', 'list', '--repo', repo, '--workflow', 'protected-release.yml', '--limit', '60',
|
|
337
|
+
'--json', 'databaseId,attempt,displayTitle,status,conclusion,createdAt,updatedAt']))
|
|
338
|
+
.filter((run) => CORPUS_RUN_TITLE.test(String(run.displayTitle || '')) && (ms(run.createdAt) ?? 0) >= since)
|
|
339
|
+
.map((run) => ({ id: run.databaseId, title: run.displayTitle, status: run.status, conclusion: run.conclusion,
|
|
340
|
+
createdAt: run.createdAt, updatedAt: run.updatedAt, ...corpusRunEvidence({ gh, repo, run, scratch }) }));
|
|
341
|
+
}
|
|
342
|
+
|
|
323
343
|
export async function gatherCorpusHealthInput({ repo, now, gh = defaultGh, root = ROOT, scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-watchdog-')) }) {
|
|
324
344
|
const at = now.getTime();
|
|
325
|
-
const recent = (iso) => (ms(iso) ?? 0) >= at - 3 * 24 * HOUR;
|
|
326
345
|
const dispatcherRuns = JSON.parse(gh(['run', 'list', '--repo', repo, '--workflow', 'corpus-nightly-dispatch.yml', '--limit', '30',
|
|
327
346
|
'--json', 'databaseId,attempt,event,status,conclusion,createdAt,updatedAt']))
|
|
328
347
|
.map((run) => ({ id: run.databaseId, attempt: run.attempt, event: run.event, status: run.status, conclusion: run.conclusion, createdAt: run.createdAt }));
|
|
329
|
-
const corpusRuns =
|
|
330
|
-
'--json', 'databaseId,attempt,displayTitle,status,conclusion,createdAt,updatedAt']))
|
|
331
|
-
.filter((run) => CORPUS_RUN_TITLE.test(String(run.displayTitle || '')) && recent(run.createdAt))
|
|
332
|
-
.map((run) => ({ id: run.databaseId, title: run.displayTitle, status: run.status, conclusion: run.conclusion,
|
|
333
|
-
createdAt: run.createdAt, updatedAt: run.updatedAt, ...corpusRunEvidence({ gh, repo, run, scratch }) }));
|
|
348
|
+
const corpusRuns = gatherCorpusRuns({ repo, since: at - 3 * 24 * HOUR, gh, scratch });
|
|
334
349
|
const releases = JSON.parse(gh(['release', 'list', '--repo', repo, '--limit', '100', '--json', 'tagName,publishedAt,isDraft,isPrerelease']));
|
|
335
350
|
// PROMOTED only: a staged candidate (and a bootstrap seed) is a prerelease no customer receives.
|
|
336
351
|
const corpusReleases = releases.filter((row) => !row.isDraft && !row.isPrerelease && isCorpusReleaseTag(row.tagName))
|
|
@@ -364,8 +379,98 @@ export async function gatherCorpusHealthInput({ repo, now, gh = defaultGh, root
|
|
|
364
379
|
return { corpusReleases, corpusRuns, dispatcherRuns, codeRelease, deferral, latestRelease };
|
|
365
380
|
}
|
|
366
381
|
|
|
382
|
+
/** A time the owner reads: local Eastern, 12-hour, named zone -- never UTC or "Z". */
|
|
383
|
+
export function formatEastern(when) {
|
|
384
|
+
const t = when instanceof Date ? when.getTime() : ms(when);
|
|
385
|
+
if (t === null || !Number.isFinite(t)) return 'an unknown time';
|
|
386
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', month: 'short', day: 'numeric',
|
|
387
|
+
hour: 'numeric', minute: '2-digit', hour12: true }).formatToParts(new Date(t)).map((part) => [part.type, part.value]));
|
|
388
|
+
return `${parts.hour}:${parts.minute} ${parts.dayPeriod.toLowerCase().replace(/^(a|p)m$/, '$1.m.')} ET ${parts.month} ${parts.day}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The job the night died in, named as the owner knows the stage, from the terminal outcome record or the job list. */
|
|
392
|
+
const STAGE_NAMES = { identity: 'authorize (corpus-identity)', prepare: 'prepare/seal (seal-exact-corpus-candidate)',
|
|
393
|
+
authorize: 'authenticate (corpus-authorize)', publish: 'publisher (corpus-publish)', canary: 'customer canary', promote: 'promote' };
|
|
394
|
+
const JOB_NAMES = { 'authorize-corpus-generation': 'identity', 'seal-exact-corpus-candidate': 'prepare', 'authenticate-corpus-preparation-provenance': 'authorize',
|
|
395
|
+
'protected-corpus-publisher': 'publish', 'customer-canary': 'canary', 'promote-canaried-corpus': 'promote' };
|
|
396
|
+
export function failedStage(run) {
|
|
397
|
+
const recorded = Object.entries(run?.outcomeRecord?.jobs || {}).find(([, result]) => result === 'failure')?.[0];
|
|
398
|
+
const listed = (run?.jobs || []).find((job) => job.conclusion === 'failure' && JOB_NAMES[job.name]);
|
|
399
|
+
const stage = recorded || (listed ? JOB_NAMES[listed.name] : null);
|
|
400
|
+
return stage ? STAGE_NAMES[stage] || stage : `an unnamed job (conclusion ${run?.conclusion || 'unknown'})`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** A night's scheduled dispatches (the 07:36Z run and its retry slot) sit within this span; a day earlier is last night. */
|
|
404
|
+
const NIGHT_SPAN_MS = 12 * HOUR;
|
|
405
|
+
/**
|
|
406
|
+
* How many protected-release corpus runs belong to THIS night. Measured 2026-10-22: a manual workflow_dispatch proving
|
|
407
|
+
* run at 10:36Z Oct 21 and a stood-down retry slot at 11:25Z Oct 21 made ONE scheduled night read "attempt 2; 1 retried".
|
|
408
|
+
* The night is anchored on the newest `schedule` dispatcher that dispatched a run, plus scheduled dispatchers within
|
|
409
|
+
* NIGHT_SPAN_MS before it; a run counts only from that start and never when its dispatcher was a manual
|
|
410
|
+
* workflow_dispatch. A stood-down retry slot created no run, so it never counts. `runs` is newest-first, in window.
|
|
411
|
+
*/
|
|
412
|
+
export function nightAttempts(runs, dispatchers) {
|
|
413
|
+
const dispatchKey = (d) => `corpus-${d.id}-${d.attempt ?? 1}`;
|
|
414
|
+
const runKey = (run) => CORPUS_RUN_TITLE.exec(String(run.title || ''))?.[1];
|
|
415
|
+
const byKey = new Map(dispatchers.map((d) => [dispatchKey(d), d]));
|
|
416
|
+
const manual = (run) => byKey.get(runKey(run))?.event === 'workflow_dispatch';
|
|
417
|
+
const [newest] = runs;
|
|
418
|
+
if (manual(newest)) return ' (1 attempt; manual dispatch)';
|
|
419
|
+
const scheduled = dispatchers.filter((d) => !d.event || d.event === 'schedule'); // an undated event is read as the schedule
|
|
420
|
+
const anchor = scheduled.find((d) => runs.some((run) => runKey(run) === dispatchKey(d)));
|
|
421
|
+
const nightStart = anchor
|
|
422
|
+
? Math.min(...scheduled.filter((d) => ms(d.createdAt) <= ms(anchor.createdAt) && ms(anchor.createdAt) - ms(d.createdAt) <= NIGHT_SPAN_MS).map((d) => ms(d.createdAt)))
|
|
423
|
+
: ms(newest.createdAt);
|
|
424
|
+
const count = runs.filter((run) => ms(run.createdAt) >= nightStart && !manual(run)).length || 1;
|
|
425
|
+
return count > 1 ? ` (attempt ${count} of the night; ${count - 1} retried)` : ' (1 attempt)';
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* THE ONE LINE the owner reads first: what the night did, result first, in Eastern time. `input` is
|
|
430
|
+
* gatherCorpusHealthInput's; the night is the newest corpus run of the last 24h and every retry of it.
|
|
431
|
+
* Never learned from a page: the watchdog writes this before ntfy ever fires.
|
|
432
|
+
*/
|
|
433
|
+
export function summarizeNight(input, now) {
|
|
434
|
+
const at = now instanceof Date ? now.getTime() : Number(now);
|
|
435
|
+
const inWindow = (iso) => { const t = ms(iso); return t !== null && t <= at && at - t <= TONIGHT_WINDOW_MS; };
|
|
436
|
+
const runs = (input?.corpusRuns || []).filter((run) => inWindow(run.createdAt))
|
|
437
|
+
.map((run) => ({ ...run, outcome: run.outcome ?? classifyCorpusRun(run) })).sort((a, b) => ms(b.createdAt) - ms(a.createdAt));
|
|
438
|
+
const dispatchers = (input?.dispatcherRuns || []).filter((d) => inWindow(d.createdAt)).sort((a, b) => ms(b.createdAt) - ms(a.createdAt));
|
|
439
|
+
const [dispatcher] = dispatchers;
|
|
440
|
+
const latest = input?.latestRelease;
|
|
441
|
+
const attempts = runs.length ? nightAttempts(runs, dispatchers) : '';
|
|
442
|
+
const keep = latest ? `customers keep ${latest.tag} (${hours(Math.max(0, at - (ms(latest.generation || latest.publishedAt) ?? at)))} old)` : 'no promoted generation is observed';
|
|
443
|
+
if (!runs.length) {
|
|
444
|
+
if (!dispatcher) return { outcome: 'no-run', line: `night: no corpus run in the last 24h (no dispatcher run either); ${keep}` };
|
|
445
|
+
if (dispatcher.status !== 'completed') return { outcome: 'dispatching', line: `night: dispatcher run ${dispatcher.id} is ${dispatcher.status} (started ${formatEastern(dispatcher.createdAt)})` };
|
|
446
|
+
if (dispatcher.conclusion !== 'success') return { outcome: 'dispatcher-failed', line: `night: dispatcher run ${dispatcher.id} concluded ${dispatcher.conclusion} at ${formatEastern(dispatcher.createdAt)}, nothing was dispatched; ${keep}` };
|
|
447
|
+
return { outcome: 'stood-down', line: `night: stood down at ${formatEastern(dispatcher.createdAt)} (CORPUS_NIGHTLY off, or no install-verified runtime); ${keep}` };
|
|
448
|
+
}
|
|
449
|
+
const [run] = runs;
|
|
450
|
+
const when = formatEastern(run.updatedAt || run.createdAt);
|
|
451
|
+
const degraded = input?.deferral?.observable && input.deferral.degraded && latest && input.deferral.newestTag === latest.tag
|
|
452
|
+
&& (input.deferral.degraded.carried.length || input.deferral.degraded.missing.length) ? input.deferral.degraded : null;
|
|
453
|
+
switch (run.outcome) {
|
|
454
|
+
case 'published': {
|
|
455
|
+
const tag = latest && (ms(latest.publishedAt) ?? 0) >= (ms(run.createdAt) ?? 0) ? latest.tag : 'a new generation';
|
|
456
|
+
if (degraded) {
|
|
457
|
+
return { outcome: 'degraded', line: `night: degraded -- published ${tag} at ${when}${attempts} with ${degraded.carried.length} carried `
|
|
458
|
+
+ `(${degraded.carried.join(', ') || 'none'}) and ${degraded.missing.length} missing (${degraded.missing.join(', ') || 'none'}), each named in CORPUS-COVERAGE.json` };
|
|
459
|
+
}
|
|
460
|
+
return { outcome: 'published', line: `night: published ${tag} at ${when}${attempts}` };
|
|
461
|
+
}
|
|
462
|
+
case 'no-change': return { outcome: 'no-change', line: `night: no-change at ${when}${attempts} -- every knowledge input equals the promoted generation; ${keep}` };
|
|
463
|
+
case 'superseded': return { outcome: 'superseded', line: `night: superseded at ${when} -- a newer code release landed; the next night builds at it; ${keep}` };
|
|
464
|
+
case 'canary-rejected': return { outcome: 'canary-rejected', line: `night: canary-rejected at ${when}${attempts} -- a clean customer install could not apply the candidate (GLOBAL: not retried); ${keep}` };
|
|
465
|
+
case 'in-progress': return { outcome: 'in-progress', line: `night: run ${run.id} still in progress, started ${formatEastern(run.createdAt)}${attempts}` };
|
|
466
|
+
case 'failed': return { outcome: 'failed', line: `night: failed in ${failedStage(run)} at ${when}${attempts}; ${keep}` };
|
|
467
|
+
default: return { outcome: 'unknown', line: `night: run ${run.id} succeeded at ${when} but reached no outcome${attempts}; ${keep}` };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
367
471
|
export function renderReport(result, now) {
|
|
368
|
-
const lines = [`corpus-watchdog ${result.verdict}
|
|
472
|
+
const lines = [`corpus-watchdog ${result.verdict} -- ${result.night?.line || `judged ${formatEastern(now)}`}`,
|
|
473
|
+
` judged ${formatEastern(now)} (${now.toISOString()})`];
|
|
369
474
|
for (const f of result.findings) lines.push(` [${f.level}] ${f.check}: ${f.detail}`);
|
|
370
475
|
return `${lines.join('\n')}\n`;
|
|
371
476
|
}
|
|
@@ -380,7 +485,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
380
485
|
}
|
|
381
486
|
let result;
|
|
382
487
|
try {
|
|
383
|
-
|
|
488
|
+
const input = await gatherCorpusHealthInput({ repo, now });
|
|
489
|
+
result = { ...judgeCorpusHealth(input, now), night: summarizeNight(input, now) };
|
|
384
490
|
} catch (error) {
|
|
385
491
|
// Could not observe = could not prove health. Absence of evidence is failure.
|
|
386
492
|
process.stderr.write(`corpus-watchdog RED: evidence could not be gathered: ${error.message}\n`);
|
|
@@ -34,6 +34,7 @@ import { canonicalJson, digest, validateGistAggregateReceipt } from './coverage-
|
|
|
34
34
|
import { promoteArtifactSet } from '../kb/incremental-refresh.mjs';
|
|
35
35
|
import { writeRvfGeneration } from './rvf-generation.mjs';
|
|
36
36
|
import { fetchGistGitSnapshot } from './gist-git-transport.mjs';
|
|
37
|
+
import { isTransientText, retryTransientAsync } from './transient-gh.mjs';
|
|
37
38
|
|
|
38
39
|
const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
39
40
|
const TEXT_EXT = new Set(['.md', '.markdown', '.txt', '.rst']);
|
|
@@ -402,9 +403,15 @@ function reusableCachedGist(cached, stub) {
|
|
|
402
403
|
return 'reuse';
|
|
403
404
|
}
|
|
404
405
|
|
|
406
|
+
// The Git snapshot is one `ls-remote` + one bare clone per gist against gist.github.com. A transient transport
|
|
407
|
+
// failure (reset, timeout, "could not read Username", or the gist moving mid-capture) is retried, bounded, so
|
|
408
|
+
// one blip on one of ~180 gists cannot fail the aggregate and with it the night. A gist that is gone
|
|
409
|
+
// ("repository not found") is an answer and is thrown at once.
|
|
410
|
+
const transientGistSnapshot = (error) => isTransientText(error?.message) || /moved while Git snapshot was captured/.test(String(error?.message || ''));
|
|
411
|
+
|
|
405
412
|
export async function captureGistSources({ observation, cache = null, fetchDetail = null,
|
|
406
413
|
fetchRaw = defaultFetchRaw, fetchGitSnapshot = fetchGistGitSnapshot, signal,
|
|
407
|
-
now = () => new Date().toISOString() } = {}) {
|
|
414
|
+
now = () => new Date().toISOString(), snapshotRetryDelayMs = 15_000 } = {}) {
|
|
408
415
|
const stubs = observation?.gists?.rows;
|
|
409
416
|
if (!Array.isArray(stubs) || stubs.some(({ id }) => !HEX_GIST.test(String(id || '')))
|
|
410
417
|
|| new Set(stubs.map(({ id }) => id)).size !== stubs.length) {
|
|
@@ -420,7 +427,8 @@ export async function captureGistSources({ observation, cache = null, fetchDetai
|
|
|
420
427
|
// The production/no-key route never spends one REST detail request per gist and never trusts
|
|
421
428
|
// a body-free cache or timestamps as content identity. A full bare clone gives one immutable
|
|
422
429
|
// Git commit/tree; every observed raw_url's blob identity must match its HEAD file bytes.
|
|
423
|
-
const snapshot = await fetchGitSnapshot({ owner: observation.owner, stub, signal })
|
|
430
|
+
const snapshot = await retryTransientAsync(() => fetchGitSnapshot({ owner: observation.owner, stub, signal }),
|
|
431
|
+
{ isTransient: transientGistSnapshot, baseDelayMs: snapshotRetryDelayMs });
|
|
424
432
|
const files = [];
|
|
425
433
|
for (const source of snapshot.files) {
|
|
426
434
|
const filename = source.filename;
|
package/scripts/release.mjs
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
import { bindCoverageToReceipt, writeCoverageAssets } from './corpus-coverage-sidecar.mjs';
|
|
72
72
|
import { describeDegraded } from './corpus-store-failure.mjs';
|
|
73
73
|
import { assertNoNewerCorpusGeneration } from './code-release-corpus.mjs';
|
|
74
|
+
import { retryingGh } from './transient-gh.mjs';
|
|
74
75
|
|
|
75
76
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
76
77
|
const PUBLISH = process.argv.includes('--publish');
|
|
@@ -141,11 +142,17 @@ const compareCodeTags = (left, right) => {
|
|
|
141
142
|
return Math.sign(a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
|
|
142
143
|
};
|
|
143
144
|
|
|
144
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* The one gh seam both corpus entry points use (RUVNET_GH_COMMAND / RUVNET_GH_SCRIPT for tests). Reads and the
|
|
147
|
+
* idempotent `release edit` retry a transient failure, bounded (scripts/transient-gh.mjs): on 2026-09-29 the
|
|
148
|
+
* night was red on "cannot confirm the promoted corpus release" -- one `release view` that failed right after
|
|
149
|
+
* the write had succeeded. `release create` runs exactly once; the absence proof above it is what makes a
|
|
150
|
+
* repeat safe to refuse.
|
|
151
|
+
*/
|
|
145
152
|
function corpusGh(env, run) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
const gh = retryingGh({ run, command: env.RUVNET_GH_COMMAND || 'gh', prefix: env.RUVNET_GH_SCRIPT ? [env.RUVNET_GH_SCRIPT] : [],
|
|
154
|
+
options: { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
155
|
+
onRetry: ({ attempt, args, delayMs }) => console.error(`[corpus-seed] gh ${args.slice(0, 2).join(' ')} failed transiently (attempt ${attempt}); retrying in ${delayMs / 1000}s`) });
|
|
149
156
|
const ghJson = (args, label) => {
|
|
150
157
|
const result = gh(args);
|
|
151
158
|
if (result.error || result.status !== 0) {
|
|
@@ -9,6 +9,7 @@ import { readRvfGenerations, sha256File } from './rvf-generation.mjs';
|
|
|
9
9
|
import { canonicalJson, coverageGenerationFor, digest, validateGistAggregateReceipt } from './coverage-integrity.mjs';
|
|
10
10
|
import { repositoryNames } from '../kb/card-lane.mjs';
|
|
11
11
|
import { rootNeverMaterialized, storeRoot } from '../kb/store-root.mjs';
|
|
12
|
+
import { isTransientText, retryTransient, sleepSync } from './transient-gh.mjs';
|
|
12
13
|
|
|
13
14
|
export { canonicalJson, digest } from './coverage-integrity.mjs';
|
|
14
15
|
|
|
@@ -52,6 +53,11 @@ function runGh(args) {
|
|
|
52
53
|
return result.stdout;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// Every observation read below goes through this: a transient failure (rate limit, 5xx, a reset) is retried,
|
|
57
|
+
// bounded, before it can fail the night (scripts/transient-gh.mjs). Applied around the INJECTED `gh`, not
|
|
58
|
+
// inside runGh, so the fault-injection suite exercises the same retry the nightly runs.
|
|
59
|
+
const retryingRead = (gh, sleep) => (args) => retryTransient(() => gh(args), { sleep });
|
|
60
|
+
|
|
55
61
|
// A fork's disposition depends on how many commits it carries that its upstream does not. The
|
|
56
62
|
// compare is pinned to the two heads THIS observation recorded (never to branch names, which move),
|
|
57
63
|
// and is run against the upstream repository in GitHub's documented cross-fork form
|
|
@@ -80,7 +86,8 @@ function observeForkDelta(owner, node, gh) {
|
|
|
80
86
|
};
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
export function observeRepositories(owner, { gh = runGh } = {}) {
|
|
89
|
+
export function observeRepositories(owner, { gh: rawGh = runGh, sleep = sleepSync } = {}) {
|
|
90
|
+
const gh = retryingRead(rawGh, sleep);
|
|
84
91
|
const rows = [];
|
|
85
92
|
const pages = [];
|
|
86
93
|
let cursor = '';
|
|
@@ -103,10 +110,11 @@ export function observeRepositories(owner, { gh = runGh } = {}) {
|
|
|
103
110
|
return { rows: withDeltas, expected, pages };
|
|
104
111
|
}
|
|
105
112
|
|
|
106
|
-
// The row an external source reports
|
|
107
|
-
// stored content classifies CURRENT, nothing is re-ingested, and the row says plainly
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
// The row an external source reports when its upstream cannot be read: exactly what was last observed (or last
|
|
114
|
+
// ingested), so the stored content classifies CURRENT, nothing is re-ingested, and the row says plainly why.
|
|
115
|
+
// `gone` is the policy's ifGone entry (404/410); for a source unreadable for a moment it is the seed ledger's
|
|
116
|
+
// own commit, with `reason` naming the failure.
|
|
117
|
+
function unavailableExternalRow(source, gone, reason) {
|
|
110
118
|
if (!/^[0-9a-f]{40}$/i.test(String(gone.sourceCommit || ''))) {
|
|
111
119
|
throw new Error(`external source ${source.repository}: ifGone.sourceCommit must be the 40-hex commit last ingested`);
|
|
112
120
|
}
|
|
@@ -114,12 +122,27 @@ function goneExternalRow(source) {
|
|
|
114
122
|
name: source.repository.split('/')[1], fullName: source.repository, storeName: source.store,
|
|
115
123
|
url: `https://github.com/${source.repository}`, description: gone.description ?? null, homepageUrl: null,
|
|
116
124
|
isFork: false, upstreamIsFork: false, isArchived: false, isDisabled: false,
|
|
117
|
-
updatedAt: gone.updatedAt ?? null, pushedAt: gone.pushedAt ?? null, upstreamUnavailable: true,
|
|
125
|
+
updatedAt: gone.updatedAt ?? null, pushedAt: gone.pushedAt ?? null, upstreamUnavailable: true, upstreamUnavailableReason: reason,
|
|
118
126
|
defaultBranchRef: { name: gone.defaultBranch || 'HEAD', target: { oid: gone.sourceCommit, committedDate: gone.committedAt ?? null } },
|
|
119
127
|
};
|
|
120
128
|
}
|
|
121
129
|
|
|
122
|
-
|
|
130
|
+
/** The one reason a transient carry records; scripts/corpus-reconcile.mjs refuses a second consecutive one by it. */
|
|
131
|
+
export const TRANSIENT_UNREADABLE_REASON = 'was unreadable this observation (transient failure after the bounded retry); it is read again next night';
|
|
132
|
+
/** The carry record a transiently unreadable source ships: STALE, not CURRENT, and counted toward the degraded bound. */
|
|
133
|
+
export const TRANSIENT_CARRY_REASON = 'transient: upstream unreadable';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `carriedCommits`: { <store>: { sourceCommit, committedAt } } from the seed's own generation ledger, already stripped
|
|
137
|
+
* of any store the seed's coverage shows was ALREADY carried this way (corpus-reconcile.mjs transientCarryCommits),
|
|
138
|
+
* so the same source is never carried two nights running. A source whose read still fails TRANSIENTLY after the
|
|
139
|
+
* bounded retry (rate limit, 5xx, a reset; measured 2026-09-26) is pinned at that commit for ONE night: its row
|
|
140
|
+
* classifies STALE with a `carry` record (nobody read upstream tonight, so it is not called current), it counts
|
|
141
|
+
* toward max(3, 5% of eligible) with every other carried store, and the next night reads it again. Without a prior
|
|
142
|
+
* commit there is nothing honest to carry, and the observation fails.
|
|
143
|
+
*/
|
|
144
|
+
export function observeExternalRepositories(sources, { gh: rawGh = runGh, sleep = sleepSync, carriedCommits = null } = {}) {
|
|
145
|
+
const gh = retryingRead(rawGh, sleep);
|
|
123
146
|
const rows = [];
|
|
124
147
|
const pages = [];
|
|
125
148
|
for (const source of sources) {
|
|
@@ -134,13 +157,28 @@ export function observeExternalRepositories(sources, { gh = runGh } = {}) {
|
|
|
134
157
|
? JSON.parse(gh(['api', `repos/${source.repository}/commits/${repo.default_branch}`]))
|
|
135
158
|
: null;
|
|
136
159
|
} catch (error) {
|
|
137
|
-
// An upstream that GitHub reports gone (404/410) must not stop every night for every customer
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
// An upstream that GitHub reports gone (404/410) must not stop every night for every customer: a source
|
|
161
|
+
// whose policy entry records `ifGone` (the commit it was last ingested at) is carried there. A source that
|
|
162
|
+
// is merely unreadable tonight (still transient after the retry) is carried at the seed's own commit when
|
|
163
|
+
// it has one. Anything else -- a bare 403, a mistyped repository, no prior bytes -- still fails the observation.
|
|
164
|
+
const message = String(error.message);
|
|
165
|
+
const gone = source.ifGone && /\(HTTP (404|410)\)/.test(message);
|
|
166
|
+
const prior = carriedCommits?.[String(source.store).toLowerCase()];
|
|
167
|
+
const carried = String(prior?.sourceCommit ?? prior ?? '').toLowerCase();
|
|
168
|
+
const unreadable = !gone && isTransientText(message) && /^[0-9a-f]{40}$/.test(carried);
|
|
169
|
+
if (!gone && !unreadable) throw error;
|
|
170
|
+
// FIXED shapes, never the error text or a timestamp: the observation digest feeds the no-change decision,
|
|
171
|
+
// so a night that carried one unreadable source and saw nothing else move must still read as unchanged.
|
|
172
|
+
const kind = gone ? 'gone' : 'transient';
|
|
173
|
+
const reason = gone ? 'is no longer reachable (HTTP 404/410)' : TRANSIENT_UNREADABLE_REASON;
|
|
174
|
+
// committedAt comes from the seed's own coverage row (corpus-reconcile.mjs carriedCommittedAt), so the customer's
|
|
175
|
+
// "oldest carried" line dates the pinned commit instead of showing an unknown date.
|
|
176
|
+
const at = gone ? source.ifGone : { sourceCommit: carried, committedAt: prior?.committedAt ?? null };
|
|
177
|
+
process.stderr.write(`[source-coverage] external source ${source.repository} ${reason}; carrying the last ingested content (${at.sourceCommit})`
|
|
178
|
+
+ `${gone ? '' : ` -- ${message.replace(/\s+/g, ' ').trim().slice(0, 200)}`}\n`);
|
|
179
|
+
rows.push({ ...unavailableExternalRow(source, at, reason), upstreamUnavailableKind: kind });
|
|
180
|
+
pages.push({ repository: source.repository, responseDigest: digest(gone ? { gone: source.repository, ifGone: source.ifGone }
|
|
181
|
+
: { unavailable: source.repository, kind, at: carried }), count: 1, terminal: true });
|
|
144
182
|
continue;
|
|
145
183
|
}
|
|
146
184
|
const row = {
|
|
@@ -164,9 +202,11 @@ export function observeSourceUniverse({
|
|
|
164
202
|
externalSources = [],
|
|
165
203
|
gh = runGh,
|
|
166
204
|
observedAt = new Date().toISOString(),
|
|
205
|
+
sleep = sleepSync,
|
|
206
|
+
carriedCommits = null,
|
|
167
207
|
} = {}) {
|
|
168
|
-
const primary = observeRepositories(owner, { gh });
|
|
169
|
-
const external = observeExternalRepositories(externalSources, { gh });
|
|
208
|
+
const primary = observeRepositories(owner, { gh, sleep });
|
|
209
|
+
const external = observeExternalRepositories(externalSources, { gh, sleep, carriedCommits });
|
|
170
210
|
const repositoryRows = canonicalRepositoryRows([...primary.rows, ...external.rows]);
|
|
171
211
|
const repositories = sourceSetObservation(repositoryRows, primary.expected + external.expected);
|
|
172
212
|
const repositoryKeys = new Set();
|
|
@@ -179,7 +219,7 @@ export function observeSourceUniverse({
|
|
|
179
219
|
repositoryKeys.add(repositoryKey);
|
|
180
220
|
storeKeys.add(storeKey);
|
|
181
221
|
}
|
|
182
|
-
const observedGists = observeGists(owner, { gh });
|
|
222
|
+
const observedGists = observeGists(owner, { gh, sleep });
|
|
183
223
|
const gists = sourceSetObservation(canonicalGistRows(observedGists.rows), observedGists.expected);
|
|
184
224
|
return canonicalSourceObservation({
|
|
185
225
|
schemaVersion: 1,
|
|
@@ -222,6 +262,8 @@ export function canonicalRepositoryRows(rows = []) {
|
|
|
222
262
|
isArchived: repo?.isArchived === true,
|
|
223
263
|
isDisabled: repo?.isDisabled === true,
|
|
224
264
|
...(repo?.upstreamUnavailable === true ? { upstreamUnavailable: true } : {}),
|
|
265
|
+
...(repo?.upstreamUnavailable === true && repo?.upstreamUnavailableReason ? { upstreamUnavailableReason: String(repo.upstreamUnavailableReason) } : {}),
|
|
266
|
+
...(repo?.upstreamUnavailable === true && repo?.upstreamUnavailableKind ? { upstreamUnavailableKind: String(repo.upstreamUnavailableKind) } : {}),
|
|
225
267
|
diskUsage: repo?.diskUsage,
|
|
226
268
|
updatedAt: repo?.updatedAt ?? null,
|
|
227
269
|
pushedAt: repo?.pushedAt ?? null,
|
|
@@ -325,7 +367,11 @@ function listGistsUnauthenticated(owner, curl) {
|
|
|
325
367
|
return pages;
|
|
326
368
|
}
|
|
327
369
|
|
|
328
|
-
export function observeGists(owner, { gh = runGh, curl = runCurl } = {}) {
|
|
370
|
+
export function observeGists(owner, { gh: rawGh = runGh, curl: rawCurl = runCurl, sleep = sleepSync } = {}) {
|
|
371
|
+
// A rate limit on either channel (measured 2026-09-26: the gist list) is retried, bounded, before the
|
|
372
|
+
// integration-token 403 is allowed to fall back to the anonymous list, which is itself rate-limited.
|
|
373
|
+
const gh = retryingRead(rawGh, sleep);
|
|
374
|
+
const curl = retryingRead(rawCurl, sleep);
|
|
329
375
|
let pages;
|
|
330
376
|
try {
|
|
331
377
|
const raw = gh(['api', `users/${owner}/gists?per_page=100`, '--paginate', '--slurp']);
|
|
@@ -414,8 +460,19 @@ export function classifyRepository(repo, evidence, exclusion = null, outcome = n
|
|
|
414
460
|
status = 'FAILED';
|
|
415
461
|
reasons.push(outcome.integrity);
|
|
416
462
|
}
|
|
417
|
-
if (repo.upstreamUnavailable === true)
|
|
463
|
+
if (repo.upstreamUnavailable === true) {
|
|
464
|
+
reasons.push(`upstream ${repo.fullName} ${repo.upstreamUnavailableReason || 'is no longer reachable (HTTP 404/410)'}; serving the content last ingested at ${upstreamSha}`);
|
|
465
|
+
}
|
|
418
466
|
const record = {};
|
|
467
|
+
// A source nobody could read tonight is never CURRENT: its bytes are pinned at the last commit the seed carried,
|
|
468
|
+
// and the row ships a carry record (missedUpstream null -- the upstream head is unknown, not merely different)
|
|
469
|
+
// that eligibleRepositoryStanding accepts only together with upstreamUnavailable. A `gone` source stays CURRENT:
|
|
470
|
+
// its upstream was READ and is final.
|
|
471
|
+
if (disposed && repo.upstreamUnavailableKind === 'transient' && status === 'CURRENT') {
|
|
472
|
+
status = 'STALE';
|
|
473
|
+
record.carry = { reason: TRANSIENT_CARRY_REASON, carriedSourceCommit: upstreamSha, missedUpstream: null,
|
|
474
|
+
attempts: 1, carriedCommittedAt: repo.defaultBranchRef?.target?.committedDate || null };
|
|
475
|
+
}
|
|
419
476
|
if (disposed && outcome?.carry && status === 'STALE') record.carry = { ...outcome.carry };
|
|
420
477
|
if (disposed && outcome?.failure && status === 'MISSING') record.failure = { ...outcome.failure };
|
|
421
478
|
return {
|
|
@@ -428,6 +485,7 @@ export function classifyRepository(repo, evidence, exclusion = null, outcome = n
|
|
|
428
485
|
disposition,
|
|
429
486
|
archived: repo.isArchived === true,
|
|
430
487
|
...(repo.upstreamUnavailable === true ? { upstreamUnavailable: true } : {}),
|
|
488
|
+
...(repo.upstreamUnavailable === true && repo.upstreamUnavailableKind ? { upstreamUnavailableKind: repo.upstreamUnavailableKind } : {}),
|
|
431
489
|
...(repo.forkDelta ? { forkDelta: { ...repo.forkDelta } } : {}),
|
|
432
490
|
upstream: { sha: upstreamSha, committedAt: repo.defaultBranchRef?.target?.committedDate || null,
|
|
433
491
|
pushedAt: repo.pushedAt, updatedAt: repo.updatedAt },
|