ruvnet-brain 4.7.23 → 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 +117 -29
- package/kb/lifecycle-evidence-retention.mjs +12 -3
- package/kb/update-storage-transaction.mjs +161 -1
- 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 +13 -2
- package/plugin/scripts/developer-update.mjs +109 -11
- package/plugin/scripts/footprint-io.mjs +22 -0
- package/plugin/scripts/learn-capture.mjs +26 -3
- package/plugin/scripts/nightly-scheduler.mjs +12 -1
- package/plugin/scripts/turn-outcome-capture.mjs +8 -4
- package/scripts/issue-autofix-night.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +17 -5
- package/scripts/onboarding-console.mjs +6 -1
- package/scripts/release-qualification-contract.mjs +18 -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
|
@@ -25,7 +25,7 @@ import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
|
|
|
25
25
|
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
|
|
26
26
|
recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
|
|
27
27
|
import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
28
|
-
import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
28
|
+
import { checkDiskSpace, disposeRecoveryRequired, previewRecoveryRequired, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
29
29
|
import { saveUpdateSource, stableNode } from '../plugin/scripts/automatic-update.mjs';
|
|
30
30
|
import { acquireDeveloperLock } from '../plugin/scripts/developer-update-lock.mjs';
|
|
31
31
|
import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
@@ -172,6 +172,13 @@ const MOVE_BRAIN_TO = (() => { const i = argv.indexOf('--move-brain'); const v =
|
|
|
172
172
|
const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
|
|
173
173
|
const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
|
|
174
174
|
const FLAG_ARCHIVE = argv.includes('--archive'); // with --clean only (#335): archive files unique to old KB copies, then release them
|
|
175
|
+
// --recover-update (#335): show every update transaction stuck at RECOVERY_REQUIRED, changing nothing.
|
|
176
|
+
// --recover-update --keep-live <sha256> [--transaction <id>]: keep that exact live tree, move the
|
|
177
|
+
// transaction's other trees intact into .kb.recovered/<id>/, and record a terminal DISPOSED receipt.
|
|
178
|
+
const FLAG_RECOVER_UPDATE = argv.includes('--recover-update');
|
|
179
|
+
const argValue = (flag) => { const i = argv.indexOf(flag); const v = i === -1 ? null : argv[i + 1]; return v && !v.startsWith('-') ? v : null; };
|
|
180
|
+
const RECOVER_KEEP_LIVE = argValue('--keep-live');
|
|
181
|
+
const RECOVER_TRANSACTION = argValue('--transaction');
|
|
175
182
|
// ── onboarding-experience flags (all optional; every offer is safe to decline) ──
|
|
176
183
|
const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
|
|
177
184
|
const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
|
|
@@ -2101,6 +2108,12 @@ export function codexBrokenMarketplaces(text) {
|
|
|
2101
2108
|
}
|
|
2102
2109
|
const sleepSync = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
2103
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
|
+
|
|
2104
2117
|
export function runCodexJson(args, {
|
|
2105
2118
|
codexBin = process.env.CODEX_BIN || 'codex',
|
|
2106
2119
|
codexHome = codexHomeDir(),
|
|
@@ -2110,9 +2123,10 @@ export function runCodexJson(args, {
|
|
|
2110
2123
|
// Refreshing another tool's marketplace is a network write; only an install/update that is about to change the
|
|
2111
2124
|
// Brain's Codex plugin opts in. --doctor and listings report the broken snapshot and change nothing (review 2026-10-24).
|
|
2112
2125
|
repair = false,
|
|
2126
|
+
timeoutMs = CODEX_PLUGIN_COMMAND_TIMEOUT_MS,
|
|
2113
2127
|
} = {}) {
|
|
2114
2128
|
const options = { stdio: 'pipe', shell: false, cwd, env: { ...process.env, CODEX_HOME: codexHome }, encoding: 'utf8',
|
|
2115
|
-
timeout:
|
|
2129
|
+
timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 };
|
|
2116
2130
|
const snapshotFailure = (res) => !res.missingBinary && res.status !== 0 && CODEX_SNAPSHOT_FAILURE.test(String(res.stderr || res.stdout || ''));
|
|
2117
2131
|
let r = run(codexBin, args, options);
|
|
2118
2132
|
// One wait covers a refresh Codex is finishing; then refresh only the OTHER Git marketplaces it named (the Brain's
|
|
@@ -4072,6 +4086,50 @@ export function acquireHostConvergenceLock(lockPath, {
|
|
|
4072
4086
|
return { acquired: false, error: 'host synchronization lock changed concurrently' };
|
|
4073
4087
|
}
|
|
4074
4088
|
|
|
4089
|
+
/**
|
|
4090
|
+
* `--recover-update` (#335). Preview is read-only; disposition needs the exact live sha256 the preview
|
|
4091
|
+
* printed, so nothing is decided for the operator. Returns the exit code.
|
|
4092
|
+
*/
|
|
4093
|
+
export function runRecoverUpdate({ kbDir = physicalPath(resolvedKbDir()), keepLive = RECOVER_KEEP_LIVE,
|
|
4094
|
+
transaction = RECOVER_TRANSACTION, json = FLAG_JSON, log = console.log } = {}) {
|
|
4095
|
+
const stuck = previewRecoveryRequired(kbDir);
|
|
4096
|
+
const short = (identity) => identity?.sha256 ? `${identity.sha256} (${identity.fileCount} files, ${identity.bytes} bytes)` : 'absent';
|
|
4097
|
+
if (!keepLive) {
|
|
4098
|
+
if (json) log(JSON.stringify({ kbDir, stuck }, null, 2));
|
|
4099
|
+
else if (!stuck.length) log('No update is stuck at RECOVERY_REQUIRED. Nothing to recover.');
|
|
4100
|
+
else {
|
|
4101
|
+
for (const t of stuck) {
|
|
4102
|
+
log(`Update ${t.transactionId} needs recovery: ${t.reason || 'no reason recorded'}`);
|
|
4103
|
+
log(` sealed prior: ${short(t.sealed.prior)}`);
|
|
4104
|
+
log(` sealed candidate: ${short(t.sealed.candidate)}`);
|
|
4105
|
+
for (const [name, tree] of Object.entries(t.trees)) {
|
|
4106
|
+
const match = tree.matchesPrior ? ' = sealed prior' : tree.matchesCandidate ? ' = sealed candidate' : tree.exists ? ' matches no sealed identity' : '';
|
|
4107
|
+
log(` ${name.padEnd(9)} ${tree.exists ? short(tree.identity) : 'absent'}${match}`);
|
|
4108
|
+
}
|
|
4109
|
+
log(` To keep the live tree above and close this update (other trees are moved, never deleted):`);
|
|
4110
|
+
log(` npx ruvnet-brain --recover-update --transaction ${t.transactionId} --keep-live ${t.trees.live.identity?.sha256 || '<live sha256>'}`);
|
|
4111
|
+
}
|
|
4112
|
+
log('Nothing was changed.');
|
|
4113
|
+
}
|
|
4114
|
+
return stuck.length ? 1 : 0;
|
|
4115
|
+
}
|
|
4116
|
+
const targets = transaction ? stuck.filter((t) => t.transactionId === transaction) : stuck;
|
|
4117
|
+
if (!targets.length) { log(transaction ? `No stuck update named ${transaction}.` : 'No update is stuck at RECOVERY_REQUIRED.'); return 1; }
|
|
4118
|
+
if (targets.length > 1) { log('More than one update is stuck; name one with --transaction <id>. Nothing was changed.'); return 1; }
|
|
4119
|
+
try {
|
|
4120
|
+
const result = disposeRecoveryRequired(kbDir, targets[0].transactionId, { keepLiveSha256: keepLive });
|
|
4121
|
+
if (json) log(JSON.stringify(result, null, 2));
|
|
4122
|
+
else {
|
|
4123
|
+
log(`Closed update ${result.transactionId}: kept the live tree ${result.live.sha256}`
|
|
4124
|
+
+ ` (${result.liveMatchesPrior ? 'it equals the sealed prior' : result.liveMatchesCandidate ? 'it equals the sealed candidate' : 'it matches no sealed identity'}).`);
|
|
4125
|
+
for (const p of result.preserved) log(` moved ${p.tree} intact to ${p.to}`);
|
|
4126
|
+
if (!result.preserved.length) log(' the update owned no other tree.');
|
|
4127
|
+
log('Run npx ruvnet-brain --update next; nothing was deleted.');
|
|
4128
|
+
}
|
|
4129
|
+
return 0;
|
|
4130
|
+
} catch (error) { log(`Not recovered: ${error.message}. Nothing was deleted.`); return 1; }
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4075
4133
|
export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
4076
4134
|
sourceRoot = REPO_ROOT,
|
|
4077
4135
|
brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
|
|
@@ -4105,51 +4163,66 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
4105
4163
|
return { ok: false, results, ...detail, error: detail.error || hostFailureReason(results) };
|
|
4106
4164
|
};
|
|
4107
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);
|
|
4108
4173
|
try {
|
|
4109
4174
|
if (wireClaude === wirePlugin) results.hookRetirement = retireManagedHookRegistrations();
|
|
4110
4175
|
results.claude = wireClaude === wirePlugin
|
|
4111
4176
|
? wirePlugin({ expectedVersion: PACKAGE_VERSION, requireManaged: true })
|
|
4112
4177
|
: wireClaude({ expectedVersion: PACKAGE_VERSION, requireManaged: true });
|
|
4113
|
-
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 {
|
|
4114
4184
|
results.codexHost = detectCodexHost();
|
|
4115
4185
|
if (results.codexHost.host) {
|
|
4116
4186
|
if (!['added', 'rewritten', 'unchanged', 'user-owned'].includes(results.codexHost.action)) {
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
results.
|
|
4125
|
-
|
|
4126
|
-
|
|
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
|
+
}
|
|
4127
4199
|
}
|
|
4128
4200
|
}
|
|
4129
4201
|
} catch (error) {
|
|
4130
|
-
results.codex = { action: 'failed', error:
|
|
4131
|
-
|
|
4202
|
+
results.codex = { action: 'failed', error: hostError(error) };
|
|
4203
|
+
failedHosts.add('codex');
|
|
4132
4204
|
}
|
|
4133
4205
|
|
|
4134
4206
|
const apply = path.join(sourceRoot, 'plugin', 'scripts', 'update-apply.mjs');
|
|
4135
4207
|
if (!fs.existsSync(apply)) return fail({ error: 'Stable Spine updater missing from package' });
|
|
4136
4208
|
const applied = runStableSpine(apply);
|
|
4137
4209
|
const okApplied = !applied.error && applied.status === 0;
|
|
4138
|
-
const codexReceipt =
|
|
4139
|
-
state: results.codex?.
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
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) {
|
|
4143
4217
|
codexReceipt.restartRequired = true;
|
|
4144
4218
|
codexReceipt.sessionSafety = results.codex.sessionSafety || null;
|
|
4145
4219
|
codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
|
|
4146
4220
|
codexReceipt.restartScope = results.codex.restartScope || 'unproven';
|
|
4147
4221
|
}
|
|
4148
|
-
const claudeReceipt =
|
|
4149
|
-
state: results.claude.
|
|
4150
|
-
version: results.claude.version || null
|
|
4151
|
-
|
|
4152
|
-
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) {
|
|
4153
4226
|
claudeReceipt.restartRequired = true;
|
|
4154
4227
|
claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
|
|
4155
4228
|
claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
|
|
@@ -4236,6 +4309,11 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
4236
4309
|
};
|
|
4237
4310
|
const convergence = classifyHostConvergence(convergedReceipt);
|
|
4238
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
|
+
}
|
|
4239
4317
|
return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
|
|
4240
4318
|
...(convergence.healthy !== true && hostRestartIsOnlyGap(convergedReceipt) ? { restartOnly: true } : {}),
|
|
4241
4319
|
error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
|
|
@@ -4374,13 +4452,16 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
4374
4452
|
/** Why host synchronization stopped, from the per-host results it had gathered; null when nothing says. */
|
|
4375
4453
|
export function hostFailureReason(results = {}) {
|
|
4376
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 = [];
|
|
4377
4457
|
if (results.claude?.host && !results.claude.wired) {
|
|
4378
|
-
|
|
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)}` : ''}`);
|
|
4379
4459
|
}
|
|
4380
|
-
if (results.
|
|
4381
|
-
|
|
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)}` : ''}`);
|
|
4382
4463
|
}
|
|
4383
|
-
return null;
|
|
4464
|
+
return reasons.length ? reasons.join('; ') : null;
|
|
4384
4465
|
}
|
|
4385
4466
|
|
|
4386
4467
|
export function hostSynchronizationFailureMessage(convergence, active, expectedVersion = PACKAGE_VERSION) {
|
|
@@ -6829,6 +6910,12 @@ Usage:
|
|
|
6829
6910
|
footprint within budget, no cruft. --doctor --json prints only that, as JSON.
|
|
6830
6911
|
npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
|
|
6831
6912
|
private files the live brain lacks — old installers, over-cap logs), then confirm.
|
|
6913
|
+
npx ruvnet-brain --recover-update
|
|
6914
|
+
Read-only: list any update stuck at RECOVERY_REQUIRED, with the measured
|
|
6915
|
+
identity of the live tree and every tree that update owns.
|
|
6916
|
+
npx ruvnet-brain --recover-update --keep-live <sha256> [--transaction <id>]
|
|
6917
|
+
Keep that exact live tree, move the update's other trees intact into
|
|
6918
|
+
.kb.recovered/<id>/ (nothing is deleted) and close the update for good.
|
|
6832
6919
|
npx ruvnet-brain --clean --archive
|
|
6833
6920
|
Same, and for a kept copy: copy only the files it alone holds into
|
|
6834
6921
|
kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
|
|
@@ -6942,6 +7029,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6942
7029
|
// and `echo $?` → 0 coexisted for so long.
|
|
6943
7030
|
// `--doctor --json`: the positive-confirmation object ONLY (no narration), exit 0 iff every line that
|
|
6944
7031
|
// can be proven here is green — for scripts, CI, and agents (ADR-0098).
|
|
7032
|
+
if (FLAG_RECOVER_UPDATE) { process.exitCode = runRecoverUpdate(); return; }
|
|
6945
7033
|
if (FLAG_DOCTOR) { process.exitCode = await doctor({ json: FLAG_JSON }); return; }
|
|
6946
7034
|
// `--clean`: enforce the footprint guarantee now, then confirm. Exit 0 only when exactly one KB copy
|
|
6947
7035
|
// remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { physicalPath } from './refresh-run.mjs';
|
|
5
5
|
|
|
6
6
|
const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
|
|
7
|
-
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
7
|
+
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']);
|
|
8
8
|
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
9
9
|
const PHASE_FILE = /^\d{3}-[A-Z_]+\.json$/;
|
|
10
10
|
const QUARANTINE = /^\.prune-[A-Za-z0-9._-]+-[a-f0-9]{16}$/;
|
|
@@ -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);
|
|
@@ -15,8 +15,13 @@ const STATES = Object.freeze([
|
|
|
15
15
|
'COMMITTED',
|
|
16
16
|
'ROLLED_BACK',
|
|
17
17
|
'RECOVERY_REQUIRED',
|
|
18
|
+
'DISPOSED',
|
|
18
19
|
]);
|
|
19
20
|
|
|
21
|
+
// Terminal states: recovery never acts on a transaction whose newest receipt is one of these.
|
|
22
|
+
// DISPOSED is the operator's explicit, evidence-preserving end to a RECOVERY_REQUIRED transaction (#335).
|
|
23
|
+
export const TERMINAL_TRANSACTION_STATES = Object.freeze(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']);
|
|
24
|
+
|
|
20
25
|
// ── DISK-SPACE PREFLIGHT ──────────────────────────────────────────────────────────────────────────
|
|
21
26
|
// An apply needs room for the unpacked bundle (temp), the candidate generation beside the live one
|
|
22
27
|
// (the bundle plus the live node_modules carried into it) and its receipts; measured ~3.3 GB growth and
|
|
@@ -337,6 +342,7 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
337
342
|
const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
|
|
338
343
|
if (!phaseFiles.length) throw new Error(`storage transaction receipt is empty: ${receipts}`);
|
|
339
344
|
const latest = JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
|
|
345
|
+
if (latest.state === 'DISPOSED') continue;
|
|
340
346
|
if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) {
|
|
341
347
|
// A quarantined unsealed candidate is kept for ONE full update cycle, then released on the next
|
|
342
348
|
// run — but only if its bytes are exactly what was sealed when it was quarantined.
|
|
@@ -354,7 +360,10 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
354
360
|
}
|
|
355
361
|
continue;
|
|
356
362
|
}
|
|
357
|
-
if (latest.state === 'RECOVERY_REQUIRED')
|
|
363
|
+
if (latest.state === 'RECOVERY_REQUIRED') {
|
|
364
|
+
throw new Error(`storage transaction requires manual recovery: ${transactionId} `
|
|
365
|
+
+ `(preview with npx ruvnet-brain --recover-update ; nothing is changed by the preview)`);
|
|
366
|
+
}
|
|
358
367
|
const paths = latest.paths;
|
|
359
368
|
const expectedPaths = transactionPaths(live, transactionId);
|
|
360
369
|
if (!paths || ['live', 'candidate', 'rollback', 'failed'].some((name) =>
|
|
@@ -567,3 +576,154 @@ export function runStorageTransaction({
|
|
|
567
576
|
record('COMMITTED', { terminalVerdict: 'applied', prior, candidate, storageDelta: delta });
|
|
568
577
|
return { terminalVerdict: 'applied', prior, candidate, paths, storageDelta: delta };
|
|
569
578
|
}
|
|
579
|
+
|
|
580
|
+
// ── OPERATOR RECOVERY FOR A RECOVERY_REQUIRED TRANSACTION (#335) ──────────────────────────────────
|
|
581
|
+
// Automatic recovery stops at RECOVERY_REQUIRED on purpose: the trees no longer match what the receipts
|
|
582
|
+
// sealed, so no receipt can say which bytes are disposable. These two functions give the operator a
|
|
583
|
+
// supported way out without weakening that check:
|
|
584
|
+
// 1. previewRecoveryRequired() measures every tree the transaction owns and compares each with the
|
|
585
|
+
// identities its receipts sealed. It changes nothing.
|
|
586
|
+
// 2. disposeRecoveryRequired() keeps the CURRENT live tree in service only when the operator names its
|
|
587
|
+
// exact sha256 from the preview, moves every other tree the transaction owns intact into
|
|
588
|
+
// `.<kb>.recovered/<transaction>/` (nothing is deleted), proves each moved tree is byte-identical
|
|
589
|
+
// after the move, and records a terminal DISPOSED receipt with what was measured. It never infers
|
|
590
|
+
// that live equals the prior sealed tree: it records whether it does.
|
|
591
|
+
|
|
592
|
+
const SAFE_TRANSACTION_ID = /^[A-Za-z0-9._-]+$/;
|
|
593
|
+
const OWNED_TREES = Object.freeze(['candidate', 'rollback', 'failed']);
|
|
594
|
+
|
|
595
|
+
function receiptsRootFor(live) {
|
|
596
|
+
return path.join(path.dirname(live), `.${path.basename(live)}.update-transactions`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Where a disposition keeps the trees it moved. Sibling of the live tree, so a move is a rename. */
|
|
600
|
+
export function recoveredRootFor(liveDir) {
|
|
601
|
+
const live = path.resolve(liveDir);
|
|
602
|
+
return path.join(path.dirname(live), `.${path.basename(live)}.recovered`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function latestReceipt(receipts) {
|
|
606
|
+
const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
|
|
607
|
+
if (!phaseFiles.length) return null;
|
|
608
|
+
return JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function sealedReceipts(receipts) {
|
|
612
|
+
// Every identity any receipt of this transaction sealed, newest last.
|
|
613
|
+
const out = { prior: null, candidate: null };
|
|
614
|
+
for (const name of fs.readdirSync(receipts).filter((n) => /^\d{3}-[A-Z_]+\.json$/.test(n)).sort()) {
|
|
615
|
+
try {
|
|
616
|
+
const r = JSON.parse(fs.readFileSync(path.join(receipts, name), 'utf8'));
|
|
617
|
+
if (r.prior?.sha256) out.prior = r.prior;
|
|
618
|
+
if (r.candidate?.sha256) out.candidate = r.candidate;
|
|
619
|
+
} catch { /* an unreadable receipt seals nothing */ }
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const sameIdentity = (a, b) => Boolean(a?.sha256 && b?.sha256 && a.sha256 === b.sha256
|
|
625
|
+
&& a.bytes === b.bytes && a.fileCount === b.fileCount);
|
|
626
|
+
|
|
627
|
+
function measureTree(dir, sealed) {
|
|
628
|
+
let st = null;
|
|
629
|
+
try { st = fs.lstatSync(dir); } catch { /* absent */ }
|
|
630
|
+
if (!st) return { path: dir, exists: false, identity: null, matchesPrior: false, matchesCandidate: false };
|
|
631
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
632
|
+
return { path: dir, exists: true, directory: false, identity: null, matchesPrior: false, matchesCandidate: false };
|
|
633
|
+
}
|
|
634
|
+
const identity = identitySummary(treeIdentity(dir));
|
|
635
|
+
return { path: dir, exists: true, directory: true, identity,
|
|
636
|
+
matchesPrior: sameIdentity(identity, sealed.prior), matchesCandidate: sameIdentity(identity, sealed.candidate) };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Mutation-free report of every transaction whose newest receipt is RECOVERY_REQUIRED.
|
|
641
|
+
* @returns {Array<{transactionId:string, reason:string|null, recordedAt:string|null,
|
|
642
|
+
* sealed:{prior:object|null,candidate:object|null}, trees:Record<string,object>}>}
|
|
643
|
+
*/
|
|
644
|
+
export function previewRecoveryRequired(liveDir) {
|
|
645
|
+
const live = path.resolve(liveDir);
|
|
646
|
+
const root = receiptsRootFor(live);
|
|
647
|
+
if (!fs.existsSync(root)) return [];
|
|
648
|
+
const out = [];
|
|
649
|
+
for (const transactionId of fs.readdirSync(root).sort()) {
|
|
650
|
+
if (!SAFE_TRANSACTION_ID.test(transactionId) || transactionId.startsWith('.')) continue;
|
|
651
|
+
const receipts = path.join(root, transactionId);
|
|
652
|
+
if (!fs.lstatSync(receipts).isDirectory()) continue;
|
|
653
|
+
const latest = latestReceipt(receipts);
|
|
654
|
+
if (latest?.state !== 'RECOVERY_REQUIRED') continue;
|
|
655
|
+
const sealed = sealedReceipts(receipts);
|
|
656
|
+
const expected = transactionPaths(live, transactionId);
|
|
657
|
+
const trees = { live: measureTree(live, sealed) };
|
|
658
|
+
for (const name of OWNED_TREES) trees[name] = measureTree(expected[name], sealed);
|
|
659
|
+
out.push({ transactionId, reason: latest.reason || null, recordedAt: latest.recordedAt || null,
|
|
660
|
+
sealed: { prior: sealed.prior ? identitySummary(sealed.prior) : null,
|
|
661
|
+
candidate: sealed.candidate ? identitySummary(sealed.candidate) : null }, trees });
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The operator's explicit disposition: keep the current live tree, preserve every other tree intact.
|
|
668
|
+
* @param {string} liveDir
|
|
669
|
+
* @param {string} transactionId
|
|
670
|
+
* @param {{ keepLiveSha256: string, rename?: Function }} options keepLiveSha256 must be the live tree's
|
|
671
|
+
* sha256 exactly as the preview reported it; any change since the preview refuses.
|
|
672
|
+
*/
|
|
673
|
+
export function disposeRecoveryRequired(liveDir, transactionId, { keepLiveSha256, rename = fs.renameSync } = {}) {
|
|
674
|
+
const live = path.resolve(liveDir);
|
|
675
|
+
if (!SAFE_TRANSACTION_ID.test(String(transactionId || '')) || String(transactionId).startsWith('.')) {
|
|
676
|
+
throw new Error(`not a transaction id: ${transactionId}`);
|
|
677
|
+
}
|
|
678
|
+
if (!/^[a-f0-9]{64}$/.test(String(keepLiveSha256 || ''))) {
|
|
679
|
+
throw new Error('name the live tree to keep by the exact sha256 the preview printed (--keep-live <sha256>)');
|
|
680
|
+
}
|
|
681
|
+
const receipts = path.join(receiptsRootFor(live), transactionId);
|
|
682
|
+
if (!fs.existsSync(receipts) || !fs.lstatSync(receipts).isDirectory()) throw new Error(`no such transaction: ${transactionId}`);
|
|
683
|
+
const latest = latestReceipt(receipts);
|
|
684
|
+
if (latest?.state !== 'RECOVERY_REQUIRED') {
|
|
685
|
+
throw new Error(`transaction ${transactionId} is ${latest?.state || 'empty'}, not RECOVERY_REQUIRED; nothing to dispose`);
|
|
686
|
+
}
|
|
687
|
+
const expected = transactionPaths(live, transactionId);
|
|
688
|
+
if (latest.paths && ['live', ...OWNED_TREES].some((name) => latest.paths[name] && path.resolve(latest.paths[name]) !== expected[name])) {
|
|
689
|
+
throw new Error(`transaction ${transactionId} names paths that belong to another live tree; refusing`);
|
|
690
|
+
}
|
|
691
|
+
assertDirectory(live, 'live tree');
|
|
692
|
+
const sealed = sealedReceipts(receipts);
|
|
693
|
+
const liveIdentity = identitySummary(treeIdentity(live));
|
|
694
|
+
if (liveIdentity.sha256 !== keepLiveSha256) {
|
|
695
|
+
throw new Error(`the live tree changed since the preview (now ${liveIdentity.sha256}); run the preview again`);
|
|
696
|
+
}
|
|
697
|
+
const archiveDir = path.join(recoveredRootFor(live), transactionId);
|
|
698
|
+
const moves = [];
|
|
699
|
+
for (const name of OWNED_TREES) {
|
|
700
|
+
const source = expected[name];
|
|
701
|
+
if (!fs.existsSync(source)) continue;
|
|
702
|
+
assertDirectory(source, `transaction ${name} tree`);
|
|
703
|
+
const target = path.join(archiveDir, name);
|
|
704
|
+
if (fs.existsSync(target)) throw new Error(`recovered tree already exists: ${target}`);
|
|
705
|
+
moves.push({ name, source, target, before: identitySummary(treeIdentity(source)) });
|
|
706
|
+
}
|
|
707
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
708
|
+
const preserved = [];
|
|
709
|
+
for (const move of moves) {
|
|
710
|
+
rename(move.source, move.target);
|
|
711
|
+
const after = identitySummary(treeIdentity(move.target));
|
|
712
|
+
if (!sameIdentity(after, move.before)) {
|
|
713
|
+
throw new Error(`${move.name} tree changed while it was moved to ${move.target}; it is kept there, nothing was deleted`);
|
|
714
|
+
}
|
|
715
|
+
preserved.push({ tree: move.name, from: move.source, to: move.target, identity: after,
|
|
716
|
+
matchesPrior: sameIdentity(after, sealed.prior), matchesCandidate: sameIdentity(after, sealed.candidate) });
|
|
717
|
+
}
|
|
718
|
+
const receipt = appendRecoveryReceipt(receipts, 'DISPOSED', {
|
|
719
|
+
terminalVerdict: 'operator-kept-live',
|
|
720
|
+
live: liveIdentity,
|
|
721
|
+
liveMatchesPrior: sameIdentity(liveIdentity, sealed.prior),
|
|
722
|
+
liveMatchesCandidate: sameIdentity(liveIdentity, sealed.candidate),
|
|
723
|
+
preserved,
|
|
724
|
+
recoveredFrom: { reason: latest.reason || null, recordedAt: latest.recordedAt || null },
|
|
725
|
+
reason: 'operator kept the current live tree; every other tree this transaction owned was moved intact, not deleted',
|
|
726
|
+
});
|
|
727
|
+
return { transactionId, terminalVerdict: 'operator-kept-live', live: liveIdentity,
|
|
728
|
+
liveMatchesPrior: receipt.liveMatchesPrior, liveMatchesCandidate: receipt.liveMatchesCandidate, preserved, archiveDir };
|
|
729
|
+
}
|
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,9 +68,16 @@ 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'
|
|
73
|
-
|
|
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' };
|
|
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; } };
|
|
76
83
|
const lstat = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
|
|
@@ -190,6 +197,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
|
|
|
190
197
|
}
|
|
191
198
|
};
|
|
192
199
|
const classifySibling = (name, full, st) => {
|
|
200
|
+
if (name === `.${base}.recovered` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --recover-update --keep-live
|
|
201
|
+
return void add({ id: 'kb-recovered', path: full, class: 'may-exist', kind: 'kb-recovered', action: 'keep', bytes: bytes(full),
|
|
202
|
+
reason: 'trees an operator-disposed update transaction owned, moved intact (--recover-update); never removed automatically' });
|
|
203
|
+
}
|
|
193
204
|
if (name === `${base}.archive` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --clean --archive
|
|
194
205
|
return void add({ id: 'kb-archive', path: full, class: 'may-exist', kind: 'kb-archive', action: 'keep', bytes: bytes(full),
|
|
195
206
|
reason: 'files unique to released KB copies, archived on request (--clean --archive); never removed automatically' });
|
|
@@ -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));
|
|
@@ -24,6 +24,21 @@ const learnDir = (brainHome, sub) => { const dir = path.join(brainHome, 'learn-c
|
|
|
24
24
|
export function recentlyCaptured(brainHome, key, now = Date.now()) {
|
|
25
25
|
try { return now - fs.statSync(path.join(learnDir(brainHome, 'recent'), key)).mtimeMs < COALESCE_MS; } catch { return false; }
|
|
26
26
|
}
|
|
27
|
+
// #377 residual (sparkling, 2026-10-30): the parent used to call markCaptured() BEFORE the detached child stored
|
|
28
|
+
// anything, so a child whose store or exact readback failed left the key acknowledged as captured for the whole
|
|
29
|
+
// COALESCE_MS window. Now the parent only marks the key IN FLIGHT (`<key>.pending`, which is never an
|
|
30
|
+
// acknowledgement and expires with the child's slot), and the child acknowledges with markCaptured() only after
|
|
31
|
+
// recordLearningObservation() has completed its exact readback. A failed child clears the in-flight mark.
|
|
32
|
+
const inFlightMarker = (brainHome, key) => path.join(learnDir(brainHome, 'recent'), `${key}.pending`);
|
|
33
|
+
export function captureInFlight(brainHome, key, now = Date.now()) {
|
|
34
|
+
try { return now - fs.statSync(inFlightMarker(brainHome, key)).mtimeMs < SLOT_STALE_MS; } catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
export function markInFlight(brainHome, key) {
|
|
37
|
+
try { fs.writeFileSync(inFlightMarker(brainHome, key), '', { mode: 0o600 }); } catch { /* best effort: coalescing only */ }
|
|
38
|
+
}
|
|
39
|
+
export function clearInFlight(brainHome, key) {
|
|
40
|
+
try { fs.unlinkSync(inFlightMarker(brainHome, key)); } catch { /* already gone */ }
|
|
41
|
+
}
|
|
27
42
|
export function markCaptured(brainHome, key, now = Date.now()) {
|
|
28
43
|
const dir = learnDir(brainHome, 'recent');
|
|
29
44
|
try { fs.writeFileSync(path.join(dir, key), '', { mode: 0o600 }); } catch { return; }
|
|
@@ -105,12 +120,13 @@ export function captureLearning(input, { env = process.env, binary = resolveRufl
|
|
|
105
120
|
const observation = { tool, action, session: String(input.session_id || '') };
|
|
106
121
|
const key = observationKey(observation);
|
|
107
122
|
if (recentlyCaptured(brainHome, key)) return true; // identical observation already captured this window: no process
|
|
123
|
+
if (detach && captureInFlight(brainHome, key)) return true; // the same observation is being stored right now
|
|
108
124
|
// Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
|
|
109
125
|
// tool call returns at once; that child writes the ledger row if the write is refused or unverified.
|
|
110
126
|
if (detach) {
|
|
111
127
|
const slot = acquireObserveSlot(brainHome);
|
|
112
128
|
if (!slot) return shedObservation(brainHome);
|
|
113
|
-
|
|
129
|
+
markInFlight(brainHome, key); // NOT an acknowledgement: only the child's exact readback acknowledges (#377)
|
|
114
130
|
return detachLearning('--observe', { env: { ...env, RUFLO_BIN: binary }, brainHome, observation, slot });
|
|
115
131
|
}
|
|
116
132
|
recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
|
|
@@ -136,6 +152,8 @@ export function detachLearning(mode, { env = process.env, brainHome, observation
|
|
|
136
152
|
}
|
|
137
153
|
|
|
138
154
|
function runDetached(mode) {
|
|
155
|
+
const brainHome = defaultBrainHome(process.env);
|
|
156
|
+
let observation = null;
|
|
139
157
|
const fail = (error) => recordHookFailure({ hook: mode === '--distill' ? 'learn-distill' : 'learn-capture', event: mode === '--distill' ? 'SessionEnd' : 'PostToolUse', error: String(error?.message || error).slice(0, 160) });
|
|
140
158
|
try {
|
|
141
159
|
const context = learningContext(); // consent is re-read: a withdrawn consent writes nothing
|
|
@@ -145,8 +163,13 @@ function runDetached(mode) {
|
|
|
145
163
|
if (mode === '--distill') { distillObservations(binary, context, { env: process.env }); return; }
|
|
146
164
|
const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
|
|
147
165
|
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
observation = { tool: o.tool, action: o.action, session: String(o.session || '') };
|
|
167
|
+
recordLearningObservation(binary, context, observation, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
|
|
168
|
+
markCaptured(brainHome, observationKey(observation)); // acknowledged only after the exact readback succeeded (#377)
|
|
169
|
+
} catch (error) { fail(error); } finally {
|
|
170
|
+
if (observation) clearInFlight(brainHome, observationKey(observation));
|
|
171
|
+
if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT);
|
|
172
|
+
}
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
const isMain = isDirectInvocation(import.meta.url);
|
|
@@ -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 } : {}) };
|
|
@@ -139,7 +139,7 @@ export async function shipAndSettle(rt, journal) {
|
|
|
139
139
|
publish('fixed', ['install-verified on Linux, macOS and Windows']);
|
|
140
140
|
patchNs(rt.paths.state, { _daily: { day: etDay(rt.now()), shipped: true, version: journal.version, issues: journal.issues } });
|
|
141
141
|
rt.page(`AUTOFIX SHIPPED v${journal.version}`, `Issues ${journal.issues.join(', ')}; install-verified on main ${journal.sha}.`, 'low');
|
|
142
|
-
return { items, status:
|
|
142
|
+
return { items, status: res.phase, exitCode: 0 }; // derived from the release result, never a literal (ADR-0024)
|
|
143
143
|
}
|
|
144
144
|
if (res.phase === 'published-unverified') {
|
|
145
145
|
publish('shipping', []);
|
|
@@ -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));
|
|
@@ -2092,7 +2092,12 @@ function gatherActivity(cwd) {
|
|
|
2092
2092
|
// recent decisions from its append-only log. Nothing here can disagree with what actually routes.
|
|
2093
2093
|
function gatherRouterEngine() {
|
|
2094
2094
|
const profile = engineProfile();
|
|
2095
|
-
|
|
2095
|
+
// A missing or invalid per-user engine catalog is a degraded panel (catalogSource says 'unavailable'),
|
|
2096
|
+
// never a thrown read model that takes the whole Console API down with it (issue #86 boundary; red on
|
|
2097
|
+
// CI from 4.7.22, where no per-user catalog exists). No built-in model list is substituted.
|
|
2098
|
+
let engineCandidates = [];
|
|
2099
|
+
try { engineCandidates = engineCatalog(); } catch { /* reported through catalogSource: 'unavailable' */ }
|
|
2100
|
+
const candidates = applyProfile(engineCandidates, profile);
|
|
2096
2101
|
const prices = effectivePrices(candidates, profile);
|
|
2097
2102
|
const { rows, unusable } = loadLabelledRows();
|
|
2098
2103
|
const installed = fs.existsSync(path.join(__dirname, '..', 'node_modules', '@metaharness', 'router', 'package.json'));
|
|
@@ -355,6 +355,24 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
355
355
|
"macos": ["tests/unit/continuation-gate-codex-transcript.test.mjs", "tests/unit/codex-boot-surface-proof.test.mjs", "tests/unit/console-activity-fleet-dedupe.test.mjs", "tests/unit/console-activity-identity.test.mjs", "tests/unit/console-activity-lessons.test.mjs", "tests/unit/lesson-gate.test.mjs", "tests/unit/ruflo-memory-session.test.mjs"]
|
|
356
356
|
}
|
|
357
357
|
},
|
|
358
|
+
{
|
|
359
|
+
"id": "open-issue-fixes-4-7-24",
|
|
360
|
+
"reason": "A storage transaction stuck at RECOVERY_REQUIRED has a supported way out: a read-only preview, then an explicit keep-live disposition bound to the live tree's exact sha256 that moves every other tree intact and records a terminal DISPOSED receipt (#335); a detached learning capture is acknowledged only after its exact readback, so a refused store is retried instead of being coalesced away for ten minutes (#377). Windows is not verified",
|
|
361
|
+
"files": [],
|
|
362
|
+
"platformFiles": {
|
|
363
|
+
"linux": ["tests/unit/update-recovery-disposition.test.mjs", "tests/unit/learn-capture-ack-after-readback.test.mjs"],
|
|
364
|
+
"macos": ["tests/unit/update-recovery-disposition.test.mjs", "tests/unit/learn-capture-ack-after-readback.test.mjs"]
|
|
365
|
+
}
|
|
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
|
+
},
|
|
358
376
|
{
|
|
359
377
|
"id": "issue-autofix-bounded-writer",
|
|
360
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",
|