ruvnet-brain 4.7.20 → 4.7.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/install.mjs +618 -104
- package/console/activity.js +122 -38
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +49 -17
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +241 -23
- package/plugin/scripts/continuation-gate.mjs +75 -11
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-gate.mjs +7 -39
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +25 -5
- package/plugin/scripts/session-start-core.mjs +7 -16
- package/plugin/scripts/session-start-health.mjs +98 -10
- package/plugin/scripts/session-start-issue-alert.mjs +63 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-boot-surface-proof.mjs +208 -0
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +116 -40
- package/scripts/job-heartbeat.sh +54 -19
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +26 -20
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +41 -18
- package/scripts/onboarding-console.mjs +123 -23
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recall-latency-probe.mjs +48 -0
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +82 -28
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +13 -5
- package/scripts/worktree-integrity.mjs +2 -2
|
@@ -3,41 +3,180 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
6
7
|
const brainRoot = brainHome => brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache/ruvnet-brain');
|
|
7
|
-
|
|
8
|
+
const pidAlive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
9
|
+
// A dead or reclaimable owner must have been gone this long; an unreadable owner is judged by the directory's age.
|
|
10
|
+
export const STALE_RECLAIM_MS = 10 * 60 * 1000;
|
|
11
|
+
// An owner-less lock younger than this is another process between creating the lock and writing its owner.
|
|
12
|
+
export const OWNERLESS_GRACE_MS = 5 * 1000;
|
|
13
|
+
/** The OS start time of `pid` (`ps -o lstart=`, as kb/refresh-run.mjs records it), or null when it cannot be read (Windows). */
|
|
14
|
+
export function processStartOf(pid, { platform = process.platform } = {}) {
|
|
15
|
+
if (platform === 'win32' || !Number.isSafeInteger(pid) || pid < 1) return null;
|
|
16
|
+
try {
|
|
17
|
+
const r = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: 5000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
|
|
18
|
+
const start = String(r.stdout || '').trim();
|
|
19
|
+
return r.status === 0 && start ? start : null;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
const removal = directory => process.platform === 'win32' ? `Remove-Item -Recurse -Force "${directory}"` : `rm -rf "${directory}"`;
|
|
23
|
+
// ── takeover is serialized (4.7.21 review, Codex #1) ─────────────────────────────────────────────
|
|
24
|
+
// Two contenders could judge the same abandoned lock: A moved it aside and acquired, then B moved A's FRESH lock
|
|
25
|
+
// aside on its stale judgement and both ran. A takeover now (1) creates an exclusive claim file inside the judged
|
|
26
|
+
// directory ('wx': one winner per directory instance), (2) re-checks, while holding the claim, that it is the same
|
|
27
|
+
// directory (dev/ino) with byte-identical owner.json, and only then (3) moves it aside. A claim left by a crashed
|
|
28
|
+
// reclaimer is superseded by the next numbered claim once CLAIM_STALE_MS old.
|
|
29
|
+
// Round 4 (Codex #1): a lock that existed before its owner record (mkdir, then write) could be judged owner-less and old,
|
|
30
|
+
// claimed, re-checked (still owner-less, so "the same") and moved aside together with the owner published in between:
|
|
31
|
+
// two owners. The lock is now built complete in a staging sibling and published by ONE rename, so it never exists
|
|
32
|
+
// without its owner; a rename onto an existing lock fails (ENOTEMPTY/EEXIST, EPERM/EACCES on Windows) and is re-judged.
|
|
33
|
+
export const CLAIM_STALE_MS = 5 * 60 * 1000;
|
|
34
|
+
const CLAIM_FILE = /^takeover(?:-(\d+))?\.json$/;
|
|
35
|
+
function observeDirectory(directory) {
|
|
36
|
+
const stat = fs.lstatSync(directory, { bigint: true });
|
|
37
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('not an owned directory');
|
|
38
|
+
let raw = null;
|
|
39
|
+
try { raw = fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
40
|
+
return { identity: `${stat.dev}:${stat.ino}`, raw };
|
|
41
|
+
}
|
|
42
|
+
const readRaw = file => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
|
|
43
|
+
// A claim file must not restart the age an owner-less lock is judged by (Opus r2 #6): writing or removing one bumps the
|
|
44
|
+
// directory's mtime, so the directory's own times are put back.
|
|
45
|
+
function keepDirectoryTimes(directory, change) {
|
|
46
|
+
let before = null;
|
|
47
|
+
try { before = fs.lstatSync(directory); } catch { /* judged below */ }
|
|
48
|
+
try { return change(); } finally { if (before) try { fs.lutimesSync(directory, before.atime, before.mtime); } catch { /* gone */ } }
|
|
49
|
+
}
|
|
50
|
+
const GONE = Symbol('judged lock vanished');
|
|
51
|
+
function claimTakeover(directory) {
|
|
52
|
+
let highest = -1, highestName = null, names;
|
|
53
|
+
// The judged lock vanished (another reclaimer moved it): re-judge rather than throw (Fable r2 P3-1).
|
|
54
|
+
try { names = fs.readdirSync(directory); } catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return GONE; throw error; }
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const match = CLAIM_FILE.exec(name);
|
|
57
|
+
if (match && (match[1] ? Number(match[1]) : 0) > highest) { highest = match[1] ? Number(match[1]) : 0; highestName = name; }
|
|
58
|
+
}
|
|
59
|
+
if (highestName) {
|
|
60
|
+
let ageMs = 0;
|
|
61
|
+
try { ageMs = Date.now() - fs.lstatSync(path.join(directory, highestName)).mtimeMs; } catch { return null; }
|
|
62
|
+
if (!(ageMs > CLAIM_STALE_MS)) return null; // another reclaimer is mid-takeover
|
|
63
|
+
}
|
|
64
|
+
const level = highest + 1;
|
|
65
|
+
const file = path.join(directory, level ? `takeover-${level}.json` : 'takeover.json');
|
|
66
|
+
const token = crypto.randomUUID();
|
|
67
|
+
try { keepDirectoryTimes(directory, () => fs.writeFileSync(file, JSON.stringify({ token, pid: process.pid, claimedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 })); }
|
|
68
|
+
catch (error) { if (error.code === 'EEXIST') return null; if (error.code === 'ENOENT') return GONE; throw error; }
|
|
69
|
+
return { file, token };
|
|
70
|
+
}
|
|
71
|
+
const dropClaim = claim => { try { if (readRaw(claim.file)?.includes(claim.token)) keepDirectoryTimes(path.dirname(claim.file), () => fs.unlinkSync(claim.file)); } catch { /* gone with its directory */ } };
|
|
72
|
+
/** Move `directory` aside only if it is still exactly the one observed: 'removed' | 'changed' | 'busy'. */
|
|
73
|
+
function removeObservedLock(directory, observed, { afterClaim = null } = {}) {
|
|
74
|
+
const claim = claimTakeover(directory);
|
|
75
|
+
if (claim === GONE) return 'changed';
|
|
76
|
+
if (!claim) return 'busy';
|
|
77
|
+
afterClaim?.({ directory, claim });
|
|
78
|
+
let same = false;
|
|
79
|
+
try {
|
|
80
|
+
const now = observeDirectory(directory);
|
|
81
|
+
same = now.identity === observed.identity && now.raw === observed.raw && Boolean(readRaw(claim.file)?.includes(claim.token));
|
|
82
|
+
} catch { same = false; }
|
|
83
|
+
if (!same) { dropClaim(claim); return 'changed'; }
|
|
84
|
+
const tomb = `${directory}.stale-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
85
|
+
try { fs.renameSync(directory, tomb); } catch (error) { if (error.code === 'ENOENT') return 'changed'; dropClaim(claim); throw error; }
|
|
86
|
+
if (!readRaw(path.join(tomb, path.basename(claim.file)))?.includes(claim.token)) {
|
|
87
|
+
try { if (!fs.existsSync(directory)) fs.renameSync(tomb, directory); } catch { /* keep it as evidence */ }
|
|
88
|
+
throw Error(`developer update lock takeover moved a directory it did not claim; ${directory}`);
|
|
89
|
+
}
|
|
90
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
91
|
+
return 'removed';
|
|
92
|
+
}
|
|
93
|
+
/** Publish the lock with its owner already inside: one rename, which fails when any populated lock is there. */
|
|
94
|
+
const CONTENDED = new Set(['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES']);
|
|
95
|
+
function publishLock(directory, content) {
|
|
96
|
+
const staged = `${directory}.acquire-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
97
|
+
const dropStaged = () => { try { fs.rmSync(staged, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(staged, { mode: 0o700 });
|
|
100
|
+
fs.writeFileSync(path.join(staged, 'owner.json'), content, { flag: 'wx', mode: 0o600 });
|
|
101
|
+
} catch (error) { dropStaged(); throw error; } // staging failures are real errors, never contention
|
|
102
|
+
try { fs.renameSync(staged, directory); return true; } catch (error) {
|
|
103
|
+
dropStaged();
|
|
104
|
+
// A refused publication is contention even if the lock is gone a moment later: its owner can release in between,
|
|
105
|
+
// and re-checking existsSync here threw a raw ENOTEMPTY at the caller (Opus r3 P2-1). Re-judge (bounded by the caller).
|
|
106
|
+
if (CONTENDED.has(error.code)) return false;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function sharedLockStatus({ brainHome, alive = pidAlive, startOf = processStartOf } = {}) {
|
|
8
111
|
const directory = path.join(brainRoot(brainHome), 'developer-update.lock');
|
|
112
|
+
let status = judgeLock(directory, alive, startOf);
|
|
113
|
+
// An owner-less reading can be the instant between one owner's release and the next owner's publication: the lock was
|
|
114
|
+
// gone (or moved aside before owner.json was read) and a new owner's lock was there when existence was checked. That
|
|
115
|
+
// live lock was judged "unknown" and the refusal offered rm -rf (Opus r3 P2-2). Look again before calling it unknown.
|
|
116
|
+
for (let look = 0; look < 2 && status.state === 'unknown' && (status.observed === null || status.observed.raw === null); look++) {
|
|
117
|
+
let again = null;
|
|
118
|
+
try { again = observeDirectory(directory); } catch { /* vanished or unjudgeable: judged afresh */ }
|
|
119
|
+
if (again && status.observed && again.identity === status.observed.identity && again.raw === null) break; // the same owner-less directory
|
|
120
|
+
status = judgeLock(directory, alive, startOf);
|
|
121
|
+
}
|
|
122
|
+
return status;
|
|
123
|
+
}
|
|
124
|
+
function judgeLock(directory, alive, startOf) {
|
|
125
|
+
let observed = null;
|
|
9
126
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
127
|
+
observed = observeDirectory(directory);
|
|
128
|
+
if (observed.raw === null) throw Object.assign(Error('owner.json absent'), { code: 'ENOENT' });
|
|
129
|
+
const owner = JSON.parse(observed.raw);
|
|
12
130
|
if (!Number.isInteger(owner.pid) || owner.pid < 1 || typeof owner.token !== 'string' || owner.token.length < 16) throw Error('invalid owner');
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
131
|
+
if (!alive(owner.pid)) return { state: 'stale', directory, owner, observed };
|
|
132
|
+
// A live pid is the owner only when the recorded process start matches: a reused pid is another process.
|
|
133
|
+
const seen = typeof owner.processStart === 'string' && owner.processStart ? startOf(owner.pid) : null;
|
|
134
|
+
if (seen && seen !== owner.processStart) return { state: 'stale', directory, owner, reusedPid: true, observed };
|
|
135
|
+
return { state: 'running', directory, owner, observed };
|
|
136
|
+
} catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message, observed }; }
|
|
137
|
+
}
|
|
138
|
+
export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive, startOf = processStartOf, now = Date.now(), afterTakeoverClaim = null } = {}) {
|
|
139
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
140
|
+
const status = sharedLockStatus({ brainHome, alive, startOf });
|
|
141
|
+
if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
|
|
142
|
+
// A dead owner's lock must not wedge every future update (measured 2026-10-15: a coordinator killed mid-run left `stale`
|
|
143
|
+
// behind and the automatic updater refused until a person removed the directory). Reclaimable: a provably dead or reused-pid
|
|
144
|
+
// owner, or a directory with no readable owner (a kill between mkdir and the owner write), once STALE_RECLAIM_MS old by its
|
|
145
|
+
// recorded start or, failing that, the directory's own mtime. The takeover is claimed and re-checked (above), then the
|
|
146
|
+
// normal atomic mkdir claim follows. A live owner, or anything younger, still refuses.
|
|
147
|
+
let ageMs = NaN;
|
|
148
|
+
if (status.state === 'stale' || status.state === 'unknown') {
|
|
149
|
+
ageMs = now - Date.parse(status.owner?.startedAt || '');
|
|
150
|
+
if (!Number.isFinite(ageMs)) try { ageMs = now - fs.lstatSync(status.directory).mtimeMs; } catch { ageMs = NaN; }
|
|
151
|
+
}
|
|
152
|
+
const refuse = state => Error(`developer update lock ${state}; ${status.directory}. If no update is running on this machine, remove it: ${removal(status.directory)}`);
|
|
153
|
+
// A lock with no owner yet that is seconds old is another process mid-creation (agentic-kit writes its owner after its
|
|
154
|
+
// mkdir), not debris: never offer to remove it (Opus r3 P2-2).
|
|
155
|
+
if (status.state === 'unknown' && status.observed?.raw === null && Number.isFinite(ageMs) && ageMs < OWNERLESS_GRACE_MS) {
|
|
156
|
+
throw Error(`developer update lock is being created by another process; ${status.directory}. Retry shortly.`);
|
|
157
|
+
}
|
|
158
|
+
if ((status.state === 'stale' || status.state === 'unknown') && Number.isFinite(ageMs) && ageMs >= STALE_RECLAIM_MS) {
|
|
159
|
+
if (!status.observed) throw refuse(status.state); // not a lock directory this protocol can judge (a symlink or a file)
|
|
160
|
+
const outcome = removeObservedLock(status.directory, status.observed, { afterClaim: afterTakeoverClaim });
|
|
161
|
+
if (outcome === 'busy') throw refuse('is being reclaimed by another process');
|
|
162
|
+
continue; // 'removed' or 'changed': judge the directory afresh
|
|
163
|
+
} else if (status.state !== 'idle') throw refuse(status.state);
|
|
164
|
+
fs.mkdirSync(path.dirname(status.directory), { recursive: true });
|
|
165
|
+
const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString(), processStart: startOf(pid) };
|
|
166
|
+
if (!publishLock(status.directory, JSON.stringify(owner))) continue; // judge the lock that won
|
|
167
|
+
return { token: owner.token, ownerPid: pid, inherited: false, release() {
|
|
168
|
+
const current = sharedLockStatus({ brainHome, alive, startOf });
|
|
169
|
+
if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
|
|
170
|
+
// Move the whole lock aside in one step, then confirm it was this owner's: removing owner.json first left an empty
|
|
171
|
+
// directory that a contender's publication rename could replace, and the follow-up removal deleted that live lock.
|
|
172
|
+
const tomb = `${status.directory}.released-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
173
|
+
fs.renameSync(status.directory, tomb);
|
|
174
|
+
if (!readRaw(path.join(tomb, 'owner.json'))?.includes(owner.token)) {
|
|
175
|
+
try { if (!fs.existsSync(status.directory)) fs.renameSync(tomb, status.directory); } catch { /* keep it as evidence */ }
|
|
176
|
+
throw Error('developer update lock ownership changed');
|
|
177
|
+
}
|
|
178
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
179
|
+
} };
|
|
180
|
+
}
|
|
181
|
+
throw Error(`developer update lock is contended; ${path.join(brainRoot(brainHome), 'developer-update.lock')}. Retry shortly.`);
|
|
43
182
|
}
|
|
@@ -3,20 +3,23 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
-
import { EXECUTION_MODULES } from './developer-update-policy.mjs';
|
|
6
|
+
import { EXECUTION_MODULES, OPTIONAL_EXECUTION_MODULES } from './developer-update-policy.mjs';
|
|
7
7
|
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
8
8
|
export function developerCoordinatorOwner({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
|
|
9
9
|
const config = path.join(brainHome, 'developer-update-config.json'), registration = path.join(brainHome, 'scheduler/registration.json');
|
|
10
|
-
let record;
|
|
11
|
-
try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch {
|
|
12
|
-
|
|
10
|
+
let record, registered = true;
|
|
11
|
+
try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch (error) { registered = error.code !== 'ENOENT'; }
|
|
12
|
+
// A config left behind by --disable-nightly (no registration at all) owns nothing: refusing every automatic update
|
|
13
|
+
// as "not ready" forever would wedge the machine (AuditC #11). An unreadable registration still refuses.
|
|
14
|
+
const configured = (fs.existsSync(config) && registered) || record?.mode === 'developer-suite';
|
|
13
15
|
if (!configured) return { active: false, ready: false };
|
|
14
16
|
const entry = record?.updateModules?.['developer-update.mjs'];
|
|
15
17
|
try {
|
|
16
18
|
if (record?.mode !== 'developer-suite' || !path.isAbsolute(entry?.path || '') || !/^[a-f0-9]{64}$/.test(entry?.sha256 || '')
|
|
17
19
|
|| hash(entry.path) !== entry.sha256 || hash(record.runnerPath) !== record.runnerSha256) throw Error('registered source is absent or changed');
|
|
18
20
|
if (!record.updateModules?.['plugin-artifact-proof.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./plugin-artifact-proof.mjs')) throw Error('new coordinator artifact closure is incomplete');
|
|
19
|
-
|
|
21
|
+
if (!record.updateModules?.['entrypoint.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./entrypoint.mjs')) throw Error('coordinator closure is missing entrypoint.mjs');
|
|
22
|
+
const generation = EXECUTION_MODULES.filter(name => !OPTIONAL_EXECUTION_MODULES.includes(name) || record.updateModules?.[name]);
|
|
20
23
|
if (generation.some(name => !record.updateModules?.[name])) throw Error('coordinator execution closure is incomplete');
|
|
21
24
|
for (const module of Object.values(record.updateModules)) if (hash(module.path) !== module.sha256) throw Error('registered module closure changed');
|
|
22
25
|
return { active: true, ready: true, entry: entry.path, node: record.nodePath, registration, sourceSha256: entry.sha256 };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs']);
|
|
1
|
+
export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs', 'entrypoint.mjs']);
|
|
2
|
+
// Members a coordinator generation may lack: each is required exactly when the staged developer-update.mjs imports it
|
|
3
|
+
// (plugin-artifact-proof since 4.6, entrypoint since the 4.7.21 entry-point sweep), so older registrations stay valid.
|
|
4
|
+
export const OPTIONAL_EXECUTION_MODULES = Object.freeze(['plugin-artifact-proof.mjs', 'entrypoint.mjs']);
|
|
2
5
|
// Shared stack classification and ordering: installations are selected, never manufactured.
|
|
3
6
|
export const FAMILY = /^(ruflo|ruvnet-brain|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|flow-nexus|agent-browser|agent-browser-mcp|agentic-flow|agentic-qe|agentic-robotics|agentic-payments|agentdb|ruv-swarm|@pacphi\/agentic-kit$|@ruvector\/|@claude-flow\/|@metaharness\/|@agentic-robotics\/)/;
|
|
4
7
|
export const PLUGIN_MARKETPLACES = new Set(['ruflo', 'ruview', 'ruvnet-brain', 'cognitum']);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// One existing-install owner. Does not install schedulers or remove plugin generations.
|
|
3
|
+
// Over the 500-line guideline in 4.7.21 on purpose: a split needs a new module in EXECUTION_MODULES, which older registrations
|
|
4
|
+
// cannot stage. The split into a new closure module is planned for 4.7.22, with the runner-changed re-registration work.
|
|
3
5
|
import fs from 'node:fs';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import os from 'node:os';
|
|
@@ -11,6 +13,7 @@ import { acquireDeveloperLock, sharedLockStatus } from './developer-update-lock.
|
|
|
11
13
|
import { maintenance } from './developer-update-maintenance.mjs';
|
|
12
14
|
import { cleanupNpxDuplicates } from './developer-update-cleanup.mjs';
|
|
13
15
|
import { pinnedClaudeTarget, verifyClaudeArtifact } from './plugin-artifact-proof.mjs';
|
|
16
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
14
17
|
export { compare, selectTag, sharedLockStatus };
|
|
15
18
|
const HOME = os.homedir();
|
|
16
19
|
const PREFIX = path.join(HOME, '.npm-global');
|
|
@@ -236,13 +239,16 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
|
|
|
236
239
|
const writable = before.filter(p => p.scope !== 'managed' && !(unattended && ['project', 'local'].includes(p.scope))), flags = pluginFlags(writable, home);
|
|
237
240
|
const provider = writable.length ? claudeProviderCommand(locate('claude'), { prefix, run }) : { command: locate('claude') };
|
|
238
241
|
const claude = provider.command;
|
|
239
|
-
|
|
242
|
+
// No Claude CLI on this PATH (desktop-app, VS Code, nvm or ~/.claude/local installs): nothing can update these plugins
|
|
243
|
+
// here, so each is preserved and reported UNSUPPORTED instead of ending the night (AuditC #1).
|
|
244
|
+
const noClaude = writable.length > 0 && !claude;
|
|
240
245
|
const steps = [], artifactCache = new Map();
|
|
241
|
-
const markets = [...new Set(writable.map(p => p.id.split('@').at(-1)))];
|
|
246
|
+
const markets = noClaude ? [] : [...new Set(writable.map(p => p.id.split('@').at(-1)))];
|
|
242
247
|
if (!dryRun) for (const name of markets) run(claude, ['plugin', 'marketplace', 'update', name]);
|
|
243
248
|
for (const p of before) {
|
|
244
|
-
if (!writable.includes(p)) {
|
|
245
|
-
const reason =
|
|
249
|
+
if (!writable.includes(p) || noClaude) {
|
|
250
|
+
const reason = noClaude && writable.includes(p) ? 'no existing Claude command on this PATH; installed plugin preserved'
|
|
251
|
+
: p.scope === 'managed' ? 'managed scope belongs to its administrator' : 'unattended project settings unverified; original scope preserved';
|
|
246
252
|
steps.push({ id: p.id, scope: p.scope, projectPath: p.projectPath, before: p, after: p, state: 'UNSUPPORTED', reason });
|
|
247
253
|
notes.push(`plugin target unsupported; preserved: ${p.id} (${p.scope}): ${reason}`);
|
|
248
254
|
continue;
|
|
@@ -250,9 +256,17 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
|
|
|
250
256
|
const target = resolvePluginTarget(p, { home, run });
|
|
251
257
|
let artifactTarget = null, artifactProof = null;
|
|
252
258
|
if (target.supported && target.authority === 'commit' && target.proof === 'catalog-pinned-commit' && p.gitCommitSha?.toLowerCase() !== target.commit) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
259
|
+
try {
|
|
260
|
+
artifactTarget = await artifactProvider(target.source, target.commit, { cache: artifactCache });
|
|
261
|
+
artifactProof = verifyClaudeArtifact(artifactTarget, p.installPath);
|
|
262
|
+
target.version = artifactTarget.manifest.version || null;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
// A rate-limited or oversized pinned-artifact proof leaves this plugin unproven, not the night failed (AuditC #16).
|
|
265
|
+
const reason = `pinned artifact unproven: ${error.message}`;
|
|
266
|
+
steps.push({ id: p.id, scope: p.scope, projectPath: p.projectPath, before: p, after: p, target, state: 'UNSUPPORTED', reason });
|
|
267
|
+
notes.push(`plugin target unsupported; preserved: ${p.id}: ${reason}`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
256
270
|
}
|
|
257
271
|
let decision = p.scope === 'managed' ? { state: 'UNSUPPORTED', reason: 'managed scope belongs to its administrator' } : pluginUpdateDecision(artifactProof?.actualVersion ? { ...p, version: artifactProof.actualVersion } : p, target);
|
|
258
272
|
if (artifactProof?.ok && decision.state !== 'AHEAD') decision = { state: 'CURRENT', proof: 'pinned-claude-artifact', sourceCommitMatched: false, actualClaudeSourceMatched: true, providerMetadataDiscrepancy: { recordedCommit: p.gitCommitSha, pinnedCommit: target.commit } };
|
|
@@ -280,36 +294,92 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
|
|
|
280
294
|
const git = (result.marketplaces || []).filter(m => m.marketplaceSource?.sourceType === 'git' && (scope === 'all' || PLUGIN_MARKETPLACES.has(m.name)));
|
|
281
295
|
if (!dryRun) for (const m of git) run(codex, ['plugin', 'marketplace', 'upgrade', m.name, '--json']);
|
|
282
296
|
const help = run(codex, ['plugin', '--help'], { timeout: 30_000 });
|
|
283
|
-
|
|
284
|
-
notes.push('Codex
|
|
297
|
+
// Tripwire, reported not thrown: a new Codex command must not end the night before the knowledge is checked (AuditC #17).
|
|
298
|
+
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
|
+
else notes.push('Codex has no plugin update command; configured Git catalogues refreshed, installed generations preserved');
|
|
285
300
|
}
|
|
286
301
|
if (JSON.stringify(flags) !== JSON.stringify(pluginFlags(writable, home))) throw Error('Codex plugin enablement changed');
|
|
287
302
|
return { before, after, steps, provider };
|
|
288
303
|
}
|
|
289
|
-
|
|
304
|
+
// The bundle download may take its 90-minute deadline (kb/download-retry.mjs) plus the apply; host-update.mjs's own
|
|
305
|
+
// knowledge limit and the SessionStart TTL sit above this, so a kill never lands mid-transaction one level up.
|
|
306
|
+
export const INSTALL_UPDATE_TIMEOUT_MS = 135 * 60_000;
|
|
307
|
+
export const TRANSIENT_RETRY_DELAYS_MS = Object.freeze([15_000, 30_000]);
|
|
308
|
+
const sleepSync = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
309
|
+
const exitOf = result => typeof result === 'string' ? 0 : (result?.exitCode ?? result?.status ?? result?.code ?? null);
|
|
310
|
+
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
|
+
/** Three tries of `attempt` (15 s, then 30 s apart) while `transient(error)` says the failure was the network's. */
|
|
312
|
+
export function withTransientRetry(attempt, { transient = () => true, sleep = sleepSync, delays = TRANSIENT_RETRY_DELAYS_MS } = {}) {
|
|
313
|
+
for (let i = 0; ; i++) {
|
|
314
|
+
try { return attempt(i); } catch (error) { if (i >= delays.length || !transient(error)) throw error; sleep(delays[i]); }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = sleepSync }) {
|
|
290
318
|
const kb = path.join(brainHome, 'kb'), updater = path.join(kb, 'forge-update.mjs');
|
|
291
319
|
if (!exists(updater)) throw Error('existing Brain KB updater absent; fresh install refused');
|
|
292
320
|
const file = path.join(brainHome, `nightly-suite-corpus-${runId}.json`);
|
|
321
|
+
// Exit codes are forge-update's: 0 current, 10 newer, 2 network (retried), 5 incompatible with this runtime.
|
|
322
|
+
const exec = (args, options) => {
|
|
323
|
+
if (!run.receipt) { run(node, args, options); return 0; }
|
|
324
|
+
const result = run.receipt(node, args, options);
|
|
325
|
+
if (result?.error) throw Error(`${path.basename(args[0])} ${args[1]}: ${result.error}`);
|
|
326
|
+
return exitOf(result);
|
|
327
|
+
};
|
|
293
328
|
function check() {
|
|
294
329
|
const started = Date.now();
|
|
295
|
-
|
|
296
|
-
|
|
330
|
+
const code = withTransientRetry(() => {
|
|
331
|
+
const status = exec([updater, '--check', '--result-file', file], { timeout: 180_000, allowed: [0, 10, 2, 5] });
|
|
332
|
+
if (status === 2) throw Object.assign(Error('corpus check could not reach the release (exit 2)'), { transient: true });
|
|
333
|
+
return status;
|
|
334
|
+
}, { transient: error => error.transient === true, sleep });
|
|
335
|
+
if (code === 5) return { currencyVerdict: 'INCOMPATIBLE', exitCode: 5 };
|
|
336
|
+
if (code !== 0 && code !== 10) throw Error(`corpus check exited ${code}`);
|
|
337
|
+
let receipt;
|
|
338
|
+
try { receipt = read(file); } finally { fs.rmSync(file, { force: true }); }
|
|
297
339
|
if (receipt.kind !== 'ruvnet-brain-check-result' || Date.parse(receipt.recordedAt) < started - 1000) throw Error('fresh corpus check receipt absent');
|
|
298
340
|
return receipt;
|
|
299
341
|
}
|
|
342
|
+
const installer = path.join(root, 'ruvnet-brain/bin/install.mjs');
|
|
343
|
+
const apply = () => {
|
|
344
|
+
if (!exists(installer)) throw Error('existing Brain installer absent');
|
|
345
|
+
// 12 is cleanup-pending: the knowledge was applied and the next run finishes the cleanup (shared exit contract).
|
|
346
|
+
const code = exec([installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: INSTALL_UPDATE_TIMEOUT_MS, allowed: [0, 12] });
|
|
347
|
+
if (code !== 0 && code !== 12) throw Error(`Brain installer --update exited ${code}`);
|
|
348
|
+
return code;
|
|
349
|
+
};
|
|
300
350
|
const before = check();
|
|
351
|
+
if (before.currencyVerdict === 'INCOMPATIBLE') {
|
|
352
|
+
// A remembered rejection keyed to the KB's runtime stamp blocks every --check; only the installer re-stamps it. When the
|
|
353
|
+
// installed package is a different version than the stamp, the installer is what clears it (AuditC #5).
|
|
354
|
+
let installed = null, stamped = null;
|
|
355
|
+
try { installed = read(path.join(root, 'ruvnet-brain/package.json')).version || null; } catch { /* no installed package */ }
|
|
356
|
+
try { stamped = read(path.join(kb, 'RUNTIME-IDENTITY.json')).brainVersion || null; } catch { /* no stamp */ }
|
|
357
|
+
if (!installed || installed === stamped) return { before, state: 'incompatible', installed, stamped };
|
|
358
|
+
if (dryRun) return { before, state: 'update-available', reason: 'runtime stamp differs from the installed package', installed, stamped };
|
|
359
|
+
const installerExit = apply(), after = check();
|
|
360
|
+
if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error(`corpus update failed to converge after re-stamping ${stamped} -> ${installed}`);
|
|
361
|
+
return { before, after, state: 'updated', installerExit, restamped: { from: stamped, to: installed } };
|
|
362
|
+
}
|
|
301
363
|
if (before.currencyVerdict === 'CURRENT' || before.currencyVerdict === 'REFUSED') return { before, after: before, state: before.currencyVerdict === 'CURRENT' ? 'current' : 'ahead-preserved' };
|
|
302
364
|
// UNKNOWN is what a tree installed from a code release reports (no corpus generation stamp): forge-update
|
|
303
365
|
// documents it as apply-allowed, exactly like UPDATE_AVAILABLE, and the check after the apply must reach
|
|
304
366
|
// CURRENT or REFUSED. Throwing here failed every scheduled night until someone ran --update by hand.
|
|
305
367
|
if (before.currencyVerdict !== 'UPDATE_AVAILABLE' && before.currencyVerdict !== 'UNKNOWN') throw Error(`corpus currency unverified: ${before.currencyVerdict}`);
|
|
306
368
|
if (dryRun) return { before, state: 'update-available' };
|
|
307
|
-
const
|
|
308
|
-
if (!exists(installer)) throw Error('existing Brain installer absent');
|
|
309
|
-
run(node, [installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: 1_800_000 });
|
|
369
|
+
const installerExit = apply();
|
|
310
370
|
const after = check();
|
|
311
371
|
if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error('corpus update failed to converge');
|
|
312
|
-
return { before, after, state: 'updated' };
|
|
372
|
+
return { before, after, state: 'updated', installerExit };
|
|
373
|
+
}
|
|
374
|
+
/** Corpus check results this coordinator left behind (a run killed between check and delete) older than 48 h. */
|
|
375
|
+
export function pruneCorpusResults(brainHome, now = Date.now()) {
|
|
376
|
+
let removed = 0;
|
|
377
|
+
for (const name of (() => { try { return fs.readdirSync(brainHome); } catch { return []; } })()) {
|
|
378
|
+
if (!/^nightly-suite-corpus-[\w-]+\.json$/.test(name)) continue;
|
|
379
|
+
const file = path.join(brainHome, name);
|
|
380
|
+
try { if (fs.lstatSync(file).isFile() && now - fs.lstatSync(file).mtimeMs > 48 * 3_600_000) { fs.rmSync(file); removed++; } } catch { /* raced */ }
|
|
381
|
+
}
|
|
382
|
+
return removed;
|
|
313
383
|
}
|
|
314
384
|
export function developerUpdatePaths({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
|
|
315
385
|
return { home, brainHome, config: path.join(brainHome, 'developer-update-config.json'),
|
|
@@ -349,7 +419,7 @@ export function locateExecutable(name, { env = process.env, platform = process.p
|
|
|
349
419
|
}
|
|
350
420
|
return null;
|
|
351
421
|
}
|
|
352
|
-
export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env } = {}) {
|
|
422
|
+
export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env, knowledgeOnly = false, sleep = sleepSync } = {}) {
|
|
353
423
|
if (!['check', 'apply'].includes(mode)) throw Error('mode must be check or apply');
|
|
354
424
|
const paths = developerUpdatePaths({ home, brainHome });
|
|
355
425
|
const config = validateDeveloperUpdateConfig(override || readDeveloperUpdateConfig(paths));
|
|
@@ -374,7 +444,7 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
374
444
|
run.receipt = (command, args, options = {}) => dispatch(command, args, { ...options, capture: true });
|
|
375
445
|
const locate = name => locateExecutable(name, { env: childEnv });
|
|
376
446
|
const receipt = { schemaVersion: 1, kind: 'nightly-suite-update', runId: crypto.randomUUID(), pid: process.pid,
|
|
377
|
-
startedAt: new Date().toISOString(), mode, scheduled: mode === 'apply' && env.RUVNET_NIGHTLY === '1' && !!env.RUVNET_NIGHTLY_IDENTITY, state: 'running', ok: false, config, steps: [], notes: [],
|
|
447
|
+
startedAt: new Date().toISOString(), mode, knowledgeOnly, scheduled: mode === 'apply' && env.RUVNET_NIGHTLY === '1' && !!env.RUVNET_NIGHTLY_IDENTITY, state: 'running', ok: false, config, steps: [], notes: [],
|
|
378
448
|
sourceSha256: null, ownerToken: lock.token, ownerPid: lock.ownerPid, schedulerIdentity: env.RUVNET_NIGHTLY_IDENTITY || null };
|
|
379
449
|
const scheduledAttempt = value => value?.mode === 'apply' && value.scheduled !== false && !!value.schedulerIdentity;
|
|
380
450
|
const readAttempt = file => {
|
|
@@ -418,42 +488,71 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
418
488
|
// Bind every subsequent install to this exact prefix instead of inherited npm config.
|
|
419
489
|
childEnv.npm_config_prefix = prefix;
|
|
420
490
|
receipt.npmIdentity = { npm, prefix, root, node: process.execPath };
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (needed) {
|
|
433
|
-
try { published = JSON.parse(run(npm, ['view', before.name, 'time', '--json'], { timeout: 90_000 })); }
|
|
434
|
-
catch { published = {}; }
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages, published }));
|
|
491
|
+
// Each stage is isolated: a failure is recorded on its stage and the run goes on, so one npm view, one plugin or a missing
|
|
492
|
+
// CLI can no longer cost the night its knowledge update (AuditC #1). Packages run before knowledge so a new code package's
|
|
493
|
+
// installer applies its matching corpus; knowledge is always attempted when the KB exists.
|
|
494
|
+
const failures = [];
|
|
495
|
+
const stage = async (name, body, onError = () => {}) => {
|
|
496
|
+
try { return await body(); } catch (error) { failures.push({ stage: name, error: error.message }); onError(error); persistReceipt(); return undefined; }
|
|
497
|
+
};
|
|
498
|
+
const viewRetry = attempt => withTransientRetry(attempt, { transient: error => TRANSIENT_NETWORK.test(error.message), sleep });
|
|
499
|
+
if (!knowledgeOnly) {
|
|
500
|
+
// An inventory failure is recorded in failures; no package is then touched, and knowledge still runs.
|
|
501
|
+
receipt.before = (await stage('inventory', () => discover(root, prefix, config.scope))) ?? [];
|
|
438
502
|
persistReceipt();
|
|
503
|
+
for (const before of receipt.before) {
|
|
504
|
+
await stage(`package ${before.name}`, () => {
|
|
505
|
+
const tags = before.localSource ? {} : JSON.parse(viewRetry(() => run(npm, ['view', before.name, 'dist-tags', '--json'], { timeout: 90_000 })));
|
|
506
|
+
// A tracked non-latest tag (alpha, Kit's next) needs publish times to tell a newer pre-release from an abandoned
|
|
507
|
+
// one (developer-update-policy pickTargetTag); fetched only when that tag and latest name different versions. A
|
|
508
|
+
// failed lookup is {} — required but unknown — so the pre-release is skipped, never installed blind.
|
|
509
|
+
let published = null;
|
|
510
|
+
if (!before.localSource) {
|
|
511
|
+
let needed = false;
|
|
512
|
+
try { needed = needsPublishTimes(normalizeNpmDistTags(tags), wantedTag(before.name, config.channel)); } catch { needed = false; }
|
|
513
|
+
if (needed) {
|
|
514
|
+
try { published = JSON.parse(viewRetry(() => run(npm, ['view', before.name, 'time', '--json'], { timeout: 90_000 }))); }
|
|
515
|
+
catch { published = {}; }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages, published }));
|
|
519
|
+
}, error => receipt.steps.push({ name: before.name, state: 'failed', error: error.message, before }));
|
|
520
|
+
persistReceipt();
|
|
521
|
+
}
|
|
439
522
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
receipt.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
523
|
+
if (exists(path.join(paths.brainHome, 'kb/forge-update.mjs'))) {
|
|
524
|
+
receipt.knowledge = await stage('knowledge', () => knowledge(run, mode === 'check', receipt.runId, { brainHome: paths.brainHome, root, node: process.execPath, sleep }),
|
|
525
|
+
error => { receipt.knowledge = { state: 'failed', error: error.message }; }) ?? receipt.knowledge;
|
|
526
|
+
pruneCorpusResults(paths.brainHome);
|
|
527
|
+
} else receipt.notes.push('Brain corpus absent; fresh knowledge install excluded');
|
|
528
|
+
persistReceipt();
|
|
529
|
+
if (!knowledgeOnly) {
|
|
530
|
+
receipt.plugins = await stage('plugins', () => synchronizePlugins(run, mode === 'check', receipt.notes, { home, prefix, scope: config.scope, locate, unattended: !!env.RUVNET_NIGHTLY_IDENTITY }));
|
|
531
|
+
receipt.maintenance = await stage('maintenance', () => maintenance(config, run, mode === 'check', { home, brainHome: paths.brainHome, locate, node: process.execPath, progress: result => { receipt.maintenance = result; persistReceipt(); } }),
|
|
532
|
+
error => { for (const item of receipt.maintenance?.stages || []) if (item.state === 'running') { item.state = 'failed'; item.error = error.message; } }) ?? receipt.maintenance;
|
|
533
|
+
receipt.cleanup = await stage('cleanup', () => cleanupNpxDuplicates({ home, globalRoot: root, enabled: config.cleanup && mode === 'apply', run }));
|
|
534
|
+
receipt.after = await stage('inventory-after', () => discover(root, prefix, config.scope));
|
|
535
|
+
} else receipt.notes.push('knowledge-only run: packages, plugins and maintenance are left to the scheduled nightly');
|
|
536
|
+
receipt.failures = failures; receipt.ok = failures.length === 0;
|
|
537
|
+
receipt.state = failures.length ? 'completed-with-failures' : mode === 'check' ? 'checked' : 'completed';
|
|
538
|
+
receipt.coverage = { scope: knowledgeOnly ? 'knowledge-only' : config.scope, excluded: receipt.steps.filter(s => /preserved$/.test(s.state)).map(s => ({ name: s.name, reason: s.state })),
|
|
448
539
|
ahead: receipt.steps.filter(s => s.state === 'ahead-preserved').map(s => s.name),
|
|
449
540
|
preservedLocalModification: receipt.steps.filter(s => s.reason === 'preservedLocalModification').map(s => s.name),
|
|
450
|
-
unverified: [...new Set([...receipt.notes.filter(note => /unverified|unsupported|no plugin update command/i.test(note)), ...(receipt.plugins?.steps || []).filter(step => step.state === 'UNSUPPORTED').map(step => `${step.id} (${step.scope}): ${step.reason}`)])], notes: [...receipt.notes, ...(receipt.maintenance?.exclusions || [])] };
|
|
541
|
+
unverified: [...new Set([...failures.map(f => `${f.stage}: ${f.error}`), ...receipt.notes.filter(note => /unverified|unsupported|no plugin update command/i.test(note)), ...(receipt.plugins?.steps || []).filter(step => step.state === 'UNSUPPORTED').map(step => `${step.id} (${step.scope}): ${step.reason}`)])], notes: [...receipt.notes, ...(receipt.maintenance?.exclusions || [])] };
|
|
542
|
+
if (failures.length) {
|
|
543
|
+
// Throw only after the receipt says which stages failed: the runner still exits 1 exactly as before.
|
|
544
|
+
receipt.error = failures.map(f => `${f.stage}: ${f.error}`).join('; ');
|
|
545
|
+
persistReceipt();
|
|
546
|
+
throw Object.assign(Error(`developer update completed with failures: ${receipt.error}`), { completedWithFailures: true });
|
|
547
|
+
}
|
|
451
548
|
return receipt;
|
|
452
549
|
} catch (error) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
550
|
+
if (!error.completedWithFailures) {
|
|
551
|
+
receipt.state = 'failed'; receipt.error = error.message;
|
|
552
|
+
for (const item of receipt.maintenance?.stages || []) if (item.state === 'running') { item.state = 'failed'; item.error = error.message; }
|
|
553
|
+
receipt.coverage = { scope: config.scope, unverified: [error.message], notes: receipt.notes };
|
|
554
|
+
try { if (receipt.npmIdentity) receipt.after = discover(receipt.npmIdentity.root, receipt.npmIdentity.prefix, config.scope); } catch (e) { receipt.snapshotError = e.message; }
|
|
555
|
+
}
|
|
457
556
|
throw error;
|
|
458
557
|
} finally {
|
|
459
558
|
receipt.finishedAt = new Date().toISOString();
|
|
@@ -462,19 +561,22 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
462
561
|
}
|
|
463
562
|
export async function developerUpdateCli(args = process.argv.slice(2)) {
|
|
464
563
|
if (args.includes('--help') || args.includes('-h')) {
|
|
465
|
-
console.log('Usage: ruvnet-brain-update [--check|--apply] [--channel latest|alpha] [--scope ruvnet|all]');
|
|
564
|
+
console.log('Usage: ruvnet-brain-update [--check|--apply] [--knowledge-only] [--channel latest|alpha] [--scope ruvnet|all]');
|
|
466
565
|
return;
|
|
467
566
|
}
|
|
468
|
-
|
|
567
|
+
// --knowledge-only: npm identity plus the knowledge step under the same lock (the SessionStart self-heal); the full suite
|
|
568
|
+
// runs only from the scheduled runner.
|
|
569
|
+
let mode = 'apply', knowledgeOnly = false; const override = readDeveloperUpdateConfig();
|
|
469
570
|
for (let i = 0; i < args.length; i++) {
|
|
470
|
-
if (args[i] === '--
|
|
571
|
+
if (args[i] === '--knowledge-only') knowledgeOnly = true;
|
|
572
|
+
else if (args[i] === '--check' || args[i] === '--dry-run') mode = 'check';
|
|
471
573
|
else if (args[i] === '--apply') mode = 'apply';
|
|
472
574
|
else if (args[i] === '--channel') override.channel = args[++i];
|
|
473
575
|
else if (args[i] === '--scope') override.scope = args[++i];
|
|
474
576
|
else throw Error(`unsupported developer update argument: ${args[i]}`);
|
|
475
577
|
}
|
|
476
|
-
const result = await runDeveloperUpdate({ mode, config: override });
|
|
578
|
+
const result = await runDeveloperUpdate({ mode, config: override, knowledgeOnly });
|
|
477
579
|
console.log(JSON.stringify(result));
|
|
478
580
|
return result;
|
|
479
581
|
}
|
|
480
|
-
if (
|
|
582
|
+
if (isDirectInvocation(import.meta.url)) developerUpdateCli().catch(error => { console.error(error.message); process.exitCode = 1; });
|