Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/commands/dual.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { loadKitConfig } from '../lib/config.mjs';
import { have } from '../lib/exec.mjs';
import { rufloRuntimeNatives } from '../lib/natives.mjs';
import { ok, warn, fail, info, dim, bold } from '../lib/output.mjs';
import {
DUAL_RUN_TEMPLATES, DUAL_RUN_TEMPLATE_NAMES, policyToDualRunConfig, escalatePolicy, parseRouteSpecs,
Expand Down Expand Up @@ -98,6 +99,23 @@ function runSwarm(configUrl, task, flags) {
});
}

/** #45 defect 2: the dual orchestrator opens a NATIVE better-sqlite3 WAL on the
* shared DB, then shells `npx ruflo memory store`, which resolves the SAME global
* tree — if that tree is WASM-only, the sql.js writer refuses to whole-image-write
* over the live native WAL and the whole run dies at the first shared write. Refuse
* PRE-SPAWN when BOTH hold: the ruflo memory runtime lacks a native binding AND
* `-wal`/`-shm` sidecars sit beside the target DB. existsSync-based on purpose
* (EC-6): a false refusal costs one `ak sync`, a false pass corrupts the store.
* Pure + injectable so it's tested without a spawn or a global tree.
* @param {string} dbPath
* @param {{ runtime?: { installed: boolean, contexts: Array<{ ok: boolean }> } | null,
* existsSync?: typeof fs.existsSync }} [opts] */
export function nativeWalConflict(dbPath, { runtime, existsSync = fs.existsSync } = {}) {
const wasmOnly = !!runtime?.installed && runtime.contexts.some((c) => !c.ok);
const sidecar = existsSync(`${dbPath}-wal`) || existsSync(`${dbPath}-shm`);
return { refuse: wasmOnly && sidecar, wasmOnly, sidecar };
}

function printPlan(template, task, config) {
console.log(bold(`dual run: ${template}`) + dim(` "${task}"`));
for (const w of config.workers) {
Expand Down Expand Up @@ -138,6 +156,20 @@ async function doRun({ positionals, flags }) {
return 1;
}

// #45 defect 2 pre-flight: refuse BEFORE spawning a worker (the crash is otherwise
// mid-run, at the first shared-memory write). Sidecar check is a cheap fs stat, so
// only pay for the runtime probe (a child `node`) when a WAL is actually live.
const dbPath = process.env.CLAUDE_FLOW_DB_PATH ?? path.join(process.cwd(), '.claude-flow', 'dual-run-memory.db');
if (fs.existsSync(`${dbPath}-wal`) || fs.existsSync(`${dbPath}-shm`)) {
const { refuse } = nativeWalConflict(dbPath, { runtime: await rufloRuntimeNatives() });
if (refuse) {
fail(`refusing to start: ruflo's memory runtime lacks a native better-sqlite3 binding AND ${dbPath} has an active native WAL (-wal/-shm sidecars). `
+ 'The orchestrator\'s native WAL writer and the sql.js (WASM) `npx ruflo memory store` cannot share this DB — it would corrupt the store.');
info('fix: run: ak sync (builds the native binding for ruflo\'s memory runtime), then retry');
return 1;
}
}

// track the temp config-module dirs so we don't leak them (L3).
const tmp = [];
const mkConfig = (c) => { const url = writeConfigModule(c, task); tmp.push(path.dirname(fileURLToPath(url))); return url; };
Expand Down
31 changes: 30 additions & 1 deletion src/commands/status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import path from 'node:path';
import { glyph, dim, bold, warn } from '../lib/output.mjs';
import { loadRing, detectRegression } from '../lib/health-history.mjs';
import * as paths from '../lib/paths.mjs';
import { nativesStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs';
import { nativesStatus, rufloRuntimeNatives, dbPathPinStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs';
import { scanNpxStale } from '../lib/npx.mjs';
import { registrationStatus, codexMcpStatus, rufloCodexMcpStatus } from '../lib/mcp.mjs';
import { listDaemons, staleDaemons } from '../lib/daemons.mjs';
Expand Down Expand Up @@ -127,10 +127,39 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) {
if (n.aqe && !n.aqe.native) {
rows.push(row('natives', 'fail', 'agentic-qe better-sqlite3 not native', 'sync repairs it'));
}
// #45: the agentdb copies above are NOT what `npx ruflo memory` loads — probe
// the binding as resolved from ruflo's own memory runtime (@claude-flow/memory
// + /cli), or the row reads ✓ while memory store runs on the WASM fallback.
const rt = await rufloRuntimeNatives();
if (rt.installed && rt.contexts.length) {
const wasm = rt.contexts.filter((c) => !c.ok);
if (wasm.length) {
rows.push(row('natives', 'fail',
`ruflo memory runtime on WASM fallback (${wasm.map((c) => `@claude-flow/${c.context}`).join(', ')}) — memory store/dual run degrade`,
'sync builds the native binding'));
} else {
rows.push(row('natives', 'ok', `ruflo memory runtime native (${rt.contexts.map((c) => c.context).join(', ')})`));
}
}
} catch (e) {
rows.push(row('natives', 'warn', `native check unavailable: ${e.message}`));
}

// #45 aftermath: a CLAUDE_FLOW_DB_PATH pin aimed at a dead or foreign path makes
// every memory op target the wrong DB ("Database not initialized" with a healthy
// DB in-repo). Warn-only — the pin may be deliberate; sync never touches it.
try {
const pin = dbPathPinStatus({
settingsLocalFile: path.join(process.cwd(), '.claude', 'settings.local.json'),
projectRoot: process.cwd(),
});
if (pin?.warn) {
rows.push(row('memory-pin', 'warn',
`CLAUDE_FLOW_DB_PATH pins ${pin.pinned} (${pin.reason})`,
'repoint it in .claude/settings.local.json env, or remove the pin'));
}
} catch { /* pin check is best-effort — never blocks status */ }

// npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the
// statusline/hooks execute these verbatim, keeping retired defects alive)
try {
Expand Down
26 changes: 20 additions & 6 deletions src/lib/heal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { run } from './exec.mjs';
import { rufloRoot, aqeRoot } from './paths.mjs';
import { agentdbLocations, bsq3IsNative, bsq3Root, aidefencePresent } from './natives.mjs';
import { agentdbLocations, bsq3IsNative, bsq3Root, deriveBsq3Spec, rufloMemoryContexts, aidefencePresent } from './natives.mjs';
import { KIT_PKG } from './versions.mjs';
import { scanRvf, quarantine } from './rvf.mjs';
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';
Expand Down Expand Up @@ -58,7 +58,11 @@ const failTail = (r) =>
export async function ensureNativeBsq3(dir, { runner = run } = {}) {
let pkgRoot = bsq3Root(dir);
if (!pkgRoot) {
await npmInstallInto(dir, 'better-sqlite3@^12', runner);
// Derive the spec from THIS tree's own overrides/deps: a hardcoded `@^12` is
// EOVERRIDE-rejected in a tree that pins better-sqlite3 (ruflo root pins
// 12.9.0, @claude-flow/cli pins ^12.9.0) — verified live. The declared spec
// installs clean and resolves a prebuilt.
await npmInstallInto(dir, `better-sqlite3@${deriveBsq3Spec(dir)}`, runner);
if (bsq3IsNative(dir)) return { ok: true, how: 'native installed' };
pkgRoot = bsq3Root(dir);
if (!pkgRoot) return { ok: false, how: 'FAILED (better-sqlite3 not resolvable)' };
Expand All @@ -72,19 +76,29 @@ export async function ensureNativeBsq3(dir, { runner = run } = {}) {
return { ok: false, how: failTail(r) };
}

/** Native better-sqlite3 into every agentdb location that lacks it. */
export async function healNatives() {
/** Native better-sqlite3 into every location the runtime resolves: the agentdb
* copies, agentic-qe, AND the ruflo memory-runtime contexts (@claude-flow/memory
* + /cli) — the copies `npx ruflo memory` actually loads. #45: healing only
* agentdb left ruflo's own memory on the WASM fallback (memory store failing)
* while status still read agentdb-native. `runner` injectable for hermetic tests. */
export async function healNatives({ runner = run } = {}) {
const details = [];
for (const dir of agentdbLocations()) {
// Re-check right before installing: an upgrade earlier in the same sync
// can remove a location (e.g. agentic-flow/node_modules/agentdb, gone in
// the 3.29.0 tree) between enumeration and heal.
if (!fs.existsSync(dir)) continue;
if (bsq3IsNative(dir)) continue;
details.push(`${dir}: ${(await ensureNativeBsq3(dir)).how}`);
details.push(`${dir}: ${(await ensureNativeBsq3(dir, { runner })).how}`);
}
if (fs.existsSync(aqeRoot()) && !bsq3IsNative(aqeRoot())) {
details.push(`agentic-qe: ${(await ensureNativeBsq3(aqeRoot())).how}`);
details.push(`agentic-qe: ${(await ensureNativeBsq3(aqeRoot(), { runner })).how}`);
}
// ruflo memory runtime — missing contexts are already filtered out (older trees
// may lack either package, EC-2), so this is a silent no-op on them.
for (const { context, dir } of rufloMemoryContexts()) {
if (bsq3IsNative(dir)) continue;
details.push(`@claude-flow/${context}: ${(await ensureNativeBsq3(dir, { runner })).how}`);
}
return { ok: !details.some((d) => d.includes('FAILED')), detail: details.join('; ') || 'already native everywhere' };
}
Expand Down
88 changes: 87 additions & 1 deletion src/lib/natives.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
// `security defend` needs (dropped from the 3.28 tree — ruvnet/ruflo#2670).
import fs from 'node:fs';
import path from 'node:path';
import { rufloNodeModules, aqeRoot } from './paths.mjs';
import { rufloRoot, rufloNodeModules, aqeRoot } from './paths.mjs';
import { run } from './exec.mjs';
import { readJson } from './settings.mjs';

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

// The packages ruflo's memory RUNTIME resolves better-sqlite3 from — not the
// agentdb copies above. @claude-flow/memory is the store; @claude-flow/cli is what
// `npx ruflo memory` runs. #45: these can be WASM-only while the agentdb copy is
// native, so the agentdb-only status was a false positive. Older ruflo trees may
// lack either package — filter to what exists so heal/status skip silently.
export function rufloMemoryContexts() {
const nm = rufloNodeModules();
return [
{ context: 'memory', dir: path.join(nm, '@claude-flow', 'memory') },
{ context: 'cli', dir: path.join(nm, '@claude-flow', 'cli') },
].filter((c) => fs.existsSync(c.dir));
}

// A PLAIN semver range/version — the only override/dependency form npm install can
// take by value. Reference forms (`$agentdb`) and protocols (workspace:/file:/link:/
// npm:/git+ssh:) are NOT installable specs, so they must be skipped during
// derivation rather than emitted (they'd make npm error). Requires a version digit
// so bare `*`/`latest`/`x` fall through to the caller's fallback.
const isPlainSemver = (v) =>
typeof v === 'string' && /\d/.test(v) && !v.includes(':') && !v.trimStart().startsWith('$');

/** The install spec for better-sqlite3 in `dir`, derived from that tree's OWN
* declarations so an npm `overrides` pin isn't fought with EOVERRIDE (verified
* live: `install better-sqlite3@^12` under @claude-flow/cli, which pins ^12.9.0,
* is rejected; the declared spec succeeds). Precedence: overrides →
* optionalDependencies → dependencies → fallback; the first PLAIN-semver value
* wins, non-semver forms are skipped. */
export function deriveBsq3Spec(dir, fallback = '^12') {
const pkg = readJson(path.join(dir, 'package.json'), {}) ?? {};
for (const field of ['overrides', 'optionalDependencies', 'dependencies']) {
const v = pkg[field]?.['better-sqlite3'];
if (isPlainSemver(v)) return v;
}
return fallback;
}

// A truthful load-test of the binding as node resolution finds it FROM `dir`: an
// ABI-mismatched or absent binding throws on `require` (exactly `ruflo doctor`'s
// "Could not locate the bindings file"), so requiring it, opening :memory:, and
// running SELECT 1 in a child process is the real WASM-vs-native answer — not a
// file-existence guess. Kept in a child process so a broken addon can't crash ak.
const RUNTIME_PROBE =
"const D=require(require.resolve('better-sqlite3',{paths:[process.argv[1]]}));"
+ "const db=new D(':memory:');const r=db.prepare('SELECT 1 AS ok').get();db.close();"
+ 'process.exit(r&&r.ok===1?0:3);';

/** Load-test better-sqlite3 as resolved from `dir`. Injectable runner keeps the
* test spawn-free. Returns {ok} or {ok:false, reason}. */
export async function probeBsq3Runtime(dir, { runner = run } = {}) {
// Generous timeout for a cold `node` spawn on CI; a real native require returns
// well under the status budget (probes run in parallel, see rufloRuntimeNatives).
const r = await runner('node', ['-e', RUNTIME_PROBE, dir], { cwd: dir, timeout: 8000 });
if (r.code === 0) return { ok: true };
return { ok: false, reason: (r.stderr || `exit ${r.code}`).trim().split('\n').pop().slice(0, 160) };
}

/** Per-context native-binding truth for ruflo's memory runtime. {installed:false}
* when ruflo is absent (EC-1: status/pre-flight skip, never crash). Probes run in
* parallel to stay inside the status time budget. */
export async function rufloRuntimeNatives({ runner = run } = {}) {
let installed;
try { installed = fs.existsSync(rufloRoot()); } catch { installed = false; }
if (!installed) return { installed: false, contexts: [] };
const contexts = await Promise.all(rufloMemoryContexts().map(async ({ context, dir }) => {
const res = await probeBsq3Runtime(dir, { runner });
return { context, dir, ok: res.ok, reason: res.reason };
}));
return { installed: true, contexts };
}

/** Drift for a CLAUDE_FLOW_DB_PATH pin in .claude/settings.local.json `env`: warn
* when the pinned DB's directory is missing OR the path lies outside the project.
* Warn-only (the pin may be deliberate); `sync` never touches it. path.relative
* for containment so drive-letter/Windows paths compare correctly, not by prefix.
* Returns null when there is no pin (EC-4: absent/unparseable settings). */
export function dbPathPinStatus({ settingsLocalFile, projectRoot }) {
const pinned = readJson(settingsLocalFile)?.env?.CLAUDE_FLOW_DB_PATH;
if (!pinned) return null;
if (!fs.existsSync(path.dirname(pinned))) return { warn: true, pinned, reason: 'directory does not exist' };
const rel = path.relative(projectRoot, pinned);
if (rel.startsWith('..') || path.isAbsolute(rel)) return { warn: true, pinned, reason: 'outside the project root' };
return { warn: false, pinned };
}

export const aidefencePresent = () =>
fs.existsSync(path.join(rufloNodeModules(), '@claude-flow', 'aidefence', 'package.json'));

Expand Down
44 changes: 44 additions & 0 deletions tests/kit/dual-preflight.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// #45 defect 2 pre-flight: ak dual run must refuse BEFORE spawning a worker when
// the ruflo memory runtime is WASM-only AND the target DB has an active native WAL
// (`-wal`/`-shm` sidecars). Unit-tests the pure predicate with an injected
// existsSync + a supplied runtime probe — no spawn, no global tree.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { nativeWalConflict } from '../../src/commands/dual.mjs';

const WASM_ONLY = { installed: true, contexts: [{ context: 'cli', ok: false }, { context: 'memory', ok: true }] };
const ALL_NATIVE = { installed: true, contexts: [{ context: 'cli', ok: true }, { context: 'memory', ok: true }] };
const NOT_INSTALLED = { installed: false, contexts: [] };

const sidecarPresent = (f) => f.endsWith('-wal') || f.endsWith('-shm');
const noSidecar = () => false;

test('refuses when the runtime is WASM-only AND a WAL sidecar is present', () => {
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: sidecarPresent });
assert.equal(c.refuse, true);
assert.equal(c.wasmOnly, true);
assert.equal(c.sidecar, true);
});

test('proceeds when the runtime is native even with a live WAL', () => {
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: ALL_NATIVE, existsSync: sidecarPresent });
assert.equal(c.refuse, false, 'native writer + native store is safe');
});

test('proceeds when there are no sidecars even on a WASM-only runtime', () => {
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: noSidecar });
assert.equal(c.refuse, false, 'no active WAL → nothing to conflict with');
});

test('proceeds when ruflo is not installed at all', () => {
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: NOT_INSTALLED, existsSync: sidecarPresent });
assert.equal(c.refuse, false);
});

test('a stale zero-length sidecar still counts as active (EC-6, presence not size)', () => {
// existsSync-based on purpose: false-refusal costs one `ak sync`, false-pass
// corrupts the DB. A -shm alone is enough.
const onlyShm = (f) => f.endsWith('-shm');
const c = nativeWalConflict('/proj/.swarm/memory.db', { runtime: WASM_ONLY, existsSync: onlyShm });
assert.equal(c.refuse, true);
});
Loading
Loading