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.
@@ -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) return detachLearning('--observe', { env, brainHome, observation });
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) }, brainHome ? { brainHome } : undefined);
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
- /** Detached, bounded child (`--observe` one observation, `--distill` the namespace). Only fixed-vocabulary fields cross. */
65
- export function detachLearning(mode, { env = process.env, brainHome, observation } = {}) {
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
- // DETERMINISTIC per (session, tool, action) (review N1): with --no-upsert, rUv's store itself dedupes, so a
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 exact = !retrieved.error && retrieved.status === 0 && String(retrieved.stdout || '').trim() === value;
57
- // A refused store whose key already holds these exact bytes is the dedupe working, not a failure.
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
- throw new Error(`ruflo memory store refused the observation: ${captureFailureReason(stored, stored.status)}`);
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
- function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true) {
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
- const digest = sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]])));
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
- fs.mkdirSync(dir, { recursive: true });
186
- if (fs.existsSync(runnerPath) && sha256(fs.readFileSync(runnerPath)) !== digest) {
187
- throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
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
- if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
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
- function readTurnState(stateFile) {
243
- try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; }
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
- try {
250
- const rows = readTail(receipts, 128 * 1024).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
251
- const receipt = rows.findLast((r) => r.kind === 'store' && r.key === previous.key);
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 queue distillation (SessionEnd, PreCompact).
270
- * Returns a plain report; `queued` means a worker request, never proof the store committed.
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
- if (kind === 'store' && !verified && status === 0) verified = read({ ruflo, db, key, run, options }) === value;
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 && (r.error || status !== 0)) error = captureFailureReason(r, status);
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
- if (row.kind === 'repository' && row.carry && eligibleRepositoryStanding(row) === 'shipped') carried.push(row.artifact?.store || row.key);
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
- /** Phase 1: the approved runtime, installed exactly as `npx ruvnet-brain@<version>` would. */
289
- export function installCustomer({ approvedVersion, work, home, log = () => {} }) {
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
- const TRANSIENT_HTTP = new Set([403, 429, 500, 502, 503, 504]);
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 = main();
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
- const defaultRun = (command, args, options) => spawnSync(command, args, { encoding: 'utf8', ...options });
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'] });