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.
@@ -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 ? '; retrying once in a fresh worker directory' : '; not retried'));
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
- return observeSourceUniverse({ owner, externalSources: policy.sources });
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
- const result = spawnSync(process.env.RUVNET_GH_COMMAND || 'gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 5 * 60_000 });
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 = JSON.parse(gh(['run', 'list', '--repo', repo, '--workflow', 'protected-release.yml', '--limit', '60',
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} at ${now.toISOString()}`];
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
- result = judgeCorpusHealth(await gatherCorpusHealthInput({ repo, now }), now);
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;
@@ -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
- /** The one gh seam both corpus entry points use (RUVNET_GH_COMMAND / RUVNET_GH_SCRIPT for tests). */
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 ghCommand = env.RUVNET_GH_COMMAND || 'gh';
147
- const ghPrefix = env.RUVNET_GH_SCRIPT ? [env.RUVNET_GH_SCRIPT] : [];
148
- const gh = (args) => run(ghCommand, [...ghPrefix, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
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 once its upstream is gone: exactly what was last observed, so the
107
- // stored content classifies CURRENT, nothing is re-ingested, and the row says plainly it is no longer live.
108
- function goneExternalRow(source) {
109
- const gone = source.ifGone;
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
- export function observeExternalRepositories(sources, { gh = runGh } = {}) {
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
- // Only a source whose policy entry records `ifGone` (the commit it was last ingested at) may be
139
- // carried; anything else -- 403, rate limit, a mistyped repository -- still fails the observation.
140
- if (!source.ifGone || !/\(HTTP (404|410)\)/.test(String(error.message))) throw error;
141
- process.stderr.write(`[source-coverage] external source ${source.repository} is gone upstream; carrying the last ingested content (${source.ifGone.sourceCommit})\n`);
142
- rows.push(goneExternalRow(source));
143
- pages.push({ repository: source.repository, responseDigest: digest({ gone: source.repository, ifGone: source.ifGone }), count: 1, terminal: true });
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) reasons.push(`upstream ${repo.fullName} is no longer reachable (HTTP 404/410); serving the content last ingested at ${upstreamSha}`);
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 },