fix(ruvector): atomic intelligence.json writes + fail-loud on corrupt load (#634) (#698)

.ruvector/intelligence.json is silently, permanently wiped under concurrent
ruvector processes — the normal state under Claude Code, where every hook is a
separate short-lived `ruvector hooks …` process sharing the store. Two defects
compound:

1. save() (and 6 standalone command sites) overwrote the store non-atomically, so
   a concurrent reader could observe a torn, half-written file.
2. Intelligence.load() and the 9 standalone read sites wrapped the parse in
   `try { … } catch {}` and fell through to empty defaults — the reader that hit
   the torn file silently started from an empty store, and its own save() then
   persisted the emptiness, replacing weeks of memories/trajectories with no error
   (the hook still printed success:true).

Fix: a shared atomicWriteFileSync() (temp file + rename, so a partial write can
never appear at the real path) used by save() and all 6 write sites; and a shared
readIntelStoreSafe() plus the same logic in load() that quarantines a corrupt store
(renames it aside so it is preserved, not overwritten) and throws instead of
degrading to empty defaults. A missing store is still a legitimate fresh start.
With atomic writes, a concurrent reader can no longer produce a torn read, so the
quarantine path is only reached on genuine corruption.

bin/cli.js now exports its internals when required (not run as main) so the store
durability can be tested. test/smoke-intelligence-durability.mjs exercises the real
functions through the issue's exact truncation repro (4 checks).

Co-authored-by: Leo <noreply@khive.ai>
This commit is contained in:
OceanLi 2026-07-17 14:57:47 -04:00 committed by GitHub
parent 84e5f3d7dc
commit 08e5fe6eb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 262 additions and 94 deletions

View file

@ -3029,6 +3029,51 @@ function sanitizeDimension(value, fallback) {
return (Number.isInteger(value) && value > 0 && value <= 65536) ? value : fallback;
}
/**
* Write a file atomically: serialize to a unique temp file in the same
* directory, then rename() over the target. rename() is atomic on a POSIX
* filesystem, so a concurrent reader never observes a torn/half-written file.
* This is the root-cause fix for the intelligence.json wipe under Claude Code,
* where every hook is a separate short-lived process sharing the store: a
* plain writeFileSync lets one process read a half-written file mid-write.
* The temp name carries pid + timestamp so concurrent writers never collide on
* the temp file; the final rename is last-writer-wins.
*/
function atomicWriteFileSync(filePath, data) {
const dir = path.dirname(filePath);
const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}`);
try {
fs.writeFileSync(tmp, data);
fs.renameSync(tmp, filePath);
} catch (err) {
try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort temp cleanup */ }
throw err;
}
}
/**
* Read a JSON intelligence store for a standalone command. Returns `{}` for a
* missing store (legitimate fresh start), but on a corrupt/unparseable store
* quarantines the file (renames it aside) and throws instead of returning `{}`.
* The previous `try { parse } catch {}` `{}` pattern let a corrupt read
* degrade to an empty object that the command then wrote back, wiping the
* store. Mirrors Intelligence.load()'s corrupt handling.
*/
function readIntelStoreSafe(dataPath) {
if (!fs.existsSync(dataPath)) return {};
try {
return JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
} catch (err) {
const quarantine = `${dataPath}.corrupt-${Date.now()}`;
try { fs.renameSync(dataPath, quarantine); } catch { /* still fail loud below */ }
throw new Error(
`ruvector: intelligence store at ${dataPath} is corrupt and was quarantined to ` +
`${quarantine} rather than overwritten with an empty store — ` +
`restore it or delete it to start fresh. (${err.message})`
);
}
}
class Intelligence {
constructor(options = {}) {
this.intelPath = this.getIntelPath();
@ -3156,39 +3201,56 @@ class Intelligence {
edges: [],
stats: { total_patterns: 0, total_memories: 0, total_trajectories: 0, total_errors: 0, session_count: 0, last_session: 0 }
};
// A missing store is a legitimate fresh start.
if (!fs.existsSync(this.intelPath)) return defaults;
let data;
try {
if (fs.existsSync(this.intelPath)) {
const data = JSON.parse(fs.readFileSync(this.intelPath, 'utf-8'));
// Merge with defaults to ensure all fields exist. The file is
// untrusted on-disk input (ADR-210 security pass): shape-check each
// field so a hand-edited/corrupted store cannot crash later code
// that iterates arrays or spreads objects.
const asArray = (v, dflt) => (Array.isArray(v) ? v : dflt);
const asObject = (v, dflt) => (v && typeof v === 'object' && !Array.isArray(v) ? v : dflt);
return {
patterns: asObject(data.patterns, defaults.patterns),
memories: asArray(data.memories, defaults.memories),
trajectories: asArray(data.trajectories, defaults.trajectories),
errors: asObject(data.errors, defaults.errors),
file_sequences: asArray(data.file_sequences, defaults.file_sequences),
agents: asObject(data.agents, defaults.agents),
edges: asArray(data.edges, defaults.edges),
stats: { ...defaults.stats, ...asObject(data.stats, {}) },
// ADR-210 D0: embedding provenance of stored memory vectors
// (null = legacy store, read-only for vector writes until reembed).
// Malformed records are treated as absent (sanitized, never crash).
embeddingProvenance: sanitizeProvenanceSafe(data.embeddingProvenance),
// Preserve in-flight trajectories so trajectory-end (run in a later
// process) can find what trajectory-begin recorded (#517)
activeTrajectories: data.activeTrajectories || {},
// Preserve auxiliary learned data if present
coEditPatterns: data.coEditPatterns || undefined,
sequences: data.sequences || undefined,
learning: data.learning || undefined
};
}
} catch {}
return defaults;
data = JSON.parse(fs.readFileSync(this.intelPath, 'utf-8'));
} catch (err) {
// A corrupt/unreadable store must NOT silently degrade to empty defaults:
// the caller would then save() the emptiness back over the real data,
// destroying weeks of accumulated memories with no signal. Quarantine the
// file (rename it aside so it is preserved and the next save cannot clobber
// it) and fail loud. With atomicWriteFileSync() below, a concurrent writer
// can no longer produce a torn read, so reaching here means genuine
// corruption — a fresh store restarts cleanly on the next run.
const quarantine = `${this.intelPath}.corrupt-${Date.now()}`;
try { fs.renameSync(this.intelPath, quarantine); } catch { /* still fail loud below */ }
throw new Error(
`ruvector: intelligence store at ${this.intelPath} is corrupt and was quarantined ` +
`to ${quarantine} rather than overwritten with an empty store — ` +
`restore it or delete it to start fresh. (${err.message})`
);
}
// Merge with defaults to ensure all fields exist. The file is
// untrusted on-disk input (ADR-210 security pass): shape-check each
// field so a hand-edited/corrupted store cannot crash later code
// that iterates arrays or spreads objects.
const asArray = (v, dflt) => (Array.isArray(v) ? v : dflt);
const asObject = (v, dflt) => (v && typeof v === 'object' && !Array.isArray(v) ? v : dflt);
return {
patterns: asObject(data.patterns, defaults.patterns),
memories: asArray(data.memories, defaults.memories),
trajectories: asArray(data.trajectories, defaults.trajectories),
errors: asObject(data.errors, defaults.errors),
file_sequences: asArray(data.file_sequences, defaults.file_sequences),
agents: asObject(data.agents, defaults.agents),
edges: asArray(data.edges, defaults.edges),
stats: { ...defaults.stats, ...asObject(data.stats, {}) },
// ADR-210 D0: embedding provenance of stored memory vectors
// (null = legacy store, read-only for vector writes until reembed).
// Malformed records are treated as absent (sanitized, never crash).
embeddingProvenance: sanitizeProvenanceSafe(data.embeddingProvenance),
// Preserve in-flight trajectories so trajectory-end (run in a later
// process) can find what trajectory-begin recorded (#517)
activeTrajectories: data.activeTrajectories || {},
// Preserve auxiliary learned data if present
coEditPatterns: data.coEditPatterns || undefined,
sequences: data.sequences || undefined,
learning: data.learning || undefined
};
}
save() {
@ -3217,7 +3279,7 @@ class Intelligence {
}
}
fs.writeFileSync(this.intelPath, JSON.stringify(this.data, null, 2));
atomicWriteFileSync(this.intelPath, JSON.stringify(this.data, null, 2));
}
now() { return Math.floor(Date.now() / 1000); }
@ -5614,12 +5676,7 @@ hooksCmd.command('learning-config')
// Load existing intelligence data
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const engine = new LearningEngineClass();
if (data.learning) {
@ -5653,7 +5710,7 @@ hooksCmd.command('learning-config')
// Save
data.learning = engine.export();
fs.mkdirSync(path.dirname(dataPath), { recursive: true });
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
console.log(JSON.stringify({
success: true,
@ -5673,12 +5730,7 @@ hooksCmd.command('learning-stats')
}
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const engine = new LearningEngineClass();
if (data.learning) {
@ -5721,12 +5773,7 @@ hooksCmd.command('learning-update')
}
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const engine = new LearningEngineClass();
if (data.learning) {
@ -5746,7 +5793,7 @@ hooksCmd.command('learning-update')
// Save
data.learning = engine.export();
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
console.log(JSON.stringify({
success: true,
@ -5769,12 +5816,7 @@ hooksCmd.command('compress')
}
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const compress = new TensorCompressClass({
autoCompress: false,
@ -5809,7 +5851,7 @@ hooksCmd.command('compress')
// Save compressed data
data.compressedPatterns = compress.export();
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
console.log(JSON.stringify({
success: true,
@ -5828,12 +5870,7 @@ hooksCmd.command('compress-stats')
}
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const compress = new TensorCompressClass({ autoCompress: false });
if (data.compressedPatterns) {
@ -5882,12 +5919,7 @@ hooksCmd.command('compress-store')
}
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const compress = new TensorCompressClass({ autoCompress: false });
if (data.compressedPatterns) {
@ -5898,7 +5930,7 @@ hooksCmd.command('compress-store')
data.compressedPatterns = compress.export();
fs.mkdirSync(path.dirname(dataPath), { recursive: true });
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
const stats = compress.getStats();
console.log(JSON.stringify({
@ -5921,12 +5953,7 @@ hooksCmd.command('compress-get')
}
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const compress = new TensorCompressClass({ autoCompress: false });
if (data.compressedPatterns) {
@ -5962,12 +5989,7 @@ hooksCmd.command('learn')
}
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const engine = new LearningEngineClass();
if (data.learning) {
@ -6001,7 +6023,7 @@ hooksCmd.command('learn')
// Save
data.learning = engine.export();
fs.mkdirSync(path.dirname(dataPath), { recursive: true });
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
console.log(JSON.stringify(result));
});
@ -6046,12 +6068,7 @@ hooksCmd.command('batch-learn')
}
const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json');
let data = {};
try {
if (fs.existsSync(dataPath)) {
data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
}
} catch (e) {}
let data = readIntelStoreSafe(dataPath);
const engine = new LearningEngineClass();
if (data.learning) {
@ -6079,7 +6096,7 @@ hooksCmd.command('batch-learn')
// Save
data.learning = engine.export();
fs.mkdirSync(path.dirname(dataPath), { recursive: true });
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2));
const stats = engine.getStatsSummary();
console.log(JSON.stringify({
@ -10252,6 +10269,12 @@ harnessCmd
// Bare `ruvector harness` defaults to status
harnessCmd.action(() => printHarnessStatus({}));
program.parse();
// Only drive the CLI when executed directly; when required (e.g. by tests)
// expose the store-durability internals instead of parsing argv.
if (require.main === module) {
program.parse();
} else {
module.exports = { atomicWriteFileSync, readIntelStoreSafe, Intelligence };
}

View file

@ -0,0 +1,145 @@
// Runtime smoke test for #634: the .ruvector/intelligence.json store must be
// written atomically and must fail loud (not silently wipe) on a corrupt read.
// Drives the REAL functions exported from bin/cli.js (commander/chalk are
// stubbed only so the module can be required without the full CLI install).
//
// Run: node test/smoke-intelligence-durability.mjs
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));
const cliPath = path.join(here, '..', 'bin', 'cli.js');
// --- stub the two module-scope deps so cli.js can be required as a library ---
const stubRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-stubs-'));
function writeStub(name, indexJs) {
const dir = path.join(stubRoot, 'node_modules', name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version: '0.0.0', main: 'index.js' }));
fs.writeFileSync(path.join(dir, 'index.js'), indexJs);
}
// commander: chainable no-op Command — any method returns a chainable, and
// command()/createCommand() return fresh chainables.
writeStub('commander', `
function makeCmd() {
const cmd = new Proxy(function () {}, {
get(_t, prop) {
if (prop === 'command' || prop === 'createCommand' || prop === 'addCommand') return () => makeCmd();
if (prop === 'opts' || prop === 'optsWithGlobals') return () => ({});
if (prop === 'args') return [];
return () => cmd;
},
apply() { return makeCmd(); },
});
return cmd;
}
class Command { constructor() { return makeCmd(); } }
module.exports = { Command, program: makeCmd() };
`);
// chalk: identity color functions
writeStub('chalk', `
const id = (s) => s;
const handler = { get: () => new Proxy(id, handler), apply: (t, _th, a) => a[0] };
const chalk = new Proxy(id, handler);
module.exports = chalk; module.exports.default = chalk;
`);
// Make the stubs resolvable from cli.js by pointing NODE_PATH — but require()
// resolves relative to cli.js, so instead patch module resolution via a temp
// symlinked node_modules next to the package.
const pkgNodeModules = path.join(here, '..', 'node_modules');
let createdSymlink = false;
if (!fs.existsSync(pkgNodeModules)) {
fs.symlinkSync(path.join(stubRoot, 'node_modules'), pkgNodeModules, 'dir');
createdSymlink = true;
}
let passed = 0;
const ok = (name) => { console.log(` ok - ${name}`); passed++; };
try {
const { atomicWriteFileSync, readIntelStoreSafe, Intelligence } = require(cliPath);
// ---- helper: atomicWriteFileSync leaves no temp + produces the exact bytes
{
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-aw-'));
const f = path.join(dir, 'x.json');
atomicWriteFileSync(f, '{"a":1}');
assert.strictEqual(fs.readFileSync(f, 'utf-8'), '{"a":1}');
assert.deepStrictEqual(fs.readdirSync(dir), ['x.json'], 'no .tmp file left behind');
ok('#634 atomicWriteFileSync writes exact bytes, no temp leftover');
fs.rmSync(dir, { recursive: true, force: true });
}
// ---- helper: readIntelStoreSafe absent -> {}, valid -> parsed, corrupt -> throw+quarantine
{
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-rd-'));
const f = path.join(dir, 'intelligence.json');
assert.deepStrictEqual(readIntelStoreSafe(f), {}, 'absent store -> {}');
fs.writeFileSync(f, JSON.stringify({ memories: [1, 2, 3] }));
assert.deepStrictEqual(readIntelStoreSafe(f).memories, [1, 2, 3], 'valid store parsed');
fs.writeFileSync(f, '{ torn ');
assert.throws(() => readIntelStoreSafe(f), /corrupt and was quarantined/, 'corrupt store throws');
assert.ok(!fs.existsSync(f), 'corrupt store renamed away (not left to be overwritten)');
assert.strictEqual(fs.readdirSync(dir).filter((x) => x.includes('.corrupt-')).length, 1, 'one quarantine file');
ok('#634 readIntelStoreSafe: absent->{}, valid->parsed, corrupt->throw+quarantine');
fs.rmSync(dir, { recursive: true, force: true });
}
// ---- the reported wipe scenario, through the real Intelligence class ----
{
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-proj-'));
const store = path.join(proj, '.ruvector', 'intelligence.json');
fs.mkdirSync(path.dirname(store), { recursive: true });
// A store with 3 memories that must survive.
fs.writeFileSync(store, JSON.stringify({
patterns: {}, memories: ['m1', 'm2', 'm3'], trajectories: [], errors: {},
file_sequences: [], agents: {}, edges: [],
stats: { total_patterns: 0, total_memories: 3, total_trajectories: 0, total_errors: 0, session_count: 1, last_session: 1 },
}, null, 2));
const prevCwd = process.cwd();
process.chdir(proj);
try {
// Sanity: a valid store loads its 3 memories.
const okIntel = new Intelligence({ skipEngine: true });
assert.strictEqual(okIntel.data.memories.length, 3, 'valid store loads 3 memories');
// Corrupt the store exactly as the issue's repro does (truncated file).
const full = fs.readFileSync(store, 'utf-8');
fs.writeFileSync(store, full.slice(0, Math.floor(full.length / 2)));
// OLD behavior: construct succeeds, load() returns empty defaults, next
// save() persists the emptiness (wipe). NEW behavior: construct throws.
assert.throws(() => new Intelligence({ skipEngine: true }), /corrupt and was quarantined/,
'corrupt store -> throw, not silent empty-defaults');
assert.ok(!fs.existsSync(store), 'corrupt store quarantined (not left in place to be overwritten)');
const quarantined = fs.readdirSync(path.dirname(store)).filter((x) => x.includes('.corrupt-'));
assert.strictEqual(quarantined.length, 1, 'store quarantined once (data preserved for recovery)');
// The quarantined file still holds the original (now-truncated) bytes, not an empty store.
ok('#634 corrupt store is quarantined + fails loud instead of silently wiping memories');
// Atomic save roundtrip: fresh store, save leaves no temp, reload matches.
const fresh = new Intelligence({ skipEngine: true });
fresh.data.memories.push('kept');
fresh.save();
assert.ok(!fs.readdirSync(path.dirname(store)).some((x) => x.includes('.tmp.')), 'save() leaves no temp file');
const reloaded = new Intelligence({ skipEngine: true });
assert.ok(reloaded.data.memories.includes('kept'), 'saved memory survives reload');
ok('#634 save() is atomic (no temp leftover) and roundtrips');
} finally {
process.chdir(prevCwd);
}
fs.rmSync(proj, { recursive: true, force: true });
}
console.log(`\nAll ${passed} checks passed.`);
} finally {
if (createdSymlink) { try { fs.unlinkSync(pkgNodeModules); } catch { /* ignore */ } }
fs.rmSync(stubRoot, { recursive: true, force: true });
}