Skip to content

Commit f30e76f

Browse files
authored
fix: truthful natives across the whole ruflo tree (#45) (#48)
ak's natives heal and status only looked at the agentdb copies of better-sqlite3, while `npx ruflo memory` resolves its binding from @claude-flow/memory and @claude-flow/cli — both declare it only as an optionalDependency, which npm >=11.17's allow-scripts gate silently drops. Result: `ak status` said "natives ok" while ruflo's own memory ran on the sql.js WASM fallback, and `ak dual run` died mid-pipeline at the first shared write (native WAL vs WASM whole-image refusal). - healNatives() now also heals the ruflo memory-runtime contexts. - ensureNativeBsq3's install rung derives its spec from the target tree's own overrides/optionalDependencies (hardcoded `@^12` is EOVERRIDE-rejected in trees pinning better-sqlite3 — verified live); reference forms ($agentdb) and protocols are skipped. - The natives status row load-tests the binding as resolved from each runtime context (require + open :memory: + SELECT 1 in a child process) — no more file-existence false positives. - `ak dual run` pre-flights the #45 defect-2 crash condition (WASM-only runtime + live -wal/-shm sidecars) and refuses with "run: ak sync" instead of corrupting the store mid-run. - New: `memory-pin` status warning when CLAUDE_FLOW_DB_PATH pins a missing or out-of-project path (warn-only; sync never touches it). - npm 9-12 supported: the approve-scripts rung tolerates its absence on npm <=11.16 (runner-matrixed tests for npm-9 and npm-12 behaviors). 53 heal/natives tests total; check chain green. Fixes #45
1 parent 82efef4 commit f30e76f

7 files changed

Lines changed: 529 additions & 9 deletions

File tree

‎src/commands/dual.mjs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import path from 'node:path';
99
import { pathToFileURL, fileURLToPath } from 'node:url';
1010
import { loadKitConfig } from '../lib/config.mjs';
1111
import { have } from '../lib/exec.mjs';
12+
import { rufloRuntimeNatives } from '../lib/natives.mjs';
1213
import { ok, warn, fail, info, dim, bold } from '../lib/output.mjs';
1314
import {
1415
DUAL_RUN_TEMPLATES, DUAL_RUN_TEMPLATE_NAMES, policyToDualRunConfig, escalatePolicy, parseRouteSpecs,
@@ -98,6 +99,23 @@ function runSwarm(configUrl, task, flags) {
9899
});
99100
}
100101

102+
/** #45 defect 2: the dual orchestrator opens a NATIVE better-sqlite3 WAL on the
103+
* shared DB, then shells `npx ruflo memory store`, which resolves the SAME global
104+
* tree — if that tree is WASM-only, the sql.js writer refuses to whole-image-write
105+
* over the live native WAL and the whole run dies at the first shared write. Refuse
106+
* PRE-SPAWN when BOTH hold: the ruflo memory runtime lacks a native binding AND
107+
* `-wal`/`-shm` sidecars sit beside the target DB. existsSync-based on purpose
108+
* (EC-6): a false refusal costs one `ak sync`, a false pass corrupts the store.
109+
* Pure + injectable so it's tested without a spawn or a global tree.
110+
* @param {string} dbPath
111+
* @param {{ runtime?: { installed: boolean, contexts: Array<{ ok: boolean }> } | null,
112+
* existsSync?: typeof fs.existsSync }} [opts] */
113+
export function nativeWalConflict(dbPath, { runtime, existsSync = fs.existsSync } = {}) {
114+
const wasmOnly = !!runtime?.installed && runtime.contexts.some((c) => !c.ok);
115+
const sidecar = existsSync(`${dbPath}-wal`) || existsSync(`${dbPath}-shm`);
116+
return { refuse: wasmOnly && sidecar, wasmOnly, sidecar };
117+
}
118+
101119
function printPlan(template, task, config) {
102120
console.log(bold(`dual run: ${template}`) + dim(` "${task}"`));
103121
for (const w of config.workers) {
@@ -138,6 +156,20 @@ async function doRun({ positionals, flags }) {
138156
return 1;
139157
}
140158

159+
// #45 defect 2 pre-flight: refuse BEFORE spawning a worker (the crash is otherwise
160+
// mid-run, at the first shared-memory write). Sidecar check is a cheap fs stat, so
161+
// only pay for the runtime probe (a child `node`) when a WAL is actually live.
162+
const dbPath = process.env.CLAUDE_FLOW_DB_PATH ?? path.join(process.cwd(), '.claude-flow', 'dual-run-memory.db');
163+
if (fs.existsSync(`${dbPath}-wal`) || fs.existsSync(`${dbPath}-shm`)) {
164+
const { refuse } = nativeWalConflict(dbPath, { runtime: await rufloRuntimeNatives() });
165+
if (refuse) {
166+
fail(`refusing to start: ruflo's memory runtime lacks a native better-sqlite3 binding AND ${dbPath} has an active native WAL (-wal/-shm sidecars). `
167+
+ 'The orchestrator\'s native WAL writer and the sql.js (WASM) `npx ruflo memory store` cannot share this DB — it would corrupt the store.');
168+
info('fix: run: ak sync (builds the native binding for ruflo\'s memory runtime), then retry');
169+
return 1;
170+
}
171+
}
172+
141173
// track the temp config-module dirs so we don't leak them (L3).
142174
const tmp = [];
143175
const mkConfig = (c) => { const url = writeConfigModule(c, task); tmp.push(path.dirname(fileURLToPath(url))); return url; };

‎src/commands/status.mjs‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import path from 'node:path';
66
import { glyph, dim, bold, warn } from '../lib/output.mjs';
77
import { loadRing, detectRegression } from '../lib/health-history.mjs';
88
import * as paths from '../lib/paths.mjs';
9-
import { nativesStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs';
9+
import { nativesStatus, rufloRuntimeNatives, dbPathPinStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs';
1010
import { scanNpxStale } from '../lib/npx.mjs';
1111
import { registrationStatus, codexMcpStatus, rufloCodexMcpStatus } from '../lib/mcp.mjs';
1212
import { listDaemons, staleDaemons } from '../lib/daemons.mjs';
@@ -127,10 +127,39 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) {
127127
if (n.aqe && !n.aqe.native) {
128128
rows.push(row('natives', 'fail', 'agentic-qe better-sqlite3 not native', 'sync repairs it'));
129129
}
130+
// #45: the agentdb copies above are NOT what `npx ruflo memory` loads — probe
131+
// the binding as resolved from ruflo's own memory runtime (@claude-flow/memory
132+
// + /cli), or the row reads ✓ while memory store runs on the WASM fallback.
133+
const rt = await rufloRuntimeNatives();
134+
if (rt.installed && rt.contexts.length) {
135+
const wasm = rt.contexts.filter((c) => !c.ok);
136+
if (wasm.length) {
137+
rows.push(row('natives', 'fail',
138+
`ruflo memory runtime on WASM fallback (${wasm.map((c) => `@claude-flow/${c.context}`).join(', ')}) — memory store/dual run degrade`,
139+
'sync builds the native binding'));
140+
} else {
141+
rows.push(row('natives', 'ok', `ruflo memory runtime native (${rt.contexts.map((c) => c.context).join(', ')})`));
142+
}
143+
}
130144
} catch (e) {
131145
rows.push(row('natives', 'warn', `native check unavailable: ${e.message}`));
132146
}
133147

148+
// #45 aftermath: a CLAUDE_FLOW_DB_PATH pin aimed at a dead or foreign path makes
149+
// every memory op target the wrong DB ("Database not initialized" with a healthy
150+
// DB in-repo). Warn-only — the pin may be deliberate; sync never touches it.
151+
try {
152+
const pin = dbPathPinStatus({
153+
settingsLocalFile: path.join(process.cwd(), '.claude', 'settings.local.json'),
154+
projectRoot: process.cwd(),
155+
});
156+
if (pin?.warn) {
157+
rows.push(row('memory-pin', 'warn',
158+
`CLAUDE_FLOW_DB_PATH pins ${pin.pinned} (${pin.reason})`,
159+
'repoint it in .claude/settings.local.json env, or remove the pin'));
160+
}
161+
} catch { /* pin check is best-effort — never blocks status */ }
162+
134163
// npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the
135164
// statusline/hooks execute these verbatim, keeping retired defects alive)
136165
try {

‎src/lib/heal.mjs‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import fs from 'node:fs';
77
import path from 'node:path';
88
import { run } from './exec.mjs';
99
import { rufloRoot, aqeRoot } from './paths.mjs';
10-
import { agentdbLocations, bsq3IsNative, bsq3Root, aidefencePresent } from './natives.mjs';
10+
import { agentdbLocations, bsq3IsNative, bsq3Root, deriveBsq3Spec, rufloMemoryContexts, aidefencePresent } from './natives.mjs';
1111
import { KIT_PKG } from './versions.mjs';
1212
import { scanRvf, quarantine } from './rvf.mjs';
1313
import { INSTALL_SPEC, INSTALL_ARGS, NIGHTLY_LABEL as RB_NIGHTLY_LABEL, nightlyAgentPlist as rbNightlyPlist, present as rbPresent, latestVersion as rbLatest, recordInstalledRelease as rbRecord } from './ruvnet-brain.mjs';
@@ -58,7 +58,11 @@ const failTail = (r) =>
5858
export async function ensureNativeBsq3(dir, { runner = run } = {}) {
5959
let pkgRoot = bsq3Root(dir);
6060
if (!pkgRoot) {
61-
await npmInstallInto(dir, 'better-sqlite3@^12', runner);
61+
// Derive the spec from THIS tree's own overrides/deps: a hardcoded `@^12` is
62+
// EOVERRIDE-rejected in a tree that pins better-sqlite3 (ruflo root pins
63+
// 12.9.0, @claude-flow/cli pins ^12.9.0) — verified live. The declared spec
64+
// installs clean and resolves a prebuilt.
65+
await npmInstallInto(dir, `better-sqlite3@${deriveBsq3Spec(dir)}`, runner);
6266
if (bsq3IsNative(dir)) return { ok: true, how: 'native installed' };
6367
pkgRoot = bsq3Root(dir);
6468
if (!pkgRoot) return { ok: false, how: 'FAILED (better-sqlite3 not resolvable)' };
@@ -72,19 +76,29 @@ export async function ensureNativeBsq3(dir, { runner = run } = {}) {
7276
return { ok: false, how: failTail(r) };
7377
}
7478

75-
/** Native better-sqlite3 into every agentdb location that lacks it. */
76-
export async function healNatives() {
79+
/** Native better-sqlite3 into every location the runtime resolves: the agentdb
80+
* copies, agentic-qe, AND the ruflo memory-runtime contexts (@claude-flow/memory
81+
* + /cli) — the copies `npx ruflo memory` actually loads. #45: healing only
82+
* agentdb left ruflo's own memory on the WASM fallback (memory store failing)
83+
* while status still read agentdb-native. `runner` injectable for hermetic tests. */
84+
export async function healNatives({ runner = run } = {}) {
7785
const details = [];
7886
for (const dir of agentdbLocations()) {
7987
// Re-check right before installing: an upgrade earlier in the same sync
8088
// can remove a location (e.g. agentic-flow/node_modules/agentdb, gone in
8189
// the 3.29.0 tree) between enumeration and heal.
8290
if (!fs.existsSync(dir)) continue;
8391
if (bsq3IsNative(dir)) continue;
84-
details.push(`${dir}: ${(await ensureNativeBsq3(dir)).how}`);
92+
details.push(`${dir}: ${(await ensureNativeBsq3(dir, { runner })).how}`);
8593
}
8694
if (fs.existsSync(aqeRoot()) && !bsq3IsNative(aqeRoot())) {
87-
details.push(`agentic-qe: ${(await ensureNativeBsq3(aqeRoot())).how}`);
95+
details.push(`agentic-qe: ${(await ensureNativeBsq3(aqeRoot(), { runner })).how}`);
96+
}
97+
// ruflo memory runtime — missing contexts are already filtered out (older trees
98+
// may lack either package, EC-2), so this is a silent no-op on them.
99+
for (const { context, dir } of rufloMemoryContexts()) {
100+
if (bsq3IsNative(dir)) continue;
101+
details.push(`@claude-flow/${context}: ${(await ensureNativeBsq3(dir, { runner })).how}`);
88102
}
89103
return { ok: !details.some((d) => d.includes('FAILED')), detail: details.join('; ') || 'already native everywhere' };
90104
}

‎src/lib/natives.mjs‎

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
// `security defend` needs (dropped from the 3.28 tree — ruvnet/ruflo#2670).
66
import fs from 'node:fs';
77
import path from 'node:path';
8-
import { rufloNodeModules, aqeRoot } from './paths.mjs';
8+
import { rufloRoot, rufloNodeModules, aqeRoot } from './paths.mjs';
9+
import { run } from './exec.mjs';
10+
import { readJson } from './settings.mjs';
911

1012
/** agentdb locations under the global ruflo tree (mirrors ruflo-patch-native). */
1113
export function agentdbLocations() {
@@ -48,6 +50,90 @@ export function nativesStatus() {
4850
return { locations, aqe };
4951
}
5052

53+
// The packages ruflo's memory RUNTIME resolves better-sqlite3 from — not the
54+
// agentdb copies above. @claude-flow/memory is the store; @claude-flow/cli is what
55+
// `npx ruflo memory` runs. #45: these can be WASM-only while the agentdb copy is
56+
// native, so the agentdb-only status was a false positive. Older ruflo trees may
57+
// lack either package — filter to what exists so heal/status skip silently.
58+
export function rufloMemoryContexts() {
59+
const nm = rufloNodeModules();
60+
return [
61+
{ context: 'memory', dir: path.join(nm, '@claude-flow', 'memory') },
62+
{ context: 'cli', dir: path.join(nm, '@claude-flow', 'cli') },
63+
].filter((c) => fs.existsSync(c.dir));
64+
}
65+
66+
// A PLAIN semver range/version — the only override/dependency form npm install can
67+
// take by value. Reference forms (`$agentdb`) and protocols (workspace:/file:/link:/
68+
// npm:/git+ssh:) are NOT installable specs, so they must be skipped during
69+
// derivation rather than emitted (they'd make npm error). Requires a version digit
70+
// so bare `*`/`latest`/`x` fall through to the caller's fallback.
71+
const isPlainSemver = (v) =>
72+
typeof v === 'string' && /\d/.test(v) && !v.includes(':') && !v.trimStart().startsWith('$');
73+
74+
/** The install spec for better-sqlite3 in `dir`, derived from that tree's OWN
75+
* declarations so an npm `overrides` pin isn't fought with EOVERRIDE (verified
76+
* live: `install better-sqlite3@^12` under @claude-flow/cli, which pins ^12.9.0,
77+
* is rejected; the declared spec succeeds). Precedence: overrides →
78+
* optionalDependencies → dependencies → fallback; the first PLAIN-semver value
79+
* wins, non-semver forms are skipped. */
80+
export function deriveBsq3Spec(dir, fallback = '^12') {
81+
const pkg = readJson(path.join(dir, 'package.json'), {}) ?? {};
82+
for (const field of ['overrides', 'optionalDependencies', 'dependencies']) {
83+
const v = pkg[field]?.['better-sqlite3'];
84+
if (isPlainSemver(v)) return v;
85+
}
86+
return fallback;
87+
}
88+
89+
// A truthful load-test of the binding as node resolution finds it FROM `dir`: an
90+
// ABI-mismatched or absent binding throws on `require` (exactly `ruflo doctor`'s
91+
// "Could not locate the bindings file"), so requiring it, opening :memory:, and
92+
// running SELECT 1 in a child process is the real WASM-vs-native answer — not a
93+
// file-existence guess. Kept in a child process so a broken addon can't crash ak.
94+
const RUNTIME_PROBE =
95+
"const D=require(require.resolve('better-sqlite3',{paths:[process.argv[1]]}));"
96+
+ "const db=new D(':memory:');const r=db.prepare('SELECT 1 AS ok').get();db.close();"
97+
+ 'process.exit(r&&r.ok===1?0:3);';
98+
99+
/** Load-test better-sqlite3 as resolved from `dir`. Injectable runner keeps the
100+
* test spawn-free. Returns {ok} or {ok:false, reason}. */
101+
export async function probeBsq3Runtime(dir, { runner = run } = {}) {
102+
// Generous timeout for a cold `node` spawn on CI; a real native require returns
103+
// well under the status budget (probes run in parallel, see rufloRuntimeNatives).
104+
const r = await runner('node', ['-e', RUNTIME_PROBE, dir], { cwd: dir, timeout: 8000 });
105+
if (r.code === 0) return { ok: true };
106+
return { ok: false, reason: (r.stderr || `exit ${r.code}`).trim().split('\n').pop().slice(0, 160) };
107+
}
108+
109+
/** Per-context native-binding truth for ruflo's memory runtime. {installed:false}
110+
* when ruflo is absent (EC-1: status/pre-flight skip, never crash). Probes run in
111+
* parallel to stay inside the status time budget. */
112+
export async function rufloRuntimeNatives({ runner = run } = {}) {
113+
let installed;
114+
try { installed = fs.existsSync(rufloRoot()); } catch { installed = false; }
115+
if (!installed) return { installed: false, contexts: [] };
116+
const contexts = await Promise.all(rufloMemoryContexts().map(async ({ context, dir }) => {
117+
const res = await probeBsq3Runtime(dir, { runner });
118+
return { context, dir, ok: res.ok, reason: res.reason };
119+
}));
120+
return { installed: true, contexts };
121+
}
122+
123+
/** Drift for a CLAUDE_FLOW_DB_PATH pin in .claude/settings.local.json `env`: warn
124+
* when the pinned DB's directory is missing OR the path lies outside the project.
125+
* Warn-only (the pin may be deliberate); `sync` never touches it. path.relative
126+
* for containment so drive-letter/Windows paths compare correctly, not by prefix.
127+
* Returns null when there is no pin (EC-4: absent/unparseable settings). */
128+
export function dbPathPinStatus({ settingsLocalFile, projectRoot }) {
129+
const pinned = readJson(settingsLocalFile)?.env?.CLAUDE_FLOW_DB_PATH;
130+
if (!pinned) return null;
131+
if (!fs.existsSync(path.dirname(pinned))) return { warn: true, pinned, reason: 'directory does not exist' };
132+
const rel = path.relative(projectRoot, pinned);
133+
if (rel.startsWith('..') || path.isAbsolute(rel)) return { warn: true, pinned, reason: 'outside the project root' };
134+
return { warn: false, pinned };
135+
}
136+
51137
export const aidefencePresent = () =>
52138
fs.existsSync(path.join(rufloNodeModules(), '@claude-flow', 'aidefence', 'package.json'));
53139

‎tests/kit/dual-preflight.test.mjs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// #45 defect 2 pre-flight: ak dual run must refuse BEFORE spawning a worker when
2+
// the ruflo memory runtime is WASM-only AND the target DB has an active native WAL
3+
// (`-wal`/`-shm` sidecars). Unit-tests the pure predicate with an injected
4+
// existsSync + a supplied runtime probe — no spawn, no global tree.
5+
import { test } from 'node:test';
6+
import assert from 'node:assert/strict';
7+
import { nativeWalConflict } from '../../src/commands/dual.mjs';
8+
9+
const WASM_ONLY = { installed: true, contexts: [{ context: 'cli', ok: false }, { context: 'memory', ok: true }] };
10+
const ALL_NATIVE = { installed: true, contexts: [{ context: 'cli', ok: true }, { context: 'memory', ok: true }] };
11+
const NOT_INSTALLED = { installed: false, contexts: [] };
12+
13+
const sidecarPresent = (f) => f.endsWith('-wal') || f.endsWith('-shm');
14+
const noSidecar = () => false;
15+
16+
test('refuses when the runtime is WASM-only AND a WAL sidecar is present', () => {
17+
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: sidecarPresent });
18+
assert.equal(c.refuse, true);
19+
assert.equal(c.wasmOnly, true);
20+
assert.equal(c.sidecar, true);
21+
});
22+
23+
test('proceeds when the runtime is native even with a live WAL', () => {
24+
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: ALL_NATIVE, existsSync: sidecarPresent });
25+
assert.equal(c.refuse, false, 'native writer + native store is safe');
26+
});
27+
28+
test('proceeds when there are no sidecars even on a WASM-only runtime', () => {
29+
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: noSidecar });
30+
assert.equal(c.refuse, false, 'no active WAL → nothing to conflict with');
31+
});
32+
33+
test('proceeds when ruflo is not installed at all', () => {
34+
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: NOT_INSTALLED, existsSync: sidecarPresent });
35+
assert.equal(c.refuse, false);
36+
});
37+
38+
test('a stale zero-length sidecar still counts as active (EC-6, presence not size)', () => {
39+
// existsSync-based on purpose: false-refusal costs one `ak sync`, false-pass
40+
// corrupts the DB. A -shm alone is enough.
41+
const onlyShm = (f) => f.endsWith('-shm');
42+
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: onlyShm });
43+
assert.equal(c.refuse, true);
44+
});

0 commit comments

Comments
 (0)