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
|
@@ -8,6 +8,15 @@ const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
|
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}$/;
|
|
11
|
+
// refresh-run.mjs atomicJson(): `<runId>.json.tmp-<pid>-<8 hex>`, written, fsynced, then renamed (#434).
|
|
12
|
+
const ATOMIC_TEMP = /^([A-Za-z0-9._-]+)\.json\.tmp-(\d+)-([a-f0-9]{8})$/;
|
|
13
|
+
// The write+rename takes milliseconds; a temp this old was abandoned by a crash or a failed write,
|
|
14
|
+
// whatever its PID (PIDs are reused, so the age is the evidence, not process liveness).
|
|
15
|
+
export const ABANDONED_TEMP_AGE_MS = 60 * 60 * 1000;
|
|
16
|
+
// Files an operating system or a file browser drops into any folder it shows (Finder, exFAT/SMB shadows,
|
|
17
|
+
// Explorer). They are never evidence and never ours to judge: ignored, and removed when a plain file.
|
|
18
|
+
const VOLUME_METADATA = /^(?:\.DS_Store|\._.*|Thumbs\.db|desktop\.ini)$/i;
|
|
19
|
+
export const isVolumeMetadata = (name) => VOLUME_METADATA.test(String(name));
|
|
11
20
|
|
|
12
21
|
export const LIFECYCLE_EVIDENCE_RETENTION_POLICY = Object.freeze({
|
|
13
22
|
schemaVersion: 1,
|
|
@@ -49,17 +58,85 @@ function stamp(value, label) {
|
|
|
49
58
|
return parsed;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
/**
|
|
62
|
+
* An abandoned atomic-write temporary (#434) is classified, not treated as foreign evidence. It is
|
|
63
|
+
* reclaimable only when it is an exclusive regular file older than ABANDONED_TEMP_AGE_MS and either
|
|
64
|
+
* empty or shadowed by a valid canonical receipt of the same run; a young one is an in-flight write
|
|
65
|
+
* (left alone), and a non-empty orphan with no canonical receipt is preserved as unsafe.
|
|
66
|
+
*/
|
|
67
|
+
function classifyAtomicTemp(root, file, match, nowMs, unsafe, temporaries) {
|
|
68
|
+
const stat = fs.lstatSync(file);
|
|
69
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('atomic-write temporary is not an exclusive regular file');
|
|
70
|
+
const identity = { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
71
|
+
const row = { path: file, runId: match[1], writerPid: Number(match[2]), bytes: stat.size, identity,
|
|
72
|
+
sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') };
|
|
73
|
+
if (!(nowMs - stat.mtimeMs >= ABANDONED_TEMP_AGE_MS)) { temporaries.push({ ...row, disposition: 'in-flight' }); return; }
|
|
74
|
+
let canonical = false;
|
|
75
|
+
try {
|
|
76
|
+
const { value } = regularJson(path.join(root, `${match[1]}.json`));
|
|
77
|
+
canonical = value?.kind === 'ruvnet-brain-refresh-run' && value.runId === match[1];
|
|
78
|
+
} catch { /* no valid canonical receipt */ }
|
|
79
|
+
if (stat.size !== 0 && !canonical) {
|
|
80
|
+
unsafe.push({ path: file, reason: 'abandoned atomic-write temporary has content and no canonical receipt; preserved' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
temporaries.push({ ...row, disposition: 'abandoned', canonicalReceipt: canonical });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A `.prune-<id>-<16 hex>` entry is the rename quarantineAndRemove() makes immediately before it deletes.
|
|
88
|
+
* Only an interruption between the two leaves one behind (a time-limit kill of the updater is the realistic
|
|
89
|
+
* cause, 4.7.21 RC2.3). It is reclaimed when it is older than ABANDONED_TEMP_AGE_MS (no prune is still
|
|
90
|
+
* between rename and delete), has the exact shape this module quarantines (a plain file under refresh-runs;
|
|
91
|
+
* a directory of phase files under the transactions root), and the run it came from is not live again
|
|
92
|
+
* (its canonical entry is absent or terminal). Anything else stays unsafe and is preserved, as before.
|
|
93
|
+
*/
|
|
94
|
+
function classifyQuarantine(root, entry, name, kind, nowMs, unsafe, quarantines) {
|
|
95
|
+
const preserve = (reason) => unsafe.push({ path: entry, reason });
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.lstatSync(entry);
|
|
98
|
+
const ageMs = nowMs - Math.max(stat.mtimeMs, stat.ctimeMs);
|
|
99
|
+
if (!(ageMs >= ABANDONED_TEMP_AGE_MS)) return preserve('recognized pruning quarantine remains');
|
|
100
|
+
const id = /^\.prune-(.+)-[a-f0-9]{16}$/.exec(name)[1];
|
|
101
|
+
if (kind === 'refresh') {
|
|
102
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return preserve('recognized pruning quarantine remains (not a quarantined receipt)');
|
|
103
|
+
try {
|
|
104
|
+
const { value } = regularJson(path.join(root, `${id}.json`));
|
|
105
|
+
if (!TERMINAL_REFRESH.has(value?.status)) return preserve('recognized pruning quarantine remains (its run is active)');
|
|
106
|
+
} catch (error) { if (error?.code !== 'ENOENT') return preserve('recognized pruning quarantine remains (its run receipt is unreadable)'); }
|
|
107
|
+
} else {
|
|
108
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return preserve('recognized pruning quarantine remains (not a quarantined transaction)');
|
|
109
|
+
const names = fs.readdirSync(entry).filter((file) => !isVolumeMetadata(file));
|
|
110
|
+
if (names.some((file) => !PHASE_FILE.test(file))) return preserve('recognized pruning quarantine remains (not a quarantined transaction)');
|
|
111
|
+
if (fs.existsSync(path.join(root, id))) return preserve('recognized pruning quarantine remains (its transaction is present)');
|
|
112
|
+
}
|
|
113
|
+
quarantines.push({ path: entry, kind, id, bytes: treeBytes(entry), identity: { dev: stat.dev, ino: stat.ino } });
|
|
114
|
+
} catch (error) { preserve(`recognized pruning quarantine remains (${error.message})`); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scanRefresh(root, unsafe, temporaries = [], nowMs = Date.now(), extras = { quarantines: [], foreign: [], volumeMetadata: [] }) {
|
|
53
118
|
if (!fs.existsSync(root)) return [];
|
|
54
119
|
const rows = [];
|
|
55
120
|
for (const name of fs.readdirSync(root).sort()) {
|
|
56
121
|
const file = path.join(root, name);
|
|
122
|
+
if (isVolumeMetadata(name)) { extras.volumeMetadata.push(file); continue; }
|
|
57
123
|
if (QUARANTINE.test(name)) {
|
|
58
|
-
|
|
124
|
+
classifyQuarantine(root, file, name, 'refresh', nowMs, unsafe, extras.quarantines);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const temp = ATOMIC_TEMP.exec(name);
|
|
128
|
+
if (temp) {
|
|
129
|
+
try { classifyAtomicTemp(root, file, temp, nowMs, unsafe, temporaries); } catch (error) { unsafe.push({ path: file, reason: error.message }); }
|
|
59
130
|
continue;
|
|
60
131
|
}
|
|
61
132
|
try {
|
|
62
|
-
if (!name.endsWith('.json'))
|
|
133
|
+
if (!name.endsWith('.json')) {
|
|
134
|
+
// Not evidence and not ours (a note, a copy someone made): reported and kept, never a reason to
|
|
135
|
+
// fail an update. Only a non-file (directory, link, device) stays unsafe.
|
|
136
|
+
const stat = fs.lstatSync(file);
|
|
137
|
+
if (stat.isFile() && !stat.isSymbolicLink()) { extras.foreign.push({ path: file, bytes: stat.size }); continue; }
|
|
138
|
+
throw new Error('unexpected refresh evidence entry');
|
|
139
|
+
}
|
|
63
140
|
const { value: receipt, bytes } = regularJson(file);
|
|
64
141
|
const runId = name.slice(0, -5);
|
|
65
142
|
if (!SAFE_ID.test(runId) || receipt?.schemaVersion !== 3 || receipt?.kind !== 'ruvnet-brain-refresh-run'
|
|
@@ -73,21 +150,25 @@ function scanRefresh(root, unsafe) {
|
|
|
73
150
|
return rows;
|
|
74
151
|
}
|
|
75
152
|
|
|
76
|
-
function scanTransactions(root, unsafe) {
|
|
153
|
+
function scanTransactions(root, unsafe, nowMs = Date.now(), extras = { quarantines: [], foreign: [], volumeMetadata: [] }) {
|
|
77
154
|
if (!fs.existsSync(root)) return [];
|
|
78
155
|
const rows = [];
|
|
79
156
|
for (const name of fs.readdirSync(root).sort()) {
|
|
80
157
|
const dir = path.join(root, name);
|
|
158
|
+
if (isVolumeMetadata(name)) { extras.volumeMetadata.push(dir); continue; }
|
|
81
159
|
if (QUARANTINE.test(name)) {
|
|
82
|
-
|
|
160
|
+
classifyQuarantine(root, dir, name, 'transaction', nowMs, unsafe, extras.quarantines);
|
|
83
161
|
continue;
|
|
84
162
|
}
|
|
85
163
|
try {
|
|
86
164
|
const stat = fs.lstatSync(dir);
|
|
165
|
+
if (stat.isFile() && !stat.isSymbolicLink()) { extras.foreign.push({ path: dir, bytes: stat.size }); continue; }
|
|
87
166
|
if (!SAFE_ID.test(name) || !stat.isDirectory() || stat.isSymbolicLink()) {
|
|
88
167
|
throw new Error('malformed transaction evidence entry');
|
|
89
168
|
}
|
|
90
|
-
const
|
|
169
|
+
const all = fs.readdirSync(dir).sort();
|
|
170
|
+
for (const file of all) if (isVolumeMetadata(file)) extras.volumeMetadata.push(path.join(dir, file));
|
|
171
|
+
const names = all.filter((file) => !isVolumeMetadata(file));
|
|
91
172
|
if (!names.length || names.some((file) => !PHASE_FILE.test(file))) throw new Error('transaction phase inventory is malformed');
|
|
92
173
|
const phases = names.map((file) => regularJson(path.join(dir, file)).value);
|
|
93
174
|
const latest = phases.at(-1);
|
|
@@ -137,8 +218,11 @@ function trustedEvidenceRoot(root, unsafe) {
|
|
|
137
218
|
function snapshot(options) {
|
|
138
219
|
const roots = rootsFor(options);
|
|
139
220
|
const unsafe = [];
|
|
140
|
-
const
|
|
141
|
-
const
|
|
221
|
+
const temporaries = [];
|
|
222
|
+
const extras = { quarantines: [], foreign: [], volumeMetadata: [] };
|
|
223
|
+
const nowMs = Date.parse((options.now || (() => new Date().toISOString()))());
|
|
224
|
+
const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe, temporaries, nowMs, extras) : [];
|
|
225
|
+
const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe, nowMs, extras) : [];
|
|
142
226
|
const preserveRefresh = new Set((options.preserveRefreshRunIds || []).map(String));
|
|
143
227
|
const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => physicalPath(entry)));
|
|
144
228
|
const protectedRefresh = new Map();
|
|
@@ -170,11 +254,11 @@ function snapshot(options) {
|
|
|
170
254
|
if (preserveTransactions.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'explicitly preserved transaction');
|
|
171
255
|
if (retainedTransactionPaths.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
|
|
172
256
|
}
|
|
173
|
-
const bytes = [...refresh, ...transactions].reduce((sum, row) => sum + row.bytes, 0)
|
|
257
|
+
const bytes = [...refresh, ...transactions, ...temporaries, ...extras.quarantines].reduce((sum, row) => sum + row.bytes, 0)
|
|
174
258
|
+ unsafe.filter(({ reason }) => /quarantine remains/.test(reason)).reduce((sum, { path: entry }) => {
|
|
175
259
|
try { return sum + treeBytes(entry); } catch { return sum; }
|
|
176
260
|
}, 0);
|
|
177
|
-
return { roots, unsafe, refresh, transactions, bytes, protectedRefresh, protectedTransactions };
|
|
261
|
+
return { roots, unsafe, refresh, transactions, temporaries, bytes, protectedRefresh, protectedTransactions, ...extras };
|
|
178
262
|
}
|
|
179
263
|
|
|
180
264
|
function counts(state) {
|
|
@@ -202,6 +286,10 @@ export function assessLifecycleEvidence(options = {}) {
|
|
|
202
286
|
&& !state.protectedTransactions.has(row.path)).sort((a, b) => a.timestamp - b.timestamp)
|
|
203
287
|
.map(({ path: entryPath }) => entryPath),
|
|
204
288
|
},
|
|
289
|
+
abandonedTemporaries: state.temporaries.filter((row) => row.disposition === 'abandoned')
|
|
290
|
+
.map(({ path: entryPath, runId, writerPid, bytes, sha256, canonicalReceipt }) => ({ path: entryPath, runId, writerPid, bytes, sha256, canonicalReceipt })),
|
|
291
|
+
abandonedQuarantines: state.quarantines.map(({ path: entryPath, kind, id, bytes }) => ({ path: entryPath, kind, id, bytes })),
|
|
292
|
+
foreign: state.foreign, volumeMetadata: state.volumeMetadata,
|
|
205
293
|
removed: { refresh: [], transactions: [] }, after: counts(state), unsafe: state.unsafe,
|
|
206
294
|
withinBudget: isWithin(state, policy) };
|
|
207
295
|
}
|
|
@@ -213,9 +301,11 @@ function quarantineAndRemove(entry, root, removeEntry, afterQuarantine) {
|
|
|
213
301
|
throw new Error(`refusing unsafe lifecycle evidence removal: ${entry}`);
|
|
214
302
|
}
|
|
215
303
|
const quarantine = path.join(root, `.prune-${name.replace(/\.json$/, '')}-${crypto.randomBytes(8).toString('hex')}`);
|
|
216
|
-
|
|
304
|
+
// Vanished since the snapshot (another sweep, the writer itself): already gone, not a failed prune.
|
|
305
|
+
try { fs.renameSync(resolved, quarantine); } catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
|
|
217
306
|
afterQuarantine?.({ entry: resolved, quarantine });
|
|
218
307
|
removeEntry(quarantine);
|
|
308
|
+
return true;
|
|
219
309
|
}
|
|
220
310
|
|
|
221
311
|
// Keep every JSON token (including numeric precision and escaped string spelling).
|
|
@@ -223,6 +313,7 @@ function quarantineAndRemove(entry, root, removeEntry, afterQuarantine) {
|
|
|
223
313
|
function compactTerminalPhases(row) {
|
|
224
314
|
const compacted = [];
|
|
225
315
|
for (const name of fs.readdirSync(row.path).sort()) {
|
|
316
|
+
if (isVolumeMetadata(name)) continue;
|
|
226
317
|
const file = path.join(row.path, name);
|
|
227
318
|
const stat = fs.lstatSync(file);
|
|
228
319
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('phase is not an exclusive regular file');
|
|
@@ -257,6 +348,7 @@ function compactTerminalPhases(row) {
|
|
|
257
348
|
return compacted;
|
|
258
349
|
}
|
|
259
350
|
|
|
351
|
+
export const MAX_VANISHED_RETRIES = 64;
|
|
260
352
|
export function pruneLifecycleEvidence(options = {}) {
|
|
261
353
|
const policy = options.policy || LIFECYCLE_EVIDENCE_RETENTION_POLICY;
|
|
262
354
|
const removeEntry = options.removeEntry || ((entry) => fs.rmSync(entry, { recursive: true, force: true }));
|
|
@@ -265,6 +357,40 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
265
357
|
// the exact entry it just quarantined. Recovery must not guess away private data.
|
|
266
358
|
const initial = assessLifecycleEvidence({ ...options, policy });
|
|
267
359
|
const removed = { refresh: [], transactions: [] };
|
|
360
|
+
// #434: reclaim abandoned atomic-write temporaries first (reported with size and sha256, never
|
|
361
|
+
// silent). The file identity is rechecked immediately before it is quarantined.
|
|
362
|
+
const reclaimedTemporaries = [];
|
|
363
|
+
for (const row of snapshot(options).temporaries.filter((entry) => entry.disposition === 'abandoned')) {
|
|
364
|
+
let current;
|
|
365
|
+
// A temp that vanished between the snapshot and this re-check is already gone; it must not throw the whole prune.
|
|
366
|
+
try { current = fs.lstatSync(row.path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
|
|
367
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || current.dev !== row.identity.dev
|
|
368
|
+
|| current.ino !== row.identity.ino || current.size !== row.identity.size || current.mtimeMs !== row.identity.mtimeMs) continue;
|
|
369
|
+
if (!quarantineAndRemove(row.path, path.dirname(row.path), removeEntry, options.afterQuarantine)) continue;
|
|
370
|
+
reclaimedTemporaries.push({ path: row.path, runId: row.runId, writerPid: row.writerPid, bytes: row.bytes,
|
|
371
|
+
sha256: row.sha256, canonicalReceipt: row.canonicalReceipt });
|
|
372
|
+
}
|
|
373
|
+
// Volume metadata (.DS_Store and friends) is removed when it is a plain file; anything else by that
|
|
374
|
+
// name is left alone (it was never counted as evidence either way).
|
|
375
|
+
const removedVolumeMetadata = [];
|
|
376
|
+
for (const entry of snapshot(options).volumeMetadata) {
|
|
377
|
+
try {
|
|
378
|
+
const stat = fs.lstatSync(entry);
|
|
379
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
380
|
+
fs.rmSync(entry, { force: true });
|
|
381
|
+
removedVolumeMetadata.push(entry);
|
|
382
|
+
} catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
383
|
+
}
|
|
384
|
+
// Abandoned quarantines of this module's own interrupted prunes (classifyQuarantine), identity re-checked
|
|
385
|
+
// immediately before removal and reported, never silent.
|
|
386
|
+
const reclaimedQuarantines = [];
|
|
387
|
+
for (const row of snapshot(options).quarantines) {
|
|
388
|
+
let current;
|
|
389
|
+
try { current = fs.lstatSync(row.path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
|
|
390
|
+
if (current.isSymbolicLink() || current.dev !== row.identity.dev || current.ino !== row.identity.ino) continue;
|
|
391
|
+
removeEntry(row.path);
|
|
392
|
+
reclaimedQuarantines.push({ path: row.path, kind: row.kind, id: row.id, bytes: row.bytes });
|
|
393
|
+
}
|
|
268
394
|
const compacted = [];
|
|
269
395
|
const compactState = snapshot(options);
|
|
270
396
|
if (!compactState.unsafe.length && compactState.bytes > policy.maxEvidenceBytes) {
|
|
@@ -272,6 +398,10 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
272
398
|
compacted.push(...compactTerminalPhases(row));
|
|
273
399
|
}
|
|
274
400
|
}
|
|
401
|
+
// Review r2 (4.7.21): an entry that "vanishes" on every rename (a filesystem that keeps listing it, a writer that
|
|
402
|
+
// keeps recreating it) must not spin this loop forever. Bounded; when the bound trips, pruning stops (fails closed:
|
|
403
|
+
// nothing more is removed) and the reason is returned.
|
|
404
|
+
let vanishedRetries = 0; let stopped = null;
|
|
275
405
|
for (;;) {
|
|
276
406
|
const state = snapshot(options);
|
|
277
407
|
if (isWithin(state, policy)) break;
|
|
@@ -285,10 +415,17 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
285
415
|
: [...refreshCandidates, ...transactionCandidates].sort((a, b) => a.timestamp - b.timestamp)[0];
|
|
286
416
|
if (!candidate) break;
|
|
287
417
|
const root = candidate.kind === 'refresh' ? state.roots.refresh : state.roots.transactions;
|
|
288
|
-
quarantineAndRemove(candidate.path, root, removeEntry, options.afterQuarantine)
|
|
418
|
+
if (!quarantineAndRemove(candidate.path, root, removeEntry, options.afterQuarantine)) { // gone: re-snapshot
|
|
419
|
+
vanishedRetries += 1;
|
|
420
|
+
if (vanishedRetries > MAX_VANISHED_RETRIES) {
|
|
421
|
+
stopped = `pruning stopped: lifecycle evidence vanished ${vanishedRetries} times while being removed (last: ${candidate.path}); nothing further was removed`;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
289
426
|
removed[candidate.kind === 'refresh' ? 'refresh' : 'transactions'].push(candidate.path);
|
|
290
427
|
}
|
|
291
428
|
const final = assessLifecycleEvidence({ ...options, policy });
|
|
292
|
-
return { ...final, before: initial.before, removed, compacted,
|
|
293
|
-
after: final.after, withinBudget: final.withinBudget };
|
|
429
|
+
return { ...final, before: initial.before, removed, reclaimedTemporaries, reclaimedQuarantines, removedVolumeMetadata, compacted,
|
|
430
|
+
after: final.after, withinBudget: final.withinBudget, ...(stopped ? { stopped } : {}) };
|
|
294
431
|
}
|
|
@@ -90,8 +90,11 @@ export function requiredEmbedderModels(kbDir) {
|
|
|
90
90
|
return models.size ? [...models].sort() : [MINILM_MODEL];
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// A model is present only when the files the strict offline reader loads are there (#301): a
|
|
94
|
+
// folder left by a download that did not finish (no onnx/model_quantized.onnx) counts as missing,
|
|
95
|
+
// or the doctor calls a cache warm that cannot answer a query.
|
|
93
96
|
export function missingEmbedderModels(modelCache, models) {
|
|
94
|
-
return models.filter((model) => !
|
|
97
|
+
return models.filter((model) => !modelCacheReady(modelCache, model));
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
export function configureTransformersModel(T, modelCache, model) {
|
package/kb/refresh-run.mjs
CHANGED
|
@@ -58,15 +58,19 @@ function sameExecutable(left, right) {
|
|
|
58
58
|
try { return fs.realpathSync(left) === fs.realpathSync(right); } catch { return path.resolve(left || '') === path.resolve(right || ''); }
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
const observed = inspectProcess
|
|
61
|
+
function observeOwnerProcess(owner, { inspectProcess, isAlive }) {
|
|
62
|
+
return inspectProcess
|
|
64
63
|
? inspectProcess(owner.pid)
|
|
65
64
|
: (isAlive && isAlive !== alive)
|
|
66
65
|
? (isAlive(owner.pid)
|
|
67
66
|
? { state: 'live', processStart: owner.processStart, executable: owner.executable }
|
|
68
67
|
: { state: 'dead' })
|
|
69
68
|
: observedProcessIdentity(owner.pid);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ownerState(owner, { hostname, inspectProcess, isAlive }) {
|
|
72
|
+
if (!owner || owner.host !== hostname || !owner.processStart || !owner.executable) return 'unknown';
|
|
73
|
+
const observed = observeOwnerProcess(owner, { inspectProcess, isAlive });
|
|
70
74
|
if (observed?.state !== 'live') return observed?.state === 'dead' ? 'dead' : 'unknown';
|
|
71
75
|
return observed.processStart === owner.processStart && sameExecutable(observed.executable, owner.executable)
|
|
72
76
|
? 'live'
|
|
@@ -78,6 +82,135 @@ export function inspectRefreshOwner(owner, { hostname = os.hostname(), inspectPr
|
|
|
78
82
|
return ownerState(owner, { hostname, inspectProcess, isAlive });
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
// A lock nobody can prove alive must not wedge every later run (4.7.21, audit C). Liveness cannot be proven
|
|
86
|
+
// across hosts (and on macOS os.hostname() follows the network, so "another host" is often this Mac on
|
|
87
|
+
// another Wi-Fi: ownerVerdict checks the pid here first), but a refresh run cannot outlive the updater's own deadlines. An owner.json that cannot be
|
|
88
|
+
// read or lacks its identity is debris once no writer could still be publishing it.
|
|
89
|
+
export const REMOTE_OWNER_DEAD_MS = 3 * 3600_000;
|
|
90
|
+
export const INCOMPLETE_OWNER_RECLAIM_MS = 3600_000;
|
|
91
|
+
const ownerComplete = (owner) => owner?.schemaVersion === 3 && Boolean(owner.runId && owner.token && owner.host
|
|
92
|
+
&& Number.isSafeInteger(owner.pid) && owner.receiptPath && owner.brainHome);
|
|
93
|
+
const removalCommand = (lockPath) => (process.platform === 'win32'
|
|
94
|
+
? `Remove-Item -Recurse -Force "${lockPath}"` : `rm -rf "${lockPath}"`);
|
|
95
|
+
function newestMtimeMs(...files) {
|
|
96
|
+
let newest = Number.NaN;
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
try { const ms = fs.lstatSync(file).mtimeMs; if (!(newest >= ms)) newest = ms; } catch { /* absent */ }
|
|
99
|
+
}
|
|
100
|
+
return newest;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 'dead' also for a remote owner past REMOTE_OWNER_DEAD_MS; every other answer is ownerState's.
|
|
105
|
+
* 4.7.21 review (Opus updater #2, reproduced): on macOS the hostname follows the network, so an owner recorded
|
|
106
|
+
* under "another host" is often THIS Mac, asleep mid-update on the old Wi-Fi. Its pid is inspected here first: the
|
|
107
|
+
* same pid alive with the same process start and executable IS the owner, whatever the hostname says. Only an
|
|
108
|
+
* absent or different local process may be aged out.
|
|
109
|
+
*/
|
|
110
|
+
function ownerVerdict(owner, { hostname, inspectProcess, isAlive, nowMs }) {
|
|
111
|
+
const state = ownerState(owner, { hostname, inspectProcess, isAlive });
|
|
112
|
+
if (state !== 'unknown' || !ownerComplete(owner) || owner.host === hostname) return { state, reason: 'exact-dead-owner' };
|
|
113
|
+
if (owner.processStart && owner.executable) {
|
|
114
|
+
const local = observeOwnerProcess(owner, { inspectProcess, isAlive });
|
|
115
|
+
if (local?.state === 'live' && local.processStart === owner.processStart
|
|
116
|
+
&& sameExecutable(local.executable, owner.executable)) return { state: 'live', reason: 'same-process-other-hostname' };
|
|
117
|
+
}
|
|
118
|
+
const startedMs = Date.parse(owner.startedAt || '');
|
|
119
|
+
return Number.isFinite(startedMs) && nowMs - startedMs > REMOTE_OWNER_DEAD_MS
|
|
120
|
+
? { state: 'dead', reason: 'stale-remote-owner' }
|
|
121
|
+
: { state, reason: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── takeover is serialized (4.7.21 review, Codex #1) ─────────────────────────────────────────────
|
|
125
|
+
// Two contenders can judge the same abandoned lock. Without a claim, A reclaims and acquires, then B removes
|
|
126
|
+
// A's FRESH lock on its stale judgement and both run. A takeover now (1) creates an exclusive claim file
|
|
127
|
+
// inside the judged lock directory ('wx': one winner per directory instance), (2) re-checks, while holding
|
|
128
|
+
// the claim, that the directory is the same one (dev/ino) and owner.json is byte-identical to what was
|
|
129
|
+
// judged, and only then (3) moves it aside. A claim left by a crashed reclaimer is superseded by the next
|
|
130
|
+
// numbered claim once CLAIM_STALE_MS old, so a crash mid-takeover cannot wedge the lock either.
|
|
131
|
+
export const CLAIM_STALE_MS = 5 * 60_000;
|
|
132
|
+
const CLAIM_FILE = /^takeover(?:-(\d+))?\.json$/;
|
|
133
|
+
function dirIdentity(dir) {
|
|
134
|
+
const stat = fs.lstatSync(dir, { bigint: true });
|
|
135
|
+
if (!stat.isDirectory()) throw Object.assign(new Error(`${dir} is not a lock directory`), { code: 'ENOTDIR' });
|
|
136
|
+
return `${stat.dev}:${stat.ino}`;
|
|
137
|
+
}
|
|
138
|
+
function readRaw(file) {
|
|
139
|
+
try { return fs.readFileSync(file, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
|
|
140
|
+
}
|
|
141
|
+
// A claim file must not restart the age an incomplete owner is judged by (Opus r2 #6): writing or removing one bumps
|
|
142
|
+
// the lock directory's mtime, which newestMtimeMs reads, so the directory's own times are put back.
|
|
143
|
+
function keepDirectoryTimes(dir, change) {
|
|
144
|
+
let before = null;
|
|
145
|
+
try { before = fs.lstatSync(dir); } catch { /* judged below */ }
|
|
146
|
+
try { return change(); } finally { if (before) try { fs.lutimesSync(dir, before.atime, before.mtime); } catch { /* gone */ } }
|
|
147
|
+
}
|
|
148
|
+
const GONE = Symbol('judged lock vanished');
|
|
149
|
+
function claimTakeover(lockPath, hostname) {
|
|
150
|
+
let highest = -1;
|
|
151
|
+
let highestName = null;
|
|
152
|
+
let names;
|
|
153
|
+
// The judged lock vanished (another reclaimer moved it): re-judge rather than throw (Fable r2 P3-1).
|
|
154
|
+
try { names = fs.readdirSync(lockPath); } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return GONE; throw error; }
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
const match = CLAIM_FILE.exec(name);
|
|
157
|
+
if (!match) continue;
|
|
158
|
+
const level = match[1] ? Number(match[1]) : 0;
|
|
159
|
+
if (level > highest) { highest = level; highestName = name; }
|
|
160
|
+
}
|
|
161
|
+
if (highestName) {
|
|
162
|
+
let ageMs = 0;
|
|
163
|
+
try { ageMs = Date.now() - fs.lstatSync(path.join(lockPath, highestName)).mtimeMs; } catch { return null; }
|
|
164
|
+
if (!(ageMs > CLAIM_STALE_MS)) return null; // another reclaimer is mid-takeover
|
|
165
|
+
}
|
|
166
|
+
const level = highest + 1;
|
|
167
|
+
const file = path.join(lockPath, level ? `takeover-${level}.json` : 'takeover.json');
|
|
168
|
+
const claim = { schemaVersion: 1, token: crypto.randomBytes(24).toString('hex'), host: hostname,
|
|
169
|
+
pid: process.pid, claimedAt: new Date().toISOString(), file };
|
|
170
|
+
let fd;
|
|
171
|
+
try { fd = keepDirectoryTimes(lockPath, () => fs.openSync(file, 'wx', 0o600)); } catch (error) {
|
|
172
|
+
if (error?.code === 'EEXIST') return null;
|
|
173
|
+
if (error?.code === 'ENOENT') return GONE;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
try { fs.writeFileSync(fd, `${JSON.stringify({ ...claim, file: undefined }, null, 2)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
177
|
+
return claim;
|
|
178
|
+
}
|
|
179
|
+
function dropClaim(claim) {
|
|
180
|
+
try { if (readRaw(claim.file)?.includes(claim.token)) keepDirectoryTimes(path.dirname(claim.file), () => fs.unlinkSync(claim.file)); } catch { /* gone with its directory */ }
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Remove the lock directory at `lockPath` only if it is still exactly the one observed. Returns 'removed',
|
|
184
|
+
* 'changed' (it is not the judged directory any more: re-judge it) or 'busy' (another reclaimer holds the claim).
|
|
185
|
+
*/
|
|
186
|
+
function removeObservedLock(lockPath, observed, { hostname, beforeRemove = null, afterClaim = null } = {}) {
|
|
187
|
+
const claim = claimTakeover(lockPath, hostname);
|
|
188
|
+
if (claim === GONE) return 'changed';
|
|
189
|
+
if (!claim) return 'busy';
|
|
190
|
+
afterClaim?.({ lockPath, claim });
|
|
191
|
+
let same = false;
|
|
192
|
+
try {
|
|
193
|
+
same = dirIdentity(lockPath) === observed.identity
|
|
194
|
+
&& readRaw(path.join(lockPath, 'owner.json')) === observed.raw
|
|
195
|
+
&& Boolean(readRaw(claim.file)?.includes(claim.token));
|
|
196
|
+
} catch { same = false; }
|
|
197
|
+
if (!same) { dropClaim(claim); return 'changed'; }
|
|
198
|
+
try { beforeRemove?.(claim); } catch (error) { dropClaim(claim); throw error; }
|
|
199
|
+
const tomb = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
200
|
+
try { fs.renameSync(lockPath, tomb); } catch (error) {
|
|
201
|
+
if (error?.code === 'ENOENT') return 'changed';
|
|
202
|
+
dropClaim(claim);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
// Only the claim holder can move the judged directory, so the moved directory must carry this claim.
|
|
206
|
+
if (!readRaw(path.join(tomb, path.basename(claim.file)))?.includes(claim.token)) {
|
|
207
|
+
try { if (!fs.existsSync(lockPath)) fs.renameSync(tomb, lockPath); } catch { /* keep it as evidence */ }
|
|
208
|
+
throw new Error(`refresh lock takeover moved a directory it did not claim (${lockPath}); refusing`);
|
|
209
|
+
}
|
|
210
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
211
|
+
return 'removed';
|
|
212
|
+
}
|
|
213
|
+
|
|
81
214
|
/**
|
|
82
215
|
* The ONE physical-path rule for brain directories. A user may reach the brain through a symlink
|
|
83
216
|
* (`~/.cache -> /Volumes/<disk>`) while the updater knows it by its real path (Node resolves
|
|
@@ -103,15 +236,12 @@ export function refreshReceiptPath(brainHome, runId) {
|
|
|
103
236
|
return file;
|
|
104
237
|
}
|
|
105
238
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const claim = { schemaVersion: 1, token: crypto.randomBytes(24).toString('hex'), host: hostname,
|
|
109
|
-
pid: process.pid, claimedAt: now() };
|
|
110
|
-
const fd = fs.openSync(claimFile, 'wx', 0o600);
|
|
111
|
-
try { fs.writeFileSync(fd, `${JSON.stringify(claim, null, 2)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
239
|
+
/** Records the dead owner's run ABANDONED. Runs only while this process holds the takeover claim on its lock. */
|
|
240
|
+
function abandonDeadOwner({ lockPath, owner, hostname, inspectProcess, isAlive, now, claim }) {
|
|
112
241
|
const observed = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
|
|
242
|
+
const verdict = ownerVerdict(observed, { hostname, inspectProcess, isAlive, nowMs: Date.parse(now()) });
|
|
113
243
|
if (observed.schemaVersion !== 3 || observed.runId !== owner.runId || observed.token !== owner.token
|
|
114
|
-
||
|
|
244
|
+
|| verdict.state !== 'dead') {
|
|
115
245
|
throw new Error('refresh owner changed or is no longer exactly dead during takeover');
|
|
116
246
|
}
|
|
117
247
|
const expectedReceipt = refreshReceiptPath(observed.brainHome, observed.runId);
|
|
@@ -136,7 +266,7 @@ function abandonDeadOwner({ lockPath, owner, hostname, inspectProcess, isAlive,
|
|
|
136
266
|
receipt.status = 'ABANDONED';
|
|
137
267
|
receipt.terminalVerdict = 'abandoned';
|
|
138
268
|
receipt.finishedAt = now();
|
|
139
|
-
receipt.detail = { reason:
|
|
269
|
+
receipt.detail = { reason: verdict.reason, abandonedOwner: { host: observed.host, pid: observed.pid,
|
|
140
270
|
processStart: observed.processStart, executable: observed.executable }, takeoverToken: claim.token };
|
|
141
271
|
delete receipt.intendedStatus;
|
|
142
272
|
delete receipt.intendedTerminalVerdict;
|
|
@@ -163,6 +293,7 @@ export function acquireRefreshLock({
|
|
|
163
293
|
processStart = null,
|
|
164
294
|
inspectProcess = null,
|
|
165
295
|
afterStage = null,
|
|
296
|
+
afterTakeoverClaim = null,
|
|
166
297
|
} = {}) {
|
|
167
298
|
const root = path.resolve(kbDir || '');
|
|
168
299
|
const lockPath = refreshLockPath(root);
|
|
@@ -204,23 +335,34 @@ export function acquireRefreshLock({
|
|
|
204
335
|
// Windows refuses rename-over-existing with EPERM/EACCES rather than EEXIST. Treat those
|
|
205
336
|
// codes as contention, then inspect the owner through the same liveness fence.
|
|
206
337
|
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error?.code)) throw error;
|
|
338
|
+
// Judge one exact directory: its identity and owner.json bytes are what the takeover re-checks later.
|
|
339
|
+
let observed;
|
|
340
|
+
try { observed = { identity: dirIdentity(lockPath), raw: readRaw(path.join(lockPath, 'owner.json')) }; }
|
|
341
|
+
catch (observeError) { if (['ENOENT', 'ENOTDIR'].includes(observeError?.code)) continue; throw observeError; }
|
|
207
342
|
let owner = null;
|
|
208
|
-
try { owner = JSON.parse(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
343
|
+
try { owner = JSON.parse(observed.raw); } catch { /* judged below */ }
|
|
344
|
+
const nowMs = Date.parse(now());
|
|
345
|
+
let beforeRemove = null;
|
|
346
|
+
if (!ownerComplete(owner)) {
|
|
347
|
+
const ageMs = nowMs - newestMtimeMs(lockPath, path.join(lockPath, 'owner.json'));
|
|
348
|
+
if (!(ageMs > INCOMPLETE_OWNER_RECLAIM_MS)) {
|
|
349
|
+
throw new Error(`refresh lock owner is incomplete or unreadable; refusing takeover until it is 1 h old `
|
|
350
|
+
+ `(lock ${lockPath}). If no update is running, remove it: ${removalCommand(lockPath)}`);
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
const verdict = ownerVerdict(owner, { hostname, inspectProcess, isAlive, nowMs });
|
|
354
|
+
if (verdict.state !== 'dead') {
|
|
355
|
+
throw new Error(`${verdict.state === 'live' ? 'another refresh run is active' : 'refresh lock owner is ambiguous'} `
|
|
356
|
+
+ `(pid ${owner.pid || '?'} on ${owner.host || '?'}, started ${owner.startedAt || '?'}, lock ${lockPath}). `
|
|
357
|
+
+ `If no update is running${owner.host && owner.host !== hostname ? ` on ${owner.host}` : ''}, remove it: ${removalCommand(lockPath)}`);
|
|
358
|
+
}
|
|
359
|
+
beforeRemove = (claim) => abandonDeadOwner({ lockPath, owner, hostname, inspectProcess, isAlive, now, claim });
|
|
215
360
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
fs.renameSync(lockPath, stale);
|
|
220
|
-
fs.rmSync(stale, { recursive: true, force: true });
|
|
221
|
-
} catch (renameError) {
|
|
222
|
-
if (renameError?.code !== 'ENOENT') throw renameError;
|
|
361
|
+
const outcome = removeObservedLock(lockPath, observed, { hostname, beforeRemove, afterClaim: afterTakeoverClaim });
|
|
362
|
+
if (outcome === 'busy') {
|
|
363
|
+
throw new Error(`another process is taking over the refresh lock ${lockPath}; refusing to race it (retry shortly)`);
|
|
223
364
|
}
|
|
365
|
+
// 'removed' or 'changed': acquire again; a changed lock is judged afresh.
|
|
224
366
|
}
|
|
225
367
|
}
|
|
226
368
|
throw new Error(`could not acquire refresh lock ${lockPath}`);
|
|
@@ -260,8 +402,14 @@ function atomicJson(file, value) {
|
|
|
260
402
|
const temporary = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
261
403
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
262
404
|
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
263
|
-
try {
|
|
264
|
-
|
|
405
|
+
try {
|
|
406
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
407
|
+
fs.renameSync(temporary, file);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
// #434: a failed write/rename must not leave a temp behind to block lifecycle retention.
|
|
410
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* the original error is the one to report */ }
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
265
413
|
}
|
|
266
414
|
|
|
267
415
|
export function openRefreshReceipt({ brainHome, lock, action, desiredVersion = null,
|
package/kb/verify-citation.mjs
CHANGED
|
@@ -127,7 +127,20 @@ function samePath(stored, docPath) {
|
|
|
127
127
|
* takes to reach the hit. A malformed JSON line is skipped, never fatal.
|
|
128
128
|
*/
|
|
129
129
|
export async function citationResolves(citation, kbDir) {
|
|
130
|
-
const
|
|
130
|
+
const own = await resolveIn(passagesFilesFor(citation.repo, kbDir), citation.docPath);
|
|
131
|
+
if (own.resolved) return own;
|
|
132
|
+
// A capability card is cited under its OWN repo (`repo=ruvector`, `path : ruvector/CARD/ruvector-card`,
|
|
133
|
+
// kb/card-lane.mjs renderCardHit) but is stored only in the derived `concepts` aggregate, under
|
|
134
|
+
// `<repo>/CARD/<repo>-card` (scripts/corpus-aggregates.mjs). Look exactly that path up there. Narrow
|
|
135
|
+
// on purpose: only a `CARD/` path, only in `concepts`, and only the exact stored path.
|
|
136
|
+
if (/^CARD\/[^/#]+-card$/.test(String(citation.docPath)) && citation.repo !== 'concepts') {
|
|
137
|
+
const card = await resolveIn(passagesFilesFor('concepts', kbDir), `${citation.repo}/${citation.docPath}`);
|
|
138
|
+
if (card.resolved) return card;
|
|
139
|
+
}
|
|
140
|
+
return own;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function resolveIn(files, docPath) {
|
|
131
144
|
if (!files.length) return { resolved: false, reason: 'no-store', file: null, storedPath: null };
|
|
132
145
|
for (const file of files) {
|
|
133
146
|
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
@@ -136,7 +149,7 @@ export async function citationResolves(citation, kbDir) {
|
|
|
136
149
|
if (!line) continue;
|
|
137
150
|
let rec;
|
|
138
151
|
try { rec = JSON.parse(line); } catch { continue; }
|
|
139
|
-
if (typeof rec?.path === 'string' && samePath(rec.path,
|
|
152
|
+
if (typeof rec?.path === 'string' && samePath(rec.path, docPath)) {
|
|
140
153
|
return { resolved: true, reason: 'ok', file: path.basename(file), storedPath: rec.path };
|
|
141
154
|
}
|
|
142
155
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.22",
|
|
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": {
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"learning:replay:check": "node scripts/learning-replay.mjs --check-portfolio",
|
|
67
67
|
"learning:replay:mutants:check": "node scripts/learning-replay.mjs --check-mutants",
|
|
68
68
|
"learning:replay:dry": "node scripts/learning-replay.mjs --dry-run",
|
|
69
|
+
"learning:replay:wire": "node scripts/learning-replay.mjs --check-wire",
|
|
69
70
|
"wired:check": "node scripts/wired-check.mjs --check",
|
|
70
71
|
"doc:currency": "node scripts/doc-currency.mjs --check",
|
|
71
72
|
"status:check": "node scripts/status-honesty.mjs",
|
|
@@ -107,6 +108,7 @@
|
|
|
107
108
|
"kb/retrieval-result.mjs",
|
|
108
109
|
"kb/forge-update.mjs",
|
|
109
110
|
"kb/download-retry.mjs",
|
|
111
|
+
"kb/forge-guard-injection.mjs",
|
|
110
112
|
"kb/corpus-release-identity.mjs",
|
|
111
113
|
"kb/capability-only.mjs",
|
|
112
114
|
"kb/capability-summaries/",
|
|
@@ -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.22",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|