diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index efa64fe..3af737a 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -48,8 +48,17 @@ Examples: ak sync --dry-run preview the plan ak sync --no-upgrade re-heal without touching versions`; -export async function run({ flags, pkgRoot }) { +export async function run({ flags, pkgRoot, fetchLatest }) { const cwd = process.cwd(); + // #134: draw the plan from CURRENT drift, not the TTL cache — a cache + // stamped before an upstream release claims "all current" and the upgrade + // never reaches the plan (the old force at apply time sat behind the very + // versions gate it needed to open). Dry-runs skip the refresh: it writes + // kit.json, and --dry-run is pinned to touch nothing — so a dry-run + // preview may be cache-stale by up to one TTL window. + if (!flags['dry-run'] && !flags['no-upgrade']) { + await driftReport({ force: true, ...(fetchLatest ? { fetchLatest } : {}) }); + } const rows = await collect({ pkgRoot, cwd }); const plan = rows.filter((r) => r.fix) .filter((r) => !(flags['no-upgrade'] && ['versions', 'self', 'ruvnet-brain', 'ruvector'].includes(r.subsystem))); @@ -79,7 +88,9 @@ export async function run({ flags, pkgRoot }) { if (subsystems.has('versions') && !flags['no-upgrade']) { report('daemons', await heal.stopAllDaemons()); - for (const d of await driftReport({ force: true })) { + // No force here: the pre-plan refresh above already ran for every + // non-dry-run, non-no-upgrade sync, so this read hits that fresh cache. + for (const d of await driftReport()) { if (d.outdated || !d.installed) await step(`upgrade ${d.pkg}`, () => heal.upgradePackage(d.pkg)); } } diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index d75f95c..7c77954 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -134,17 +134,20 @@ export async function healAidefence() { } /** Optional native sublinear solver for agentic-qe (best-effort). */ -export async function healAqeSolver({ runner = run } = {}) { +export async function healAqeSolver() { if (!fs.existsSync(aqeRoot())) { return { ok: true, status: 'skipped', usable: false, detail: 'agentic-qe not installed' }; } const probe = path.join(aqeRoot(), 'node_modules', '@ruvector', 'solver-node', 'package.json'); if (fs.existsSync(probe)) return { ok: true, status: 'ok', usable: true, detail: 'already present' }; - const r = await npmInstallInto(aqeRoot(), '@ruvector/solver-node', runner); - if (r.code === 0) return { ok: true, status: 'ok', usable: true, detail: 'installed' }; + // The native accelerator was never published to npm, and upstream resolved + // its own half by documenting the TypeScript solver as the implementation + // (agentic-qe#617 → #620, shipped in aqe 3.13.10). Attempting the install + // would only manufacture a 404 warning for a by-design state (#135). The + // probe above still detects a native that arrives by any other route. return { - ok: true, status: 'degraded', usable: true, - detail: `native solver unavailable; TypeScript fallback active (<50K nodes) (${failTail(r)})`, + ok: true, status: 'ok', usable: true, + detail: 'native solver unpublished upstream (agentic-qe#617) — TypeScript fallback is the implementation (<50K nodes)', }; } diff --git a/src/lib/versions.mjs b/src/lib/versions.mjs index a7d1637..8c2da98 100644 --- a/src/lib/versions.mjs +++ b/src/lib/versions.mjs @@ -54,8 +54,11 @@ export function cmpVersions(a, b) { const newer = (a, b) => cmpVersions(a, b) > 0; /** Drift report for the managed packages. Network hit at most once per TTL - * window (cached in kit.json); force=true bypasses the cache. */ -export async function driftReport({ force = false } = {}) { + * window (cached in kit.json); force=true bypasses the cache. A failed probe + * falls back to the cached value per package, and a run where EVERY probe + * failed neither overwrites `seen` nor stamps `last` — clobbering good data + * with nulls would suppress upgrade detection for a whole TTL window (#134). */ +export async function driftReport({ force = false, fetchLatest = latestVersion } = {}) { const cfg = loadKitConfig(); const ttlMs = (cfg.versionCheck?.ttlHours ?? 24) * 3600_000; const fresh = !force && cfg.versionCheck?.last && Date.now() - cfg.versionCheck.last < ttlMs; @@ -67,12 +70,20 @@ export async function driftReport({ force = false } = {}) { const HOST_PKGS = ['@anthropic-ai/claude-code', '@openai/codex', 'opencode-ai']; const pkgs = ['ruflo', 'agentic-qe', ...HOST_PKGS.filter((p) => installedVersion(p))]; const report = []; - let latest = cfg.versionCheck?.seen ?? {}; + const cached = cfg.versionCheck?.seen ?? {}; + let latest = cached; if (!fresh) { latest = {}; - for (const p of pkgs) latest[p] = await latestVersion(p); - cfg.versionCheck = { ...cfg.versionCheck, last: Date.now(), seen: latest }; - try { saveKitConfig(cfg); } catch { /* read-only envs: nudge just re-fetches */ } + let succeeded = 0; + for (const p of pkgs) { + const v = await fetchLatest(p); + if (v) succeeded += 1; + latest[p] = v ?? cached[p] ?? null; + } + if (succeeded > 0) { + cfg.versionCheck = { ...cfg.versionCheck, last: Date.now(), seen: latest }; + try { saveKitConfig(cfg); } catch { /* read-only envs: nudge just re-fetches */ } + } } for (const p of pkgs) { const installed = installedVersion(p); diff --git a/tests/kit/drift-freshness.test.mjs b/tests/kit/drift-freshness.test.mjs new file mode 100644 index 0000000..e4a2c52 --- /dev/null +++ b/tests/kit/drift-freshness.test.mjs @@ -0,0 +1,93 @@ +// #134 — sync must not let the version-drift TTL cache hide an available +// upgrade, and a failed forced fetch must never clobber good cached data. +// #135's solver contract lives in heal-natives.test.mjs; this file owns the +// drift-report resilience contract and the sync plan-freshness contract. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + sandboxHome, assertSandboxed, captureLog, rmrf, + sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot, +} from './helpers/home-sandbox.mjs'; + +const HOME = sandboxHome('ak-drift-fresh'); +const paths = await import('../../src/lib/paths.mjs'); +const { driftReport } = await import('../../src/lib/versions.mjs'); +const sync = await import('../../src/commands/sync.mjs'); +const { loadKitConfig } = await import('../../src/lib/config.mjs'); +assertSandboxed(paths, HOME); + +const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PROJECT = sandboxProject('ak-drift-fresh'); +const FLAGS = (over = {}) => ({ 'dry-run': false, 'no-upgrade': false, json: false, ...over }); + +/** kit.json with an explicit versionCheck state; global root has ruflo+aqe fixtures. */ +function seedHome({ last, seen, ruflo = '9.9.9' }) { + rmrf(paths.claudeDir(), paths.configDir()); + fs.mkdirSync(paths.claudeDir(), { recursive: true }); + fs.writeFileSync(paths.claudeMdPath(), '# machine notes\n'); + const cfg = offlineKitConfig(); + cfg.versionCheck = { ttlHours: 24, last, seen, self: cfg.versionCheck.self }; + writeKitConfig(HOME, cfg); + paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo, 'agentic-qe': '9.9.9' })); +} + +const inSandboxProject = async (fn) => { + const cwd = process.cwd(); + process.chdir(PROJECT); + try { return await fn(); } finally { process.chdir(cwd); } +}; + +test('a failed forced fetch falls back to the cached seen values instead of nulling them', async () => { + seedHome({ last: 1, seen: { ruflo: '9.9.10', 'agentic-qe': '9.9.9' } }); // stale cache, good data + const report = await driftReport({ force: true, fetchLatest: async () => null }); // npm down + + const ruflo = report.find((r) => r.pkg === 'ruflo'); + assert.equal(ruflo.latest, '9.9.10', 'cached seen survives a failed fetch'); + assert.equal(ruflo.outdated, true, '9.9.10 > installed 9.9.9 still detected'); +}); + +test('a fully-failed forced fetch neither clobbers seen nor stamps last (TTL retries promptly)', async () => { + seedHome({ last: 1, seen: { ruflo: '9.9.10', 'agentic-qe': '9.9.9' } }); + await driftReport({ force: true, fetchLatest: async () => null }); + + const after = loadKitConfig().versionCheck; + assert.equal(after.seen.ruflo, '9.9.10', 'seen preserved on total failure'); + assert.equal(after.last, 1, 'last not stamped — the next call must retry, not trust a failed probe'); +}); + +test('a successful forced fetch updates seen, stamps last, and reports drift', async () => { + seedHome({ last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' } }); // FRESH but wrong + const report = await driftReport({ force: true, fetchLatest: async (pkg) => (pkg === 'ruflo' ? '9.9.11' : '9.9.9') }); + + assert.equal(report.find((r) => r.pkg === 'ruflo').outdated, true); + const after = loadKitConfig().versionCheck; + assert.equal(after.seen.ruflo, '9.9.11'); + assert.ok(after.last > 1, 'last stamped on success'); +}); + +test('a STALE cache with newer seen data reaches the sync plan even when npm is unreachable', async () => { + // The collect() path: last=0 forces a refetch; offline that refetch fails. + // Resilient fallback must keep 9.9.10 visible so the plan includes the upgrade. + seedHome({ last: 0, seen: { ruflo: '9.9.10', 'agentic-qe': '9.9.9' } }); + const { result, out } = await inSandboxProject(() => + captureLog(() => sync.run({ flags: FLAGS({ 'dry-run': true }), pkgRoot: PKG_ROOT }))); + + assert.equal(result, 0); + assert.match(out, /\[versions\].*ruflo 9\.9\.9 installed, 9\.9\.10 available/, + 'the plan must surface the upgrade the cache already knows about'); +}); + +test('sync (non-dry) force-refreshes drift BEFORE building the plan, so a fresh-but-wrong cache cannot hide an upgrade', async () => { + seedHome({ last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' } }); // fresh cache: "all current" + const { out } = await inSandboxProject(() => + captureLog(() => sync.run({ + flags: FLAGS(), pkgRoot: PKG_ROOT, + fetchLatest: async (pkg) => (pkg === 'ruflo' ? '9.9.12' : '9.9.9'), // npm knows better + }))); + + assert.match(out, /\[versions\].*ruflo 9\.9\.9 installed, 9\.9\.12 available/, + 'plan must be built from a forced refresh, not the stale-fresh cache'); +}); diff --git a/tests/kit/heal-natives.test.mjs b/tests/kit/heal-natives.test.mjs index 6a3939d..205d6f3 100644 --- a/tests/kit/heal-natives.test.mjs +++ b/tests/kit/heal-natives.test.mjs @@ -340,17 +340,39 @@ test('brain installer stamps only a zero-exit installation', async () => { assert.equal(stamped, '4.0.12'); }); -test('AQE native solver failure is an explicit degraded fallback', async () => { +test('AQE solver: the unpublished native is never install-attempted and the TS fallback is reported as the implementation (#135)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-solver-')); fs.mkdirSync(path.join(root, 'agentic-qe'), { recursive: true }); _setGlobalRootForTest(root); try { + // upstream declared the package not-installable (agentic-qe#617/#620) — + // any npm invocation here is a regression to the pre-#620 behavior. const r = await healAqeSolver({ - runner: async () => ({ code: 1, stdout: '', stderr: 'native package unavailable\n' }), + runner: async () => { throw new Error('must not shell out for @ruvector/solver-node'); }, }); - assert.equal(r.ok, true, 'the TypeScript fallback remains usable'); - assert.equal(r.status, 'degraded'); - assert.equal(r.usable, true); + assert.equal(r.ok, true); + assert.equal(r.status, 'ok', 'expected state, not a warning'); + assert.equal(r.usable, true, 'the TypeScript fallback is the implementation'); + assert.match(r.detail, /unpublished upstream/); + assert.doesNotMatch(r.detail, /FAILED|npm error/, 'no error tail for a by-design state'); + } finally { + _setGlobalRootForTest(null); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('AQE solver: a native already present on disk is still detected and reported', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-solver-present-')); + const probe = path.join(root, 'agentic-qe', 'node_modules', '@ruvector', 'solver-node'); + fs.mkdirSync(probe, { recursive: true }); + fs.writeFileSync(path.join(probe, 'package.json'), '{"name":"@ruvector/solver-node"}'); + _setGlobalRootForTest(root); + try { + const r = await healAqeSolver({ + runner: async () => { throw new Error('must not shell out'); }, + }); + assert.equal(r.status, 'ok'); + assert.equal(r.detail, 'already present'); } finally { _setGlobalRootForTest(null); fs.rmSync(root, { recursive: true, force: true });