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
|
@@ -1,14 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
4
5
|
import { spawn } from 'node:child_process';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { readStdinBounded } from './hook-input.mjs';
|
|
7
8
|
import { learningContext } from './runtime-preferences.mjs';
|
|
8
|
-
import { recordLearningObservation, distillObservations } from './learning-store.mjs';
|
|
9
|
+
import { recordLearningObservation, distillObservations, observationKey } from './learning-store.mjs';
|
|
9
10
|
import { resolveRuflo } from './ruflo-bin.mjs';
|
|
10
11
|
import { recordHookFailure } from './hook-failures.mjs';
|
|
11
12
|
|
|
13
|
+
// LOAD BOUNDS (measured 2026-10-21: one busy session spawned hundreds of concurrent detached `ruflo memory store`
|
|
14
|
+
// children, which timed out against each other and wrote 826 ledger rows in a day). Owner ruling 2026-10-12 stands:
|
|
15
|
+
// no local queue, no replay — an observation that finds no slot is DROPPED and counted.
|
|
16
|
+
export const OBSERVE_SLOTS = 2; // detached --observe children alive at once, per Brain home
|
|
17
|
+
export const SLOT_STALE_MS = 60_000; // a slot older than this, or held by a dead pid, is reclaimed
|
|
18
|
+
export const COALESCE_MS = 10 * 60_000; // an identical observation key inside this window does not spawn again
|
|
19
|
+
const defaultBrainHome = (env) => env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
20
|
+
const learnDir = (brainHome, sub) => { const dir = path.join(brainHome, 'learn-capture', sub); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; };
|
|
21
|
+
|
|
22
|
+
/** Parent-side coalescing: marker file per key under `learn-capture/recent/`, pruned when it grows past 256 entries. */
|
|
23
|
+
export function recentlyCaptured(brainHome, key, now = Date.now()) {
|
|
24
|
+
try { return now - fs.statSync(path.join(learnDir(brainHome, 'recent'), key)).mtimeMs < COALESCE_MS; } catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
export function markCaptured(brainHome, key, now = Date.now()) {
|
|
27
|
+
const dir = learnDir(brainHome, 'recent');
|
|
28
|
+
try { fs.writeFileSync(path.join(dir, key), '', { mode: 0o600 }); } catch { return; }
|
|
29
|
+
const names = fs.readdirSync(dir);
|
|
30
|
+
if (names.length > 256) for (const name of names) { try { if (now - fs.statSync(path.join(dir, name)).mtimeMs >= COALESCE_MS) fs.unlinkSync(path.join(dir, name)); } catch { /* raced */ } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function slotStale(slot, now) {
|
|
34
|
+
let held;
|
|
35
|
+
try { held = JSON.parse(fs.readFileSync(slot, 'utf8')); } catch { try { return now - fs.statSync(slot).mtimeMs > SLOT_STALE_MS; } catch { return false; } }
|
|
36
|
+
if (!Number.isInteger(held?.pid) || now - Number(held.at) > SLOT_STALE_MS) return true;
|
|
37
|
+
try { process.kill(held.pid, 0); return false; } catch (error) { return error.code === 'ESRCH'; }
|
|
38
|
+
}
|
|
39
|
+
/** O_EXCL slot files `learn-capture/slots/slot-N`; a stale one is reclaimed by rename, so only one claimant wins it. */
|
|
40
|
+
export function acquireObserveSlot(brainHome, now = Date.now()) {
|
|
41
|
+
const dir = learnDir(brainHome, 'slots');
|
|
42
|
+
for (let i = 0; i < OBSERVE_SLOTS; i += 1) {
|
|
43
|
+
const slot = path.join(dir, `slot-${i}`);
|
|
44
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
45
|
+
try { fs.writeFileSync(slot, JSON.stringify({ pid: process.pid, at: now }), { flag: 'wx', mode: 0o600 }); return slot; } catch (error) { if (error.code !== 'EEXIST') break; }
|
|
46
|
+
if (!slotStale(slot, now)) break;
|
|
47
|
+
const claim = `${slot}.reclaim-${process.pid}`;
|
|
48
|
+
try { fs.renameSync(slot, claim); fs.unlinkSync(claim); } catch { break; } // another claimant took it
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
export function releaseObserveSlot(slot, pid = process.pid) {
|
|
54
|
+
try { if (JSON.parse(fs.readFileSync(slot, 'utf8')).pid === pid) fs.unlinkSync(slot); } catch { /* reclaimed already */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every shed is one fixed-string ledger call; hook-failures.mjs's own window counts the repeats on one row. */
|
|
58
|
+
export const SHED_ERROR = 'shed observation (load)';
|
|
59
|
+
export function shedObservation(brainHome) {
|
|
60
|
+
recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: SHED_ERROR }, { brainHome });
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
12
64
|
// Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
|
|
13
65
|
const verbs = new Set('git npm node python python3 cargo rustc go gh cd ls rg cat export mkdir rm cp mv bash sh curl wget npx ruflo'.split(' '));
|
|
14
66
|
const subs = new Set('status diff log show add commit push pull fetch checkout switch branch test run install ci build check fmt clippy auth workflow release memory hooks'.split(' '));
|
|
@@ -30,7 +82,7 @@ export function safeAction(tool, value) {
|
|
|
30
82
|
* session-snapshot's PostToolUse boundary. learningContext() owns consent and brain-off. A refused or
|
|
31
83
|
* unverified write is LOUD: one row in the hook-failures ledger, summarized at the next SessionStart.
|
|
32
84
|
*/
|
|
33
|
-
export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome, detach = false } = {}) {
|
|
85
|
+
export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome = defaultBrainHome(env), detach = false } = {}) {
|
|
34
86
|
let tool; let action;
|
|
35
87
|
try {
|
|
36
88
|
const context = learningContext();
|
|
@@ -48,24 +100,37 @@ export function captureLearning(input, { env = process.env, binary = resolveRufl
|
|
|
48
100
|
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) return false;
|
|
49
101
|
action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
|
|
50
102
|
if (!action) return false;
|
|
51
|
-
if (!binary) throw new Error('ruflo not found');
|
|
103
|
+
if (!binary) throw new Error('ruflo not found'); // one ledger row per 10 min: recordHookFailure counts the repeats
|
|
52
104
|
const observation = { tool, action, session: String(input.session_id || '') };
|
|
105
|
+
const key = observationKey(observation);
|
|
106
|
+
if (recentlyCaptured(brainHome, key)) return true; // identical observation already captured this window: no process
|
|
53
107
|
// Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
|
|
54
108
|
// tool call returns at once; that child writes the ledger row if the write is refused or unverified.
|
|
55
|
-
if (detach)
|
|
109
|
+
if (detach) {
|
|
110
|
+
const slot = acquireObserveSlot(brainHome);
|
|
111
|
+
if (!slot) return shedObservation(brainHome);
|
|
112
|
+
markCaptured(brainHome, key);
|
|
113
|
+
return detachLearning('--observe', { env: { ...env, RUFLO_BIN: binary }, brainHome, observation, slot });
|
|
114
|
+
}
|
|
56
115
|
recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
|
|
116
|
+
markCaptured(brainHome, key);
|
|
57
117
|
return true;
|
|
58
118
|
} catch (error) {
|
|
59
|
-
if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) },
|
|
119
|
+
if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) }, { brainHome });
|
|
60
120
|
return false;
|
|
61
121
|
}
|
|
62
122
|
}
|
|
63
123
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Detached, bounded child (`--observe` one observation, `--distill` the namespace). Only fixed-vocabulary fields
|
|
126
|
+
* cross. The parent's resolved ruflo travels as RUFLO_BIN (resolveRuflo's authoritative seam), so the child never
|
|
127
|
+
* re-resolves it; the slot it holds travels as RUVNET_LEARN_SLOT and is re-stamped with the child's pid.
|
|
128
|
+
*/
|
|
129
|
+
export function detachLearning(mode, { env = process.env, brainHome, observation, slot } = {}) {
|
|
66
130
|
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), mode], { cwd: process.cwd(), detached: true, stdio: 'ignore', windowsHide: true,
|
|
67
|
-
env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}) } });
|
|
131
|
+
env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(slot ? { RUVNET_LEARN_SLOT: slot } : {}) } });
|
|
68
132
|
child.unref();
|
|
133
|
+
if (slot) { try { fs.writeFileSync(slot, JSON.stringify({ pid: child.pid, at: Date.now() }), { mode: 0o600 }); } catch { /* reclaimed by age */ } }
|
|
69
134
|
return true;
|
|
70
135
|
}
|
|
71
136
|
|
|
@@ -80,7 +145,7 @@ function runDetached(mode) {
|
|
|
80
145
|
const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
|
|
81
146
|
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
|
|
82
147
|
recordLearningObservation(binary, context, { tool: o.tool, action: o.action, session: String(o.session || '') }, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
|
|
83
|
-
} catch (error) { fail(error); }
|
|
148
|
+
} catch (error) { fail(error); } finally { if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT); }
|
|
84
149
|
}
|
|
85
150
|
|
|
86
151
|
const isMain = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
|
|
@@ -42,22 +42,32 @@ export function runLearningRuflo(binary, args, db, { env = process.env, deadline
|
|
|
42
42
|
* No local queue, no worker, no replay (owner ruling 2026-10-12): a failure throws to the caller, which
|
|
43
43
|
* records it in the hook-failures ledger so SessionStart reports it.
|
|
44
44
|
*/
|
|
45
|
+
// DETERMINISTIC per (session, tool, action) (review N1): with --no-upsert, rUv's store itself dedupes, so a
|
|
46
|
+
// session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
|
|
47
|
+
export const observationKey = ({ session = '', tool, action }) => 'workflow-' + createHash('sha256').update(JSON.stringify([String(session), tool, action])).digest('hex');
|
|
48
|
+
|
|
45
49
|
export function recordLearningObservation(binary, context, { tool, action, session = '' }, options = {}) {
|
|
46
50
|
const db = learningTarget(context, options);
|
|
47
|
-
|
|
48
|
-
// session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
|
|
49
|
-
const key = 'workflow-' + createHash('sha256').update(JSON.stringify([session, tool, action])).digest('hex');
|
|
51
|
+
const key = observationKey({ session, tool, action });
|
|
50
52
|
const value = JSON.stringify({ schemaVersion: 1, tool, action, scope: context.scope,
|
|
51
53
|
authoritative: false, provenance: 'system-observation', outcome: 'host-reported-success' });
|
|
52
54
|
const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
|
|
53
55
|
const stored = run(['memory', 'store', '--key', key, '--value', value, '--namespace', LEARNING_NAMESPACE,
|
|
54
56
|
'--no-upsert', '--provenance', 'system_observation', '--path', db]);
|
|
55
57
|
const retrieved = run(['memory', 'retrieve', '--key', key, '--namespace', LEARNING_NAMESPACE, '--value-only', '--path', db]);
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
+
const readBack = !retrieved.error && retrieved.status === 0 ? String(retrieved.stdout || '').trim() : null;
|
|
59
|
+
const exact = readBack === value;
|
|
60
|
+
// A refused store whose key already holds these exact bytes is the dedupe working, not a failure (idempotent).
|
|
61
|
+
// Measured 2026-10-21: 141 ledger rows said "already exists" because the readback itself had timed out, so the
|
|
62
|
+
// row blamed the store; the reason names which step failed, and a key holding DIFFERENT bytes stays a failure.
|
|
58
63
|
if (stored.error || stored.status !== 0) {
|
|
59
64
|
if (exact) return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, alreadyStored: true };
|
|
60
|
-
|
|
65
|
+
const reason = captureFailureReason(stored, stored.status);
|
|
66
|
+
if (/already exists/i.test(reason)) {
|
|
67
|
+
if (readBack !== null) throw new Error('duplicate key holds a different observation');
|
|
68
|
+
throw new Error(`exact readback of the existing key failed: ${captureFailureReason(retrieved, retrieved.status)}`);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`ruflo memory store refused the observation: ${reason}`);
|
|
61
71
|
}
|
|
62
72
|
if (!exact) throw new Error('canonical exact CLI readback failed');
|
|
63
73
|
return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true };
|
|
@@ -147,17 +147,23 @@ const BRIDGED_UPDATE_MODULES = [...HISTORICAL_UPDATE_MODULES, 'developer-update-
|
|
|
147
147
|
const LEGACY_UPDATE_MODULES = [...BRIDGED_UPDATE_MODULES, 'developer-update-policy.mjs'];
|
|
148
148
|
const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs')];
|
|
149
149
|
const DEVELOPER_UPDATE_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES])];
|
|
150
|
-
|
|
150
|
+
/** The shipped module set and the content digest it is registered under (`update-modules-<digest>`); reads only. */
|
|
151
|
+
function readModuleSet(sourceDir, developerSuite) {
|
|
151
152
|
const files = developerSuite ? DEVELOPER_UPDATE_MODULES : LEGACY_UPDATE_MODULES;
|
|
152
153
|
const bytes = Object.fromEntries(files.map(file => [file, fs.readFileSync(path.join(sourceDir, file))]));
|
|
153
|
-
|
|
154
|
+
return { files, bytes, digest: sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]]))) };
|
|
155
|
+
}
|
|
156
|
+
const registeredModuleDigest = (record) => /^update-modules-([a-f0-9]{64})$/.exec(path.basename(path.dirname(record.updateModules?.['automatic-update.mjs']?.path || '')))?.[1] || null;
|
|
157
|
+
|
|
158
|
+
function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true, { write = true } = {}) {
|
|
159
|
+
const { files, bytes, digest } = readModuleSet(sourceDir, developerSuite);
|
|
154
160
|
const target = path.join(directory, `update-modules-${digest}`);
|
|
155
|
-
fs.mkdirSync(target, { recursive: true });
|
|
161
|
+
if (write) fs.mkdirSync(target, { recursive: true });
|
|
156
162
|
return Object.fromEntries(files.map(file => {
|
|
157
163
|
const dest = path.join(target, file);
|
|
158
164
|
const hash = sha256(bytes[file]);
|
|
159
|
-
if (fs.existsSync(dest) && sha256(fs.readFileSync(dest)) !== hash) throw new Error('immutable update module was changed');
|
|
160
|
-
if (!fs.existsSync(dest)) fs.writeFileSync(dest, bytes[file], { mode: 0o600 });
|
|
165
|
+
if (write && fs.existsSync(dest) && sha256(fs.readFileSync(dest)) !== hash) throw new Error('immutable update module was changed');
|
|
166
|
+
if (write && !fs.existsSync(dest)) fs.writeFileSync(dest, bytes[file], { mode: 0o600 });
|
|
161
167
|
return [file, { path: dest, sha256: hash }];
|
|
162
168
|
}));
|
|
163
169
|
}
|
|
@@ -174,31 +180,80 @@ function verifyUpdateModules(modules) {
|
|
|
174
180
|
}
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The ONE writer of a nightly registration. `dryRun` builds the exact record it WOULD write (runner, modules, record)
|
|
185
|
+
* and writes nothing — the refresh path compares that candidate before any rename. `environment`: undefined derives
|
|
186
|
+
* the allowlisted values from env; an object is kept verbatim; null omits the field (an older registration without it).
|
|
187
|
+
*/
|
|
177
188
|
export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
|
|
178
|
-
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity) } = {}) {
|
|
189
|
+
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false } = {}) {
|
|
179
190
|
validateIdentity(identity);
|
|
180
191
|
if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
|
|
181
192
|
const bytes = fs.readFileSync(source);
|
|
182
193
|
const digest = sha256(bytes);
|
|
183
194
|
const dir = path.join(brainHome, 'scheduler');
|
|
184
195
|
const runnerPath = path.join(dir, `nightly-refresh-${digest}.mjs`);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
196
|
+
if (!dryRun) {
|
|
197
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
198
|
+
if (fs.existsSync(runnerPath) && sha256(fs.readFileSync(runnerPath)) !== digest) {
|
|
199
|
+
throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
|
|
200
|
+
}
|
|
201
|
+
if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
|
|
188
202
|
}
|
|
189
|
-
|
|
190
|
-
const modules = updateModules(dir, moduleSource, developerSuite);
|
|
203
|
+
const modules = updateModules(dir, moduleSource, developerSuite, { write: !dryRun });
|
|
191
204
|
const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
|
|
192
205
|
nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
|
|
193
|
-
environment: Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])),
|
|
206
|
+
...(environment === null ? {} : { environment: environment ?? Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])) }),
|
|
194
207
|
packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
|
|
195
208
|
const recordPath = path.join(dir, registrationName(identity));
|
|
209
|
+
if (dryRun) return { ...record, recordPath };
|
|
196
210
|
const tmp = `${recordPath}.tmp-${process.pid}`;
|
|
197
211
|
fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
|
|
198
212
|
fs.renameSync(tmp, recordPath);
|
|
199
213
|
return { ...record, recordPath };
|
|
200
214
|
}
|
|
201
215
|
|
|
216
|
+
export const NIGHTLY_ENABLE_FIX = 'npx ruvnet-brain@latest --enable-nightly';
|
|
217
|
+
export const NIGHTLY_UPDATE_FIX = 'npx ruvnet-brain@latest --update';
|
|
218
|
+
/**
|
|
219
|
+
* Does the registered nightly run the code installed now? The scheduler executes a content-addressed snapshot of
|
|
220
|
+
* the update modules that only --enable-nightly used to refresh (measured 2026-10-22: the 4.7.15 developer-update
|
|
221
|
+
* fix shipped to disk and the 03:30 job still ran the old module). Pure read: `current`, `stale` (modules only;
|
|
222
|
+
* --update refreshes them), `runner-changed` (the LaunchAgent command would change: explicit enablement only),
|
|
223
|
+
* or `unregistered`.
|
|
224
|
+
*/
|
|
225
|
+
export function nightlyModuleCurrency({ brainHome, identity = NIGHTLY_LABEL, moduleSource = path.dirname(fileURLToPath(import.meta.url)), runnerSource } = {}) {
|
|
226
|
+
const registration = readNightlyRegistration({ brainHome, identity });
|
|
227
|
+
if (!registration.ok) return { state: 'unregistered', why: registration.why };
|
|
228
|
+
const record = registration.record;
|
|
229
|
+
const registered = registeredModuleDigest(record);
|
|
230
|
+
if (!registered) return { state: 'unregistered', why: 'registration carries no module snapshot', fix: NIGHTLY_ENABLE_FIX };
|
|
231
|
+
const current = readModuleSet(moduleSource, record.mode === 'developer-suite').digest;
|
|
232
|
+
if (runnerSource && sha256(fs.readFileSync(runnerSource)) !== record.runnerSha256) return { state: 'runner-changed', registered, current, record, fix: NIGHTLY_ENABLE_FIX };
|
|
233
|
+
return registered === current ? { state: 'current', registered, current, record } : { state: 'stale', registered, current, record, fix: NIGHTLY_UPDATE_FIX };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* After a successful --update: re-point a stale registration at the installed module set. Same identity, node,
|
|
238
|
+
* runner bytes, environment and targets, so only registration.json and a new update-modules-<digest> dir change
|
|
239
|
+
* and the LaunchAgent command stays exactly what it was; anything else is refused and left as it was.
|
|
240
|
+
*/
|
|
241
|
+
export const SCHEDULER_VISIBLE_FIELDS = Object.freeze(['identity', 'nodePath', 'runnerPath', 'runnerSha256', 'argv', 'environment', 'packageTarget', 'bundleTarget', 'mode']);
|
|
242
|
+
export function refreshNightlyModules(options = {}) {
|
|
243
|
+
const currency = nightlyModuleCurrency(options);
|
|
244
|
+
if (currency.state !== 'stale') return currency;
|
|
245
|
+
const { record } = currency;
|
|
246
|
+
const plan = { brainHome: options.brainHome, identity: record.identity, source: record.runnerPath, nodePath: record.nodePath, packageTarget: record.packageTarget,
|
|
247
|
+
bundleTarget: record.bundleTarget, environment: record.environment ?? null, moduleSource: options.moduleSource, developerSuite: record.mode === 'developer-suite' };
|
|
248
|
+
// Compare the candidate BEFORE anything is written (reviewer proof 2026-10-22: an older registration without
|
|
249
|
+
// `environment` was rewritten with {} and only then refused). `build` is the dry-run of the one writer.
|
|
250
|
+
const candidate = (options.build || ((p) => installNightlyRunner({ ...p, dryRun: true })))(plan);
|
|
251
|
+
const differs = SCHEDULER_VISIBLE_FIELDS.filter((key) => JSON.stringify(candidate[key] ?? null) !== JSON.stringify(record[key] ?? null));
|
|
252
|
+
if (differs.length) return { ...currency, state: 'refused', reason: `refresh would change ${differs.join(', ')}; registration left as it was`, fix: NIGHTLY_ENABLE_FIX };
|
|
253
|
+
const refreshed = installNightlyRunner(plan);
|
|
254
|
+
return { ...currency, state: 'refreshed', registered: registeredModuleDigest(refreshed), fix: undefined };
|
|
255
|
+
}
|
|
256
|
+
|
|
202
257
|
export function readNightlyRegistration({ brainHome, identity = NIGHTLY_LABEL,
|
|
203
258
|
recordPath = path.join(brainHome, 'scheduler', registrationName(identity)) }) {
|
|
204
259
|
try {
|
|
@@ -239,20 +239,18 @@ export function launchDetached(steps, { receipts, brainHome, projectDir, env = p
|
|
|
239
239
|
return { launched: true, pid: child.pid };
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
const readTurnState = (stateFile) => { try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; } };
|
|
243
|
+
|
|
244
|
+
/** The newest store receipt for this exact key, while the receipt tail still holds it. */
|
|
245
|
+
const storeReceipt = (receipts, key) => { try { return readTail(receipts, 128 * 1024).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } }).findLast((r) => r.kind === 'store' && r.key === key) || null; } catch { return null; } };
|
|
246
|
+
const verifiedReceipt = (r) => r?.status === 0 && r.verified === true;
|
|
245
247
|
|
|
246
248
|
function sameTurnSeen(stateFile, sessionKey, fingerprint, receipts) {
|
|
247
249
|
const previous = readTurnState(stateFile)[sessionKey];
|
|
248
250
|
if (previous?.fingerprint !== fingerprint) return false;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (receipt) return receipt.status === 0 && receipt.verified === true;
|
|
253
|
-
} catch { /* queued worker has no receipt yet */ }
|
|
254
|
-
// An in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
|
|
255
|
-
return Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
|
|
251
|
+
const receipt = storeReceipt(receipts, previous.key);
|
|
252
|
+
// No receipt yet: an in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
|
|
253
|
+
return receipt ? verifiedReceipt(receipt) : Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
|
|
256
254
|
}
|
|
257
255
|
|
|
258
256
|
function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
|
|
@@ -266,9 +264,8 @@ function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
|
|
|
266
264
|
}
|
|
267
265
|
|
|
268
266
|
/**
|
|
269
|
-
* Capture this turn's outcome (Stop) and/or
|
|
270
|
-
*
|
|
271
|
-
* `skipped` / `distill.skipped` carry the reason whenever nothing is requested.
|
|
267
|
+
* Capture this turn's outcome (Stop) and/or start distillation (SessionEnd, PreCompact). Returns a plain report:
|
|
268
|
+
* `queued` means a request, never proof the store committed; `skipped` / `distill.skipped` carry the reason.
|
|
272
269
|
*/
|
|
273
270
|
export function captureTurnOutcome({
|
|
274
271
|
projectDir, event, payload = {}, host = 'claude',
|
|
@@ -322,10 +319,12 @@ export function captureTurnOutcome({
|
|
|
322
319
|
const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
|
|
323
320
|
const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
|
|
324
321
|
const identity = crypto.createHash('sha256').update(`${host}:${db}:${sessionKey}`).digest('hex');
|
|
322
|
+
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
325
323
|
if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
|
|
324
|
+
// Proven at Stop already: SessionEnd/PreCompact must not re-send it (2026-10-19/20: each re-send was an "append rejected" row).
|
|
325
|
+
else if (verifiedReceipt(storeReceipt(receipts, key))) report.skipped = 'turn key already verified at an earlier boundary';
|
|
326
326
|
else {
|
|
327
327
|
const at = now();
|
|
328
|
-
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
329
328
|
dedupe = { stateFile, identity, fingerprint, key };
|
|
330
329
|
const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
|
|
331
330
|
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '--key', key, '--value', value, '--namespace', TURN_NAMESPACE,
|
|
@@ -444,13 +443,14 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
|
|
|
444
443
|
delete options.env.CLAUDE_FLOW_MEMORY_PATH;
|
|
445
444
|
const r = run(invocation.executable, invocation.args, options);
|
|
446
445
|
status = Number.isInteger(r.status) ? r.status : 1;
|
|
447
|
-
|
|
448
|
-
if (verified) status = 0;
|
|
446
|
+
const duplicate = kind === 'store' && status !== 0 && /already exists/i.test(captureFailureReason(r, status)); // append-only refusal
|
|
447
|
+
if (kind === 'store' && (status === 0 || duplicate)) { verified = read({ ruflo, db, key, run, options }) === value; if (verified) status = 0; } // identical bytes = success
|
|
449
448
|
if (kind === 'distill' && status === 0) {
|
|
450
449
|
const match = /(?:^|\n)\s*skipped:\s*([^\n]+)/i.exec(String(r.stdout || '').replace(/\x1b\[[0-9;]*m/g, ''));
|
|
451
450
|
if (match) skipped = redactText(match[1]).slice(0, 300);
|
|
452
451
|
}
|
|
453
|
-
if (!verified &&
|
|
452
|
+
if (!verified && duplicate) error = 'duplicate turn key holds different content';
|
|
453
|
+
else if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
|
|
454
454
|
else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
455
455
|
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
456
456
|
if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
|
|
@@ -46,6 +46,7 @@ import fs from 'node:fs';
|
|
|
46
46
|
import os from 'node:os';
|
|
47
47
|
import path from 'node:path';
|
|
48
48
|
import { spawnSync } from 'node:child_process';
|
|
49
|
+
import { retryTransientResult } from './transient-gh.mjs';
|
|
49
50
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
50
51
|
|
|
51
52
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -190,9 +191,12 @@ const semverDescending = (left, right) => {
|
|
|
190
191
|
return b[0] - a[0] || b[1] - a[1] || b[2] - a[2];
|
|
191
192
|
};
|
|
192
193
|
|
|
194
|
+
// Every read here is a `gh` read of public release metadata or assets; a transient failure (rate limit, 5xx,
|
|
195
|
+
// a reset mid-download of the ~555 MB archive) is retried, bounded, before it can fail the dispatcher, the
|
|
196
|
+
// identity job or the publisher (scripts/transient-gh.mjs). A refused aggregate is an answer and is never retried.
|
|
193
197
|
function defaultGh(args) {
|
|
194
|
-
const result = spawnSync(process.env.RUVNET_GH_COMMAND || 'gh', args,
|
|
195
|
-
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 15 * 60_000 });
|
|
198
|
+
const result = retryTransientResult(() => spawnSync(process.env.RUVNET_GH_COMMAND || 'gh', args,
|
|
199
|
+
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 15 * 60_000 }));
|
|
196
200
|
if (result.error || result.status !== 0) {
|
|
197
201
|
throw new Error(`gh ${args.slice(0, 3).join(' ')} failed: ${String(result.error?.message || result.stderr || `exit ${result.status}`).trim().slice(0, 400)}`);
|
|
198
202
|
}
|
|
@@ -47,6 +47,7 @@ import { addPrivateStore, resolveRuntime, resultFromRefreshReceipt, runInstaller
|
|
|
47
47
|
import { CANARY_VERDICT_KIND, CORPUS_TAG_PATTERN, REQUIRED_CANARY_CHECKS } from './corpus-promotion.mjs';
|
|
48
48
|
import { inventoryFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
49
49
|
import { eligibleRepositoryStanding } from '../plugin/scripts/coverage-integrity.mjs';
|
|
50
|
+
import { TRANSIENT_HTTP, retryTransientAsync } from './transient-gh.mjs';
|
|
50
51
|
|
|
51
52
|
export const PUBLIC_API = 'https://api.github.com';
|
|
52
53
|
export const FRESHNESS_LIMIT_MS = 48 * 3_600_000;
|
|
@@ -151,9 +152,11 @@ export function storeFreshness({ after, before = null, now }) {
|
|
|
151
152
|
const upstream = row.upstream?.sha || null;
|
|
152
153
|
const built = row.artifact?.sourceCommit || null;
|
|
153
154
|
if (!upstream || !built) continue;
|
|
155
|
+
// Named either way: a refresh that failed (upstream read, differs) or an upstream nobody could read tonight
|
|
156
|
+
// (pinned at the carried commit, `upstreamUnavailable`). Both are carried with a record the validator accepts.
|
|
157
|
+
if (row.kind === 'repository' && row.carry && eligibleRepositoryStanding(row) === 'shipped') { carried.push(row.artifact?.store || row.key); continue; }
|
|
154
158
|
if (upstream !== built) {
|
|
155
|
-
|
|
156
|
-
else stale.push(row.artifact?.store || row.key);
|
|
159
|
+
stale.push(row.artifact?.store || row.key);
|
|
157
160
|
continue;
|
|
158
161
|
}
|
|
159
162
|
if (before && priorCommit.get(row.key) !== upstream) moved += 1;
|
|
@@ -285,15 +288,18 @@ function runLogged(command, args, { env, cwd, timeoutMs, log }) {
|
|
|
285
288
|
return result;
|
|
286
289
|
}
|
|
287
290
|
|
|
288
|
-
/**
|
|
289
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Phase 1: the approved runtime, installed exactly as `npx ruvnet-brain@<version>` would. The registry read is
|
|
293
|
+
* retried on a transient failure (bounded) so a registry blip reads as weather, never as "the customer refused".
|
|
294
|
+
*/
|
|
295
|
+
export async function installCustomer({ approvedVersion, work, home, log = () => {}, retryDelayMs = 30_000 }) {
|
|
290
296
|
const env = customerEnv({ home, work });
|
|
291
297
|
fs.mkdirSync(env.TMPDIR, { recursive: true });
|
|
292
298
|
const prefix = path.join(work, 'npx');
|
|
293
299
|
fs.mkdirSync(prefix, { recursive: true });
|
|
294
300
|
const npm = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
295
|
-
runLogged(npm, ['install', '--prefix', prefix, '--no-audit', '--no-fund', '--no-save', `ruvnet-brain@${approvedVersion}`],
|
|
296
|
-
{ env, cwd: prefix, timeoutMs: 600_000, log });
|
|
301
|
+
await retryTransientAsync(() => runLogged(npm, ['install', '--prefix', prefix, '--no-audit', '--no-fund', '--no-save', `ruvnet-brain@${approvedVersion}`],
|
|
302
|
+
{ env, cwd: prefix, timeoutMs: 600_000, log }), { baseDelayMs: retryDelayMs, onRetry: ({ attempt }) => log(`--- npm install failed transiently (attempt ${attempt}); retrying`) });
|
|
297
303
|
const installer = path.join(prefix, 'node_modules', 'ruvnet-brain', 'bin', 'install.mjs');
|
|
298
304
|
const installed = readJson(path.join(prefix, 'node_modules', 'ruvnet-brain', 'package.json')).version;
|
|
299
305
|
if (installed !== approvedVersion) throw new Error(`npm served ruvnet-brain ${installed}, not the approved ${approvedVersion}`);
|
|
@@ -316,8 +322,8 @@ function runUpdater({ kbDir, env, resultFile }) {
|
|
|
316
322
|
}
|
|
317
323
|
|
|
318
324
|
// An anonymous GitHub API read from a shared runner address is sometimes refused (403/429) or fails (5xx)
|
|
319
|
-
// for a moment; one such blip must not fail the night. The same call is retried after a short wait.
|
|
320
|
-
|
|
325
|
+
// for a moment; one such blip must not fail the night. The same call is retried after a short wait. The
|
|
326
|
+
// status set is the pipeline's one definition (scripts/transient-gh.mjs TRANSIENT_HTTP), not a private copy.
|
|
321
327
|
async function fetchJson(url, what, { retryDelayMs = 15_000, maxAttempts = 4 } = {}) {
|
|
322
328
|
for (let attempt = 1; ; attempt += 1) {
|
|
323
329
|
let status = null;
|
|
@@ -19,11 +19,31 @@
|
|
|
19
19
|
// the new workflow passes (--approved-tag, --no-change-out, --stage-candidate, --promote-staged), so the nightly stands down rather than
|
|
20
20
|
// let new workflow text drive old scripts.
|
|
21
21
|
|
|
22
|
+
//
|
|
23
|
+
// THE RETRY SLOT (2026-10-20). Measured on 38 nights: 24 failed, most on one transient blip, and nothing
|
|
24
|
+
// retried until the next calendar day. The dispatcher now also fires at RETRY_CRON (11:17 UTC, four hours
|
|
25
|
+
// after the night's first slot) and asks decideRetry(): re-dispatch ONLY when every corpus run of the
|
|
26
|
+
// night so far ended `failed` (or reached no outcome), none is still running, none ended published /
|
|
27
|
+
// no-change / superseded / canary-rejected (those are answers, not weather), and fewer than
|
|
28
|
+
// MAX_CORPUS_RUNS_PER_NIGHT runs exist -- so one night gets at most one retry, and a deterministic
|
|
29
|
+
// failure fails closed a second time rather than looping. The kill switch and the approved-runtime
|
|
30
|
+
// resolution still run after it, exactly as on the first slot. Double publication is impossible by
|
|
31
|
+
// construction: a failed run promoted nothing, every generation is content-addressed, and release.mjs
|
|
32
|
+
// refuses a tag that already exists.
|
|
33
|
+
|
|
22
34
|
import fs from 'node:fs';
|
|
23
35
|
import path from 'node:path';
|
|
24
36
|
import { pathToFileURL } from 'node:url';
|
|
25
37
|
|
|
26
38
|
export const MIN_PIPELINE_VERSION = '4.3.38'; // sync-version-ignore: a fixed pipeline floor, not the current version
|
|
39
|
+
/** The dispatcher's second schedule entry; corpus-nightly-dispatch.yml's `on.schedule` must carry exactly this line. */
|
|
40
|
+
export const RETRY_CRON = '17 11 * * *';
|
|
41
|
+
/** How far back the retry slot looks for the night's runs: past the 07:17 UTC first slot, never into yesterday. */
|
|
42
|
+
export const RETRY_WINDOW_MS = 10 * 3_600_000;
|
|
43
|
+
export const MAX_CORPUS_RUNS_PER_NIGHT = 2;
|
|
44
|
+
// `unknown` (every job green, no outcome) is retried only when the run itself did NOT conclude success: a green
|
|
45
|
+
// run that reached no outcome is a workflow defect the terminal-outcome job already fails loudly, not weather.
|
|
46
|
+
const retryable = (run) => run.outcome === 'failed' || (run.outcome === 'unknown' && run.conclusion !== 'success');
|
|
27
47
|
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
28
48
|
const HEX40 = /^[0-9a-f]{40}$/;
|
|
29
49
|
const CODE_TAG = /^v\d+\.\d+\.\d+$/;
|
|
@@ -82,6 +102,21 @@ export function decide({ nightlyVar, resolution, minPipelineVersion = MIN_PIPELI
|
|
|
82
102
|
return { verdict: 'arm', reason: `building the corpus at approved runtime ${tag} @ ${sourceSha}`, tag, version, sourceSha };
|
|
83
103
|
}
|
|
84
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The retry slot's one decision. `runs`: the night's protected-release corpus runs so far
|
|
107
|
+
* (scripts/corpus-watchdog.mjs gatherCorpusRuns, each with `outcome` from classifyCorpusRun).
|
|
108
|
+
* Returns { verdict: 'retry' | 'stand-down', reason }.
|
|
109
|
+
*/
|
|
110
|
+
export function decideRetry({ runs = [], maxRuns = MAX_CORPUS_RUNS_PER_NIGHT } = {}) {
|
|
111
|
+
if (!runs.length) return { verdict: 'stand-down', reason: 'the first slot dispatched no corpus run tonight (it stood down or failed); the watchdog judges that, a retry would not' };
|
|
112
|
+
const running = runs.find((run) => run.outcome === 'in-progress');
|
|
113
|
+
if (running) return { verdict: 'stand-down', reason: `corpus run ${running.id} is still in progress` };
|
|
114
|
+
const settled = runs.find((run) => !retryable(run));
|
|
115
|
+
if (settled) return { verdict: 'stand-down', reason: `corpus run ${settled.id} ended ${settled.outcome}; that is the night's answer, not weather` };
|
|
116
|
+
if (runs.length >= maxRuns) return { verdict: 'stand-down', reason: `${runs.length} corpus run(s) already failed tonight; the bound is ${maxRuns} per night, so this is not retried again` };
|
|
117
|
+
return { verdict: 'retry', reason: `corpus run ${runs.map((run) => run.id).join(', ')} failed and nothing published; dispatching the night's one retry` };
|
|
118
|
+
}
|
|
119
|
+
|
|
85
120
|
/** approved-runtime.mjs --resolve's exit status + stdout (+ stderr, naming a refused release) -> a resolution. */
|
|
86
121
|
export function resolutionFrom({ status, stdout, stderr = '' }) {
|
|
87
122
|
if (String(status) === '3') return { status: 'not-yet-verified' };
|
|
@@ -107,12 +142,31 @@ const arg = (argv, name) => {
|
|
|
107
142
|
* CLI: --nightly <value> --resolve-status <n|skipped> --release <file> [--resolve-stderr <file>]
|
|
108
143
|
* --github-output <file>. Exit 1 only on `fail`.
|
|
109
144
|
* --is-off <value> prints true|false (lets the workflow skip the ~555 MB resolve when the switch is off)
|
|
145
|
+
* --retry-slot --repo <owner/name> [--now ISO] --github-output <file>
|
|
146
|
+
* writes retry=true|false and retry_reason; exit 1 only when run history could not be read
|
|
110
147
|
*/
|
|
111
|
-
export function main(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
148
|
+
export async function main(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
112
149
|
if (argv.includes('--is-off')) {
|
|
113
150
|
stdout.write(`${killSwitchOff(arg(argv, '--is-off'))}\n`);
|
|
114
151
|
return 0;
|
|
115
152
|
}
|
|
153
|
+
if (argv.includes('--retry-slot')) {
|
|
154
|
+
const { classifyCorpusRun, gatherCorpusRuns } = await import('./corpus-watchdog.mjs');
|
|
155
|
+
const now = arg(argv, '--now') ? Date.parse(arg(argv, '--now')) : Date.now();
|
|
156
|
+
const repo = arg(argv, '--repo') || process.env.GITHUB_REPOSITORY;
|
|
157
|
+
let decision;
|
|
158
|
+
try {
|
|
159
|
+
const runs = gatherCorpusRuns({ repo, since: now - RETRY_WINDOW_MS }).map((run) => ({ ...run, outcome: classifyCorpusRun(run) }));
|
|
160
|
+
decision = decideRetry({ runs });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
stdout.write(`::error::corpus-nightly-dispatch retry slot could not read the night's runs: ${error.message}\n`);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
const githubOutput = arg(argv, '--github-output');
|
|
166
|
+
if (githubOutput) fs.appendFileSync(path.resolve(githubOutput), `retry=${decision.verdict === 'retry'}\nretry_reason=${decision.reason.replace(/\n/g, ' ')}\n`);
|
|
167
|
+
stdout.write(`::notice::corpus-nightly-dispatch retry slot ${decision.verdict}: ${decision.reason}\n`);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
116
170
|
const nightlyVar = arg(argv, '--nightly') ?? '';
|
|
117
171
|
const status = arg(argv, '--resolve-status');
|
|
118
172
|
const releaseFile = arg(argv, '--release');
|
|
@@ -134,5 +188,5 @@ export function main(argv = process.argv.slice(2), { stdout = process.stdout } =
|
|
|
134
188
|
}
|
|
135
189
|
|
|
136
190
|
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
137
|
-
process.exitCode =
|
|
191
|
+
main().then((code) => { process.exitCode = code; });
|
|
138
192
|
}
|
|
@@ -61,6 +61,7 @@ import path from 'node:path';
|
|
|
61
61
|
import { spawnSync } from 'node:child_process';
|
|
62
62
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
63
63
|
import { COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET, bindCoverageToReceipt, verifyCoverageSidecar } from './corpus-coverage-sidecar.mjs';
|
|
64
|
+
import { retryTransientResult } from './transient-gh.mjs';
|
|
64
65
|
|
|
65
66
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
66
67
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
@@ -76,7 +77,10 @@ const REQUIRED_ASSETS = [ARCHIVE_ASSET, SIGNATURE_ASSET, RECEIPT_ASSET, RECALL_A
|
|
|
76
77
|
/** ADR-0091 D4: judge at most this many of the newest generations, then use the committed bootstrap. */
|
|
77
78
|
export const SEARCH_BOUND = 5;
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
// Every `gh` call below is a read (release list, release view, small-asset download). A transient failure is
|
|
81
|
+
// retried, bounded, before it is recorded as `indeterminate` (scripts/transient-gh.mjs): an unretried blip here
|
|
82
|
+
// used to send the whole night back to the committed bootstrap seed -- a full rebuild dressed as a quiet night.
|
|
83
|
+
const defaultRun = (command, args, options) => retryTransientResult(() => spawnSync(command, args, { encoding: 'utf8', ...options }));
|
|
80
84
|
|
|
81
85
|
function ghJson(run, args) {
|
|
82
86
|
const result = run('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|