ruvnet-brain 4.7.24 → 4.7.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +58 -28
- package/kb/lifecycle-evidence-retention.mjs +11 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/brain-footprint.mjs +8 -1
- package/plugin/scripts/developer-update.mjs +109 -11
- package/plugin/scripts/footprint-io.mjs +22 -0
- package/plugin/scripts/nightly-scheduler.mjs +12 -1
- package/plugin/scripts/turn-outcome-capture.mjs +8 -4
- package/scripts/nightly-watchdog.mjs +17 -5
- package/scripts/release-qualification-contract.mjs +9 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
9
9
|
|
|
10
10
|
### Discover the stack. Read the source. Build with confidence.
|
|
11
11
|
|
|
12
|
-
[](plugin/.claude-plugin/plugin.json)
|
|
13
13
|
|
|
14
14
|
**A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
|
|
15
15
|
|
package/bin/install.mjs
CHANGED
|
@@ -2108,6 +2108,12 @@ export function codexBrokenMarketplaces(text) {
|
|
|
2108
2108
|
}
|
|
2109
2109
|
const sleepSync = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
2110
2110
|
|
|
2111
|
+
// `codex plugin list` refreshes Codex's remote plugin catalog when its cache is stale: a 56 MB download measured at
|
|
2112
|
+
// 38-40 s on the owner's Mac (codex-cli 0.166.0, 2026-10-30). The old 30 s limit killed it every time, and a killed
|
|
2113
|
+
// fetch writes no cache, so every later attempt (the 03:30 nightly, each retry, each --update) repeated the cold fetch
|
|
2114
|
+
// and failed the same way until someone used Codex by hand. Bounded, with ~4x headroom over the measured fetch.
|
|
2115
|
+
export const CODEX_PLUGIN_COMMAND_TIMEOUT_MS = 180_000;
|
|
2116
|
+
|
|
2111
2117
|
export function runCodexJson(args, {
|
|
2112
2118
|
codexBin = process.env.CODEX_BIN || 'codex',
|
|
2113
2119
|
codexHome = codexHomeDir(),
|
|
@@ -2117,9 +2123,10 @@ export function runCodexJson(args, {
|
|
|
2117
2123
|
// Refreshing another tool's marketplace is a network write; only an install/update that is about to change the
|
|
2118
2124
|
// Brain's Codex plugin opts in. --doctor and listings report the broken snapshot and change nothing (review 2026-10-24).
|
|
2119
2125
|
repair = false,
|
|
2126
|
+
timeoutMs = CODEX_PLUGIN_COMMAND_TIMEOUT_MS,
|
|
2120
2127
|
} = {}) {
|
|
2121
2128
|
const options = { stdio: 'pipe', shell: false, cwd, env: { ...process.env, CODEX_HOME: codexHome }, encoding: 'utf8',
|
|
2122
|
-
timeout:
|
|
2129
|
+
timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 };
|
|
2123
2130
|
const snapshotFailure = (res) => !res.missingBinary && res.status !== 0 && CODEX_SNAPSHOT_FAILURE.test(String(res.stderr || res.stdout || ''));
|
|
2124
2131
|
let r = run(codexBin, args, options);
|
|
2125
2132
|
// One wait covers a refresh Codex is finishing; then refresh only the OTHER Git marketplaces it named (the Brain's
|
|
@@ -4156,51 +4163,66 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
4156
4163
|
return { ok: false, results, ...detail, error: detail.error || hostFailureReason(results) };
|
|
4157
4164
|
};
|
|
4158
4165
|
|
|
4166
|
+
// ONE HOST CANNOT HOLD THE OTHERS BACK (2026-10-30). A Codex failure used to return before the Stable Spine and the
|
|
4167
|
+
// convergence receipt, so a single `codex plugin list` timeout left Claude Code on the new plugin, the runtime and
|
|
4168
|
+
// Codex on the old one, and no receipt saying so (owner's Mac, 4.7.21 -> 4.7.24, three nights). Each host is now
|
|
4169
|
+
// attempted on its own, the runtime is activated regardless, and a failed host is RECORDED as `failed` with its
|
|
4170
|
+
// reason: the run still ends not-ok, so the night still fails and pages, but nothing is left half-updated.
|
|
4171
|
+
const failedHosts = new Set();
|
|
4172
|
+
const hostError = (error) => error?.message || String(error);
|
|
4159
4173
|
try {
|
|
4160
4174
|
if (wireClaude === wirePlugin) results.hookRetirement = retireManagedHookRegistrations();
|
|
4161
4175
|
results.claude = wireClaude === wirePlugin
|
|
4162
4176
|
? wirePlugin({ expectedVersion: PACKAGE_VERSION, requireManaged: true })
|
|
4163
4177
|
: wireClaude({ expectedVersion: PACKAGE_VERSION, requireManaged: true });
|
|
4164
|
-
if (results.claude.host && !results.claude.wired)
|
|
4178
|
+
if (results.claude.host && !results.claude.wired) failedHosts.add('claude');
|
|
4179
|
+
} catch (error) {
|
|
4180
|
+
results.claude = { host: true, wired: false, action: 'failed', error: hostError(error) };
|
|
4181
|
+
failedHosts.add('claude');
|
|
4182
|
+
}
|
|
4183
|
+
try {
|
|
4165
4184
|
results.codexHost = detectCodexHost();
|
|
4166
4185
|
if (results.codexHost.host) {
|
|
4167
4186
|
if (!['added', 'rewritten', 'unchanged', 'user-owned'].includes(results.codexHost.action)) {
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
results.
|
|
4176
|
-
|
|
4177
|
-
|
|
4187
|
+
results.codexHostError = `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}`;
|
|
4188
|
+
failedHosts.add('codex');
|
|
4189
|
+
} else {
|
|
4190
|
+
results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
|
|
4191
|
+
// ~/.codex exists but no `codex` binary is on PATH (an uninstalled CLI, or a desktop-only setup whose shell
|
|
4192
|
+
// cannot see it): there is no Codex host to converge here, which is absence, not a failed update (4.7.21,
|
|
4193
|
+
// audit C-opus #6). Any other unavailability (the binary ran and failed) still fails.
|
|
4194
|
+
if (results.codex.action === 'codex-unavailable' && results.codex.missingBinary === true) {
|
|
4195
|
+
results.codexAbsent = true;
|
|
4196
|
+
} else if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
|
|
4197
|
+
failedHosts.add('codex');
|
|
4198
|
+
}
|
|
4178
4199
|
}
|
|
4179
4200
|
}
|
|
4180
4201
|
} catch (error) {
|
|
4181
|
-
results.codex = { action: 'failed', error:
|
|
4182
|
-
|
|
4202
|
+
results.codex = { action: 'failed', error: hostError(error) };
|
|
4203
|
+
failedHosts.add('codex');
|
|
4183
4204
|
}
|
|
4184
4205
|
|
|
4185
4206
|
const apply = path.join(sourceRoot, 'plugin', 'scripts', 'update-apply.mjs');
|
|
4186
4207
|
if (!fs.existsSync(apply)) return fail({ error: 'Stable Spine updater missing from package' });
|
|
4187
4208
|
const applied = runStableSpine(apply);
|
|
4188
4209
|
const okApplied = !applied.error && applied.status === 0;
|
|
4189
|
-
const codexReceipt =
|
|
4190
|
-
state: results.codex?.
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4210
|
+
const codexReceipt = failedHosts.has('codex')
|
|
4211
|
+
? { state: 'failed', version: results.codex?.version || null, error: hostFailureReason({ codex: results.codex, codexHostError: results.codexHostError }) }
|
|
4212
|
+
: {
|
|
4213
|
+
state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host && !results.codexAbsent ? 'ready' : 'absent'),
|
|
4214
|
+
version: results.codex?.version || null,
|
|
4215
|
+
};
|
|
4216
|
+
if (!failedHosts.has('codex') && results.codex?.restartRequired === true) {
|
|
4194
4217
|
codexReceipt.restartRequired = true;
|
|
4195
4218
|
codexReceipt.sessionSafety = results.codex.sessionSafety || null;
|
|
4196
4219
|
codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
|
|
4197
4220
|
codexReceipt.restartScope = results.codex.restartScope || 'unproven';
|
|
4198
4221
|
}
|
|
4199
|
-
const claudeReceipt =
|
|
4200
|
-
state: results.claude.
|
|
4201
|
-
version: results.claude.version || null
|
|
4202
|
-
|
|
4203
|
-
if (results.claude?.restartRequired === true) {
|
|
4222
|
+
const claudeReceipt = failedHosts.has('claude')
|
|
4223
|
+
? { state: 'failed', version: results.claude.version || null, error: hostFailureReason({ claude: results.claude }) }
|
|
4224
|
+
: { state: results.claude.host ? 'ready' : 'absent', version: results.claude.version || null };
|
|
4225
|
+
if (!failedHosts.has('claude') && results.claude?.restartRequired === true) {
|
|
4204
4226
|
claudeReceipt.restartRequired = true;
|
|
4205
4227
|
claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
|
|
4206
4228
|
claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
|
|
@@ -4287,6 +4309,11 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
4287
4309
|
};
|
|
4288
4310
|
const convergence = classifyHostConvergence(convergedReceipt);
|
|
4289
4311
|
hostLock.release();
|
|
4312
|
+
if (failedHosts.size) {
|
|
4313
|
+
// The runtime and every other host converged; the failed host is the night's failure, with its own reason.
|
|
4314
|
+
return { ok: false, convergence, results, applyStatus: applied.status, failedHosts: [...failedHosts],
|
|
4315
|
+
error: `${hostFailureReason(results)}; runtime ${PACKAGE_VERSION} and the other hosts converged` };
|
|
4316
|
+
}
|
|
4290
4317
|
return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
|
|
4291
4318
|
...(convergence.healthy !== true && hostRestartIsOnlyGap(convergedReceipt) ? { restartOnly: true } : {}),
|
|
4292
4319
|
error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
|
|
@@ -4425,13 +4452,16 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
4425
4452
|
/** Why host synchronization stopped, from the per-host results it had gathered; null when nothing says. */
|
|
4426
4453
|
export function hostFailureReason(results = {}) {
|
|
4427
4454
|
const firstLines = (text) => String(text || '').trim().split('\n').slice(0, 3).join(' ');
|
|
4455
|
+
// Every failed host is named: since hosts no longer stop at the first failure, two can fail in one run.
|
|
4456
|
+
const reasons = [];
|
|
4428
4457
|
if (results.claude?.host && !results.claude.wired) {
|
|
4429
|
-
|
|
4458
|
+
reasons.push(`Claude Code plugin ${results.claude.action || 'wiring failed'}${results.claude.version ? ` (installed ${results.claude.version}, required ${results.claude.expectedVersion || PACKAGE_VERSION})` : ''}${results.claude.error ? `: ${firstLines(results.claude.error)}` : ''}`);
|
|
4430
4459
|
}
|
|
4431
|
-
if (results.
|
|
4432
|
-
|
|
4460
|
+
if (results.codexHostError) reasons.push(results.codexHostError);
|
|
4461
|
+
else if (results.codex && !['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
|
|
4462
|
+
reasons.push(`Codex plugin ${results.codex.action || 'failed'}${results.codex.error ? `: ${firstLines(results.codex.error)}` : ''}`);
|
|
4433
4463
|
}
|
|
4434
|
-
return null;
|
|
4464
|
+
return reasons.length ? reasons.join('; ') : null;
|
|
4435
4465
|
}
|
|
4436
4466
|
|
|
4437
4467
|
export function hostSynchronizationFailureMessage(convergence, active, expectedVersion = PACKAGE_VERSION) {
|
|
@@ -349,8 +349,17 @@ function compactTerminalPhases(row) {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
export const MAX_VANISHED_RETRIES = 64;
|
|
352
|
+
// A prune stops this far UNDER the byte budget, not at it. The update that prunes still writes its own terminal refresh
|
|
353
|
+
// receipt afterwards; measured on the owner's Mac on 2026-10-30, a prune left 16,769,613 bytes (8 KiB under the
|
|
354
|
+
// 16 MiB budget), the settled receipt pushed it to 16,785,892, and --doctor reported the evidence as cruft after a
|
|
355
|
+
// clean update. The room is made by compacting terminal phase files first, then by removing the oldest removable entry.
|
|
356
|
+
export const PRUNE_HEADROOM_BYTES = 1024 * 1024;
|
|
352
357
|
export function pruneLifecycleEvidence(options = {}) {
|
|
353
358
|
const policy = options.policy || LIFECYCLE_EVIDENCE_RETENTION_POLICY;
|
|
359
|
+
// 1 MiB on the real 16 MiB budget; never more than a sixteenth of a (test-sized) budget.
|
|
360
|
+
const headroom = Number.isFinite(options.headroomBytes) ? Math.max(0, options.headroomBytes)
|
|
361
|
+
: Math.min(PRUNE_HEADROOM_BYTES, Math.floor(policy.maxEvidenceBytes / 16));
|
|
362
|
+
const roomy = (state) => isWithin(state, policy) && state.bytes <= policy.maxEvidenceBytes - headroom;
|
|
354
363
|
const removeEntry = options.removeEntry || ((entry) => fs.rmSync(entry, { recursive: true, force: true }));
|
|
355
364
|
// A prior invocation's quarantine name is not proof of ownership of its current
|
|
356
365
|
// bytes. Preserve it as unsafe, measured evidence; only this invocation may remove
|
|
@@ -393,7 +402,7 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
393
402
|
}
|
|
394
403
|
const compacted = [];
|
|
395
404
|
const compactState = snapshot(options);
|
|
396
|
-
if (!compactState.unsafe.length && compactState.bytes > policy.maxEvidenceBytes) {
|
|
405
|
+
if (!compactState.unsafe.length && compactState.bytes > policy.maxEvidenceBytes - headroom) {
|
|
397
406
|
for (const row of compactState.transactions.filter((entry) => TERMINAL_TRANSACTION.has(entry.latest.state))) {
|
|
398
407
|
compacted.push(...compactTerminalPhases(row));
|
|
399
408
|
}
|
|
@@ -404,7 +413,7 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
404
413
|
let vanishedRetries = 0; let stopped = null;
|
|
405
414
|
for (;;) {
|
|
406
415
|
const state = snapshot(options);
|
|
407
|
-
if (
|
|
416
|
+
if (roomy(state)) break;
|
|
408
417
|
if (state.unsafe.length) break;
|
|
409
418
|
const refreshCandidates = state.refresh.filter((row) => TERMINAL_REFRESH.has(row.receipt.status)
|
|
410
419
|
&& !state.protectedRefresh.has(row.path)).sort((a, b) => a.timestamp - b.timestamp);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.25",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.7.
|
|
4
|
+
"version": "4.7.25",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -68,8 +68,15 @@ const RECOVERY_LEFTOVER = /(?:\.(?:bak|retired|dead)-\d{6,}|^bootstrap-backup-\d
|
|
|
68
68
|
// One list with ruflo-bin.mjs, which clears the same names before each call (incl. a self-update's npm install, 1.3 GB on 2026-10-22).
|
|
69
69
|
const RUFLO_DEBRIS = new Set(RUFLO_CWD_ARTIFACTS);
|
|
70
70
|
// Directories in the brain home that hold data another tool owns: measured, shown, never budgeted or removed.
|
|
71
|
+
// The maintainer-only issue-autofix job (ADR-107, scripts/issue-autofix-state.mjs) keeps its clone, runner and fix
|
|
72
|
+
// worktrees here too: ~1.1 GB on the owner's Mac on 2026-10-30, counted as "Brain state", which put a correctly
|
|
73
|
+
// pruned Brain 0.7 GB over its budget every night. They are that job's working data, not the product's footprint.
|
|
71
74
|
const FOREIGN = { 'ruvector-mcp': 'RuVector MCP server working store (its own live memory)',
|
|
72
|
-
'worktree-recovery': 'hand-made recovery archives, not created by the Brain'
|
|
75
|
+
'worktree-recovery': 'hand-made recovery archives, not created by the Brain',
|
|
76
|
+
'autofix-repo': 'the maintainer issue-autofix job\'s clone (ADR-107); that job owns and prunes it',
|
|
77
|
+
'autofix-runner': 'the maintainer issue-autofix job\'s pinned runner worktree (ADR-107); that job owns it',
|
|
78
|
+
'issue-fix-worktrees': 'the maintainer issue-autofix job\'s fix worktrees (ADR-107); that job owns and prunes them',
|
|
79
|
+
'issue-autofix': 'the maintainer issue-autofix job\'s run receipts (ADR-107); that job owns them' };
|
|
73
80
|
const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']); // DISPOSED: operator recovery (#335)
|
|
74
81
|
|
|
75
82
|
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
@@ -232,6 +232,9 @@ export function pluginUpdateDecision(plugin, target) {
|
|
|
232
232
|
return { state: target.version === plugin.version ? 'CURRENT' : 'UPDATE_AVAILABLE', proof: target.proof,
|
|
233
233
|
sourceCommitMatched: target.catalogCommit ? plugin.gitCommitSha?.toLowerCase() === target.catalogCommit : null };
|
|
234
234
|
}
|
|
235
|
+
// Codex refreshes a remote plugin catalog (56 MB, 38-40 s cold, 2026-10-30) inside its plugin commands; a killed refresh
|
|
236
|
+
// caches nothing, so a 30 s limit failed every later night the same way. Same bound as bin/install.mjs.
|
|
237
|
+
export const CODEX_COMMAND_TIMEOUT_MS = 180_000;
|
|
235
238
|
export async function synchronizePlugins(run, dryRun, notes, { home, prefix, scope, locate, unattended = false, artifactProvider = pinnedClaudeTarget }) {
|
|
236
239
|
const installed = path.join(home, '.claude/plugins/installed_plugins.json');
|
|
237
240
|
const inventoryOptions = { unattended };
|
|
@@ -290,10 +293,10 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
|
|
|
290
293
|
for (const p of after.filter(p => writable.some(b => scopeKey(b) === scopeKey(p)))) if (!exists(p.installPath)) throw Error(`plugin artifact missing: ${p.id}`);
|
|
291
294
|
const codex = locate('codex');
|
|
292
295
|
if (codex) {
|
|
293
|
-
const result = JSON.parse(run(codex, ['plugin', 'marketplace', 'list', '--json'], { timeout:
|
|
296
|
+
const result = JSON.parse(run(codex, ['plugin', 'marketplace', 'list', '--json'], { timeout: CODEX_COMMAND_TIMEOUT_MS }));
|
|
294
297
|
const git = (result.marketplaces || []).filter(m => m.marketplaceSource?.sourceType === 'git' && (scope === 'all' || PLUGIN_MARKETPLACES.has(m.name)));
|
|
295
298
|
if (!dryRun) for (const m of git) run(codex, ['plugin', 'marketplace', 'upgrade', m.name, '--json']);
|
|
296
|
-
const help = run(codex, ['plugin', '--help'], { timeout:
|
|
299
|
+
const help = run(codex, ['plugin', '--help'], { timeout: CODEX_COMMAND_TIMEOUT_MS });
|
|
297
300
|
// Tripwire, reported not thrown: a new Codex command must not end the night before the knowledge is checked (AuditC #17).
|
|
298
301
|
if (/^\s+update\s/m.test(help)) notes.push('Codex plugin update now exists and is unsupported until its scope-aware policy is reviewed; installed generations preserved');
|
|
299
302
|
else notes.push('Codex has no plugin update command; configured Git catalogues refreshed, installed generations preserved');
|
|
@@ -307,6 +310,13 @@ export const INSTALL_UPDATE_TIMEOUT_MS = 135 * 60_000;
|
|
|
307
310
|
export const TRANSIENT_RETRY_DELAYS_MS = Object.freeze([15_000, 30_000]);
|
|
308
311
|
const sleepSync = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
309
312
|
const exitOf = result => typeof result === 'string' ? 0 : (result?.exitCode ?? result?.status ?? result?.code ?? null);
|
|
313
|
+
/** The lines of a child's output that say why it failed (the ✗/failed/incomplete ones, else its last lines), ANSI-free, <= 600 chars. */
|
|
314
|
+
export function outputReason(result) {
|
|
315
|
+
if (!result || typeof result === 'string') return '';
|
|
316
|
+
const lines = `${result.stderr || ''}\n${result.stdout || ''}`.replace(/\x1b\[[0-9;]*m/g, '').split('\n').map(l => l.trim()).filter(Boolean);
|
|
317
|
+
const telling = lines.filter(l => /✗|\berror\b|failed|incomplete|refused|timed out|ETIMEDOUT/i.test(l));
|
|
318
|
+
return [...new Set((telling.length ? telling : lines).slice(-3))].join(' | ').slice(-600);
|
|
319
|
+
}
|
|
310
320
|
export const TRANSIENT_NETWORK = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|network|timed out|\bE5\d\d\b|\b50[234]\b/i;
|
|
311
321
|
/** Three tries of `attempt` (15 s, then 30 s apart) while `transient(error)` says the failure was the network's. */
|
|
312
322
|
export function withTransientRetry(attempt, { transient = () => true, sleep = sleepSync, delays = TRANSIENT_RETRY_DELAYS_MS } = {}) {
|
|
@@ -314,15 +324,17 @@ export function withTransientRetry(attempt, { transient = () => true, sleep = sl
|
|
|
314
324
|
try { return attempt(i); } catch (error) { if (i >= delays.length || !transient(error)) throw error; sleep(delays[i]); }
|
|
315
325
|
}
|
|
316
326
|
}
|
|
317
|
-
export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = sleepSync }) {
|
|
327
|
+
export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = sleepSync, convergeRuntime = true }) {
|
|
318
328
|
const kb = path.join(brainHome, 'kb'), updater = path.join(kb, 'forge-update.mjs');
|
|
319
329
|
if (!exists(updater)) throw Error('existing Brain KB updater absent; fresh install refused');
|
|
320
330
|
const file = path.join(brainHome, `nightly-suite-corpus-${runId}.json`);
|
|
321
331
|
// Exit codes are forge-update's: 0 current, 10 newer, 2 network (retried), 5 incompatible with this runtime.
|
|
332
|
+
let lastOutput = '';
|
|
322
333
|
const exec = (args, options) => {
|
|
323
334
|
if (!run.receipt) { run(node, args, options); return 0; }
|
|
324
335
|
const result = run.receipt(node, args, options);
|
|
325
|
-
|
|
336
|
+
lastOutput = outputReason(result);
|
|
337
|
+
if (result?.error) throw Error(`${path.basename(args[0])} ${args[1]}: ${result.error}${lastOutput ? ` — ${lastOutput}` : ''}`);
|
|
326
338
|
return exitOf(result);
|
|
327
339
|
};
|
|
328
340
|
function check() {
|
|
@@ -333,7 +345,7 @@ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = s
|
|
|
333
345
|
return status;
|
|
334
346
|
}, { transient: error => error.transient === true, sleep });
|
|
335
347
|
if (code === 5) return { currencyVerdict: 'INCOMPATIBLE', exitCode: 5 };
|
|
336
|
-
if (code !== 0 && code !== 10) throw Error(`corpus check exited ${code}`);
|
|
348
|
+
if (code !== 0 && code !== 10) throw Error(`corpus check exited ${code}${lastOutput ? `: ${lastOutput}` : ''}`);
|
|
337
349
|
let receipt;
|
|
338
350
|
try { receipt = read(file); } finally { fs.rmSync(file, { force: true }); }
|
|
339
351
|
if (receipt.kind !== 'ruvnet-brain-check-result' || Date.parse(receipt.recordedAt) < started - 1000) throw Error('fresh corpus check receipt absent');
|
|
@@ -344,7 +356,8 @@ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = s
|
|
|
344
356
|
if (!exists(installer)) throw Error('existing Brain installer absent');
|
|
345
357
|
// 12 is cleanup-pending: the knowledge was applied and the next run finishes the cleanup (shared exit contract).
|
|
346
358
|
const code = exec([installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: INSTALL_UPDATE_TIMEOUT_MS, allowed: [0, 12] });
|
|
347
|
-
|
|
359
|
+
// The installer's own words ride along: "exited 1" alone hid a Codex timeout for three nights (2026-10-28..30).
|
|
360
|
+
if (code !== 0 && code !== 12) throw Error(`Brain installer --update exited ${code}${lastOutput ? `: ${lastOutput}` : ''}`);
|
|
348
361
|
return code;
|
|
349
362
|
};
|
|
350
363
|
const before = check();
|
|
@@ -360,7 +373,21 @@ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = s
|
|
|
360
373
|
if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error(`corpus update failed to converge after re-stamping ${stamped} -> ${installed}`);
|
|
361
374
|
return { before, after, state: 'updated', installerExit, restamped: { from: stamped, to: installed } };
|
|
362
375
|
}
|
|
363
|
-
if (before.currencyVerdict === 'CURRENT' || before.currencyVerdict === 'REFUSED')
|
|
376
|
+
if (before.currencyVerdict === 'CURRENT' || before.currencyVerdict === 'REFUSED') {
|
|
377
|
+
// A CURRENT corpus is not a converged machine: the package stage can install a newer ruvnet-brain on a night with no
|
|
378
|
+
// new corpus, and only the installer's --update moves the runtime and hosts to it (a no-op on the corpus). Before
|
|
379
|
+
// this, such a night reported "current" and left runtime + hosts on the old version until a corpus was published.
|
|
380
|
+
const runtime = runtimeConvergence({ root, brainHome });
|
|
381
|
+
const state = before.currencyVerdict === 'CURRENT' ? 'current' : 'ahead-preserved';
|
|
382
|
+
if (runtime.converged) return { before, after: before, state, runtime };
|
|
383
|
+
// The SessionStart self-heal (--knowledge-only) runs unattended without the nightly's identity, so an installer
|
|
384
|
+
// run there would count as interactive (and could reload a shared Codex app-server): it reports, the nightly acts.
|
|
385
|
+
if (!convergeRuntime) return { before, after: before, state, runtime, deferred: 'runtime convergence is left to the scheduled nightly' };
|
|
386
|
+
if (dryRun) return { before, state: 'update-available', reason: runtime.reason, runtime };
|
|
387
|
+
const installerExit = apply(), after = check(), converged = runtimeConvergence({ root, brainHome });
|
|
388
|
+
if (!converged.converged) throw Error(`runtime did not converge: ${converged.reason}`);
|
|
389
|
+
return { before, after, state: 'runtime-converged', installerExit, runtime: converged };
|
|
390
|
+
}
|
|
364
391
|
// UNKNOWN is what a tree installed from a code release reports (no corpus generation stamp): forge-update
|
|
365
392
|
// documents it as apply-allowed, exactly like UPDATE_AVAILABLE, and the check after the apply must reach
|
|
366
393
|
// CURRENT or REFUSED. Throwing here failed every scheduled night until someone ran --update by hand.
|
|
@@ -369,7 +396,34 @@ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = s
|
|
|
369
396
|
const installerExit = apply();
|
|
370
397
|
const after = check();
|
|
371
398
|
if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error('corpus update failed to converge');
|
|
372
|
-
|
|
399
|
+
// Positive confirmation: the corpus converged AND the runtime and hosts sit on the installed package.
|
|
400
|
+
const runtime = runtimeConvergence({ root, brainHome });
|
|
401
|
+
if (!runtime.converged) throw Error(`corpus updated but the runtime did not converge: ${runtime.reason}`);
|
|
402
|
+
return { before, after, state: 'updated', installerExit, runtime };
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Does this machine RUN the installed ruvnet-brain package? The Stable Spine (active.json) and the host-convergence
|
|
406
|
+
* receipt must name that version (or a newer one: ahead is not behind) and no host may be recorded failed. Evidence
|
|
407
|
+
* that does not exist is not judged (an install without a spine has nothing to compare).
|
|
408
|
+
*/
|
|
409
|
+
export function runtimeConvergence({ root = ROOT, brainHome = CACHE } = {}) {
|
|
410
|
+
const value = (file) => { try { return read(file); } catch { return null; } };
|
|
411
|
+
// A linked source checkout's version is not a release the runtime can be converged to (it may be ahead of npm): its
|
|
412
|
+
// versions are not compared, but a host recorded failed is still a host that did not converge.
|
|
413
|
+
let linked = false;
|
|
414
|
+
try { linked = fs.lstatSync(path.join(root, 'ruvnet-brain')).isSymbolicLink(); } catch { /* absent: judged below */ }
|
|
415
|
+
const installed = linked ? null : value(path.join(root, 'ruvnet-brain/package.json'))?.version || null;
|
|
416
|
+
const active = value(path.join(brainHome, 'active.json'));
|
|
417
|
+
const receipt = value(path.join(brainHome, 'host-convergence.json'));
|
|
418
|
+
const evidence = { installed, active: active?.version || null, desired: receipt?.desiredVersion || null };
|
|
419
|
+
const behind = (version) => { try { return !version || compare(version, installed) < 0; } catch { return true; } };
|
|
420
|
+
if (!installed && !linked) return { converged: true, ...evidence, reason: 'no installed ruvnet-brain package' };
|
|
421
|
+
if (!linked && active && behind(active.version)) return { converged: false, ...evidence, reason: `runtime ${active.version || 'unknown'} is behind the installed package ${installed}` };
|
|
422
|
+
if (!linked && receipt && behind(receipt.desiredVersion)) return { converged: false, ...evidence, reason: `hosts were last converged to ${receipt.desiredVersion || 'unknown'}, not the installed package ${installed}` };
|
|
423
|
+
const failed = Object.entries(receipt?.hosts || {}).filter(([, host]) => !['ready', 'disabled', 'absent'].includes(host?.state));
|
|
424
|
+
if (failed.length) return { converged: false, ...evidence, reason: `host ${failed.map(([name, host]) => `${name} ${host?.state || 'unknown'}${host?.error ? ` (${host.error})` : ''}`).join(', ')}` };
|
|
425
|
+
if (linked) return { converged: true, ...evidence, reason: 'ruvnet-brain is a linked source checkout' };
|
|
426
|
+
return { converged: true, ...evidence, reason: active || receipt ? `runtime and hosts on ${installed}` : 'no runtime evidence to compare' };
|
|
373
427
|
}
|
|
374
428
|
/** Corpus check results this coordinator left behind (a run killed between check and delete) older than 48 h. */
|
|
375
429
|
export function pruneCorpusResults(brainHome, now = Date.now()) {
|
|
@@ -419,7 +473,31 @@ export function locateExecutable(name, { env = process.env, platform = process.p
|
|
|
419
473
|
}
|
|
420
474
|
return null;
|
|
421
475
|
}
|
|
422
|
-
|
|
476
|
+
// A failed scheduled knowledge stage is tried once more the same night, this long after the first attempt (a network or
|
|
477
|
+
// release-publication blip clears; a deterministic failure fails twice and pages with both reasons).
|
|
478
|
+
export const SCHEDULED_RETRY_DELAY_MS = 20 * 60_000;
|
|
479
|
+
/**
|
|
480
|
+
* Page the owner about a scheduled night through the shipped scripts/notify.sh (ntfy). Topic: NTFY_TOPIC, else the
|
|
481
|
+
* owner's `<brain home>/ntfy-topic` opt-in file; neither = paging off (recorded, not an error). Never throws, never
|
|
482
|
+
* records the topic. `kind`: 'failed' (urgent) or 'recovered' (after a failed night).
|
|
483
|
+
*/
|
|
484
|
+
export function pageNight(receipt, kind, { root = ROOT, brainHome = CACHE, env = process.env, runner = invoke, platform = process.platform } = {}) {
|
|
485
|
+
try {
|
|
486
|
+
if (platform === 'win32') return { kind, state: 'unsupported', reason: 'scripts/notify.sh needs a POSIX shell' };
|
|
487
|
+
const notify = path.join(root, 'ruvnet-brain/scripts/notify.sh');
|
|
488
|
+
if (!exists(notify)) return { kind, state: 'unavailable', reason: 'the installed package has no scripts/notify.sh' };
|
|
489
|
+
let topic = env.NTFY_TOPIC;
|
|
490
|
+
if (topic === undefined) { try { topic = fs.readFileSync(path.join(brainHome, 'ntfy-topic'), 'utf8').trim(); } catch { topic = ''; } }
|
|
491
|
+
if (!topic) return { kind, state: 'off', reason: 'no ntfy topic (NTFY_TOPIC or the brain home ntfy-topic file)' };
|
|
492
|
+
const title = kind === 'failed' ? 'RuvNet Brain nightly update FAILED' : 'RuvNet Brain nightly update recovered';
|
|
493
|
+
const body = `${kind === 'failed' ? receipt.error || receipt.state : `completed after a failed night (${receipt.state})`}\n\nrun ${receipt.runId}, started ${receipt.startedAt}\nreceipt: ${path.join(brainHome, 'scheduler', 'last-suite-attempt.json')}`;
|
|
494
|
+
const r = runner('/bin/sh', [notify, title, body.slice(0, 1500), kind === 'failed' ? 'urgent' : 'default', kind === 'failed' ? 'rotating_light' : 'white_check_mark'],
|
|
495
|
+
{ capture: true, timeout: 30_000, env: { ...env, NTFY_TOPIC: topic } });
|
|
496
|
+
const exitCode = r?.exitCode ?? null;
|
|
497
|
+
return { kind, state: exitCode === 0 ? 'delivered' : exitCode === 2 ? 'off' : 'not-delivered', exitCode, at: new Date().toISOString() };
|
|
498
|
+
} catch (error) { return { kind, state: 'not-delivered', reason: error.message }; }
|
|
499
|
+
}
|
|
500
|
+
export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env, knowledgeOnly = false, sleep = sleepSync, retryDelayMs = SCHEDULED_RETRY_DELAY_MS } = {}) {
|
|
423
501
|
if (!['check', 'apply'].includes(mode)) throw Error('mode must be check or apply');
|
|
424
502
|
const paths = developerUpdatePaths({ home, brainHome });
|
|
425
503
|
const config = validateDeveloperUpdateConfig(override || readDeveloperUpdateConfig(paths));
|
|
@@ -467,6 +545,8 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
467
545
|
}
|
|
468
546
|
};
|
|
469
547
|
let latestMayBeReplaced = receipt.scheduled;
|
|
548
|
+
let previousScheduled = null;
|
|
549
|
+
try { previousScheduled = receipt.scheduled ? readAttempt(paths.scheduledReceipt) : null; } catch { previousScheduled = null; }
|
|
470
550
|
try {
|
|
471
551
|
if (!receipt.scheduled) {
|
|
472
552
|
const latest = readAttempt(paths.receipt), retained = readAttempt(paths.scheduledReceipt);
|
|
@@ -521,8 +601,20 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
521
601
|
}
|
|
522
602
|
}
|
|
523
603
|
if (exists(path.join(paths.brainHome, 'kb/forge-update.mjs'))) {
|
|
524
|
-
|
|
525
|
-
error => { receipt.knowledge = { state: 'failed', error: error.message }; })
|
|
604
|
+
const knowledgeStage = () => stage('knowledge', () => knowledge(run, mode === 'check', receipt.runId, { brainHome: paths.brainHome, root, node: process.execPath, sleep, convergeRuntime: !knowledgeOnly }),
|
|
605
|
+
error => { receipt.knowledge = { state: 'failed', error: error.message }; });
|
|
606
|
+
receipt.knowledge = await knowledgeStage() ?? receipt.knowledge;
|
|
607
|
+
// SELF-RETRY THE SAME NIGHT (2026-10-30): a scheduled night whose knowledge stage failed tries once more after
|
|
608
|
+
// retryDelayMs. Only the second attempt's outcome counts; the first one's reason stays on the receipt.
|
|
609
|
+
if (receipt.scheduled && mode === 'apply' && receipt.knowledge?.state === 'failed') {
|
|
610
|
+
const firstAttemptError = receipt.knowledge.error;
|
|
611
|
+
receipt.notes.push(`knowledge attempt 1 failed (${firstAttemptError}); retrying once in ${Math.round(retryDelayMs / 60_000)} min`);
|
|
612
|
+
persistReceipt();
|
|
613
|
+
sleep(retryDelayMs);
|
|
614
|
+
const firstFailure = failures.findIndex(f => f.stage === 'knowledge');
|
|
615
|
+
if (firstFailure >= 0) failures.splice(firstFailure, 1);
|
|
616
|
+
receipt.knowledge = { ...(await knowledgeStage() ?? receipt.knowledge), attempts: 2, firstAttemptError };
|
|
617
|
+
}
|
|
526
618
|
pruneCorpusResults(paths.brainHome);
|
|
527
619
|
} else receipt.notes.push('Brain corpus absent; fresh knowledge install excluded');
|
|
528
620
|
persistReceipt();
|
|
@@ -556,6 +648,12 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
556
648
|
throw error;
|
|
557
649
|
} finally {
|
|
558
650
|
receipt.finishedAt = new Date().toISOString();
|
|
651
|
+
// Every scheduled night ends with a word to the owner when it failed, and once more when it recovers.
|
|
652
|
+
if (receipt.scheduled && mode === 'apply') {
|
|
653
|
+
const previousFailed = previousScheduled && previousScheduled.ok !== true && previousScheduled.state !== 'running';
|
|
654
|
+
if (receipt.ok !== true) receipt.page = pageNight(receipt, 'failed', { root: receipt.npmIdentity?.root, brainHome: paths.brainHome, env, runner });
|
|
655
|
+
else if (previousFailed) receipt.page = pageNight(receipt, 'recovered', { root: receipt.npmIdentity?.root, brainHome: paths.brainHome, env, runner });
|
|
656
|
+
}
|
|
559
657
|
try { if (latestMayBeReplaced) persistReceipt(); } finally { lock.release(); }
|
|
560
658
|
}
|
|
561
659
|
}
|
|
@@ -131,7 +131,29 @@ export function treeBytes(target) {
|
|
|
131
131
|
|
|
132
132
|
/** Atomic rename-rotation: <name> -> <name>.1 (replacing the previous .1). Appenders reopen by path. */
|
|
133
133
|
export function rotate(file) { fs.renameSync(file, `${file}.1`); }
|
|
134
|
+
/**
|
|
135
|
+
* Keep a text log's newest tail IN PLACE (same inode). A rename-over gave the log a new inode, so a writer that still
|
|
136
|
+
* held it open (launchd's StandardOutPath for the nightly, whose own installer runs this sweep) wrote the rest of the
|
|
137
|
+
* night into a deleted file (review, 2026-10-30). Appenders (O_APPEND) continue at the new end.
|
|
138
|
+
*/
|
|
139
|
+
// In-place trimming is only safe for an O_APPEND writer; the SessionStart jobs open their `.last-*.log` with 'w' (a
|
|
140
|
+
// trim under them would leave a hole of zero bytes), so only the launchd nightly log, appended to all night, is
|
|
141
|
+
// trimmed in place; every other text log keeps the rename-over trim (review r2, 2026-10-30).
|
|
142
|
+
const TRIM_IN_PLACE = new Set(['.last-nightly-update.log']);
|
|
134
143
|
export function truncateToTail(file, keep) {
|
|
144
|
+
if (!TRIM_IN_PLACE.has(path.basename(file))) return truncateByRename(file, keep);
|
|
145
|
+
const fd = fs.openSync(file, 'r+');
|
|
146
|
+
try {
|
|
147
|
+
const size = fs.fstatSync(fd).size;
|
|
148
|
+
const buf = Buffer.alloc(Math.min(keep, size));
|
|
149
|
+
fs.readSync(fd, buf, 0, buf.length, size - buf.length);
|
|
150
|
+
const nl = buf.indexOf(10);
|
|
151
|
+
const tail = nl >= 0 ? buf.subarray(nl + 1) : buf;
|
|
152
|
+
fs.ftruncateSync(fd, 0);
|
|
153
|
+
fs.writeSync(fd, tail, 0, tail.length, 0);
|
|
154
|
+
} finally { fs.closeSync(fd); }
|
|
155
|
+
}
|
|
156
|
+
function truncateByRename(file, keep) {
|
|
135
157
|
const size = fs.statSync(file).size;
|
|
136
158
|
const fd = fs.openSync(file, 'r');
|
|
137
159
|
const buf = Buffer.alloc(Math.min(keep, size));
|
|
@@ -609,6 +609,17 @@ function cronRows(run) {
|
|
|
609
609
|
return { ok: true, rows: String(listed.stdout || '').split('\n').filter(Boolean) };
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Where the scheduled run's own output goes. On macOS it was kb/update.log, INSIDE the tree each knowledge update
|
|
614
|
+
* replaces: launchd wrote the night into the old tree, the swap moved it aside and the updater reclaimed it, so every
|
|
615
|
+
* failed night's log was gone by morning (owner's Mac, 2026-10-28..30: no update.log existed). The brain home keeps it;
|
|
616
|
+
* the `.last-*.log` name puts it under the footprint's text-log cap (truncated to its tail past 512 KiB). Linux keeps
|
|
617
|
+
* its exact registered cron line (changing it would read every existing install as degraded); Windows logs nothing.
|
|
618
|
+
*/
|
|
619
|
+
export function nightlyLogPath({ platform = process.platform, kbDir }) {
|
|
620
|
+
return platform === 'darwin' ? path.join(path.dirname(kbDir), '.last-nightly-update.log') : path.join(kbDir, 'update.log');
|
|
621
|
+
}
|
|
622
|
+
|
|
612
623
|
export function installScheduler(record, { platform = process.platform, env = process.env, kbDir,
|
|
613
624
|
run = spawnSync, testMode = false, proofTick = false, proofAt, pathValue = process.env.PATH || '' } = {}) {
|
|
614
625
|
validateIdentity(record.identity);
|
|
@@ -616,7 +627,7 @@ export function installScheduler(record, { platform = process.platform, env = pr
|
|
|
616
627
|
const artifact = nightlyArtifact({ platform, env, brainHome: path.dirname(kbDir), identity: record.identity });
|
|
617
628
|
if (!artifact.supported) return { ok: false, artifact, why: `unsupported platform: ${platform}` };
|
|
618
629
|
if (testMode && platform !== 'darwin') run = fixtureSchedulerRun(env, platform, record.identity);
|
|
619
|
-
const logPath =
|
|
630
|
+
const logPath = nightlyLogPath({ platform, kbDir });
|
|
620
631
|
if (platform === 'darwin') {
|
|
621
632
|
fs.mkdirSync(path.dirname(artifact.path), { recursive: true });
|
|
622
633
|
fs.writeFileSync(artifact.path, launchdPlist(record, { kbDir, logPath, pathValue, env }));
|
|
@@ -40,7 +40,7 @@ import crypto from 'node:crypto';
|
|
|
40
40
|
import { spawn, spawnSync } from 'node:child_process';
|
|
41
41
|
import { fileURLToPath } from 'node:url';
|
|
42
42
|
import { nativeTurnLines } from './native-turn-transcript.mjs';
|
|
43
|
-
import { resolveRuflo, rufloInvocation,
|
|
43
|
+
import { resolveRuflo, rufloInvocation, rufloRunDir, rufloScratchRoot } from './ruflo-bin.mjs';
|
|
44
44
|
import { redactText } from './continuity-events.mjs';
|
|
45
45
|
import { resolveProjectStore, globalEnrollmentAllowed, storeEncryptedAtRest, ENCRYPTED_STORE_REASON } from './project-store-resolver.mjs';
|
|
46
46
|
import { recordHookFailure } from './hook-failures.mjs';
|
|
@@ -388,7 +388,7 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
|
|
|
388
388
|
};
|
|
389
389
|
const results = [];
|
|
390
390
|
for (const queued of steps) {
|
|
391
|
-
let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
|
|
391
|
+
let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = []; let scratchRun = null;
|
|
392
392
|
const kind = queued.kind;
|
|
393
393
|
try {
|
|
394
394
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
@@ -439,9 +439,12 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
|
|
|
439
439
|
if (remaining <= 0) throw new Error('turn write deadline exhausted');
|
|
440
440
|
// Measured 2026-10-10 with global Ruflo 3.56.3: cwd=<project>/.swarm litters ruvector.db/.claude-flow there, and
|
|
441
441
|
// CLAUDE_FLOW_MEMORY_PATH=<.swarm> makes Ruflo create a second agentdb-memory.db beside the canonical store.
|
|
442
|
-
// The explicit --path already binds the store, so run from private scratch with neither.
|
|
442
|
+
// The explicit --path already binds the store, so run from private scratch with neither. One fresh run-* directory
|
|
443
|
+
// per call, removed after it (ruflo-bin rufloRunDir): the shared project scratch dir used here before kept ruflo's
|
|
444
|
+
// ruvector.db/.swarm/.claude-flow copies between turns, which --doctor counted as cruft after every turn (2026-10-30).
|
|
445
|
+
scratchRun = rufloRunDir(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) });
|
|
443
446
|
const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), windowsHide: true,
|
|
444
|
-
cwd:
|
|
447
|
+
cwd: scratchRun,
|
|
445
448
|
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV } };
|
|
446
449
|
delete options.env.CLAUDE_FLOW_MEMORY_PATH;
|
|
447
450
|
const r = run(invocation.executable, invocation.args, options);
|
|
@@ -456,6 +459,7 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
|
|
|
456
459
|
else if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
|
|
457
460
|
else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
458
461
|
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
462
|
+
if (scratchRun) { try { fs.rmSync(scratchRun, { recursive: true, force: true }); } catch { /* a stale run-* is swept by the next rufloRunDir */ } }
|
|
459
463
|
if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
|
|
460
464
|
const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
|
|
461
465
|
...(kind === 'store' ? { verified } : {}), ...(skipped ? { skipped } : {}) };
|
|
@@ -245,8 +245,13 @@ export function checkAll(now, { registry = REGISTRY, loaded = loadedLabels(), hb
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
export function productSchedulerVerdict(status) {
|
|
248
|
+
// The night's receipt: its runId tells one failed night from the next, and a failed night the coordinator already
|
|
249
|
+
// paged itself (developer-update.mjs pageNight, delivered) is handled like a wrapper-paged one.
|
|
250
|
+
const receipt = status?.runHealth?.receipt || null;
|
|
251
|
+
const night = { ...(receipt?.runId ? { runId: receipt.runId } : {}),
|
|
252
|
+
...(receipt?.page?.kind === 'failed' && receipt.page.state === 'delivered' ? { coordinatorPaged: true } : {}) };
|
|
248
253
|
const base = { label: NIGHTLY_LABEL, what: 'Refreshes the installed Brain corpus and converges every detected host',
|
|
249
|
-
schedule: 'daily 03:30', maxAgeHours: 30, required: true };
|
|
254
|
+
schedule: 'daily 03:30', maxAgeHours: 30, required: true, ...night };
|
|
250
255
|
// A 'degraded' adapter whose only defect is a NON-ZERO LAST EXIT is a job that is loaded, verified
|
|
251
256
|
// and FIRED — the exact opposite of MISSING ("not loaded — it can never fire", top of this file).
|
|
252
257
|
// Its truth is the receipt. Measured 2026-09-12 against two FAILED receipts: this projected as
|
|
@@ -282,9 +287,13 @@ export function brainUpdateOwnedByAgenticKit(home = os.homedir()) {
|
|
|
282
287
|
const loadState = () => { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return {}; } };
|
|
283
288
|
const saveState = (s) => { fs.mkdirSync(path.dirname(STATE), { recursive: true }); fs.writeFileSync(STATE, JSON.stringify(s, null, 2)); };
|
|
284
289
|
|
|
285
|
-
/** Page only on a CHANGE of state. Repeating the same alarm nightly is how alarms get ignored.
|
|
290
|
+
/** Page only on a CHANGE of state. Repeating the same alarm nightly is how alarms get ignored. One exception: a NEW
|
|
291
|
+
* failed night of the product nightly (a different receipt runId than the one last handled) pages once, because "it
|
|
292
|
+
* failed again tonight" is news; before this, three failed nights in a row paged only the first (2026-10-27..30). */
|
|
286
293
|
export function transitions(results, prev) {
|
|
287
|
-
|
|
294
|
+
const handledRun = (label) => prev._runs?.[label];
|
|
295
|
+
return results.filter((r) => !r.self && ((prev[r.label] ?? OK) !== r.state
|
|
296
|
+
|| (r.state === FAILING && !!r.runId && !!handledRun(r.label) && handledRun(r.label) !== r.runId)));
|
|
288
297
|
}
|
|
289
298
|
|
|
290
299
|
/** An HTTP header value must be a ByteString. An emoji in `Title` made fetch() throw before sending a byte, so
|
|
@@ -371,7 +380,7 @@ async function main() {
|
|
|
371
380
|
const pagedBy = { ...(prev._pagedBy || {}) };
|
|
372
381
|
const sends = []; const owners = []; const viaBoth = new Set();
|
|
373
382
|
for (const c of transitions(results, prev)) {
|
|
374
|
-
if (c.wrapperPaged) { pagedBy[c.label] = 'wrapper'; continue; }
|
|
383
|
+
if (c.wrapperPaged || (c.coordinatorPaged && c.state === FAILING)) { pagedBy[c.label] = 'wrapper'; continue; }
|
|
375
384
|
if (c.state === OK) {
|
|
376
385
|
if (pagedBy[c.label] === 'both') { delete pagedBy[c.label]; continue; } // the wrapper's "Recovered" covers it
|
|
377
386
|
if (prev[c.label] === FAILING && pagedBy[c.label] !== 'watchdog') { delete pagedBy[c.label]; continue; }
|
|
@@ -389,7 +398,10 @@ async function main() {
|
|
|
389
398
|
}
|
|
390
399
|
const next = Object.fromEntries(results.map((r) => [r.label,
|
|
391
400
|
r.self || undelivered.has(r.label) ? (prev[r.label] ?? OK) : r.state]));
|
|
392
|
-
|
|
401
|
+
// The night each label's verdict was last handled for; an undelivered page keeps the old one so it re-fires.
|
|
402
|
+
const runs = { ...(prev._runs || {}) };
|
|
403
|
+
for (const r of results) if (r.runId && !undelivered.has(r.label)) runs[r.label] = r.runId;
|
|
404
|
+
saveState({ ...next, _pagedBy: pagedBy, _runs: runs });
|
|
393
405
|
}
|
|
394
406
|
|
|
395
407
|
if (json) console.log(JSON.stringify({ results, checkedAt: new Date().toISOString() }, null, 2));
|
|
@@ -364,6 +364,15 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
364
364
|
"macos": ["tests/unit/update-recovery-disposition.test.mjs", "tests/unit/learn-capture-ack-after-readback.test.mjs"]
|
|
365
365
|
}
|
|
366
366
|
},
|
|
367
|
+
{
|
|
368
|
+
"id": "nightly-converges-4-7-25",
|
|
369
|
+
"reason": "The scheduled nightly converges or says why (ADR-104 4.7.25): a CURRENT corpus still runs the installer when the runtime or a host is behind the installed package, an installer exit 0 that leaves it behind is a failure, a failed knowledge stage is retried once the same night, a night that ends not-ok pages through scripts/notify.sh and the next good one pages recovered, the failure carries the installer's own lines, and Codex plugin commands get the catalog-refresh limit. Paging needs a POSIX shell, so Windows is not verified",
|
|
370
|
+
"files": [],
|
|
371
|
+
"platformFiles": {
|
|
372
|
+
"linux": ["tests/unit/nightly-self-retry-and-page.test.mjs"],
|
|
373
|
+
"macos": ["tests/unit/nightly-self-retry-and-page.test.mjs"]
|
|
374
|
+
}
|
|
375
|
+
},
|
|
367
376
|
{
|
|
368
377
|
"id": "issue-autofix-bounded-writer",
|
|
369
378
|
"reason": "The unattended issue fixer (ADR-107) stays inside its authority: kill switch, single-writer lock, deny-by-default editable paths, the static malware and secret scan, the manifest/lockfile/pack/audit night gate, the red-team judge, restricted tool-less LLM stages, the macOS sandbox for model-written code (macOS only), never-weakened tests, independent verifier, resumable never-doubled release steps, no force or merge, owner-marker-prefixed comments, personal notes that are never boilerplate or repeated, and a registry and LaunchAgent that match. It runs on the owner's macOS launchd, so it is qualified on macOS and Linux only, the same scope as the ntfy inbox reader",
|