From 323144ace80a339a78ef0f0c55ae13a8900a6748 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Mon, 28 Sep 2026 23:50:12 -0700 Subject: [PATCH 01/54] fix(cli): keep command usage failures machine-readable --- bin/agentic-kit.mjs | 4 ++- src/commands/audit.mjs | 14 +++++--- src/commands/heal.mjs | 28 ++++++++-------- src/commands/models.mjs | 36 ++++++++++++++++----- src/commands/status.mjs | 2 +- src/commands/telemetry.mjs | 4 ++- src/commands/usage.mjs | 11 ++++--- src/commands/x/aqe-embedding.mjs | 13 ++++++-- src/commands/x/aqe-store.mjs | 5 +-- src/commands/x/codex-context.mjs | 8 +++-- src/commands/x/daemon-gc.mjs | 9 ++++-- src/commands/x/harvest.mjs | 9 ++++-- src/commands/x/host-adapters.mjs | 8 +++-- src/commands/x/host.mjs | 17 ++++++++-- src/commands/x/reference.mjs | 7 +++- src/commands/x/skills.mjs | 5 +-- src/commands/x/statusline.mjs | 15 ++++++--- tests/kit/cli-json-honesty.test.mjs | 50 +++++++++++++++++++++++++++++ tests/kit/models-command.test.mjs | 9 ++++++ tests/kit/status-command.test.mjs | 5 +-- tests/kit/telemetry-cli.test.mjs | 9 ++++++ 21 files changed, 210 insertions(+), 58 deletions(-) diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index d18c1ba6..b73b87c9 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -194,7 +194,9 @@ async function main() { } catch (err) { if (!String(err?.code ?? '').startsWith('ERR_PARSE_ARGS_')) throw err; if (cmd === 'telemetry') { - console.error('Telemetry failed: invalid command options.'); + const error = 'Telemetry failed: invalid command options.'; + console.error(error); + console.log(JSON.stringify({ error, exitCode: 2 })); return 2; } // Under --json a rejected option still answers with one JSON object (the diff --git a/src/commands/audit.mjs b/src/commands/audit.mjs index 90a1141f..69535fcb 100644 --- a/src/commands/audit.mjs +++ b/src/commands/audit.mjs @@ -9,6 +9,7 @@ import { collectContextEvidence } from '../lib/context-audit-sources.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { projectCensus, projectsInScope } from '../lib/project-census.mjs'; import { installedVersion } from '../lib/versions.mjs'; +import { reportFailure } from '../lib/output.mjs'; export const options = { json: { type: 'boolean', default: false }, @@ -152,8 +153,11 @@ export async function run({ contextCollectorFn = collectContextAudit, }) { if (positionals.length !== 1 || !['hooks', 'context'].includes(positionals[0])) { - console.error('ak audit requires the hooks or context subcommand'); - console.log(help); + const error = 'ak audit requires the hooks or context subcommand'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => { + console.error(error); + console.log(help); + } }); return 2; } if (positionals[0] === 'context') { @@ -161,7 +165,8 @@ export async function run({ try { report = await contextCollectorFn({ flags, pkgRoot, loadConfigFn }); } catch (error) { - console.error(`context audit failed: ${error.message}`); + const message = `context audit failed: ${error.message}`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => console.error(message) }); return 2; } if (flags.json) console.log(JSON.stringify(report, null, 2)); @@ -172,7 +177,8 @@ export async function run({ try { report = collectHookAudit({ flags, detectVersionFn, loadConfigFn }); } catch (error) { - console.error(`hook audit failed: ${error.message}`); + const message = `hook audit failed: ${error.message}`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => console.error(message) }); return 2; } if (flags.json) { diff --git a/src/commands/heal.mjs b/src/commands/heal.mjs index cd68085e..8b30d965 100644 --- a/src/commands/heal.mjs +++ b/src/commands/heal.mjs @@ -1,4 +1,5 @@ import path from 'node:path'; +import { reportFailure } from '../lib/output.mjs'; import { collectHookAudit } from './audit.mjs'; import { @@ -99,25 +100,28 @@ function validateMode(flags) { if (flags.apply && !flags.yes) throw new TypeError('--apply requires --yes'); } +function usageError(flags, message, showHelp = false) { + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => { + console.error(message); + if (showHelp) console.log(help); + } }); + return 2; +} + export async function run({ flags, positionals, detectVersionFn, loadConfigFn }) { if (positionals.length !== 1 || positionals[0] !== 'hooks') { - console.error('ak heal requires the hooks subcommand'); - console.log(help); - return 2; + return usageError(flags, 'ak heal requires the hooks subcommand', true); } try { validateMode(flags); } catch (error) { - console.error(`hook healing refused: ${error.message}`); - return 2; + return usageError(flags, `hook healing refused: ${error.message}`); } const transactionsRoot = transactionRoot(flags); if (flags.undo && flags.recover) { - console.error('hook healing refused: --undo and --recover are mutually exclusive'); - return 2; + return usageError(flags, 'hook healing refused: --undo and --recover are mutually exclusive'); } if (flags.undo || flags.recover) { if ((flags.action?.length ?? 0) || flags['plan-digest']) { - console.error('hook healing refused: rollback/recovery cannot be combined with plan action flags'); - return 2; + return usageError(flags, 'hook healing refused: rollback/recovery cannot be combined with plan action flags'); } const result = flags.recover ? (flags.apply @@ -145,8 +149,7 @@ export async function run({ flags, positionals, detectVersionFn, loadConfigFn }) return audit.summary.invalidSources || audit.summary.configurationIssues ? 1 : 0; } if (!flags.action?.length || !flags['plan-digest']) { - console.error('hook healing refused: apply requires --action and --plan-digest from a preview'); - return 2; + return usageError(flags, 'hook healing refused: apply requires --action and --plan-digest from a preview'); } if (unfinishedTransactions.length) { throw new Error(`unfinished hook transaction(s) require --recover first: ${unfinishedTransactions.map((item) => item.id).join(', ')}`); @@ -160,7 +163,6 @@ export async function run({ flags, positionals, detectVersionFn, loadConfigFn }) }); return printResult(result, flags.json); } catch (error) { - console.error(`hook healing failed: ${error.message}`); - return 2; + return usageError(flags, `hook healing failed: ${error.message}`); } } diff --git a/src/commands/models.mjs b/src/commands/models.mjs index 89026435..b9562567 100644 --- a/src/commands/models.mjs +++ b/src/commands/models.mjs @@ -1,4 +1,4 @@ -import { heading, info, ok, warn, dim } from '../lib/output.mjs'; +import { heading, info, ok, warn, dim, reportFailure } from '../lib/output.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { aqeRouterFile } from '../lib/providers.mjs'; import { readJson } from '../lib/settings.mjs'; @@ -129,10 +129,6 @@ async function runRefresh(ctx) { function runStatus(ctx) { const { flags, cacheFile, store, latest } = ctx; - if (flags.host && !ALL_OWNERS.includes(flags.host)) { - warn(`unsupported model host: ${flags.host}`); - return 2; - } const snapshot = visibleSnapshot(latest, flags.host); const since = flags.since ? Date.parse(flags.since) : null; const history = store.snapshots.filter((entry) => entry.scope.fingerprint === latest.scope.fingerprint @@ -174,7 +170,7 @@ function runDiff(ctx) { function runExplain(ctx) { const { positionals, flags, latest } = ctx; const selector = positionals[1] ?? flags.to; - if (!selector) { warn('usage: ak models explain HOST:MODEL'); return 2; } + if (!selector) { modelUsageError(flags, 'usage: ak models explain HOST:MODEL'); return 2; } const result = explainModel(latest, selector); if (flags.json) printJson(result); else if (!result.found) warn(`Model not found: ${selector}`); @@ -193,7 +189,7 @@ function runPlan(ctx) { const { flags, positionals, latest } = ctx; const activity = flags.activity; const to = flags.to ?? positionals[1]; - if (!activity || !to) { warn('usage: ak models plan --activity ACTIVITY [--from HOST:MODEL] --to HOST:MODEL'); return 2; } + if (!activity || !to) { modelUsageError(flags, 'usage: ak models plan --activity ACTIVITY [--from HOST:MODEL] --to HOST:MODEL'); return 2; } const result = planModelChange(latest, { activity, from: flags.from, to }); if (flags.json) printJson(result); else { @@ -211,9 +207,34 @@ function runPlan(ctx) { // dispatched by name once that store/latest snapshot is in hand (below). const READ_ACTIONS = { status: runStatus, diff: runDiff, explain: runExplain, plan: runPlan }; +function modelUsageError(flags, message) { + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); +} + /** @param {{flags: Record, positionals: string[], deps?: Record}} input */ export async function run({ flags, positionals, deps = {} }) { const action = positionals[0] ?? 'status'; + if (action !== 'refresh' && !Object.hasOwn(READ_ACTIONS, action)) { + modelUsageError(flags, 'usage: ak models status|refresh|diff|explain|plan'); + return 2; + } + const maxPositionals = action === 'diff' ? 3 : action === 'explain' || action === 'plan' ? 2 : 1; + if (positionals.length > maxPositionals) { + modelUsageError(flags, `unexpected argument '${positionals[maxPositionals]}'`); + return 2; + } + if (action === 'explain' && !positionals[1] && !flags.to) { + modelUsageError(flags, 'usage: ak models explain HOST:MODEL'); + return 2; + } + if (action === 'plan' && (!flags.activity || !(flags.to ?? positionals[1]))) { + modelUsageError(flags, 'usage: ak models plan --activity ACTIVITY [--from HOST:MODEL] --to HOST:MODEL'); + return 2; + } + if (action === 'status' && flags.host && !ALL_OWNERS.includes(flags.host)) { + modelUsageError(flags, `unsupported model host: ${flags.host}`); + return 2; + } const cacheFile = deps.cacheFile ?? modelInventoryPath(); const readStore = deps.readStore ?? readModelStore; const append = deps.append ?? appendModelSnapshot; @@ -229,6 +250,5 @@ export async function run({ flags, positionals, deps = {} }) { if (!latest) return noSnapshot(flags, cacheFile); const handler = READ_ACTIONS[action]; - if (!handler) { warn('usage: ak models status|refresh|diff|explain|plan'); return 2; } return handler({ ...ctx, store, latest }); } diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 0020ec5f..e45a296a 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -281,9 +281,9 @@ function strayArgumentError(positionals) { export async function run({ flags, positionals = [], pkgRoot, deps = {} }) { const request = refreshRequestFromFlags(flags); if ('error' in request) return usageError(flags, request.error); - if (!request.strength) return report(flags, { rows: await collect({ pkgRoot, refresh: false }) }); const stray = strayArgumentError(positionals); if (stray) return usageError(flags, stray); + if (!request.strength) return report(flags, { rows: await collect({ pkgRoot, refresh: false }) }); return runRefreshed({ flags, pkgRoot, request, deps }); } diff --git a/src/commands/telemetry.mjs b/src/commands/telemetry.mjs index 77d3c503..142eaa66 100644 --- a/src/commands/telemetry.mjs +++ b/src/commands/telemetry.mjs @@ -98,7 +98,9 @@ export async function run({ flags, positionals, pkgRoot, deps = {} }) { return 0; } catch { // Source failures and hostile input must not echo filenames or parser details. - console.error('Telemetry failed: check command options, schema/digest, compatible snapshots, private identity, and readable input/new output files.'); + const error = 'Telemetry failed: check command options, schema/digest, compatible snapshots, private identity, and readable input/new output files.'; + console.error(error); + console.log(JSON.stringify({ error, exitCode: 2 })); return 2; } } diff --git a/src/commands/usage.mjs b/src/commands/usage.mjs index 2d786207..34b70ab0 100644 --- a/src/commands/usage.mjs +++ b/src/commands/usage.mjs @@ -2,7 +2,7 @@ // offline text scorecard (`score`) rendered from the SAME local-transcript // aggregate `ak dashboard`'s Usage tab reads — no cost/token/percentile // arithmetic is redone here; see the score section below for the boundary. -import { heading, info, ok, warn, dim } from '../lib/output.mjs'; +import { heading, info, ok, warn, dim, reportFailure } from '../lib/output.mjs'; import { stripUnsafeChars } from '../lib/text-safety.mjs'; import { readIndex } from '../lib/usage-index.mjs'; import { @@ -321,7 +321,8 @@ function scoreProjection(agg, windowDays) { async function runScore({ flags, deps }) { const windowDays = parseScoreWindow(flags.window ?? '14'); if (windowDays == null) { - warn(`ak usage score: --window must be 7, 14, or 30 (got ${JSON.stringify(flags.window)})`); + const message = `ak usage score: --window must be 7, 14, or 30 (got ${JSON.stringify(flags.window)})`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); return 2; } const readAgg = deps.readIndex ?? readIndex; @@ -953,7 +954,8 @@ function printDeepPass(deep) { async function runPrompts({ flags, deps }) { const win = parsePromptWindow(flags.window); if (win == null) { - warn(`ak usage prompts: --window must be 7, 14, 30, or all (got ${JSON.stringify(flags.window)})`); + const message = `ak usage prompts: --window must be 7, 14, 30, or all (got ${JSON.stringify(flags.window)})`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); return 2; } const readAgg = deps.readIndex ?? readIndex; @@ -1096,6 +1098,7 @@ export async function run({ flags, positionals, deps = {} }) { return runOpenRouterRefresh({ flags, cacheFile, refresh: deps.refresh ?? refreshOpenRouterActivity }); } - warn('usage: ak usage status | ak usage refresh openrouter | ak usage score | ak usage prompts'); + const message = 'usage: ak usage status | ak usage refresh openrouter | ak usage score | ak usage prompts'; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); return 2; } diff --git a/src/commands/x/aqe-embedding.mjs b/src/commands/x/aqe-embedding.mjs index 25601712..fad9d06e 100644 --- a/src/commands/x/aqe-embedding.mjs +++ b/src/commands/x/aqe-embedding.mjs @@ -3,6 +3,7 @@ import { embeddingIntentFromFlags, embeddingSetupDisclosure } from '../../lib/aq import { prepareAqeEmbedding, AQE_EMBEDDING_COACHING } from '../../lib/aqe-embedding-lifecycle.mjs'; import { inspectAqeEmbeddingProjections, reconcileAqeEmbeddingProjections } from '../../lib/aqe-embedding-projection.mjs'; import { reconcileOpencodeAqeEmbedding } from '../../lib/opencode-core.mjs'; +import { reportFailure } from '../../lib/output.mjs'; export const options = { 'aqe-embedding-mode': { type: 'string' }, 'aqe-embedding-endpoint': { type: 'string' }, @@ -43,11 +44,19 @@ export async function run({ flags = {}, positionals = [], reconcileOpenCode = reconcileOpencodeAqeEmbedding, }) { const action = positionals[0] ?? 'status'; - if (!['status', 'configure', 'prepare', 'verify'].includes(action) || positionals.length > 1) return 2; + if (!['status', 'configure', 'prepare', 'verify'].includes(action) || positionals.length > 1) { + const error = 'usage: ak x aqe-embedding [status|configure|prepare|verify]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => console.error(error) }); + return 2; + } const cfg = load(); if (action === 'configure') { try { cfg.aqeEmbedding = embeddingIntentFromFlags(cfg, flags); } - catch { console.error('Invalid embedding selection; run ak x aqe-embedding --help.'); return 2; } + catch { + const error = 'Invalid embedding selection; run ak x aqe-embedding --help.'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => console.error(error) }); + return 2; + } } const emit = value => console.log(flags.json ? JSON.stringify(value) : value.detail); const openCodeReady = (dryRun) => { diff --git a/src/commands/x/aqe-store.mjs b/src/commands/x/aqe-store.mjs index fb26b5b7..9d4e5158 100644 --- a/src/commands/x/aqe-store.mjs +++ b/src/commands/x/aqe-store.mjs @@ -3,7 +3,7 @@ // while any AQE writer is open. The work is in src/lib/aqe-store-merge.mjs. import { mergeAqeStores, restoreSteps } from '../../lib/aqe-store-merge.mjs'; import { repoRoot } from '../../lib/paths.mjs'; -import { ok, warn, fail, info } from '../../lib/output.mjs'; +import { ok, warn, fail, info, reportFailure } from '../../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, @@ -139,7 +139,8 @@ function printInterrupted(result) { export async function run({ flags = {}, positionals = [], cwd = process.cwd(), merge = mergeAqeStores }) { const action = positionals[0] ?? 'status'; if (!['status', 'merge'].includes(action) || positionals.length > 1) { - fail('usage: ak x aqe-store [status|merge] [--yes] [--dry-run] [--json]'); + const error = 'usage: ak x aqe-store [status|merge] [--yes] [--dry-run] [--json]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); return 2; } const root = repoRoot(cwd); diff --git a/src/commands/x/codex-context.mjs b/src/commands/x/codex-context.mjs index 3502beca..16866127 100644 --- a/src/commands/x/codex-context.mjs +++ b/src/commands/x/codex-context.mjs @@ -1,6 +1,6 @@ import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { inspectCodexContext, manageCodexContext, releaseCodexContext } from '../../lib/codex-context.mjs'; -import { info, ok, warn } from '../../lib/output.mjs'; +import { info, ok, warn, reportFailure } from '../../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, json: { type: 'boolean', default: false } }; export const help = `ak x codex-context — manage Codex's native per-model context capacities @@ -21,7 +21,11 @@ Examples: export async function run({ flags, positionals, contextOptions = {} }) { const choice = positionals[0] ?? 'status'; - if (!['status', 'max', 'off'].includes(choice) || positionals.length > 1) { warn(help); return 2; } + if (!['status', 'max', 'off'].includes(choice) || positionals.length > 1) { + const error = 'usage: ak x codex-context [status|max|off] [--dry-run] [--json]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(help) }); + return 2; + } const cfg = loadKitConfig(); const status = inspectCodexContext(cfg, contextOptions); if (choice === 'status' || flags['dry-run']) { diff --git a/src/commands/x/daemon-gc.mjs b/src/commands/x/daemon-gc.mjs index d6d6947d..69a0b13c 100644 --- a/src/commands/x/daemon-gc.mjs +++ b/src/commands/x/daemon-gc.mjs @@ -4,7 +4,7 @@ import { listDaemons, staleDaemons, reap, listMcpTransports, orphanedMcpTransports, reapMcpTransports, } from '../../lib/daemons.mjs'; -import { ok, warn, dim } from '../../lib/output.mjs'; +import { ok, warn, dim, reportFailure } from '../../lib/output.mjs'; export const options = { kill: { type: 'boolean', default: false }, @@ -32,7 +32,12 @@ Examples: ak x daemon-gc --kill reap stale background daemons ak x daemon-gc --mcp --kill also reap same-user PPID-1 MCP orphans`; -export async function run({ flags }) { +export async function run({ flags, positionals = [] }) { + if (positionals.length) { + const error = `unexpected argument '${positionals[0]}'`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); + return 2; + } const daemons = await listDaemons(); const stale = staleDaemons(daemons); const mcpTransports = await listMcpTransports(); diff --git a/src/commands/x/harvest.mjs b/src/commands/x/harvest.mjs index 86f2dcbc..7b690dc6 100644 --- a/src/commands/x/harvest.mjs +++ b/src/commands/x/harvest.mjs @@ -7,7 +7,7 @@ // memory root. It NEVER starts a daemon and NEVER backgrounds anything. import { loadKitConfig } from '../../lib/config.mjs'; import { planHarvest, runHarvest } from '../../lib/harvest.mjs'; -import { ok, fail, warn, info, dim, heading } from '../../lib/output.mjs'; +import { ok, fail, warn, info, dim, heading, reportFailure } from '../../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, @@ -45,7 +45,12 @@ Examples: ak x harvest record the outcome (only when opted in) ak x harvest --distill record, then distill the project store`; -export async function run({ flags }) { +export async function run({ flags, positionals = [] }) { + if (positionals.length) { + const error = `unexpected argument '${positionals[0]}'`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); + return 2; + } const cwd = process.cwd(); const cfg = loadKitConfig(); const enabled = cfg.harvest === true; diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 0264c2fe..117cc77e 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -25,7 +25,7 @@ import { HOST_REGISTRY } from '../../lib/adapters/registries.mjs'; import * as consentStore from '../../lib/adapters/consent.mjs'; import { runTieredConformance as defaultRunTieredConformance } from '../../lib/adapters/conformance.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; -import { ok, warn, fail, info, dim, bold } from '../../lib/output.mjs'; +import { ok, warn, fail, info, dim, bold, reportFailure } from '../../lib/output.mjs'; import { grant as grantCap, gate as gateTier, status as statusReport, revokeGrant, } from './host-adapters-grants.mjs'; @@ -505,7 +505,8 @@ export async function run({ if (failSafe) return failSafe(ctx); if (!flagEnabled(env)) { - fail(`experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`); + const error = `experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); return 2; } @@ -514,6 +515,7 @@ export async function run({ const gated = GATED_HANDLERS[sub]; if (gated) return gated(ctx); - fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance|grant|bless|gate|status|revoke-grant)`); + const error = `unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance|grant|bless|gate|status|revoke-grant)`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); return 2; } diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 73231e87..744b36bb 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -32,7 +32,7 @@ import { hostManagement, hostEnableCommand, HOST_MANAGEMENT_LABELS, NOT_PARTICIPATING, } from '../../lib/host-management.mjs'; import { - ok, warn, fail, info, dim, bold, yellow, humanOutputToStderr, + ok, warn, fail, info, dim, bold, yellow, humanOutputToStderr, reportFailure, } from '../../lib/output.mjs'; import { repoRoot } from '../../lib/paths.mjs'; import { writeJsonWithBackup } from '../../lib/settings.mjs'; @@ -185,6 +185,12 @@ export async function run({ flags, positionals, pkgRoot }) { const sub = positionals[0] ?? 'status'; const cwd = process.cwd(); + if (['status', 'off', 'pick', 'reset-routes', 'align'].includes(sub) && positionals.length > 1) { + const error = `unexpected argument '${positionals[1]}'`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); + return 2; + } + if (sub === 'status') return status({ flags, cwd }); if (sub === 'off') return off({ cwd, pkgRoot, flags }); if (sub === 'pick') return pick({ flags, cwd, pkgRoot }); @@ -196,12 +202,17 @@ export async function run({ flags, positionals, pkgRoot }) { // read-only preview to give --dry-run, so it is refused outright // instead of silently behaving like a real run: a flag we declare is a // flag we honor, or refuse. - if (flags['dry-run']) { fail('ak host adapters has no preview; run it without --dry-run'); return 2; } + if (flags['dry-run']) { + const error = 'ak host adapters has no preview; run it without --dry-run'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); + return 2; + } return (await import('./host-adapters.mjs')).run({ flags, positionals: positionals.slice(1) }); } if (sub === 'check-connection') return (await import('./host-connection.mjs')).run({ flags, positionals: positionals.slice(1) }); - fail(`unknown host subcommand: ${sub} (status|pick|reset-routes|off|check-connection|adapters|align)`); + const error = `unknown host subcommand: ${sub} (status|pick|reset-routes|off|check-connection|adapters|align)`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); return 2; } diff --git a/src/commands/x/reference.mjs b/src/commands/x/reference.mjs index 8c42cd83..d37b40ff 100644 --- a/src/commands/x/reference.mjs +++ b/src/commands/x/reference.mjs @@ -1,7 +1,7 @@ // x reference — inspect (diff) or reconcile (sync) every managed host-guidance target. import { reconcileGuidance } from '../../lib/blocks.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; -import { ok, warn, dim } from '../../lib/output.mjs'; +import { ok, warn, dim, reportFailure } from '../../lib/output.mjs'; export const options = { json: { type: 'boolean', default: false } }; @@ -20,6 +20,11 @@ Examples: export async function run({ flags, positionals, pkgRoot }) { const sub = positionals[0] ?? 'diff'; + if (!['diff', 'sync'].includes(sub) || positionals.length > 1) { + const error = 'usage: ak x reference [diff|sync] [--json]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); + return 2; + } const cfg = loadKitConfig(); const dryRun = sub !== 'sync'; const res = await reconcileGuidance({ diff --git a/src/commands/x/skills.mjs b/src/commands/x/skills.mjs index 08b62044..5dc7f6ac 100644 --- a/src/commands/x/skills.mjs +++ b/src/commands/x/skills.mjs @@ -3,7 +3,7 @@ import path from 'node:path'; import { collectCatalog } from '../../lib/footprint/catalog.mjs'; import { buildSkillMaintenancePlan } from '../../lib/skill-maintenance-plan.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; -import { heading, info, warn, dim } from '../../lib/output.mjs'; +import { heading, info, warn, dim, reportFailure } from '../../lib/output.mjs'; export const options = { project: { type: 'string' }, @@ -27,7 +27,8 @@ Examples: export async function run({ flags, positionals }) { if ((positionals[0] ?? 'plan') !== 'plan' || positionals.length > 1) { - warn('usage: ak x skills plan [--project PATH] [--json]'); + const error = 'usage: ak x skills plan [--project PATH] [--json]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); return 2; } const project = path.resolve(flags.project ?? process.cwd()); diff --git a/src/commands/x/statusline.mjs b/src/commands/x/statusline.mjs index 13465758..92567c16 100644 --- a/src/commands/x/statusline.mjs +++ b/src/commands/x/statusline.mjs @@ -3,7 +3,7 @@ import { PRESETS, applyCodexStatusline, inspectCodexStatusline, projectionFor, removeCodexStatusline, statuslineDrift, } from '../../lib/codex-statusline.mjs'; -import { ok, info, warn } from '../../lib/output.mjs'; +import { ok, info, warn, reportFailure } from '../../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, @@ -33,7 +33,7 @@ Examples: export async function run({ flags, positionals }) { const cfg = loadKitConfig(); const [target = 'status', choice] = positionals; - if (target === 'status') { + if (target === 'status' && positionals.length <= 1) { const current = inspectCodexStatusline(); const drift = statuslineDrift(cfg); const result = { ownership: cfg.statusline?.codex ?? null, current, drifted: drift.drifted }; @@ -45,8 +45,9 @@ export async function run({ flags, positionals }) { } return 0; } - if (target !== 'codex' || !choice) { - warn('usage: ak x statusline status | codex native | codex extended | codex off'); + if (target !== 'codex' || !choice || positionals.length !== 2) { + const error = 'usage: ak x statusline status | codex native | codex extended | codex off'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); return 2; } if (choice === 'off') { @@ -63,7 +64,11 @@ export async function run({ flags, positionals }) { ok(`codex status-line management disabled${result.changed ? '; unchanged managed keys removed' : '; user-modified keys preserved'}`); return 0; } - if (!PRESETS[choice]) { warn(`unknown preset '${choice}' (expected native, extended, or off)`); return 2; } + if (!PRESETS[choice]) { + const error = `unknown preset '${choice}' (expected native, extended, or off)`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); + return 2; + } if (flags['dry-run']) { info(`[dry-run] apply Codex ${choice} preset at user scope`); return 0; } // Persist ownership first. If the TOML merge then fails, sync retains enough // intent to report/retry it; the inverse ordering could mutate config.toml diff --git a/tests/kit/cli-json-honesty.test.mjs b/tests/kit/cli-json-honesty.test.mjs index 1a4a0a34..0b2313db 100644 --- a/tests/kit/cli-json-honesty.test.mjs +++ b/tests/kit/cli-json-honesty.test.mjs @@ -124,6 +124,56 @@ for (const [args, message] of USAGE_ERRORS) { }); } +const COMMAND_USAGE_ERRORS = [ + [['usage', 'bogus', '--json'], /usage: ak usage/], + [['usage', 'score', '--window', '99', '--json'], /--window must be/], + [['usage', 'prompts', '--window', '99', '--json'], /--window must be/], + [['models', 'bogus', '--json'], /usage: ak models/], + [['models', 'explain', '--json'], /usage: ak models explain/], + [['models', 'plan', '--json'], /usage: ak models plan/], + [['models', 'status', 'extra', '--json'], /unexpected argument/], + [['models', 'status', '--host', 'bogus', '--json'], /unsupported model host/], + [['audit', 'bogus', '--json'], /requires the hooks or context subcommand/], + [['heal', 'bogus', '--json'], /requires the hooks subcommand/], + [['heal', 'hooks', '--yes', '--json'], /--yes requires --apply/], + [['x', 'aqe-store', 'bogus', '--json'], /usage: ak x aqe-store/], + [['x', 'aqe-embedding', 'bogus', '--json'], /aqe-embedding/], + [['x', 'codex-context', 'bogus', '--json'], /codex-context/], + [['x', 'skills', 'bogus', '--json'], /usage: ak x skills/], + [['x', 'reference', 'bogus', '--json'], /reference/], + [['x', 'statusline', 'bogus', '--json'], /usage: ak x statusline/], + [['x', 'daemon-gc', 'bogus', '--json'], /unexpected argument/], + [['x', 'harvest', 'bogus', '--json'], /unexpected argument/], + [['host', 'bogus', '--json'], /unknown host subcommand/], + [['host', 'status', 'extra', '--json'], /unexpected argument/], + [['host', 'adapters', '--dry-run', '--json'], /has no preview/], + [['host', 'adapters', 'unknown', '--json'], /experimental host-adapter surface is disabled/], +]; + +for (const [args, message] of COMMAND_USAGE_ERRORS) { + test(`ak ${args.join(' ')} reports one command-level JSON usage error`, () => { + const child = ak(args); + const out = oneJson(child); + assert.equal(child.status, 2, child.stderr); + assert.deepEqual(Object.keys(out), ['error', 'exitCode']); + assert.equal(out.exitCode, 2); + assert.match(out.error, message); + assert.match(child.stderr, message); + }); +} + +test('models rejects an unknown verb even when no snapshot exists', () => { + const child = ak(['models', 'bogus', '--json']); + assert.equal(child.status, 2, child.stderr); + assert.match(oneJson(child).error, /usage: ak models/); +}); + +test('plain status rejects a stray positional with exit 2', () => { + const child = ak(['status', 'stray']); + assert.equal(child.status, 2, child.stderr); + assert.match(child.stdout, /unexpected argument 'stray'/); +}); + test('without --json a command-level usage error still prints on stdout', () => { const child = ak(['status', '--refresh=bogus']); assert.equal(child.status, 2); diff --git a/tests/kit/models-command.test.mjs b/tests/kit/models-command.test.mjs index b4774fd4..fc815f6f 100644 --- a/tests/kit/models-command.test.mjs +++ b/tests/kit/models-command.test.mjs @@ -40,6 +40,15 @@ test('models status is a cache-only read', async () => { assert.equal(JSON.parse(result.output).inventory.snapshotId, 'models:test'); }); +test('models rejects an unknown verb with a populated snapshot store', async () => { + const result = await capture(() => run({ + flags: { json: true }, positionals: ['bogus'], + deps: { loadConfig: () => cfg, readStore: () => store }, + })); + assert.equal(result.code, 2); + assert.match(result.output, /usage: ak models status\|refresh\|diff\|explain\|plan/); +}); + test('models status host filter rejects unknown owners and narrows evidence', async () => { const invalid = await capture(() => run({ flags: { json: true, host: 'unknown' }, positionals: ['status'], diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index 27744242..8b78e10f 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -970,7 +970,7 @@ test('--refresh prints one line per finished stage, then the status table', asyn assert.match(r.out, /ak status\n.*versions +fake versions row/); }); -test('plain status runs no refresh stage', async () => { +test('plain status rejects a stray positional before any refresh stage', async () => { seedHome(); let calls = 0; const refreshStages = new Proxy({}, { get: () => async () => { calls += 1; return { ok: true }; } }); @@ -981,7 +981,8 @@ test('plain status runs no refresh stage', async () => { r = await captureLog(() => status.run({ flags: {}, positionals: ['extra'], pkgRoot: PKG_ROOT, deps: { refreshStages } })); } finally { process.chdir(cwd); } assert.equal(calls, 0); - assert.notEqual(r.result, 2, 'plain status still ignores a positional, as it always has'); + assert.equal(r.result, 2); + assert.match(r.out, /unexpected argument 'extra'/); }); test('a refresh with a stray argument is a usage error that names the one-token spelling', async () => { diff --git a/tests/kit/telemetry-cli.test.mjs b/tests/kit/telemetry-cli.test.mjs index f7f2ad7b..a91132f2 100644 --- a/tests/kit/telemetry-cli.test.mjs +++ b/tests/kit/telemetry-cli.test.mjs @@ -66,6 +66,15 @@ test('should_keepErrorsGeneric_when_malformedFilesContainSecrets', t => { const result = cli(['validate', file]); assert.equal(result.status, 2); assert.doesNotMatch(result.stdout + result.stderr, /SECRET/); + assert.deepEqual(Object.keys(JSON.parse(result.stdout)), ['error', 'exitCode']); + assert.equal(JSON.parse(result.stdout).exitCode, 2); +}); +test('telemetry parser rejection remains private and machine-readable', () => { + const result = cli(['schema', '--private-path=/secret/SECRET']); + assert.equal(result.status, 2); + assert.deepEqual(Object.keys(JSON.parse(result.stdout)), ['error', 'exitCode']); + assert.equal(JSON.parse(result.stdout).exitCode, 2); + assert.doesNotMatch(result.stdout + result.stderr, /SECRET|\/secret/); }); test('should_degradeSourcesIndependently_when_usageReaderFails', async () => { const { collectSnapshot } = await import('../../src/lib/telemetry/collect.mjs'); From 557ba358e18a3c5ec50f806f5b69dae529a1cf98 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Mon, 28 Sep 2026 23:56:54 -0700 Subject: [PATCH 02/54] fix(cli): validate usage and adapter revocation arguments --- src/commands/usage.mjs | 6 ++++++ src/commands/x/host-adapters-grants.mjs | 10 +++++++--- src/commands/x/host-adapters.mjs | 12 ++++++++---- tests/kit/cli-json-honesty.test.mjs | 20 ++++++++++++++++++-- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/commands/usage.mjs b/src/commands/usage.mjs index 34b70ab0..e2c578f7 100644 --- a/src/commands/usage.mjs +++ b/src/commands/usage.mjs @@ -1089,6 +1089,12 @@ export async function run({ flags, positionals, deps = {} }) { const provider = positionals[1]; const cacheFile = deps.cacheFile ?? openRouterActivityFile(); + if (['score', 'prompts'].includes(action) && positionals.length > 1) { + const message = `unexpected argument '${positionals[1]}'`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); + return 2; + } + if (action === 'status' && provider === undefined) { return runOpenRouterStatus({ flags, cacheFile, read: deps.read ?? readOpenRouterActivity }); } diff --git a/src/commands/x/host-adapters-grants.mjs b/src/commands/x/host-adapters-grants.mjs index 8e3dbe8f..12c2ddd3 100644 --- a/src/commands/x/host-adapters-grants.mjs +++ b/src/commands/x/host-adapters-grants.mjs @@ -19,7 +19,7 @@ import { import { bootstrapHostAdapters } from '../../lib/adapters/admission.mjs'; import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { applyAqeRouter } from '../../lib/providers.mjs'; -import { ok, warn, fail, info, bold } from '../../lib/output.mjs'; +import { ok, warn, fail, info, bold, reportFailure } from '../../lib/output.mjs'; import { findEntry, loadAndHash, stripControl, hookCommandsFor, } from './host-adapters.mjs'; @@ -396,12 +396,16 @@ async function reconcileRevokedAqeProvider({ } export async function revokeGrant({ - name, capability, grantsFile, cfg, env, cwd = process.cwd(), + name, capability, grantsFile, cfg, env, cwd = process.cwd(), flags = /** @type {{json?: boolean}} */ ({}), saveConfig = saveKitConfig, bootstrapAdapters = bootstrapHostAdapters, applyRouter = applyAqeRouter, }) { - if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke-grant [capability]'); return 2; } + if (typeof name !== 'string' || !name) { + const error = 'usage: ak host adapters revoke-grant [capability]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); + return 2; + } const safeName = stripControl(name); if (typeof capability === 'string' && capability) { diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 117cc77e..9d2091db 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -298,8 +298,12 @@ async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash } return 0; } -function revoke({ name, consent }) { - if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke '); return 2; } +function revoke({ name, consent, flags = /** @type {{json?: boolean}} */ ({}) }) { + if (typeof name !== 'string' || !name) { + const error = 'usage: ak host adapters revoke '; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); + return 2; + } const existed = consent.revokeConsent(name); if (existed) { ok(`revoked consent for '${name}'`); return 0; } info(`no recorded consent for '${name}'`); @@ -437,9 +441,9 @@ async function conformance({ // standing consent or grant record, or it silently reactivates the next time // the flag is turned back on. const FAIL_SAFE_HANDLERS = { - revoke: (ctx) => revoke({ name: ctx.name, consent: ctx.consent }), + revoke: (ctx) => revoke({ name: ctx.name, consent: ctx.consent, flags: ctx.flags }), 'revoke-grant': (ctx) => revokeGrant({ - name: ctx.name, capability: ctx.positionals[2], grantsFile: ctx.grantsFile, cfg: ctx.cfg, env: ctx.env, cwd: ctx.cwd, + name: ctx.name, capability: ctx.positionals[2], grantsFile: ctx.grantsFile, cfg: ctx.cfg, env: ctx.env, cwd: ctx.cwd, flags: ctx.flags, ...(ctx.saveConfig ? { saveConfig: ctx.saveConfig } : {}), ...(ctx.bootstrapAdapters ? { bootstrapAdapters: ctx.bootstrapAdapters } : {}), ...(ctx.applyRouter ? { applyRouter: ctx.applyRouter } : {}), diff --git a/tests/kit/cli-json-honesty.test.mjs b/tests/kit/cli-json-honesty.test.mjs index 0b2313db..f82b79bf 100644 --- a/tests/kit/cli-json-honesty.test.mjs +++ b/tests/kit/cli-json-honesty.test.mjs @@ -23,9 +23,9 @@ const BIN = path.join(PKG_ROOT, 'bin', 'agentic-kit.mjs'); const KIT_JSON = path.join(HOME, '.config', 'agentic-kit', 'kit.json'); /** Run `ak …args` in the sandbox. */ -function ak(args) { +function ak(args, envOverrides = {}) { return spawnSync(process.execPath, [BIN, ...args], { - cwd: PROJECT, env: spawnEnv(HOME), encoding: 'utf8', timeout: 120_000, + cwd: PROJECT, env: { ...spawnEnv(HOME), ...envOverrides }, encoding: 'utf8', timeout: 120_000, }); } @@ -128,6 +128,8 @@ const COMMAND_USAGE_ERRORS = [ [['usage', 'bogus', '--json'], /usage: ak usage/], [['usage', 'score', '--window', '99', '--json'], /--window must be/], [['usage', 'prompts', '--window', '99', '--json'], /--window must be/], + [['usage', 'score', 'extra', '--json'], /unexpected argument 'extra'/], + [['usage', 'prompts', 'extra', '--json'], /unexpected argument 'extra'/], [['models', 'bogus', '--json'], /usage: ak models/], [['models', 'explain', '--json'], /usage: ak models explain/], [['models', 'plan', '--json'], /usage: ak models plan/], @@ -162,6 +164,20 @@ for (const [args, message] of COMMAND_USAGE_ERRORS) { }); } +for (const enabled of ['0', '1']) { + for (const verb of ['revoke', 'revoke-grant']) { + test(`ak host adapters ${verb} --json without a name is JSON with feature flag ${enabled}`, () => { + const child = ak(['host', 'adapters', verb, '--json'], { AK_EXPERIMENTAL_HOST_ADAPTERS: enabled }); + const out = oneJson(child); + assert.equal(child.status, 2, child.stderr); + assert.deepEqual(Object.keys(out), ['error', 'exitCode']); + assert.equal(out.exitCode, 2); + assert.match(out.error, new RegExp(`usage: ak host adapters ${verb} `)); + assert.match(child.stderr, new RegExp(`usage: ak host adapters ${verb} `)); + }); + } +} + test('models rejects an unknown verb even when no snapshot exists', () => { const child = ak(['models', 'bogus', '--json']); assert.equal(child.status, 2, child.stderr); From e29ddd92012b033f14ffee61a173982a8d7e374a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 00:06:01 -0700 Subject: [PATCH 03/54] fix(host): keep dry-run JSON previews structured --- src/commands/x/host.mjs | 87 ++++++++++++++++++++++++--------- tests/kit/host-dry-run.test.mjs | 68 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 24 deletions(-) diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 744b36bb..13174929 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -389,27 +389,38 @@ async function resetRoutes({ flags, cwd }) { const cfg = loadKitConfig(); const policy = cfg.routing?.routes ?? {}; const diverged = divergedRoutes(policy); - if (!diverged.length) { ok('no seeded routes diverge from the current defaults'); return 0; } - - console.log(bold('seeded routes that diverge from current defaults')); - for (const d of diverged) { - const head = d.modelDiverged ? `${d.model} → ${d.defaultModel}` : d.model; - console.log(` ${d.activity.padEnd(18)} ${d.host.padEnd(7)} ${head}`); - for (const e of d.escalation) console.log(` ${dim('escalation:')} ${e.model} → ${e.defaultModel}`); - // The trade, not just the ids: choosing on a price axis while paying on a - // turns axis is the misreading this whole surface exists to prevent. - if (d.modelDiverged && d.currentNote) console.log(dim(` now: ${d.model} — ${d.currentNote}`)); - if (d.modelDiverged && d.defaultNote) console.log(dim(` default: ${d.defaultModel} — ${d.defaultNote}`)); - for (const e of d.escalation) { - const note = modelNote(e.defaultModel); - if (note) console.log(dim(` default: ${e.defaultModel} — ${note}`)); + const jsonDryRun = flags['dry-run'] && flags.json; + const previewJson = (activities) => console.log(JSON.stringify({ dryRun: true, activities }, null, 2)); + if (!diverged.length) { + if (jsonDryRun) previewJson([]); + else ok('no seeded routes diverge from the current defaults'); + return 0; + } + + if (!jsonDryRun) { + console.log(bold('seeded routes that diverge from current defaults')); + for (const d of diverged) { + const head = d.modelDiverged ? `${d.model} → ${d.defaultModel}` : d.model; + console.log(` ${d.activity.padEnd(18)} ${d.host.padEnd(7)} ${head}`); + for (const e of d.escalation) console.log(` ${dim('escalation:')} ${e.model} → ${e.defaultModel}`); + // The trade, not just the ids: choosing on a price axis while paying on a + // turns axis is the misreading this whole surface exists to prevent. + if (d.modelDiverged && d.currentNote) console.log(dim(` now: ${d.model} — ${d.currentNote}`)); + if (d.modelDiverged && d.defaultNote) console.log(dim(` default: ${d.defaultModel} — ${d.defaultNote}`)); + for (const e of d.escalation) { + const note = modelNote(e.defaultModel); + if (note) console.log(dim(` default: ${e.defaultModel} — ${note}`)); + } } } let picked; if (flags.activity !== undefined) { const want = flags.activity.split(',').map((s) => s.trim()).filter(Boolean); - for (const a of want.filter((a) => !ACTIVITIES.includes(a))) warn(`unknown activity '${a}' — ignored`); + for (const a of want.filter((a) => !ACTIVITIES.includes(a))) { + if (jsonDryRun) await humanOutputToStderr(() => warn(`unknown activity '${a}' — ignored`)); + else warn(`unknown activity '${a}' — ignored`); + } picked = want.filter((a) => diverged.some((d) => d.activity === a)); } else if (flags.yes || flags['dry-run']) { // --dry-run never prompts: with nothing else naming a subset, preview the @@ -424,11 +435,18 @@ async function resetRoutes({ flags, cwd }) { ? diverged.map((d) => d.activity) : ans.split(',').map((s) => s.trim()).filter((a) => diverged.some((d) => d.activity === a)); } - if (!picked.length) { info('no routes reset — routes left as they are'); return 0; } + if (!picked.length) { + if (jsonDryRun) previewJson([]); + else info('no routes reset — routes left as they are'); + return 0; + } if (flags['dry-run']) { - info(`would reset ${picked.length} route(s) to the current defaults: ${picked.join(', ')}`); - info('dry run — nothing changed'); + if (jsonDryRun) previewJson(picked); + else { + info(`would reset ${picked.length} route(s) to the current defaults: ${picked.join(', ')}`); + info('dry run — nothing changed'); + } return 0; } @@ -468,11 +486,30 @@ function printOffDryRunSummary(cfg, opts) { if (codexOwned) console.log(' would remove ak-managed Codex MCP wiring'); } -async function off({ cwd, pkgRoot, flags = {} }) { +async function off({ cwd, pkgRoot, flags = /** @type {{ json?: boolean, 'dry-run'?: boolean }} */ ({}) }) { const cfg = loadKitConfig(); if (flags['dry-run']) { - printOffDryRunSummary(cfg, { pkgRoot }); - info('dry run — nothing changed'); + if (flags.json) { + await humanOutputToStderr(() => { + printOffDryRunSummary(cfg, { pkgRoot }); + info('dry run — nothing changed'); + }); + console.log(JSON.stringify({ + dryRun: true, + wouldDisable: HOSTS.filter((h) => cfg.integrations.hosts[h.id]).map((h) => h.id), + primaryHost: DEFAULT_PRIMARY_HOST, + wouldClear: ['aqe provider/fallback', 'ruflo providers', 'activity routing'], + wouldStripManagedProviderEnv: true, + wouldRestoreOrRemoveManagedAqeConfig: true, + wouldReconcileOpencodeGuidance: !!pkgRoot, + wouldTeardownOpencode: !!(cfg.integrations.hosts.opencode || cfg.integrations?.ownership?.opencode), + wouldRemoveManagedCodexMcp: cfg.integrations?.ownership?.codex?.mcp === 'ak' + || cfg.integrations?.ownership?.codex?.reverseMcp === 'ak', + }, null, 2)); + } else { + printOffDryRunSummary(cfg, { pkgRoot }); + info('dry run — nothing changed'); + } return 0; } const codexMcpManaged = cfg.integrations?.ownership?.codex?.mcp === 'ak'; @@ -726,8 +763,9 @@ async function resolvePickDecision(cfg, { const known = new Set([...MANAGED_HOSTS, ...EFFECTIVE_ROUTING]); const unknown = enabled.filter((h) => !known.has(h)); if (unknown.length) { - fail(`unknown host(s): ${unknown.join(', ')} (valid: ${[...known].join(', ')}) — nothing changed`); - return { code: 2 }; + const error = `unknown host(s): ${unknown.join(', ')} (valid: ${[...known].join(', ')}) — nothing changed`; + fail(error); + return { code: 2, error }; } // The routing set needs at least one primary-capable member; OpenCode remains // routable but cannot satisfy that primary-host invariant on its own. @@ -1069,7 +1107,7 @@ async function resolvePickIntentAndPreview({ aqeProviderTypes, aqeChainProviderTypes, }); - if (decision.code !== undefined) return { code: decision.code }; + if (decision.code !== undefined) return decision; const { enabled, routing, primaryHost, aqeProvider, seed, } = decision; @@ -1144,6 +1182,7 @@ export async function pick({ flags, cwd, pkgRoot, migrateRoutes = migrateRetired const outcome = jsonDryRun ? await humanOutputToStderr(resolve) : await resolve(); if (outcome.code !== undefined) { if (outcome.json) console.log(JSON.stringify(outcome.json, null, 2)); + else if (jsonDryRun) console.log(JSON.stringify({ error: outcome.error ?? 'host pick refused', exitCode: outcome.code }, null, 2)); return outcome.code; } const { diff --git a/tests/kit/host-dry-run.test.mjs b/tests/kit/host-dry-run.test.mjs index a7db8ed2..30c226ca 100644 --- a/tests/kit/host-dry-run.test.mjs +++ b/tests/kit/host-dry-run.test.mjs @@ -119,6 +119,74 @@ test('ak host pick --dry-run --json carries previewOfCurrent, true only with no assert.equal(explicitJson.previewOfCurrent, false); }); +test('host pick refusal and x host alias dry-run emit one JSON object without writes', (t) => { + const sb = sandbox(t); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + for (const command of ['host', 'x']) { + const args = command === 'x' ? ['x', 'host'] : ['host']; + const r = ak(sb, ...args, 'pick', '--host', 'claude,opencdoe', '--dry-run', '--json'); + assert.equal(r.status, 2, r.all); + const out = JSON.parse(r.stdout); + assert.deepEqual(Object.keys(out), ['error', 'exitCode']); + assert.equal(out.exitCode, 2); + assert.match(out.error, /unknown host\(s\): opencdoe/); + assert.match(r.stderr, /unknown host\(s\): opencdoe/); + } + assertUnchanged(beforeHome, sb.home, 'refused picks must not touch HOME'); + assertUnchanged(beforeProject, sb.project, 'refused picks must not touch the project'); +}); + +test('host off dry-run JSON previews teardown and preserves configuration', (t) => { + const sb = sandbox(t, divergedConfig()); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + const r = ak(sb, 'host', 'off', '--dry-run', '--json'); + assert.equal(r.status, 0, r.all); + const out = JSON.parse(r.stdout); + assert.equal(out.dryRun, true); + assert.deepEqual(out.wouldDisable, ['claude', 'codex']); + assert.equal(out.primaryHost, 'claude'); + assert.deepEqual(out.wouldClear, ['aqe provider/fallback', 'ruflo providers', 'activity routing']); + assert.equal(out.wouldStripManagedProviderEnv, true); + assert.equal(out.wouldRestoreOrRemoveManagedAqeConfig, true); + assert.equal(out.wouldReconcileOpencodeGuidance, true); + assert.equal(out.wouldTeardownOpencode, false); + assert.equal(out.wouldRemoveManagedCodexMcp, false); + assert.match(r.stderr, /dry run/i); + assertUnchanged(beforeHome, sb.home, 'off preview must not touch HOME'); + assertUnchanged(beforeProject, sb.project, 'off preview must not touch the project'); +}); + +test('host reset-routes dry-run JSON reports selected and empty routes without writes', (t) => { + const sb = sandbox(t, divergedConfig()); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + const selected = ak(sb, 'host', 'reset-routes', '--activity', 'architecture', '--dry-run', '--json'); + assert.equal(selected.status, 0, selected.all); + assert.deepEqual(JSON.parse(selected.stdout), { dryRun: true, activities: ['architecture'] }); + const empty = ak(sb, 'host', 'reset-routes', '--activity', 'design', '--dry-run', '--json'); + assert.equal(empty.status, 0, empty.all); + assert.deepEqual(JSON.parse(empty.stdout), { dryRun: true, activities: [] }); + const invalid = ak(sb, 'host', 'reset-routes', '--activity', 'not-an-activity', '--dry-run', '--json'); + assert.equal(invalid.status, 0, invalid.all); + assert.deepEqual(JSON.parse(invalid.stdout), { dryRun: true, activities: [] }); + assert.match(invalid.stderr, /unknown activity 'not-an-activity'/); + assertUnchanged(beforeHome, sb.home, 'route previews must not touch HOME'); + assertUnchanged(beforeProject, sb.project, 'route previews must not touch the project'); +}); + +test('host reset-routes dry-run JSON reports no divergence as an empty preview', (t) => { + const sb = sandbox(t); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + const r = ak(sb, 'host', 'reset-routes', '--dry-run', '--json'); + assert.equal(r.status, 0, r.all); + assert.deepEqual(JSON.parse(r.stdout), { dryRun: true, activities: [] }); + assertUnchanged(beforeHome, sb.home, 'no-op route preview must not touch HOME'); + assertUnchanged(beforeProject, sb.project, 'no-op route preview must not touch the project'); +}); + test('ak host off --dry-run previews and writes nothing', (t) => { const sb = sandbox(t, divergedConfig()); const beforeHome = snapshot(sb.home); From 43c858d2e2559d93c13c3ddf6640cd9371acb2a2 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 00:17:15 -0700 Subject: [PATCH 04/54] fix(host): avoid status evidence writes in dry runs --- src/commands/x/host.mjs | 2 +- tests/kit/host-dry-run.test.mjs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 13174929..250ca304 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -333,7 +333,7 @@ const STATUS_SECTIONS = [ async function status({ flags, cwd }) { const cfg = loadKitConfig(); - const facts = await collectIntegrationFacts({ cwd, cfg }); + const facts = await collectIntegrationFacts({ cwd, cfg, record: !flags['dry-run'] }); const hosts = facts.hosts; const providers = facts.providers; const { scope } = settingsTarget(cwd); diff --git a/tests/kit/host-dry-run.test.mjs b/tests/kit/host-dry-run.test.mjs index 30c226ca..68fec135 100644 --- a/tests/kit/host-dry-run.test.mjs +++ b/tests/kit/host-dry-run.test.mjs @@ -59,6 +59,23 @@ function ak(sb, ...args) { const readKit = (home) => fs.readFileSync(path.join(home, '.config', 'agentic-kit', 'kit.json'), 'utf8'); +for (const args of [ + ['host', 'status'], ['host'], ['x', 'host'], +]) { + test(`ak ${args.join(' ')} --dry-run --json reports status without recording evidence`, (t) => { + const sb = sandbox(t); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + const r = ak(sb, ...args, '--dry-run', '--json'); + assert.equal(r.status, 0, r.all); + const out = JSON.parse(r.stdout); + assert.deepEqual(Object.keys(out), ['scope', 'config', 'hosts', 'providers']); + assert.ok(out.hosts.claude); + assertUnchanged(beforeHome, sb.home, 'status preview must not write host evidence or config'); + assertUnchanged(beforeProject, sb.project, 'status preview must not write project files'); + }); +} + test('ak host pick --dry-run previews and writes nothing', (t) => { const sb = sandbox(t); const beforeHome = snapshot(sb.home); From 9952e7280404e350bece1b56f166c58309e42e3b Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 00:24:47 -0700 Subject: [PATCH 05/54] fix(versions): throttle offline self retries by channel --- src/lib/versions.mjs | 39 ++++++----- tests/kit/sync-self-freshness.test.mjs | 96 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 18 deletions(-) diff --git a/src/lib/versions.mjs b/src/lib/versions.mjs index 7e541a9a..dadc50a3 100644 --- a/src/lib/versions.mjs +++ b/src/lib/versions.mjs @@ -159,27 +159,23 @@ async function fetchSelfCandidate(tags, cachedBest, fetchLatest) { return { best, observed, answered }; } -/** The self record to save after a lookup, or null to save nothing. A live - * winner is an observation. When every lookup failed, the record stays and - * `last` is restamped, so the next lookup waits one TTL window; `observedAt` - * keeps when the recorded best was seen (a record written before it existed - * takes the previous `last`). A partial answer that leaves a cached candidate - * winning renews nothing. Neither does a failure when the recorded best is - * one this install cannot use (a `next` candidate on a stable install): such - * a record is never fresh, so a restamp would only rewrite kit.json on every - * call, and dropping the candidate would make an empty record look fresh and - * stop the lookup for a TTL window once the registry is back. */ -function selfRecord(cached, usable, { best, observed, answered }, now = Date.now()) { +/** `last` and `observedAt` describe the winning candidate; `attempt` only + * throttles a lookup that could not update it. The attempt's ordered tag set + * prevents a stable-channel retry from suppressing an untried next channel. */ +function selfRecord(cached, usable, { best, observed, answered }, tags, now = Date.now()) { if (observed) return { last: now, best, observedAt: now }; - if (answered || (cached?.best && !usable)) return null; - return { ...cached, last: now, observedAt: cached?.observedAt ?? cached?.last }; + if (answered || (cached?.best && !usable)) { + return { ...cached, attempt: { at: now, tags } }; + } + const { attempt: _priorAttempt, ...prior } = cached ?? {}; + return { ...prior, last: now, observedAt: cached?.observedAt ?? cached?.last }; } /** Look the kit up on its channels; with `record`, save what selfRecord keeps. * Returns the winning candidate. */ async function lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record }) { const candidate = await fetchSelfCandidate(tags, cachedBest, fetchLatest); - const entry = record ? selfRecord(cached, cachedBest, candidate) : null; + const entry = record ? selfRecord(cached, cachedBest, candidate, tags) : null; if (entry) { cfg.versionCheck = { ...cfg.versionCheck, self: entry }; try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ } @@ -191,8 +187,9 @@ async function lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record } * (pkgRoot). Prerelease installs also consult the `next` dist-tag — * prereleases publish there, so `latest` alone would never see them; the * higher of latest/next wins. Cached in kit.json alongside versionCheck. - * Failed lookups preserve eligible cached evidence (see selfRecord); `force` - * retries within the TTL. cacheOnly=true reports the recorded best with no + * Failed lookups preserve eligible cached evidence (see selfRecord); an + * `attempt` stamp limits partial/unusable retries to once per tag set and TTL. + * `force` retries within the TTL. cacheOnly=true reports the recorded best with no * network and no write (`ak sync --skip self`); record=false reports what the * lookup found without saving it (ADR-0063). * @param {{ pkgRoot?: string, force?: boolean, cacheOnly?: boolean, record?: boolean, @@ -208,8 +205,14 @@ export async function selfDrift({ pkgRoot, force = false, cacheOnly = false, rec const tags = installed?.includes('-') ? ['latest', 'next'] : ['latest']; const cachedBest = cached?.best && tags.includes(cached.best.tag) && isValidSemver(cached.best.version) ? cached.best : null; - const fresh = !force && cached?.last && Date.now() - cached.last < ttlMs - && (!cached.best || cachedBest); + const now = Date.now(); + const attempt = cached?.attempt; + const attemptFresh = Number.isSafeInteger(attempt?.at) && attempt.at > 0 && attempt.at <= now + && Array.isArray(attempt.tags) && attempt.tags.length === tags.length + && tags.every((tag, index) => attempt.tags[index] === tag) + && now - attempt.at < ttlMs; + const fresh = !force && ((cached?.last && now - cached.last < ttlMs + && (!cached.best || cachedBest)) || attemptFresh); const best = fresh || cacheOnly ? cachedBest : await lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record }); return { pkg: KIT_PKG, diff --git a/tests/kit/sync-self-freshness.test.mjs b/tests/kit/sync-self-freshness.test.mjs index 6d726340..1dad2770 100644 --- a/tests/kit/sync-self-freshness.test.mjs +++ b/tests/kit/sync-self-freshness.test.mjs @@ -113,6 +113,40 @@ test('a failed next lookup retains its cached candidate without claiming a fresh assert.equal(loadKitConfig().versionCheck.self.last, 1); }); +test('partial self answers retry once per TTL without renewing the cached observation', async t => { + seed(); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 100, observedAt: 80, best: { version: '4.0.0-alpha.50', tag: 'next' } }; + writeKitConfig(home, cfg); + let now = 200_000_000; + t.mock.method(Date, 'now', () => now); + const tags = []; + const fetchLatest = async (_pkg, tag) => { tags.push(tag); return tag === 'latest' ? '4.0.0-alpha.0' : null; }; + const first = await selfDrift({ pkgRoot, fetchLatest }); + assert.deepEqual([first.latest, first.tag], ['4.0.0-alpha.50', 'next']); + assert.deepEqual(loadKitConfig().versionCheck.self, { + last: 100, observedAt: 80, best: { version: '4.0.0-alpha.50', tag: 'next' }, + attempt: { at: now, tags: ['latest', 'next'] }, + }); + const afterFirst = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + now += 1000; + assert.equal((await selfDrift({ pkgRoot, fetchLatest })).latest, first.latest); + assert.deepEqual(tags, ['latest', 'next']); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), afterFirst); + await selfDrift({ pkgRoot, force: true, fetchLatest }); + assert.deepEqual(tags, ['latest', 'next', 'latest', 'next']); + now += 24 * 3600_000; + await selfDrift({ pkgRoot, fetchLatest }); + assert.deepEqual(tags, ['latest', 'next', 'latest', 'next', 'latest', 'next']); + assert.equal(loadKitConfig().versionCheck.self.observedAt, 80); + now += 1000; + const recovered = await selfDrift({ pkgRoot, force: true, fetchLatest: async (_pkg, tag) => + tag === 'next' ? '4.0.0-alpha.51' : null }); + assert.equal(recovered.latest, '4.0.0-alpha.51'); + assert.deepEqual(loadKitConfig().versionCheck.self, + { last: now, observedAt: now, best: { version: '4.0.0-alpha.51', tag: 'next' } }); +}); + test('stable installs reject cached next-channel candidates when latest is unavailable', async () => { seed('4.0.0'); const cfg = loadKitConfig(); @@ -145,6 +179,68 @@ test('a stable install whose record holds only a next-channel candidate is not r assert.deepEqual(loadKitConfig().versionCheck.self.best, { version: '5.0.0-alpha.1', tag: 'next' }); }); +test('offline self attempts are scoped to tags and do not persist in read-only modes', async t => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 100, observedAt: 80, best: { version: '5.0.0-alpha.1', tag: 'next' } }; + writeKitConfig(home, cfg); + let now = 200_000_000; + t.mock.method(Date, 'now', () => now); + const tags = []; + const fetchLatest = async (_pkg, tag) => { tags.push(tag); return null; }; + assert.equal((await selfDrift({ pkgRoot, fetchLatest })).latest, null); + assert.deepEqual(loadKitConfig().versionCheck.self, { + last: 100, observedAt: 80, best: { version: '5.0.0-alpha.1', tag: 'next' }, + attempt: { at: now, tags: ['latest'] }, + }); + const afterFirst = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + await selfDrift({ pkgRoot, fetchLatest }); + assert.deepEqual(tags, ['latest']); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), afterFirst); + await selfDrift({ pkgRoot, force: true, fetchLatest }); + assert.deepEqual(tags, ['latest', 'latest']); + fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version: '4.0.0-alpha.1' })); + await selfDrift({ pkgRoot, fetchLatest }); + assert.deepEqual(tags, ['latest', 'latest', 'latest', 'next'], 'the untried channel is probed'); + const beforeReadOnly = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + await selfDrift({ pkgRoot, force: true, record: false, fetchLatest }); + assert.deepEqual(tags.slice(-2), ['latest', 'next']); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), beforeReadOnly); + await selfDrift({ pkgRoot, force: true, cacheOnly: true, fetchLatest }); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), beforeReadOnly); + assert.equal(tags.length, 6); + now += 24 * 3600_000; + await selfDrift({ pkgRoot, fetchLatest }); + assert.equal(tags.length, 8); +}); + +test('malformed self attempt stamps cannot suppress an offline retry', async t => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { + last: 100, best: { version: '5.0.0-alpha.1', tag: 'next' }, + attempt: { at: Number.MAX_SAFE_INTEGER, tags: ['latest'] }, + }; + writeKitConfig(home, cfg); + t.mock.method(Date, 'now', () => 200_000_000); + let calls = 0; + await selfDrift({ pkgRoot, fetchLatest: async () => { calls += 1; return null; } }); + assert.equal(calls, 1); + assert.deepEqual(loadKitConfig().versionCheck.self.attempt, { at: 200_000_000, tags: ['latest'] }); + for (const attempt of [ + { at: '200000000', tags: ['latest'] }, + { at: 200_000_000.5, tags: ['latest'] }, + { at: 200_000_000, tags: ['next'] }, + { at: 200_000_000, tags: 'latest' }, + ]) { + const next = loadKitConfig(); + next.versionCheck.self.attempt = attempt; + writeKitConfig(home, next); + await selfDrift({ pkgRoot, fetchLatest: async () => { calls += 1; return null; } }); + } + assert.equal(calls, 5); +}); + test('successful registry observations supersede cached versions even after a channel rollback', async () => { seed(); const cfg = loadKitConfig(); From 175677a682ec028c775eb1afe440add846827527 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 00:31:32 -0700 Subject: [PATCH 06/54] fix(versions): scope self cache freshness to checked tags --- src/lib/versions.mjs | 29 ++++++--- .../kit/status-version-drift-refresh.test.mjs | 2 +- tests/kit/sync-self-freshness.test.mjs | 65 ++++++++++++++++++- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/lib/versions.mjs b/src/lib/versions.mjs index dadc50a3..8617c98b 100644 --- a/src/lib/versions.mjs +++ b/src/lib/versions.mjs @@ -159,18 +159,21 @@ async function fetchSelfCandidate(tags, cachedBest, fetchLatest) { return { best, observed, answered }; } -/** `last` and `observedAt` describe the winning candidate; `attempt` only - * throttles a lookup that could not update it. The attempt's ordered tag set - * prevents a stable-channel retry from suppressing an untried next channel. */ +/** `observedAt` describes the winning candidate; `last` and `lastTags` scope + * the latest completed check. `attempt` throttles a lookup that could not + * update that check. Neither stamp makes a cached winner newly observed. */ function selfRecord(cached, usable, { best, observed, answered }, tags, now = Date.now()) { - if (observed) return { last: now, best, observedAt: now }; + if (observed) return { last: now, best, observedAt: now, lastTags: tags }; if (answered || (cached?.best && !usable)) { return { ...cached, attempt: { at: now, tags } }; } const { attempt: _priorAttempt, ...prior } = cached ?? {}; - return { ...prior, last: now, observedAt: cached?.observedAt ?? cached?.last }; + return { ...prior, last: now, observedAt: cached?.observedAt ?? cached?.last, lastTags: tags }; } +const sameTags = (recorded, tags) => Array.isArray(recorded) && recorded.length === tags.length + && tags.every((tag, index) => recorded[index] === tag); + /** Look the kit up on its channels; with `record`, save what selfRecord keeps. * Returns the winning candidate. */ async function lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record }) { @@ -189,7 +192,8 @@ async function lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record } * higher of latest/next wins. Cached in kit.json alongside versionCheck. * Failed lookups preserve eligible cached evidence (see selfRecord); an * `attempt` stamp limits partial/unusable retries to once per tag set and TTL. - * `force` retries within the TTL. cacheOnly=true reports the recorded best with no + * `lastTags` scopes completed checks. `force` retries within the TTL. + * cacheOnly=true reports the recorded best with no * network and no write (`ak sync --skip self`); record=false reports what the * lookup found without saving it (ADR-0063). * @param {{ pkgRoot?: string, force?: boolean, cacheOnly?: boolean, record?: boolean, @@ -208,11 +212,16 @@ export async function selfDrift({ pkgRoot, force = false, cacheOnly = false, rec const now = Date.now(); const attempt = cached?.attempt; const attemptFresh = Number.isSafeInteger(attempt?.at) && attempt.at > 0 && attempt.at <= now - && Array.isArray(attempt.tags) && attempt.tags.length === tags.length - && tags.every((tag, index) => attempt.tags[index] === tag) + && sameTags(attempt.tags, tags) && now - attempt.at < ttlMs; - const fresh = !force && ((cached?.last && now - cached.last < ttlMs - && (!cached.best || cachedBest)) || attemptFresh); + // Older records have no scope: a `next` winner proves both tags were tried; + // otherwise only the single latest channel is safe to reuse. + const lastScope = cached?.lastTags === undefined + ? (tags.length === 1 || cached?.best?.tag === 'next') + : sameTags(cached.lastTags, tags); + const lastFresh = Number.isSafeInteger(cached?.last) && cached.last > 0 && cached.last <= now + && now - cached.last < ttlMs && lastScope && (!cached.best || cachedBest); + const fresh = !force && (lastFresh || attemptFresh); const best = fresh || cacheOnly ? cachedBest : await lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record }); return { pkg: KIT_PKG, diff --git a/tests/kit/status-version-drift-refresh.test.mjs b/tests/kit/status-version-drift-refresh.test.mjs index 0f9bc8f7..88dd9dc3 100644 --- a/tests/kit/status-version-drift-refresh.test.mjs +++ b/tests/kit/status-version-drift-refresh.test.mjs @@ -151,7 +151,7 @@ function seedSelfHome({ ageMs = 0 } = {}) { ttlHours: 24, last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' }, - self: { last: Date.now() - ageMs, best: { version: '0.0.1', tag: 'latest' } }, + self: { last: Date.now() - ageMs, best: { version: '0.0.1', tag: 'latest' }, lastTags: ['latest', 'next'] }, }, }); writeKitConfig(HOME, cfg); diff --git a/tests/kit/sync-self-freshness.test.mjs b/tests/kit/sync-self-freshness.test.mjs index 1dad2770..2bfce5d2 100644 --- a/tests/kit/sync-self-freshness.test.mjs +++ b/tests/kit/sync-self-freshness.test.mjs @@ -144,7 +144,70 @@ test('partial self answers retry once per TTL without renewing the cached observ tag === 'next' ? '4.0.0-alpha.51' : null }); assert.equal(recovered.latest, '4.0.0-alpha.51'); assert.deepEqual(loadKitConfig().versionCheck.self, - { last: now, observedAt: now, best: { version: '4.0.0-alpha.51', tag: 'next' } }); + { last: now, observedAt: now, best: { version: '4.0.0-alpha.51', tag: 'next' }, lastTags: ['latest', 'next'] }); +}); + +test('a stable lookup cannot make an untried prerelease channel fresh', async t => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 100, observedAt: 80, best: { version: '4.0.0', tag: 'latest' } }; + writeKitConfig(home, cfg); + let now = 200_000_000; + t.mock.method(Date, 'now', () => now); + const stableTags = []; + await selfDrift({ pkgRoot, fetchLatest: async (_pkg, tag) => { + stableTags.push(tag); return null; + } }); + assert.deepEqual(stableTags, ['latest']); + const stable = loadKitConfig().versionCheck.self; + assert.deepEqual(stable, { + last: now, observedAt: 80, best: { version: '4.0.0', tag: 'latest' }, lastTags: ['latest'], + }); + fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version: '4.0.0-alpha.1' })); + now += 1000; + const prereleaseTags = []; + const offline = async (_pkg, tag) => { prereleaseTags.push(tag); return null; }; + const first = await selfDrift({ pkgRoot, fetchLatest: offline }); + assert.deepEqual(prereleaseTags, ['latest', 'next']); + assert.deepEqual([first.latest, first.tag], ['4.0.0', 'latest']); + const afterFirst = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + await selfDrift({ pkgRoot, fetchLatest: offline }); + assert.deepEqual(prereleaseTags, ['latest', 'next']); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), afterFirst); + assert.equal(loadKitConfig().versionCheck.self.observedAt, 80); +}); + +test('a legacy latest-only record does not suppress an untried next channel', async t => { + seed('4.0.0-alpha.1'); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 200_000_000, observedAt: 100, best: { version: '4.0.0', tag: 'latest' } }; + writeKitConfig(home, cfg); + t.mock.method(Date, 'now', () => 200_001_000); + const tags = []; + await selfDrift({ pkgRoot, fetchLatest: async (_pkg, tag) => { tags.push(tag); return null; } }); + assert.deepEqual(tags, ['latest', 'next']); + assert.equal(loadKitConfig().versionCheck.self.observedAt, 100); +}); + +test('a successful stable observation also leaves next untried after a channel switch', async t => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self.last = 100; + writeKitConfig(home, cfg); + let now = 200_000_000; + t.mock.method(Date, 'now', () => now); + await selfDrift({ pkgRoot, fetchLatest: async () => '4.0.1' }); + assert.deepEqual(loadKitConfig().versionCheck.self.lastTags, ['latest']); + assert.equal(loadKitConfig().versionCheck.self.observedAt, now); + fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version: '4.0.0-alpha.1' })); + now += 1000; + const tags = []; + const result = await selfDrift({ pkgRoot, fetchLatest: async (_pkg, tag) => { + tags.push(tag); return tag === 'next' ? '4.1.0-alpha.1' : null; + } }); + assert.deepEqual(tags, ['latest', 'next']); + assert.deepEqual([result.latest, result.tag], ['4.1.0-alpha.1', 'next']); + assert.deepEqual(loadKitConfig().versionCheck.self.lastTags, ['latest', 'next']); }); test('stable installs reject cached next-channel candidates when latest is unavailable', async () => { From f0cba7b53021f32334bf3b006de93f8bf4ed6cfe Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 00:41:46 -0700 Subject: [PATCH 07/54] test(maintain): guard injected refresh construction --- .../maintenance-refresh-injection.test.mjs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/kit/maintenance-refresh-injection.test.mjs diff --git a/tests/kit/maintenance-refresh-injection.test.mjs b/tests/kit/maintenance-refresh-injection.test.mjs new file mode 100644 index 00000000..03a363fe --- /dev/null +++ b/tests/kit/maintenance-refresh-injection.test.mjs @@ -0,0 +1,78 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +// The Maintenance service statically imports footprint/index.mjs, so blocking +// that module's import would reject a valid injected run. Instrument its real +// constructor instead; the management facade is lazy and must never import. +const guard = ` +export async function resolve(specifier, context, nextResolve) { + if (specifier.endsWith('/maintenance/management/service.mjs')) { + throw new Error('real management facade imported'); + } + return nextResolve(specifier, context); +} +export async function load(url, context, nextLoad) { + const loaded = await nextLoad(url, context); + if (!url.endsWith('/footprint/index.mjs')) return loaded; + const needle = ' const collect = {'; + const source = String(loaded.source); + if (source.split(needle).length !== 2) throw new Error('collector constructor guard no longer matches'); + return { ...loaded, source: source.replace(needle, + " throw new Error('real footprint collector constructed');\\n" + needle) }; +} +`; + +const child = ` +import assert from 'node:assert/strict'; +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; +register('data:text/javascript,' + encodeURIComponent(process.env.AK_REFRESH_GUARD)); +const { run } = await import(pathToFileURL(process.env.AK_MAINTAIN_MODULE).href); +const calls = []; +const refreshStages = { + maintenance: async () => { calls.push('maintenance'); return { ok: true }; }, + inventory: async () => { calls.push('inventory'); return { ok: true }; }, + local: async () => { calls.push('local'); return { ok: true }; }, +}; +const service = { async report() { calls.push('report'); return { mode: 'read-only' }; } }; +const lines = []; +const originalLog = console.log; +console.log = (...args) => lines.push(args.join(' ')); +let code; +try { + code = await run({ flags: { json: true, refresh: '' }, positionals: ['report'], + deps: { refreshStages, service } }); +} finally { + console.log = originalLog; +} +assert.equal(code, 0, lines.join('\\n')); +assert.deepEqual(calls, ['maintenance', 'inventory', 'local', 'report']); +const result = JSON.parse(lines.join('\\n')); +assert.equal(result.mode, 'read-only'); +assert.equal(result.refresh.ok, true); +assert.deepEqual(result.refresh.stages.map(({ id }) => id), ['maintenance', 'inventory', 'local']); +originalLog('injected refresh used no real constructors'); +`; + +test('injected maintain refresh constructs no real collector or management facade', (t) => { + const home = tempDir('ak-maintain-injected-home', t); + const project = tempDir('ak-maintain-injected-project', t); + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', child], { + cwd: project, + env: spawnEnv(home, { + AK_MAINTAIN_MODULE: path.join(ROOT, 'src/commands/maintain.mjs'), + AK_REFRESH_GUARD: guard, + }), + encoding: 'utf8', + }); + assert.ifError(result.error); + assert.equal(result.status, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`); + assert.match(result.stdout, /injected refresh used no real constructors/); +}); From b94d19f5f9dbde61a67fbddc62afd1d31d1d8478 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 01:10:33 -0700 Subject: [PATCH 08/54] fix(setup): isolate memory probe native mirror --- docs/plans/2026-09-28-follow-ups-v2.md | 4 +- src/commands/setup.mjs | 56 +++++++++---- tests/kit/setup-memory-probe.test.mjs | 112 +++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index 7c1bc897..04b7dfb0 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -2,7 +2,7 @@ ## Status -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. +**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. Other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. @@ -23,7 +23,7 @@ The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-u | B9 | `src/lib/exec.mjs`, `execution/process-tree.mjs` | `tests/kit/process-tree.test.mjs`; abort kills descendants; Windows CI required | | B10 | `src/lib/maintenance/discovery/partitions.mjs`; inventory `src/lib/live/jsonl-tailer.mjs`, `live/transcript-streams.mjs`, `telemetry/store.mjs`, `maintenance/management/service-store.mjs` for additional persisted IDs | `tests/kit/file-identity-bigint.test.mjs`; distinguish IDs above `2^53`; enumerate the exact sites before edit | | B11 | `src/lib/live-checks.mjs` | `tests/kit/live-checks.test.mjs`; skipped deja-vu check says skipped and check-created temp folders are cleaned | -| B12 | `src/commands/setup.mjs`; `src/lib/memory-probe-cleanup.mjs` | `tests/kit/setup-memory-probe.test.mjs`; disposable real Ruflo reproduction first, fix only if unused `agentdb-memory.db` appears | +| B12 | `src/commands/setup.mjs`; `src/lib/memory-probe-cleanup.mjs` unchanged | **Fixed:** `tests/kit/setup-memory-probe.test.mjs`; Ruflo 3.48.0 seeded reproduction created an unused native side file, and a disposable candidate run confirmed a private mirror leaves no canonical side file or probe row | | B13 | `src/commands/sync.mjs`; `src/lib/aqe-project-pin.mjs` | `tests/kit/sync-command.test.mjs`, `aqe-project-pin.test.mjs`; only if program §2 step 2 shows sync omitted the AQE pin | | C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/live/ruflo-memory-routing.test.mjs`, `tests/kit/aqe-readiness.test.mjs`; disposable macOS and temporary Linux/Windows CI busy-rule evidence; remove temporary job before merge; #240 action follows result | | C2 | `docs/host-support.md`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/ruflo-support-window.test.mjs` plus link check; verify AQE 3.14.4 #528/#532/#535 and Ruflo #2356/#420 first | diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 772294d5..cbb2629a 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -5,6 +5,7 @@ // Project scope (when run inside a git repo / --project): the port of // ruflo-setup-project — init, sanitize, pin, activate, verify, daemon. import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline/promises'; import { run as runCmd, have } from '../lib/exec.mjs'; @@ -602,25 +603,52 @@ export async function startProjectDaemon(root, { } else warn('daemon failed to start — try: ruflo daemon start'); } -/** Step 7: write-verification (store → actual on-disk row, then clean up). - * The CLI mirrors the write into agentdb-memory.db under the memory root - * (CLAUDE_FLOW_MEMORY_PATH, else a config persistPath, else /.swarm). - * The probe pins that root beside the pinned memory.db, so both copies land - * in the stores cleanup checks even when the project's root is redirected; - * this is the user's real corpus, so nothing of the probe may be left behind. */ +/** Step 7: verify a real primary write. Ruflo also writes a native mirror + * under CLAUDE_FLOW_MEMORY_PATH; keep that disposable mirror in a private + * directory so setup cannot create a spare project store. */ export async function verifyProjectMemoryWrite(root, env, { runner = runCmd } = {}) { const probeKey = `_setup/verify-${process.pid}-${Date.now()}`; - const probeEnv = { ...env, CLAUDE_FLOW_MEMORY_PATH: path.dirname(env?.CLAUDE_FLOW_DB_PATH ?? paths.projectMemoryDb(root)) }; - const stored = (await runner('ruflo', ['memory', 'store', '-k', probeKey, '--value', 'setup-verify', '-n', '_setup'], { cwd: root, env: probeEnv })).code === 0; - const landed = stored ? findMemoryEntry(root, '_setup', probeKey) : null; - if (!landed) { + let mirrorDir; + let stored = false; + let landed = null; + let cleanup; + let runnerError = false; + let mirrorCleanupError = false; + try { + mirrorDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-setup-memory-probe-')); + const probeEnv = { + ...env, + CLAUDE_FLOW_DB_PATH: env?.CLAUDE_FLOW_DB_PATH ?? paths.projectMemoryDb(root), + CLAUDE_FLOW_MEMORY_PATH: mirrorDir, + RUFLO_DAEMON_AUTOSTART: '0', + }; + stored = (await runner('ruflo', ['memory', 'store', '-k', probeKey, '--value', 'setup-verify', '-n', '_setup'], { cwd: root, env: probeEnv })).code === 0; + } catch { + runnerError = true; + } finally { + // A failed command may still have written its primary row. Keep the + // existing refusal behavior for unreadable or busy project stores. + if (mirrorDir) { + try { + landed = findMemoryEntry(root, '_setup', probeKey); + cleanup = removeMemoryProbe(root, '_setup', probeKey); + } catch { + runnerError = true; + } finally { + try { fs.rmSync(mirrorDir, { recursive: true, maxRetries: 3 }); } + catch { mirrorCleanupError = true; } + } + } + } + if (cleanup?.failed.length) { + warn(`memory probe cleanup failed in ${cleanup.failed.map((f) => `${path.basename(f.file)} (${f.kind})`).join(', ')} — remove ${probeKey} from _setup manually`); + } + if (mirrorCleanupError) warn(`memory probe temporary mirror cleanup failed at ${mirrorDir} — inspect it manually`); + if (!stored || !landed || runnerError || cleanup?.failed.length || mirrorCleanupError) { fail('memory write verification FAILED — run: ak status / ruflo doctor -c memory'); return; } - const cleanup = removeMemoryProbe(root, '_setup', probeKey); - if (cleanup.failed.length) { - warn(`memory write verified, but probe cleanup failed in ${cleanup.failed.map((f) => `${path.basename(f.file)} (${f.kind})`).join(', ')} — remove ${probeKey} from _setup manually`); - } else ok(`memory write VERIFIED (store → ${path.basename(landed.file)} row confirmed)`); + ok(`memory write VERIFIED (store → ${path.basename(landed.file)} row confirmed)`); } function reportProjectGuidance(result) { diff --git a/tests/kit/setup-memory-probe.test.mjs b/tests/kit/setup-memory-probe.test.mjs index d477d976..e9842afd 100644 --- a/tests/kit/setup-memory-probe.test.mjs +++ b/tests/kit/setup-memory-probe.test.mjs @@ -10,6 +10,7 @@ import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { verifyProjectMemoryWrite } from '../../src/commands/setup.mjs'; import { captureLog } from './helpers/home-sandbox.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; function store(file) { fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -109,3 +110,114 @@ test('the setup probe leaves nothing in a redirected memory root', async (t) => assert.deepEqual(keys(path.join(redirected, 'agentdb-memory.db')), ['user-row'], 'the redirected MCP store kept a setup probe'); for (const name of ['memory.db', 'agentdb-memory.db']) assert.deepEqual(keys(path.join(root, '.swarm', name)), ['user-row']); }); + +// Ruflo 3.48 writes its native mirror under CLAUDE_FLOW_MEMORY_PATH even when +// CLAUDE_FLOW_DB_PATH points elsewhere. The fake follows those two routes. +function routedProject(t) { + const root = tempDir('ak-setup-route', t); + const primary = path.join(root, '.swarm', 'memory.db'); + const canonical = path.join(root, '.swarm', 'agentdb-memory.db'); + const redirected = path.join(root, 'data', 'memory', 'agentdb-memory.db'); + for (const file of [primary, canonical, redirected]) { + const db = store(file); + db.prepare("INSERT INTO memory_entries VALUES ('real', 'user-row', 'active')").run(); + db.close(); + } + const mirrors = []; + const runner = async (_cmd, args, { env }) => { + const key = args[args.indexOf('-k') + 1]; + assert.equal(env.RUFLO_DAEMON_AUTOSTART, '0'); + assert.equal(env.CLAUDE_FLOW_DB_PATH, primary); + const mirror = path.join(env.CLAUDE_FLOW_MEMORY_PATH, 'agentdb-memory.db'); + mirrors.push(mirror); + for (const file of [env.CLAUDE_FLOW_DB_PATH, mirror]) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + db.exec('CREATE TABLE IF NOT EXISTS memory_entries (namespace TEXT, key TEXT, status TEXT)'); + db.prepare("INSERT INTO memory_entries VALUES ('_setup', ?, 'active')").run(key); + db.close(); + } + return { code: 0, stdout: '', stderr: '' }; + }; + return { root, primary, canonical, redirected, mirrors, runner }; +} + +test('setup verifies the primary while removing its private mirror and preserving native corpora', async (t) => { + const p = routedProject(t); + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, { CLAUDE_FLOW_DB_PATH: p.primary, + CLAUDE_FLOW_MEMORY_PATH: path.dirname(p.redirected) }, { runner: p.runner })); + assert.match(out, /memory write VERIFIED/); + assert.deepEqual(keys(p.primary), ['user-row']); + assert.deepEqual(keys(p.canonical), ['user-row']); + assert.deepEqual(keys(p.redirected), ['user-row']); + assert.equal(p.mirrors.length, 1); + assert.equal(fs.existsSync(path.dirname(p.mirrors[0])), false); +}); + +test('setup pins the primary when caller env is absent and removes the mirror on a failed store', async (t) => { + const p = routedProject(t); + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, undefined, { + runner: async (cmd, args, options) => { + await p.runner(cmd, args, options); + return { code: 1, stdout: '', stderr: 'failed after write' }; + }, + })); + assert.match(out, /memory write verification FAILED/); + assert.doesNotMatch(out, /memory write VERIFIED/); + assert.deepEqual(keys(p.canonical), ['user-row']); + assert.deepEqual(keys(p.redirected), ['user-row']); + assert.equal(fs.existsSync(path.dirname(p.mirrors[0])), false); +}); + +test('setup removes its private mirror after a runner throws', async (t) => { + const p = routedProject(t); + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, {}, { + runner: async (cmd, args, options) => { + await p.runner(cmd, args, options); + throw new Error('runner failed'); + }, + })); + assert.match(out, /memory write verification FAILED/); + assert.equal(fs.existsSync(path.dirname(p.mirrors[0])), false); + assert.deepEqual(keys(p.canonical), ['user-row']); +}); + +test('setup reports a missing primary row and removes the private mirror', async (t) => { + const p = routedProject(t); + let mirror; + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, {}, { + runner: async (_cmd, _args, { env }) => { + mirror = env.CLAUDE_FLOW_MEMORY_PATH; + const db = store(path.join(mirror, 'agentdb-memory.db')); + db.close(); + return { code: 0, stdout: '', stderr: '' }; + }, + })); + assert.match(out, /memory write verification FAILED/); + assert.doesNotMatch(out, /memory write VERIFIED/); + assert.equal(fs.existsSync(mirror), false); + assert.deepEqual(keys(p.primary), ['user-row']); +}); + +test('setup does not claim complete verification when private mirror removal fails', { skip: process.platform === 'win32' }, async (t) => { + const p = routedProject(t); + let mirror; + t.after(() => { + if (mirror && fs.existsSync(mirror)) { + fs.chmodSync(mirror, 0o700); + fs.rmSync(mirror, { recursive: true, force: true }); + } + }); + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, {}, { + runner: async (cmd, args, options) => { + await p.runner(cmd, args, options); + mirror = options.env.CLAUDE_FLOW_MEMORY_PATH; + fs.chmodSync(mirror, 0o000); + return { code: 0, stdout: '', stderr: '' }; + }, + })); + assert.match(out, /temporary mirror cleanup failed/); + assert.match(out, /memory write verification FAILED/); + assert.doesNotMatch(out, /memory write VERIFIED/); + assert.deepEqual(keys(p.primary), ['user-row']); +}); From 06ffcaf128fab2ddeb9ca0ae88b5c2b847564147 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 01:23:35 -0700 Subject: [PATCH 09/54] test(evidence): prove repair commands re-record fresh facts --- docs/plans/2026-09-28-follow-ups-v2.md | 2 +- src/commands/setup.mjs | 20 +++++---- src/commands/x/daemon-gc.mjs | 16 ++++--- src/commands/x/host.mjs | 21 +++++---- tests/kit/daemon-gc-rerecord.test.mjs | 62 ++++++++++++++++++++++++++ tests/kit/host-pick-rerecord.test.mjs | 57 +++++++++++++++++++++++ tests/kit/setup-host-rerecord.test.mjs | 51 +++++++++++++++++++++ 7 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 tests/kit/daemon-gc-rerecord.test.mjs create mode 100644 tests/kit/host-pick-rerecord.test.mjs create mode 100644 tests/kit/setup-host-rerecord.test.mjs diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index 04b7dfb0..973dd466 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -2,7 +2,7 @@ ## Status -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. Other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. +**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 is implemented in the isolated `fix/follow-ups-v2-rest` branch, pending independent review and integration. Other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index cbb2629a..fc649db6 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -382,21 +382,24 @@ function deployTokenAuditSkill(pkgRoot) { * left alone. Shares HOSTS/hostInstallState/installHost with `ak sync`'s * and `ak host pick`'s own host-install loops; the interactive confirmation * here (vs. their unconditional install) is this command's own UX. */ -async function installEnabledAbsentHosts(cfg, flags) { +export async function installEnabledAbsentHosts(cfg, flags, lifecycle = {}) { + const { installState, install, collectFacts } = { + installState: hostInstallState, install: installHost, collectFacts: collectIntegrationFacts, ...lifecycle, + }; let installed = false; for (const h of HOSTS) { if (!cfg.integrations?.hosts?.[h.id]) continue; - const st = await hostInstallState(h); + const st = await installState(h); if (st.method === 'absent') { if (await ask(`${h.id} CLI not found — install ${h.pkg} globally?`, true, flags.yes)) { - const r = await installHost(h.id); + const r = await install(h.id); (r.ok ? ok : warn)(`${h.id}: ${r.detail}`); if (r.ok) { installed = true; // hostInstallState() above already recorded the pre-install // 'absent' evidence; re-probe now so a subsequent `ak status` // doesn't read that stale row back. - await hostInstallState(h, { refresh: true, record: true, source: 'setup' }); + await installState(h, { refresh: true, record: true, source: 'setup' }); } } else warn(`${h.id} not installed — enable/install later with: ak host pick`); } else { @@ -405,7 +408,7 @@ async function installEnabledAbsentHosts(cfg, flags) { } // host-setup covers every host in one call; refresh it once after the // loop, not per host, once anything actually changed. - if (installed) await collectIntegrationFacts({ cfg, refresh: true, record: true, source: 'setup' }); + if (installed) await collectFacts({ cfg, refresh: true, record: true, source: 'setup' }); } /** Step 6b: host lifecycle wiring — connected MCPs, compact lazy gateway, @@ -468,7 +471,7 @@ async function printUndetectedHostHints(cfg) { } } -export async function run_machine({ flags, pkgRoot, cfg }) { +export async function run_machine({ flags, pkgRoot, cfg, deps = { hostLifecycle: undefined } }) { heading('machine setup'); if (flags['dry-run']) { info('dry-run: would ensure packages (incl. agent-browser and ruvnet-brain), deploy skill (blocks + MCP land in the final pass)'); return true; } @@ -480,7 +483,7 @@ export async function run_machine({ flags, pkgRoot, cfg }) { // key on `codex` being on PATH / dual-mode enablement). Running them here // warned + drifted on genuinely bare machines. deployTokenAuditSkill(pkgRoot); - await installEnabledAbsentHosts(cfg, flags); + await installEnabledAbsentHosts(cfg, flags, deps.hostLifecycle); if (!(await applyMachineHostLifecycles(cfg, pkgRoot))) return false; if (cfg.codexContext && cfg.integrations?.hosts?.codex) { try { await manageCodexContext(cfg, { persist: saveKitConfig }); } @@ -954,6 +957,7 @@ async function applySetupCodexRepairs(flags, repairPlan, cwd, repairTopology) { export async function run({ flags, pkgRoot, confirm = ask, dejaVuLifecycle = DEFAULT_DEJA_VU_LIFECYCLE, + deps = { hostLifecycle: undefined }, ...runtimeOverrides }) { const runtime = { ...DEFAULT_SETUP_RUNTIME, ...runtimeOverrides }; @@ -994,7 +998,7 @@ export async function run({ flags, cfg, hostFlags, companionPreflight, dejaVuFlagsResult, }); - if (!(await runtime.machineSetup({ flags, pkgRoot, cfg }))) return 1; + if (!(await runtime.machineSetup({ flags, pkgRoot, cfg, deps }))) return 1; if (!flags['dry-run'] && cfg.aqe !== false) { // Persist the selected choice even on failure, so retry/sync has an exact plan. saveKitConfig(cfg); diff --git a/src/commands/x/daemon-gc.mjs b/src/commands/x/daemon-gc.mjs index 69a0b13c..8a1e691a 100644 --- a/src/commands/x/daemon-gc.mjs +++ b/src/commands/x/daemon-gc.mjs @@ -32,15 +32,19 @@ Examples: ak x daemon-gc --kill reap stale background daemons ak x daemon-gc --mcp --kill also reap same-user PPID-1 MCP orphans`; -export async function run({ flags, positionals = [] }) { +export async function run({ flags, positionals = [], deps = { daemonLifecycle: undefined, mcpLifecycle: undefined } }) { if (positionals.length) { const error = `unexpected argument '${positionals[0]}'`; reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); return 2; } - const daemons = await listDaemons(); + const { list, reap: reapFn } = { list: listDaemons, reap, ...deps.daemonLifecycle }; + const { list: listMcp, reap: reapMcp } = { + list: listMcpTransports, reap: reapMcpTransports, ...deps.mcpLifecycle, + }; + const daemons = await list(); const stale = staleDaemons(daemons); - const mcpTransports = await listMcpTransports(); + const mcpTransports = await listMcp(); const mcpOrphans = orphanedMcpTransports(mcpTransports); if (flags.json) { console.log(JSON.stringify({ @@ -52,14 +56,14 @@ export async function run({ flags, positionals = [] }) { return 0; } if (stale.length && flags.kill) { - const reaped = reap(stale); + const reaped = reapFn(stale); for (const r of reaped) { if (r.killed) ok(`stopped stale daemon pid=${r.pid} ${dim(r.workspace ?? '')}`); else warn(`could not stop pid=${r.pid} (already exited?)`); } // Refresh daemon-sweep evidence so a later read sees the daemons that are // actually still alive, not the pre-reap list. - if (reaped.some((r) => r.killed)) await listDaemons({ refresh: true, record: true, source: 'daemon-gc' }); + if (reaped.some((r) => r.killed)) await list({ refresh: true, record: true, source: 'daemon-gc' }); } else if (stale.length) { for (const d of stale) { warn(`stale daemon pid=${d.pid} ${dim(d.workspace ?? '(unknown workspace)')} ${dim(d.workspaceExists ? `age ${d.ageSecs}s > TTL` : 'workspace gone')}`); @@ -68,7 +72,7 @@ export async function run({ flags, positionals = [] }) { } if (flags.mcp && flags.kill) { - for (const result of reapMcpTransports(mcpOrphans)) { + for (const result of reapMcp(mcpOrphans)) { if (result.killed) ok(`stopped orphaned Ruflo MCP pid=${result.pid}`); else warn(`could not stop MCP pid=${result.pid} (identity or orphan proof changed)`); } diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 250ca304..8f4ec772 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -181,7 +181,7 @@ export const parseFallback = (str) => str.split(';').map((s) => s.trim()).filter return { provider: provider.trim().toLowerCase(), models: models.split(',').map((m) => m.trim()).filter(Boolean) }; }); -export async function run({ flags, positionals, pkgRoot }) { +export async function run({ flags, positionals, pkgRoot, deps = { hostLifecycle: undefined } }) { const sub = positionals[0] ?? 'status'; const cwd = process.cwd(); @@ -193,7 +193,7 @@ export async function run({ flags, positionals, pkgRoot }) { if (sub === 'status') return status({ flags, cwd }); if (sub === 'off') return off({ cwd, pkgRoot, flags }); - if (sub === 'pick') return pick({ flags, cwd, pkgRoot }); + if (sub === 'pick') return pick({ flags, cwd, pkgRoot, deps }); if (sub === 'reset-routes') return resetRoutes({ flags, cwd }); if (sub === 'align') return (await import('./host-align.mjs')).run({ flags }); if (sub === 'adapters') { @@ -918,25 +918,28 @@ async function retireCodexOnDisable(cfg, cwd, { codexMcpManaged, rufloCodexManag /** Install any enabled host that is entirely absent (external installs * untouched). Unlike setup's install loop, pick never prompts first — the * user already confirmed the trust manifest for this exact enable. */ -async function installPickAbsentHosts(cfg, cwd) { +export async function installPickAbsentHosts(cfg, cwd, lifecycle = {}) { + const { installState, install, collectFacts } = { + installState: hostInstallState, install: installHost, collectFacts: collectIntegrationFacts, ...lifecycle, + }; let installed = false; for (const h of HOSTS) { if (!cfg.integrations.hosts[h.id]) continue; - if ((await hostInstallState(h)).method !== 'absent') continue; + if ((await installState(h)).method !== 'absent') continue; info(`${h.id} not installed — installing ${h.pkg}…`); - const r = await installHost(h.id); + const r = await install(h.id); (r.ok ? ok : warn)(`${h.id}: ${r.detail}`); if (r.ok) { installed = true; // hostInstallState() above already recorded the pre-install 'absent' // evidence; re-probe now so a subsequent `ak status` doesn't read that // stale row back. - await hostInstallState(h, { refresh: true, record: true, source: 'host-pick' }); + await installState(h, { refresh: true, record: true, source: 'host-pick' }); } } // host-setup covers every host in one call; refresh it once after the // loop, not per host, once anything actually changed. - if (installed) await collectIntegrationFacts({ cwd, cfg, refresh: true, record: true, source: 'host-pick' }); + if (installed) await collectFacts({ cwd, cfg, refresh: true, record: true, source: 'host-pick' }); } /** opencode enable half: apply the same owner-module stack setup/sync use — @@ -1144,7 +1147,7 @@ async function resolvePickIntentAndPreview({ }; } -export async function pick({ flags, cwd, pkgRoot, migrateRoutes = migrateRetiredRoutesInConfig }) { +export async function pick({ flags, cwd, pkgRoot, deps = { hostLifecycle: undefined }, migrateRoutes = migrateRetiredRoutesInConfig }) { const aqeProviderTypes = aqeSelectableProviderTypes(); const aqeChainProviderTypes = aqeSelectableChainProviderTypes(); const cfg = loadKitConfig(); @@ -1195,7 +1198,7 @@ export async function pick({ flags, cwd, pkgRoot, migrateRoutes = migrateRetired } saveKitConfig(cfg); - await installPickAbsentHosts(cfg, cwd); + await installPickAbsentHosts(cfg, cwd, deps.hostLifecycle); const { incompleteTeardown } = await applyPickOpencodeLifecycle(cfg, { pkgRoot, cwd, prevOpencode }); const { router } = await applyPickProviderStack(cfg, cwd, { diff --git a/tests/kit/daemon-gc-rerecord.test.mjs b/tests/kit/daemon-gc-rerecord.test.mjs new file mode 100644 index 00000000..5adb05e5 --- /dev/null +++ b/tests/kit/daemon-gc-rerecord.test.mjs @@ -0,0 +1,62 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { sandboxHome, rmrf } from './helpers/home-sandbox.mjs'; + +const home = sandboxHome('ak-daemon-gc-rerecord'); +after(() => rmrf(home)); +const trapDir = path.join(home, 'no-such-bin'); +const processProbeMarker = path.join(home, 'process-probe-reached'); +fs.mkdirSync(trapDir, { recursive: true }); +for (const command of ['ps', 'powershell']) { + const trap = path.join(trapDir, command); + fs.writeFileSync(trap, `#!/bin/sh\n: > '${processProbeMarker}'\nexit 99\n`, { mode: 0o755 }); +} +const { run } = await import('../../src/commands/x/daemon-gc.mjs'); + +for (const [name, kill, killed, expected] of [ + ['successful reap', true, true, 2], + ['failed reap', true, false, 1], + ['list only', false, true, 1], +]) { + test(`${name} re-records only after a successful kill`, async () => { + const calls = []; + let reapCalls = 0; + const daemon = { pid: 4242, workspace: '/missing-ak-workspace', workspaceExists: false, ageSecs: 1 }; + const result = await run({ + flags: { kill, mcp: false, quiet: true }, + deps: { + daemonLifecycle: { + list: async opts => { calls.push(opts); return [daemon]; }, + reap: () => { reapCalls++; return [{ ...daemon, killed }]; }, + }, + mcpLifecycle: { list: async () => [], reap: () => { throw new Error('MCP reap forbidden'); } }, + }, + }); + assert.equal(result, 0); + assert.equal(reapCalls, kill ? 1 : 0); + assert.equal(calls.length, expected); + if (expected === 2) assert.deepEqual(calls[1], { refresh: true, record: true, source: 'daemon-gc' }); + assert.equal(fs.existsSync(processProbeMarker), false, 'real process discovery was reached'); + }); +} + +test('JSON listing observes MCP transports without reaping or re-recording', async () => { + const daemonCalls = []; + let mcpCalls = 0; + const oldLog = console.log; + console.log = () => {}; + try { + assert.equal(await run({ + flags: { json: true, kill: true, mcp: false }, + deps: { + daemonLifecycle: { list: async opts => { daemonCalls.push(opts); return []; }, reap: () => { throw new Error('reap forbidden'); } }, + mcpLifecycle: { list: async () => { mcpCalls++; return []; }, reap: () => { throw new Error('MCP reap forbidden'); } }, + }, + }), 0); + } finally { console.log = oldLog; } + assert.deepEqual(daemonCalls, [undefined]); + assert.equal(mcpCalls, 1); + assert.equal(fs.existsSync(processProbeMarker), false, 'real process discovery was reached'); +}); diff --git a/tests/kit/host-pick-rerecord.test.mjs b/tests/kit/host-pick-rerecord.test.mjs new file mode 100644 index 00000000..72887e89 --- /dev/null +++ b/tests/kit/host-pick-rerecord.test.mjs @@ -0,0 +1,57 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { sandboxHome, rmrf, writeKitConfig, offlineKitConfig } from './helpers/home-sandbox.mjs'; + +const home = sandboxHome('ak-host-pick-rerecord'); +after(() => rmrf(home)); +const host = await import('../../src/commands/x/host.mjs'); +const cfg = { integrations: { hosts: { claude: false, codex: true, opencode: false } } }; +const cwd = '/disposable-project'; + +for (const [name, initial, ok, expected] of [ + ['successful install', 'absent', true, 2], + ['failed install', 'absent', false, 1], + ['present host', 'external', true, 1], +]) { + test(`host pick ${name} re-records only after success`, async () => { + const states = []; + const facts = []; + const installs = []; + await host.installPickAbsentHosts(cfg, cwd, { + installState: async (_host, opts) => { states.push(opts); return { method: states.length === 1 ? initial : 'npm', version: '1.0.0' }; }, + install: async id => { installs.push(id); return { ok, detail: ok ? 'installed' : 'failed' }; }, + collectFacts: async opts => { facts.push(opts); }, + }); + assert.equal(states.length, expected); + assert.deepEqual(installs, initial === 'absent' ? ['codex'] : []); + if (expected === 2) { + assert.deepEqual(states[1], { refresh: true, record: true, source: 'host-pick' }); + assert.deepEqual(facts, [{ cwd, cfg, refresh: true, record: true, source: 'host-pick' }]); + } else assert.deepEqual(facts, []); + }); +} + +test('disabled hosts do not probe, install, or record evidence', async () => { + await host.installPickAbsentHosts({ integrations: { hosts: {} } }, cwd, { + installState: async () => { throw new Error('disabled host probed'); }, + install: async () => { throw new Error('disabled host installed'); }, + collectFacts: async () => { throw new Error('disabled host recorded'); }, + }); +}); + +test('host command dispatch passes the lifecycle to pick before installation', async () => { + writeKitConfig(home, offlineKitConfig()); + const sentinel = new Error('injected host lifecycle reached'); + let calls = 0; + await assert.rejects(host.run({ + flags: { host: 'codex', yes: true, 'aqe-provider': 'none' }, + positionals: ['pick'], + pkgRoot: process.cwd(), + deps: { hostLifecycle: { + installState: async () => { calls++; throw sentinel; }, + install: async () => { throw new Error('installer reached'); }, + collectFacts: async () => { throw new Error('collector reached'); }, + } }, + }), error => error === sentinel); + assert.equal(calls, 1); +}); diff --git a/tests/kit/setup-host-rerecord.test.mjs b/tests/kit/setup-host-rerecord.test.mjs new file mode 100644 index 00000000..fc1f0c41 --- /dev/null +++ b/tests/kit/setup-host-rerecord.test.mjs @@ -0,0 +1,51 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { sandboxHome, rmrf } from './helpers/home-sandbox.mjs'; + +const home = sandboxHome('ak-setup-host-rerecord'); +after(() => rmrf(home)); +const setup = await import('../../src/commands/setup.mjs'); +const cfg = { integrations: { hosts: { claude: false, codex: true, opencode: false } } }; + +for (const [name, initial, ok, expected] of [ + ['successful install', 'absent', true, 2], + ['failed install', 'absent', false, 1], + ['present host', 'npm', true, 1], +]) { + test(`${name} records fresh host facts only when installation succeeds`, async () => { + const stateCalls = []; + const facts = []; + const installs = []; + await setup.installEnabledAbsentHosts(cfg, { yes: true }, { + installState: async (_host, opts) => { stateCalls.push(opts); return { method: stateCalls.length === 1 ? initial : 'npm', version: '1.0.0' }; }, + install: async id => { installs.push(id); return { ok, detail: ok ? 'installed' : 'failed' }; }, + collectFacts: async opts => { facts.push(opts); }, + }); + assert.equal(stateCalls.length, expected); + assert.deepEqual(installs, initial === 'absent' ? ['codex'] : []); + if (expected === 2) { + assert.deepEqual(stateCalls[1], { refresh: true, record: true, source: 'setup' }); + assert.deepEqual(facts, [{ cfg, refresh: true, record: true, source: 'setup' }]); + } else assert.deepEqual(facts, []); + }); +} + +test('disabled hosts do not probe, install, or record evidence', async () => { + await setup.installEnabledAbsentHosts({ integrations: { hosts: {} } }, { yes: true }, { + installState: async () => { throw new Error('disabled host probed'); }, + install: async () => { throw new Error('disabled host installed'); }, + collectFacts: async () => { throw new Error('disabled host recorded'); }, + }); +}); + +test('setup run passes its host lifecycle through the machine setup boundary', async () => { + const lifecycle = { installState: async () => { throw new Error('sentinel'); } }; + let received; + await setup.run({ + flags: { 'dry-run': true, minimal: true, 'no-aqe': true, 'no-ruvnet-brain': true, 'no-agent-browser': true, yes: true }, + pkgRoot: process.cwd(), + deps: { hostLifecycle: lifecycle }, + machineSetup: async args => { received = args.deps?.hostLifecycle; return false; }, + }); + assert.equal(received, lifecycle); +}); From 001225daa57662bfbc77510b39383e606b865823 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 01:28:24 -0700 Subject: [PATCH 10/54] test(evidence): exercise setup machine host lifecycle wiring --- tests/kit/setup-host-rerecord.test.mjs | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/kit/setup-host-rerecord.test.mjs b/tests/kit/setup-host-rerecord.test.mjs index fc1f0c41..90c2e9fa 100644 --- a/tests/kit/setup-host-rerecord.test.mjs +++ b/tests/kit/setup-host-rerecord.test.mjs @@ -1,5 +1,6 @@ import { test, after } from 'node:test'; import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; import { sandboxHome, rmrf } from './helpers/home-sandbox.mjs'; const home = sandboxHome('ak-setup-host-rerecord'); @@ -49,3 +50,55 @@ test('setup run passes its host lifecycle through the machine setup boundary', a }); assert.equal(received, lifecycle); }); + +test('real run_machine passes injected lifecycle to the host installation loop', async () => { + const stubs = new Map([ + ['../lib/versions.mjs', "export const installedVersion = () => '3.48.0'; export const cmpVersions = () => 0;"], + ['../lib/heal.mjs', "export const healNatives = async () => ({ ok: true, detail: 'stub' }); export const healAidefence = async () => ({ ok: true, detail: 'stub' });"], + ['../lib/exec.mjs', "export const have = async () => false; export const run = async () => { throw Error('real command reached'); };"], + ['../lib/providers.mjs', ` + export const HOSTS = [{ id: 'codex', pkg: '@openai/codex' }]; + export const hostInstallState = async () => { throw Error('default host probe reached'); }; + export const installHost = async () => { throw Error('default installer reached'); }; + export const collectIntegrationFacts = async () => { throw Error('default facts collector reached'); }; + export const migrateRetiredRoutesInConfig = () => {}; + export const printActivityRoutingTable = () => {}; + export const convergeProviderStack = () => {}; + export const applySetupHostFlags = () => {}; + export const guidanceContext = () => {}; + export const reportRetiredRouteChanges = () => {}; + `], + ['../lib/adapters/lifecycle-registry.mjs', ` + export const hostsWithLifecycle = () => []; + export const lifecycleAdapterFor = () => { throw Error('host lifecycle reached'); }; + export const lifecycleExecutionEnabled = () => false; + export const detectionBinFor = () => { throw Error('host lifecycle reached'); }; + `], + ['../lib/ruflo-components/apply.mjs', "export const reconcileRufloComponents = async () => { throw Error('components reached'); };"], + ['./status/sections/ruflo-components.mjs', "export const componentResultReport = () => [];"], + ]); + registerHooks({ + resolve(specifier, context, nextResolve) { + if (context.parentURL?.includes('/src/commands/setup.mjs?b2-machine') && stubs.has(specifier)) { + return { url: `data:text/javascript,${encodeURIComponent(stubs.get(specifier))}`, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + }); + const isolatedSetup = await import('../../src/commands/setup.mjs?b2-machine'); + const sentinel = new Error('injected host lifecycle reached'); + const machineCfg = { + agentBrowser: false, aqe: false, ruvnetBrain: false, security: false, + integrations: { hosts: { codex: true } }, + }; + let calls = 0; + await assert.rejects(isolatedSetup.run_machine({ + flags: { yes: true }, cfg: machineCfg, pkgRoot: home, + deps: { hostLifecycle: { + installState: async () => { calls++; throw sentinel; }, + install: async () => { throw Error('injected installer reached'); }, + collectFacts: async () => { throw Error('injected facts reached'); }, + } }, + }), error => error === sentinel); + assert.equal(calls, 1); +}); From f37b1bd37ec5e77208769c9d5c6015cc1c4d3aeb Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 01:36:05 -0700 Subject: [PATCH 11/54] test(aqe): verify live-lock fallback on installed artifact --- docs/plans/2026-09-28-follow-ups-v2.md | 4 +- tests/live/aqe-live-lock-conformance.test.mjs | 151 ++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tests/live/aqe-live-lock-conformance.test.mjs diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index 973dd466..e0929dfa 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -2,7 +2,7 @@ ## Status -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 is implemented in the isolated `fix/follow-ups-v2-rest` branch, pending independent review and integration. Other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. +**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 is implemented in the isolated `fix/follow-ups-v2-rest` branch, pending independent review and integration. C1 has a portable, opt-in live-lock probe that passed on macOS against AQE 3.14.4; Linux CI proof and the busy-rule decision are pending. Other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. @@ -25,7 +25,7 @@ The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-u | B11 | `src/lib/live-checks.mjs` | `tests/kit/live-checks.test.mjs`; skipped deja-vu check says skipped and check-created temp folders are cleaned | | B12 | `src/commands/setup.mjs`; `src/lib/memory-probe-cleanup.mjs` unchanged | **Fixed:** `tests/kit/setup-memory-probe.test.mjs`; Ruflo 3.48.0 seeded reproduction created an unused native side file, and a disposable candidate run confirmed a private mirror leaves no canonical side file or probe row | | B13 | `src/commands/sync.mjs`; `src/lib/aqe-project-pin.mjs` | `tests/kit/sync-command.test.mjs`, `aqe-project-pin.test.mjs`; only if program §2 step 2 shows sync omitted the AQE pin | -| C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/live/ruflo-memory-routing.test.mjs`, `tests/kit/aqe-readiness.test.mjs`; disposable macOS and temporary Linux/Windows CI busy-rule evidence; remove temporary job before merge; #240 action follows result | +| C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | Probe prerequisite: `tests/live/aqe-live-lock-conformance.test.mjs` passed on macOS with installed AQE 3.14.4; ignored temporary CI job proposal and report in `.superpowers/sdd/2026-09-28-follow-ups-v2/`. Linux proof, existing `tests/live/ruflo-memory-routing.test.mjs` on Linux/Windows, busy-rule decision, and #240 action remain pending. Remove temporary job before merge. | | C2 | `docs/host-support.md`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/ruflo-support-window.test.mjs` plus link check; verify AQE 3.14.4 #528/#532/#535 and Ruflo #2356/#420 first | | C3 | `.github/workflows/nightly.yml`; vidaunited's `trace-ort.mjs` hook (obtain and verify its exact script path before adding) | `tests/kit/upstream-watch-workflow.test.mjs` plus macOS artifact receipt; exact upstream #2885 post text requires user approval | | C4 | `scripts/upstream-watch/classify.mjs`, `fetch.mjs`, `ledger.mjs`, `dispatch.mjs`, `render.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/upstream-watch-script.test.mjs`, `upstream-watch-record.test.mjs`, `upstream-watch-dispatch.test.mjs`, `upstream-watch-registry.test.mjs`; use ignored `reports/n5-253-deferred-minors.md` §2 for M7/M8/minors 1–12; M10 declined | diff --git a/tests/live/aqe-live-lock-conformance.test.mjs b/tests/live/aqe-live-lock-conformance.test.mjs new file mode 100644 index 00000000..d4a50e87 --- /dev/null +++ b/tests/live/aqe-live-lock-conformance.test.mjs @@ -0,0 +1,151 @@ +// Opt-in native contract for agentic-qe#574. No installed package is patched. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { tempDir } from '../kit/helpers/temp-dir.mjs'; + +const required = process.env.AK_AQE_LOCK_LIVE === '1'; +const digest = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function installedRoot() { + if (process.env.AK_AQE_PACKAGE_ROOT) { + assert.ok(path.isAbsolute(process.env.AK_AQE_PACKAGE_ROOT), 'package root must be absolute'); + return fs.realpathSync(process.env.AK_AQE_PACKAGE_ROOT); + } + for (const dir of (process.env.PATH ?? '').split(path.delimiter)) { + const bin = path.join(dir, 'aqe'); + if (dir && fs.existsSync(bin)) { + const root = path.resolve(path.dirname(fs.realpathSync(bin)), '../..'); + if (fs.existsSync(path.join(root, 'package.json'))) return root; + } + } + throw new Error('AQE missing: set AK_AQE_PACKAGE_ROOT to absolute installed package root'); +} + +function childEnv(root, project) { + const home = path.join(root, 'home'); + const tmp = path.join(root, 'tmp'); + fs.mkdirSync(home); fs.mkdirSync(tmp); + return { + PATH: process.env.PATH ?? '', + ...(process.platform === 'win32' ? { + SystemRoot: process.env.SystemRoot ?? 'C:\\Windows', + ComSpec: process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe', + PATHEXT: process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD', + } : {}), + HOME: home, USERPROFILE: home, TMPDIR: tmp, TMP: tmp, TEMP: tmp, + LANG: 'en_US.UTF-8', CI: '1', NO_COLOR: '1', + XDG_CONFIG_HOME: path.join(home, 'config'), XDG_STATE_HOME: path.join(home, 'state'), + XDG_DATA_HOME: path.join(home, 'data'), XDG_CACHE_HOME: path.join(home, 'cache'), + APPDATA: path.join(home, 'appdata'), LOCALAPPDATA: path.join(home, 'localappdata'), + CODEX_HOME: path.join(home, 'codex'), CLAUDE_CONFIG_DIR: path.join(home, 'claude'), + HERMES_HOME: path.join(home, 'hermes'), npm_config_prefix: path.join(home, 'npm-prefix'), + npm_config_cache: path.join(home, 'npm-cache'), + MISE_DATA_DIR: path.join(home, 'mise-data'), MISE_CONFIG_DIR: path.join(home, 'mise-config'), + MISE_CACHE_DIR: path.join(home, 'mise-cache'), AQE_PROJECT_ROOT: project, + AQE_MEMORY_PATH: path.join(project, '.agentic-qe', 'memory.db'), + AQE_STORAGE_PATH: path.join(project, '.agentic-qe'), + CLAUDE_FLOW_MEMORY_PATH: path.join(project, '.swarm'), + CLAUDE_FLOW_DB_PATH: path.join(project, '.swarm', 'memory.db'), + RUFLO_DAEMON_AUTOSTART: '0', + }; +} + +function launch(file, args, options) { + const child = spawn(process.execPath, [file, ...args], { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; let stderr = ''; + child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); + child.stdout.on('data', (s) => { stdout += s; }); + child.stderr.on('data', (s) => { stderr += s; }); + const done = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, stdout, stderr })); + }); + return { child, done }; +} + +async function bounded(file, args, options, limit) { + const run = launch(file, args, options); + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; run.child.kill('SIGKILL'); }, limit); + try { return { ...await run.done, timedOut }; } finally { clearTimeout(timer); } +} + +function snapshot(store) { + return Object.fromEntries(fs.readdirSync(store).filter((n) => n.startsWith('patterns.rvf')) + .sort().map((name) => { + const file = path.join(store, name); + return [name, { sha256: digest(file), bytes: fs.statSync(file).size }]; + })); +} + +function check(label, result, owner, before, store) { + const output = result.stdout + result.stderr; + assert.equal(result.timedOut, false, `${label} timed out`); + assert.equal(result.code, 0, `${label} exited ${result.code}: ${output.slice(-1200)}`); + assert.equal(owner.exitCode, null, `native holder exited during ${label}`); + assert.match(output, /is locked by a live process/, `${label} missed live-lock warning`); + assert.match(output, /LockHeld|0x0300/, `${label} missed LockHeld fallback`); + assert.doesNotMatch(output, /FsyncFailed|0x0303/, `${label} emitted FsyncFailed`); + assert.deepEqual(snapshot(store), before, `${label} changed RVF bytes`); + assert.ok(!fs.readdirSync(store).some((n) => n.includes('.corrupt-')), `${label} quarantined RVF`); +} + +test('installed AQE degrades under a live native RVF lock without changing the store', + { skip: !required && 'set AK_AQE_LOCK_LIVE=1 for native proof', timeout: 240_000 }, async (t) => { + const root = tempDir('ak-aqe-live-lock', t); + const project = path.join(root, 'project'); fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'package.json'), '{"name":"aqe-live-lock-probe","version":"1.0.0","type":"module"}\n'); + const env = childEnv(root, project); + const packageRoot = installedRoot(); + const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); + assert.equal(pkg.name, 'agentic-qe'); + if (process.env.AK_AQE_EXPECTED_VERSION) assert.equal(pkg.version, process.env.AK_AQE_EXPECTED_VERSION); + const entry = path.join(packageRoot, 'dist', 'cli', 'bundle.js'); + const adapter = path.join(packageRoot, 'dist', 'integrations', 'ruvector', 'shared-rvf-adapter.js'); + assert.ok(fs.existsSync(entry) && fs.existsSync(adapter), 'installed AQE CLI and adapter required'); + const sourceHashes = { entry: digest(entry), adapter: digest(adapter) }; + const options = { cwd: project, env }; + const init = await bounded(entry, ['init', '--minimal', '--auto'], options, 150_000); + assert.equal(init.timedOut, false); + assert.equal(init.code, 0, `aqe init failed: ${(init.stdout + init.stderr).slice(-1200)}`); + const store = path.join(project, '.agentic-qe'); + const ready = path.join(root, 'ready'); + const holderFile = path.join(root, 'holder.mjs'); + const moduleUrl = pathToFileURL(adapter).href; + fs.writeFileSync(holderFile, `import { createRequire } from 'node:module'; import fs from 'node:fs';\nglobalThis.require=createRequire(${JSON.stringify(adapter)});\nconst {getSharedRvfAdapter}=await import(${JSON.stringify(moduleUrl)});\nglobalThis.hold=getSharedRvfAdapter(${JSON.stringify(store)},384);\nif(!globalThis.hold)process.exit(2);\nfs.writeFileSync(${JSON.stringify(ready)},String(process.pid));\nconst timer=setInterval(()=>{if(!globalThis.hold)process.exit(3)},1000);\nprocess.on('SIGTERM',()=>{clearInterval(timer);globalThis.hold.close();process.exit(0)});\n`); + const owner = launch(holderFile, [], options); + try { + const deadline = Date.now() + 30_000; + while (!fs.existsSync(ready) && owner.child.exitCode === null && Date.now() < deadline) await pause(50); + assert.ok(fs.existsSync(ready), 'holder did not signal ready'); + assert.equal(Number(fs.readFileSync(ready, 'utf8')), owner.child.pid, 'holder PID mismatch'); + assert.equal(owner.child.exitCode, null, 'holder exited after ready'); + const before = snapshot(store); + assert.ok(before['patterns.rvf'] && before['patterns.rvf.lock'], 'native RVF and lock required'); + const status = await bounded(entry, ['status'], options, 150_000); + check('aqe status', status, owner.child, before, store); + const challengerFile = path.join(root, 'challenger.mjs'); + fs.writeFileSync(challengerFile, `import {createRequire} from 'node:module';\nglobalThis.require=createRequire(${JSON.stringify(adapter)});\nconst {getSharedRvfAdapter}=await import(${JSON.stringify(moduleUrl)});\nconst adapter=getSharedRvfAdapter(${JSON.stringify(store)},384);\nconsole.log(JSON.stringify({fallback:adapter===null}));\nif(adapter){adapter.close();process.exitCode=2}\n`); + const challenger = await bounded(challengerFile, [], options, 30_000); + check('shipped adapter', challenger, owner.child, before, store); + assert.match(challenger.stdout, /"fallback":true/, 'adapter did not fall back'); + console.log(JSON.stringify({ aqeVersion: pkg.version, platform: process.platform, + node: process.version, sourceHashes, ownerPid: owner.child.pid, + status: { exit: status.code, liveLock: true, lockHeld: true, fsyncFailed: false }, + adapter: { exit: challenger.code, fallback: true, liveLock: true, lockHeld: true, fsyncFailed: false }, + before, after: snapshot(store) })); + } finally { + if (owner.child.exitCode === null) owner.child.kill('SIGTERM'); + const timer = setTimeout(() => owner.child.kill('SIGKILL'), 10_000); + try { + const closed = await owner.done; + assert.equal(closed.code, 0, `holder failed to close: ${closed.stderr.slice(-1000)}`); + } finally { clearTimeout(timer); } + } + }); From d6b9d111ddc76d1bc3c0d5bfb10613697ddf758f Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 01:46:58 -0700 Subject: [PATCH 12/54] test(aqe): guard live-lock probe cancellation --- docs/plans/2026-09-28-follow-ups-v2.md | 2 +- tests/live/aqe-live-lock-conformance.test.mjs | 69 ++++++++-------- tests/live/aqe-live-lock-process.mjs | 80 +++++++++++++++++++ tests/live/aqe-live-lock-process.test.mjs | 35 ++++++++ 4 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 tests/live/aqe-live-lock-process.mjs create mode 100644 tests/live/aqe-live-lock-process.test.mjs diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index e0929dfa..bce867cd 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -2,7 +2,7 @@ ## Status -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 is implemented in the isolated `fix/follow-ups-v2-rest` branch, pending independent review and integration. C1 has a portable, opt-in live-lock probe that passed on macOS against AQE 3.14.4; Linux CI proof and the busy-rule decision are pending. Other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. +**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 is implemented in the isolated `fix/follow-ups-v2-rest` branch, pending independent review and integration. C1 has a portable, opt-in live-lock probe that passed on macOS against AQE 3.14.4; its review fixes add abort-aware child closure, a sufficient overall timeout, and corrected isolated CI proposal paths. Linux CI proof and the busy-rule decision remain pending. Other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. diff --git a/tests/live/aqe-live-lock-conformance.test.mjs b/tests/live/aqe-live-lock-conformance.test.mjs index d4a50e87..bf5e6f2b 100644 --- a/tests/live/aqe-live-lock-conformance.test.mjs +++ b/tests/live/aqe-live-lock-conformance.test.mjs @@ -1,12 +1,12 @@ // Opt-in native contract for agentic-qe#574. No installed package is patched. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { tempDir } from '../kit/helpers/temp-dir.mjs'; +import { createProcessScope } from './aqe-live-lock-process.mjs'; const required = process.env.AK_AQE_LOCK_LIVE === '1'; const digest = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); @@ -56,24 +56,9 @@ function childEnv(root, project) { }; } -function launch(file, args, options) { - const child = spawn(process.execPath, [file, ...args], { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; let stderr = ''; - child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); - child.stdout.on('data', (s) => { stdout += s; }); - child.stderr.on('data', (s) => { stderr += s; }); - const done = new Promise((resolve, reject) => { - child.once('error', reject); - child.once('close', (code, signal) => resolve({ code, signal, stdout, stderr })); - }); - return { child, done }; -} - -async function bounded(file, args, options, limit) { - const run = launch(file, args, options); - let timedOut = false; - const timer = setTimeout(() => { timedOut = true; run.child.kill('SIGKILL'); }, limit); - try { return { ...await run.done, timedOut }; } finally { clearTimeout(timer); } +async function bounded(scope, file, args, options, limit) { + const run = scope.launch(process.execPath, [file, ...args], options); + return scope.wait(run, limit); } function snapshot(store) { @@ -88,7 +73,10 @@ function check(label, result, owner, before, store) { const output = result.stdout + result.stderr; assert.equal(result.timedOut, false, `${label} timed out`); assert.equal(result.code, 0, `${label} exited ${result.code}: ${output.slice(-1200)}`); - assert.equal(owner.exitCode, null, `native holder exited during ${label}`); + assert.equal(owner.closed, false, `native holder closed during ${label}`); + assert.equal(owner.child.exitCode, null, `native holder exited during ${label}`); + assert.equal(owner.child.signalCode, null, `native holder signaled during ${label}`); + assert.ifError(owner.error); assert.match(output, /is locked by a live process/, `${label} missed live-lock warning`); assert.match(output, /LockHeld|0x0300/, `${label} missed LockHeld fallback`); assert.doesNotMatch(output, /FsyncFailed|0x0303/, `${label} emitted FsyncFailed`); @@ -97,8 +85,17 @@ function check(label, result, owner, before, store) { } test('installed AQE degrades under a live native RVF lock without changing the store', - { skip: !required && 'set AK_AQE_LOCK_LIVE=1 for native proof', timeout: 240_000 }, async (t) => { - const root = tempDir('ak-aqe-live-lock', t); + { skip: !required && 'set AK_AQE_LOCK_LIVE=1 for native proof', timeout: 420_000 }, async (t) => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-live-lock-'))); + const scope = createProcessScope(t.signal); + t.after(async () => { + try { + await scope.closeAll(); + } catch (error) { + throw new Error(`owned child closure unverified; retained ${root}`, { cause: error }); + } + fs.rmSync(root, { recursive: true, force: true, maxRetries: 3 }); + }); const project = path.join(root, 'project'); fs.mkdirSync(project); fs.writeFileSync(path.join(project, 'package.json'), '{"name":"aqe-live-lock-probe","version":"1.0.0","type":"module"}\n'); const env = childEnv(root, project); @@ -111,7 +108,7 @@ test('installed AQE degrades under a live native RVF lock without changing the s assert.ok(fs.existsSync(entry) && fs.existsSync(adapter), 'installed AQE CLI and adapter required'); const sourceHashes = { entry: digest(entry), adapter: digest(adapter) }; const options = { cwd: project, env }; - const init = await bounded(entry, ['init', '--minimal', '--auto'], options, 150_000); + const init = await bounded(scope, entry, ['init', '--minimal', '--auto'], options, 150_000); assert.equal(init.timedOut, false); assert.equal(init.code, 0, `aqe init failed: ${(init.stdout + init.stderr).slice(-1200)}`); const store = path.join(project, '.agentic-qe'); @@ -119,21 +116,25 @@ test('installed AQE degrades under a live native RVF lock without changing the s const holderFile = path.join(root, 'holder.mjs'); const moduleUrl = pathToFileURL(adapter).href; fs.writeFileSync(holderFile, `import { createRequire } from 'node:module'; import fs from 'node:fs';\nglobalThis.require=createRequire(${JSON.stringify(adapter)});\nconst {getSharedRvfAdapter}=await import(${JSON.stringify(moduleUrl)});\nglobalThis.hold=getSharedRvfAdapter(${JSON.stringify(store)},384);\nif(!globalThis.hold)process.exit(2);\nfs.writeFileSync(${JSON.stringify(ready)},String(process.pid));\nconst timer=setInterval(()=>{if(!globalThis.hold)process.exit(3)},1000);\nprocess.on('SIGTERM',()=>{clearInterval(timer);globalThis.hold.close();process.exit(0)});\n`); - const owner = launch(holderFile, [], options); + const owner = scope.launch(process.execPath, [holderFile], options); try { const deadline = Date.now() + 30_000; - while (!fs.existsSync(ready) && owner.child.exitCode === null && Date.now() < deadline) await pause(50); + while (!fs.existsSync(ready) && !owner.closed && !owner.error && owner.child.exitCode === null + && owner.child.signalCode === null && Date.now() < deadline) await pause(50); + assert.ifError(owner.error); assert.ok(fs.existsSync(ready), 'holder did not signal ready'); assert.equal(Number(fs.readFileSync(ready, 'utf8')), owner.child.pid, 'holder PID mismatch'); + assert.equal(owner.closed, false, 'holder closed after ready'); assert.equal(owner.child.exitCode, null, 'holder exited after ready'); + assert.equal(owner.child.signalCode, null, 'holder signaled after ready'); const before = snapshot(store); assert.ok(before['patterns.rvf'] && before['patterns.rvf.lock'], 'native RVF and lock required'); - const status = await bounded(entry, ['status'], options, 150_000); - check('aqe status', status, owner.child, before, store); + const status = await bounded(scope, entry, ['status'], options, 150_000); + check('aqe status', status, owner, before, store); const challengerFile = path.join(root, 'challenger.mjs'); fs.writeFileSync(challengerFile, `import {createRequire} from 'node:module';\nglobalThis.require=createRequire(${JSON.stringify(adapter)});\nconst {getSharedRvfAdapter}=await import(${JSON.stringify(moduleUrl)});\nconst adapter=getSharedRvfAdapter(${JSON.stringify(store)},384);\nconsole.log(JSON.stringify({fallback:adapter===null}));\nif(adapter){adapter.close();process.exitCode=2}\n`); - const challenger = await bounded(challengerFile, [], options, 30_000); - check('shipped adapter', challenger, owner.child, before, store); + const challenger = await bounded(scope, challengerFile, [], options, 30_000); + check('shipped adapter', challenger, owner, before, store); assert.match(challenger.stdout, /"fallback":true/, 'adapter did not fall back'); console.log(JSON.stringify({ aqeVersion: pkg.version, platform: process.platform, node: process.version, sourceHashes, ownerPid: owner.child.pid, @@ -141,11 +142,9 @@ test('installed AQE degrades under a live native RVF lock without changing the s adapter: { exit: challenger.code, fallback: true, liveLock: true, lockHeld: true, fsyncFailed: false }, before, after: snapshot(store) })); } finally { - if (owner.child.exitCode === null) owner.child.kill('SIGTERM'); - const timer = setTimeout(() => owner.child.kill('SIGKILL'), 10_000); - try { - const closed = await owner.done; + const closed = await scope.stop(owner, 10_000); + if (!t.signal.aborted) { assert.equal(closed.code, 0, `holder failed to close: ${closed.stderr.slice(-1000)}`); - } finally { clearTimeout(timer); } + } } }); diff --git a/tests/live/aqe-live-lock-process.mjs b/tests/live/aqe-live-lock-process.mjs new file mode 100644 index 00000000..0f82d697 --- /dev/null +++ b/tests/live/aqe-live-lock-process.mjs @@ -0,0 +1,80 @@ +import { spawn } from 'node:child_process'; + +const CLOSE_LIMIT_MS = 10_000; + +function closedWithin(run, ms) { + if (run.closed) return Promise.resolve(run.result); + let timer; + return Promise.race([ + run.done, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`call-owned child ${run.child.pid ?? 'unspawned'} did not close`)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +export function createProcessScope(signal) { + const runs = new Set(); + const killLive = () => { + for (const run of runs) if (!run.closed) run.child.kill('SIGKILL'); + }; + signal.addEventListener('abort', killLive, { once: true }); + + function launch(command, args, options) { + const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + const run = { child, closed: false, error: null, stdout: '', stderr: '', done: null, result: null }; + runs.add(run); + child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); + child.stdout.on('data', (s) => { run.stdout += s; }); + child.stderr.on('data', (s) => { run.stderr += s; }); + // Resolve on close even after spawn error. The caller can inspect error immediately + // while polling readiness, and no delayed rejection can go unhandled. + run.done = new Promise((resolve) => { + child.once('error', (error) => { run.error = error; }); + child.once('close', (code, childSignal) => { + run.closed = true; + run.result = { code, signal: childSignal, stdout: run.stdout, stderr: run.stderr }; + resolve(run.result); + }); + }); + if (signal.aborted) child.kill('SIGKILL'); + return run; + } + + async function wait(run, limit) { + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + if (!run.closed) run.child.kill('SIGKILL'); + }, limit); + try { + const result = await closedWithin(run, limit + CLOSE_LIMIT_MS); + if (run.error) throw run.error; + return { ...result, timedOut }; + } finally { clearTimeout(timer); } + } + + async function stop(run, grace = CLOSE_LIMIT_MS) { + if (!run.closed) run.child.kill('SIGTERM'); + let timer; + try { + await Promise.race([ + run.done, + new Promise((resolve) => { timer = setTimeout(resolve, grace); }), + ]); + } finally { + clearTimeout(timer); + if (!run.closed) run.child.kill('SIGKILL'); + } + return closedWithin(run, CLOSE_LIMIT_MS); + } + + async function closeAll() { + killLive(); + // A failed close keeps the caller's temporary root intact for diagnosis. + await Promise.all([...runs].map((run) => closedWithin(run, CLOSE_LIMIT_MS))); + signal.removeEventListener('abort', killLive); + } + + return { launch, wait, stop, closeAll }; +} diff --git a/tests/live/aqe-live-lock-process.test.mjs b/tests/live/aqe-live-lock-process.test.mjs new file mode 100644 index 00000000..f6ca6542 --- /dev/null +++ b/tests/live/aqe-live-lock-process.test.mjs @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, existsSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createProcessScope } from './aqe-live-lock-process.mjs'; + +test('abort closes a call-owned child before its temporary root is removed', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-abort-proof-')); + const controller = new AbortController(); + const scope = createProcessScope(controller.signal); + try { + const marker = path.join(root, 'child-ready'); + const run = scope.launch(process.execPath, ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ready');setInterval(() => {}, 1000)`], { cwd: root, env: { PATH: process.env.PATH ?? '' } }); + assert.ok(run.child.pid > 0); + const deadline = Date.now() + 2000; + while (!existsSync(marker) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(existsSync(marker), 'the child must be running before cancellation'); + controller.abort(); + await scope.closeAll(); + assert.equal(run.closed, true); + assert.ok(existsSync(root), 'root must remain until closure is established'); + } finally { + await scope.closeAll(); + rmSync(root, { recursive: true, force: true }); + } + assert.equal(existsSync(root), false); +}); + +test('spawn failure is retained without an unhandled rejection', async () => { + const scope = createProcessScope(new AbortController().signal); + const run = scope.launch(path.join(os.tmpdir(), 'ak-missing-executable'), [], { env: { PATH: '' } }); + await assert.rejects(scope.wait(run, 1000), /ENOENT/); + await scope.closeAll(); +}); From b4cc7d37bd198ff5d99e1e0d3b18baf4b19fff96 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 01:53:27 -0700 Subject: [PATCH 13/54] fix(memory): explain unsuitable locations and strict temp nesting --- .../2026-09-28-remediation-program-v2.md | 2 +- src/lib/ruflo-memory.mjs | 26 +++++++---- tests/kit/ruflo-memory-location.test.mjs | 43 +++++++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/docs/plans/2026-09-28-remediation-program-v2.md b/docs/plans/2026-09-28-remediation-program-v2.md index 3da32f49..f06ef348 100644 --- a/docs/plans/2026-09-28-remediation-program-v2.md +++ b/docs/plans/2026-09-28-remediation-program-v2.md @@ -308,7 +308,7 @@ One unit commit per line, test-first. The source text is the Branch 9 plan where 1. A relative `XDG_*` value is ignored (Branch 9 Task 6; B6a-6, B2-6). 2. Re-record seams for `x/daemon-gc.mjs` and `setup.mjs` (Task 8), plus the one for `x/host.mjs` `pick` (deferred 13a) (B6a-3). -3. `rufloMemoryLocation` names both reasons when the root and the folder are both unsuitable (deferred item 11). `inside()` stops treating equality as "inside", so a `TMPDIR` set to a tool folder is read correctly (B9-12, B0-15). +3. `rufloMemoryLocation` names both reasons when the root and the folder are both unsuitable (deferred item 11). `inside()` stops treating equality as "inside", so a `TMPDIR` set to a tool folder is read correctly (B9-12, B0-15). Implemented in V4 B3; isolated-branch review pending (`.superpowers/sdd/2026-09-28-follow-ups-v2/b3-report.md`). 4. N4, as D-4 decides (B9-3). 5. The stray scan walks dot folders below the root (B5-9, D-7). The real `~/.agentic-qe` home store is listed (B5-11). The `codex-mcp` hint stops suggesting AQE's broken Codex platform setup (agentic-qe#757) (B5-10). 6. `ruflo-components`: the applied-but-unverified row stops repeating the restart instruction (B0-21). The rows reading "partial — missing: Codex hooks" get a fix line once ruvnet/ruflo#3419 answers; if it is still unanswered at the pre-PR check, this part waits (LQ-2). diff --git a/src/lib/ruflo-memory.mjs b/src/lib/ruflo-memory.mjs index 358bdc9f..b7aa541e 100644 --- a/src/lib/ruflo-memory.mjs +++ b/src/lib/ruflo-memory.mjs @@ -36,9 +36,10 @@ import { componentById } from './ruflo-components/catalogue.mjs'; // are the same folder, as the filesystem treats them. const realOr = (p, file) => { try { return fs.realpathSync(file); } catch { return p.resolve(file); } }; const same = (p, a, b) => p.relative(a, b) === ''; +// Strict containment: equality cannot make a tool root a deeper temp boundary. const inside = (p, child, parent) => { const rel = p.relative(parent, child); - return rel === '' || (!rel.startsWith('..') && !p.isAbsolute(rel)); + return rel !== '' && !rel.startsWith('..') && !p.isAbsolute(rel); }; /** `~/…` for a folder under the home folder, else the absolute path. */ export function homeRelative(file, home = paths.home, p = path) { @@ -57,7 +58,8 @@ function unsuitableReason(dir, { home, env, platform, p }) { if (temps.some((temp) => same(p, dir, temp))) return 'a temporary folder'; const tool = paths.toolInternalDirs({ home, env, platform, p }).find((folder) => { const real = realOr(p, folder); - return inside(p, dir, real) && !temps.some((temp) => inside(p, temp, real) && inside(p, dir, temp)); + return (same(p, dir, real) || inside(p, dir, real)) + && !temps.some((temp) => inside(p, temp, real) && inside(p, dir, temp)); }); return tool ? `inside ${homeRelative(tool, home, p)}, a tool's own folder` : null; } @@ -75,14 +77,20 @@ export function rufloMemoryLocation(cwd = process.cwd(), { home = paths.home, env = process.env, platform = process.platform, p = path, } = {}) { const options = { home, env, platform, p }; - let reason = null; - for (const [kind, candidate] of [['project', paths.repoRoot(cwd, p)], ['folder', cwd]]) { - if (!candidate) continue; - const root = realOr(p, candidate); - const why = unsuitableReason(root, options); - if (!why) return { kind, root, dir: p.join(root, '.swarm'), db: paths.projectMemoryDb(root, p), reason: null }; - reason ??= why; + const repository = paths.repoRoot(cwd, p); + const projectRoot = repository && realOr(p, repository); + const projectReason = projectRoot && unsuitableReason(projectRoot, options); + if (projectRoot && !projectReason) { + return { kind: 'project', root: projectRoot, dir: p.join(projectRoot, '.swarm'), db: paths.projectMemoryDb(projectRoot, p), reason: null }; } + const folderRoot = realOr(p, cwd); + const folderReason = unsuitableReason(folderRoot, options); + if (!folderReason) { + return { kind: 'folder', root: folderRoot, dir: p.join(folderRoot, '.swarm'), db: paths.projectMemoryDb(folderRoot, p), reason: null }; + } + const reason = projectReason && !same(p, projectRoot, folderRoot) && projectReason !== folderReason + ? `${folderReason}, in a repository whose root is ${projectReason}` + : folderReason; const dir = paths.userMemoryDir(home, p); return { kind: 'user', root: dir, dir, db: p.join(dir, 'memory.db'), reason }; } diff --git a/tests/kit/ruflo-memory-location.test.mjs b/tests/kit/ruflo-memory-location.test.mjs index 212b8d43..1fa5d0aa 100644 --- a/tests/kit/ruflo-memory-location.test.mjs +++ b/tests/kit/ruflo-memory-location.test.mjs @@ -84,6 +84,30 @@ test('a disposable folder below a temporary root inside a tool folder keeps its assert.equal(launch.env.CLAUDE_FLOW_MEMORY_PATH, undefined); }); +test('a temporary root equal to a tool folder does not exempt its descendants', (t) => { + const home = sandbox(t); + const cache = mkdir(path.join(home, '.cache')); + const child = mkdir(path.join(cache, 'project')); + const at = (cwd) => rufloMemoryLocation(cwd, { home, env: { TMPDIR: cache } }); + assert.equal(at(cache).kind, 'user', 'the tool folder itself remains unsuitable'); + const location = at(child); + assert.equal(location.kind, 'user'); + assert.match(location.reason, /inside ~\/\.cache, a tool's own folder/); + assert.equal(location.db, path.join(userStore(home), 'memory.db')); +}); + +test('Windows same-path temp and tool roots are not deeper disposable boundaries', () => { + const home = 'C:\\Users\\Me'; + const local = `${home}\\AppData\\Local`; + const options = { home, platform: 'win32', p: path.win32, env: { LOCALAPPDATA: local, TMPDIR: local.toLowerCase() } }; + const tool = rufloMemoryLocation(local, options); + const child = rufloMemoryLocation(`${local}\\project`, options); + assert.equal(tool.kind, 'user'); + assert.equal(child.kind, 'user'); + assert.match(child.reason, /tool's own folder/); + assert.equal(child.db, `${home}\\.claude-flow\\memory\\memory.db`); +}); + test('the filesystem root, the home folder and a temporary root use the one user-level store', (t) => { const home = sandbox(t); for (const [cwd, reason] of [ @@ -167,6 +191,25 @@ test('a Git repository at the home folder does not pull a plain subfolder into ~ assert.equal(rufloMemoryLocation(home, { home }).kind, 'user'); }); +test('user-level location explains both an unsuitable folder and its unsuitable repository root', (t) => { + const home = sandbox(t); + fs.mkdirSync(path.join(home, '.git')); + const codex = mkdir(path.join(home, '.codex', 'sessions')); + const location = rufloMemoryLocation(codex, { home }); + assert.deepEqual([location.kind, location.root, location.dir, location.db], + ['user', userStore(home), userStore(home), path.join(userStore(home), 'memory.db')]); + assert.equal(location.reason, "inside ~/.codex, a tool's own folder, in a repository whose root is the home folder"); +}); + +test('location does not repeat a reason when root and folder share one tool area', (t) => { + const home = sandbox(t); + const root = mkdir(path.join(home, '.codex', 'workspace')); + fs.mkdirSync(path.join(root, '.git')); + const child = mkdir(path.join(root, 'src')); + assert.equal(rufloMemoryLocation(child, { home }).reason, "inside ~/.codex, a tool's own folder"); + assert.equal(rufloMemoryLocation(root, { home }).reason, "inside ~/.codex, a tool's own folder"); +}); + test('the launcher pins both memory variables to the user-level store and starts Ruflo inside it', (t) => { const home = sandbox(t); const launch = rufloMcpLaunch(home, { CLAUDE_FLOW_DB_PATH: '/.swarm/memory.db', KEEP: 'yes' }, { cfg, rufloVersion: '3.45.0', home }); From d701b6afa6f2f7e08de8a1a87177091e1acf2c23 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 02:03:31 -0700 Subject: [PATCH 14/54] fix(memory): bind coexistence status to routing evidence --- docs/plans/2026-09-28-follow-ups-v2.md | 4 +- .../status/sections/project-memory.mjs | 16 ++++- src/lib/live-check-evidence.mjs | 14 ++-- src/lib/live-checks.mjs | 22 ++++-- tests/kit/live-check-evidence.test.mjs | 69 ++++++++++++++++++- tests/kit/live-checks.test.mjs | 28 ++++++-- tests/kit/verify-memory-routes.test.mjs | 17 ++++- 7 files changed, 150 insertions(+), 20 deletions(-) diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index bce867cd..1021a407 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -2,7 +2,7 @@ ## Status -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 is implemented in the isolated `fix/follow-ups-v2-rest` branch, pending independent review and integration. C1 has a portable, opt-in live-lock probe that passed on macOS against AQE 3.14.4; its review fixes add abort-aware child closure, a sufficient overall timeout, and corrected isolated CI proposal paths. Linux CI proof and the busy-rule decision remain pending. Other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. +**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 and B3 are implemented in the isolated `fix/follow-ups-v2-rest` branch, pending integration; B4 is implemented there, pending independent review. C1 has a portable, opt-in live-lock probe that passed on macOS against AQE 3.14.4; its review fixes add abort-aware child closure, a sufficient overall timeout, and corrected isolated CI proposal paths. Linux CI proof and the busy-rule decision remain pending. Other rows remain unimplemented. The controller reviews and assigns later rows. One unit commit per row. The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. @@ -15,7 +15,7 @@ The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-u | B1 | `src/lib/paths.mjs`; `src/lib/footprint/index.mjs`, `storage.mjs`, `consumers.mjs`, `storage-reclaim-detectors.mjs`, `install.mjs`; `src/lib/host-readiness-local.mjs`, `live/process-sessions.mjs`, `hook-audit/providers/opencode.mjs`, `usage-opencode.mjs`; `src/commands/uninstall.mjs` | `tests/kit/xdg-relative.test.mjs` and specified regressions; exact-head CI gate passed before edit; preserve nullable OpenCode fallback | | B2 | `src/commands/x/daemon-gc.mjs`, `src/commands/x/host.mjs`, `src/commands/setup.mjs` | New `tests/kit/daemon-gc-rerecord.test.mjs`, `setup-host-rerecord.test.mjs`, `host-pick-rerecord.test.mjs`; Branch 9 Task 8 plus deferred host pick; compare `sync-host-repair.test.mjs` | | B3 | `src/lib/ruflo-memory.mjs`, `paths.mjs` | `tests/kit/ruflo-memory-location.test.mjs`, `project-memory-status.test.mjs`; compose both unsuitable reasons and make `inside()` exclude equality | -| B4 | `src/commands/status/sections/project-memory.mjs`; `src/lib/ruflo-memory-contract.mjs`, `live-check-evidence.mjs` | D-4 **B approved**: `tests/kit/project-memory-status.test.mjs`, `live-check-evidence.test.mjs`, `verify-memory-routes.test.mjs`; info only after successful installed-version `memory-routes` evidence, warn on upgrade or failure | +| B4 | `src/commands/status/sections/project-memory.mjs`; `src/lib/live-check-evidence.mjs`, `live-checks.mjs` | **Implemented, pending review:** distinct `memory-routes` evidence binds installed CLI version and platform; generic `memory` cannot lower the row. Focused evidence, runner, status, and routing tests cover pass, upgrade, failure, timeout, and read-only render. | | B5 | `src/lib/project-memory.mjs`, `aqe-readiness.mjs`; `src/commands/status/sections/project-memory.mjs`, `aqe.mjs` | `tests/kit/project-memory.test.mjs`, `aqe-readiness.test.mjs`, `project-memory-status.test.mjs`; dot-folder scan, real `~/.agentic-qe`, and agentic-qe#757 hint | | B6 | `src/commands/status/sections/ruflo-components.mjs`; `src/lib/ruflo-components/states.mjs` | `tests/kit/ruflo-components-status.test.mjs`; applied-but-unverified row; hooks fix line requires #3419 answer first | | B7 | `src/lib/ruflo-daemon-config.mjs`; `src/commands/sync.mjs`, `sync/plan-versions.mjs` | `tests/kit/sync-daemon-repair.test.mjs`, `sync-dry-run-preview.test.mjs`, `sync-skip-versions.test.mjs`; F6 hidden YAML keys and F7 versions-only preview parity | diff --git a/src/commands/status/sections/project-memory.mjs b/src/commands/status/sections/project-memory.mjs index a76c2f57..e9055539 100644 --- a/src/commands/status/sections/project-memory.mjs +++ b/src/commands/status/sections/project-memory.mjs @@ -26,7 +26,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { projectDaemonAlive } from '../../../lib/daemons.mjs'; -import { formatLiveCheckAge as ago } from '../../../lib/live-check-evidence.mjs'; +import { formatLiveCheckAge as ago, liveCheckInputsKey, readLiveCheck } from '../../../lib/live-check-evidence.mjs'; import { memoryMaintenanceStatus } from '../../../lib/memory-maintenance.mjs'; import { findStrayMemoryStores, projectMemoryStatus } from '../../../lib/project-memory.mjs'; import { findProbeRows } from '../../../lib/memory-probe-cleanup.mjs'; @@ -206,7 +206,19 @@ export default { ? storeMessage(store) : `${path.basename(store.file)} store is unreadable (${store.file}); existing-corpus access unverified`)); } - if (memory.secondary) rows.push(row('memory', 'warn', twoStoreMessage(rufloVersion, platform))); + if (memory.secondary) { + const routing = readLiveCheck('memory-routes', { + inputsKey: liveCheckInputsKey('memory-routes', { routingVersion: rufloVersion, platform }), now, + }); + const observed = typeof rufloVersion === 'string' && rufloVersion.length > 0 && + routing?.status === 'passed' && !routing.invalidated; + const message = observed + ? twoStoreMessage(rufloVersion, platform).replace('MCP routing needs separate verification.', 'Isolated CLI/MCP routing was observed.') + : twoStoreMessage(rufloVersion, platform); + rows.push(row('memory', observed ? 'info' : 'warn', observed + ? `${message}; isolated CLI/MCP routing observed (${ago(routing.ageMs)}) for this installed CLI version and platform; existing-corpus access unverified` + : message)); + } const orphaned = orphanedStoreRow(root, memory); if (orphaned) rows.push(orphaned); rows.push(...maintenanceRows(root, memory, now)); diff --git a/src/lib/live-check-evidence.mjs b/src/lib/live-check-evidence.mjs index 3ec1448f..5e39c9b0 100644 --- a/src/lib/live-check-evidence.mjs +++ b/src/lib/live-check-evidence.mjs @@ -30,8 +30,10 @@ import { warn } from './output.mjs'; // The checks whose results are remembered: the quick, free live checks. One // list, owned by the refresh vocabulary (a constants-only import). import { LIVE_CHECK_IDS } from './refresh.mjs'; +import { installedRoutingVersion } from './ruflo-memory-contract.mjs'; export { LIVE_CHECK_IDS }; +export const RECORDED_CHECK_IDS = Object.freeze([...LIVE_CHECK_IDS, 'memory-routes']); const STATUSES = new Set(['passed', 'failed', 'inconclusive']); /** The sources a record is written with, and the ones it may still be read with. */ const WRITE_SOURCES = new Set(['sync', 'status-refresh-live']); @@ -45,7 +47,7 @@ const REASON_MAX = 200; export const liveCheckDir = () => path.join(paths.evidenceDir(), 'live-check'); function assertKnownId(id) { - if (!LIVE_CHECK_IDS.includes(id)) throw new TypeError(`unknown live check id: ${String(id).slice(0, 40)}`); + if (!RECORDED_CHECK_IDS.includes(id)) throw new TypeError(`unknown live check id: ${String(id).slice(0, 40)}`); } /** Printable, single-line, bounded. Stored reasons come from the kit's own @@ -141,18 +143,22 @@ const INPUTS = { security: () => ({ ruflo: rufloVersion() }), 'deja-vu': ({ cfg }) => ({ dejaVu: cfg.integrations?.tools?.dejaVu ?? null }), memory: () => ({ ruflo: rufloVersion() }), + 'memory-routes': ({ routingVersion, platform }) => ({ + routingVersion: routingVersion === undefined ? installedRoutingVersion() : routingVersion, + platform: platform ?? process.platform, + }), }; /** * The inputs key a check ran against. Writers and readers MUST both call this * so the same configuration yields the same key. Never throws. * @param {string} id - * @param {{cfg?:any,env?:NodeJS.ProcessEnv,cwd?:string}} [facts] + * @param {{cfg?:any,env?:NodeJS.ProcessEnv,cwd?:string,routingVersion?:string|null,platform?:string}} [facts] */ -export function liveCheckInputsKey(id, { cfg = {}, env = process.env, cwd = process.cwd() } = {}) { +export function liveCheckInputsKey(id, { cfg = {}, env = process.env, cwd = process.cwd(), routingVersion, platform } = {}) { assertKnownId(id); let parts; - try { parts = INPUTS[id]({ cfg: cfg ?? {}, env, cwd }); } catch { parts = { unavailable: true }; } + try { parts = INPUTS[id]({ cfg: cfg ?? {}, env, cwd, routingVersion, platform }); } catch { parts = { unavailable: true }; } return digest({ id, ...parts }); } diff --git a/src/lib/live-checks.mjs b/src/lib/live-checks.mjs index 2aaab40c..12aae9b5 100644 --- a/src/lib/live-checks.mjs +++ b/src/lib/live-checks.mjs @@ -107,9 +107,11 @@ export async function observeProjectMemoryRoutes(tmp, env, namespace, deps) { try { const observation = await probeProjectMemoryRoutes(tmp, env, namespace, deps); for (const { level, message } of describeMemoryRoutes(observation)) (level === 'ok' ? ok : warn)(message); + return observation; } catch (e) { // An observation problem must never turn a working CLI proof into a failure. warn(`cross-interface routing not observed: ${e.message}`); + return null; } } @@ -134,9 +136,9 @@ async function purgeProofNamespace(tmp, env, namespace, key, runner = runCmd) { * the CLI round trip: the route observation (the `memory-routes` proof) * starts a real MCP server and can only add warnings, which a live-check * record does not carry. - * @param {{ observeRoutes?: boolean, tmpRoot?: string, runner?: typeof runCmd, haveCmd?: typeof have }} [options] */ + * @param {{ observeRoutes?: boolean, routeVerdict?: boolean, tmpRoot?: string, runner?: typeof runCmd, haveCmd?: typeof have }} [options] */ export async function verifyMemory({ - observeRoutes = true, tmpRoot = os.tmpdir(), runner = runCmd, haveCmd = have, + observeRoutes = true, routeVerdict = false, tmpRoot = os.tmpdir(), runner = runCmd, haveCmd = have, } = {}) { heading('memory — store, retrieve, locate the on-disk row, purge, and observe CLI/MCP routing in an isolated dir'); if (!(await haveCmd('ruflo'))) { fail('ruflo CLI not installed — cannot prove project memory'); return false; } @@ -182,7 +184,15 @@ export async function verifyMemory({ purged = await purgeProofNamespace(tmp, env, namespace, key, runner); if (!purged) { fail('isolated namespace purge did not remove the proof row'); return false; } ok('isolated proof namespace purged'); - if (observeRoutes) await observeProjectMemoryRoutes(tmp, env, namespace); + if (observeRoutes) { + const route = /** @type {{status?:string,cliToMcp?:string,mcpToCli?:string}|null} */ + (await observeProjectMemoryRoutes(tmp, env, namespace)); + if (routeVerdict) return route?.status === 'observed' && + ['visible', 'not-visible'].includes(route.cliToMcp) && + ['visible', 'not-visible'].includes(route.mcpToCli) + ? { status: 'passed', reason: null } + : { status: 'inconclusive', reason: 'cross-interface routing not observed completely' }; + } return true; } catch (e) { fail(`memory proof error: ${e.message}`); @@ -637,9 +647,9 @@ const CHECKS = Object.freeze([ // The full AQE proof remembers only its live embedding request, itself, and // only for a backend the kit manages. { ...slow('aqe'), run: ({ cfg, cwd, onEvidence }) => verifyAqe({ cfg, cwd, onEvidence }) }, - // The memory round trip plus the CLI/MCP route observation; its verdict is - // the memory check's. - { ...slow('memory-routes', 'memory'), run: () => verifyMemory({ observeRoutes: true }) }, + // The memory round trip plus the CLI/MCP route observation has distinct + // evidence; a CLI-only pass cannot establish routing. + { ...slow('memory-routes', 'memory-routes'), run: () => verifyMemory({ observeRoutes: true, routeVerdict: true }) }, ].map((check) => Object.freeze(check))); /** diff --git a/tests/kit/live-check-evidence.test.mjs b/tests/kit/live-check-evidence.test.mjs index f23f385b..7b010b60 100644 --- a/tests/kit/live-check-evidence.test.mjs +++ b/tests/kit/live-check-evidence.test.mjs @@ -7,6 +7,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { sandboxHome, assertSandboxed, snapshot, assertUnchanged, captureLog, rmrf, sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot, @@ -18,9 +19,11 @@ const paths = await import('../../src/lib/paths.mjs'); const evidence = await import('../../src/lib/live-check-evidence.mjs'); const { writeEvidence } = await import('../../src/lib/evidence.mjs'); const aqeSection = (await import('../../src/commands/status/sections/aqe.mjs')).default; +const projectMemorySection = (await import('../../src/commands/status/sections/project-memory.mjs')).default; const { SYNC_STEPS } = await import('../../src/commands/sync.mjs'); assertSandboxed(paths, HOME); -paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' })); +const GLOBAL_ROOT = fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' }); +paths._setGlobalRootForTest(GLOBAL_ROOT); const PROJECT = sandboxProject('ak-live-evidence'); const NOW = Date.parse('2026-09-26T12:00:00Z'); @@ -33,6 +36,70 @@ test('the store lives under the kit state directory, one file per check', () => assert.deepEqual([...evidence.LIVE_CHECK_IDS].sort(), ['aqe-embedding', 'deja-vu', 'mcp', 'memory', 'providers', 'security']); assert.equal(evidence.LIVE_CHECK_TTL_MS, 24 * 3600_000); + assert.deepEqual(evidence.RECORDED_CHECK_IDS, [...evidence.LIVE_CHECK_IDS, 'memory-routes']); +}); + +test('routing evidence is separate from the generic memory round trip and bound to CLI version and platform', () => { + reset(); + const key = (routingVersion, platform) => evidence.liveCheckInputsKey('memory-routes', { routingVersion, platform }); + assert.notEqual(key('3.45.0', 'darwin'), key('3.45.1', 'darwin')); + assert.notEqual(key('3.45.0', 'darwin'), key('3.45.0', 'linux')); + evidence.recordLiveCheck({ id: 'memory', status: 'passed', source: 'status-refresh-live', inputsKey: 'generic' }, { now: NOW }); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: key('3.45.0', 'darwin'), now: NOW }), null); + evidence.recordLiveCheck({ id: 'memory-routes', status: 'passed', source: 'status-refresh-live', + inputsKey: key('3.45.0', 'darwin') }, { now: NOW }); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: key('3.45.1', 'darwin'), now: NOW }).invalidated, true); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: key('3.45.0', 'linux'), now: NOW }).invalidated, true); +}); + +test('the routing key follows the installed CLI even when the wrapper version stays fixed', (t) => { + const root = fs.mkdtempSync(path.join(HOME, 'route-version-')); + t.after(() => { paths._setGlobalRootForTest(GLOBAL_ROOT); rmrf(root); }); + const wrapper = path.join(root, 'ruflo'); + const cli = path.join(wrapper, 'node_modules', '@claude-flow', 'cli'); + fs.mkdirSync(cli, { recursive: true }); + fs.writeFileSync(path.join(wrapper, 'package.json'), JSON.stringify({ version: '3.45.0' })); + const setCli = (version) => fs.writeFileSync(path.join(cli, 'package.json'), JSON.stringify({ version })); + paths._setGlobalRootForTest(root); + setCli('3.45.0'); + const before = evidence.liveCheckInputsKey('memory-routes', { platform: 'darwin' }); + setCli('3.45.1'); + assert.notEqual(evidence.liveCheckInputsKey('memory-routes', { platform: 'darwin' }), before); +}); + +test('two-store row lowers only for applicable routing proof and warns after failure or upgrade', async (t) => { + reset(); + const cwd = sandboxProject('ak-route-row'); + t.after(() => rmrf(cwd)); + const dir = path.join(cwd, '.swarm'); + fs.mkdirSync(dir); + for (const name of ['memory.db', 'agentdb-memory.db']) { + const db = new DatabaseSync(path.join(dir, name)); + db.exec('CREATE TABLE memory_entries (status TEXT); INSERT INTO memory_entries VALUES (NULL)'); + db.close(); + } + const row = async (routingVersion = '3.45.0', platform = 'darwin', now = NOW) => + (await projectMemorySection.collect({ cwd, rufloVersion: routingVersion, platform, now })) + .find((item) => /two project memory stores/.test(item.message)); + const key = evidence.liveCheckInputsKey('memory-routes', { routingVersion: '3.45.0', platform: 'darwin' }); + assert.equal((await row()).level, 'warn'); + evidence.recordLiveCheck({ id: 'memory', status: 'passed', source: 'status-refresh-live', inputsKey: 'generic' }, { now: NOW }); + assert.equal((await row()).level, 'warn'); + evidence.recordLiveCheck({ id: 'memory-routes', status: 'passed', source: 'status-refresh-live', inputsKey: key }, { now: NOW }); + const beforeRead = snapshot(HOME); + assert.equal((await row()).level, 'info'); + assert.match((await row()).message, /existing-corpus access unverified/); + assertUnchanged(beforeRead, HOME, 'ordinary project-memory status reads neither probe nor write'); + assert.equal((await row('3.45.1')).level, 'warn'); + assert.equal((await row('3.45.0', 'linux')).level, 'warn'); + assert.equal((await row(null)).level, 'warn'); + assert.equal((await row('3.45.0', 'darwin', NOW + 2 * evidence.LIVE_CHECK_TTL_MS)).level, 'info'); + evidence.recordLiveCheck({ id: 'memory-routes', status: 'inconclusive', source: 'status-refresh-live', inputsKey: key }, { now: NOW + 1000 }); + assert.equal((await row('3.45.0', 'darwin', NOW + 2000)).level, 'warn'); + evidence.recordLiveCheck({ id: 'memory', status: 'passed', source: 'status-refresh-live', inputsKey: 'generic' }, { now: NOW + 3000 }); + assert.equal((await row('3.45.0', 'darwin', NOW + 4000)).level, 'warn'); + fs.writeFileSync(path.join(evidence.liveCheckDir(), 'memory-routes.json'), '{corrupt'); + assert.equal((await row()).level, 'warn'); }); test('a recorded result reads back with its source and age', () => { diff --git a/tests/kit/live-checks.test.mjs b/tests/kit/live-checks.test.mjs index 75a2b1e0..30982202 100644 --- a/tests/kit/live-checks.test.mjs +++ b/tests/kit/live-checks.test.mjs @@ -93,7 +93,7 @@ test('each check carries its timeout and the evidence id its result is remembere assert.equal(entry('learning').evidenceId, null); assert.equal(entry('harvest').evidenceId, null); assert.equal(entry('aqe').evidenceId, null, 'the aqe proof remembers only its embedding request, itself'); - assert.equal(entry('memory-routes').evidenceId, 'memory'); + assert.equal(entry('memory-routes').evidenceId, 'memory-routes'); }); test('without --only the quick checks that apply run; mcp runs whenever Codex is enabled', async () => { @@ -585,14 +585,34 @@ test('a failed check is remembered for status with its first failure as the reas assert.equal(got.reason, '@claude-flow/security missing'); }); -test('the memory-routes proof is remembered as the memory check; learning and harvest are not remembered', async () => { +test('the memory-routes proof is remembered separately; learning and harvest are not remembered', async () => { seedHome(); rmrf(evidence.liveCheckDir()); await runOnly(['memory-routes', 'learning', 'harvest']); - const got = evidence.readLiveCheck('memory', {}); + const got = evidence.readLiveCheck('memory-routes', {}); assert.equal(got.status, 'failed'); assert.equal(got.source, 'status-refresh-live'); - assert.deepEqual(fs.readdirSync(evidence.liveCheckDir()), ['memory.json']); + assert.equal(evidence.readLiveCheck('memory', {}), null); + assert.deepEqual(fs.readdirSync(evidence.liveCheckDir()), ['memory-routes.json']); +}); + +test('a failed or timed-out routing run replaces a previous pass; a later generic memory pass cannot revive it', async () => { + seedHome(); + rmrf(evidence.liveCheckDir()); + const cfg = offlineKitConfig(); + const route = (run, timeoutMs = 50) => ({ id: 'memory-routes', evidenceId: 'memory-routes', + timeoutMs, applies: () => true, run }); + const generic = { id: 'memory', evidenceId: 'memory', timeoutMs: 50, applies: () => true, + run: async () => true }; + await live.runLiveChecks({ cfg, cwd: PROJECT, checks: [route(async () => ({ status: 'passed' }))] }); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'passed'); + await live.runLiveChecks({ cfg, cwd: PROJECT, checks: [route(async () => false)] }); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'failed'); + await live.runLiveChecks({ cfg, cwd: PROJECT, checks: [route(() => new Promise(() => {}), 10)], graceMs: 1 }); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); + await live.runLiveChecks({ cfg, cwd: PROJECT, checks: [generic] }); + assert.equal(evidence.readLiveCheck('memory').status, 'passed'); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); }); test('a skipped deja-vu proof is not remembered as a pass', async () => { diff --git a/tests/kit/verify-memory-routes.test.mjs b/tests/kit/verify-memory-routes.test.mjs index b1e87d23..035b2d7b 100644 --- a/tests/kit/verify-memory-routes.test.mjs +++ b/tests/kit/verify-memory-routes.test.mjs @@ -241,8 +241,10 @@ test('the quick live memory check proves the CLI round trip without starting an assert.ok(!calls.includes('mcp start'), 'the live check stays quick: routing is observed only by the memory-routes proof'); }); -test('--only memory-routes runs the memory proof with the route observation and remembers it as memory', posix, async () => { +test('--only memory-routes runs the memory proof with the route observation and remembers it separately', posix, async () => { const cfg = offlineKitConfig(); + const evidence = await import('../../src/lib/live-check-evidence.mjs'); + const beforeMemory = evidence.readLiveCheck('memory'); const { results, calls } = await withFakeRuflo('aligned', async () => ({ results: await verify.runLiveChecks({ cfg, cwd: PROJECT, only: ['memory-routes'] }), })); @@ -250,4 +252,17 @@ test('--only memory-routes runs the memory proof with the route observation and assert.equal(results[0].status, 'passed', JSON.stringify(results[0])); assert.ok(calls.includes('mcp start'), 'the named proof observes CLI↔MCP routing'); assert.ok(results[0].entries.some((e) => /see each other's writes/.test(e.text)), JSON.stringify(results[0].entries)); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'passed'); + assert.equal(evidence.readLiveCheck('memory')?.checkedAt, beforeMemory?.checkedAt, + 'the route proof does not overwrite generic memory evidence'); +}); + +test('an unavailable route observation is inconclusive even after a successful CLI round trip', posix, async () => { + const cfg = offlineKitConfig(); + const { results } = await withFakeRuflo('mcp-down', async () => ({ + results: await verify.runLiveChecks({ cfg, cwd: PROJECT, only: ['memory-routes'] }), + })); + assert.equal(results[0].status, 'inconclusive'); + const evidence = await import('../../src/lib/live-check-evidence.mjs'); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); }); From 722c992f9d35707f178038fee0f4707e8873b3b1 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 02:07:33 -0700 Subject: [PATCH 15/54] test(status): align offline self cache with checked tags --- tests/kit/helpers/home-sandbox.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/kit/helpers/home-sandbox.mjs b/tests/kit/helpers/home-sandbox.mjs index 65f8bad0..038c6dfd 100644 --- a/tests/kit/helpers/home-sandbox.mjs +++ b/tests/kit/helpers/home-sandbox.mjs @@ -287,7 +287,9 @@ export function offlineKitConfig(extra = {}) { ttlHours: 24, last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' }, - self: { last: Date.now(), best: { version: '0.0.1', tag: 'latest' } }, + // This fixture runs against the prerelease kit, whose self check uses + // both channels. A legacy latest-only record must retry under A3. + self: { last: Date.now(), best: { version: '0.0.1', tag: 'latest' }, lastTags: ['latest', 'next'] }, }, ...extra, }; From c8bfd64f7322718778499bf068352f3c7f723457 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 02:17:22 -0700 Subject: [PATCH 16/54] fix(memory): preserve cli proof across route checks --- docs/adr/0055-aqe-embedding-lifecycle.md | 6 +- ...3-evidence-store-and-refresh-vocabulary.md | 6 +- docs/troubleshooting.md | 2 +- src/commands/status.mjs | 2 +- src/lib/live-check-evidence.mjs | 6 +- src/lib/live-checks.mjs | 41 ++++++++---- tests/kit/live-checks.test.mjs | 64 ++++++++++++++++++- tests/kit/verify-memory-routes.test.mjs | 13 ++-- 8 files changed, 111 insertions(+), 29 deletions(-) diff --git a/docs/adr/0055-aqe-embedding-lifecycle.md b/docs/adr/0055-aqe-embedding-lifecycle.md index 0f5bacd4..8dc742f1 100644 --- a/docs/adr/0055-aqe-embedding-lifecycle.md +++ b/docs/adr/0055-aqe-embedding-lifecycle.md @@ -21,9 +21,9 @@ full `aqe` proof runs with `--only aqe`. A live-check evidence row now carries the source id `status-refresh-live`, labelled "ak status --refresh=live"; a row recorded before this rename under the retired `verify`/`status-live` source ids still reads back, labelled "an earlier live - check" — the label never names a retired command. Evidence ids are unchanged: `memory-routes` - still records under the `memory` id, and the full `aqe` proof still records only its embedding - request under `aqe-embedding` (remediation program, branch 6b; see + check" — the label never names a retired command. `memory-routes` records its CLI round trip + under `memory` and its routing observation under `memory-routes`; the full `aqe` proof still + records only its embedding request under `aqe-embedding` (remediation program, branch 6b; see [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md)) - **Related:** [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md), [September repair](https://github.com/pacphi/agentic-kit/blob/main/docs/archive/2026-09-09-audit-aqe-integration-repair.md) diff --git a/docs/adr/0063-evidence-store-and-refresh-vocabulary.md b/docs/adr/0063-evidence-store-and-refresh-vocabulary.md index c3a15207..e8f62658 100644 --- a/docs/adr/0063-evidence-store-and-refresh-vocabulary.md +++ b/docs/adr/0063-evidence-store-and-refresh-vocabulary.md @@ -428,9 +428,9 @@ this branch" section, and "Ahead: the dashboard half" below. `learning`, `harvest`, `aqe`, `memory-routes` — slow, `--only`-only) now live in `src/lib/live-checks.mjs` and run as `--refresh=live`'s `live` stage. `memory` is the quick store/retrieve/purge round trip; `memory-routes` additionally observes whether the CLI and MCP - see each other's writes, and its result is remembered under the `memory` evidence id, not its - own. A live-check evidence row now carries the source id `status-refresh-live`, labelled "ak - status --refresh=live"; a row recorded before this branch under the retired `verify` or + see each other's writes. Its CLI result is remembered under `memory` and its routing result + under `memory-routes`. A live-check evidence row carries the source id + `status-refresh-live`, labelled "ak status --refresh=live"; a row recorded before this branch under the retired `verify` or `status-live` source ids still reads back, labelled "an earlier live check" — the label never names a retired command (`live-check-evidence.mjs`'s `SOURCE_LABEL`). - **The Codex quota presence gate.** `/api/limits` asks `codex app-server` for its diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 576d9f40..5fdcba7b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -151,7 +151,7 @@ up to six minutes each: ak status --refresh=live --only learning # trains a cycle in an isolated dir; asserts patterns persist to disk ak status --refresh=live --only aqe # agentic-qe genuinely on ruvector (no FsyncFailed) ak status --refresh=live --only harvest # Ruflo's learning-write path (post-task + distill) in an isolated store -ak status --refresh=live --only memory-routes # CLI/MCP routing observation, remembered as the memory check +ak status --refresh=live --only memory-routes # CLI round trip remembered as memory; routing observation remembered separately ak status --refresh=live --only learning,harvest,aqe,memory-routes,security,deja-vu,providers,mcp,aqe-embedding ``` diff --git a/src/commands/status.mjs b/src/commands/status.mjs index e45a296a..f7147d31 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -89,7 +89,7 @@ Slow proofs run only when named with --only, up to six minutes each: aqe storage, embedding configuration and provenance, and the browser payload memory-routes the memory round trip, plus whether CLI and MCP see each - other's writes (remembered as the memory check) + other's writes (CLI result remembered as memory; routing separately) A named check runs even when it would not apply; its result is remembered only when it applies. learning and harvest are never remembered. diff --git a/src/lib/live-check-evidence.mjs b/src/lib/live-check-evidence.mjs index 5e39c9b0..c3143f03 100644 --- a/src/lib/live-check-evidence.mjs +++ b/src/lib/live-check-evidence.mjs @@ -187,12 +187,12 @@ export function embeddingProbeOutcome(live) { * when it could not be remembered. Returns whether it was recorded. * @param {string} id * @param {{status:string,reason?:string|null}|null} outcome - * @param {{source:string,cfg?:any,env?:NodeJS.ProcessEnv,cwd?:string,now?:number}} context + * @param {{source:string,cfg?:any,env?:NodeJS.ProcessEnv,cwd?:string,now?:number,inputsKey?:string}} context */ -export function rememberLiveCheck(id, outcome, { source, cfg, env, cwd, now } = /** @type {any} */ ({})) { +export function rememberLiveCheck(id, outcome, { source, cfg, env, cwd, now, inputsKey } = /** @type {any} */ ({})) { if (!outcome) return false; const recorded = recordLiveCheck({ id, status: outcome.status, reason: outcome.reason ?? null, source, - inputsKey: liveCheckInputsKey(id, { cfg, env, cwd }) }, { now }); + inputsKey: inputsKey ?? liveCheckInputsKey(id, { cfg, env, cwd }) }, { now }); if (!recorded) warn(`${id}: could not remember this live check result; ak status will not show it`); return recorded; } diff --git a/src/lib/live-checks.mjs b/src/lib/live-checks.mjs index 12aae9b5..e781595a 100644 --- a/src/lib/live-checks.mjs +++ b/src/lib/live-checks.mjs @@ -31,7 +31,7 @@ import { runHarvest } from './harvest.mjs'; import { runLifecycle } from './adapters/lifecycle.mjs'; import { companionLifecycleFor } from './adapters/companion-lifecycle-registry.mjs'; import { ok, warn, fail, info, heading, captureOutput } from './output.mjs'; -import { rememberLiveCheck, embeddingProbeOutcome } from './live-check-evidence.mjs'; +import { rememberLiveCheck, liveCheckInputsKey, embeddingProbeOutcome } from './live-check-evidence.mjs'; import { LIVE_CHECK_IDS, SLOW_PROOF_IDS } from './refresh.mjs'; export { LIVE_CHECK_IDS, SLOW_PROOF_IDS }; @@ -134,11 +134,12 @@ async function purgeProofNamespace(tmp, env, namespace, key, runner = runCmd) { /** The memory round trip in an isolated folder under `tmpRoot`, removed * whatever happens. `observeRoutes: false` keeps the quick `memory` check to * the CLI round trip: the route observation (the `memory-routes` proof) - * starts a real MCP server and can only add warnings, which a live-check - * record does not carry. - * @param {{ observeRoutes?: boolean, routeVerdict?: boolean, tmpRoot?: string, runner?: typeof runCmd, haveCmd?: typeof have }} [options] */ + * starts a real MCP server. Its observation has separate evidence, while + * the CLI round-trip evidence remains under `memory`. + * @param {{ observeRoutes?: boolean, routeVerdict?: boolean, onCliOutcome?: (outcome:{status:string,reason:null})=>void, + * tmpRoot?: string, runner?: typeof runCmd, haveCmd?: typeof have }} [options] */ export async function verifyMemory({ - observeRoutes = true, routeVerdict = false, tmpRoot = os.tmpdir(), runner = runCmd, haveCmd = have, + observeRoutes = true, routeVerdict = false, onCliOutcome, tmpRoot = os.tmpdir(), runner = runCmd, haveCmd = have, } = {}) { heading('memory — store, retrieve, locate the on-disk row, purge, and observe CLI/MCP routing in an isolated dir'); if (!(await haveCmd('ruflo'))) { fail('ruflo CLI not installed — cannot prove project memory'); return false; } @@ -184,6 +185,7 @@ export async function verifyMemory({ purged = await purgeProofNamespace(tmp, env, namespace, key, runner); if (!purged) { fail('isolated namespace purge did not remove the proof row'); return false; } ok('isolated proof namespace purged'); + onCliOutcome?.({ status: 'passed', reason: null }); if (observeRoutes) { const route = /** @type {{status?:string,cliToMcp?:string,mcpToCli?:string}|null} */ (await observeProjectMemoryRoutes(tmp, env, namespace)); @@ -649,7 +651,8 @@ const CHECKS = Object.freeze([ { ...slow('aqe'), run: ({ cfg, cwd, onEvidence }) => verifyAqe({ cfg, cwd, onEvidence }) }, // The memory round trip plus the CLI/MCP route observation has distinct // evidence; a CLI-only pass cannot establish routing. - { ...slow('memory-routes', 'memory-routes'), run: () => verifyMemory({ observeRoutes: true, routeVerdict: true }) }, + { ...slow('memory-routes', 'memory-routes'), run: ({ onCliOutcome }) => + verifyMemory({ observeRoutes: true, routeVerdict: true, onCliOutcome }) }, ].map((check) => Object.freeze(check))); /** @@ -683,7 +686,9 @@ const duration = (ms) => (ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`) async function runOneLiveCheck(check, ctx, { timeoutMs, graceMs }) { const controller = new AbortController(); const started = Date.now(); - const work = captureOutput(() => withAbortSignal(controller.signal, () => check.run(ctx))) + let cliOutcome = null; + const work = captureOutput(() => withAbortSignal(controller.signal, + () => check.run({ ...ctx, onCliOutcome: (outcome) => { cliOutcome = outcome; } }))) .then(({ result, entries }) => ({ outcome: checkOutcome(result, entries, check.id), entries }), () => ({ outcome: { status: 'inconclusive', reason: 'the check could not run' }, entries: [] })); const deadline = sleep(timeoutMs); @@ -697,7 +702,8 @@ async function runOneLiveCheck(check, ctx, { timeoutMs, graceMs }) { settled = { outcome: { status: 'inconclusive', reason: `no result within ${duration(timeoutMs)}` }, entries: late?.entries ?? [] }; } const { outcome, entries } = settled; - return { id: check.id, status: outcome.status, reason: outcome.reason ?? null, elapsedMs: Date.now() - started, entries }; + return { id: check.id, status: outcome.status, reason: outcome.reason ?? null, + elapsedMs: Date.now() - started, entries, cliOutcome }; } /** @@ -716,15 +722,28 @@ export async function runLiveChecks({ cfg = loadKitConfig(), cwd = process.cwd(), only = [], checks = liveChecksFor(cfg, only), timeoutMs, graceMs = LIVE_CHECK_GRACE_MS, source = 'status-refresh-live', } = {}) { - const remember = (id, outcome) => rememberLiveCheck(id, outcome, { source, cfg, cwd }); + const remember = (id, outcome, inputsKey) => rememberLiveCheck(id, outcome, { source, cfg, cwd, inputsKey }); const ctx = { cfg, cwd, onEvidence: remember }; + // Capture the installed implementation before any proof starts. A package + // upgrade while the slow check runs cannot turn an old observation into a + // pass for the newly installed CLI. + const routeKeys = checks.map((check) => check.id === 'memory-routes' + ? liveCheckInputsKey('memory-routes', { cfg, cwd }) : null); const results = await Promise.all(checks.map(async (check) => ({ ...(await runOneLiveCheck(check, ctx, { timeoutMs: timeoutMs ?? check.timeoutMs ?? QUICK_TIMEOUT_MS, graceMs })), applies: check.applies?.(cfg ?? {}) ?? true, }))); checks.forEach((check, i) => { const evidenceId = check.evidenceId === undefined ? check.id : check.evidenceId; - if (evidenceId && results[i].applies) remember(evidenceId, results[i]); + if (!results[i].applies) return; + if (check.id === 'memory-routes') { + remember('memory', results[i].cliOutcome ?? results[i]); + if (routeKeys[i] !== liveCheckInputsKey('memory-routes', { cfg, cwd })) { + results[i].status = 'inconclusive'; + results[i].reason = 'installed routing implementation changed during the check'; + } + remember('memory-routes', results[i], routeKeys[i]); + } else if (evidenceId) remember(evidenceId, results[i]); }); - return results; + return results.map(({ cliOutcome: _cliOutcome, ...result }) => result); } diff --git a/tests/kit/live-checks.test.mjs b/tests/kit/live-checks.test.mjs index 30982202..a9eb9248 100644 --- a/tests/kit/live-checks.test.mjs +++ b/tests/kit/live-checks.test.mjs @@ -12,6 +12,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { sandboxHome, assertSandboxed, snapshot, assertUnchanged, captureLog, rmrf, @@ -22,13 +23,15 @@ const HOME = sandboxHome('ak-live-checks'); const paths = await import('../../src/lib/paths.mjs'); const live = await import('../../src/lib/live-checks.mjs'); const evidence = await import('../../src/lib/live-check-evidence.mjs'); +const projectMemorySection = (await import('../../src/commands/status/sections/project-memory.mjs')).default; const { refreshRequestFromFlags, cliRefreshStages } = await import('../../src/lib/refresh.mjs'); const status = await import('../../src/commands/status.mjs'); const { loadKitConfig } = await import('../../src/lib/config.mjs'); assertSandboxed(paths, HOME); const PROJECT = sandboxProject('ak-live-checks'); -paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9' })); +const GLOBAL_ROOT = fakeGlobalRoot(HOME, { ruflo: '9.9.9' }); +paths._setGlobalRootForTest(GLOBAL_ROOT); const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const seedHome = (cfg = offlineKitConfig()) => { @@ -592,8 +595,8 @@ test('the memory-routes proof is remembered separately; learning and harvest are const got = evidence.readLiveCheck('memory-routes', {}); assert.equal(got.status, 'failed'); assert.equal(got.source, 'status-refresh-live'); - assert.equal(evidence.readLiveCheck('memory', {}), null); - assert.deepEqual(fs.readdirSync(evidence.liveCheckDir()), ['memory-routes.json']); + assert.equal(evidence.readLiveCheck('memory', {}).status, 'failed'); + assert.deepEqual(fs.readdirSync(evidence.liveCheckDir()), ['memory-routes.json', 'memory.json']); }); test('a failed or timed-out routing run replaces a previous pass; a later generic memory pass cannot revive it', async () => { @@ -615,6 +618,61 @@ test('a failed or timed-out routing run replaces a previous pass; a later generi assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); }); +test('a route timeout after the CLI proof keeps the generic memory pass', async () => { + seedHome(); + rmrf(evidence.liveCheckDir()); + const check = { id: 'memory-routes', evidenceId: 'memory-routes', timeoutMs: 10, applies: () => true, + run: ({ onCliOutcome }) => { + onCliOutcome({ status: 'passed', reason: null }); + return new Promise(() => {}); + } }; + const [result] = await live.runLiveChecks({ cfg: offlineKitConfig(), cwd: PROJECT, checks: [check], graceMs: 1 }); + assert.equal(result.status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory').status, 'passed'); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); +}); + +test('a CLI upgrade during the route check cannot attribute the old observation to the new version', async (t) => { + seedHome(); + rmrf(evidence.liveCheckDir()); + const root = fs.mkdtempSync(path.join(HOME, 'routing-upgrade-')); + const cli = path.join(root, 'ruflo', 'node_modules', '@claude-flow', 'cli'); + fs.mkdirSync(cli, { recursive: true }); + const setVersion = (version) => fs.writeFileSync(path.join(cli, 'package.json'), JSON.stringify({ version })); + t.after(() => { paths._setGlobalRootForTest(GLOBAL_ROOT); rmrf(root); }); + paths._setGlobalRootForTest(root); + setVersion('3.45.0'); + const oldKey = evidence.liveCheckInputsKey('memory-routes'); + const check = { id: 'memory-routes', evidenceId: 'memory-routes', timeoutMs: 50, applies: () => true, + run: async ({ onCliOutcome }) => { + onCliOutcome({ status: 'passed', reason: null }); + setVersion('3.45.1'); + return { status: 'passed', reason: null }; + } }; + const [result] = await live.runLiveChecks({ cfg: offlineKitConfig(), cwd: PROJECT, checks: [check] }); + const currentKey = evidence.liveCheckInputsKey('memory-routes'); + assert.notEqual(currentKey, oldKey); + assert.equal(result.status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: oldKey }).status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: oldKey }).invalidated, false); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: currentKey }).invalidated, true); + assert.equal(evidence.readLiveCheck('memory').status, 'passed'); + + const project = sandboxProject('ak-route-upgrade-row'); + t.after(() => rmrf(project)); + const swarm = path.join(project, '.swarm'); + fs.mkdirSync(swarm); + for (const name of ['memory.db', 'agentdb-memory.db']) { + const db = new DatabaseSync(path.join(swarm, name)); + db.exec('CREATE TABLE memory_entries (status TEXT); INSERT INTO memory_entries VALUES (NULL)'); + db.close(); + } + const row = async (version) => (await projectMemorySection.collect({ cwd: project, + rufloVersion: version })).find((item) => /two project memory stores/.test(item.message)); + assert.equal((await row('3.45.0')).level, 'warn'); + assert.equal((await row('3.45.1')).level, 'warn'); +}); + test('a skipped deja-vu proof is not remembered as a pass', async () => { seedHome(); rmrf(evidence.liveCheckDir()); diff --git a/tests/kit/verify-memory-routes.test.mjs b/tests/kit/verify-memory-routes.test.mjs index 035b2d7b..cf530a51 100644 --- a/tests/kit/verify-memory-routes.test.mjs +++ b/tests/kit/verify-memory-routes.test.mjs @@ -244,7 +244,8 @@ test('the quick live memory check proves the CLI round trip without starting an test('--only memory-routes runs the memory proof with the route observation and remembers it separately', posix, async () => { const cfg = offlineKitConfig(); const evidence = await import('../../src/lib/live-check-evidence.mjs'); - const beforeMemory = evidence.readLiveCheck('memory'); + evidence.recordLiveCheck({ id: 'memory', status: 'failed', source: 'status-refresh-live', + inputsKey: evidence.liveCheckInputsKey('memory') }); const { results, calls } = await withFakeRuflo('aligned', async () => ({ results: await verify.runLiveChecks({ cfg, cwd: PROJECT, only: ['memory-routes'] }), })); @@ -253,16 +254,20 @@ test('--only memory-routes runs the memory proof with the route observation and assert.ok(calls.includes('mcp start'), 'the named proof observes CLI↔MCP routing'); assert.ok(results[0].entries.some((e) => /see each other's writes/.test(e.text)), JSON.stringify(results[0].entries)); assert.equal(evidence.readLiveCheck('memory-routes').status, 'passed'); - assert.equal(evidence.readLiveCheck('memory')?.checkedAt, beforeMemory?.checkedAt, - 'the route proof does not overwrite generic memory evidence'); + assert.equal(evidence.readLiveCheck('memory').status, 'passed', + 'the successful CLI round trip refreshes the generic memory row'); }); test('an unavailable route observation is inconclusive even after a successful CLI round trip', posix, async () => { const cfg = offlineKitConfig(); + const evidence = await import('../../src/lib/live-check-evidence.mjs'); + evidence.recordLiveCheck({ id: 'memory', status: 'failed', source: 'status-refresh-live', + inputsKey: evidence.liveCheckInputsKey('memory') }); const { results } = await withFakeRuflo('mcp-down', async () => ({ results: await verify.runLiveChecks({ cfg, cwd: PROJECT, only: ['memory-routes'] }), })); assert.equal(results[0].status, 'inconclusive'); - const evidence = await import('../../src/lib/live-check-evidence.mjs'); assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory').status, 'passed', + 'MCP unavailability does not undo the successful CLI proof'); }); From ae5d9fea85d05941dce32153f3c77be735f56808 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 02:27:06 -0700 Subject: [PATCH 17/54] fix(memory): discover stray stores in ordinary dot directories --- src/lib/project-memory.mjs | 21 ++++++++++--------- tests/kit/project-memory.test.mjs | 35 ++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/lib/project-memory.mjs b/src/lib/project-memory.mjs index e42dbe3b..1801eef4 100644 --- a/src/lib/project-memory.mjs +++ b/src/lib/project-memory.mjs @@ -143,13 +143,14 @@ export function removeMemoryProbe(root, namespace, key) { // aqe a .agentic-qe/ folder below the project root (AQE resolves a // relative AQE_MEMORY_PATH against the folder it runs in) // Bounded: at most `maxDirs` folders listed and `maxDepth` levels deep; dot -// folders (other checkouts under .claude/worktrees, .git) and node_modules are -// never walked, only a root dot folder's own markers are checked. A folder that +// tool homes (including other checkouts under .claude/worktrees), .git and +// node_modules are never walked. Ordinary dot folders are walked. A folder that // holds `.git` (nested repository, submodule, worktree inside the checkout) is // another repository: neither it nor anything below it is searched; it is // listed in `nestedRepositories`. const RUFLO_STORE_FILES = Object.freeze(['memory.db', 'agentdb-memory.db']); const ROOT_STRAYS = Object.freeze([['agentdb.db', 'agentdb-cli'], ['agentdb.rvf', 'agentdb-rvf'], ['ruvector.db', 'ruvector']]); +const SCAN_EXCLUDED_DIRS = new Set(['.git', '.swarm', '.agentic-qe', '.claude', '.codex', '.claude-flow', '.agents', '.harness', 'node_modules']); const isDirectory = (file) => { try { return fs.lstatSync(file).isDirectory(); } catch { return false; } }; const isFile = (file) => { try { return fs.lstatSync(file).isFile(); } catch { return false; } }; @@ -170,7 +171,7 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { visited += 1; try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); - } catch { return []; } + } catch { complete = false; return []; } }; const checkMarkers = (dir, { ruflo = true } = {}) => { if (isDirectory(path.join(dir, '.agentic-qe'))) add('aqe', path.join(dir, '.agentic-qe'), null); @@ -202,14 +203,14 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { if (!entry.isDirectory()) continue; const full = path.join(dir, entry.name); if (entry.name !== '.git' && otherRepository(full)) continue; - if (entry.name.startsWith('.')) { - // .swarm's own subtree is walked separately; only its AQE marker here. - if (depth === 0 && entry.name !== '.git') checkMarkers(full, { ruflo: entry.name !== '.swarm' }); - continue; - } - if (entry.name === 'node_modules') continue; - checkMarkers(full); + if (entry.name !== '.git') checkMarkers(full, { ruflo: entry.name !== '.swarm' }); + if (SCAN_EXCLUDED_DIRS.has(entry.name)) continue; if (depth + 1 < maxDepth) walk(full, depth + 1); + else { + // A marker at the depth boundary is visible, but descendants are not. + const children = list(full); + if (children?.some((child) => child.isDirectory() && !SCAN_EXCLUDED_DIRS.has(child.name))) complete = false; + } } }; diff --git a/tests/kit/project-memory.test.mjs b/tests/kit/project-memory.test.mjs index 7579dbba..ddde320b 100644 --- a/tests/kit/project-memory.test.mjs +++ b/tests/kit/project-memory.test.mjs @@ -274,7 +274,7 @@ test('the stray search is bounded and says when it stopped early', (t) => { assert.equal(capped.complete, false); assert.equal(capped.visited, 3); const deep = findStrayMemoryStores(root); - assert.equal(deep.complete, true); + assert.equal(deep.complete, false, 'a depth cutoff leaves descendants unchecked'); assert.deepEqual(deep.strays, [], 'folders deeper than the depth bound are not searched'); assert.deepEqual(findStrayMemoryStores(path.join(root, 'missing')), { strays: [], complete: true, visited: 0, nestedRepositories: [] }); }); @@ -300,3 +300,36 @@ test('the stray search stops at a nested repository or an in-checkout worktree: assert.deepEqual(strays.map((stray) => `${stray.kind} ${stray.path}`), ['aqe docs/.agentic-qe']); assert.deepEqual(nestedRepositories, ['.tools', 'packages/api', 'wt/feature']); }); + +test('the stray search finds AQE below ordinary dot folders without entering tool homes or linked folders', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-dot-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-outside-')); + t.after(() => { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(outside, { recursive: true, force: true }); }); + fs.mkdirSync(path.join(root, '.superpowers', 'sdd', 'program', 'reports', '.agentic-qe'), { recursive: true }); + fs.mkdirSync(path.join(root, '.notes', 'drafts', '.agentic-qe'), { recursive: true }); + for (const dir of ['.git', '.claude', '.codex', '.agentic-qe', '.swarm', 'node_modules']) { + fs.mkdirSync(path.join(root, dir, 'nested', '.agentic-qe'), { recursive: true }); + } + fs.mkdirSync(path.join(outside, '.agentic-qe')); + fs.symlinkSync(outside, path.join(root, '.notes', 'linked')); + fs.symlinkSync(root, path.join(root, '.notes', 'loop')); + fs.mkdirSync(path.join(root, '.other-repo', '.agentic-qe'), { recursive: true }); + fs.writeFileSync(path.join(root, '.other-repo', '.git'), 'gitdir: elsewhere\n'); + + const result = findStrayMemoryStores(root); + assert.equal(result.complete, true); + assert.deepEqual(result.strays.map((stray) => stray.path), [ + '.notes/drafts/.agentic-qe', + '.superpowers/sdd/program/reports/.agentic-qe', + ]); + assert.deepEqual(result.nestedRepositories, ['.other-repo']); +}); + +test('unreadable or depth-limited dot subtrees do not claim complete stray coverage', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-limit-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, '.notes', 'a', 'b', 'c', 'd', '.agentic-qe'), { recursive: true }); + const result = findStrayMemoryStores(root); + assert.equal(result.complete, false); + assert.deepEqual(result.strays, []); +}); From da3b820bdfadb408898b9f728614318cbfad9c88 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 02:28:23 -0700 Subject: [PATCH 18/54] fix(status): report AQE home store separately --- src/commands/status/sections/user-memory.mjs | 21 ++++++++++++++++++++ tests/kit/ruflo-memory-location.test.mjs | 21 ++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/commands/status/sections/user-memory.mjs b/src/commands/status/sections/user-memory.mjs index 2759b8e3..d2306d6c 100644 --- a/src/commands/status/sections/user-memory.mjs +++ b/src/commands/status/sections/user-memory.mjs @@ -5,6 +5,8 @@ // that store when it exists, and the stray stores such sessions left before: // `~/.swarm` and `~/.codex/.chatgpt-projects/*/.swarm`. Everything here is // information only: ak never moves, merges or deletes a store. +import fs from 'node:fs'; +import path from 'node:path'; import * as paths from '../../../lib/paths.mjs'; import { findUserStrayStores, memoryDirStatus } from '../../../lib/project-memory.mjs'; import { homeRelative } from '../../../lib/ruflo-memory.mjs'; @@ -30,6 +32,23 @@ function strayRow(found, home, userDir) { + (found.complete ? '' : '; only the first 500 Codex project folders were checked')); } +function homeAqeRow(home) { + const dir = path.join(home, '.agentic-qe'); + let folder; + try { folder = fs.lstatSync(dir); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } + if (!folder.isDirectory()) return row('aqe', 'info', `AQE home path ${dir} is not a directory; contents unverified`); + const db = path.join(dir, 'memory.db'); + let file; + try { file = fs.lstatSync(db); } catch (e) { if (e.code !== 'ENOENT') throw e; } + if (!file?.isFile()) return row('aqe', 'info', `AQE home directory ${dir}: memory.db absent; contents and runtime health unverified`); + let walBytes = 0; + try { + const wal = fs.lstatSync(`${db}-wal`); + if (wal.isFile()) walBytes = wal.size; + } catch (e) { if (e.code !== 'ENOENT') throw e; } + return row('aqe', 'info', `AQE home directory ${dir}: memory.db present (${formatBytes(file.size + walBytes)} with WAL); contents and runtime health unverified`); +} + export default { id: 'user-memory', /** @param {{ home?: string, env?: NodeJS.ProcessEnv, cfg?: any }} [ctx] */ @@ -47,6 +66,8 @@ export default { const found = findUserStrayStores({ home, codexHome: env.CODEX_HOME || undefined }); const stray = strayRow(found, home, dir); if (stray) rows.push(stray); + const aqe = homeAqeRow(home); + if (aqe) rows.push(aqe); } catch (e) { rows.push(row('memory', 'warn', `user-level memory check unavailable: ${e.message}`)); } diff --git a/tests/kit/ruflo-memory-location.test.mjs b/tests/kit/ruflo-memory-location.test.mjs index 1fa5d0aa..32fa0c54 100644 --- a/tests/kit/ruflo-memory-location.test.mjs +++ b/tests/kit/ruflo-memory-location.test.mjs @@ -258,6 +258,27 @@ test('status reports the user-level store and stray stores outside projects, for assert.match(strays[0].message, /leaves them in place/); }); +test('user status reports AQE home data separately without calling an empty folder healthy', async (t) => { + const home = sandbox(t); + const aqeDir = path.join(home, '.agentic-qe'); + fs.mkdirSync(aqeDir); + let rows = await userMemory.collect({ home, env: {} }); + let aqe = rows.find((r) => r.message.includes(aqeDir)); + assert.ok(aqe); + assert.equal(aqe.level, 'info'); + assert.equal(aqe.fix, null); + assert.match(aqe.message, /AQE.*memory\.db absent.*unverified/); + assert.doesNotMatch(aqe.message, /Ruflo|healthy|merge|move/i); + + fs.writeFileSync(path.join(aqeDir, 'memory.db'), 'placeholder'); + fs.writeFileSync(path.join(aqeDir, 'memory.db-wal'), 'wal'); + rows = await userMemory.collect({ home, env: {} }); + aqe = rows.find((r) => r.message.includes(aqeDir)); + assert.match(aqe.message, /AQE.*memory\.db present.*14 B.*unverified/); + assert.doesNotMatch(aqe.message, /Ruflo|healthy|merge|move/i); + assert.equal(rows.filter((r) => /stray Ruflo/.test(r.message)).length, 0); +}); + test('Claude mode from the home folder pins both memory variables to the user-level store', (t) => { const home = sandbox(t); const launch = rufloMcpLaunch(home, { RUFLO_INTELLIGENCE_MODE: 'fast' }, { cfg, rufloVersion: '3.46.1', home, host: 'claude' }); From 4d376a11677f7a779c14f34a3012c85aa1649e5b Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 02:30:30 -0700 Subject: [PATCH 19/54] fix(status): replace broken AQE Codex setup hint --- docs/plans/2026-09-28-follow-ups-v2.md | 4 ++-- src/commands/status/sections/codex-mcp.mjs | 2 +- src/lib/hook-audit/agentic-dependency-constraints.json | 4 ++-- tests/kit/status-command.test.mjs | 10 ++++++++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index 1021a407..b7d05654 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -2,7 +2,7 @@ ## Status -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 and B3 are implemented in the isolated `fix/follow-ups-v2-rest` branch, pending integration; B4 is implemented there, pending independent review. C1 has a portable, opt-in live-lock probe that passed on macOS against AQE 3.14.4; its review fixes add abort-aware child closure, a sufficient overall timeout, and corrected isolated CI proposal paths. Linux CI proof and the busy-rule decision remain pending. Other rows remain unimplemented. The controller reviews and assigns later rows. One unit commit per row. +**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 and B3 are implemented in the isolated `fix/follow-ups-v2-rest` branch, pending integration; B4 is implemented there, pending independent review. B5's three units are implemented there, pending independent review. C1 has a portable, opt-in live-lock probe that passed on macOS against AQE 3.14.4; its review fixes add abort-aware child closure, a sufficient overall timeout, and corrected isolated CI proposal paths. Linux CI proof and the busy-rule decision remain pending. Other rows remain unimplemented. The controller reviews and assigns later rows. One unit commit per row. The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. @@ -16,7 +16,7 @@ The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-u | B2 | `src/commands/x/daemon-gc.mjs`, `src/commands/x/host.mjs`, `src/commands/setup.mjs` | New `tests/kit/daemon-gc-rerecord.test.mjs`, `setup-host-rerecord.test.mjs`, `host-pick-rerecord.test.mjs`; Branch 9 Task 8 plus deferred host pick; compare `sync-host-repair.test.mjs` | | B3 | `src/lib/ruflo-memory.mjs`, `paths.mjs` | `tests/kit/ruflo-memory-location.test.mjs`, `project-memory-status.test.mjs`; compose both unsuitable reasons and make `inside()` exclude equality | | B4 | `src/commands/status/sections/project-memory.mjs`; `src/lib/live-check-evidence.mjs`, `live-checks.mjs` | **Implemented, pending review:** distinct `memory-routes` evidence binds installed CLI version and platform; generic `memory` cannot lower the row. Focused evidence, runner, status, and routing tests cover pass, upgrade, failure, timeout, and read-only render. | -| B5 | `src/lib/project-memory.mjs`, `aqe-readiness.mjs`; `src/commands/status/sections/project-memory.mjs`, `aqe.mjs` | `tests/kit/project-memory.test.mjs`, `aqe-readiness.test.mjs`, `project-memory-status.test.mjs`; dot-folder scan, real `~/.agentic-qe`, and agentic-qe#757 hint | +| B5 | `src/lib/project-memory.mjs`; `src/commands/status/sections/user-memory.mjs`, `codex-mcp.mjs`; #757 registry entry | **Implemented, pending review:** bounded ordinary dot-folder discovery, read-only AQE home data row, and an AQE-owned init hint. `tests/kit/project-memory.test.mjs`, `project-memory-status.test.mjs`, `ruflo-memory-location.test.mjs`, `status-command.test.mjs` cover the three units. No real store was merged or moved. | | B6 | `src/commands/status/sections/ruflo-components.mjs`; `src/lib/ruflo-components/states.mjs` | `tests/kit/ruflo-components-status.test.mjs`; applied-but-unverified row; hooks fix line requires #3419 answer first | | B7 | `src/lib/ruflo-daemon-config.mjs`; `src/commands/sync.mjs`, `sync/plan-versions.mjs` | `tests/kit/sync-daemon-repair.test.mjs`, `sync-dry-run-preview.test.mjs`, `sync-skip-versions.test.mjs`; F6 hidden YAML keys and F7 versions-only preview parity | | B8 | `src/lib/maintenance/discovery/orchestrator.mjs`, `history.mjs` | `tests/kit/maintenance-discovery-orchestrator.test.mjs`, `maintenance-recovery.test.mjs`; restart after pause shows paused history | diff --git a/src/commands/status/sections/codex-mcp.mjs b/src/commands/status/sections/codex-mcp.mjs index 620db669..2e46c1c1 100644 --- a/src/commands/status/sections/codex-mcp.mjs +++ b/src/commands/status/sections/codex-mcp.mjs @@ -87,7 +87,7 @@ function topologyRows(cwd, cfg) { if (!topology.agenticQeRegistrations.length) { // Agentic-QE owns its Codex registration (ADR-0033); sync never writes it. rows.push(row('codex-mcp', 'warn', 'agentic-qe MCP is not concretely registered in Codex', - 'run: aqe platform setup codex --overwrite --with-ruflo', { repair: 'manual' })); + 'run: aqe init --auto --with-codex --codex-guidance compact in this project, then recheck; AQE 3.14.4 may still omit Codex assets (agentic-qe#755)', { repair: 'manual' })); } else { rows.push(row('codex-mcp', 'ok', 'agentic-qe MCP concretely registered in Codex')); } diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index 8f51ef94..571b4e28 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -2702,8 +2702,8 @@ "dependency": "agentic-qe", "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, "mapping": "mapped", - "kitImpact": {"refs": ["ak runs aqe init --with-codex, not platform setup", "the codex-mcp status row's manual fix names aqe platform setup codex (src/commands/status/sections/codex-mcp.mjs)"], "files": []}, - "adjustment": "none: ak does not run platform setup; once fixed, the codex-mcp status row's manual fix (aqe platform setup codex --overwrite --with-ruflo) works as written.", + "kitImpact": {"refs": ["ak runs aqe init --with-codex, not platform setup", "the codex-mcp status row formerly named the broken platform setup command (src/commands/status/sections/codex-mcp.mjs)"], "files": []}, + "adjustment": "The codex-mcp status row now points to AQE's supported aqe init --auto --with-codex --codex-guidance compact flow and notes the separate 3.14.4 Codex asset gap (agentic-qe#755). ak does not run platform setup or write AQE's Codex registration.", "status": "fixed-unreleased", "constraintIds": [], "history": [ diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index 8b78e10f..92ca6191 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -658,6 +658,7 @@ test('Codex MCP topology fails recursive self-registration and reports missing A 'args = ["x", "ruflo-mcp"]', ].join('\n')); try { + const codexConfigBefore = fs.readFileSync(path.join(PROJECT, '.codex', 'config.toml'), 'utf8'); const rows = rowsFor(await collect(), 'codex-mcp'); assert.equal(rows.find((r) => /recursive codex/.test(r.message))?.level, 'fail'); assert.equal(rows.find((r) => /agentic-qe MCP is not concretely/.test(r.message))?.level, 'warn'); @@ -666,7 +667,12 @@ test('Codex MCP topology fails recursive self-registration and reports missing A // can prove (codexMcpRepairPlan); Agentic-QE owns its own Codex registration. assert.equal(rows.find((r) => /recursive codex/.test(r.message))?.repair, 'sync'); assert.equal(rows.find((r) => /duplicate Ruflo/.test(r.message))?.repair, 'sync'); - assert.equal(rows.find((r) => /agentic-qe MCP is not concretely/.test(r.message))?.repair, 'manual'); + const aqe = rows.find((r) => /agentic-qe MCP is not concretely/.test(r.message)); + assert.equal(aqe?.repair, 'manual'); + assert.match(aqe.fix, /aqe init --auto --with-codex --codex-guidance compact/); + assert.doesNotMatch(aqe.fix, /platform setup|codex mcp-server/); + assert.equal(fs.readFileSync(path.join(PROJECT, '.codex', 'config.toml'), 'utf8'), codexConfigBefore, + 'status only reports the AQE-owned initialization flow'); } finally { rmrf(path.join(PROJECT, '.codex')); } @@ -716,7 +722,7 @@ test('a user-owned deprecated codex mcp-server entry is a manual removal', async }); test('Codex MCP topology does not ask an aqe:false machine to register agentic-qe in Codex', async () => { - // #237 N1: with AQE opted out, `aqe platform setup codex` is advice for a + // #237 N1: with AQE opted out, Codex initialization advice is for a // tool the user declined; the topology rows must honor kit.json like the // aqe section does. seedHome(offlineKitConfig({ From 77557d4f3095c62f447b54d6fbd0d9d23ba7aceb Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 02:37:52 -0700 Subject: [PATCH 20/54] fix(memory): keep dependency markers and metadata errors out of complete scans --- src/lib/project-memory.mjs | 24 +++++++++++++++++------- tests/kit/project-memory.test.mjs | 29 ++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/lib/project-memory.mjs b/src/lib/project-memory.mjs index 1801eef4..07856e85 100644 --- a/src/lib/project-memory.mjs +++ b/src/lib/project-memory.mjs @@ -152,16 +152,25 @@ const RUFLO_STORE_FILES = Object.freeze(['memory.db', 'agentdb-memory.db']); const ROOT_STRAYS = Object.freeze([['agentdb.db', 'agentdb-cli'], ['agentdb.rvf', 'agentdb-rvf'], ['ruvector.db', 'ruvector']]); const SCAN_EXCLUDED_DIRS = new Set(['.git', '.swarm', '.agentic-qe', '.claude', '.codex', '.claude-flow', '.agents', '.harness', 'node_modules']); -const isDirectory = (file) => { try { return fs.lstatSync(file).isDirectory(); } catch { return false; } }; const isFile = (file) => { try { return fs.lstatSync(file).isFile(); } catch { return false; } }; const storeBytes = (file) => (fileBytes(file) ?? 0) + (fileBytes(`${file}-wal`) ?? 0); export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = {}) { - if (!isDirectory(root)) return { strays: [], complete: true, visited: 0, nestedRepositories: [] }; + let rootStat; + try { rootStat = fs.lstatSync(root); } catch (e) { + return { strays: [], complete: e.code === 'ENOENT', visited: 0, nestedRepositories: [] }; + } + if (!rootStat.isDirectory()) return { strays: [], complete: true, visited: 0, nestedRepositories: [] }; const found = new Map(); const nestedRepositories = []; let visited = 0; let complete = true; + const stat = (file) => { + try { return fs.lstatSync(file); } catch (e) { + if (e.code !== 'ENOENT') complete = false; + return null; + } + }; const add = (kind, file, sizeBytes) => { const relative = path.relative(root, file).split(path.sep).join('/'); found.set(relative, { kind, path: relative, file, sizeBytes }); @@ -174,11 +183,11 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { } catch { complete = false; return []; } }; const checkMarkers = (dir, { ruflo = true } = {}) => { - if (isDirectory(path.join(dir, '.agentic-qe'))) add('aqe', path.join(dir, '.agentic-qe'), null); + if (stat(path.join(dir, '.agentic-qe'))?.isDirectory()) add('aqe', path.join(dir, '.agentic-qe'), null); if (!ruflo) return; for (const name of RUFLO_STORE_FILES) { const file = path.join(dir, '.swarm', name); - if (isFile(file)) add('ruflo', file, storeBytes(file)); + if (stat(file)?.isFile()) add('ruflo', file, storeBytes(file)); } }; const walkSwarm = (dir, depth) => { @@ -193,7 +202,7 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { // a worktree inside the checkout) is another repository: its stores are its // own, and ak's pin makes its `.agentic-qe` that repository's store (review M3). const otherRepository = (full) => { - try { fs.lstatSync(path.join(full, '.git')); } catch { return false; } + if (!stat(path.join(full, '.git'))) return false; nestedRepositories.push(path.relative(root, full).split(path.sep).join('/')); return true; }; @@ -201,6 +210,7 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { const entries = list(dir); for (const entry of entries ?? []) { if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules') continue; const full = path.join(dir, entry.name); if (entry.name !== '.git' && otherRepository(full)) continue; if (entry.name !== '.git') checkMarkers(full, { ruflo: entry.name !== '.swarm' }); @@ -216,10 +226,10 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { for (const [name, kind] of ROOT_STRAYS) { const file = path.join(root, name); - if (isFile(file)) add(kind, file, storeBytes(file)); + if (stat(file)?.isFile()) add(kind, file, storeBytes(file)); } // .swarm first: it is small, and the most likely home of a stray Ruflo store. - if (isDirectory(path.join(root, '.swarm'))) walkSwarm(path.join(root, '.swarm'), 0); + if (stat(path.join(root, '.swarm'))?.isDirectory()) walkSwarm(path.join(root, '.swarm'), 0); walk(root, 0); const strays = [...found.values()].sort((a, b) => a.path.localeCompare(b.path)); return { strays, complete, visited, nestedRepositories: nestedRepositories.sort() }; diff --git a/tests/kit/project-memory.test.mjs b/tests/kit/project-memory.test.mjs index ddde320b..30b1cb80 100644 --- a/tests/kit/project-memory.test.mjs +++ b/tests/kit/project-memory.test.mjs @@ -325,7 +325,7 @@ test('the stray search finds AQE below ordinary dot folders without entering too assert.deepEqual(result.nestedRepositories, ['.other-repo']); }); -test('unreadable or depth-limited dot subtrees do not claim complete stray coverage', (t) => { +test('depth-limited dot subtrees do not claim complete stray coverage', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-limit-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); fs.mkdirSync(path.join(root, '.notes', 'a', 'b', 'c', 'd', '.agentic-qe'), { recursive: true }); @@ -333,3 +333,30 @@ test('unreadable or depth-limited dot subtrees do not claim complete stray cover assert.equal(result.complete, false); assert.deepEqual(result.strays, []); }); + +test('dependency root markers are excluded before stray inspection', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-dependency-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'node_modules', '.agentic-qe'), { recursive: true }); + touch(root, 'node_modules/.swarm/memory.db'); + touch(root, 'node_modules/.swarm/agentdb-memory.db'); + const result = findStrayMemoryStores(root); + assert.equal(result.complete, true); + assert.deepEqual(result.strays, []); +}); + +test('a listed dot subtree with denied marker metadata reports incomplete coverage', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-metadata-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, '.notes', '.agentic-qe'), { recursive: true }); + const denied = path.join(root, '.notes', '.agentic-qe'); + const original = fs.lstatSync; + fs.lstatSync = (file, ...args) => { + if (file === denied) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + return original(file, ...args); + }; + let result; + try { result = findStrayMemoryStores(root); } finally { fs.lstatSync = original; } + assert.equal(result.complete, false); + assert.deepEqual(result.strays, []); +}); From 07e9c6738a2573a0dfceafacc0e2e6857d2e9c6a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 02:53:29 -0700 Subject: [PATCH 21/54] fix(status): give one ruflo component restart instruction --- docs/plans/2026-09-28-follow-ups-v2.md | 2 +- .../status/sections/ruflo-components.mjs | 5 ++- tests/kit/ruflo-components-status.test.mjs | 40 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index b7d05654..95ecfa99 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -17,7 +17,7 @@ The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-u | B3 | `src/lib/ruflo-memory.mjs`, `paths.mjs` | `tests/kit/ruflo-memory-location.test.mjs`, `project-memory-status.test.mjs`; compose both unsuitable reasons and make `inside()` exclude equality | | B4 | `src/commands/status/sections/project-memory.mjs`; `src/lib/live-check-evidence.mjs`, `live-checks.mjs` | **Implemented, pending review:** distinct `memory-routes` evidence binds installed CLI version and platform; generic `memory` cannot lower the row. Focused evidence, runner, status, and routing tests cover pass, upgrade, failure, timeout, and read-only render. | | B5 | `src/lib/project-memory.mjs`; `src/commands/status/sections/user-memory.mjs`, `codex-mcp.mjs`; #757 registry entry | **Implemented, pending review:** bounded ordinary dot-folder discovery, read-only AQE home data row, and an AQE-owned init hint. `tests/kit/project-memory.test.mjs`, `project-memory-status.test.mjs`, `ruflo-memory-location.test.mjs`, `status-command.test.mjs` cover the three units. No real store was merged or moved. | -| B6 | `src/commands/status/sections/ruflo-components.mjs`; `src/lib/ruflo-components/states.mjs` | `tests/kit/ruflo-components-status.test.mjs`; applied-but-unverified row; hooks fix line requires #3419 answer first | +| B6 | `src/commands/status/sections/ruflo-components.mjs` | **Implemented, pending review:** applied-but-unverified keeps its state and meaning in the message and gives one restart/recheck instruction in its manual fix. Rendered-row and neighboring-state tests cover the contract. The Codex-hooks fix line remains conditional on a Ruflo-supported answer to #3419 and a pre-PR recheck. | | B7 | `src/lib/ruflo-daemon-config.mjs`; `src/commands/sync.mjs`, `sync/plan-versions.mjs` | `tests/kit/sync-daemon-repair.test.mjs`, `sync-dry-run-preview.test.mjs`, `sync-skip-versions.test.mjs`; F6 hidden YAML keys and F7 versions-only preview parity | | B8 | `src/lib/maintenance/discovery/orchestrator.mjs`, `history.mjs` | `tests/kit/maintenance-discovery-orchestrator.test.mjs`, `maintenance-recovery.test.mjs`; restart after pause shows paused history | | B9 | `src/lib/exec.mjs`, `execution/process-tree.mjs` | `tests/kit/process-tree.test.mjs`; abort kills descendants; Windows CI required | diff --git a/src/commands/status/sections/ruflo-components.mjs b/src/commands/status/sections/ruflo-components.mjs index 9bbdebad..ee5270eb 100644 --- a/src/commands/status/sections/ruflo-components.mjs +++ b/src/commands/status/sections/ruflo-components.mjs @@ -29,7 +29,10 @@ export function rufloComponentRows(snapshot) { const rows = [row('ruflo-components', snapshot.summary.active === snapshot.summary.total ? 'ok' : 'info', `ruflo components: ${snapshot.summary.active} of ${snapshot.summary.total} active (ruflo ${snapshot.rufloVersion ?? 'not installed'})`)]; for (const c of snapshot.components) { - const text = `${c.label} — ${c.state.label}: ${c.state.meaning}${c.state.action ? ` ${c.state.action}` : ''}`; + // The applied-unverified action is carried by its manual fix below; repeating it + // in the message renders the same host restart instruction twice. + const action = c.state.id === 'applied-unverified' ? '' : c.state.action; + const text = `${c.label} — ${c.state.label}: ${c.state.meaning}${action ? ` ${action}` : ''}`; rows.push({ ...(FIXABLE.has(c.state.id) ? row('ruflo-components', LEVEL(c.state.id), text, `sync applies ${c.label} (${c.state.action || 'reconcile'})`) diff --git a/tests/kit/ruflo-components-status.test.mjs b/tests/kit/ruflo-components-status.test.mjs index 8e8be73b..15f468ae 100644 --- a/tests/kit/ruflo-components-status.test.mjs +++ b/tests/kit/ruflo-components-status.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { rufloComponentRows, formatComponentResults, componentResultReport, RESTART_REMINDER } from '../../src/commands/status/sections/ruflo-components.mjs'; +import { describeState } from '../../src/lib/ruflo-components/states.mjs'; import { rufloComponentsTrustGroup, trustManifestLines } from '../../src/lib/trust-manifest.mjs'; const view = (id, label, stateId, stateLabel, meaning, action = '') => ({ id, label, state: { id: stateId, label: stateLabel, meaning, action } }); @@ -23,6 +24,45 @@ test('rows lead with a summary and always carry the meaning', () => { assert.equal(rows[3].fix, null); }); +test('applied-unverified gives one restart instruction across message and manual fix', () => { + const [summary, unverified] = rufloComponentRows({ + rufloVersion: '3.44.0', summary: { active: 0, total: 1 }, components: [ + { id: 'minilmPicker', label: 'MiniLM agent picker', state: describeState('applied-unverified') }, + ], + }); + assert.equal(summary.level, 'info'); + assert.equal(unverified.state, 'applied-unverified'); + assert.equal(unverified.level, 'warn'); + assert.equal(unverified.repair, 'manual'); + assert.match(unverified.message, /MiniLM agent picker — applied, not verified: Set, but not yet confirmed/); + assert.match(unverified.fix, /restart Claude Code, Codex and OpenCode, then run ak status --refresh/i); + assert.equal(`${unverified.message} ${unverified.fix}`.match(/restart Claude Code, Codex and OpenCode/gi)?.length, 1); +}); + +test('neighboring component rows retain action, repair, and convergence contracts', () => { + const states = ['not-applied', 'drifted', 'blocked', 'active']; + const rows = rufloComponentRows({ + rufloVersion: '3.44.0', summary: { active: 1, total: 4 }, + components: states.map((id) => ({ id, label: `${id} component`, state: describeState(id) })), + }).slice(1); + for (const id of ['not-applied', 'drifted']) { + const rendered = rows.find((r) => r.state === id); + assert.equal(rendered.level, 'warn'); + assert.equal(rendered.repair, 'sync'); + assert.match(rendered.message, /Run ak sync/); + assert.match(rendered.fix, /sync applies/); + } + const blocked = rows.find((r) => r.state === 'blocked'); + assert.equal(blocked.level, 'fail'); + assert.equal(blocked.repair, 'sync'); + assert.match(blocked.message, /Follow the reason shown, then run ak sync/); + assert.match(blocked.fix, /sync applies/); + const active = rows.find((r) => r.state === 'active'); + assert.equal(active.level, 'ok'); + assert.equal(active.repair, null); + assert.equal(active.fix, null); +}); + test('setup results table lists state and meaning per component', () => { const lines = formatComponentResults(snapshot); assert.ok(lines.some((l) => /Learning profile.*user-managed.*leaves it alone/.test(l))); From 324ad1df22d01d909a5feb853a20858932f20042 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 03:07:45 -0700 Subject: [PATCH 22/54] fix(daemon): preserve YAML configuration precedence --- src/commands/status/sections/daemons.mjs | 6 ++ src/commands/sync.mjs | 5 +- src/lib/ruflo-daemon-config.mjs | 46 ++++++++++++- tests/kit/ruflo-daemon-config.test.mjs | 88 ++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/src/commands/status/sections/daemons.mjs b/src/commands/status/sections/daemons.mjs index 152eb9c5..363f907d 100644 --- a/src/commands/status/sections/daemons.mjs +++ b/src/commands/status/sections/daemons.mjs @@ -87,6 +87,12 @@ const flatKeys = (entries, pick) => entries.map((e) => `"${e.key}": ${JSON.strin export function heldRow(held) { const file = DAEMON_CONFIG_RELATIVE.split(path.sep).join('/'); const want = flatKeys(held.entries, (e) => e.want); + if (held.reason === 'yaml-shadow') return row('daemons', 'warn', + `${file} is not ak-managed: creating it would hide existing .claude-flow/config.yaml or config.yml daemon values`, + `review the YAML daemon values and set ${want} in the active config yourself, ${RESTART}`, { repair: 'manual' }); + if (held.reason === 'higher-priority-json') return row('daemons', 'warn', + `${file} is not ak-managed: Ruflo reads claude-flow.config.json first`, + `review claude-flow.config.json and set ${want} there yourself, ${RESTART}`, { repair: 'manual' }); return held.invalid ? row('daemons', 'warn', `${file} is not ak-managed: it is unreadable or not a JSON object, so ak leaves it untouched`, `fix ${file} so it is a JSON object holding ${want} (flat keys), ${RESTART}`, { repair: 'manual' }) diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 3e6ef56c..c9d86da0 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -493,7 +493,10 @@ export const SYNC_STEPS = [ if (applied.result.changed) ok(`ruflo daemon settings: config ${config}, start-on-use ${autostart}`); const { held } = applied.result; if (held) { - warn(`.claude-flow/config.json is not ak-managed here (${held.invalid ? 'unreadable or not a JSON object' : 'a key holds your own value'}); ` + const reason = held.reason === 'yaml-shadow' ? 'creating JSON would hide existing YAML daemon values' + : held.reason === 'higher-priority-json' ? 'Ruflo reads root claude-flow.config.json first' + : held.invalid ? 'unreadable or not a JSON object' : 'a key holds your own value'; + warn(`.claude-flow/config.json is not ak-managed here (${reason}); ` + `left as is, and the daemon is not restarted for ${held.entries.map((e) => e.key).join(', ')}`); } if (applied.restarted) ok('ruflo daemon restarted so it reads its settings'); diff --git a/src/lib/ruflo-daemon-config.mjs b/src/lib/ruflo-daemon-config.mjs index f517d557..5a94d254 100644 --- a/src/lib/ruflo-daemon-config.mjs +++ b/src/lib/ruflo-daemon-config.mjs @@ -100,10 +100,14 @@ function readDaemonConfig(file) { } } +const pathPresent = (candidate) => { + try { fs.lstatSync(candidate); return true; } catch (error) { return error?.code !== 'ENOENT'; } +}; + /** A desired key ak leaves to the user: the file is unreadable (`invalid`), * or it holds the user's own value `have` instead of `want`. * @typedef {{ key: string, want: number, have?: unknown }} HeldKey - * @typedef {{ invalid: boolean, entries: HeldKey[] } | null} HeldConfig */ + * @typedef {{ invalid: boolean, entries: HeldKey[], reason?: 'yaml-shadow'|'higher-priority-json' } | null} HeldConfig */ /** Plan the config.json edit: which owned keys to drop, which to set. */ function planConfig(current, owned, desired) { @@ -127,8 +131,24 @@ function planConfig(current, owned, desired) { return { next, nextOwned, removed, written, conflicts }; } +/** @returns {{status: string, changed: boolean, held: HeldConfig}} */ function reconcileConfig(root, receipt, desired, dryRun) { const file = path.join(root, DAEMON_CONFIG_RELATIVE); + // Never follow a user-controlled .claude-flow link (or special file) when + // reading, creating, replacing or removing config.json. + try { + const dir = fs.lstatSync(path.dirname(file)); + if (!dir.isDirectory() || dir.isSymbolicLink()) { + const entries = Object.entries(desired).map(([key, want]) => ({ key, want })); + return { status: 'user-managed', changed: false, held: entries.length ? { invalid: true, entries } : null }; + } + } catch (error) { + if (error?.code !== 'ENOENT') { + const entries = Object.entries(desired).map(([key, want]) => ({ key, want })); + return { status: 'user-managed', changed: false, held: entries.length ? { invalid: true, entries } : null }; + } + } + const higherPriority = pathPresent(path.join(root, 'claude-flow.config.json')); const owned = receipt.configKeys ?? {}; const current = readDaemonConfig(file); if (current.state === 'invalid') { @@ -139,6 +159,17 @@ function reconcileConfig(root, receipt, desired, dryRun) { receipt.configKeys = {}; receipt.configCreated = false; if (!Object.keys(desired).length) return { status: 'absent', changed: false, held: null }; + // Ruflo 3.48.0 chooses root JSON, then .claude-flow/config.json, then + // config.yaml/yml. Creating our JSON over YAML would hide all user YAML + // daemon values; under root JSON this file would have no effect at all. + const yaml = ['config.yaml', 'config.yml'].some((name) => pathPresent(path.join(root, '.claude-flow', name))); + if (higherPriority || yaml) return { + status: 'user-managed', changed: false, + held: { + invalid: false, reason: higherPriority ? 'higher-priority-json' : 'yaml-shadow', + entries: Object.entries(desired).map(([key, want]) => ({ key, want })), + }, + }; if (!dryRun) { writePrivateFileAtomic(file, `${JSON.stringify(desired, null, 2)}\n`); receipt.configKeys = { ...desired }; @@ -146,7 +177,13 @@ function reconcileConfig(root, receipt, desired, dryRun) { } return { status: 'written', changed: true, held: null }; } - const plan = planConfig(current.value, owned, desired); + // A root JSON file wins even over existing .claude-flow/config.json. Keep + // receipted still-needed keys and clean obsolete ones, but add no new keys + // to a file the daemon does not read. + const effectiveDesired = higherPriority + ? Object.fromEntries(Object.entries(desired).filter(([key]) => Object.hasOwn(owned, key))) + : desired; + const plan = planConfig(current.value, owned, effectiveDesired); const changed = plan.written || plan.removed; const empty = Object.keys(plan.next).length === 0; if (!dryRun) { @@ -156,7 +193,10 @@ function reconcileConfig(root, receipt, desired, dryRun) { receipt.configCreated ??= false; if (changed && empty && receipt.configCreated === true) receipt.configCreated = false; } - const held = plan.conflicts.length ? { invalid: false, entries: plan.conflicts } : null; + /** @type {HeldConfig} */ + const held = higherPriority && Object.keys(desired).length + ? { invalid: false, reason: 'higher-priority-json', entries: Object.entries(desired).map(([key, want]) => ({ key, want })) } + : plan.conflicts.length ? { invalid: false, entries: plan.conflicts } : null; if (plan.written) return { status: 'written', changed, held }; if (plan.removed) return { status: 'removed', changed, held }; return { status: held ? 'user-managed' : 'converged', changed: false, held }; diff --git a/tests/kit/ruflo-daemon-config.test.mjs b/tests/kit/ruflo-daemon-config.test.mjs index 8c4ac413..8b8471de 100644 --- a/tests/kit/ruflo-daemon-config.test.mjs +++ b/tests/kit/ruflo-daemon-config.test.mjs @@ -120,6 +120,94 @@ test('a malformed, non-object or symlinked config.json is user-managed and untou assert.equal(fs.readFileSync(target, 'utf8'), '{}'); }); +test('YAML markers hold JSON creation in apply and preview without hiding user daemon values', (t) => { + for (const extension of ['yaml', 'yml']) { + const root = tmpProject(t); + fs.mkdirSync(path.join(root, '.claude-flow')); + const yaml = path.join(root, '.claude-flow', `config.${extension}`); + fs.writeFileSync(yaml, 'daemon:\n maxConcurrent: 7\n'); + writeSettings(root, { claudeFlow: { daemon: { autoStart: false } } }); + const receipts = {}; + const preview = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts, dryRun: true }); + assert.equal(preview.config, 'user-managed'); + assert.equal(preview.held?.reason, 'yaml-shadow'); + assert.equal(preview.autostart, 'enabled'); + assert.deepEqual(receipts, {}); + const applied = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + assert.deepEqual({ config: applied.config, held: applied.held, changed: applied.changed }, + { config: preview.config, held: preview.held, changed: preview.changed }); + assert.equal(fs.existsSync(configFile(root)), false); + assert.equal(fs.readFileSync(yaml, 'utf8'), 'daemon:\n maxConcurrent: 7\n'); + assert.equal(autoStart(root), true); + } +}); + +test('a root JSON config holds ineffective creation of lower-priority daemon JSON', (t) => { + const root = tmpProject(t); + fs.writeFileSync(path.join(root, 'claude-flow.config.json'), '{"daemon.maxConcurrent":7}'); + const result = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts: {} }); + assert.equal(result.held?.reason, 'higher-priority-json'); + assert.equal(fs.existsSync(configFile(root)), false); +}); + +test('a symlinked .claude-flow directory cannot redirect daemon JSON edits outside the project', (t) => { + const root = tmpProject(t); + const target = tmpProject(t); + fs.symlinkSync(target, path.join(root, '.claude-flow')); + const receipts = {}; + const result = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + assert.equal(result.config, 'user-managed'); + assert.equal(result.held?.invalid, true); + assert.equal(fs.existsSync(path.join(target, 'config.json')), false); + assert.deepEqual(receipts, {}); + fs.writeFileSync(path.join(target, 'config.json'), '{}'); + const second = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + assert.equal(second.config, 'user-managed'); + assert.equal(fs.readFileSync(path.join(target, 'config.json'), 'utf8'), '{}'); +}); + +test('root JSON becoming active holds new keys but permits receipted obsolete-key cleanup', (t) => { + const root = tmpProject(t); + const receipts = {}; + reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + fs.writeFileSync(path.join(root, 'claude-flow.config.json'), '{"daemon.maxConcurrent":7}'); + const result = reconcileRufloDaemon(root, { rufloVersion: '3.46.1', platform: 'darwin', receipts }); + assert.equal(result.held?.reason, 'higher-priority-json'); + assert.deepEqual(readConfig(root), { 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + assert.deepEqual(receipts[path.resolve(root)].configKeys, + { 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); +}); + +test('an existing JSON keeps its ownership rules beside YAML, and release exposes YAML again', (t) => { + const root = tmpProject(t); + const receipts = {}; + reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + const yaml = path.join(root, '.claude-flow', 'config.yml'); + fs.writeFileSync(yaml, 'daemon:\n maxConcurrent: 7\n'); + const preview = reconcileRufloDaemon(root, { rufloVersion: '3.46.1', platform: 'linux', receipts, dryRun: true }); + assert.equal(preview.config, 'removed'); + assert.equal(fs.existsSync(configFile(root)), true); + assert.equal(reconcileRufloDaemon(root, { rufloVersion: '3.46.1', platform: 'linux', receipts }).config, 'removed'); + assert.equal(fs.existsSync(configFile(root)), false); + assert.equal(fs.readFileSync(yaml, 'utf8'), 'daemon:\n maxConcurrent: 7\n'); + assert.deepEqual(receipts, {}); +}); + +test('a YAML hold stops repeated low-memory restarts while leaving the YAML and receipts alone', async (t) => { + const root = rufloRepo(t); + fs.writeFileSync(path.join(root, '.claude-flow', 'config.yaml'), 'daemon:\n maxConcurrent: 7\n'); + fs.mkdirSync(path.join(root, '.claude-flow', 'logs')); + fs.writeFileSync(path.join(root, '.claude-flow', 'logs', 'daemon.log'), + `[${new Date().toISOString()}] [INFO] Worker consolidate deferred: Memory too low: 3.9% free\n`); + const { calls, runner } = recorder(); + const cfg = { rufloDaemon: { receipts: {} } }; + const result = await applyRufloDaemon(root, { cfg, rufloVersion: '3.46.1', platform: 'darwin', runner, alive: () => true }); + assert.equal(result.restarted, false); + assert.deepEqual(calls, []); + assert.equal(fs.existsSync(configFile(root)), false); + assert.deepEqual(cfg.rufloDaemon.receipts, {}); +}); + test('the memory pin still wins and .swarm stays the memory root', (t) => { const root = tmpProject(t); fs.writeFileSync(path.join(root, 'claude-flow.config.json'), JSON.stringify({ memory: { persistPath: '.swarm' } })); From d5538bd19d5da17a0b97c334b9281ae4d549e255 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 03:13:05 -0700 Subject: [PATCH 23/54] fix(sync): preview version-triggered daemon convergence --- src/commands/sync.mjs | 20 ++++++++--- tests/kit/sync-daemon-repair.test.mjs | 47 +++++++++++++++++++++++++ tests/kit/sync-dry-run-preview.test.mjs | 38 ++++++++++++++++++++ 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index c9d86da0..f8403ed7 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -22,7 +22,7 @@ import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, det import { companionLifecycleFor } from '../lib/adapters/companion-lifecycle-registry.mjs'; import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; -import { applyRufloDaemon } from '../lib/ruflo-daemon-config.mjs'; +import { applyRufloDaemon, rufloDaemonProjectRoot } from '../lib/ruflo-daemon-config.mjs'; import { cleanupProbeRows } from '../lib/memory-probe-cleanup.mjs'; import { rufloMemoryLocation } from '../lib/ruflo-memory.mjs'; import { installedRoutingVersion } from '../lib/ruflo-memory-contract.mjs'; @@ -484,11 +484,11 @@ export const SYNC_STEPS = [ // that are actually still alive, not the ones just killed. if (reaped.some((r) => r.killed)) await list({ cwd: ctx.cwd, refresh: true, record: true, source: 'sync' }); // Read the version now: the versions step may have just upgraded Ruflo. - const applied = await applyRufloDaemon(ctx.cwd, { - cfg: ctx.cfg, rufloVersion: installedRoutingVersion() ?? installedVersion('ruflo'), + const applied = await (ctx.daemonApply ?? applyRufloDaemon)(ctx.cwd, { + cfg: ctx.cfg, rufloVersion: (ctx.daemonVersion ?? (() => installedRoutingVersion() ?? installedVersion('ruflo')))(), }); if (!applied) return; - saveKitConfig(ctx.cfg); + (ctx.saveConfig ?? saveKitConfig)(ctx.cfg); const { config, autostart } = applied.result; if (applied.result.changed) ok(`ruflo daemon settings: config ${config}, start-on-use ${autostart}`); const { held } = applied.result; @@ -1126,6 +1126,18 @@ async function converge({ // (not-applied/drifted/blocked) don't need an upgrade and stay in the plan. .filter((r) => !(flags['no-upgrade'] && r.subsystem === 'ruflo-components' && r.state === 'needs-ruflo')); + // The daemon step also runs for a versions item, even when the collector + // reports no current daemon drift. Its settings are decided after upgrades + // from the installed Ruflo version, so the preview can promise only this + // recheck, not exact keys or a restart. + if (!skip.has('versions') && candidates.some((r) => r.subsystem === 'versions') + && !candidates.some((r) => r.subsystem === 'daemons') + && rufloDaemonProjectRoot(cwd)) { + candidates.push(row('daemons', 'info', + 'package changes may change the daemon settings needed by the installed Ruflo version', + 'recheck daemon settings against the installed Ruflo version; write receipted keys or restart a running daemon only if needed')); + } + const cfg = loadKitConfig(); if (cfg.aqe !== false && cfg.aqeEmbedding && cfg.aqeEmbedding.mode !== 'unmanaged') { candidates.push(row('aqe-embedding', 'info', 'selected semantic backend requires live verification', diff --git a/tests/kit/sync-daemon-repair.test.mjs b/tests/kit/sync-daemon-repair.test.mjs index 616841da..79c9aa82 100644 --- a/tests/kit/sync-daemon-repair.test.mjs +++ b/tests/kit/sync-daemon-repair.test.mjs @@ -16,6 +16,7 @@ import { sandboxHome, rmrf } from './helpers/home-sandbox.mjs'; const SANDBOX_HOME = sandboxHome('ak-sync-daemon-repair'); after(() => rmrf(SANDBOX_HOME)); const sync = await import('../../src/commands/sync.mjs'); +const { applyRufloDaemon } = await import('../../src/lib/ruflo-daemon-config.mjs'); const daemonsStep = sync.SYNC_STEPS.find((s) => s.id === 'daemons'); @@ -75,3 +76,49 @@ test('a reap attempt that fails to kill anything is not re-recorded either', asy assert.equal(listCalls.length, 1, 'a failed reap (pid already gone/reused) leaves the process list unchanged; no re-record is needed'); } finally { rmrf(cwd); } }); + +test('daemon step uses injected version, apply and save seams after its sweep', async () => { + const cwd = freshCwd(); + const calls = []; + try { + await daemonsStep.run({ + cwd, cfg: {}, + daemonLifecycle: { list: async () => [], reap: () => [] }, + daemonVersion: () => { calls.push('version'); return '3.48.0'; }, + daemonApply: async (_cwd, options) => { + calls.push(['apply', options.rufloVersion]); + return { result: { config: 'converged', autostart: 'converged', changed: false, held: null }, restarted: false }; + }, + saveConfig: () => calls.push('save'), + }); + assert.deepEqual(calls, ['version', ['apply', '3.48.0'], 'save']); + } finally { rmrf(cwd); } +}); + +test('versions-triggered daemon step applies only fixture settings with no external processes', async () => { + const cwd = freshCwd(); + const calls = []; + try { + fs.mkdirSync(path.join(cwd, '.git')); + fs.mkdirSync(path.join(cwd, '.claude-flow')); + fs.mkdirSync(path.join(cwd, '.swarm')); + fs.writeFileSync(path.join(cwd, '.swarm', 'memory.db'), ''); + const cfg = { rufloDaemon: { receipts: {} } }; + assert.ok(sync.activeSteps(new Set(['versions']), {}, cfg).includes('daemons')); + await daemonsStep.run({ + cwd, cfg, + daemonLifecycle: { list: async () => { calls.push('list'); return []; }, reap: () => [] }, + daemonVersion: () => '3.45.0', + daemonApply: (root, options) => applyRufloDaemon(root, { + ...options, platform: 'darwin', alive: () => { calls.push('alive'); return true; }, + runner: async (_tool, args) => { calls.push(args.join(' ')); return { code: 0 }; }, + }), + saveConfig: () => calls.push('save'), + }); + assert.deepEqual(calls, ['list', 'alive', 'daemon stop', 'daemon start', 'save']); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(cwd, '.claude-flow', 'config.json'), 'utf8')), + { 'daemon.idleSecs': 0, 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + assert.deepEqual(cfg.rufloDaemon.receipts[path.resolve(cwd)].configKeys, + { 'daemon.idleSecs': 0, 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + } finally { rmrf(cwd); } +}); diff --git a/tests/kit/sync-dry-run-preview.test.mjs b/tests/kit/sync-dry-run-preview.test.mjs index f3def05e..19c1b9ec 100644 --- a/tests/kit/sync-dry-run-preview.test.mjs +++ b/tests/kit/sync-dry-run-preview.test.mjs @@ -136,6 +136,44 @@ const DRY = (over = {}) => ({ 'dry-run': true, 'no-upgrade': false, yes: false, const failedDates = async () => ({ code: 1, stdout: '', stderr: 'offline' }); const NOT_CHECKED = /versions not checked online \(offline or timed out\); this plan uses the versions ak recorded/; +test('versions-only dry run previews conditional daemon convergence in a Ruflo project without writes', async () => { + seed(); + const marker = path.join(PROJECT, '.claude-flow', 'config.yaml'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, 'daemon:\n maxConcurrent: 7\n'); + const beforeHome = snapshot(HOME); + const beforeProject = snapshot(PROJECT); + try { + const { result, out } = await syncLines({ + flags: DRY(), fetchLatest: async () => null, releaseDatesRunner: failedDates, + collectFn: async () => [{ subsystem: 'versions', level: 'warn', message: 'ruflo update available', fix: 'sync upgrades', repair: 'sync' }], + }); + assert.equal(result, 0, out); + assert.match(out, /\[daemons\].*installed Ruflo version/); + assert.doesNotMatch(out, /daemon\.idleSecs.*0/, 'the target version is not yet known'); + assertUnchanged(beforeHome, HOME); + assertUnchanged(beforeProject, PROJECT); + } finally { fs.rmSync(marker); } +}); + +test('versions-triggered daemon preview honors skip daemons and skip versions', async () => { + seed(); + const marker = path.join(PROJECT, '.claude-flow', 'config.yaml'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, 'daemon:\n maxConcurrent: 7\n'); + const collectFn = async () => [{ subsystem: 'versions', level: 'warn', message: 'update available', fix: 'sync upgrades', repair: 'sync' }]; + try { + const daemonSkipped = await syncLines({ + flags: DRY({ skip: ['daemons'] }), fetchLatest: async () => null, releaseDatesRunner: failedDates, collectFn, + }); + assert.match(daemonSkipped.out, /skipped by request: \[daemons\]/); + const versionSkipped = await syncLines({ + flags: DRY({ skip: ['versions'] }), fetchLatest: async () => null, releaseDatesRunner: failedDates, collectFn, + }); + assert.doesNotMatch(versionSkipped.out, /\[daemons\]/); + } finally { fs.rmSync(marker); } +}); + test('a dry run looks the versions up online and plans the upgrade a fresh cache does not know about, recording nothing', () => { const root = seed({ age: HOUR }); const env = spawnEnv(HOME); From 03af512a7e42bec9e67744ff9b0f384c35ccbb28 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 03:23:34 -0700 Subject: [PATCH 24/54] fix(daemon): respect explicit config and active restart source --- src/commands/status/sections/daemons.mjs | 15 +++-- src/commands/sync.mjs | 1 + src/lib/ruflo-daemon-config.mjs | 60 ++++++++++-------- tests/kit/ruflo-daemon-config.test.mjs | 80 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 32 deletions(-) diff --git a/src/commands/status/sections/daemons.mjs b/src/commands/status/sections/daemons.mjs index 363f907d..e156cd14 100644 --- a/src/commands/status/sections/daemons.mjs +++ b/src/commands/status/sections/daemons.mjs @@ -93,6 +93,9 @@ export function heldRow(held) { if (held.reason === 'higher-priority-json') return row('daemons', 'warn', `${file} is not ak-managed: Ruflo reads claude-flow.config.json first`, `review claude-flow.config.json and set ${want} there yourself, ${RESTART}`, { repair: 'manual' }); + if (held.reason === 'explicit-config') return row('daemons', 'warn', + `${file} is not ak-managed: Ruflo currently reads CLAUDE_FLOW_CONFIG before YAML`, + `review the CLAUDE_FLOW_CONFIG file and set ${want} there yourself, ${RESTART}`, { repair: 'manual' }); return held.invalid ? row('daemons', 'warn', `${file} is not ak-managed: it is unreadable or not a JSON object, so ak leaves it untouched`, `fix ${file} so it is a JSON object holding ${want} (flat keys), ${RESTART}`, { repair: 'manual' }) @@ -102,16 +105,16 @@ export function heldRow(held) { /** The Ruflo repository around `cwd`, kit.json, and the keys its config.json * keeps from ak (read once for the deferral and drift rows). */ -function rufloContext(cwd, { loadConfig, rufloVersion, platform }) { +function rufloContext(cwd, { loadConfig, rufloVersion, platform, env }) { const root = rufloDaemonProjectRoot(cwd); if (!root) return { root: null, cfg: null, held: null }; const cfg = loadConfig(); - return { root, cfg, held: daemonConfigHeld(root, { cfg, rufloVersion, platform }) }; + return { root, cfg, held: daemonConfigHeld(root, { cfg, rufloVersion, platform, env }) }; } -function driftRows({ root, cfg, held }, { rufloVersion, platform }) { +function driftRows({ root, cfg, held }, { rufloVersion, platform, env }) { if (!root) return []; - const parts = daemonDrift(root, { cfg, rufloVersion, platform }); + const parts = daemonDrift(root, { cfg, rufloVersion, platform, env }); return [held && heldRow(held), parts && row('daemons', 'warn', `ak-managed daemon settings differ from what Ruflo ${rufloVersion ?? '(version unknown)'} ` + `needs: ${parts.join('; ')}`, "sync applies ak's Ruflo daemon settings")].filter(Boolean); } @@ -140,10 +143,10 @@ export default { rows.push(row('daemons', 'ok', daemons.length ? `${daemons.length} running (one per active project is expected)` : 'none running')); } - const ruflo = rufloContext(cwd, { loadConfig, rufloVersion, platform }); + const ruflo = rufloContext(cwd, { loadConfig, rufloVersion, platform, env }); const deferral = deferralRow(root, { now, platform, ruflo }); if (deferral) rows.push(deferral); - rows.push(...driftRows(ruflo, { rufloVersion, platform })); + rows.push(...driftRows(ruflo, { rufloVersion, platform, env })); } catch (e) { rows.push(row('daemons', 'warn', `daemon check unavailable: ${e.message}`)); } diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index f8403ed7..48ca68ba 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -495,6 +495,7 @@ export const SYNC_STEPS = [ if (held) { const reason = held.reason === 'yaml-shadow' ? 'creating JSON would hide existing YAML daemon values' : held.reason === 'higher-priority-json' ? 'Ruflo reads root claude-flow.config.json first' + : held.reason === 'explicit-config' ? 'Ruflo reads CLAUDE_FLOW_CONFIG before YAML' : held.invalid ? 'unreadable or not a JSON object' : 'a key holds your own value'; warn(`.claude-flow/config.json is not ak-managed here (${reason}); ` + `left as is, and the daemon is not restarted for ${held.entries.map((e) => e.key).join(', ')}`); diff --git a/src/lib/ruflo-daemon-config.mjs b/src/lib/ruflo-daemon-config.mjs index 5a94d254..81048959 100644 --- a/src/lib/ruflo-daemon-config.mjs +++ b/src/lib/ruflo-daemon-config.mjs @@ -107,7 +107,7 @@ const pathPresent = (candidate) => { /** A desired key ak leaves to the user: the file is unreadable (`invalid`), * or it holds the user's own value `have` instead of `want`. * @typedef {{ key: string, want: number, have?: unknown }} HeldKey - * @typedef {{ invalid: boolean, entries: HeldKey[], reason?: 'yaml-shadow'|'higher-priority-json' } | null} HeldConfig */ + * @typedef {{ invalid: boolean, entries: HeldKey[], reason?: 'yaml-shadow'|'higher-priority-json'|'explicit-config' } | null} HeldConfig */ /** Plan the config.json edit: which owned keys to drop, which to set. */ function planConfig(current, owned, desired) { @@ -131,8 +131,8 @@ function planConfig(current, owned, desired) { return { next, nextOwned, removed, written, conflicts }; } -/** @returns {{status: string, changed: boolean, held: HeldConfig}} */ -function reconcileConfig(root, receipt, desired, dryRun) { +/** @returns {{status: string, changed: boolean, activeChanged: boolean, held: HeldConfig}} */ +function reconcileConfig(root, receipt, desired, dryRun, env) { const file = path.join(root, DAEMON_CONFIG_RELATIVE); // Never follow a user-controlled .claude-flow link (or special file) when // reading, creating, replacing or removing config.json. @@ -140,33 +140,38 @@ function reconcileConfig(root, receipt, desired, dryRun) { const dir = fs.lstatSync(path.dirname(file)); if (!dir.isDirectory() || dir.isSymbolicLink()) { const entries = Object.entries(desired).map(([key, want]) => ({ key, want })); - return { status: 'user-managed', changed: false, held: entries.length ? { invalid: true, entries } : null }; + return { status: 'user-managed', changed: false, activeChanged: false, held: entries.length ? { invalid: true, entries } : null }; } } catch (error) { if (error?.code !== 'ENOENT') { const entries = Object.entries(desired).map(([key, want]) => ({ key, want })); - return { status: 'user-managed', changed: false, held: entries.length ? { invalid: true, entries } : null }; + return { status: 'user-managed', changed: false, activeChanged: false, held: entries.length ? { invalid: true, entries } : null }; } } const higherPriority = pathPresent(path.join(root, 'claude-flow.config.json')); + // ConfigFileManager.findConfig checks the explicit path only after both + // JSON candidates. existsSync resolves a relative env path from process.cwd, + // exactly as Ruflo does; it does not resolve it against the project root. + const explicitConfig = !higherPriority && typeof env?.CLAUDE_FLOW_CONFIG === 'string' + && fs.existsSync(env.CLAUDE_FLOW_CONFIG); const owned = receipt.configKeys ?? {}; const current = readDaemonConfig(file); if (current.state === 'invalid') { const entries = Object.entries(desired).map(([key, want]) => ({ key, want })); - return { status: 'user-managed', changed: false, held: entries.length ? { invalid: true, entries } : null }; + return { status: 'user-managed', changed: false, activeChanged: false, held: entries.length ? { invalid: true, entries } : null }; } if (current.state === 'absent') { receipt.configKeys = {}; receipt.configCreated = false; - if (!Object.keys(desired).length) return { status: 'absent', changed: false, held: null }; + if (!Object.keys(desired).length) return { status: 'absent', changed: false, activeChanged: false, held: null }; // Ruflo 3.48.0 chooses root JSON, then .claude-flow/config.json, then // config.yaml/yml. Creating our JSON over YAML would hide all user YAML // daemon values; under root JSON this file would have no effect at all. const yaml = ['config.yaml', 'config.yml'].some((name) => pathPresent(path.join(root, '.claude-flow', name))); - if (higherPriority || yaml) return { - status: 'user-managed', changed: false, + if (higherPriority || explicitConfig || yaml) return { + status: 'user-managed', changed: false, activeChanged: false, held: { - invalid: false, reason: higherPriority ? 'higher-priority-json' : 'yaml-shadow', + invalid: false, reason: higherPriority ? 'higher-priority-json' : explicitConfig ? 'explicit-config' : 'yaml-shadow', entries: Object.entries(desired).map(([key, want]) => ({ key, want })), }, }; @@ -175,7 +180,7 @@ function reconcileConfig(root, receipt, desired, dryRun) { receipt.configKeys = { ...desired }; receipt.configCreated = true; } - return { status: 'written', changed: true, held: null }; + return { status: 'written', changed: true, activeChanged: true, held: null }; } // A root JSON file wins even over existing .claude-flow/config.json. Keep // receipted still-needed keys and clean obsolete ones, but add no new keys @@ -197,9 +202,9 @@ function reconcileConfig(root, receipt, desired, dryRun) { const held = higherPriority && Object.keys(desired).length ? { invalid: false, reason: 'higher-priority-json', entries: Object.entries(desired).map(([key, want]) => ({ key, want })) } : plan.conflicts.length ? { invalid: false, entries: plan.conflicts } : null; - if (plan.written) return { status: 'written', changed, held }; - if (plan.removed) return { status: 'removed', changed, held }; - return { status: held ? 'user-managed' : 'converged', changed: false, held }; + if (plan.written) return { status: 'written', changed, activeChanged: !higherPriority, held }; + if (plan.removed) return { status: 'removed', changed, activeChanged: !higherPriority, held }; + return { status: held ? 'user-managed' : 'converged', changed: false, activeChanged: false, held }; } function reconcileAutostart(root, receipt, wanted, dryRun) { @@ -236,25 +241,27 @@ const hasOwnership = (receipt) => Object.keys(receipt.configKeys ?? {}).length > * Converge one project's managed daemon settings. * @param {string} root the Ruflo project root * @param {{rufloVersion?: (string|null), platform?: string, receipts: Record, - * autoStart?: boolean, dryRun?: boolean, desired?: Record, runner?: unknown}} options + * autoStart?: boolean, dryRun?: boolean, desired?: Record, runner?: unknown, + * env?: NodeJS.ProcessEnv}} options * `autoStart` is kit.json rufloDaemon.autoStart !== false; `runner` is accepted and never used. - * @returns {{config: string, autostart: string, changed: boolean, held: HeldConfig}} + * @returns {{config: string, autostart: string, changed: boolean, configActiveChanged: boolean, held: HeldConfig}} * `held` names the desired keys ak cannot manage here (null when none). */ export function reconcileRufloDaemon(root, { rufloVersion = null, platform = process.platform, receipts, autoStart = true, dryRun = false, - desired = desiredDaemonKeys({ rufloVersion, platform }), + desired = desiredDaemonKeys({ rufloVersion, platform }), env = process.env, } = /** @type {any} */ ({})) { const key = path.resolve(root); const receipt = structuredClone(receipts[key] ?? {}); - const config = reconcileConfig(root, receipt, desired, dryRun); + const config = reconcileConfig(root, receipt, desired, dryRun, env); const autostart = reconcileAutostart(root, receipt, autoStart, dryRun); if (!dryRun) { if (hasOwnership(receipt)) receipts[key] = receipt; else delete receipts[key]; } return { - config: config.status, autostart: autostart.status, changed: config.changed || autostart.changed, held: config.held, + config: config.status, autostart: autostart.status, changed: config.changed || autostart.changed, + configActiveChanged: config.activeChanged, held: config.held, }; } @@ -294,16 +301,17 @@ export function daemonIntent(cfg) { * is left for Ruflo's start-on-use. Returns null outside a Ruflo project. * @param {string} cwd * @param {{cfg: any, rufloVersion?: (string|null), platform?: string, runner?: typeof run, - * alive?: (root: string) => boolean, dryRun?: boolean}} options + * alive?: (root: string) => boolean, dryRun?: boolean, env?: NodeJS.ProcessEnv}} options */ export async function applyRufloDaemon(cwd, { cfg, rufloVersion = null, platform = process.platform, runner = run, alive = projectDaemonAlive, dryRun = false, + env = process.env, }) { const root = rufloDaemonProjectRoot(cwd); if (!root) return null; const intent = daemonIntent(cfg); - const result = reconcileRufloDaemon(root, { rufloVersion, platform, receipts: intent.receipts, autoStart: intent.autoStart, dryRun }); - const configChanged = result.config === 'written' || result.config === 'removed'; + const result = reconcileRufloDaemon(root, { rufloVersion, platform, receipts: intent.receipts, autoStart: intent.autoStart, dryRun, env }); + const configChanged = result.configActiveChanged; // A config.json that keeps the floor from ak (unreadable, or the user's own // value) changed nothing a restart would pick up. const floorHeld = result.held?.entries.some((e) => e.key === MEMORY_FLOOR_KEY) ?? false; @@ -319,17 +327,17 @@ export async function applyRufloDaemon(cwd, { /** Status: the desired keys a user-managed config.json keeps from ak (a dry run). * @returns {HeldConfig} */ -export function daemonConfigHeld(root, { cfg, rufloVersion = null, platform = process.platform }) { +export function daemonConfigHeld(root, { cfg, rufloVersion = null, platform = process.platform, env = process.env }) { const intent = daemonIntent(structuredClone(cfg ?? {})); const receipts = structuredClone(intent.receipts); - return reconcileRufloDaemon(root, { rufloVersion, platform, receipts, autoStart: intent.autoStart, dryRun: true }).held; + return reconcileRufloDaemon(root, { rufloVersion, platform, receipts, autoStart: intent.autoStart, dryRun: true, env }).held; } /** Status: what a sync would change here, as phrases, or null when converged. */ -export function daemonDrift(root, { cfg, rufloVersion = null, platform = process.platform }) { +export function daemonDrift(root, { cfg, rufloVersion = null, platform = process.platform, env = process.env }) { const intent = daemonIntent(structuredClone(cfg ?? {})); const receipts = structuredClone(intent.receipts); - const r = reconcileRufloDaemon(root, { rufloVersion, platform, receipts, autoStart: intent.autoStart, dryRun: true }); + const r = reconcileRufloDaemon(root, { rufloVersion, platform, receipts, autoStart: intent.autoStart, dryRun: true, env }); if (!r.changed) return null; const parts = []; const desired = desiredDaemonKeys({ rufloVersion, platform }); diff --git a/tests/kit/ruflo-daemon-config.test.mjs b/tests/kit/ruflo-daemon-config.test.mjs index 8b8471de..90467e09 100644 --- a/tests/kit/ruflo-daemon-config.test.mjs +++ b/tests/kit/ruflo-daemon-config.test.mjs @@ -178,6 +178,86 @@ test('root JSON becoming active holds new keys but permits receipted obsolete-ke { 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); }); +test('cleanup of inactive receipted JSON under root JSON does not restart a live daemon', async (t) => { + const root = rufloRepo(t); + const cfg = { rufloDaemon: { receipts: {} } }; + reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts: cfg.rufloDaemon.receipts }); + fs.writeFileSync(path.join(root, 'claude-flow.config.json'), '{"daemon.maxConcurrent":7}'); + const before = JSON.stringify(cfg.rufloDaemon.receipts); + const preview = reconcileRufloDaemon(root, { + rufloVersion: '3.46.1', platform: 'linux', receipts: cfg.rufloDaemon.receipts, dryRun: true, + }); + assert.equal(preview.config, 'removed'); + assert.equal(JSON.stringify(cfg.rufloDaemon.receipts), before); + const { calls, runner } = recorder(); + const applied = await applyRufloDaemon(root, { + cfg, rufloVersion: '3.46.1', platform: 'linux', runner, alive: () => true, + }); + assert.equal(applied.result.config, 'removed'); + assert.equal(applied.restarted, false); + assert.deepEqual(calls, []); + assert.equal(fs.existsSync(configFile(root)), false); + assert.deepEqual(cfg.rufloDaemon.receipts, {}); +}); + +test('an existing explicit config holds creation of daemon JSON in preview and apply', (t) => { + const root = tmpProject(t); + const external = tmpProject(t); + const custom = path.join(external, 'custom-config.json'); + fs.writeFileSync(custom, '{"daemon.maxConcurrent":7}'); + fs.mkdirSync(path.join(root, '.claude-flow')); + fs.writeFileSync(path.join(root, '.claude-flow', 'config.yaml'), 'daemon:\n maxConcurrent: 9\n'); + const receipts = {}; + const env = { CLAUDE_FLOW_CONFIG: custom }; + const before = fs.readFileSync(custom, 'utf8'); + const preview = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts, env, dryRun: true }); + assert.equal(preview.config, 'user-managed'); + assert.equal(preview.held?.reason, 'explicit-config'); + assert.deepEqual(receipts, {}); + const applied = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts, env }); + assert.deepEqual(applied, preview); + assert.equal(fs.existsSync(configFile(root)), false); + assert.equal(fs.readFileSync(custom, 'utf8'), before); +}); + +test('relative explicit config resolves from process cwd, and absent path does not hold JSON creation', (t) => { + const root = tmpProject(t); + const external = tmpProject(t); + const originalCwd = process.cwd(); + fs.writeFileSync(path.join(external, 'custom-config.json'), '{"daemon.maxConcurrent":7}'); + try { + process.chdir(external); + const held = reconcileRufloDaemon(root, { + rufloVersion: '3.45.0', platform: 'darwin', receipts: {}, + env: { CLAUDE_FLOW_CONFIG: './custom-config.json' }, dryRun: true, + }); + assert.equal(held.held?.reason, 'explicit-config'); + const writable = reconcileRufloDaemon(root, { + rufloVersion: '3.45.0', platform: 'darwin', receipts: {}, + env: { CLAUDE_FLOW_CONFIG: './missing-config.json' }, dryRun: true, + }); + assert.equal(writable.config, 'written'); + } finally { process.chdir(originalCwd); } +}); + +test('an existing daemon JSON takes precedence over CLAUDE_FLOW_CONFIG', async (t) => { + const root = rufloRepo(t); + const external = tmpProject(t); + const custom = path.join(external, 'custom-config.json'); + fs.writeFileSync(custom, '{"daemon.maxConcurrent":7}'); + fs.writeFileSync(configFile(root), '{}'); + const { calls, runner } = recorder(); + const result = await applyRufloDaemon(root, { + cfg: { rufloDaemon: { receipts: {} } }, rufloVersion: '3.46.1', platform: 'darwin', + env: { CLAUDE_FLOW_CONFIG: custom }, runner, alive: () => true, + }); + assert.equal(result.result.config, 'written'); + assert.equal(result.restarted, true); + assert.deepEqual(readConfig(root), { 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + assert.equal(fs.readFileSync(custom, 'utf8'), '{"daemon.maxConcurrent":7}'); + assert.deepEqual(calls, [['ruflo', 'daemon', 'stop', root], ['ruflo', 'daemon', 'start', root]]); +}); + test('an existing JSON keeps its ownership rules beside YAML, and release exposes YAML again', (t) => { const root = tmpProject(t); const receipts = {}; From b5570e1ed22717648a109c0dc2c4ff9418a992c8 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 03:36:46 -0700 Subject: [PATCH 25/54] fix(discovery): restore paused coverage from durable summary --- docs/maintenance.md | 7 +- src/lib/maintenance/discovery/history.mjs | 15 +-- .../maintenance/discovery/orchestrator.mjs | 33 ++++-- ...aintenance-discovery-orchestrator.test.mjs | 108 ++++++++++++++++++ .../maintenance-management-service.test.mjs | 29 +++++ 5 files changed, 173 insertions(+), 19 deletions(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index fa9bafb4..7feb233a 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -771,13 +771,14 @@ See the [dated top-50 coverage list](https://github.com/pacphi/agentic-kit/blob/ Activity presents scan history as a table grouped by the browser’s local calendar date, newest first. Each date has a chevron toggle to expand or collapse its source rows; groups -start collapsed and retain their state while the dashboard stays open. Source rows show completion time and timezone, source, status, and entry count. +start collapsed and retain their state while the dashboard stays open. Source rows show a completion time when available, plus source, status, and entry count. Discovery focuses on source configuration and current coverage; historical scans appear only in Activity. Groups represent dates, not inferred shared scan runs. Missing dates remain explicitly unknown. Version measurements, update checks, and snooze deadlines also use local date/time formatting. -Scan history retains the latest 10 completed records per source and environment, within the -90-day retention limit. Each new record replaces the oldest retained record for that source. +Scan history retains the latest 10 records per source and environment, within the +90-day retention limit. A pause records a continuation boundary with a recorded time, +not a scan completion time. Each new record replaces the oldest retained record for that source. Activity places recovery, work in progress, receipts, dispositions, and recipe changes in a responsive card grid above the full-width scan history. Narrow screens use a single column. diff --git a/src/lib/maintenance/discovery/history.mjs b/src/lib/maintenance/discovery/history.mjs index 0d2c0691..0b6f5aff 100644 --- a/src/lib/maintenance/discovery/history.mjs +++ b/src/lib/maintenance/discovery/history.mjs @@ -1,9 +1,9 @@ // ADR-0048 scan history — bounded, retained scan summaries // (docs/maintenance.md "Retention"). -// This store holds ONLY terminal scan summaries. It structurally cannot reach +// This store holds terminal scan summaries and paused continuation boundaries. It cannot reach // receipts, dispositions, or recipe acceptance records — those live in other // agents' stores — so `clearHistory` cannot violate MNT-PRV-008 by scope -// alone; the `isProtected` guard below is defense in depth for a summary that +// alone; the `hasOpenContinuation` guard below is defense in depth for a summary that // still names an open continuation. import fs from 'node:fs'; import path from 'node:path'; @@ -30,14 +30,14 @@ function pruneSummaries(summaries, retention, now) { const cutoff = now - retention.maxAgeDays * 86_400_000; const bySource = new Map(); for (const summary of summaries - .filter((entry) => Date.parse(entry.completedAt) >= cutoff) - .sort((a, b) => Date.parse(a.completedAt) - Date.parse(b.completedAt))) { + .filter((entry) => Date.parse(entry.recordedAt ?? entry.completedAt) >= cutoff) + .sort((a, b) => Date.parse(a.recordedAt ?? a.completedAt) - Date.parse(b.recordedAt ?? b.completedAt))) { const key = JSON.stringify([summary.environmentId, summary.sourceId]); const list = bySource.get(key) ?? []; list.push(summary); bySource.set(key, list.slice(-retention.maxSummaries)); } - return [...bySource.values()].flat().sort((a, b) => Date.parse(a.completedAt) - Date.parse(b.completedAt)); + return [...bySource.values()].flat().sort((a, b) => Date.parse(a.recordedAt ?? a.completedAt) - Date.parse(b.recordedAt ?? b.completedAt)); } /** @@ -47,7 +47,8 @@ function pruneSummaries(summaries, retention, now) { */ export function createScanHistoryStore(dir, { fsImpl = fs, now = Date.now, retention = SCAN_HISTORY_RETENTION, - hasOpenContinuation = (/** @type {string} */ _scanId) => false, + hasOpenContinuation = (scanId) => typeof scanId === 'string' && /^[A-Za-z0-9_-]{1,80}$/u.test(scanId) + && fsImpl.existsSync(path.join(dir, 'checkpoints', `${scanId}.json`)), } = {}) { const file = path.join(dir, 'scan-history.json'); let effectiveRetention = clampRetention(retention); @@ -86,7 +87,7 @@ export function createScanHistoryStore(dir, { return environmentId ? summaries.filter((entry) => entry.environmentId === environmentId) : summaries; } - /** Remove only terminal, non-protected summaries; refuses (never throws) to + /** Remove only non-protected summaries; refuses (never throws) to * touch a summary whose scanId still names an open continuation. * @param {{environmentId?: string}} [options] */ function clearHistory({ environmentId } = {}) { diff --git a/src/lib/maintenance/discovery/orchestrator.mjs b/src/lib/maintenance/discovery/orchestrator.mjs index 38aa917d..4ad5ce95 100644 --- a/src/lib/maintenance/discovery/orchestrator.mjs +++ b/src/lib/maintenance/discovery/orchestrator.mjs @@ -118,11 +118,18 @@ function toCoverageRecord(record) { }; } -function toSummary(record, now) { +function toSummary(record, now, state = record.scanState) { + const recordedAt = new Date(now()).toISOString(); return { scanId: record.scanId, sourceId: record.sourceId, environmentId: record.environmentId, - state: record.scanState, startedAt: record.createdAt, completedAt: new Date(now()).toISOString(), + state, startedAt: record.createdAt, + completedAt: state === 'paused' ? null : recordedAt, + ...(state === 'paused' ? { recordedAt } : {}), visited: record.visited, limitingReason: record.limitingReason, ceiling: record.ceiling ?? null, + ...(state === 'paused' ? { + completedPartitions: record.completedPartitions.length, + pendingPartitions: record.pendingPartitions.length, + } : {}), }; } @@ -180,7 +187,8 @@ function remainingEntryBudget(ceilingEntries, visited) { * remove: (scanId: string) => void, list: () => string[] }} CheckpointStore * @typedef {{ recordSummary: (summary: object) => any, * list: (options?: {environmentId?: string}) => Array<{sourceId: string, environmentId: string, - * state: string, completedAt: string, visited?: number, limitingReason?: string|null, + * state: string, completedAt: string|null, recordedAt?: string, visited?: number, + * completedPartitions?: number, pendingPartitions?: number, limitingReason?: string|null, * ceiling?: string|null}> }} HistoryStore * @typedef {{ write: (entry: object) => boolean, * current: () => Array<{sourceId: string, environmentId: string}> }} LastGoodStore @@ -446,6 +454,11 @@ export function createScanOrchestrator({ error.code = 'SOURCE_NOT_PAUSABLE'; throw error; } + if (!historyStore) throw new Error('scan history unavailable; cannot persist pause'); + // Commit the continuation before claiming that pause is durable. If + // either store rejects the write, leave the live state unchanged. + persistCheckpoint(record); + historyStore.recordSummary(toSummary(record, now, 'paused')); transition(record, 'paused'); return coverageFor(toCoverageRecord(record)); } @@ -486,8 +499,8 @@ export function createScanOrchestrator({ /** A configured source with no live record — never started, paused, or * stopped in this process (e.g. right after a restart, when `records` is - * empty again) — first restores its latest terminal history summary when - * that summary is itself a failure, or a stop at a limit other than the + * empty again) — first restores its latest history summary when + * that summary is a pause, a failure, or a stop at a limit other than the * user's own request (audit finding M1b): a real failure must read the * same on the Discovery panel and the Inventory banner both before and * after a restart, since both read this same `coverage()`. A user stop is @@ -501,11 +514,13 @@ export function createScanOrchestrator({ const record = records.get(source.sourceId); if (record) return coverageFor(toCoverageRecord(record)); const summary = summaryBySource?.get(`${source.environmentId}:${source.sourceId}`); - if (summary && (summary.state === 'failed' || (summary.state === 'stopped' && summary.limitingReason !== 'stopped-by-user'))) { + if (summary && (summary.state === 'paused' || summary.state === 'failed' || (summary.state === 'stopped' && summary.limitingReason !== 'stopped-by-user'))) { return coverageFor({ ...toCoverageRecord(initRecord(source)), scanState: summary.state, visited: summary.visited ?? 0, + completedPartitions: summary.completedPartitions ?? 0, + pendingPartitions: summary.pendingPartitions ?? 0, limitingReason: summary.limitingReason ?? null, ceiling: summary.ceiling ?? null, }); @@ -516,10 +531,10 @@ export function createScanOrchestrator({ return coverageFor(toCoverageRecord(initRecord(source))); } - /** Index the newest terminal summary per `(environmentId, sourceId)` from + /** Index the newest summary per `(environmentId, sourceId)` from * the history store, read once per `coverage()` call rather than once per * source (M1b). `historyStore.list()` is already ascending by - * `completedAt` with ties in recording order, so `>=` (not `>`) keeps the + * record timestamp with ties in recording order, so `>=` (not `>`) keeps the * most-recently-recorded summary on a same-millisecond tie — a later * successful scan must win over an earlier failure even when both * complete within the same clock tick. */ @@ -528,7 +543,7 @@ export function createScanOrchestrator({ for (const summary of historyStore?.list() ?? []) { const key = `${summary.environmentId}:${summary.sourceId}`; const existing = bySource.get(key); - if (!existing || Date.parse(summary.completedAt) >= Date.parse(existing.completedAt)) bySource.set(key, summary); + if (!existing || Date.parse(summary.recordedAt ?? summary.completedAt) >= Date.parse(existing.recordedAt ?? existing.completedAt)) bySource.set(key, summary); } return bySource; } diff --git a/tests/kit/maintenance-discovery-orchestrator.test.mjs b/tests/kit/maintenance-discovery-orchestrator.test.mjs index 1d72f60c..c9d92ba2 100644 --- a/tests/kit/maintenance-discovery-orchestrator.test.mjs +++ b/tests/kit/maintenance-discovery-orchestrator.test.mjs @@ -145,6 +145,99 @@ test('pause and resume: a paused source keeps its checkpoint and completes once assert.equal(published.scanState, 'published'); }); +test('a paused scan survives restart without publishing partial evidence, then resumes', async (t) => { + const dir = fixture(t); + const root = fixture(t); + buildLargeTree(root, { dirs: 5, filesPerDir: 4 }); + const first = control(root, dir); + const [checkpointed] = await first.orchestrator.start({ sourceIds: [SOURCE.sourceId], maxSlices: 1 }); + assert.equal(checkpointed.scanState, 'checkpointed'); + const paused = first.orchestrator.pause({ sourceId: SOURCE.sourceId }); + assert.equal(paused.state, 'paused'); + assert.ok(paused.visited > 0); + const [summary] = first.historyStore.list(); + assert.equal(summary.state, 'paused'); + assert.equal(summary.visited, paused.visited); + assert.equal(summary.completedPartitions, paused.completedPartitions); + assert.equal(summary.pendingPartitions, paused.pendingPartitions); + assert.equal(summary.completedAt, null); + assert.ok(summary.recordedAt); + assert.equal(first.lastGoodStore.current().length, 0); + + const restarted = control(root, dir); + const [row] = restarted.orchestrator.coverage(); + assert.equal(row.state, 'paused'); + assert.equal(row.visited, paused.visited); + assert.equal(row.completedPartitions, paused.completedPartitions); + assert.equal(row.pendingPartitions, paused.pendingPartitions); + assert.equal(row.lastCompletedAt, null); + assert.equal(restarted.checkpointStore.list().length, 1); + const [published] = await restarted.orchestrator.resume({ sourceIds: [SOURCE.sourceId] }); + assert.equal(published.scanState, 'published'); + assert.equal(restarted.orchestrator.coverage()[0].state, 'complete'); + assert.equal(restarted.checkpointStore.list().length, 0); + assert.equal(restarted.lastGoodStore.current().length, 1); + assert.equal(control(root, dir).orchestrator.coverage()[0].state, 'complete'); +}); + +test('a confirmed stop after pause prevents old paused state from returning', async (t) => { + const dir = fixture(t); + const root = fixture(t); + buildLargeTree(root); + const first = control(root, dir); + await first.orchestrator.start({ sourceIds: [SOURCE.sourceId], maxSlices: 1 }); + first.orchestrator.pause({ sourceId: SOURCE.sourceId }); + first.orchestrator.stop({ sourceId: SOURCE.sourceId, confirmed: true }); + assert.equal(first.checkpointStore.list().length, 0); + assert.equal(control(root, dir).orchestrator.coverage()[0].state, 'not-scanned'); +}); + +test('pause does not claim durable state when its summary write fails', async (t) => { + const dir = fixture(t); + const root = fixture(t); + buildLargeTree(root); + const first = control(root, dir); + await first.orchestrator.start({ sourceIds: [SOURCE.sourceId], maxSlices: 1 }); + const historyStore = { ...first.historyStore, recordSummary: () => { throw new Error('history unavailable'); } }; + const second = control(root, dir, { historyStore }); + assert.throws(() => second.orchestrator.pause({ sourceId: SOURCE.sourceId }), /history unavailable/); + assert.equal(second.orchestrator.coverage()[0].state, 'scanning'); + assert.equal(second.historyStore.list().length, 0); + assert.equal(second.checkpointStore.list().length, 1); +}); + +test('pause refuses when no history store can record the boundary', async (t) => { + const dir = fixture(t); + const root = fixture(t); + buildLargeTree(root); + const first = control(root, dir); + await first.orchestrator.start({ sourceIds: [SOURCE.sourceId], maxSlices: 1 }); + const restarted = control(root, dir, { historyStore: null }); + assert.throws(() => restarted.orchestrator.pause({ sourceId: SOURCE.sourceId }), /history unavailable/); + assert.equal(restarted.orchestrator.coverage()[0].state, 'scanning'); +}); + +test('paused history restores only its matching environment and loses to a later failure at the same millisecond', (t) => { + const dir = fixture(t); + const root = fixture(t); + const fixed = Date.parse('2026-09-08T12:00:00.000Z'); + const first = control(root, dir, { now: () => fixed }); + first.historyStore.recordSummary({ scanId: 'one', sourceId: SOURCE.sourceId, + environmentId: 'other', state: 'paused', completedAt: null, + recordedAt: new Date(fixed).toISOString(), visited: 7 }); + assert.equal(control(root, dir).orchestrator.coverage()[0].state, 'not-scanned'); + first.historyStore.recordSummary({ scanId: 'two', sourceId: SOURCE.sourceId, + environmentId: SOURCE.environmentId, state: 'paused', completedAt: null, + recordedAt: new Date(fixed).toISOString(), visited: 8 }); + assert.equal(control(root, dir).orchestrator.coverage()[0].state, 'paused'); + first.historyStore.recordSummary({ scanId: 'two', sourceId: SOURCE.sourceId, + environmentId: SOURCE.environmentId, state: 'failed', completedAt: new Date(fixed).toISOString(), + visited: 9, limitingReason: 'io-failure' }); + const [row] = control(root, dir).orchestrator.coverage(); + assert.equal(row.state, 'failed'); + assert.equal(row.visited, 9); +}); + test('MNT-DSC-018: stop previews affected work before confirming, then removes the active scan and retains history', async (t) => { const controlDir = fixture(t); const root = fixture(t); @@ -662,3 +755,18 @@ test('scan history rolls over independently at ten records per source and surviv [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); } }); + +test('clearHistory keeps a paused summary while its continuation is open', (t) => { + const dir = fixture(t); + const checkpoints = createCheckpointStore(path.join(dir, 'checkpoints'), { fsImpl: fs }); + const history = createScanHistoryStore(dir, { fsImpl: fs }); + const scanId = 'paused-scan'; + checkpoints.write({ scanId, sourceId: SOURCE.sourceId, environmentId: SOURCE.environmentId, + scanEpoch: 1, completedPartitions: [], pendingPartitions: [], workCounts: { visited: 1 }, + sourceStamps: [], createdAt: new Date().toISOString() }); + history.recordSummary({ scanId, sourceId: SOURCE.sourceId, environmentId: SOURCE.environmentId, + state: 'paused', completedAt: null, recordedAt: new Date().toISOString(), visited: 1 }); + assert.deepEqual(history.clearHistory(), { removed: 0, kept: 1 }); + checkpoints.remove(scanId); + assert.deepEqual(history.clearHistory(), { removed: 1, kept: 0 }); +}); diff --git a/tests/kit/maintenance-management-service.test.mjs b/tests/kit/maintenance-management-service.test.mjs index 0a9da99f..562e49b5 100644 --- a/tests/kit/maintenance-management-service.test.mjs +++ b/tests/kit/maintenance-management-service.test.mjs @@ -696,6 +696,35 @@ test('scan lifecycle: pauseScan and resumeScan accept a bare sourceId (symmetric } }); +test('paused collection root survives a service restart in Discovery and the Inventory partial banner', async (t) => { + const controlRoot = fixtureRoot(t); + const root = fixtureRoot(t); + fs.writeFileSync(path.join(root, 'root-file.txt'), 'x'); + for (let i = 0; i < 5; i += 1) { + const child = path.join(root, `project-${i}`); + fs.mkdirSync(path.join(child, '.git'), { recursive: true }); + fs.writeFileSync(path.join(child, 'file.txt'), 'x'); + } + const sourceId = opaqueId('src', { kind: 'collection-root', root }, INSTALLATION_KEY); + const discovery = { collectionRoots: [{ root, sourceId, maxDepth: null, includeNetwork: false }] }; + const first = buildHarness(t, { controlRoot, discovery }); + await first.service.startScan({ sourceId, maxSlices: 1 }); + const paused = first.service.pauseScan({ sourceId }); + const pausedRow = paused.coverage.find((entry) => entry.sourceId === sourceId); + assert.equal(pausedRow.state, 'paused'); + assert.ok(pausedRow.visited > 0); + + const restarted = buildHarness(t, { controlRoot, discovery }); + const row = restarted.service.discovery().coverage.find((entry) => entry.sourceId === sourceId); + assert.equal(row.state, 'paused'); + assert.equal(row.visited, pausedRow.visited); + assert.equal(row.label, 'Collection root'); + await restarted.service.refreshInventory(); + const page = restarted.service.inventory({}); + assert.equal(page.partialSources.total, 5, 'the paused root joins four unscanned automatic roots'); + assert.ok(page.partialSources.entries.some((entry) => entry.sourceId === sourceId && entry.state === 'paused')); +}); + test('DSC: setAutomaticSource, addExclusion, and removeExclusion round-trip through kit.json', async (t) => { const h = buildHarness(t); await h.service.setAutomaticSource({ sourceId: 'ollama', enabled: false }); From f753d2d99b1324b175e74bd5a14095dc2a0927ab Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 03:53:13 -0700 Subject: [PATCH 26/54] fix(exec): abort owned process trees --- src/lib/exec.mjs | 92 ++++++++++++++++------------- tests/kit/exec.test.mjs | 125 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 40 deletions(-) diff --git a/src/lib/exec.mjs b/src/lib/exec.mjs index 6726a0f5..bced116f 100644 --- a/src/lib/exec.mjs +++ b/src/lib/exec.mjs @@ -1,5 +1,5 @@ // Subprocess helpers. Rule (binding, from the plan): NOTHING goes through a -// shell string — execFile with argv arrays only, shell ALWAYS false. +// shell string — spawn with argv arrays only, shell ALWAYS false. // // npm/npx/claude/deja/ruflo/aqe/claude-flow are .cmd shims on Windows, and // Windows' CreateProcess cannot launch a .cmd directly — that historically @@ -7,26 +7,21 @@ // command line to cmd.exe as ONE string (CVE-class: any arg with `&`/`|`/`^` // breaks out into a second command). The actual fix is resolving the shim to // its real file on PATH. Native .com/.exe files run directly. A .cmd shim is -// never passed to execFile: Node does not execute batch files without a shell. +// never passed to spawn: Node does not execute batch files without a shell. // Instead, its sibling .ps1 shim runs through Windows PowerShell's `-File` // interface, preserving every caller argument as a separate argv element. -import { execFile, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { AsyncLocalStorage } from 'node:async_hooks'; -import { promisify } from 'node:util'; import fs from 'node:fs'; import path from 'node:path'; import { isWindows } from './paths.mjs'; -const pexecFile = promisify(execFile); const MAX_EXEC_BUFFER = 16 * 1024 * 1024; // A caller that bounds work it does not own (a live check under `ak status // --refresh=live` running the full live-check suite) scopes an AbortSignal // here; every run() inside the scope that passes no signal of its own uses it, -// so a timed-out check's direct child processes stop and its own cleanup still -// runs. The abort signals only that direct child: a process it started keeps -// running (on Windows a .cmd shim's child is PowerShell, so the ruflo or aqe -// node process behind it survives the abort). +// so a timed-out check's entire owned process tree stops and cleanup still runs. const abortScope = new AsyncLocalStorage(); /** Run `fn` with `signal` as the default abort signal for run() calls in it. @@ -156,53 +151,76 @@ function captureStream(stream, encoding, maxBuffer, onOverflow) { return state; } -/** The stdin-feeding, process-group variant of `run()`, used whenever a caller - * passes `opts.input`. Two reasons it exists, both from the security review: - * the payload stays out of argv (SEC-7 — `ps -ww` shows argv to the same user - * here, and `/proc//cmdline` shows it to ANY local user on Linux), and - * the child leads its own process group so the timeout reaps its subprocesses - * (SEC-8). - * - * WHY `spawn` AND NOT `execFile`: execFile forwards only a fixed whitelist of - * options through to spawn, and `detached` is not on it — passing it there is - * silently ignored, and the child stays in the PARENT's process group, where - * `process.kill(-pid)` fails ESRCH and the grandchild survives. Measured, not - * assumed. The timeout is likewise managed here rather than handed to the - * child process API, whose own `timeout` signals only the direct child. */ -function runWithInput(command, args, execOpts, { windows, input }) { +/** `spawn` is required for both paths: execFile silently drops `detached`, so + * its AbortSignal and timeout can only stop the direct child. Input stays on + * stdin and never enters argv. The Windows taskkill completion is awaited + * before returning, even if the direct child closes first. */ +function runOwned(command, args, execOpts, { windows, input }) { const { timeout, encoding, maxBuffer, cwd, env, signal } = execOpts; + if (signal?.aborted) return Promise.resolve({ code: 1, stdout: '', stderr: 'The operation was aborted' }); return new Promise((resolve) => { let child; try { - child = spawn(command, args, { cwd, env, signal, shell: false, detached: !windows }); + child = spawn(command, args, { cwd, env, shell: false, detached: !windows }); } catch (err) { resolve(failureResult(err)); return; } let failure = null; - const abort = (reason) => { failure ??= reason; killGroup(child); }; + let closed = false; + let stopping = Promise.resolve(); + const abort = (reason) => { + if (closed || (windows && (child.exitCode !== null || child.signalCode !== null))) return; + if (failure) return; + failure = reason; + if (!windows) { killGroup(child); return; } + if (!Number.isInteger(child.pid) || child.pid < 1) return; + stopping = new Promise((done) => { + let killer; + try { + killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { + stdio: 'ignore', shell: false, + }); + } catch { try { child.kill('SIGKILL'); } catch { /* exited */ } done(); return; } + killer.once('error', () => { try { child.kill('SIGKILL'); } catch { /* exited */ } done(); }); + killer.once('close', (code) => { + if (code !== 0 && child.exitCode === null && child.signalCode === null) { + try { child.kill('SIGKILL'); } catch { /* exited */ } + } + done(); + }); + }); + }; + const onSignal = () => abort('The operation was aborted'); + signal?.addEventListener('abort', onSignal, { once: true }); + if (signal?.aborted) onSignal(); const out = captureStream(child.stdout, encoding, maxBuffer, () => abort('stdout maxBuffer length exceeded')); const errOut = captureStream(child.stderr, encoding, maxBuffer, () => abort('stderr maxBuffer length exceeded')); - const timer = setTimeout(() => abort(`timed out after ${timeout}ms`), timeout); - timer.unref?.(); + const timer = timeout > 0 ? setTimeout(() => abort(`timed out after ${timeout}ms`), timeout) : null; + timer?.unref?.(); child.on('error', (err) => { clearTimeout(timer); - resolve(failureResult(err, out.text, errOut.text)); + signal?.removeEventListener('abort', onSignal); + stopping.then(() => resolve(failureResult(err, out.text, errOut.text))); }); - child.on('close', (code) => { + child.on('close', (code, exitSignal) => { + closed = true; clearTimeout(timer); + signal?.removeEventListener('abort', onSignal); const exitCode = typeof code === 'number' ? code : 1; - resolve({ + stopping.then(() => resolve({ code: failure ? exitCode || 1 : exitCode, stdout: out.text, stderr: failure ? errOut.text || failure : errOut.text, - }); + ...(exitSignal ? { signal: exitSignal } : {}), + })); }); // A child that exits without reading its input makes this write fail with // EPIPE. That is the child's own non-zero exit to report, not a crash here. child.stdin?.on('error', () => {}); - child.stdin?.end(input); + if (typeof input === 'string') child.stdin?.end(input); + else child.stdin?.end(); }); } @@ -229,13 +247,9 @@ export async function run(cmd, args = [], opts = {}) { env, shell: false, }; - if (typeof opts.input === 'string') { - return await runWithInput(invocation.command, invocation.args, execOpts, { - windows, input: opts.input, - }); - } - const { stdout, stderr } = await pexecFile(invocation.command, invocation.args, execOpts); - return { code: 0, stdout, stderr }; + return await runOwned(invocation.command, invocation.args, execOpts, { + windows, input: opts.input, + }); } catch (err) { return failureResult(err); } diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index 0526bcda..2e705cad 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -3,7 +3,130 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { run, have, resolveShim } from '../../src/lib/exec.mjs'; +import { run, have, resolveShim, withAbortSignal } from '../../src/lib/exec.mjs'; + +const isAlive = (pid) => { + try { process.kill(pid, 0); return true; } catch { return false; } +}; +async function waitUntil(predicate) { + for (let n = 0; n < 60; n += 1) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return predicate(); +} + +for (const [name, options] of [ + ['no input with explicit signal', { input: undefined, inherited: false }], + ['input with explicit signal', { input: '', inherited: false }], + ['no input with inherited signal', { input: undefined, inherited: true }], + ['input with inherited signal', { input: '', inherited: true }], +]) { + test(`run() abort reaps owned grandchild: ${name}`, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-abort-tree-')); + const pidFile = path.join(dir, 'pids.json'); + const controller = new AbortController(); + const code = `const {spawn}=require('node:child_process'); + const gc=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); + require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); + setInterval(()=>{},1000);`; + let pids = []; + try { + const launch = () => run(process.execPath, ['-e', code], { + input: options.input, timeout: 5_000, + ...(options.inherited ? {} : { signal: controller.signal }), + }); + const pending = options.inherited ? withAbortSignal(controller.signal, launch) : launch(); + assert.equal(await waitUntil(() => fs.existsSync(pidFile)), true, 'owned children started'); + pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + assert.equal(isAlive(pids[1]), true, 'grandchild alive before abort'); + controller.abort(); + const result = await pending; + assert.notEqual(result.code, 0); + assert.equal(await waitUntil(() => !isAlive(pids[1])), true, + 'abort must terminate the grandchild, not only its parent'); + } finally { + controller.abort(); + for (const pid of pids) { + if (isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } + } + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} + +test('run() does not spawn for a pre-aborted signal, including inherited abort', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-preabort-')); + const marker = path.join(dir, 'spawned'); + const aborted = new AbortController(); + aborted.abort(); + const args = ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)},'yes')`]; + try { + for (const input of [undefined, '']) { + const explicit = await run(process.execPath, args, { signal: aborted.signal, input }); + const inherited = await withAbortSignal(aborted.signal, + () => run(process.execPath, args, { input })); + assert.notEqual(explicit.code, 0); + assert.notEqual(inherited.code, 0); + assert.equal(fs.existsSync(marker), false); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('an explicit live signal takes precedence over an aborted inherited signal', async () => { + const inherited = new AbortController(); + inherited.abort(); + const explicit = new AbortController(); + for (const input of [undefined, '']) { + const result = await withAbortSignal(inherited.signal, () => run( + process.execPath, ['-e', 'process.stdout.write("ok")'], + { signal: explicit.signal, input }, + )); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, 'ok'); + } +}); + +test('Windows abort reaps the Node child behind a PowerShell shim and its grandchild', { + skip: process.platform !== 'win32', +}, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-abort-shim-')); + const pidFile = path.join(dir, 'pids.json'); + const controller = new AbortController(); + const quotedNode = process.execPath.replaceAll("'", "''"); + let pids = []; + try { + fs.writeFileSync(path.join(dir, 'codex.cmd'), '@echo off\r\n'); + fs.writeFileSync(path.join(dir, 'codex.ps1'), `& '${quotedNode}' -e $args[0]\n`); + const code = `const {spawn}=require('node:child_process'); + const gc=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); + require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); + setInterval(()=>{},1000);`; + const pending = run('codex', [code], { + env: { PATH: dir, PATHEXT: '.CMD' }, signal: controller.signal, timeout: 10_000, + }); + assert.equal(await waitUntil(() => fs.existsSync(pidFile)), true, 'PowerShell launched Node'); + pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + assert.equal(isAlive(pids[1]), true); + controller.abort(); + const result = await pending; + assert.notEqual(result.code, 0); + assert.equal(await waitUntil(() => pids.every((pid) => !isAlive(pid))), true, + 'taskkill must remove the Node process and its grandchild'); + } finally { + controller.abort(); + for (const pid of pids) { + if (isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } + } + } + fs.rmSync(dir, { recursive: true, force: true }); + } +}); // code-quality Finding 2: exec.mjs used to set shell:true for a fixed set of // Windows .cmd shims (npm/npx/claude/ruflo/aqe/claude-flow), which handed From 53f6a17f8f79262035048e6c4dfd129103d32042 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:01:21 -0700 Subject: [PATCH 27/54] fix(exec): bound uncertain Windows cleanup and byte caps --- src/lib/exec.mjs | 73 +++++++++++++++++++++++++++++++---------- tests/kit/exec.test.mjs | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 17 deletions(-) diff --git a/src/lib/exec.mjs b/src/lib/exec.mjs index bced116f..3662581a 100644 --- a/src/lib/exec.mjs +++ b/src/lib/exec.mjs @@ -137,13 +137,18 @@ export function killProcessTree(child, { platform = process.platform, spawnFn = /** Accumulate one child stream, capped at `maxBuffer` — `execFile` applies its * own cap internally, so the spawn-based path below has to reimplement it. */ function captureStream(stream, encoding, maxBuffer, onOverflow) { - const state = { text: '', overflowed: false }; - stream?.setEncoding?.(encoding); + const chunks = []; + const state = { + bytes: 0, + overflowed: false, + get text() { return Buffer.concat(chunks).toString(encoding); }, + }; stream?.on('data', (chunk) => { if (state.overflowed) return; - state.text += chunk; - if (state.text.length > maxBuffer) { - state.text = state.text.slice(0, maxBuffer); + const remaining = maxBuffer - state.bytes; + chunks.push(chunk.subarray(0, remaining)); + state.bytes += chunk.length; + if (state.bytes > maxBuffer) { state.overflowed = true; onOverflow(); } @@ -167,26 +172,60 @@ function runOwned(command, args, execOpts, { windows, input }) { let failure = null; let closed = false; let stopping = Promise.resolve(); + const closePipes = () => { + child.stdout?.destroy(); + child.stderr?.destroy(); + child.stdin?.destroy(); + }; + const incomplete = (reason) => { + failure = `${reason}; incomplete process-tree cleanup`; + closePipes(); + }; const abort = (reason) => { - if (closed || (windows && (child.exitCode !== null || child.signalCode !== null))) return; + if (closed) return; if (failure) return; failure = reason; if (!windows) { killGroup(child); return; } - if (!Number.isInteger(child.pid) || child.pid < 1) return; + // A reaped root PID may already name a different process. The owned + // descendant may still hold our pipes, but cannot be safely found by PID. + if (child.exitCode !== null || child.signalCode !== null) { + incomplete(reason); + return; + } + if (!Number.isInteger(child.pid) || child.pid < 1) { + incomplete(reason); + return; + } stopping = new Promise((done) => { let killer; + const fallback = (detail) => { + incomplete(`${reason} (${detail})`); + if (child.exitCode === null && child.signalCode === null) { + try { child.kill('SIGKILL'); } catch { /* exited */ } + } + done(); + }; try { killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', shell: false, }); - } catch { try { child.kill('SIGKILL'); } catch { /* exited */ } done(); return; } - killer.once('error', () => { try { child.kill('SIGKILL'); } catch { /* exited */ } done(); }); - killer.once('close', (code) => { - if (code !== 0 && child.exitCode === null && child.signalCode === null) { - try { child.kill('SIGKILL'); } catch { /* exited */ } - } - done(); - }); + } catch { fallback('taskkill could not start'); return; } + const finish = (code, detail) => { + clearTimeout(deadline); + killer.removeListener('error', onError); + killer.removeListener('close', onClose); + if (code === 0) done(); + else fallback(detail); + }; + const onError = () => finish(null, 'taskkill could not start'); + const onClose = (code) => finish(code, `taskkill exited ${code}`); + const deadline = setTimeout(() => { + try { killer.kill('SIGKILL'); } catch { /* already exited */ } + killer.unref?.(); + finish(null, 'taskkill exceeded cleanup deadline'); + }, 1_000); + killer.once('error', onError); + killer.once('close', onClose); }); }; const onSignal = () => abort('The operation was aborted'); @@ -212,7 +251,7 @@ function runOwned(command, args, execOpts, { windows, input }) { stopping.then(() => resolve({ code: failure ? exitCode || 1 : exitCode, stdout: out.text, - stderr: failure ? errOut.text || failure : errOut.text, + stderr: failure ? [errOut.text, failure].filter(Boolean).join('\n') : errOut.text, ...(exitSignal ? { signal: exitSignal } : {}), })); }); @@ -226,7 +265,7 @@ function runOwned(command, args, execOpts, { windows, input }) { /** Run a command; never throws. Returns {code, stdout, stderr}. * `opts.input` (a string) is delivered on the child's stdin instead of argv; - * see `runWithInput` for the two guarantees that carries. */ + * `runOwned` keeps that payload out of the process table. */ export async function run(cmd, args = [], opts = {}) { try { const env = opts.env ? { ...process.env, ...opts.env } : process.env; diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index 2e705cad..8a153465 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -91,6 +91,79 @@ test('an explicit live signal takes precedence over an aborted inherited signal' } }); +for (const stop of ['abort', 'timeout']) { + test(`Windows ${stop} after direct-child exit reports incomplete tree cleanup`, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-exited-root-')); + const pidFile = path.join(dir, 'pids.json'); + const exitFile = path.join(dir, 'parent-exit'); + const controller = new AbortController(); + const code = `const {spawn}=require('node:child_process'); + const gc=spawn(process.execPath,['-e','setTimeout(()=>{},5000)'],{stdio:['ignore','inherit','inherit']}); + gc.unref(); + require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); + process.on('exit',()=>require('node:fs').writeFileSync(${JSON.stringify(exitFile)},'yes'));`; + let pids = []; + let pending; + try { + const started = Date.now(); + pending = run(process.execPath, ['-e', code], { + windows: true, signal: controller.signal, timeout: stop === 'timeout' ? 1_200 : 5_000, + }); + assert.equal(await waitUntil(() => fs.existsSync(pidFile)), true); + pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + assert.equal(await waitUntil(() => fs.existsSync(exitFile)), true, 'direct child exited'); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(isAlive(pids[1]), true, 'descendant still owns the output pipe'); + const stoppedAt = Date.now(); + if (stop === 'abort') controller.abort(); + const result = await pending; + assert.notEqual(result.code, 0, 'an incomplete run cannot report success'); + assert.match(result.stderr, /incomplete.*tree cleanup/i); + assert.ok(Date.now() - (stop === 'abort' ? stoppedAt : started) + < (stop === 'abort' ? 1_400 : 2_600), 'return is bounded by abort or timeout'); + } finally { + controller.abort(); + for (const pid of pids) { + if (isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } + } + } + await pending; + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} + +test('Windows taskkill stall has a bounded cleanup wait and reports uncertainty', { + skip: process.platform === 'win32', +}, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-taskkill-stall-')); + const killer = path.join(dir, 'taskkill.exe'); + const oldPath = process.env.PATH; + fs.writeFileSync(killer, `#!${process.execPath}\nsetTimeout(() => process.exit(1), 1500);\n`, { mode: 0o755 }); + process.env.PATH = `${dir}${path.delimiter}${oldPath}`; + try { + const started = Date.now(); + const result = await run(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + windows: true, timeout: 100, + }); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /incomplete.*tree cleanup/i); + assert.ok(Date.now() - started < 1400, 'does not wait for the stalled taskkill process'); + } finally { + process.env.PATH = oldPath; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('run() enforces maxBuffer in UTF-8 bytes', async () => { + const result = await run(process.execPath, ['-e', 'process.stdout.write("ééé")'], { + maxBuffer: 4, + }); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /maxBuffer/i); +}); + test('Windows abort reaps the Node child behind a PowerShell shim and its grandchild', { skip: process.platform !== 'win32', }, async () => { From 8e8889a113f63912f2c4c9d7a04841b07960e7ce Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:05:48 -0700 Subject: [PATCH 28/54] test(setup): isolate host rerecord project fixture --- tests/kit/setup-host-rerecord.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/kit/setup-host-rerecord.test.mjs b/tests/kit/setup-host-rerecord.test.mjs index 90c2e9fa..80cb1110 100644 --- a/tests/kit/setup-host-rerecord.test.mjs +++ b/tests/kit/setup-host-rerecord.test.mjs @@ -2,9 +2,11 @@ import { test, after } from 'node:test'; import assert from 'node:assert/strict'; import { registerHooks } from 'node:module'; import { sandboxHome, rmrf } from './helpers/home-sandbox.mjs'; +import { isolateProject, REPO_ROOT } from './helpers/project-isolation.mjs'; const home = sandboxHome('ak-setup-host-rerecord'); after(() => rmrf(home)); +isolateProject('ak-setup-host-rerecord'); const setup = await import('../../src/commands/setup.mjs'); const cfg = { integrations: { hosts: { claude: false, codex: true, opencode: false } } }; @@ -44,7 +46,7 @@ test('setup run passes its host lifecycle through the machine setup boundary', a let received; await setup.run({ flags: { 'dry-run': true, minimal: true, 'no-aqe': true, 'no-ruvnet-brain': true, 'no-agent-browser': true, yes: true }, - pkgRoot: process.cwd(), + pkgRoot: REPO_ROOT, deps: { hostLifecycle: lifecycle }, machineSetup: async args => { received = args.deps?.hostLifecycle; return false; }, }); From cd71b031e3bd41cf34fc11b0c5037f196c38d73d Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:12:15 -0700 Subject: [PATCH 29/54] fix(test): hold C1 run root until owned children close --- tests/kit/aqe-live-lock-process.test.mjs | 109 ++++++++++++++++++++++ tests/live/aqe-live-lock-process.mjs | 29 +++++- tests/live/aqe-live-lock-process.test.mjs | 35 ------- 3 files changed, 135 insertions(+), 38 deletions(-) create mode 100644 tests/kit/aqe-live-lock-process.test.mjs delete mode 100644 tests/live/aqe-live-lock-process.test.mjs diff --git a/tests/kit/aqe-live-lock-process.test.mjs b/tests/kit/aqe-live-lock-process.test.mjs new file mode 100644 index 00000000..83a15411 --- /dev/null +++ b/tests/kit/aqe-live-lock-process.test.mjs @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, existsSync, rmSync, mkdirSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createProcessScope } from '../live/aqe-live-lock-process.mjs'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; +import { ownerRecord, writeOwner, readOwner, prepareRunRootHolds, + inspectRunRootHolds } from '../../scripts/run-roots.mjs'; + +function sandbox(root) { + const home = path.join(root, 'home'); + mkdirSync(home); + return spawnEnv(home); +} + +test('abort closes a call-owned child before its temporary root is removed', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-abort-proof-')); + const controller = new AbortController(); + const scope = createProcessScope(controller.signal); + let closed = false; + try { + const marker = path.join(root, 'child-ready'); + const run = scope.launch(process.execPath, ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ready');setInterval(() => {}, 1000)`], { cwd: root, env: sandbox(root) }); + assert.ok(run.child.pid > 0); + const deadline = Date.now() + 2000; + while (!existsSync(marker) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(existsSync(marker), 'the child must be running before cancellation'); + controller.abort(); + await scope.closeAll(); + closed = true; + assert.equal(run.closed, true); + assert.throws(() => scope.launch(process.execPath, [], { env: {} }), /cannot launch/); + assert.ok(existsSync(root), 'root must remain until closure is established'); + } finally { + if (!closed) await scope.closeAll(); + if (closed) rmSync(root, { recursive: true, force: true }); + } + assert.equal(existsSync(root), false); +}); + +test('spawn failure is retained without an unhandled rejection', async () => { + const scope = createProcessScope(new AbortController().signal); + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-spawn-failure-')); + const run = scope.launch(path.join(root, 'ak-missing-executable'), [], { env: sandbox(root) }); + await assert.rejects(scope.wait(run, 1000), /ENOENT/); + await scope.closeAll(); + rmSync(root, { recursive: true, force: true }); +}); + +test('requires explicit sandbox env and passes it to the child', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-env-proof-')); + const scope = createProcessScope(new AbortController().signal); + try { + assert.throws(() => scope.launch(process.execPath, [], {}), /explicit sandbox env/); + assert.throws(() => scope.launch(process.execPath, [], { env: {} }), /requires sandbox home/); + const env = sandbox(root); + const run = scope.launch(process.execPath, + ['-e', 'console.log(JSON.stringify({home:process.env.HOME,tmp:process.env.TMPDIR,state:process.env.XDG_STATE_HOME}))'], + { cwd: root, env }); + const result = await scope.wait(run, 2000); + assert.equal(result.code, 0); + const observed = JSON.parse(result.stdout.trim()); + assert.equal(observed.home, env.HOME); + assert.equal(observed.tmp, env.TMPDIR); + assert.equal(observed.state, env.XDG_STATE_HOME); + } finally { + await scope.closeAll(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a guarded scope holds the enclosing run root until owned children close', async () => { + const base = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-hold-base-')); + const root = mkdtempSync(path.join(base, 'ak-suite-')); + const owner = ownerRecord(); + writeOwner(root, owner); + prepareRunRootHolds(root, owner.runId); + const beforeRoot = process.env.AK_SUITE_ROOT; + const beforeId = process.env.AK_SUITE_RUN_ID; + process.env.AK_SUITE_ROOT = root; + process.env.AK_SUITE_RUN_ID = owner.runId; + let scope; + try { + scope = createProcessScope(new AbortController().signal, { closeLimitMs: 25 }); + assert.equal(inspectRunRootHolds(root, readOwner(root).runId).unresolved, true); + const run = scope.launch(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], + { env: sandbox(base) }); + assert.equal(run.closed, false); + const realKill = run.child.kill.bind(run.child); + run.child.kill = () => false; + try { + await assert.rejects(scope.closeAll(), /did not close/); + assert.equal(inspectRunRootHolds(root, owner.runId).unresolved, true); + } finally { + run.child.kill = realKill; + } + await scope.closeAll(); + assert.equal(run.closed, true); + assert.equal(inspectRunRootHolds(root, owner.runId).unresolved, false); + } finally { + if (scope) await scope.closeAll(); + if (beforeRoot === undefined) delete process.env.AK_SUITE_ROOT; + else process.env.AK_SUITE_ROOT = beforeRoot; + if (beforeId === undefined) delete process.env.AK_SUITE_RUN_ID; + else process.env.AK_SUITE_RUN_ID = beforeId; + rmSync(base, { recursive: true, force: true }); + } +}); diff --git a/tests/live/aqe-live-lock-process.mjs b/tests/live/aqe-live-lock-process.mjs index 0f82d697..f2324382 100644 --- a/tests/live/aqe-live-lock-process.mjs +++ b/tests/live/aqe-live-lock-process.mjs @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { acquireRunRootHold, releaseRunRootHold } from '../../scripts/run-roots.mjs'; const CLOSE_LIMIT_MS = 10_000; @@ -13,15 +14,33 @@ function closedWithin(run, ms) { ]).finally(() => clearTimeout(timer)); } -export function createProcessScope(signal) { +export function createProcessScope(signal, { closeLimitMs = CLOSE_LIMIT_MS } = {}) { + // Register uncertainty with the enclosing guarded runner before any child starts. + const hold = acquireRunRootHold(); const runs = new Set(); + let closed = false; + let closing = false; const killLive = () => { for (const run of runs) if (!run.closed) run.child.kill('SIGKILL'); }; signal.addEventListener('abort', killLive, { once: true }); function launch(command, args, options) { - const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + if (closing || signal.aborted) throw Error('cannot launch after process scope closing or aborted'); + if (!options?.env || typeof options.env !== 'object' || Array.isArray(options.env)) { + throw Error('call-owned child requires an explicit sandbox env'); + } + const env = options.env; + const required = ['HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'XDG_CONFIG_HOME', + 'XDG_STATE_HOME', 'APPDATA', 'LOCALAPPDATA']; + if (required.some((key) => typeof env[key] !== 'string' || !env[key])) { + throw Error('call-owned child requires sandbox home, temp, and state env'); + } + if (process.platform === 'win32' && ['SystemRoot', 'ComSpec', 'PATHEXT'] + .some((key) => typeof env[key] !== 'string' || !env[key])) { + throw Error('call-owned child requires Windows process env'); + } + const child = spawn(command, args, { ...options, env, stdio: ['ignore', 'pipe', 'pipe'] }); const run = { child, closed: false, error: null, stdout: '', stderr: '', done: null, result: null }; runs.add(run); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); @@ -70,9 +89,13 @@ export function createProcessScope(signal) { } async function closeAll() { + if (closed) return; + closing = true; killLive(); // A failed close keeps the caller's temporary root intact for diagnosis. - await Promise.all([...runs].map((run) => closedWithin(run, CLOSE_LIMIT_MS))); + await Promise.all([...runs].map((run) => closedWithin(run, closeLimitMs))); + releaseRunRootHold(hold); + closed = true; signal.removeEventListener('abort', killLive); } diff --git a/tests/live/aqe-live-lock-process.test.mjs b/tests/live/aqe-live-lock-process.test.mjs deleted file mode 100644 index f6ca6542..00000000 --- a/tests/live/aqe-live-lock-process.test.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, existsSync, rmSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createProcessScope } from './aqe-live-lock-process.mjs'; - -test('abort closes a call-owned child before its temporary root is removed', async () => { - const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-abort-proof-')); - const controller = new AbortController(); - const scope = createProcessScope(controller.signal); - try { - const marker = path.join(root, 'child-ready'); - const run = scope.launch(process.execPath, ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ready');setInterval(() => {}, 1000)`], { cwd: root, env: { PATH: process.env.PATH ?? '' } }); - assert.ok(run.child.pid > 0); - const deadline = Date.now() + 2000; - while (!existsSync(marker) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); - assert.ok(existsSync(marker), 'the child must be running before cancellation'); - controller.abort(); - await scope.closeAll(); - assert.equal(run.closed, true); - assert.ok(existsSync(root), 'root must remain until closure is established'); - } finally { - await scope.closeAll(); - rmSync(root, { recursive: true, force: true }); - } - assert.equal(existsSync(root), false); -}); - -test('spawn failure is retained without an unhandled rejection', async () => { - const scope = createProcessScope(new AbortController().signal); - const run = scope.launch(path.join(os.tmpdir(), 'ak-missing-executable'), [], { env: { PATH: '' } }); - await assert.rejects(scope.wait(run, 1000), /ENOENT/); - await scope.closeAll(); -}); From ceb295b7b62fe162faf006a87f904479d7f4dfb0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:17:14 -0700 Subject: [PATCH 30/54] fix(test): accept Windows bootstrap env casing --- tests/kit/aqe-live-lock-process.test.mjs | 31 +++++++++++++++++++++++- tests/live/aqe-live-lock-process.mjs | 12 ++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/kit/aqe-live-lock-process.test.mjs b/tests/kit/aqe-live-lock-process.test.mjs index 83a15411..467f34b8 100644 --- a/tests/kit/aqe-live-lock-process.test.mjs +++ b/tests/kit/aqe-live-lock-process.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, existsSync, rmSync, mkdirSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createProcessScope } from '../live/aqe-live-lock-process.mjs'; -import { spawnEnv } from './helpers/home-sandbox.mjs'; +import { spawnEnv, envValue } from './helpers/home-sandbox.mjs'; import { ownerRecord, writeOwner, readOwner, prepareRunRootHolds, inspectRunRootHolds } from '../../scripts/run-roots.mjs'; @@ -70,6 +70,35 @@ test('requires explicit sandbox env and passes it to the child', async () => { } }); +test('Windows bootstrap validation accepts preserved mixed-case names without adding duplicates', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-windows-env-')); + const home = path.join(root, 'home'); + mkdirSync(home); + const env = spawnEnv(home, {}, { platform: 'win32', env: { + SYSTEMROOT: process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows', + COMSPEC: process.env.ComSpec ?? process.env.COMSPEC ?? 'C:\\Windows\\System32\\cmd.exe', + PaThExT: process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD', + Path: process.env.PATH ?? '', + } }); + assert.equal(env.SystemRoot, undefined); + assert.equal(env.ComSpec, undefined); + assert.equal(envValue(env, 'SystemRoot', 'win32'), env.SYSTEMROOT); + const scope = createProcessScope(new AbortController().signal, { platform: 'win32' }); + try { + assert.throws(() => scope.launch(process.execPath, [], { env: { ...env, COMSPEC: '' } }), + /requires Windows process env/); + const run = scope.launch(process.execPath, ['-e', 'console.log("bootstrapped")'], { env }); + const result = await scope.wait(run, 2000); + assert.equal(result.code, 0); + assert.match(result.stdout, /bootstrapped/); + assert.equal(Object.keys(env).filter((key) => key.toUpperCase() === 'SYSTEMROOT').length, 1); + assert.equal(Object.keys(env).filter((key) => key.toUpperCase() === 'COMSPEC').length, 1); + } finally { + await scope.closeAll(); + rmSync(root, { recursive: true, force: true }); + } +}); + test('a guarded scope holds the enclosing run root until owned children close', async () => { const base = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-hold-base-')); const root = mkdtempSync(path.join(base, 'ak-suite-')); diff --git a/tests/live/aqe-live-lock-process.mjs b/tests/live/aqe-live-lock-process.mjs index f2324382..433ab8a3 100644 --- a/tests/live/aqe-live-lock-process.mjs +++ b/tests/live/aqe-live-lock-process.mjs @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import { acquireRunRootHold, releaseRunRootHold } from '../../scripts/run-roots.mjs'; +import { envValue } from '../kit/helpers/home-sandbox.mjs'; const CLOSE_LIMIT_MS = 10_000; @@ -14,7 +15,7 @@ function closedWithin(run, ms) { ]).finally(() => clearTimeout(timer)); } -export function createProcessScope(signal, { closeLimitMs = CLOSE_LIMIT_MS } = {}) { +export function createProcessScope(signal, { closeLimitMs = CLOSE_LIMIT_MS, platform = process.platform } = {}) { // Register uncertainty with the enclosing guarded runner before any child starts. const hold = acquireRunRootHold(); const runs = new Set(); @@ -31,13 +32,16 @@ export function createProcessScope(signal, { closeLimitMs = CLOSE_LIMIT_MS } = { throw Error('call-owned child requires an explicit sandbox env'); } const env = options.env; + const missing = (key) => { + const value = envValue(env, key, platform); + return typeof value !== 'string' || !value; + }; const required = ['HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'APPDATA', 'LOCALAPPDATA']; - if (required.some((key) => typeof env[key] !== 'string' || !env[key])) { + if (required.some(missing)) { throw Error('call-owned child requires sandbox home, temp, and state env'); } - if (process.platform === 'win32' && ['SystemRoot', 'ComSpec', 'PATHEXT'] - .some((key) => typeof env[key] !== 'string' || !env[key])) { + if (platform === 'win32' && ['SystemRoot', 'ComSpec', 'PATHEXT'].some(missing)) { throw Error('call-owned child requires Windows process env'); } const child = spawn(command, args, { ...options, env, stdio: ['ignore', 'pipe', 'pipe'] }); From 79fdb609f2e349476224822b0e70e96ec02bf715 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:23:00 -0700 Subject: [PATCH 31/54] test(ci): seed both requested self-drift tags --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5dfc6d8..fb59a044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: # The smoke test proves local CLI behavior, not npm reachability. # Seed a fresh empty drift cache so an offline Windows runner does # not pay four sequential 20s `npm view` timeouts inside status. - node -e 'const fs=require("node:fs"),p=require("node:path"),base=process.platform==="win32"?process.env.APPDATA:process.env.XDG_CONFIG_HOME,dir=p.join(base,"agentic-kit"),last=Date.now();fs.mkdirSync(dir,{recursive:true});fs.writeFileSync(p.join(dir,"kit.json"),JSON.stringify({versionCheck:{last,seen:{},self:{last,best:null}}}))' + node -e 'const fs=require("node:fs"),p=require("node:path"),base=process.platform==="win32"?process.env.APPDATA:process.env.XDG_CONFIG_HOME,dir=p.join(base,"agentic-kit"),last=Date.now();fs.mkdirSync(dir,{recursive:true});fs.writeFileSync(p.join(dir,"kit.json"),JSON.stringify({versionCheck:{last,seen:{},self:{last,best:null,lastTags:["latest","next"]}}}))' node bin/agentic-kit.mjs --version node bin/agentic-kit.mjs --help --all > /dev/null # status must emit valid JSON and exit deterministically even on a From c3e166332aaa40c281586831ee9a0a25f569e585 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:23:06 -0700 Subject: [PATCH 32/54] test(ci): probe pinned upstream conformance in isolated homes --- .github/workflows/ci.yml | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb59a044..d3eba709 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,3 +138,90 @@ jobs: with: args: "--offline --include-fragments --config lychee.toml README.md CLAUDE.md AGENTS.md 'docker/*.md' 'claude/**/*.md' 'src/templates/**/*.md' 'docs/**/*.md' 'docs/**/*.html'" fail: true + + # Temporary C1 proof: remove this job before merging the feature PR. + c1-native-proof: + name: C1 native proof (${{ matrix.os }}, AQE ${{ matrix.aqe }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, aqe: '3.14.4', memory: true } + - { os: windows-latest, aqe: '3.14.4', memory: true } + - { os: ubuntu-latest, aqe: '3.14.5', memory: false } + - { os: macos-latest, aqe: '3.14.5', memory: false } + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Configure isolated npm prefix + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP 'c1-installer-home' + $prefix = Join-Path $root 'npm-prefix' + foreach ($dir in @($root, $prefix, (Join-Path $root 'npm-cache'))) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + foreach ($key in @('HOME','USERPROFILE','XDG_CONFIG_HOME','XDG_STATE_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','APPDATA','LOCALAPPDATA','CODEX_HOME','CLAUDE_CONFIG_DIR','HERMES_HOME','MISE_DATA_DIR','MISE_CONFIG_DIR','MISE_CACHE_DIR')) { + $value = Join-Path $root $key.ToLowerInvariant() + New-Item -ItemType Directory -Force -Path $value | Out-Null + "$key=$value" >> $env:GITHUB_ENV + } + "npm_config_prefix=$prefix" >> $env:GITHUB_ENV + "npm_config_cache=$(Join-Path $root 'npm-cache')" >> $env:GITHUB_ENV + if ($IsWindows) { + "AK_AQE_PACKAGE_ROOT=$(Join-Path $prefix 'node_modules/agentic-qe')" >> $env:GITHUB_ENV + $prefix >> $env:GITHUB_PATH + } else { + "AK_AQE_PACKAGE_ROOT=$(Join-Path $prefix 'lib/node_modules/agentic-qe')" >> $env:GITHUB_ENV + (Join-Path $prefix 'bin') >> $env:GITHUB_PATH + } + - name: Install exact upstream artifacts with native scripts + shell: pwsh + run: | + npm install -g --allow-scripts=ruflo,agentic-qe,@claude-flow/cli,better-sqlite3,hnswlib-node,agentdb,agentic-flow,argon2,onnxruntime-node,sharp,protobufjs,@google/genai,tldjs,vibium ruflo@3.48.0 agentic-qe@${{ matrix.aqe }} + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node -e "const p=require(process.env.AK_AQE_PACKAGE_ROOT+'/package.json');if(p.version!=='${{ matrix.aqe }}')process.exit(1);console.log(p.name,p.version)" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ruflo --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Source and package receipt + shell: pwsh + run: | + git rev-parse HEAD | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-head.txt') + node -e "const fs=require('fs'),c=require('crypto'),p=require('path'),r=process.env.AK_AQE_PACKAGE_ROOT;for(const f of ['package.json','dist/cli/bundle.js','dist/integrations/ruvector/shared-rvf-adapter.js'])console.log(f,c.createHash('sha256').update(fs.readFileSync(p.join(r,f))).digest('hex'))" | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-source-hashes.txt') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Native AQE live-lock conformance (POSIX) + if: matrix.os != 'windows-latest' + shell: pwsh + env: + AK_AQE_LOCK_LIVE: '1' + AK_AQE_EXPECTED_VERSION: ${{ matrix.aqe }} + run: | + node scripts/run-tests.mjs exec -- --test tests/live/aqe-live-lock-conformance.test.mjs 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-aqe-live-lock.log') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $text = Get-Content -Raw (Join-Path $env:RUNNER_TEMP 'c1-aqe-live-lock.log') + if ($text -notmatch '"liveLock":true' -or $text -notmatch '"lockHeld":true') { throw 'AQE proof lacked the native contention JSON receipt' } + - name: Real Ruflo memory routing (Linux and Windows) + if: matrix.memory + shell: pwsh + run: | + ruflo --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/run-tests.mjs exec -- --test tests/live/ruflo-memory-routing.test.mjs 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-memory-routing.log') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $text = Get-Content -Raw (Join-Path $env:RUNNER_TEMP 'c1-memory-routing.log') + if ($text -notmatch '"status":"observed"') { throw 'Ruflo routing proof lacked an observed JSON receipt' } + - name: Upload C1 source-bound proof + if: always() + uses: actions/upload-artifact@v7 + with: + name: c1-native-proof-${{ matrix.os }}-aqe-${{ matrix.aqe }}-${{ github.sha }} + path: | + ${{ runner.temp }}/c1-*.log + ${{ runner.temp }}/c1-head.txt + ${{ runner.temp }}/c1-source-hashes.txt + if-no-files-found: error From d5389aa6d8219e225247b79cea2275c5b2e80ced Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:33:29 -0700 Subject: [PATCH 33/54] docs(host-support): align upstream risks with verified releases --- docs/host-support.md | 24 ++++++++++++------- .../agentic-dependency-constraints.json | 10 ++++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/host-support.md b/docs/host-support.md index 6bbfb259..151f5fed 100644 --- a/docs/host-support.md +++ b/docs/host-support.md @@ -119,15 +119,20 @@ Official extension references: [Claude hooks](https://code.claude.com/docs/en/ho | Teardown | Managed blocks and registrations | Receipt-based managed teardown | Value- and hash-receipt teardown; user-owned values survive | Ruflo MCP access and Ruflo-backed inference are different contracts. In -particular, Ruflo's [`agent_execute` provider-key behavior](https://github.com/ruvnet/ruflo/issues/2356) -can still require a separate provider credential even when invoked from Codex. +Ruflo 3.48.0's shipped `agent_execute` path, execution uses a separately +configured inference provider; its no-provider branch still returns an error +instead of delegating to the MCP host +([ruflo #2356](https://github.com/ruvnet/ruflo/issues/2356)). This is a source +check, not a credentialed runtime probe from Codex. `ak run` avoids that conflation by executing the selected host directly and using Ruflo for tools, memory, routing context, and orchestration assets. The dated upstream risk inventory includes: -- force initialization can overwrite unrelated `.mcp.json` content - ([ruflo #420](https://github.com/ruvnet/ruflo/issues/420)); +- force initialization still writes a generated `.mcp.json` over the existing + file in Ruflo 3.48.0's shipped source, without merging unrelated servers + ([ruflo #420](https://github.com/ruvnet/ruflo/issues/420)); this was not + exercised on a real project; - generated Claude and Codex instructions can diverge ([#2638](https://github.com/ruvnet/ruflo/issues/2638)); - init and plugin installation can duplicate assets or hooks @@ -171,10 +176,13 @@ Current AQE includes a subscription-backed `codex` provider. Agentic-kit accepts `ak host pick --aqe-provider codex`, admits Codex fallback rungs, enables Codex providers referenced by `agentOverrides`, and projects Codex activity routes. -The dated AQE risk inventory includes its -[MCP entrypoint double-spawn](https://github.com/proffesor-for-testing/agentic-qe/issues/528), -[multi-platform initialization behavior](https://github.com/proffesor-for-testing/agentic-qe/issues/532), -[MCP tool correctness gaps](https://github.com/proffesor-for-testing/agentic-qe/issues/535), +AQE 3.14.4 adopted fixes for the +[MCP entrypoint double-spawn](https://github.com/proffesor-for-testing/agentic-qe/issues/528) +and [exclusive platform initialization](https://github.com/proffesor-for-testing/agentic-qe/issues/532) +(`aqe init --no-claude`). Both upstream issues remain open; versions below +3.14.4 retain those gaps. The remaining dated AQE risk inventory includes +[GOAP `maxSteps` and world-state, test-generation quality, and coherence recommendation-text gaps](https://github.com/proffesor-for-testing/agentic-qe/issues/535) +(the 3.14.4 recheck did not exercise `goap_execute`), [RVF recovery loop](https://github.com/proffesor-for-testing/agentic-qe/issues/574), and [local-embedding audit findings](https://github.com/proffesor-for-testing/agentic-qe/issues/615). diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index 571b4e28..55627867 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -2129,10 +2129,10 @@ "kind": "issue", "title": "`agentic-qe mcp` double-spawns the server (stdio:'inherit') and drops stdin → premature shutdown", "dependency": "agentic-qe", - "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, + "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": "3.14.4" } }, "mapping": "mapped", "kitImpact": { "refs": [], "files": ["docs/host-support.md"] }, - "adjustment": "Once a released agentic-qe mcp stops double-spawning the server, drop the host-support.md risk link.", + "adjustment": "The 3.14.4 MCP entry runs in-process; host-support.md records adoption from that version. Keep watching the still-open upstream issue until its closed-completed condition is met.", "status": "watching", "constraintIds": [], "history": [ @@ -2147,10 +2147,10 @@ "kind": "issue", "title": "init: platform installs are additive, not exclusive — always installs Claude Code surface; need per-platform/only mode", "dependency": "agentic-qe", - "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, + "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": "3.14.4" } }, "mapping": "mapped", "kitImpact": { "refs": [], "files": ["docs/host-support.md"] }, - "adjustment": "Once a released agentic-qe init offers an exclusive per-platform mode, drop the host-support.md risk link.", + "adjustment": "The 3.14.4 aqe init --no-claude option supports exclusive platform initialization; host-support.md records adoption from that version. Keep watching the still-open upstream issue until its closed-completed condition is met.", "status": "watching", "constraintIds": [], "history": [ @@ -2168,7 +2168,7 @@ "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, "mapping": "mapped", "kitImpact": { "refs": [], "files": ["docs/host-support.md"] }, - "adjustment": "Once a released agentic-qe fixes the listed MCP tool bugs, drop the host-support.md risk link.", + "adjustment": "Keep the host-support.md risk link limited to the remaining 3.14.4 GOAP maxSteps/world-state, test-generation quality, and coherence recommendation-text gaps. memory_delete and cross-phase stats were fixed; goap_execute was not re-exercised.", "status": "watching", "constraintIds": [], "history": [ From 195fc9708921110a2f7508396a8dbc4922015efa Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:34:06 -0700 Subject: [PATCH 34/54] docs(upstream): register aqe init settings churn report --- .../agentic-dependency-constraints.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index 55627867..32860ccb 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -2749,6 +2749,24 @@ { "date": "2026-09-27", "event": "registered" }, { "date": "2026-09-28", "event": "closed", "note": "completed by PR 766; no release contains it yet" } ] + }, + { + "id": "proffesor-for-testing/agentic-qe#778", + "url": "https://github.com/proffesor-for-testing/agentic-qe/issues/778", + "relation": "filed", + "kind": "issue", + "title": "fix: repeated aqe init changes generated settings on an unchanged project", + "dependency": "agentic-qe", + "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, + "mapping": "mapped", + "kitImpact": { "refs": ["ak setup invokes aqe init and can inherit its repeat-run settings churn"], "files": [] }, + "adjustment": "AQE 3.14.4 repeated init rewrites .claude/settings.json, changes domain and learning defaults on the second run, and creates a backup; CLAUDE.md and AGENTS.md hashes and mtimes stayed unchanged. Keep setup convergence claims bounded until a released fix is verified; 3.14.5 behavior has not been established.", + "status": "watching", + "constraintIds": [], + "history": [ + { "date": "2026-09-29", "event": "filed", "note": "approved exact draft posted; remote title and body match receipt" }, + { "date": "2026-09-29", "event": "registered", "note": "3.14.4 disposable project repro: settings changed on all three runs; guidance hashes and mtimes did not" } + ] } ] } From 0fa85c25259c766c26265bcaea0be8e4b2334c4a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:38:04 -0700 Subject: [PATCH 35/54] docs(host-support): clarify Ruflo source caveat --- docs/host-support.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/host-support.md b/docs/host-support.md index 151f5fed..572d8995 100644 --- a/docs/host-support.md +++ b/docs/host-support.md @@ -118,8 +118,8 @@ Official extension references: [Claude hooks](https://code.claude.com/docs/en/ho | Upgrade convergence | `ak sync` heals managed assets | `ak sync` heals Ruflo/AQE access and retires owned legacy MCP | `ak sync` regenerates the embedded catalogue and repairs exact-receipted plugins/config | | Teardown | Managed blocks and registrations | Receipt-based managed teardown | Value- and hash-receipt teardown; user-owned values survive | -Ruflo MCP access and Ruflo-backed inference are different contracts. In -Ruflo 3.48.0's shipped `agent_execute` path, execution uses a separately +Ruflo MCP access and Ruflo-backed inference are different contracts. +In Ruflo 3.48.0's shipped `agent_execute` path, execution uses a separately configured inference provider; its no-provider branch still returns an error instead of delegating to the MCP host ([ruflo #2356](https://github.com/ruvnet/ruflo/issues/2356)). This is a source From 82aa8b4c6eaeebd0af17b51dc1d45be4d298d176 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:45:32 -0700 Subject: [PATCH 36/54] test(exec): retain a ready descendant after Windows parent exit --- tests/kit/exec.test.mjs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index 8a153465..4ff77bf1 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -31,12 +31,13 @@ for (const [name, options] of [ require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); setInterval(()=>{},1000);`; let pids = []; + let pending; try { const launch = () => run(process.execPath, ['-e', code], { input: options.input, timeout: 5_000, ...(options.inherited ? {} : { signal: controller.signal }), }); - const pending = options.inherited ? withAbortSignal(controller.signal, launch) : launch(); + pending = options.inherited ? withAbortSignal(controller.signal, launch) : launch(); assert.equal(await waitUntil(() => fs.existsSync(pidFile)), true, 'owned children started'); pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); assert.equal(isAlive(pids[1]), true, 'grandchild alive before abort'); @@ -52,6 +53,8 @@ for (const [name, options] of [ try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } } } + await pending; + assert.equal(await waitUntil(() => pids.every((pid) => !isAlive(pid))), true, 'fixture children exited'); fs.rmSync(dir, { recursive: true, force: true }); } }); @@ -96,12 +99,24 @@ for (const stop of ['abort', 'timeout']) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-exited-root-')); const pidFile = path.join(dir, 'pids.json'); const exitFile = path.join(dir, 'parent-exit'); + const readyFile = path.join(dir, 'descendant-ready'); const controller = new AbortController(); + // libuv's Windows Job Object kills non-detached children when their + // parent exits. unref() alone only releases the event-loop reference. + // Deliberately escape that job while retaining the real output handles. + const descendant = `require('node:fs').writeFileSync(${JSON.stringify(readyFile)},'ready'); + setTimeout(()=>{},10000);`; const code = `const {spawn}=require('node:child_process'); - const gc=spawn(process.execPath,['-e','setTimeout(()=>{},5000)'],{stdio:['ignore','inherit','inherit']}); + const fs=require('node:fs'); + const gc=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{ + detached:process.platform==='win32',stdio:['ignore','inherit','inherit']}); gc.unref(); - require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); - process.on('exit',()=>require('node:fs').writeFileSync(${JSON.stringify(exitFile)},'yes'));`; + fs.writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); + const ready=setInterval(()=>{ + if(fs.existsSync(${JSON.stringify(readyFile)})) clearInterval(ready); + },10); + setTimeout(()=>process.exit(2),4000).unref(); + process.on('exit',()=>fs.writeFileSync(${JSON.stringify(exitFile)},'yes'));`; let pids = []; let pending; try { @@ -111,6 +126,7 @@ for (const stop of ['abort', 'timeout']) { }); assert.equal(await waitUntil(() => fs.existsSync(pidFile)), true); pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + assert.equal(await waitUntil(() => fs.existsSync(readyFile)), true, 'descendant initialized'); assert.equal(await waitUntil(() => fs.existsSync(exitFile)), true, 'direct child exited'); await new Promise((resolve) => setTimeout(resolve, 100)); assert.equal(isAlive(pids[1]), true, 'descendant still owns the output pipe'); @@ -129,6 +145,7 @@ for (const stop of ['abort', 'timeout']) { } } await pending; + assert.equal(await waitUntil(() => pids.every((pid) => !isAlive(pid))), true, 'fixture children exited'); fs.rmSync(dir, { recursive: true, force: true }); } }); From cca06dc1f43a0a21c3ad41ada805c1ec55d4a4ba Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:45:38 -0700 Subject: [PATCH 37/54] test(exec): launch a script through the native PowerShell fixture --- tests/kit/exec.test.mjs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index 4ff77bf1..d225a077 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -189,17 +189,25 @@ test('Windows abort reaps the Node child behind a PowerShell shim and its grandc const controller = new AbortController(); const quotedNode = process.execPath.replaceAll("'", "''"); let pids = []; + let pending; + let outcome; try { fs.writeFileSync(path.join(dir, 'codex.cmd'), '@echo off\r\n'); - fs.writeFileSync(path.join(dir, 'codex.ps1'), `& '${quotedNode}' -e $args[0]\n`); + fs.writeFileSync(path.join(dir, 'codex.ps1'), `& '${quotedNode}' $args[0]\nexit $LASTEXITCODE\n`); const code = `const {spawn}=require('node:child_process'); const gc=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); setInterval(()=>{},1000);`; - const pending = run('codex', [code], { + // npm shims forward a script filename; PowerShell 5.1 reserializes + // native arguments, so multiline node -e source is not that interface. + const script = path.join(dir, 'fixture.cjs'); + fs.writeFileSync(script, code); + pending = run('codex', [script], { env: { PATH: dir, PATHEXT: '.CMD' }, signal: controller.signal, timeout: 10_000, }); - assert.equal(await waitUntil(() => fs.existsSync(pidFile)), true, 'PowerShell launched Node'); + pending.then((result) => { outcome = result; }); + assert.equal(await waitUntil(() => fs.existsSync(pidFile) || outcome), true, 'PowerShell launch settled or ready'); + assert.equal(fs.existsSync(pidFile), true, `PowerShell launched Node: ${JSON.stringify(outcome)}`); pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); assert.equal(isAlive(pids[1]), true); controller.abort(); @@ -214,6 +222,8 @@ test('Windows abort reaps the Node child behind a PowerShell shim and its grandc try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } } } + await pending; + assert.equal(await waitUntil(() => pids.every((pid) => !isAlive(pid))), true, 'fixture children exited'); fs.rmSync(dir, { recursive: true, force: true }); } }); From 2146b24527dad84e4278a17f0f994a93310b8fc3 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:46:06 -0700 Subject: [PATCH 38/54] test(ruflo): diagnose native Windows MCP transport boundaries --- tests/live/ruflo-windows-transport.test.mjs | 128 ++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/live/ruflo-windows-transport.test.mjs diff --git a/tests/live/ruflo-windows-transport.test.mjs b/tests/live/ruflo-windows-transport.test.mjs new file mode 100644 index 00000000..897a670c --- /dev/null +++ b/tests/live/ruflo-windows-transport.test.mjs @@ -0,0 +1,128 @@ +// Opt-in diagnosis only. A backend response never substitutes for public routing +// proof. Run alongside (not instead of) ruflo-memory-routing.test.mjs. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { spawnEnv, envValue } from '../kit/helpers/home-sandbox.mjs'; +import { resolveShim, run } from '../../src/lib/exec.mjs'; +import { acquireRunRootHold, releaseRunRootHold } from '../../scripts/run-roots.mjs'; + +const sha = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function initialize(invocation, cwd, env, endInput) { + const child = spawn(invocation.command, invocation.args, { + cwd, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'], + }); + let closed = false; + let error; + let stdout = ''; + let stderr = ''; + let overflow = false; + let bytes = 0; + const capture = (current, chunk) => { + bytes += chunk.length; + const value = current + chunk.toString('utf8'); + if (bytes > 65536) overflow = true; + return value.slice(0, 16384); + }; + child.stdout.on('data', (chunk) => { stdout = capture(stdout, chunk); }); + child.stderr.on('data', (chunk) => { stderr = capture(stderr, chunk); }); + child.on('error', (e) => { error = e.message; }); + child.stdin.on('error', (e) => { error = e.message; }); + const done = new Promise((resolve) => child.once('close', (code, signal) => { + closed = true; resolve({ code, signal }); + })); + const response = () => stdout.split('\n').some((line) => { + try { const r = JSON.parse(line); return r.id === 1 && Boolean(r.result?.protocolVersion); } + catch { return false; } + }); + const started = Date.now(); + try { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { + protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'ak-native-diagnostic', version: '1' }, + } })}\n`); + if (endInput) child.stdin.end(); + while (!closed && !response() && !overflow && Date.now() - started < 15000) await delay(50); + return { initialized: response(), endInput, elapsedMs: Date.now() - started, + exitCodeBeforeCleanup: child.exitCode, signalBeforeCleanup: child.signalCode, + overflow, error, stdout, stderr }; + } finally { + // Never target a PID after its owned process has exited. Failure to observe + // pipe closure retains the suite hold and disposable root for inspection. + if (!closed && child.exitCode === null && child.signalCode === null && child.pid) { + const killed = await run('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { + env, timeout: 3000, maxBuffer: 65536, + }); + assert.equal(killed.code, 0, `diagnostic tree cleanup: ${killed.stderr}`); + } + await Promise.race([done, delay(5000)]); + assert.equal(closed, true, 'diagnostic process pipes closed before removing root'); + } +} + +test('diagnose installed Ruflo Windows public shim versus installed bin transport', { + skip: process.env.AK_RUFLO_WINDOWS_DIAGNOSTIC !== '1', timeout: 90000, +}, async () => { + assert.equal(process.platform, 'win32', 'diagnostic requires native Windows'); + const packageRoot = process.env.AK_RUFLO_PACKAGE_ROOT; + assert.ok(packageRoot && path.isAbsolute(packageRoot), 'explicit installed Ruflo package root required'); + const packageFile = path.join(packageRoot, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8')); + assert.equal(pkg.name, 'ruflo'); + const bin = path.resolve(packageRoot, typeof pkg.bin === 'string' ? pkg.bin : pkg.bin.ruflo); + assert.ok(bin.startsWith(`${fs.realpathSync(packageRoot)}${path.sep}`), 'bin belongs to installed package'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-ruflo-win-diagnostic-')); + const hold = acquireRunRootHold(); + let clean = false; + try { + const bootstrap = {}; + for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT']) { + const value = envValue(process.env, key); + if (value) bootstrap[key] = value; + } + const env = spawnEnv(root, { + CLAUDE_FLOW_DB_PATH: path.join(root, '.swarm', 'memory.db'), + CLAUDE_FLOW_MEMORY_PATH: path.join(root, '.swarm'), RUFLO_DAEMON_AUTOSTART: '0', + }, { env: bootstrap }); + fs.writeFileSync(path.join(root, 'claude-flow.config.json'), JSON.stringify({ daemon: { autostart: false } })); + const invocation = resolveShim('ruflo', ['mcp', 'start'], { env }); + assert.equal(invocation.resolved, true, 'installed public shim must resolve'); + const scriptIndex = invocation.args.indexOf('-File'); + assert.ok(scriptIndex >= 0, 'receipt requires the native PowerShell transport'); + const shim = invocation.args[scriptIndex + 1]; + const version = await run('ruflo', ['--version'], { cwd: root, env, timeout: 10000 }); + console.log(JSON.stringify({ diagnostic: 'inputs', node: process.version, uv: process.versions.uv, + platform: process.platform, packageVersion: pkg.version, version, + packageSha: sha(packageFile), bin, binSha: sha(bin), invocation, + shimSha: sha(shim), shimSource: fs.readFileSync(shim, 'utf8').slice(0, 8192), + sourceSha: sha(new URL(import.meta.url)) })); + // Capture the original fixture's native argument forwarding without + // creating any descendants: this isolates quoting from tree ownership. + const legacyShim = path.join(root, 'legacy-fixture.ps1'); + const marker = path.join(root, 'legacy-marker'); + fs.writeFileSync(legacyShim, `& '${process.execPath.replaceAll("'", "''")}' -e $args[0]\nexit $LASTEXITCODE\n`); + const legacyCode = `const {spawn}=require('node:child_process'); + require('node:fs').writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid]));`; + const legacy = await run(invocation.command, [ + ...invocation.args.slice(0, scriptIndex + 1), legacyShim, legacyCode, + ], { cwd: root, env, timeout: 5000, maxBuffer: 65536 }); + console.log(JSON.stringify({ diagnostic: 'legacy-multiline-node-e', + marker: fs.existsSync(marker), ...legacy })); + for (const [transport, spec, eof] of [ + ['public-open-stdin', invocation, false], + ['public-eof', invocation, true], + ['installed-bin-open-stdin', { command: process.execPath, args: [bin, 'mcp', 'start'] }, false], + ]) { + console.log(JSON.stringify({ diagnostic: transport, ...await initialize(spec, root, env, eof) })); + } + clean = true; + } finally { + if (clean) { releaseRunRootHold(hold); fs.rmSync(root, { recursive: true, force: true }); } + else console.error(`diagnostic root retained: ${root}`); + } +}); From d98f9c610769942002cb2fd09c926e0417f28310 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:55:39 -0700 Subject: [PATCH 39/54] test(ruflo): isolate diagnostic launches and retain uncertain cleanup --- .../ruflo-windows-diagnostic-process.test.mjs | 132 ++++++++++++++++++ .../live/ruflo-windows-diagnostic-process.mjs | 101 ++++++++++++++ tests/live/ruflo-windows-transport.test.mjs | 90 ++++-------- 3 files changed, 259 insertions(+), 64 deletions(-) create mode 100644 tests/kit/ruflo-windows-diagnostic-process.test.mjs create mode 100644 tests/live/ruflo-windows-diagnostic-process.mjs diff --git a/tests/kit/ruflo-windows-diagnostic-process.test.mjs b/tests/kit/ruflo-windows-diagnostic-process.test.mjs new file mode 100644 index 00000000..07d85cbe --- /dev/null +++ b/tests/kit/ruflo-windows-diagnostic-process.test.mjs @@ -0,0 +1,132 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { spawn } from 'node:child_process'; +import { createDiagnosticScope } from '../live/ruflo-windows-diagnostic-process.mjs'; + +function fakeChild({ closes = true, code = 0 } = {}) { + const child = new EventEmitter(); + Object.assign(child, { pid: 123, exitCode: null, signalCode: null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + unreferenced: false, killed: false }); + child.unref = () => { child.unreferenced = true; }; + child.kill = () => { + child.killed = true; + if (closes) queueMicrotask(() => { child.exitCode = code; child.emit('close', code, null); }); + }; + return child; +} + +function scopeFor(spawnFn) { + let released = false; + const scope = createDiagnosticScope({ spawnFn, platform: 'win32', + killTimeoutMs: 20, closeTimeoutMs: 20, + acquireHold: () => ({}), releaseHold: () => { released = true; } }); + return { scope, released: () => released }; +} + +test('every diagnostic role passes the exact environment, excluding parent-only credentials', async () => { + process.env.AK_DIAGNOSTIC_PARENT_ONLY = 'synthetic-do-not-inherit'; + const env = { PATH: 'fixture-only' }; + const seen = []; + let active; + const { scope } = scopeFor((command, _args, options) => { + seen.push({ command, env: options.env }); + const child = fakeChild(); + if (command === 'taskkill.exe') queueMicrotask(() => { active.kill(); child.kill(); }); + else active = child; + return child; + }); + try { + for (const command of ['version', 'legacy', 'initialize']) { + const r = await scope.launch({ command, args: [] }, { env, timeoutMs: 10 }); + assert.equal(r.cleanupComplete, true); + } + assert.deepEqual(seen.map((x) => x.command), ['version', 'taskkill.exe', 'legacy', 'taskkill.exe', 'initialize', 'taskkill.exe']); + for (const entry of seen) { + assert.strictEqual(entry.env, env); + assert.equal(entry.env.AK_DIAGNOSTIC_PARENT_ONLY, undefined); + } + assert.equal(scope.release(), true); + } finally { delete process.env.AK_DIAGNOSTIC_PARENT_ONLY; } +}); + +for (const failure of ['nonzero', 'spawn-error', 'stall']) { + test(`taskkill ${failure} always disposes handles and retains uncertainty`, async () => { + const children = []; + const { scope, released } = scopeFor((command) => { + const child = fakeChild({ closes: false }); children.push(child); + if (command === 'taskkill.exe' && failure !== 'stall') queueMicrotask(() => { + if (failure === 'spawn-error') child.emit('error', new Error('ENOENT')); + child.exitCode = 1; child.emit('close', 1, null); + }); + return child; + }); + const started = Date.now(); + const r = await scope.launch({ command: 'initialize', args: [] }, { env: {}, timeoutMs: 10 }); + assert.equal(r.cleanupComplete, false); + assert.ok(Date.now() - started < 1000); + assert.equal(children[0].killed, true, 'direct-child fallback attempted'); + assert.equal(children[0].unreferenced, true); + assert.equal(children[0].stdout.destroyed, true); + assert.equal(children[0].stderr.destroyed, true); + assert.equal(children[0].stdin.destroyed, true); + assert.equal(scope.release(), false); + assert.equal(released(), false); + if (failure === 'stall') assert.equal(children[1].unreferenced, true); + }); +} + +for (const command of ['version', 'legacy']) { + test(`${command} cleanup failure cannot be cleared by a later successful launch`, async () => { + const { scope, released } = scopeFor((cmd) => { + const child = fakeChild(); + if (cmd === 'later' || cmd === 'taskkill.exe') queueMicrotask(() => { + child.exitCode = cmd === 'taskkill.exe' ? 1 : 0; child.emit('close', child.exitCode, null); + }); + return child; + }); + assert.equal((await scope.launch({ command, args: [] }, { env: {}, timeoutMs: 10 })).cleanupComplete, false); + assert.equal((await scope.launch({ command: 'later', args: [] }, { env: {}, timeoutMs: 10 })).cleanupComplete, true); + assert.equal(scope.release(), false); + assert.equal(released(), false); + }); +} + +test('real version, legacy, initialize and taskkill children cannot see a parent-only sentinel', async () => { + const key = 'AK_DIAGNOSTIC_PARENT_ONLY'; + const previous = process.env[key]; + process.env[key] = 'synthetic-do-not-inherit'; + const env = { AK_DIAGNOSTIC_ALLOWED: 'yes' }; + const observed = []; + const scope = createDiagnosticScope({ + platform: 'win32', acquireHold: () => ({}), releaseHold: () => {}, + killTimeoutMs: 1000, closeTimeoutMs: 1000, + spawnFn: (command, args, options) => { + const payload = 'process.stdout.write(JSON.stringify({allowed:process.env.AK_DIAGNOSTIC_ALLOWED,sentinel:process.env.AK_DIAGNOSTIC_PARENT_ONLY}));'; + const code = command === 'taskkill.exe' + ? `${payload}process.kill(${Number(args[1])},'SIGKILL');` + : `${payload}${command === 'initialize' ? 'setInterval(()=>{},1000);' : ''}`; + const child = spawn(process.execPath, ['-e', code], { ...options, env: options.env }); + let output = ''; + child.stdout.on('data', (chunk) => { output += chunk; }); + child.on('close', () => observed.push({ command, ...JSON.parse(output) })); + return child; + }, + }); + try { + for (const command of ['version', 'legacy', 'initialize']) { + const result = await scope.launch({ command, args: [] }, { env, timeoutMs: 500 }); + assert.equal(result.cleanupComplete, true); + } + assert.deepEqual(observed.map((x) => x.command).sort(), ['initialize', 'legacy', 'taskkill.exe', 'version']); + for (const result of observed) { + assert.equal(result.allowed, 'yes'); + assert.equal(result.sentinel, undefined); + } + assert.equal(scope.release(), true); + } finally { + if (previous === undefined) delete process.env[key]; else process.env[key] = previous; + } +}); diff --git a/tests/live/ruflo-windows-diagnostic-process.mjs b/tests/live/ruflo-windows-diagnostic-process.mjs new file mode 100644 index 00000000..3e38df05 --- /dev/null +++ b/tests/live/ruflo-windows-diagnostic-process.mjs @@ -0,0 +1,101 @@ +// Test-only exact-environment launcher. Every attempt contributes independently +// to the scope's cleanup receipt; uncertainty is sticky until handoff. +import { spawn } from 'node:child_process'; +import { acquireRunRootHold, releaseRunRootHold } from '../../scripts/run-roots.mjs'; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function createDiagnosticScope({ spawnFn = spawn, platform = process.platform, + killTimeoutMs = 3000, closeTimeoutMs = 5000, + acquireHold = acquireRunRootHold, releaseHold = releaseRunRootHold } = {}) { + const hold = acquireHold(); + const receipts = []; + let released = false; + + async function launch(spec, { cwd, env, timeoutMs, input = '', endInput = true, + until = () => false, tree = true }) { + if (released) throw Error('diagnostic scope already released'); + if (!env || typeof env !== 'object') throw Error('explicit diagnostic environment required'); + const result = { code: null, signal: null, stdout: '', stderr: '', error: null, + timedOut: false, overflow: false, matched: false, cleanupComplete: false, elapsedMs: 0 }; + receipts.push(result); + let child; + let closed = false; + let bytes = 0; + const started = Date.now(); + const live = () => child?.pid && child.exitCode === null && child.signalCode === null; + const capture = (key, chunk) => { + bytes += chunk.length; + result.overflow ||= bytes > 65536; + result[key] = (result[key] + chunk.toString('utf8')).slice(0, 16384); + }; + const waitClose = async () => { + const deadline = Date.now() + closeTimeoutMs; + while (!closed && Date.now() < deadline) await delay(10); + }; + try { + child = spawnFn(spec.command, spec.args, { + cwd, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'], + detached: tree && platform !== 'win32', + }); + child.on('error', (e) => { result.error = e.message; }); + child.once('close', (code, signal) => { + closed = true; result.code = code; result.signal = signal; + }); + child.stdout.on('data', (chunk) => capture('stdout', chunk)); + child.stderr.on('data', (chunk) => capture('stderr', chunk)); + child.stdin.on('error', (e) => { result.error = e.message; }); + child.stdin.write(input); + if (endInput) child.stdin.end(); + while (!closed && !result.error && !result.overflow && Date.now() - started < timeoutMs) { + result.matched = until(result.stdout); + if (result.matched) break; + await delay(10); + } + result.matched ||= until(result.stdout); + result.timedOut = !closed && !result.matched && !result.error && !result.overflow; + } catch (error) { + result.error = error.message; + } finally { + // Never throw before cleanup. Failed tree termination is uncertainty even + // if the direct-child fallback subsequently closes its own pipes. + let treeStopped = closed || !child; + if (child && !closed) { + if (live()) { + if (tree && platform === 'win32') { + const killed = await launch({ command: 'taskkill.exe', args: ['/PID', String(child.pid), '/T', '/F'] }, + { cwd, env, timeoutMs: killTimeoutMs, tree: false }); + treeStopped = killed.cleanupComplete && killed.code === 0 && !killed.timedOut && !killed.error; + } else { + try { + if (tree) process.kill(-child.pid, 'SIGKILL'); + else child.kill('SIGKILL'); + treeStopped = true; + } catch { treeStopped = false; } + } + if (!treeStopped && live()) { + try { child.kill('SIGKILL'); } catch { /* preserve uncertainty */ } + } + } + await waitClose(); + } + result.cleanupComplete = treeStopped && (closed || !child); + if (child && !closed) { + // Close local handles and release event-loop references without claiming + // the process tree exited. The scope hold remains active. + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + child.unref(); + } + result.elapsedMs = Date.now() - started; + } + return result; + } + + function release() { + if (receipts.some((receipt) => !receipt.cleanupComplete)) return false; + if (!released) releaseHold(hold); + released = true; + return true; + } + return { launch, release, receipts }; +} diff --git a/tests/live/ruflo-windows-transport.test.mjs b/tests/live/ruflo-windows-transport.test.mjs index 897a670c..20a5a14f 100644 --- a/tests/live/ruflo-windows-transport.test.mjs +++ b/tests/live/ruflo-windows-transport.test.mjs @@ -6,67 +6,21 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; -import { spawn } from 'node:child_process'; import { spawnEnv, envValue } from '../kit/helpers/home-sandbox.mjs'; -import { resolveShim, run } from '../../src/lib/exec.mjs'; -import { acquireRunRootHold, releaseRunRootHold } from '../../scripts/run-roots.mjs'; +import { resolveShim } from '../../src/lib/exec.mjs'; +import { createDiagnosticScope } from './ruflo-windows-diagnostic-process.mjs'; const sha = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function initialize(invocation, cwd, env, endInput) { - const child = spawn(invocation.command, invocation.args, { - cwd, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'], - }); - let closed = false; - let error; - let stdout = ''; - let stderr = ''; - let overflow = false; - let bytes = 0; - const capture = (current, chunk) => { - bytes += chunk.length; - const value = current + chunk.toString('utf8'); - if (bytes > 65536) overflow = true; - return value.slice(0, 16384); - }; - child.stdout.on('data', (chunk) => { stdout = capture(stdout, chunk); }); - child.stderr.on('data', (chunk) => { stderr = capture(stderr, chunk); }); - child.on('error', (e) => { error = e.message; }); - child.stdin.on('error', (e) => { error = e.message; }); - const done = new Promise((resolve) => child.once('close', (code, signal) => { - closed = true; resolve({ code, signal }); - })); - const response = () => stdout.split('\n').some((line) => { - try { const r = JSON.parse(line); return r.id === 1 && Boolean(r.result?.protocolVersion); } - catch { return false; } - }); - const started = Date.now(); - try { - child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { - protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'ak-native-diagnostic', version: '1' }, - } })}\n`); - if (endInput) child.stdin.end(); - while (!closed && !response() && !overflow && Date.now() - started < 15000) await delay(50); - return { initialized: response(), endInput, elapsedMs: Date.now() - started, - exitCodeBeforeCleanup: child.exitCode, signalBeforeCleanup: child.signalCode, - overflow, error, stdout, stderr }; - } finally { - // Never target a PID after its owned process has exited. Failure to observe - // pipe closure retains the suite hold and disposable root for inspection. - if (!closed && child.exitCode === null && child.signalCode === null && child.pid) { - const killed = await run('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { - env, timeout: 3000, maxBuffer: 65536, - }); - assert.equal(killed.code, 0, `diagnostic tree cleanup: ${killed.stderr}`); - } - await Promise.race([done, delay(5000)]); - assert.equal(closed, true, 'diagnostic process pipes closed before removing root'); - } -} +const initialized = (stdout) => stdout.split('\n').some((line) => { + try { const r = JSON.parse(line); return r.id === 1 && Boolean(r.result?.protocolVersion); } + catch { return false; } +}); +const input = `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { + protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'ak-native-diagnostic', version: '1' }, +} })}\n`; test('diagnose installed Ruflo Windows public shim versus installed bin transport', { - skip: process.env.AK_RUFLO_WINDOWS_DIAGNOSTIC !== '1', timeout: 90000, + skip: process.env.AK_RUFLO_WINDOWS_DIAGNOSTIC !== '1', timeout: 120000, }, async () => { assert.equal(process.platform, 'win32', 'diagnostic requires native Windows'); const packageRoot = process.env.AK_RUFLO_PACKAGE_ROOT; @@ -77,7 +31,7 @@ test('diagnose installed Ruflo Windows public shim versus installed bin transpor const bin = path.resolve(packageRoot, typeof pkg.bin === 'string' ? pkg.bin : pkg.bin.ruflo); assert.ok(bin.startsWith(`${fs.realpathSync(packageRoot)}${path.sep}`), 'bin belongs to installed package'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-ruflo-win-diagnostic-')); - const hold = acquireRunRootHold(); + const scope = createDiagnosticScope(); let clean = false; try { const bootstrap = {}; @@ -95,12 +49,15 @@ test('diagnose installed Ruflo Windows public shim versus installed bin transpor const scriptIndex = invocation.args.indexOf('-File'); assert.ok(scriptIndex >= 0, 'receipt requires the native PowerShell transport'); const shim = invocation.args[scriptIndex + 1]; - const version = await run('ruflo', ['--version'], { cwd: root, env, timeout: 10000 }); + const version = await scope.launch(resolveShim('ruflo', ['--version'], { env }), + { cwd: root, env, timeoutMs: 10000 }); console.log(JSON.stringify({ diagnostic: 'inputs', node: process.version, uv: process.versions.uv, platform: process.platform, packageVersion: pkg.version, version, packageSha: sha(packageFile), bin, binSha: sha(bin), invocation, shimSha: sha(shim), shimSource: fs.readFileSync(shim, 'utf8').slice(0, 8192), - sourceSha: sha(new URL(import.meta.url)) })); + sourceSha: sha(new URL(import.meta.url)), + launcherSha: sha(new URL('./ruflo-windows-diagnostic-process.mjs', import.meta.url)) })); + assert.equal(version.cleanupComplete, true, 'version launch cleanup incomplete'); // Capture the original fixture's native argument forwarding without // creating any descendants: this isolates quoting from tree ownership. const legacyShim = path.join(root, 'legacy-fixture.ps1'); @@ -108,21 +65,26 @@ test('diagnose installed Ruflo Windows public shim versus installed bin transpor fs.writeFileSync(legacyShim, `& '${process.execPath.replaceAll("'", "''")}' -e $args[0]\nexit $LASTEXITCODE\n`); const legacyCode = `const {spawn}=require('node:child_process'); require('node:fs').writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid]));`; - const legacy = await run(invocation.command, [ + const legacy = await scope.launch({ command: invocation.command, args: [ ...invocation.args.slice(0, scriptIndex + 1), legacyShim, legacyCode, - ], { cwd: root, env, timeout: 5000, maxBuffer: 65536 }); + ] }, { cwd: root, env, timeoutMs: 5000 }); console.log(JSON.stringify({ diagnostic: 'legacy-multiline-node-e', marker: fs.existsSync(marker), ...legacy })); + assert.equal(legacy.cleanupComplete, true, 'legacy launch cleanup incomplete'); for (const [transport, spec, eof] of [ ['public-open-stdin', invocation, false], ['public-eof', invocation, true], ['installed-bin-open-stdin', { command: process.execPath, args: [bin, 'mcp', 'start'] }, false], ]) { - console.log(JSON.stringify({ diagnostic: transport, ...await initialize(spec, root, env, eof) })); + const observation = await scope.launch(spec, { cwd: root, env, input, endInput: eof, + timeoutMs: 15000, until: initialized }); + console.log(JSON.stringify({ diagnostic: transport, initialized: observation.matched, endInput: eof, ...observation })); + assert.equal(observation.cleanupComplete, true, `${transport} cleanup incomplete`); } - clean = true; + clean = scope.release(); + assert.equal(clean, true, 'every diagnostic launch must prove cleanup before root removal'); } finally { - if (clean) { releaseRunRootHold(hold); fs.rmSync(root, { recursive: true, force: true }); } + if (clean) fs.rmSync(root, { recursive: true, force: true }); else console.error(`diagnostic root retained: ${root}`); } }); From 07e20bd88f1d6112b1515d557619cade2eb0e19c Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 04:59:25 -0700 Subject: [PATCH 40/54] test(ci): capture native Windows MCP transport diagnostics --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3eba709..4cf09a9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,6 +205,15 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $text = Get-Content -Raw (Join-Path $env:RUNNER_TEMP 'c1-aqe-live-lock.log') if ($text -notmatch '"liveLock":true' -or $text -notmatch '"lockHeld":true') { throw 'AQE proof lacked the native contention JSON receipt' } + - name: Diagnose Windows Ruflo transport + if: runner.os == 'Windows' + shell: pwsh + env: + AK_RUFLO_WINDOWS_DIAGNOSTIC: '1' + run: | + $env:AK_RUFLO_PACKAGE_ROOT = Join-Path $env:npm_config_prefix 'node_modules/ruflo' + node scripts/run-tests.mjs focus tests/live/ruflo-windows-transport.test.mjs 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-windows-transport.log') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Real Ruflo memory routing (Linux and Windows) if: matrix.memory shell: pwsh From 84f2464d190c5623364fa3a3434a8fb4a90d5d74 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:12:00 -0700 Subject: [PATCH 41/54] fix(aqe): retire FsyncFailed live-lock exception --- docs/adr/0055-aqe-embedding-lifecycle.md | 1 + docs/adr/0062-aqe-project-store-integrity.md | 1 + docs/plans/2026-09-28-follow-ups-v2.md | 4 +- docs/troubleshooting.md | 9 +++-- src/lib/aqe-readiness.mjs | 22 +---------- .../agentic-dependency-constraints.json | 16 ++++---- tests/kit/aqe-verification.test.mjs | 39 ++++++++++--------- tests/kit/upstream-watch-registry.test.mjs | 13 +++++-- 8 files changed, 49 insertions(+), 56 deletions(-) diff --git a/docs/adr/0055-aqe-embedding-lifecycle.md b/docs/adr/0055-aqe-embedding-lifecycle.md index 8dc742f1..2475c1df 100644 --- a/docs/adr/0055-aqe-embedding-lifecycle.md +++ b/docs/adr/0055-aqe-embedding-lifecycle.md @@ -14,6 +14,7 @@ - **Updated:** 2026-09-27 — the recognizer accepts every plain npx spelling of AQE's server (optional `-y`/`--yes`; unversioned, `@latest` or an exact version), audit item 5 choice A - **Updated:** 2026-09-27 — a passing embedding check reads "embedder verified"; status, `ak x verify aqe` and setup say AQE's pattern index binding stays unverified (agentic-qe#754) and corpus compatibility stays separate - **Updated:** 2026-09-27 — the busy rule's removal condition is agentic-qe#574 fixed in a released agentic-qe that is the kit floor; agentic-qe#719 (carried by 3.14.4) is only a partial fix +- **Updated:** 2026-09-29 — the later approved N-1 criterion supersedes that floor condition: released AQE 3.14.4 passed native macOS and Linux live-owner probes without `FsyncFailed`, so ak removed the exact 3.14.3 `FsyncFailed`-as-busy exception. Any `FsyncFailed`/`0x0303` now fails, including the old sequence. Ordinary `LockHeld` SQLite fallback remains busy with owner health and RVF integrity unknown. AQE 3.14.4 is the verified baseline for this decision, not a universal minimum; native Windows AQE conformance was not run - **Updated:** 2026-09-27 — beside these projections, `ak sync` and `ak setup` pin AQE to the project root (absolute `AQE_PROJECT_ROOT`, `AQE_MEMORY_PATH`, `AQE_STORAGE_PATH`) in `.claude/settings.local.json`, the recognized `.mcp.json` entry and both AQE tables of the project `.codex/config.toml`, under receipts from the same owned-env engine; a file git tracks is not pinned, and AQE's own relative `AQE_MEMORY_PATH` is taken back even after an AQE re-init. Stray AQE stores are merged and archived by `ak x aqe-store merge`. See [ADR-0062](0062-aqe-project-store-integrity.md) (remediation Branch 5, B5-D1 to B5-D5, B5-M5) - **Updated:** 2026-09-28 — live-check evidence storage relocated from `/agentic-kit/live-checks/.json` to the shared `/agentic-kit/evidence/live-check/.json` layout; this ADR's own live-check BEHAVIOR (TTL, statuses, remembered-check display) is unchanged, only where the evidence file lives. `ak x aqe-embedding verify` (distinct from `ak x verify`'s `aqe-embedding` row) does not persist evidence either way — it is a one-shot, unpersisted synthetic-backend proof. See [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md) (remediation program, branch 6a task 2) - **Updated:** 2026-09-28 — `ak x verify` is retired; its live checks (this ADR's own quick diff --git a/docs/adr/0062-aqe-project-store-integrity.md b/docs/adr/0062-aqe-project-store-integrity.md index 2790861d..dcd5b6d5 100644 --- a/docs/adr/0062-aqe-project-store-integrity.md +++ b/docs/adr/0062-aqe-project-store-integrity.md @@ -6,6 +6,7 @@ re-init value taken back, clean release; holder checks that time out refuse; nested repositories are not strays; stores fingerprinted at copy time; root checked before backup; applying receipt; starter patterns the root holds keep their usage +- **Updated:** 2026-09-29 — released AQE 3.14.4 passed native macOS and Linux live-owner conformance with `LockHeld` and no `FsyncFailed`; ak retired only the old exact `FsyncFailed`-as-busy exception in AQE startup classification. This is a verified baseline for that rule, not a universal AQE minimum. The 3.14.4 minimum below applies only to store merge. Native Windows AQE conformance remains unverified - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md) (project memory status and stray stores), [ADR-0055](0055-aqe-embedding-lifecycle.md) (the AQE embedding projections this diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index 95ecfa99..492d40b4 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -2,7 +2,7 @@ ## Status -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 and B3 are implemented in the isolated `fix/follow-ups-v2-rest` branch, pending integration; B4 is implemented there, pending independent review. B5's three units are implemented there, pending independent review. C1 has a portable, opt-in live-lock probe that passed on macOS against AQE 3.14.4; its review fixes add abort-aware child closure, a sufficient overall timeout, and corrected isolated CI proposal paths. Linux CI proof and the busy-rule decision remain pending. Other rows remain unimplemented. The controller reviews and assigns later rows. One unit commit per row. +**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 and B3 are implemented in the isolated `fix/follow-ups-v2-rest` branch, pending integration; B4 is implemented there, pending independent review. B5's three units are implemented there, pending independent review. C1's released AQE 3.14.4 live-lock probe passed on native macOS and Linux; its exact contention exception is retired in the isolated branch, pending review. Native Windows AQE conformance remains unverified, and the separate Ruflo Windows MCP result remains open. Other rows remain unimplemented. The controller reviews and assigns later rows. One unit commit per row. The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. @@ -25,7 +25,7 @@ The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-u | B11 | `src/lib/live-checks.mjs` | `tests/kit/live-checks.test.mjs`; skipped deja-vu check says skipped and check-created temp folders are cleaned | | B12 | `src/commands/setup.mjs`; `src/lib/memory-probe-cleanup.mjs` unchanged | **Fixed:** `tests/kit/setup-memory-probe.test.mjs`; Ruflo 3.48.0 seeded reproduction created an unused native side file, and a disposable candidate run confirmed a private mirror leaves no canonical side file or probe row | | B13 | `src/commands/sync.mjs`; `src/lib/aqe-project-pin.mjs` | `tests/kit/sync-command.test.mjs`, `aqe-project-pin.test.mjs`; only if program §2 step 2 shows sync omitted the AQE pin | -| C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | Probe prerequisite: `tests/live/aqe-live-lock-conformance.test.mjs` passed on macOS with installed AQE 3.14.4; ignored temporary CI job proposal and report in `.superpowers/sdd/2026-09-28-follow-ups-v2/`. Linux proof, existing `tests/live/ruflo-memory-routing.test.mjs` on Linux/Windows, busy-rule decision, and #240 action remain pending. Remove temporary job before merge. | +| C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | **Implemented, pending review:** native macOS and Linux live-owner probes on released AQE 3.14.4 omitted `FsyncFailed`; the exact exception is retired. Ordinary `LockHeld` remains busy, while any `FsyncFailed` fails. The temporary CI job remains until the separate Windows Ruflo MCP investigation finishes; remove it before V4 merge. #240 closure waits for the final main PR. No native Windows AQE conformance is claimed. | | C2 | `docs/host-support.md`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/ruflo-support-window.test.mjs` plus link check; verify AQE 3.14.4 #528/#532/#535 and Ruflo #2356/#420 first | | C3 | `.github/workflows/nightly.yml`; vidaunited's `trace-ort.mjs` hook (obtain and verify its exact script path before adding) | `tests/kit/upstream-watch-workflow.test.mjs` plus macOS artifact receipt; exact upstream #2885 post text requires user approval | | C4 | `scripts/upstream-watch/classify.mjs`, `fetch.mjs`, `ledger.mjs`, `dispatch.mjs`, `render.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/upstream-watch-script.test.mjs`, `upstream-watch-record.test.mjs`, `upstream-watch-dispatch.test.mjs`, `upstream-watch-registry.test.mjs`; use ignored `reports/n5-253-deferred-minors.md` §2 for M7/M8/minors 1–12; M10 declined | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5fdcba7b..58d06d53 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -157,9 +157,12 @@ ak status --refresh=live --only learning,harvest,aqe,memory-routes,security,deja If `ak status --refresh=live --only aqe` warns that RVF is held by another live process, another AQE process (usually the AQE MCP server in an open Claude Code session) owns the store. -That is contention, not a storage failure, even though agentic-qe 3.14.3 also prints -`FsyncFailed` in this case ([#240](https://github.com/pacphi/agentic-kit/issues/240)). -A `FsyncFailed` without the live-owner lines still fails verification. +Released agentic-qe 3.14.4 passed native macOS and Linux live-owner probes with `LockHeld` +and no `FsyncFailed` ([#240](https://github.com/pacphi/agentic-kit/issues/240)). +The old 3.14.3 sequence also printed `FsyncFailed`; ak now treats any `FsyncFailed` or +`0x0303` as an RVF failure, even alongside live-owner lines. An ordinary live lock stays +busy with SQLite fallback observed; owner health and RVF integrity remain unverified. +Native Windows AQE live-lock conformance has not been run. ## Known upstream gaps (not fixable by sync) diff --git a/src/lib/aqe-readiness.mjs b/src/lib/aqe-readiness.mjs index 6c8c204f..91f819c4 100644 --- a/src/lib/aqe-readiness.mjs +++ b/src/lib/aqe-readiness.mjs @@ -24,33 +24,13 @@ export function aqeEmbeddingConfiguration({ packageRoot = aqeRoot(), env = proce } catch { return { status: 'missing-backend', backend: null }; } } -// TEMPORARY (remove once fixed upstream; tracked by pacphi/agentic-kit#240): on a live -// RVF lock, agentic-qe 3.14.3 logs the busy warning, then falls through to a create -// attempt that fails with FsyncFailed; store and lock are untouched (agentic-qe#574). -// 3.14.4 carries agentic-qe#719, a partial fix (it rethrows LockHeld); whether 3.14.4 -// still emits this sequence is unverified, so the rule stays for it. Only that exact -// sequence is contention. -// The middle line is emitted solely by AQE's live-owner quarantine refusal, so a bare -// FsyncFailed, or a lock warning plus FsyncFailed without it, still fails below. The rule -// has no version gate. Remove it and its test when a released agentic-qe fixes -// agentic-qe#574 and that release is the kit floor; #719 alone does not remove it. -const LIVE_OWNER_CONTENTION = [ - /is locked by a live process/, - /is unusable but its lock is held by a live process/, - /FsyncFailed|0x0303/, -]; - export function classifyAqeStartup(result) { if (result.code !== 0) return { status: 'failed', reason: 'AQE command failed' }; const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; - const contention = LIVE_OWNER_CONTENTION.every(line => line.test(output)); - if (!contention && /FsyncFailed|0x0303/.test(output)) return { status: 'failed', reason: 'RVF backend failed' }; + if (/FsyncFailed|0x0303/.test(output)) return { status: 'failed', reason: 'RVF backend failed' }; if (/prewarm failed|Transformer initialization previously failed|Embedding model failed|semantic embedding unavailable/i.test(output)) { return { status: 'degraded', reason: 'Embedding initialization failed' }; } - if (contention) { - return { status: 'busy', reason: 'RVF is held by another live process; its FsyncFailed came from an AQE create attempt during contention (agentic-qe#574), not a storage error; SQLite fallback observed; owner health and RVF integrity unverified' }; - } if (/locked by a live process|lock is held by a live process/.test(output)) { return { status: 'busy', reason: 'RVF is held by another live process; SQLite fallback observed; owner health and RVF integrity unverified' }; } diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index 32860ccb..71bf3945 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -501,14 +501,15 @@ "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, "mapping": "mapped", "kitImpact": { "refs": ["lane C: live-lock busy rule", "pacphi/agentic-kit#240"], "files": ["docs/host-support.md"] }, - "adjustment": "agentic-qe 3.14.4 reports the real LockHeld instead of FsyncFailed after a live lock (Windows confirmation 2026-09-28). Re-run the live-owner fixture on macOS and Linux against 3.14.4; if no FsyncFailed follows the live-lock warning, remove the temporary busy rule in src/lib/aqe-readiness.mjs and close pacphi/agentic-kit#240.", - "status": "watching", + "adjustment": "Released agentic-qe 3.14.4 passed disposable live-owner conformance on macOS and Linux: status and the shipped adapter emitted LockHeld without FsyncFailed, and RVF/lock bytes were unchanged. ak retired the exact FsyncFailed-as-busy exception; old 3.14.3 output now fails closed. Ordinary live-lock SQLite fallback remains busy. This is a verified artifact baseline for this rule, not a universal AQE minimum or native Windows conformance. Upstream issue state remains separate from this kit retirement.", + "status": "retired", "constraintIds": [], "history": [ { "date": "2026-09-26", "event": "commented" }, { "date": "2026-09-26", "event": "registered" }, { "date": "2026-09-27", "event": "commented", "note": "#719 fixed FsyncFailed; a small fall-through remains (issuecomment-5863219357)" }, - { "date": "2026-09-28", "event": "commented", "note": "thanked fedor-drmanovic for the Windows 3.14.4 results; ak re-runs its live-owner fixture on macOS and Linux next (issuecomment-5878041570)" } + { "date": "2026-09-28", "event": "commented", "note": "thanked fedor-drmanovic for the Windows 3.14.4 results; ak re-runs its live-owner fixture on macOS and Linux next (issuecomment-5878041570)" }, + { "date": "2026-09-29", "event": "retired", "note": "ak retired its exact FsyncFailed-as-busy exception after released 3.14.4 passed native macOS and Linux live-owner probes; ordinary LockHeld fallback remains busy; no native Windows AQE proof or upstream closure claimed" } ] }, { @@ -749,7 +750,7 @@ { "date": "2026-09-26", "event": "registered" }, { "date": "2026-09-27", "event": "retired", "note": "merged and released in 3.14.4; context only: agentic-qe#574, tracked by pacphi/agentic-kit#240, drives removing the busy rule" } ], - "note": "Context only: a partial fix for agentic-qe#574 (rethrows LockHeld). Its release alone does not prove a live lock no longer surfaces FsyncFailed, so agentic-qe#574 drives removing the busy rule and closing pacphi/agentic-kit#240." + "note": "Context only: a partial fix for agentic-qe#574 (rethrows LockHeld), released in 3.14.4. Native macOS and Linux conformance against that artifact later showed no FsyncFailed under a live lock; ak retired the exact exception on 2026-09-29. Native Windows AQE conformance and pacphi/agentic-kit#240 closure remain separate." }, { "id": "proffesor-for-testing/agentic-qe#734", @@ -2024,15 +2025,16 @@ "dependency": null, "doneWhen": { "state": "closed-completed", "release": null }, "mapping": "mapped", - "kitImpact": { "refs": ["decision 7", "lane C: live-lock busy rule"], "files": ["src/lib/aqe-readiness.mjs"] }, - "adjustment": "Close #240 when a released agentic-qe no longer falls through to create after a live RVF lock (agentic-qe#574) and ak removes the temporary busy rule. agentic-qe#719 (partial fix: rethrows LockHeld) was released in 3.14.4 on 2026-09-27; whether that release alone clears FsyncFailed under a live lock is not yet verified.", + "kitImpact": { "refs": ["decision 7", "lane C: live-lock busy rule"], "files": ["docs/troubleshooting.md"] }, + "adjustment": "After the final main PR merges, the controller can close #240 with the released 3.14.4 native macOS/Linux live-owner evidence and ak's exact exception retirement for agentic-qe#574. The old 3.14.3 FsyncFailed sequence now fails closed; ordinary LockHeld fallback remains busy. No native Windows AQE conformance or upstream issue closure is claimed.", "status": "watching", "constraintIds": [], "tracks": ["proffesor-for-testing/agentic-qe#574", "proffesor-for-testing/agentic-qe#719"], "history": [ { "date": "2026-09-26", "event": "filed" }, { "date": "2026-09-26", "event": "registered" }, - { "date": "2026-09-27", "event": "commented", "note": "moved the upstream part of this tracker to the watch registry; agentic-qe 3.14.4 carries PR 719, not yet verified to stop FsyncFailed under a live lock (issuecomment-5858567140)" } + { "date": "2026-09-27", "event": "commented", "note": "moved the upstream part of this tracker to the watch registry; agentic-qe 3.14.4 carries PR 719, not yet verified to stop FsyncFailed under a live lock (issuecomment-5858567140)" }, + { "date": "2026-09-29", "event": "reviewed", "note": "released 3.14.4 passed native macOS and Linux live-owner conformance and ak retired the exact exception; issue closure waits for final main PR" } ] }, { diff --git a/tests/kit/aqe-verification.test.mjs b/tests/kit/aqe-verification.test.mjs index 1bad2acd..60e24c5b 100644 --- a/tests/kit/aqe-verification.test.mjs +++ b/tests/kit/aqe-verification.test.mjs @@ -6,39 +6,40 @@ import { aqeVerificationPassed } from '../../src/lib/aqe-verification.mjs'; test('a live lock does not hide an independent storage error', () => { assert.equal(classifyAqeStartup({ code: 0, stderr: 'locked by a live process; FsyncFailed' }).status, 'failed'); }); -// TEMPORARY (remove with the rule in classifyAqeStartup, pacphi/agentic-kit#240): the -// exact stderr agentic-qe 3.14.3 emits when a healthy patterns.rvf is held by a live -// owner (captured from a fixture; store and lock bytes were unchanged). The FsyncFailed -// comes from a create attempt AQE should not make (agentic-qe#574). Remove this test -// when a released agentic-qe fixes agentic-qe#574 and that release is the kit's floor; -// agentic-qe#719 (in 3.14.4) is a partial fix and does not remove it. -const LIVE_OWNER_CONTENTION = [ +// AQE 3.14.3 emitted this exact sequence under a live lock. The released 3.14.4 +// artifact omits FsyncFailed in macOS and Linux conformance probes; old output fails closed. +const OLD_LIVE_OWNER_CONTENTION = [ '[RVF] /p/.agentic-qe/patterns.rvf is locked by a live process (pid 70149) — not breaking the lock; degrading to SQLite for this run.', '[RVF] /p/.agentic-qe/patterns.rvf is unusable but its lock is held by a live process — leaving it alone and degrading to SQLite for this run.', '[RVF] Shared adapter init failed: RVF error 0x0303: FsyncFailed', ].join('\n'); -test('live-owner lock contention reads as busy, not a storage failure (agentic-qe#574)', () => { - const startup = classifyAqeStartup({ code: 0, stdout: '', stderr: LIVE_OWNER_CONTENTION }); - assert.equal(startup.status, 'busy'); - assert.match(startup.reason, /another live process/); - assert.match(startup.reason, /integrity unverified/); - assert.equal(aqeVerificationPassed(startup, { status: 'passed', corpus: { status: 'healthy' } }), true); +test('old live-owner FsyncFailed sequence fails closed and blocks verification', () => { + const startup = classifyAqeStartup({ code: 0, stdout: '', stderr: OLD_LIVE_OWNER_CONTENTION }); + assert.deepEqual(startup, { status: 'failed', reason: 'RVF backend failed' }); + assert.equal(aqeVerificationPassed(startup, { status: 'passed', corpus: { status: 'healthy' } }), false); }); -test('the contention rule needs all three lines; a partial match still fails', () => { - const [locked, unusable, fsync] = LIVE_OWNER_CONTENTION.split('\n'); +test('FsyncFailed fails with or without partial live-lock lines', () => { + const [locked, unusable, fsync] = OLD_LIVE_OWNER_CONTENTION.split('\n'); for (const stderr of [fsync, `${locked}\n${fsync}`, `${unusable}\n${fsync}`]) { assert.equal(classifyAqeStartup({ code: 0, stderr }).status, 'failed', stderr); } - assert.equal(classifyAqeStartup({ code: 1, stderr: LIVE_OWNER_CONTENTION }).status, 'failed'); + assert.equal(classifyAqeStartup({ code: 1, stderr: OLD_LIVE_OWNER_CONTENTION }).status, 'failed'); }); -test('live-owner contention does not hide failed embedding initialization', () => { - const stderr = `${LIVE_OWNER_CONTENTION}\nReasoningBank prewarm failed`; - assert.equal(classifyAqeStartup({ code: 0, stderr }).status, 'degraded'); +test('FsyncFailed takes precedence over failed embedding initialization', () => { + const stderr = `${OLD_LIVE_OWNER_CONTENTION}\nReasoningBank prewarm failed`; + assert.equal(classifyAqeStartup({ code: 0, stderr }).status, 'failed'); }); test('a live lock does not hide failed embedding initialization', () => { assert.equal(classifyAqeStartup({ code: 0, stderr: 'locked by a live process; prewarm failed' }).status, 'degraded'); }); +test('ordinary live lock remains busy with owner health and RVF integrity unknown', () => { + const startup = classifyAqeStartup({ code: 0, stderr: '[RVF] locked by a live process; 0x0300: LockHeld; degrading to SQLite' }); + assert.equal(startup.status, 'busy'); + assert.match(startup.reason, /SQLite fallback observed/); + assert.match(startup.reason, /owner health and RVF integrity unverified/); + assert.equal(aqeVerificationPassed(startup, { status: 'passed', corpus: { status: 'healthy' } }), true); +}); test('busy RVF permits qualified semantic proof when corpus is verified', () => { assert.equal(aqeVerificationPassed({ status: 'busy' }, { status: 'passed', corpus: { status: 'healthy' } }), true); }); diff --git a/tests/kit/upstream-watch-registry.test.mjs b/tests/kit/upstream-watch-registry.test.mjs index 7b1d43b9..18eca394 100644 --- a/tests/kit/upstream-watch-registry.test.mjs +++ b/tests/kit/upstream-watch-registry.test.mjs @@ -193,11 +193,10 @@ test('the tracking issues carry their whole upstream remainder', () => { const t240 = entry(doc, 'pacphi/agentic-kit#240'); assert.deepEqual([...t240.tracks].sort(), ['proffesor-for-testing/agentic-qe#574', 'proffesor-for-testing/agentic-qe#719']); assert.match(t240.adjustment, /agentic-qe#574/); - assert.ok(t240.kitImpact.files.includes('src/lib/aqe-readiness.mjs')); + assert.ok(t240.kitImpact.files.includes('docs/troubleshooting.md')); }); -// agentic-qe#719 is a partial fix for #574: releasing it alone must not dispatch removing the busy rule. -test('the partial fix agentic-qe#719 is context only; agentic-qe#574 drives the dispatch', () => { +test('the #574 exception retirement records released conformance without closing #240', () => { const doc = document(); const partial = entry(doc, 'proffesor-for-testing/agentic-qe#719'); assert.equal(partial.mapping, 'unmapped'); @@ -206,7 +205,13 @@ test('the partial fix agentic-qe#719 is context only; agentic-qe#574 drives the assert.match(partial.note, /agentic-qe#574/); const driver = entry(doc, 'proffesor-for-testing/agentic-qe#574'); assert.equal(driver.mapping, 'mapped'); - assert.match(driver.adjustment, /busy rule/); + assert.equal(driver.status, 'retired'); + assert.match(driver.adjustment, /macOS and Linux/); + assert.match(driver.adjustment, /not a universal AQE minimum/); + assert.ok(driver.history.some((item) => item.event === 'retired' && item.date === '2026-09-29')); + const tracker = entry(doc, 'pacphi/agentic-kit#240'); + assert.equal(tracker.status, 'watching'); + assert.match(tracker.adjustment, /final main PR/); }); test('stale threads are mapped to what ak carries, or retired with a reason', () => { From af9618fc0ae58f5ada887686fb00eb4d3c7acde7 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:24:18 -0700 Subject: [PATCH 42/54] test(exec): hardcode the PowerShell fixture entry point --- tests/kit/exec.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index d225a077..707b6103 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -193,7 +193,6 @@ test('Windows abort reaps the Node child behind a PowerShell shim and its grandc let outcome; try { fs.writeFileSync(path.join(dir, 'codex.cmd'), '@echo off\r\n'); - fs.writeFileSync(path.join(dir, 'codex.ps1'), `& '${quotedNode}' $args[0]\nexit $LASTEXITCODE\n`); const code = `const {spawn}=require('node:child_process'); const gc=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); @@ -202,7 +201,9 @@ test('Windows abort reaps the Node child behind a PowerShell shim and its grandc // native arguments, so multiline node -e source is not that interface. const script = path.join(dir, 'fixture.cjs'); fs.writeFileSync(script, code); - pending = run('codex', [script], { + fs.writeFileSync(path.join(dir, 'codex.ps1'), + `& '${quotedNode}' '${script.replaceAll("'", "''")}' $args\nexit $LASTEXITCODE\n`); + pending = run('codex', [], { env: { PATH: dir, PATHEXT: '.CMD' }, signal: controller.signal, timeout: 10_000, }); pending.then((result) => { outcome = result; }); From 087a67154dccba79399900545574c72c86815041 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:31:43 -0700 Subject: [PATCH 43/54] fix(exec): launch recognized npm Windows shims through their public bins --- src/lib/exec.mjs | 36 +++-- src/lib/mcp-probe.mjs | 3 +- src/lib/mcp-tool-call.mjs | 3 +- src/lib/windows-npm-shim.mjs | 83 +++++++++++ tests/fixtures/npm-windows-shim/license.txt | 15 ++ tests/fixtures/npm-windows-shim/ruflo.cmd | 17 +++ tests/fixtures/npm-windows-shim/ruflo.ps1 | 28 ++++ tests/kit/windows-npm-shim.test.mjs | 151 ++++++++++++++++++++ tests/live/ruflo-windows-transport.test.mjs | 17 ++- 9 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 src/lib/windows-npm-shim.mjs create mode 100644 tests/fixtures/npm-windows-shim/license.txt create mode 100644 tests/fixtures/npm-windows-shim/ruflo.cmd create mode 100644 tests/fixtures/npm-windows-shim/ruflo.ps1 create mode 100644 tests/kit/windows-npm-shim.test.mjs diff --git a/src/lib/exec.mjs b/src/lib/exec.mjs index 3662581a..9a30aa6a 100644 --- a/src/lib/exec.mjs +++ b/src/lib/exec.mjs @@ -8,13 +8,15 @@ // breaks out into a second command). The actual fix is resolving the shim to // its real file on PATH. Native .com/.exe files run directly. A .cmd shim is // never passed to spawn: Node does not execute batch files without a shell. -// Instead, its sibling .ps1 shim runs through Windows PowerShell's `-File` -// interface, preserving every caller argument as a separate argv element. +// An exact recognized npm wrapper pair launches its declared public bin with +// Node directly, preserving interactive stdio and literal argv. Other wrappers +// keep their sibling .ps1 through Windows PowerShell's `-File` interface. import { spawn } from 'node:child_process'; import { AsyncLocalStorage } from 'node:async_hooks'; import fs from 'node:fs'; import path from 'node:path'; import { isWindows } from './paths.mjs'; +import { npmShimInvocation, windowsEnvValue, mergeWindowsEnv } from './windows-npm-shim.mjs'; const MAX_EXEC_BUFFER = 16 * 1024 * 1024; @@ -36,18 +38,20 @@ const CMD_SHIMS = new Set([ /** Build a shell-free invocation for `cmd`, trying Windows' shim extensions in * PATHEXT order. A native executable is launched directly; a .cmd shim is - * accepted only when its sibling .ps1 and system PowerShell both exist. + * mapped to its own public Node bin only for an exact recognized npm wrapper + * pair; other .cmd wrappers require a sibling .ps1 and system PowerShell. * Falls back to the bare name with resolved:false when no safe target exists. * Exported: the execution adapters spawn these CLIs directly (subprocess.mjs * for claude/codex, opencode.mjs for the serve child, x/ruflo-mcp.mjs for the * Ruflo MCP launcher) and must share the same * resolution `run()`/`have()` use, or readiness passes but launch ENOENTs on - * Windows (swarm review, #88). */ -export function resolveShim(cmd, args = [], { windows = isWindows, env = process.env } = {}) { + * Windows (swarm review, #88). `npmBin:false` retains the PowerShell boundary + * for native diagnostics. */ +export function resolveShim(cmd, args = [], { windows = isWindows, env = process.env, npmBin = true } = {}) { const direct = { command: cmd, args: [...args], resolved: !windows }; if (!windows) return direct; - const systemRoot = env.SystemRoot || env.WINDIR; + const systemRoot = windowsEnvValue(env, 'SystemRoot') || windowsEnvValue(env, 'WINDIR'); const powershell = systemRoot ? path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : null; @@ -59,7 +63,10 @@ export function resolveShim(cmd, args = [], { windows = isWindows, env = process if (ext === '.com' || ext === '.exe' || !ext) { return { command: candidate, args: [...args], resolved: true }; } - if (ext !== '.cmd' || !powershell) return null; + if (ext !== '.cmd') return null; + const npm = npmBin ? npmShimInvocation(candidate, args, env) : null; + if (npm) return npm; + if (!powershell) return null; const script = `${candidate.slice(0, -ext.length)}.ps1`; try { if (!fs.statSync(script).isFile() || !fs.statSync(powershell).isFile()) return null; @@ -75,15 +82,21 @@ export function resolveShim(cmd, args = [], { windows = isWindows, env = process }; if (path.isAbsolute(cmd)) return invocationFor(cmd) ?? direct; - const exts = (env.PATHEXT || '.COM;.EXE;.BAT;.CMD') + const exts = (windowsEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD') .split(';') .map((ext) => ext.trim()) .filter(Boolean); - for (const dir of (env.PATH || env.Path || '').split(path.delimiter)) { + for (const dir of (windowsEnvValue(env, 'PATH') || '').split(path.delimiter)) { if (!dir) continue; for (const ext of exts) { - const invocation = invocationFor(path.join(dir, cmd + ext.toLowerCase())); + const candidate = path.join(dir, cmd + ext.toLowerCase()); + const invocation = invocationFor(candidate); if (invocation) return invocation; + // An earlier custom/unusable .cmd still selects this installation. Do + // not silently switch to a later package because its shim is recognized. + if (ext.toLowerCase() === '.cmd') { + try { if (fs.statSync(candidate).isFile()) return direct; } catch { /* absent */ } + } } } return direct; @@ -268,8 +281,9 @@ function runOwned(command, args, execOpts, { windows, input }) { * `runOwned` keeps that payload out of the process table. */ export async function run(cmd, args = [], opts = {}) { try { - const env = opts.env ? { ...process.env, ...opts.env } : process.env; const windows = opts.windows ?? isWindows; + const env = opts.env + ? (windows ? mergeWindowsEnv(process.env, opts.env) : { ...process.env, ...opts.env }) : process.env; const invocation = CMD_SHIMS.has(cmd) ? resolveShim(cmd, args, { windows, env }) : { command: cmd, args }; diff --git a/src/lib/mcp-probe.mjs b/src/lib/mcp-probe.mjs index dcdc4416..d7302370 100644 --- a/src/lib/mcp-probe.mjs +++ b/src/lib/mcp-probe.mjs @@ -2,6 +2,7 @@ // repository content, environment values, or stderr are returned in receipts. import { spawn } from 'node:child_process'; import { resolveShim, killProcessTree } from './exec.mjs'; +import { mergeWindowsEnv } from './windows-npm-shim.mjs'; /** @param {{command:string,args?:string[],cwd?:string,env?:NodeJS.ProcessEnv,timeoutMs?:number}} options */ export function probeMcp({ command, args = [], cwd, env = {}, timeoutMs = 30_000 }) { @@ -12,7 +13,7 @@ export function probeMcp({ command, args = [], cwd, env = {}, timeoutMs = 30_000 } return new Promise((resolve) => { const started = performance.now(); - const mergedEnv = { ...process.env, ...env }; + const mergedEnv = process.platform === 'win32' ? mergeWindowsEnv(process.env, env) : { ...process.env, ...env }; const invocation = resolveShim(command, args, { env: mergedEnv }); const child = spawn(invocation.command, invocation.args, { cwd, env: mergedEnv, shell: false, detached: process.platform !== 'win32', diff --git a/src/lib/mcp-tool-call.mjs b/src/lib/mcp-tool-call.mjs index 7b83678d..26154fb9 100644 --- a/src/lib/mcp-tool-call.mjs +++ b/src/lib/mcp-tool-call.mjs @@ -14,6 +14,7 @@ // shim's node process). import { spawn } from 'node:child_process'; import { resolveShim, killProcessTree } from './exec.mjs'; +import { mergeWindowsEnv } from './windows-npm-shim.mjs'; const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; /** How long the killed server may take to exit before the call reports @@ -44,7 +45,7 @@ export async function callMcpTools({ }) { validate({ command, args, calls, timeoutMs, exitGraceMs }); return new Promise((resolve) => { - const merged = { ...process.env, ...env }; + const merged = process.platform === 'win32' ? mergeWindowsEnv(process.env, env) : { ...process.env, ...env }; const invocation = resolveShim(command, args, { env: merged }); const child = spawn(invocation.command, invocation.args, { cwd, env: merged, shell: false, detached: process.platform !== 'win32', diff --git a/src/lib/windows-npm-shim.mjs b/src/lib/windows-npm-shim.mjs new file mode 100644 index 00000000..433ee81c --- /dev/null +++ b/src/lib/windows-npm-shim.mjs @@ -0,0 +1,83 @@ +// Recognize, never interpret, npm cmd-shim 8's plain `env node` wrapper pair. +// Fingerprints normalize only CRLF and the package entry path. Any flags, +// environment assignments, comments or custom behavior keep the PS fallback. +// Fixtures were emitted by cmd-shim 8.0.0; the ps1 hash matches the native +// Ruflo 3.48.0 CI artifact. No package manager or package code runs here. +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +const CMD_TEMPLATE = '46268d032014c41f0112ffc0f52a9b297a809c289587e984e5919c65f29dad79'; +const PS_TEMPLATE = '11a7c411407320ddc34a9ae9633d6372b1867994ff723693be5be16148dd45a8'; + +export function windowsEnvValue(env, key) { + return env[Object.keys(env).sort().find((name) => name.toUpperCase() === key.toUpperCase())]; +} + +/** Windows treats environment names case-insensitively. Remove a replaced + * spelling before applying the override, so Node's sorted env selection and + * the resolver agree about the caller's PATH and runtime. */ +export function mergeWindowsEnv(base, overrides) { + const merged = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + for (const old of Object.keys(merged)) if (old.toUpperCase() === key.toUpperCase()) delete merged[old]; + merged[key] = value; + } + return merged; +} + +function readBounded(file) { + const stat = fs.statSync(file); + if (!stat.isFile() || stat.size > 65536) throw Error('not a bounded shim or manifest'); + return fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n'); +} +const fingerprint = (source, target) => createHash('sha256').update(source.replaceAll(target, '')).digest('hex'); +const isFile = (file) => { try { return fs.statSync(file).isFile(); } catch { return false; } }; +function inside(root, file) { + const relative = path.relative(root, file); + return relative && !relative.split(path.sep).includes('..') && !path.isAbsolute(relative); +} +function plainNodeShebang(file) { + const fd = fs.openSync(file, 'r'); + try { + const data = Buffer.alloc(128); + const size = fs.readSync(fd, data, 0, data.length, 0); + return /^#!\/usr\/bin\/env node\r?\n/.test(data.subarray(0, size).toString('utf8')); + } finally { fs.closeSync(fd); } +} + +/** Map ONLY the PATH-selected, unchanged npm wrapper pair to its own manifest's + * public bin. Never consult a global-root guess or bypass an internal bundle. + * Return null when ownership, containment, template or runtime is uncertain. */ +export function npmShimInvocation(candidate, args, env) { + try { + const cmd = readBounded(candidate); + const ps = readBounded(`${candidate.slice(0, -4)}.ps1`); + const target = ps.match(/"\$basedir\/(node_modules\/[A-Za-z0-9@_./-]+)"/)?.[1]; + if (!target || target.split('/').some((part) => !part || part === '.' || part === '..')) return null; + if (fingerprint(ps, target) !== PS_TEMPLATE + || fingerprint(cmd, target.replaceAll('/', '\\')) !== CMD_TEMPLATE) return null; + const parts = target.split('/'); + const packageName = parts[1].startsWith('@') ? `${parts[1]}/${parts[2]}` : parts[1]; + const base = path.resolve(path.dirname(candidate)); + const packageRoot = path.join(base, 'node_modules', ...packageName.split('/')); + const pkg = JSON.parse(readBounded(path.join(packageRoot, 'package.json'))); + if (pkg.name !== packageName) return null; + const name = path.basename(candidate).slice(0, -4); + const declared = typeof pkg.bin === 'string' + ? (packageName.split('/').at(-1) === name ? pkg.bin : null) : pkg.bin?.[name]; + if (typeof declared !== 'string' || !declared || path.win32.isAbsolute(declared) + || path.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) return null; + const entry = path.resolve(base, ...parts); + if (path.resolve(packageRoot, declared) !== entry || !isFile(entry)) return null; + const realPackage = fs.realpathSync(packageRoot); + if (!inside(fs.realpathSync(base), realPackage) + || !inside(realPackage, fs.realpathSync(entry)) || !plainNodeShebang(entry)) return null; + // npm chooses adjacent node.exe first, then node.exe on the caller's PATH. + // Do not substitute agentic-kit's current runtime or a different npm tree. + const adjacent = path.join(base, 'node.exe'); + const node = isFile(adjacent) ? adjacent : (windowsEnvValue(env, 'PATH') || '') + .split(path.delimiter).filter(Boolean).map((dir) => path.resolve(dir, 'node.exe')).find(isFile); + return node ? { command: node, args: [entry, ...args], resolved: true } : null; + } catch { return null; } +} diff --git a/tests/fixtures/npm-windows-shim/license.txt b/tests/fixtures/npm-windows-shim/license.txt new file mode 100644 index 00000000..20a47625 --- /dev/null +++ b/tests/fixtures/npm-windows-shim/license.txt @@ -0,0 +1,15 @@ +The ISC License + +Copyright (c) npm, Inc. and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/tests/fixtures/npm-windows-shim/ruflo.cmd b/tests/fixtures/npm-windows-shim/ruflo.cmd new file mode 100644 index 00000000..ea9a8598 --- /dev/null +++ b/tests/fixtures/npm-windows-shim/ruflo.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\ruflo\bin\ruflo.js" %* diff --git a/tests/fixtures/npm-windows-shim/ruflo.ps1 b/tests/fixtures/npm-windows-shim/ruflo.ps1 new file mode 100644 index 00000000..7de4de00 --- /dev/null +++ b/tests/fixtures/npm-windows-shim/ruflo.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/node_modules/ruflo/bin/ruflo.js" $args + } else { + & "$basedir/node$exe" "$basedir/node_modules/ruflo/bin/ruflo.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/node_modules/ruflo/bin/ruflo.js" $args + } else { + & "node$exe" "$basedir/node_modules/ruflo/bin/ruflo.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/tests/kit/windows-npm-shim.test.mjs b/tests/kit/windows-npm-shim.test.mjs new file mode 100644 index 00000000..fc965df1 --- /dev/null +++ b/tests/kit/windows-npm-shim.test.mjs @@ -0,0 +1,151 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveShim, run } from '../../src/lib/exec.mjs'; +import { callMcpTools } from '../../src/lib/mcp-tool-call.mjs'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; + +const templates = Object.fromEntries(['cmd', 'ps1'].map((ext) => [ext, + fs.readFileSync(new URL(`../fixtures/npm-windows-shim/ruflo.${ext}`, import.meta.url), 'utf8')])); +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npm-shim-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const entry = path.join(root, 'node_modules', 'ruflo', 'bin', 'ruflo.js'); + const manifest = path.join(root, 'node_modules', 'ruflo', 'package.json'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, '#!/usr/bin/env node\n'); + fs.writeFileSync(manifest, JSON.stringify({ name: 'ruflo', bin: { ruflo: 'bin/ruflo.js' } })); + for (const ext of ['cmd', 'ps1']) fs.writeFileSync(path.join(root, `ruflo.${ext}`), templates[ext]); + const powershell = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + fs.mkdirSync(path.dirname(powershell), { recursive: true }); fs.writeFileSync(powershell, 'fixture'); + const node = path.join(root, 'node.exe'); fs.writeFileSync(node, 'fixture'); + const env = { PATH: root, PATHEXT: '.EXE;.CMD', SystemRoot: root }; + return { root, entry, manifest, node, powershell, env }; +} + +test('recognized npm shim maps to its declared public bin with literal argv and adjacent Node', (t) => { + const f = fixture(t); + const args = ['mcp', 'start', 'a & b', 'quoted " argument', '$(ignored)', '', '\n']; + assert.deepEqual(resolveShim('ruflo', args, { windows: true, env: f.env }), { + command: f.node, args: [f.entry, ...args], resolved: true, + }); + assert.deepEqual(resolveShim(path.join(f.root, 'ruflo.cmd'), args, { windows: true, env: f.env }), { + command: f.node, args: [f.entry, ...args], resolved: true, + }); + assert.equal(resolveShim('ruflo', args, { windows: true, env: f.env, npmBin: false }).command, f.powershell); + for (const ext of ['cmd', 'ps1']) { + fs.writeFileSync(path.join(f.root, `ruflo.${ext}`), templates[ext].replaceAll('\r\n', '\n').replaceAll('\n', '\r\n')); + } + assert.equal(resolveShim('ruflo', args, { windows: true, env: f.env }).command, f.node, 'CRLF templates remain recognized'); +}); + +test('PATH-selected installation and case-insensitive Node lookup beat current runtime/global guesses', (t) => { + const f = fixture(t); const other = fixture(t); + fs.rmSync(f.node); + const env = { pAtH: `${f.root}${path.delimiter}${other.root}`, pAtHeXt: '.CMD', sYsTeMrOoT: f.root }; + assert.deepEqual(resolveShim('ruflo', ['mcp', 'start'], { windows: true, env }), { + command: other.node, args: [f.entry, 'mcp', 'start'], resolved: true, + }); +}); + +for (const mutation of ['cmd', 'ps1', 'manifest', 'foreign-package', 'undeclared-bin', 'traversal', 'shebang', 'missing-bin', 'missing-node']) { + test(`${mutation} cannot silently bypass a custom or unverified wrapper`, (t) => { + const f = fixture(t); + if (mutation === 'cmd' || mutation === 'ps1') fs.appendFileSync(path.join(f.root, `ruflo.${mutation}`), '\ncustom-behavior\n'); + if (mutation === 'manifest') fs.writeFileSync(f.manifest, 'broken json'); + if (mutation === 'foreign-package') fs.writeFileSync(f.manifest, JSON.stringify({ name: 'other', bin: { ruflo: 'bin/ruflo.js' } })); + if (mutation === 'undeclared-bin') fs.writeFileSync(f.manifest, JSON.stringify({ name: 'ruflo', bin: { other: 'bin/ruflo.js' } })); + if (mutation === 'traversal') fs.writeFileSync(f.manifest, JSON.stringify({ name: 'ruflo', bin: { ruflo: '../other.js' } })); + if (mutation === 'shebang') fs.writeFileSync(f.entry, '#!/usr/bin/env node --require injected\n'); + if (mutation === 'missing-bin') fs.rmSync(f.entry); + if (mutation === 'missing-node') fs.rmSync(f.node); + assert.equal(resolveShim('ruflo', [], { windows: true, env: f.env }).command, f.powershell); + }); +} + +test('custom wrapper first on PATH and native executable precedence remain authoritative', (t) => { + const first = fixture(t); const second = fixture(t); + fs.appendFileSync(path.join(first.root, 'ruflo.ps1'), '\n# user customization\n'); + const env = { ...first.env, PATH: `${first.root}${path.delimiter}${second.root}` }; + assert.equal(resolveShim('ruflo', [], { windows: true, env }).command, first.powershell); + fs.writeFileSync(path.join(first.root, 'ruflo.exe'), 'native'); + assert.deepEqual(resolveShim('ruflo', ['literal'], { windows: true, env }), { + command: path.join(first.root, 'ruflo.exe'), args: ['literal'], resolved: true, + }); +}); + +test('bin symlink escaping its package cannot authorize bypass', { skip: process.platform === 'win32' }, (t) => { + const f = fixture(t); + const outside = path.join(f.root, 'outside.js'); fs.writeFileSync(outside, '#!/usr/bin/env node\n'); + fs.rmSync(f.entry); fs.symlinkSync(outside, f.entry); + assert.equal(resolveShim('ruflo', [], { windows: true, env: f.env }).command, f.powershell); +}); + +test('recognized public entry point answers MCP initialize without stdin EOF', { skip: process.platform === 'win32' }, async (t) => { + const f = fixture(t); + fs.rmSync(f.node); fs.symlinkSync(process.execPath, f.node); + fs.writeFileSync(f.entry, `#!/usr/bin/env node +require('node:readline').createInterface({input:process.stdin}).on('line', line => { + const r=JSON.parse(line); + if(r.method==='initialize') process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result:{protocolVersion:'2024-11-05'}})+'\\n'); +}); +`); + const spec = resolveShim('ruflo', ['mcp', 'start'], { windows: true, env: f.env }); + assert.equal(spec.command, f.node); + const result = await callMcpTools({ ...spec, cwd: f.root, env: spawnEnv(f.root), calls: [], timeoutMs: 1000 }); + assert.equal(result.status, 'ok'); +}); + +test('scoped package aliases and string bin declarations require manifest agreement', (t) => { + const f = fixture(t); + fs.writeFileSync(f.manifest, JSON.stringify({ name: 'ruflo', bin: './bin/ruflo.js' })); + assert.equal(resolveShim('ruflo', [], { windows: true, env: f.env }).command, f.node); + const target = 'node_modules/@scope/tool/bin/cli.js'; + const entry = path.join(f.root, ...target.split('/')); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, '#!/usr/bin/env node\n'); + const manifest = path.join(f.root, 'node_modules', '@scope', 'tool', 'package.json'); + fs.writeFileSync(manifest, JSON.stringify({ name: '@scope/tool', bin: { ruflo: 'bin/cli.js' } })); + for (const ext of ['cmd', 'ps1']) { + const oldTarget = ext === 'cmd' ? 'node_modules\\ruflo\\bin\\ruflo.js' : 'node_modules/ruflo/bin/ruflo.js'; + fs.writeFileSync(path.join(f.root, `ruflo.${ext}`), templates[ext].replaceAll(oldTarget, ext === 'cmd' ? target.replaceAll('/', '\\') : target)); + } + assert.deepEqual(resolveShim('ruflo', [], { windows: true, env: f.env }), { + command: f.node, args: [entry], resolved: true, + }); + fs.writeFileSync(manifest, JSON.stringify({ name: '@scope/tool', bin: 'bin/cli.js' })); + assert.equal(resolveShim('ruflo', [], { windows: true, env: f.env }).command, f.powershell); +}); + +test('run honors a differently cased PATH override without duplicate environment keys', { skip: process.platform === 'win32' }, async (t) => { + const f = fixture(t); + fs.rmSync(f.node); fs.symlinkSync(process.execPath, f.node); + fs.writeFileSync(f.entry, `#!/usr/bin/env node +process.stdout.write(JSON.stringify({argv:process.argv.slice(2),paths:Object.keys(process.env).filter(k=>k.toUpperCase()==='PATH')})); +`); + const args = ['mcp', 'quoted " value', 'a & b', '']; + const result = await run('ruflo', args, { windows: true, env: { + pAtH: f.root, pAtHeXt: '.CMD', sYsTeMrOoT: f.root, + } }); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { argv: args, paths: ['pAtH'] }); +}); + +test('an earlier cmd with no safe sibling does not fall through to another installation', (t) => { + const first = fixture(t); const second = fixture(t); + fs.rmSync(path.join(first.root, 'ruflo.ps1')); + const env = { ...first.env, PATH: `${first.root}${path.delimiter}${second.root}` }; + assert.deepEqual(resolveShim('ruflo', ['mcp'], { windows: true, env }), { + command: 'ruflo', args: ['mcp'], resolved: false, + }); +}); + +test('package symlink escaping the selected installation cannot authorize bypass', { skip: process.platform === 'win32' }, (t) => { + const first = fixture(t); const second = fixture(t); + const pkg = path.join(first.root, 'node_modules', 'ruflo'); + fs.rmSync(pkg, { recursive: true }); + fs.symlinkSync(path.join(second.root, 'node_modules', 'ruflo'), pkg); + assert.equal(resolveShim('ruflo', [], { windows: true, env: first.env }).command, first.powershell); +}); diff --git a/tests/live/ruflo-windows-transport.test.mjs b/tests/live/ruflo-windows-transport.test.mjs index 20a5a14f..d4c91e08 100644 --- a/tests/live/ruflo-windows-transport.test.mjs +++ b/tests/live/ruflo-windows-transport.test.mjs @@ -20,7 +20,7 @@ const input = `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', p } })}\n`; test('diagnose installed Ruflo Windows public shim versus installed bin transport', { - skip: process.env.AK_RUFLO_WINDOWS_DIAGNOSTIC !== '1', timeout: 120000, + skip: process.env.AK_RUFLO_WINDOWS_DIAGNOSTIC !== '1', timeout: 180000, }, async () => { assert.equal(process.platform, 'win32', 'diagnostic requires native Windows'); const packageRoot = process.env.AK_RUFLO_PACKAGE_ROOT; @@ -46,14 +46,17 @@ test('diagnose installed Ruflo Windows public shim versus installed bin transpor fs.writeFileSync(path.join(root, 'claude-flow.config.json'), JSON.stringify({ daemon: { autostart: false } })); const invocation = resolveShim('ruflo', ['mcp', 'start'], { env }); assert.equal(invocation.resolved, true, 'installed public shim must resolve'); - const scriptIndex = invocation.args.indexOf('-File'); + const powershell = resolveShim('ruflo', ['mcp', 'start'], { env, npmBin: false }); + const scriptIndex = powershell.args.indexOf('-File'); assert.ok(scriptIndex >= 0, 'receipt requires the native PowerShell transport'); - const shim = invocation.args[scriptIndex + 1]; + const shim = powershell.args[scriptIndex + 1]; + const cmdShim = shim.slice(0, -4) + '.cmd'; const version = await scope.launch(resolveShim('ruflo', ['--version'], { env }), { cwd: root, env, timeoutMs: 10000 }); console.log(JSON.stringify({ diagnostic: 'inputs', node: process.version, uv: process.versions.uv, platform: process.platform, packageVersion: pkg.version, version, - packageSha: sha(packageFile), bin, binSha: sha(bin), invocation, + packageSha: sha(packageFile), bin, binSha: sha(bin), invocation, powershell, + cmdSha: sha(cmdShim), cmdSource: fs.readFileSync(cmdShim, 'utf8').slice(0, 8192), shimSha: sha(shim), shimSource: fs.readFileSync(shim, 'utf8').slice(0, 8192), sourceSha: sha(new URL(import.meta.url)), launcherSha: sha(new URL('./ruflo-windows-diagnostic-process.mjs', import.meta.url)) })); @@ -65,8 +68,8 @@ test('diagnose installed Ruflo Windows public shim versus installed bin transpor fs.writeFileSync(legacyShim, `& '${process.execPath.replaceAll("'", "''")}' -e $args[0]\nexit $LASTEXITCODE\n`); const legacyCode = `const {spawn}=require('node:child_process'); require('node:fs').writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid]));`; - const legacy = await scope.launch({ command: invocation.command, args: [ - ...invocation.args.slice(0, scriptIndex + 1), legacyShim, legacyCode, + const legacy = await scope.launch({ command: powershell.command, args: [ + ...powershell.args.slice(0, scriptIndex + 1), legacyShim, legacyCode, ] }, { cwd: root, env, timeoutMs: 5000 }); console.log(JSON.stringify({ diagnostic: 'legacy-multiline-node-e', marker: fs.existsSync(marker), ...legacy })); @@ -74,6 +77,8 @@ test('diagnose installed Ruflo Windows public shim versus installed bin transpor for (const [transport, spec, eof] of [ ['public-open-stdin', invocation, false], ['public-eof', invocation, true], + ['powershell-open-stdin', powershell, false], + ['powershell-eof', powershell, true], ['installed-bin-open-stdin', { command: process.execPath, args: [bin, 'mcp', 'start'] }, false], ]) { const observation = await scope.launch(spec, { cwd: root, env, input, endInput: eof, From be8108c2677788ea8fa84eb0df5b24aba68492bc Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:52:40 -0700 Subject: [PATCH 44/54] fix(identity): preserve exact persisted file IDs --- src/lib/file-identity.mjs | 18 ++ src/lib/hook-remediation/engine.mjs | 5 +- src/lib/hook-remediation/fs-port.mjs | 28 +-- src/lib/hook-remediation/store.mjs | 3 +- src/lib/host-alignment.mjs | 7 +- src/lib/host-health-evidence.mjs | 5 +- src/lib/live/jsonl-tailer.mjs | 20 +- src/lib/live/transcript-streams.mjs | 6 +- src/lib/maintenance/discovery/partitions.mjs | 7 +- tests/kit/persisted-file-identity.test.mjs | 196 +++++++++++++++++++ 10 files changed, 259 insertions(+), 36 deletions(-) create mode 100644 src/lib/file-identity.mjs create mode 100644 tests/kit/persisted-file-identity.test.mjs diff --git a/src/lib/file-identity.mjs b/src/lib/file-identity.mjs new file mode 100644 index 00000000..b2f764c3 --- /dev/null +++ b/src/lib/file-identity.mjs @@ -0,0 +1,18 @@ +// Persisted file IDs are decimal strings. A legacy Number is comparable only +// when it was a safe integer; an unsafe Number has already lost information. +export function fileId(value) { + if (typeof value === 'bigint') return value >= 0n ? value.toString() : null; + if (typeof value === 'number') return Number.isSafeInteger(value) && value >= 0 ? String(value) : null; + if (typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value)) return value; + return null; +} + +export function sameFileId(left, right) { + const a = fileId(left); + return a !== null && a === fileId(right); +} + +export function statMtimeMs(stat) { + if (typeof stat.mtimeNs !== 'bigint') return stat.mtimeMs; + return Number(stat.mtimeNs / 1_000_000n) + Number(stat.mtimeNs % 1_000_000n) / 1_000_000; +} diff --git a/src/lib/hook-remediation/engine.mjs b/src/lib/hook-remediation/engine.mjs index 767b449e..2bbfa475 100644 --- a/src/lib/hook-remediation/engine.mjs +++ b/src/lib/hook-remediation/engine.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { sameFileId } from '../file-identity.mjs'; import { assertHookHealingPlanIntegrity, buildHookHealingPlan, @@ -27,8 +28,8 @@ function sameOwnerAndParent(snapshot, expected) { && snapshot.gid === (expected.gid ?? snapshot.gid) && snapshot.specialMode === (expected.specialMode ?? 0) && snapshot.parent.realPath === (expected.parent?.realPath ?? snapshot.parent.realPath) - && snapshot.parent.dev === (expected.parent?.dev ?? snapshot.parent.dev) - && snapshot.parent.ino === (expected.parent?.ino ?? snapshot.parent.ino); + && sameFileId(snapshot.parent.dev, expected.parent?.dev ?? snapshot.parent.dev) + && sameFileId(snapshot.parent.ino, expected.parent?.ino ?? snapshot.parent.ino); } function preflight(plan, actionIds, expectedPlanDigest, options) { diff --git a/src/lib/hook-remediation/fs-port.mjs b/src/lib/hook-remediation/fs-port.mjs index 90a6ad9f..42a9ae5c 100644 --- a/src/lib/hook-remediation/fs-port.mjs +++ b/src/lib/hook-remediation/fs-port.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { randomBytes } from 'node:crypto'; import { MAX_AUDIT_SOURCE_BYTES, sha256 } from '../hook-audit/common.mjs'; +import { fileId, sameFileId, statMtimeMs } from '../file-identity.mjs'; export const MAX_HOOK_TARGET_BYTES = MAX_AUDIT_SOURCE_BYTES; @@ -44,29 +45,27 @@ export function inspectHookTarget(file, containmentRoot, { const realFile = fsImpl.realpathSync(file); if (!contained(realRoot, realFile, platform)) throw new Error('target escapes its containment root'); descriptor = fsImpl.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); - const opened = fsImpl.fstatSync(descriptor); + const opened = fsImpl.fstatSync(descriptor, { bigint: true }); if (!opened.isFile() || opened.size > maxBytes) throw new Error('opened target is not a bounded regular file'); - // BigInt identity: Windows file IDs can exceed 2^53; `opened` stays Number for the image. - const openedId = fsImpl.fstatSync(descriptor, { bigint: true }); - if (openedId.dev !== stat.dev || openedId.ino !== stat.ino) { + if (opened.dev !== stat.dev || opened.ino !== stat.ino) { throw new Error('target identity changed between inspection and open'); } const reopened = fsImpl.realpathSync(file); if (reopened !== realFile || !contained(realRoot, reopened, platform)) { throw new Error('target path changed between inspection and open'); } - const bytes = readDescriptor(fsImpl, descriptor, opened.size); + const bytes = readDescriptor(fsImpl, descriptor, Number(opened.size)); const parent = path.dirname(realFile); - const parentStat = fsImpl.statSync(parent); + const parentStat = fsImpl.statSync(parent, { bigint: true }); return { file: path.resolve(file), containmentRoot: path.resolve(containmentRoot), realFile, realRoot, bytes, sha256: sha256(bytes), size: bytes.length, - mode: platform === 'win32' ? null : opened.mode & 0o777, - modeSupported: platform !== 'win32', mtimeMs: opened.mtimeMs, - uid: typeof opened.uid === 'number' ? opened.uid : null, - gid: typeof opened.gid === 'number' ? opened.gid : null, - specialMode: platform === 'win32' ? 0 : opened.mode & 0o7000, - parent: { realPath: parent, dev: parentStat.dev, ino: parentStat.ino }, + mode: platform === 'win32' ? null : Number(opened.mode & 0o777n), + modeSupported: platform !== 'win32', mtimeMs: statMtimeMs(opened), + uid: typeof opened.uid === 'bigint' ? Number(opened.uid) : null, + gid: typeof opened.gid === 'bigint' ? Number(opened.gid) : null, + specialMode: platform === 'win32' ? 0 : Number(opened.mode & 0o7000n), + parent: { realPath: parent, dev: fileId(parentStat.dev), ino: fileId(parentStat.ino) }, }; } finally { if (descriptor !== undefined) fsImpl.closeSync(descriptor); @@ -134,9 +133,10 @@ export function atomicReplaceHookTarget(snapshot, bytes, desiredMode = snapshot. || current.specialMode !== snapshot.specialMode) { throw new Error(`target changed immediately before replacement: ${snapshot.file}`); } - const currentParent = fsImpl.statSync(path.dirname(current.realFile)); + const currentParent = fsImpl.statSync(path.dirname(current.realFile), { bigint: true }); if (current.parent.realPath !== snapshot.parent.realPath - || currentParent.dev !== snapshot.parent.dev || currentParent.ino !== snapshot.parent.ino) { + || !sameFileId(currentParent.dev, snapshot.parent.dev) + || !sameFileId(currentParent.ino, snapshot.parent.ino)) { throw new Error(`target parent changed immediately before replacement: ${snapshot.file}`); } const suffix = randomBytes(12).toString('hex'); diff --git a/src/lib/hook-remediation/store.mjs b/src/lib/hook-remediation/store.mjs index 32a91cf7..d4214eb4 100644 --- a/src/lib/hook-remediation/store.mjs +++ b/src/lib/hook-remediation/store.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { randomBytes } from 'node:crypto'; import { MAX_AUDIT_SOURCE_BYTES, sha256, stableJson } from '../hook-audit/common.mjs'; +import { fileId } from '../file-identity.mjs'; export const HOOK_HEAL_RECEIPT_SCHEMA = 'hook-heal-receipt/v1'; const RECEIPT_ID = /^tx-[0-9TZ.-]+-[a-f0-9]{16}$/; @@ -202,7 +203,7 @@ function validImage(image, { preimage = false } = {}) { && (image.gid === null || Number.isInteger(image.gid)) && Number.isInteger(image.specialMode) && image.specialMode >= 0 && image.specialMode <= 0o7000 && path.isAbsolute(image.parent?.realPath ?? '') - && Number.isInteger(image.parent?.dev) && Number.isInteger(image.parent?.ino) + && fileId(image.parent?.dev) !== null && fileId(image.parent?.ino) !== null )); } diff --git a/src/lib/host-alignment.mjs b/src/lib/host-alignment.mjs index ab9867c9..b1757f5c 100644 --- a/src/lib/host-alignment.mjs +++ b/src/lib/host-alignment.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; +import { fileId } from './file-identity.mjs'; import { writeFileWithBackup } from './file-write.mjs'; import { inspectCodexTomlStructure, isTomlTableLine } from './codex-toml-safety.mjs'; import { enabledPluginRefs } from './codex-plugins.mjs'; @@ -58,13 +59,13 @@ function uniqueJson(source) { } function readSource(file) { - const stat = fs.lstatSync(file); + const stat = fs.lstatSync(file, { bigint: true }); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024) throw new Error('configuration is not a bounded regular file'); const bytes = fs.readFileSync(file); const source = bytes.toString('utf8'); if (!bytes.equals(Buffer.from(source))) throw new Error('configuration is not UTF-8'); - return { file, source, digest: hash(bytes), mode: stat.mode & 0o777, - identity: { real: fs.realpathSync(file), device: stat.dev, inode: stat.ino, mode: stat.mode } }; + return { file, source, digest: hash(bytes), mode: Number(stat.mode & 0o777n), + identity: { real: fs.realpathSync(file), device: fileId(stat.dev), inode: fileId(stat.ino), mode: Number(stat.mode) } }; } function safeJsonTransport(entry) { diff --git a/src/lib/host-health-evidence.mjs b/src/lib/host-health-evidence.mjs index 600b17ed..2d2cd983 100644 --- a/src/lib/host-health-evidence.mjs +++ b/src/lib/host-health-evidence.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { createHmac, randomBytes } from 'node:crypto'; import { hostHealthInputPaths } from './paths.mjs'; +import { fileId, statMtimeMs } from './file-identity.mjs'; export function createHostHealthSnapshot({ secret = randomBytes(32), env = process.env, inputPaths = hostHealthInputPaths } = {}) { return ({ cwd, cfg }) => { @@ -35,9 +36,9 @@ export function createHostHealthSnapshot({ secret = randomBytes(32), env = proce for (const dir of (env.PATH ?? '').split(path.delimiter).slice(0, 256)) { const file = path.resolve(dir, host + (process.platform === 'win32' ? '.cmd' : '')); try { - const st = fs.statSync(file); + const st = fs.statSync(file, { bigint: true }); if (!st.isFile()) continue; - hash.update(JSON.stringify([host, fs.realpathSync(file), st.size, st.mtimeMs, st.ino])); + hash.update(JSON.stringify([host, fs.realpathSync(file), Number(st.size), statMtimeMs(st), fileId(st.dev), fileId(st.ino)])); break; } catch { /* next PATH entry */ } } diff --git a/src/lib/live/jsonl-tailer.mjs b/src/lib/live/jsonl-tailer.mjs index 8a3c3663..610abc44 100644 --- a/src/lib/live/jsonl-tailer.mjs +++ b/src/lib/live/jsonl-tailer.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import { StringDecoder } from 'node:string_decoder'; +import { fileId } from '../file-identity.mjs'; const limit = (value, ceiling) => Number.isSafeInteger(value) && value > 0 ? Math.min(value, ceiling) : ceiling; @@ -70,7 +71,7 @@ export class JsonlTailer { reconcile() { if (!this.canRead(this.#file)) return; let stat; - try { stat = fs.statSync(this.#file); } catch (error) { + try { stat = fs.statSync(this.#file, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') this.#absent(); else this.#unreadable(error); return; @@ -82,31 +83,32 @@ export class JsonlTailer { })); return; } - const identity = `${stat.dev}:${stat.ino}`; + const identity = `${fileId(stat.dev)}:${fileId(stat.ino)}`; + const size = Number(stat.size); if (this.#identity == null) { this.#identity = identity; // A file that appeared after tailing began holds only new records, so // neither a resume offset nor startAtEnd may skip any of it. if (this.#absentSeen) this.#offset = 0; - else if (this.startOffset != null) this.#offset = Math.min(this.startOffset, stat.size); - else if (this.startAtEnd) this.#offset = stat.size; - } else if (this.#identity !== identity || stat.size < this.#offset) { + else if (this.startOffset != null) this.#offset = Math.min(this.startOffset, size); + else if (this.startAtEnd) this.#offset = size; + } else if (this.#identity !== identity || size < this.#offset) { this.#identity = identity; this.#resetPosition(); } - if (stat.size <= this.#offset) { + if (size <= this.#offset) { // Nothing new to read. Prove readability anyway when it is not yet known // (or was lost), so a mode-000 file cannot pass as healthy by staying // the same size. if (this.#presence !== 'readable') this.#probeReadable(); - this.#coverage(stat.size); + this.#coverage(size); return; } try { const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0); const fd = fs.openSync(this.#file, flags); try { - let remaining = Math.min(stat.size - this.#offset, this.maxReadBytes); + let remaining = Math.min(size - this.#offset, this.maxReadBytes); const bytes = Buffer.alloc(Math.min(remaining, this.maxChunkBytes)); while (remaining > 0) { const count = fs.readSync(fd, bytes, 0, Math.min(bytes.length, remaining), this.#offset); @@ -118,7 +120,7 @@ export class JsonlTailer { } finally { fs.closeSync(fd); } this.#presence = 'readable'; } catch (error) { this.#unreadable(error); } - this.#coverage(stat.size); + this.#coverage(size); } #resetPosition() { diff --git a/src/lib/live/transcript-streams.mjs b/src/lib/live/transcript-streams.mjs index 32dee5a4..32a41201 100644 --- a/src/lib/live/transcript-streams.mjs +++ b/src/lib/live/transcript-streams.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileId } from '../file-identity.mjs'; import { JsonlTailer } from './jsonl-tailer.mjs'; import { LiveReplayStream } from './replay-stream.mjs'; import { @@ -155,12 +156,13 @@ class TranscriptStream { this.#host = host; this.#sessionId = sessionId; this.#options = options; - const epoch = fs.statSync(file).ino; + const stat = fs.statSync(file, { bigint: true }); + const epoch = fileId(stat.ino); this.#stream = new LiveReplayStream({ capacity: options.replayCapacity, prefix: `tx-${host}-${sessionId}-${epoch}`, }); - const offset = fs.statSync(file).size; + const offset = Number(stat.size); const history = tailLines(file, offset, options.maxHistoryBytes, options.maxHistoryRecords); const candidates = []; for (const raw of history.lines) { diff --git a/src/lib/maintenance/discovery/partitions.mjs b/src/lib/maintenance/discovery/partitions.mjs index d2b249a3..6066d605 100644 --- a/src/lib/maintenance/discovery/partitions.mjs +++ b/src/lib/maintenance/discovery/partitions.mjs @@ -5,14 +5,15 @@ // partition is executed as one or more `observeWalkForest` calls. import fs from 'node:fs'; import path from 'node:path'; +import { fileId, sameFileId, statMtimeMs } from '../../file-identity.mjs'; const DEFAULT_MAX_FANOUT = 64; function stampFor(target, fsImpl) { try { - const stat = fsImpl.lstatSync(target); + const stat = fsImpl.lstatSync(target, { bigint: true }); return { - target, mtimeMs: stat.mtimeMs, ino: Number(stat.ino) || null, size: stat.isFile() ? stat.size : null, + target, mtimeMs: statMtimeMs(stat), ino: fileId(stat.ino), size: stat.isFile() ? Number(stat.size) : null, }; } catch (error) { return { target, mtimeMs: null, ino: null, size: null, reason: error?.code ?? 'io' }; @@ -110,6 +111,6 @@ export function mergeWalkResults(results) { export function partitionDrifted(partition, { fsImpl = fs } = {}) { return partition.sourceStamps.some((recorded) => { const current = stampFor(recorded.target, fsImpl); - return current.mtimeMs !== recorded.mtimeMs || current.ino !== recorded.ino || current.size !== recorded.size; + return current.mtimeMs !== recorded.mtimeMs || !sameFileId(current.ino, recorded.ino) || current.size !== recorded.size; }); } diff --git a/tests/kit/persisted-file-identity.test.mjs b/tests/kit/persisted-file-identity.test.mjs new file mode 100644 index 00000000..f7142623 --- /dev/null +++ b/tests/kit/persisted-file-identity.test.mjs @@ -0,0 +1,196 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { tempDir } from './helpers/temp-dir.mjs'; +import { planPartitions, partitionDrifted } from '../../src/lib/maintenance/discovery/partitions.mjs'; +import { inspectHookTarget, atomicReplaceHookTarget } from '../../src/lib/hook-remediation/fs-port.mjs'; +import { JsonlTailer } from '../../src/lib/live/jsonl-tailer.mjs'; +import { createHostHealthSnapshot } from '../../src/lib/host-health-evidence.mjs'; +import { HOOK_HEAL_RECEIPT_SCHEMA, readHookReceipt, writeHookReceipt } from '../../src/lib/hook-remediation/store.mjs'; +import { inspectHostAlignment } from '../../src/lib/host-alignment.mjs'; +import { TranscriptStreams } from '../../src/lib/live/transcript-streams.mjs'; + +const A = 9007199254740992n; +const B = 9007199254740993n; + +test('Discovery stamp survives JSON with adjacent 64-bit inodes and fractional mtime', (t) => { + const root = tempDir('ak-partition-id', t); + const lstatSync = fs.lstatSync; + let ino = A; + const fsImpl = { ...fs, lstatSync(target, options) { + const stat = lstatSync(target, options); + stat.ino = options?.bigint ? ino : Number(ino); + return stat; + } }; + const stamp = planPartitions(root, { fsImpl }).partitions[0].sourceStamps[0]; + assert.equal(stamp.ino, '9007199254740992'); + assert.equal(typeof stamp.mtimeMs, 'number'); + assert.equal(stamp.mtimeMs, lstatSync(root).mtimeMs); + const restored = JSON.parse(JSON.stringify(stamp)); + assert.equal(partitionDrifted({ sourceStamps: [{ ...restored, ino: Number(A) }] }, { fsImpl }), true, + 'unsafe legacy number cannot authorize a match'); + ino = B; + assert.equal(partitionDrifted({ sourceStamps: [restored] }, { fsImpl }), true); + for (const malformed of ['-1', '01', -1]) { + assert.equal(partitionDrifted({ sourceStamps: [{ ...restored, ino: malformed }] }, { fsImpl }), true); + } + ino = 42n; + const safeStamp = planPartitions(root, { fsImpl }).partitions[0].sourceStamps[0]; + assert.equal(partitionDrifted({ sourceStamps: [{ ...safeStamp, ino: 42 }] }, { fsImpl }), false); +}); + +test('hook target parent identity survives JSON and refuses adjacent replacement', (t) => { + const root = tempDir('ak-hook-parent-id', t); + const file = path.join(root, 'hook.json'); + fs.writeFileSync(file, '{}'); + const statSync = fs.statSync; + let ino = A; + const fsImpl = { ...fs, statSync(target, options) { + const stat = statSync(target, options); + if (target === root) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + } }; + const snapshot = inspectHookTarget(file, root, { fsImpl }); + assert.equal(snapshot.parent.ino, '9007199254740992'); + const savedParent = JSON.parse(JSON.stringify(snapshot.parent)); + ino = B; + assert.throws(() => atomicReplaceHookTarget({ ...snapshot, parent: savedParent }, Buffer.from('[]'), undefined, { fsImpl }), + /target parent changed/); + assert.equal(fs.readFileSync(file, 'utf8'), '{}'); +}); + +test('hook snapshot gets identity and fractional mtime from one descriptor stat', (t) => { + const root = tempDir('ak-hook-one-stat', t); + const file = path.join(root, 'hook.json'); + fs.writeFileSync(file, '{}'); + const fstatSync = fs.fstatSync; + let calls = 0; + const fsImpl = { ...fs, fstatSync(fd, options) { + calls++; + assert.equal(options?.bigint, true); + const stat = fstatSync(fd, options); + stat.ino = A; + return stat; + }, lstatSync(target, options) { + const stat = fs.lstatSync(target, options); + if (target === file) stat.ino = A; + return stat; + } }; + const snapshot = inspectHookTarget(file, root, { fsImpl }); + assert.equal(calls, 1); + assert.equal(snapshot.mtimeMs, fs.statSync(file).mtimeMs); +}); + +test('JSONL replacement with a colliding Number inode resets offset and emits new records', (t) => { + const root = tempDir('ak-tailer-id', t); + const file = path.join(root, 'events.jsonl'); + fs.writeFileSync(file, '{"a":1}\n'); + const statSync = fs.statSync; + let ino = A; + t.mock.method(fs, 'statSync', (target, options) => { + const stat = statSync(target, options); + if (target === file) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + }); + const records = []; + const tailer = new JsonlTailer(file, { onRecord: record => records.push(record) }); + tailer.reconcile(); + fs.writeFileSync(file, '{"b":2}\n'); + ino = B; + tailer.reconcile(); + assert.deepEqual(records, [{ a: 1 }, { b: 2 }]); +}); + +test('host health evidence changes when adjacent 64-bit launcher inode changes', (t) => { + const root = tempDir('ak-health-id', t); + const launcher = path.join(root, 'codex'); + fs.writeFileSync(launcher, 'launcher'); + const statSync = fs.statSync; + let ino = A; + t.mock.method(fs, 'statSync', (target, options) => { + const stat = statSync(target, options); + if (target === launcher) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + }); + const snapshot = createHostHealthSnapshot({ env: { PATH: root }, inputPaths: () => [] }); + const before = snapshot({ cwd: root, cfg: {} }).key; + ino = B; + assert.notEqual(snapshot({ cwd: root, cfg: {} }).key, before); +}); + +test('hook receipt accepts exact parent IDs but refuses unsafe legacy Numbers', (t) => { + const root = tempDir('ak-receipt-id', t); + fs.chmodSync(root, 0o700); + const id = 'tx-2026-09-29T00-00-00.000Z-0123456789abcdef'; + const dir = path.join(root, id); + fs.mkdirSync(dir, { mode: 0o700 }); + const file = path.join(dir, 'receipt.json'); + const digest = 'a'.repeat(64); + const receipt = { + schemaVersion: HOOK_HEAL_RECEIPT_SCHEMA, id, createdAt: new Date().toISOString(), + status: 'prepared', planDigest: digest, auditId: 'audit', + authorization: { mechanism: 'explicit-action-selection', actionIds: ['one'], trustMutationAuthorized: false }, + actions: [{ + id: 'one', host: 'codex', hostVersion: '1', recipeId: 'recipe', profileId: 'profile', + target: path.join(root, 'hook'), containmentRoot: root, classification: 'safe-automatic', state: 'prepared', + preimage: { sha256: digest, size: 2, mode: 0o600, modeSupported: true, + uid: null, gid: null, specialMode: 0, parent: { realPath: root, dev: '1', ino: '9007199254740993' } }, + postimage: { sha256: digest, size: 2, mode: 0o600, modeSupported: true }, + backup: { relative: path.join('backups', '0000.bin'), sha256: digest, size: 2 }, + }], + }; + writeHookReceipt(file, receipt); + assert.equal(readHookReceipt(root, id).receipt.actions[0].preimage.parent.ino, '9007199254740993'); + receipt.actions[0].preimage.parent.ino = Number(B); + writeHookReceipt(file, receipt); + assert.throws(() => readHookReceipt(root, id), /receipt action image is invalid/); + for (const malformed of ['-1', '01', -1]) { + receipt.actions[0].preimage.parent.ino = malformed; + writeHookReceipt(file, receipt); + assert.throws(() => readHookReceipt(root, id), /receipt action image is invalid/); + } + receipt.actions[0].preimage.parent.ino = 42; + writeHookReceipt(file, receipt); + assert.equal(readHookReceipt(root, id).receipt.actions[0].preimage.parent.ino, 42); +}); + +test('host alignment snapshot serializes adjacent file IDs distinctly', (t) => { + const root = tempDir('ak-align-id', t); + const file = path.join(root, '.mcp.json'); + fs.writeFileSync(file, '{}'); + const lstatSync = fs.lstatSync; + let ino = A; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = lstatSync(target, options); + if (target === file) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + }); + const options = { home: root, codexHome: path.join(root, '.codex'), projectRoots: [root] }; + const before = inspectHostAlignment(options); + assert.equal(before.snapshots.find(s => s.file === file).identity.inode, '9007199254740992'); + ino = B; + assert.notEqual(inspectHostAlignment(options).digest, before.digest); +}); + +test('transcript replay cursor distinguishes adjacent 64-bit file epochs', (t) => { + const root = tempDir('ak-transcript-id', t); + const file = path.join(root, 'session-1.jsonl'); + fs.writeFileSync(file, ''); + const statSync = fs.statSync; + let ino = A; + t.mock.method(fs, 'statSync', (target, options) => { + const stat = statSync(target, options); + if (target === file) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + }); + const first = new TranscriptStreams({ roots: { claude: root }, mask: value => value }); + const before = first.open('claude', 'session-1').snapshot().cursor; + first.close(); + ino = B; + const second = new TranscriptStreams({ roots: { claude: root }, mask: value => value }); + const after = second.open('claude', 'session-1').snapshot().cursor; + second.close(); + assert.notEqual(before, after); +}); From db1aefc422c994a8db8c8bbed3fbcfb7981e1624 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:56:42 -0700 Subject: [PATCH 45/54] test(ruflo): await natural closure for EOF diagnostics --- .../ruflo-windows-diagnostic-process.test.mjs | 59 +++++++++++++++++++ .../live/ruflo-windows-diagnostic-process.mjs | 6 +- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/kit/ruflo-windows-diagnostic-process.test.mjs b/tests/kit/ruflo-windows-diagnostic-process.test.mjs index 07d85cbe..d902dcd2 100644 --- a/tests/kit/ruflo-windows-diagnostic-process.test.mjs +++ b/tests/kit/ruflo-windows-diagnostic-process.test.mjs @@ -130,3 +130,62 @@ test('real version, legacy, initialize and taskkill children cannot see a parent if (previous === undefined) delete process.env[key]; else process.env[key] = previous; } }); + +test('EOF response waits for natural pipe closure without racing taskkill', async () => { + let child; + const commands = []; + const { scope, released } = scopeFor((command) => { + commands.push(command); + child = fakeChild({ closes: false }); + queueMicrotask(() => child.stdout.write('initialized')); + setTimeout(() => { child.exitCode = 0; }, 5); + setTimeout(() => child.emit('close', 0, null), 60); + return child; + }); + const result = await scope.launch({ command: 'eof', args: [] }, { + env: {}, timeoutMs: 200, endInput: true, until: (text) => text === 'initialized', + }); + assert.equal(result.matched, true); + assert.equal(result.code, 0); + assert.equal(result.cleanupComplete, true, 'requires observed close, not just exit code'); + assert.equal(result.timedOut, false); + assert.equal(child.killed, false); + assert.deepEqual(commands, ['eof']); + assert.equal(scope.release(), true); + assert.equal(released(), true); +}); + +test('EOF match plus exit code zero without pipe closure retains uncertainty', async () => { + const commands = []; + let child; + const { scope, released } = scopeFor((command) => { + commands.push(command); + child = fakeChild({ closes: false }); + queueMicrotask(() => { child.stdout.write('initialized'); child.exitCode = 0; }); + return child; + }); + const result = await scope.launch({ command: 'eof', args: [] }, { + env: {}, timeoutMs: 30, endInput: true, until: (text) => text === 'initialized', + }); + assert.equal(result.matched, true); + assert.equal(result.timedOut, true); + assert.equal(result.cleanupComplete, false); + assert.deepEqual(commands, ['eof'], 'never target a reaped PID'); + assert.equal(child.unreferenced, true); + assert.equal(child.stdout.destroyed, true); + assert.equal(scope.release(), false); + assert.equal(released(), false); +}); + +test('a real EOF responder exits naturally after its reply before cleanup is accepted', async () => { + const scope = createDiagnosticScope({ acquireHold: () => ({}), releaseHold: () => {} }); + const code = "process.stdin.resume();process.stdin.on('end',()=>{process.stdout.write('initialized');setTimeout(()=>process.exit(0),80)});"; + const result = await scope.launch({ command: process.execPath, args: ['-e', code] }, { + env: {}, timeoutMs: 2000, endInput: true, until: (text) => text === 'initialized', + }); + assert.equal(result.matched, true); + assert.equal(result.code, 0); + assert.equal(result.signal, null, 'no forced termination after the reply'); + assert.equal(result.cleanupComplete, true); + assert.equal(scope.release(), true); +}); diff --git a/tests/live/ruflo-windows-diagnostic-process.mjs b/tests/live/ruflo-windows-diagnostic-process.mjs index 3e38df05..5ec1b20a 100644 --- a/tests/live/ruflo-windows-diagnostic-process.mjs +++ b/tests/live/ruflo-windows-diagnostic-process.mjs @@ -49,11 +49,13 @@ export function createDiagnosticScope({ spawnFn = spawn, platform = process.plat if (endInput) child.stdin.end(); while (!closed && !result.error && !result.overflow && Date.now() - started < timeoutMs) { result.matched = until(result.stdout); - if (result.matched) break; + // EOF comparisons must observe natural closure. Stopping on the + // first response can race a server already exiting after stdin end. + if (result.matched && !endInput) break; await delay(10); } result.matched ||= until(result.stdout); - result.timedOut = !closed && !result.matched && !result.error && !result.overflow; + result.timedOut = !closed && !(result.matched && !endInput) && !result.error && !result.overflow; } catch (error) { result.error = error.message; } finally { From 386efb6356783d027c759fafcc473141431ecb73 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:56:50 -0700 Subject: [PATCH 46/54] test(exec): preserve native extensions in PowerShell ownership fixture --- tests/kit/exec.test.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index 707b6103..10a07f16 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -186,6 +186,7 @@ test('Windows abort reaps the Node child behind a PowerShell shim and its grandc }, async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-abort-shim-')); const pidFile = path.join(dir, 'pids.json'); + const shimEntry = path.join(dir, 'powershell-pid'); const controller = new AbortController(); const quotedNode = process.execPath.replaceAll("'", "''"); let pids = []; @@ -195,21 +196,26 @@ test('Windows abort reaps the Node child behind a PowerShell shim and its grandc fs.writeFileSync(path.join(dir, 'codex.cmd'), '@echo off\r\n'); const code = `const {spawn}=require('node:child_process'); const gc=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); - require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); + require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid,process.ppid])); setInterval(()=>{},1000);`; // npm shims forward a script filename; PowerShell 5.1 reserializes // native arguments, so multiline node -e source is not that interface. const script = path.join(dir, 'fixture.cjs'); fs.writeFileSync(script, code); fs.writeFileSync(path.join(dir, 'codex.ps1'), - `& '${quotedNode}' '${script.replaceAll("'", "''")}' $args\nexit $LASTEXITCODE\n`); + `[System.IO.File]::WriteAllText('${shimEntry.replaceAll("'", "''")}',[string]$PID)\n` + + `& '${quotedNode}' '${script.replaceAll("'", "''")}' $args\nexit $LASTEXITCODE\n`); pending = run('codex', [], { - env: { PATH: dir, PATHEXT: '.CMD' }, signal: controller.signal, timeout: 10_000, + // PowerShell checks PATHEXT even for the absolute Node.exe path. + // Excluding .EXE changes native execution into document activation. + env: { PATH: dir, PATHEXT: '.COM;.EXE;.BAT;.CMD' }, signal: controller.signal, timeout: 10_000, }); pending.then((result) => { outcome = result; }); assert.equal(await waitUntil(() => fs.existsSync(pidFile) || outcome), true, 'PowerShell launch settled or ready'); + assert.equal(fs.existsSync(shimEntry), true, `PowerShell entered owned shim: ${JSON.stringify(outcome)}`); assert.equal(fs.existsSync(pidFile), true, `PowerShell launched Node: ${JSON.stringify(outcome)}`); pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + assert.equal(pids[2], Number(fs.readFileSync(shimEntry, 'utf8')), 'Node is a child of the observed PowerShell shim'); assert.equal(isAlive(pids[1]), true); controller.abort(); const result = await pending; From 00a6faaa2622396309a231ef794628c856fc1071 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 06:03:22 -0700 Subject: [PATCH 47/54] test(exec): keep reported parent out of cleanup authority --- tests/kit/exec.test.mjs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index 10a07f16..770ff176 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -181,6 +181,28 @@ test('run() enforces maxBuffer in UTF-8 bytes', async () => { assert.match(result.stderr, /maxBuffer/i); }); +function recordShimChildren(reported, observedShimPid, cleanupPids) { + // The reported parent is assertion evidence, never kill authority. + cleanupPids.push(reported[0], reported[1], observedShimPid); + assert.equal(reported[2], observedShimPid, 'Node is a child of the observed PowerShell shim'); +} + +test('a mismatched reported parent never becomes a fixture cleanup kill target', () => { + const cleanupPids = []; + const killTargets = []; + const unexpectedParent = 990003; + // Synthetic PIDs and an injected recording function: no real process signal. + const kill = (pid) => { killTargets.push(pid); }; + try { + assert.throws(() => recordShimChildren([990001, 990002, unexpectedParent], 990004, cleanupPids), + /Node is a child of the observed PowerShell shim/); + } finally { + for (const pid of cleanupPids) kill(pid); + } + assert.equal(killTargets.includes(unexpectedParent), false, 'unowned reported parent must never be signalled'); + assert.deepEqual(killTargets, [990001, 990002, 990004]); +}); + test('Windows abort reaps the Node child behind a PowerShell shim and its grandchild', { skip: process.platform !== 'win32', }, async () => { @@ -189,7 +211,7 @@ test('Windows abort reaps the Node child behind a PowerShell shim and its grandc const shimEntry = path.join(dir, 'powershell-pid'); const controller = new AbortController(); const quotedNode = process.execPath.replaceAll("'", "''"); - let pids = []; + const pids = []; let pending; let outcome; try { @@ -214,8 +236,8 @@ test('Windows abort reaps the Node child behind a PowerShell shim and its grandc assert.equal(await waitUntil(() => fs.existsSync(pidFile) || outcome), true, 'PowerShell launch settled or ready'); assert.equal(fs.existsSync(shimEntry), true, `PowerShell entered owned shim: ${JSON.stringify(outcome)}`); assert.equal(fs.existsSync(pidFile), true, `PowerShell launched Node: ${JSON.stringify(outcome)}`); - pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); - assert.equal(pids[2], Number(fs.readFileSync(shimEntry, 'utf8')), 'Node is a child of the observed PowerShell shim'); + const reported = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + recordShimChildren(reported, Number(fs.readFileSync(shimEntry, 'utf8')), pids); assert.equal(isAlive(pids[1]), true); controller.abort(); const result = await pending; From d47452e91ee8c445b1d103b5ba792cc791c6e0ec Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 06:16:12 -0700 Subject: [PATCH 48/54] fix(live-checks): report skipped deja-vu and clean proof temp dirs --- src/lib/live-checks.mjs | 19 ++-- tests/kit/deja-vu-teardown-verify.test.mjs | 2 +- tests/kit/live-checks.test.mjs | 111 ++++++++++++++++++++- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/lib/live-checks.mjs b/src/lib/live-checks.mjs index e781595a..9553008a 100644 --- a/src/lib/live-checks.mjs +++ b/src/lib/live-checks.mjs @@ -200,11 +200,14 @@ export async function verifyMemory({ fail(`memory proof error: ${e.message}`); return false; } finally { - if (stored && !purged) { - await runner('ruflo', ['memory', 'purge', '--namespace', namespace, '--force'], - { cwd: tmp, env, timeout: 120_000 }); + try { + if (stored && !purged) { + await runner('ruflo', ['memory', 'purge', '--namespace', namespace, '--force'], + { cwd: tmp, env, timeout: 120_000 }); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); } - fs.rmSync(tmp, { recursive: true, force: true }); } } @@ -463,10 +466,10 @@ async function verifyProjectProviders(root, cfg, { runner, haveCmd }) { * bridge derives agentdb-memory.db from (CLAUDE_FLOW_MEMORY_PATH), and * AGENTDB_PATH. An inherited value of any of them would otherwise receive the * proof rows. Nothing is seeded: the proof is Ruflo's own verbs succeeding. */ -export async function verifyHarvest({ runner = runCmd, haveCmd = have } = {}) { +export async function verifyHarvest({ tmpRoot = os.tmpdir(), runner = runCmd, haveCmd = have } = {}) { heading('harvest — record an outcome and distill, in an isolated store'); if (!(await haveCmd('ruflo'))) { fail('ruflo CLI not installed — cannot prove the harvest write path'); return false; } - const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'agentic-kit-harvest-'))); + const tmp = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'agentic-kit-harvest-'))); const swarm = path.join(tmp, '.swarm'); // Pinned explicitly: a temporary folder inside a Git checkout would make the // derived project root the enclosing repository. @@ -589,7 +592,7 @@ export async function verifyDejaVu({ const enabled = cfg?.integrations?.tools?.dejaVu?.enabled === true; if (!dejaVuProofApplies(cfg)) { warn('deja-vu disabled and unowned — skipped'); - return true; + return { status: 'skipped', reason: 'disabled and unowned' }; } if (!adapter) { fail('deja-vu lifecycle adapter unavailable'); @@ -735,7 +738,7 @@ export async function runLiveChecks({ }))); checks.forEach((check, i) => { const evidenceId = check.evidenceId === undefined ? check.id : check.evidenceId; - if (!results[i].applies) return; + if (!results[i].applies || results[i].status === 'skipped') return; if (check.id === 'memory-routes') { remember('memory', results[i].cliOutcome ?? results[i]); if (routeKeys[i] !== liveCheckInputsKey('memory-routes', { cfg, cwd })) { diff --git a/tests/kit/deja-vu-teardown-verify.test.mjs b/tests/kit/deja-vu-teardown-verify.test.mjs index 8f5041cf..c36e0ca2 100644 --- a/tests/kit/deja-vu-teardown-verify.test.mjs +++ b/tests/kit/deja-vu-teardown-verify.test.mjs @@ -80,7 +80,7 @@ test('deja-vu verify cleanly skips disabled, unowned integration without probing const { result, out } = await captureLog(() => verify.verifyDejaVu({ cfg: cfg({ enabled: false }), adapter, })); - assert.equal(result, true); + assert.deepEqual(result, { status: 'skipped', reason: 'disabled and unowned' }); assert.deepEqual(calls, []); assert.match(out, /disabled and unowned — skipped/); }); diff --git a/tests/kit/live-checks.test.mjs b/tests/kit/live-checks.test.mjs index a9eb9248..ad5db1a5 100644 --- a/tests/kit/live-checks.test.mjs +++ b/tests/kit/live-checks.test.mjs @@ -222,9 +222,11 @@ test('a named check that does not apply says on its line that its result is not assert.match(out, /^⚠ {2}mcp failed \(20 ms\) — effective Codex MCP inventory unavailable; not remembered: this check does not apply to your setup$/m); assert.match(out, /^✓ security passed \(30 ms\)$/m, 'a check that applies says nothing more'); const skipped = await runStatus({ refresh: 'live', only: ['deja-vu'] }, [ - { id: 'deja-vu', status: 'passed', reason: null, elapsedMs: 2, entries: [], applies: false }, + { id: 'deja-vu', status: 'skipped', reason: 'disabled and unowned', elapsedMs: 2, entries: [], applies: false }, ]); - assert.match(skipped.out, /^✓ deja-vu passed \(2 ms\) — not remembered: this check does not apply to your setup$/m); + assert.equal(skipped.code, 1, 'a named skipped proof did not pass'); + assert.match(skipped.out, /Running live checks \(\d+ ms\): 1 skipped/); + assert.match(skipped.out, /^⚠ {2}deja-vu skipped \(2 ms\) — disabled and unowned; not remembered: this check does not apply to your setup$/m); }); test('one line per check; with --only each check\'s own lines are indented under it', async () => { @@ -677,11 +679,114 @@ test('a skipped deja-vu proof is not remembered as a pass', async () => { seedHome(); rmrf(evidence.liveCheckDir()); const [r] = await runOnly(['deja-vu']); - assert.equal(r.status, 'passed'); + assert.equal(r.status, 'skipped'); + assert.equal(r.reason, 'disabled and unowned'); assert.match(texts(r), /deja-vu disabled and unowned — skipped/); assert.equal(evidence.readLiveCheck('deja-vu', {}), null); }); +test('a skipped applicable check never writes conformance evidence', async () => { + seedHome(); + rmrf(evidence.liveCheckDir()); + const [result] = await live.runLiveChecks({ cfg: offlineKitConfig(), cwd: PROJECT, + checks: [{ id: 'deja-vu', evidenceId: 'deja-vu', applies: () => true, + run: async () => ({ status: 'skipped', reason: 'not installed' }) }] }); + assert.equal(result.status, 'skipped'); + assert.equal(evidence.readLiveCheck('deja-vu', {}), null); +}); + +test('learning removes only its call-owned folder after success, missing artifacts, thrown runner, and abort', async (t) => { + const tmpRoot = privateRoot(t); + const unrelated = path.join(tmpRoot, 'unrelated'); + fs.mkdirSync(unrelated); + const cases = [ + async (_cmd, _args, { cwd }) => { + const neural = path.join(cwd, '.claude-flow', 'neural'); + fs.mkdirSync(neural, { recursive: true }); + fs.writeFileSync(path.join(neural, 'stats.json'), '{"patternsLearned":1}'); + fs.writeFileSync(path.join(neural, 'patterns.json'), '[{"id":"one"}]'); + return { code: 0, stdout: '', stderr: '' }; + }, + async () => ({ code: 0, stdout: '', stderr: '' }), + async () => { throw new Error('runner failed'); }, + async () => { throw new DOMException('aborted', 'AbortError'); }, + ]; + for (const [i, runner] of cases.entries()) { + const { result } = await captureLog(() => live.verifyLearning({ tmpRoot, runner })); + assert.equal(result, i === 0, `case ${i}`); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated'], `case ${i} left a call-owned folder`); + } +}); + +test('memory removes its call-owned folder even when final purge throws', async (t) => { + const tmpRoot = privateRoot(t); + fs.mkdirSync(path.join(tmpRoot, 'unrelated')); + const runner = async (_cmd, args) => { + if (args[1] === 'init') return { code: 0, stdout: '', stderr: '' }; + if (args[1] === 'store') return { code: 1, stdout: '', stderr: 'store failed' }; + throw new Error('purge failed'); + }; + await assert.rejects(captureLog(() => live.verifyMemory({ tmpRoot, runner, haveCmd: async () => true })), /purge failed/); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated']); +}); + +test('memory removes only its call-owned folder when init throws or is aborted', async (t) => { + const tmpRoot = privateRoot(t); + fs.mkdirSync(path.join(tmpRoot, 'unrelated')); + for (const error of [new Error('runner failed'), new DOMException('aborted', 'AbortError')]) { + const { result } = await captureLog(() => live.verifyMemory({ tmpRoot, + runner: async () => { throw error; }, haveCmd: async () => true })); + assert.equal(result, false); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated']); + } +}); + +test('memory removes its call-owned folder after a successful mocked CLI round trip', async (t) => { + const tmpRoot = privateRoot(t); + fs.mkdirSync(path.join(tmpRoot, 'unrelated')); + let db; + let storedValue; + const runner = async (_cmd, args, { cwd }) => { + if (args[1] === 'init') { + fs.mkdirSync(path.join(cwd, '.swarm')); + db = new DatabaseSync(path.join(cwd, '.swarm', 'memory.db')); + db.exec('CREATE TABLE memory_entries (namespace TEXT, key TEXT)'); + } else if (args[1] === 'store') { + db.prepare('INSERT INTO memory_entries VALUES (?, ?)').run(args[args.indexOf('-n') + 1], args[args.indexOf('-k') + 1]); + storedValue = args[args.indexOf('--value') + 1]; + } else if (args[1] === 'retrieve') { + return { code: 0, stdout: storedValue, stderr: '' }; + } else if (args[1] === 'purge') { + db.exec('DELETE FROM memory_entries'); + db.close(); + } + return { code: 0, stdout: '', stderr: '' }; + }; + const { result } = await captureLog(() => live.verifyMemory({ + tmpRoot, runner, haveCmd: async () => true, observeRoutes: false, + })); + assert.equal(result, true); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated']); +}); + +test('harvest removes only its call-owned folder on success, nonzero result, thrown runner, and abort', async (t) => { + const tmpRoot = privateRoot(t); + fs.mkdirSync(path.join(tmpRoot, 'unrelated')); + for (const mode of ['success', 'nonzero', 'throw', 'abort']) { + const dirs = []; + const runner = async (_cmd, _args, { cwd }) => { + dirs.push(cwd); + if (mode === 'throw') throw new Error('runner failed'); + if (mode === 'abort') throw new DOMException('aborted', 'AbortError'); + return { code: mode === 'nonzero' ? 1 : 0, stdout: '', stderr: '' }; + }; + const { result } = await captureLog(() => live.verifyHarvest({ tmpRoot, runner, haveCmd: async () => true })); + assert.equal(result, mode === 'success', mode); + assert.ok(dirs.length >= 1 && dirs.every((dir) => path.dirname(dir) === tmpRoot), mode); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated'], mode); + } +}); + test('an aqe proof stopped before the embedding request remembers no embedding result', async () => { seedHome(); rmrf(evidence.liveCheckDir()); From abc7401bfcf7586f64797bd677b8be8f88588f52 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 06:43:50 -0700 Subject: [PATCH 49/54] test(identity): correct Windows persisted identity fixtures --- tests/kit/persisted-file-identity.test.mjs | 55 +++++++++++++++++----- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/tests/kit/persisted-file-identity.test.mjs b/tests/kit/persisted-file-identity.test.mjs index f7142623..087f80f8 100644 --- a/tests/kit/persisted-file-identity.test.mjs +++ b/tests/kit/persisted-file-identity.test.mjs @@ -8,7 +8,7 @@ import { planPartitions, partitionDrifted } from '../../src/lib/maintenance/disc import { inspectHookTarget, atomicReplaceHookTarget } from '../../src/lib/hook-remediation/fs-port.mjs'; import { JsonlTailer } from '../../src/lib/live/jsonl-tailer.mjs'; import { createHostHealthSnapshot } from '../../src/lib/host-health-evidence.mjs'; -import { HOOK_HEAL_RECEIPT_SCHEMA, readHookReceipt, writeHookReceipt } from '../../src/lib/hook-remediation/store.mjs'; +import { HOOK_HEAL_RECEIPT_SCHEMA, readHookReceipt, sealHookReceipt } from '../../src/lib/hook-remediation/store.mjs'; import { inspectHostAlignment } from '../../src/lib/host-alignment.mjs'; import { TranscriptStreams } from '../../src/lib/live/transcript-streams.mjs'; @@ -52,15 +52,35 @@ test('hook target parent identity survives JSON and refuses adjacent replacement if (target === root) stat.ino = options?.bigint ? ino : Number(ino); return stat; } }; - const snapshot = inspectHookTarget(file, root, { fsImpl }); + // Exercise the parent guard on every OS before any mutation is allowed. + // This injected branch is not evidence of native Windows atomic replacement. + fsImpl.openSync = (target, flags) => { + assert.equal(flags, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + return fs.openSync(target, flags); + }; + fsImpl.renameSync = () => assert.fail('parent drift must refuse before rename'); + const options = { fsImpl, platform: 'linux' }; + const snapshot = inspectHookTarget(file, root, options); assert.equal(snapshot.parent.ino, '9007199254740992'); const savedParent = JSON.parse(JSON.stringify(snapshot.parent)); ino = B; - assert.throws(() => atomicReplaceHookTarget({ ...snapshot, parent: savedParent }, Buffer.from('[]'), undefined, { fsImpl }), + assert.throws(() => atomicReplaceHookTarget({ ...snapshot, parent: savedParent }, Buffer.from('[]'), undefined, options), /target parent changed/); assert.equal(fs.readFileSync(file, 'utf8'), '{}'); }); +test('Windows hook mutation refuses before accessing the filesystem', (t) => { + const root = tempDir('ak-hook-windows-refusal', t); + const file = path.join(root, 'hook.json'); + fs.writeFileSync(file, '{}'); + const snapshot = inspectHookTarget(file, root, { platform: 'win32' }); + const fsImpl = { lstatSync: () => assert.fail('unsupported mutation must refuse before inspection') }; + assert.throws(() => atomicReplaceHookTarget(snapshot, Buffer.from('[]'), undefined, { fsImpl, platform: 'win32' }), + /hook mutation is unsupported on Windows until replace-existing atomicity is proven/); + assert.equal(fs.readFileSync(file, 'utf8'), '{}'); + assert.deepEqual(fs.readdirSync(root), ['hook.json']); +}); + test('hook snapshot gets identity and fractional mtime from one descriptor stat', (t) => { const root = tempDir('ak-hook-one-stat', t); const file = path.join(root, 'hook.json'); @@ -105,19 +125,26 @@ test('JSONL replacement with a colliding Number inode resets offset and emits ne test('host health evidence changes when adjacent 64-bit launcher inode changes', (t) => { const root = tempDir('ak-health-id', t); - const launcher = path.join(root, 'codex'); + const launcher = path.join(root, process.platform === 'win32' ? 'codex.cmd' : 'codex'); fs.writeFileSync(launcher, 'launcher'); + const observedIds = []; const statSync = fs.statSync; let ino = A; t.mock.method(fs, 'statSync', (target, options) => { const stat = statSync(target, options); - if (target === launcher) stat.ino = options?.bigint ? ino : Number(ino); + if (target === launcher) { + assert.equal(options?.bigint, true); + stat.ino = ino; + observedIds.push(ino); + } return stat; }); - const snapshot = createHostHealthSnapshot({ env: { PATH: root }, inputPaths: () => [] }); + const snapshot = createHostHealthSnapshot({ env: { PATH: root, PATHEXT: '.CMD' }, inputPaths: () => [] }); const before = snapshot({ cwd: root, cfg: {} }).key; + assert.deepEqual(observedIds, [A], 'the fixture must be the launcher fingerprinted by this platform'); ino = B; assert.notEqual(snapshot({ cwd: root, cfg: {} }).key, before); + assert.deepEqual(observedIds, [A, B]); }); test('hook receipt accepts exact parent IDs but refuses unsafe legacy Numbers', (t) => { @@ -141,18 +168,24 @@ test('hook receipt accepts exact parent IDs but refuses unsafe legacy Numbers', backup: { relative: path.join('backups', '0000.bin'), sha256: digest, size: 2 }, }], }; - writeHookReceipt(file, receipt); - assert.equal(readHookReceipt(root, id).receipt.actions[0].preimage.parent.ino, '9007199254740993'); + // Seed sealed on-disk inputs for reader validation. Durable receipt writes + // have a separate contract and require native directory fsync support. + const seedReceipt = () => fs.writeFileSync(file, JSON.stringify(sealHookReceipt(receipt))); + for (const ino of [String(A), String(B)]) { + receipt.actions[0].preimage.parent.ino = ino; + seedReceipt(); + assert.equal(readHookReceipt(root, id).receipt.actions[0].preimage.parent.ino, ino); + } receipt.actions[0].preimage.parent.ino = Number(B); - writeHookReceipt(file, receipt); + seedReceipt(); assert.throws(() => readHookReceipt(root, id), /receipt action image is invalid/); for (const malformed of ['-1', '01', -1]) { receipt.actions[0].preimage.parent.ino = malformed; - writeHookReceipt(file, receipt); + seedReceipt(); assert.throws(() => readHookReceipt(root, id), /receipt action image is invalid/); } receipt.actions[0].preimage.parent.ino = 42; - writeHookReceipt(file, receipt); + seedReceipt(); assert.equal(readHookReceipt(root, id).receipt.actions[0].preimage.parent.ino, 42); }); From 257e6940837c20343e161842975bd6b486c7a0dc Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 06:49:01 -0700 Subject: [PATCH 50/54] test(ci): retire completed native integration proof job --- .github/workflows/ci.yml | 96 ---------------------------------------- 1 file changed, 96 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cf09a9c..fb59a044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,99 +138,3 @@ jobs: with: args: "--offline --include-fragments --config lychee.toml README.md CLAUDE.md AGENTS.md 'docker/*.md' 'claude/**/*.md' 'src/templates/**/*.md' 'docs/**/*.md' 'docs/**/*.html'" fail: true - - # Temporary C1 proof: remove this job before merging the feature PR. - c1-native-proof: - name: C1 native proof (${{ matrix.os }}, AQE ${{ matrix.aqe }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - { os: ubuntu-latest, aqe: '3.14.4', memory: true } - - { os: windows-latest, aqe: '3.14.4', memory: true } - - { os: ubuntu-latest, aqe: '3.14.5', memory: false } - - { os: macos-latest, aqe: '3.14.5', memory: false } - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 22 - - name: Configure isolated npm prefix - shell: pwsh - run: | - $root = Join-Path $env:RUNNER_TEMP 'c1-installer-home' - $prefix = Join-Path $root 'npm-prefix' - foreach ($dir in @($root, $prefix, (Join-Path $root 'npm-cache'))) { - New-Item -ItemType Directory -Force -Path $dir | Out-Null - } - foreach ($key in @('HOME','USERPROFILE','XDG_CONFIG_HOME','XDG_STATE_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','APPDATA','LOCALAPPDATA','CODEX_HOME','CLAUDE_CONFIG_DIR','HERMES_HOME','MISE_DATA_DIR','MISE_CONFIG_DIR','MISE_CACHE_DIR')) { - $value = Join-Path $root $key.ToLowerInvariant() - New-Item -ItemType Directory -Force -Path $value | Out-Null - "$key=$value" >> $env:GITHUB_ENV - } - "npm_config_prefix=$prefix" >> $env:GITHUB_ENV - "npm_config_cache=$(Join-Path $root 'npm-cache')" >> $env:GITHUB_ENV - if ($IsWindows) { - "AK_AQE_PACKAGE_ROOT=$(Join-Path $prefix 'node_modules/agentic-qe')" >> $env:GITHUB_ENV - $prefix >> $env:GITHUB_PATH - } else { - "AK_AQE_PACKAGE_ROOT=$(Join-Path $prefix 'lib/node_modules/agentic-qe')" >> $env:GITHUB_ENV - (Join-Path $prefix 'bin') >> $env:GITHUB_PATH - } - - name: Install exact upstream artifacts with native scripts - shell: pwsh - run: | - npm install -g --allow-scripts=ruflo,agentic-qe,@claude-flow/cli,better-sqlite3,hnswlib-node,agentdb,agentic-flow,argon2,onnxruntime-node,sharp,protobufjs,@google/genai,tldjs,vibium ruflo@3.48.0 agentic-qe@${{ matrix.aqe }} - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - node -e "const p=require(process.env.AK_AQE_PACKAGE_ROOT+'/package.json');if(p.version!=='${{ matrix.aqe }}')process.exit(1);console.log(p.name,p.version)" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - ruflo --version - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Source and package receipt - shell: pwsh - run: | - git rev-parse HEAD | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-head.txt') - node -e "const fs=require('fs'),c=require('crypto'),p=require('path'),r=process.env.AK_AQE_PACKAGE_ROOT;for(const f of ['package.json','dist/cli/bundle.js','dist/integrations/ruvector/shared-rvf-adapter.js'])console.log(f,c.createHash('sha256').update(fs.readFileSync(p.join(r,f))).digest('hex'))" | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-source-hashes.txt') - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Native AQE live-lock conformance (POSIX) - if: matrix.os != 'windows-latest' - shell: pwsh - env: - AK_AQE_LOCK_LIVE: '1' - AK_AQE_EXPECTED_VERSION: ${{ matrix.aqe }} - run: | - node scripts/run-tests.mjs exec -- --test tests/live/aqe-live-lock-conformance.test.mjs 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-aqe-live-lock.log') - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $text = Get-Content -Raw (Join-Path $env:RUNNER_TEMP 'c1-aqe-live-lock.log') - if ($text -notmatch '"liveLock":true' -or $text -notmatch '"lockHeld":true') { throw 'AQE proof lacked the native contention JSON receipt' } - - name: Diagnose Windows Ruflo transport - if: runner.os == 'Windows' - shell: pwsh - env: - AK_RUFLO_WINDOWS_DIAGNOSTIC: '1' - run: | - $env:AK_RUFLO_PACKAGE_ROOT = Join-Path $env:npm_config_prefix 'node_modules/ruflo' - node scripts/run-tests.mjs focus tests/live/ruflo-windows-transport.test.mjs 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-windows-transport.log') - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Real Ruflo memory routing (Linux and Windows) - if: matrix.memory - shell: pwsh - run: | - ruflo --version - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - node scripts/run-tests.mjs exec -- --test tests/live/ruflo-memory-routing.test.mjs 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'c1-memory-routing.log') - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $text = Get-Content -Raw (Join-Path $env:RUNNER_TEMP 'c1-memory-routing.log') - if ($text -notmatch '"status":"observed"') { throw 'Ruflo routing proof lacked an observed JSON receipt' } - - name: Upload C1 source-bound proof - if: always() - uses: actions/upload-artifact@v7 - with: - name: c1-native-proof-${{ matrix.os }}-aqe-${{ matrix.aqe }}-${{ github.sha }} - path: | - ${{ runner.temp }}/c1-*.log - ${{ runner.temp }}/c1-head.txt - ${{ runner.temp }}/c1-source-hashes.txt - if-no-files-found: error From f80bc55e2bcc80e0ef29ff35da14fe8b9a12f74b Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 06:54:42 -0700 Subject: [PATCH 51/54] fix(test-runner): compare exact file identities before cleanup --- scripts/run-roots.mjs | 19 ++-- scripts/run-tests.mjs | 6 +- tests/kit/run-root-identities.test.mjs | 139 +++++++++++++++++++++++++ tests/kit/run-tests-runner.test.mjs | 5 +- 4 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 tests/kit/run-root-identities.test.mjs diff --git a/scripts/run-roots.mjs b/scripts/run-roots.mjs index a077cec5..ff4621c2 100644 --- a/scripts/run-roots.mjs +++ b/scripts/run-roots.mjs @@ -46,20 +46,20 @@ export function readOwner(root) { let record = null; try { const file = path.join(root, OWNER_FILE); - const before = fs.lstatSync(file); - if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 - || before.size > MAX_OWNER_BYTES || (currentUid() !== null && before.uid !== currentUid())) { + const before = fs.lstatSync(file, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n + || before.size > BigInt(MAX_OWNER_BYTES) || (currentUid() !== null && before.uid !== BigInt(currentUid()))) { throw Error('unsafe owner file'); } // Bitwise flags treat an unavailable platform constant as zero. fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); - const opened = fs.fstatSync(fd); - if (!opened.isFile() || !sameIdentity(before, opened) || opened.size > MAX_OWNER_BYTES) { + const opened = fs.fstatSync(fd, { bigint: true }); + if (!opened.isFile() || !sameIdentity(before, opened) || opened.size > BigInt(MAX_OWNER_BYTES)) { throw Error('owner file changed at open'); } const bytes = Buffer.alloc(MAX_OWNER_BYTES + 1); const count = fs.readSync(fd, bytes, 0, bytes.length, 0); - if (count > MAX_OWNER_BYTES || !sameIdentity(opened, fs.lstatSync(file))) throw Error('owner file changed at read'); + if (count > MAX_OWNER_BYTES || !sameIdentity(opened, fs.lstatSync(file, { bigint: true }))) throw Error('owner file changed at read'); const parsed = JSON.parse(bytes.subarray(0, count).toString('utf8')); if (validRecord(parsed, root)) record = parsed; } catch { /* Unknown metadata never grants ownership. */ } @@ -184,7 +184,8 @@ export function defaultProbes(_platform = process.platform) { return { listOnly: true, alive: () => null, startedAfter: () => null, completeExit: () => null }; } -function sameIdentity(a, b) { return a.dev === b.dev && a.ino === b.ino && a.ctimeMs === b.ctimeMs; } +// Keep inode/device IDs and change times exact; Number stats can alias distinct files. +function sameIdentity(a, b) { return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs; } /** Revalidate after injected probes; recursive rm can still fail partway through. * The fixture seam is not an installed platform containment implementation. @@ -208,13 +209,13 @@ export function collectAbandonedRoots({ tmpdir, selfRoot, homedir, uid = current const safe = removableRunRoot(root, options); if (!safe.ok) { keep(root, safe.reason); continue; } try { - const identity = fs.lstatSync(root); + const identity = fs.lstatSync(root, { bigint: true }); const owner = readOwner(root); if (!owner) { keep(root, 'owner changed during inspection'); continue; } const proof = proveAbandoned(root, owner, probes); if (!proof.abandoned) { keep(root, proof.reason); continue; } const boundary = removableRunRoot(root, options); - if (!boundary.ok || !sameIdentity(identity, fs.lstatSync(root)) + if (!boundary.ok || !sameIdentity(identity, fs.lstatSync(root, { bigint: true })) || JSON.stringify(owner) !== JSON.stringify(readOwner(root))) { keep(root, 'root or owner changed before removal'); continue; } diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 166a5324..d0587a8d 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -63,13 +63,13 @@ export function runGuarded(commands, { const owner = ownerRecord(); try { writeOwner(tempRoot, owner); } catch (error) { log(`could not record run owner; kept run root ${tempRoot}: ${error.message}`); return 2; } - const identity = fs.lstatSync(tempRoot); + const identity = fs.lstatSync(tempRoot, { bigint: true }); const removeOwnRoot = () => { const safe = removableRunRoot(tempRoot, { tmpdir, homedir, requireOwner: false }); if (!safe.ok) { log(`kept own run root ${tempRoot}: ${safe.reason}`); return false; } try { - const current = fs.lstatSync(tempRoot); - if (current.dev !== identity.dev || current.ino !== identity.ino || current.birthtimeMs !== identity.birthtimeMs) { + const current = fs.lstatSync(tempRoot, { bigint: true }); + if (current.dev !== identity.dev || current.ino !== identity.ino || current.birthtimeNs !== identity.birthtimeNs) { log(`kept own run root ${tempRoot}: directory identity changed`); return false; } fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 3 }); diff --git a/tests/kit/run-root-identities.test.mjs b/tests/kit/run-root-identities.test.mjs new file mode 100644 index 00000000..4b79d17b --- /dev/null +++ b/tests/kit/run-root-identities.test.mjs @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { tempDir } from './helpers/temp-dir.mjs'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; +import { ownerRecord, writeOwner, readOwner, collectAbandonedRoots, OWNER_FILE } from '../../scripts/run-roots.mjs'; +import { runGuarded } from '../../scripts/run-tests.mjs'; + +const first = 2n ** 53n; +const second = first + 1n; +assert.equal(Number(first), Number(second), 'fixture must collide through Number'); + +// Model the OS exposing exact BigInt fields or lossy legacy Number fields. +function identity(stat, options, field, value) { + const exact = options?.bigint === true; + const key = !exact && field.endsWith('Ns') ? field.replace(/Ns$/, 'Ms') : field; + stat[key] = exact ? value : Number(value) / (field.endsWith('Ns') ? 1e6 : 1); + return stat; +} +function fixture(t) { + const parent = fs.realpathSync(tempDir('ak-exact-root', t)); + const root = fs.mkdtempSync(path.join(parent, 'ak-suite-')); + const home = path.join(parent, 'home'); + fs.mkdirSync(home); + writeOwner(root, ownerRecord()); + return { parent, root, home }; +} + +for (const field of ['dev', 'ino', 'ctimeNs']) { + for (const phase of ['open', 'read']) { + test(`owner ${field} collision at ${phase} refuses swapped file`, (t) => { + const { root } = fixture(t); + const file = path.join(root, OWNER_FILE); + const originalLstat = fs.lstatSync; + const originalFstat = fs.fstatSync; + let reads = 0; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = originalLstat(target, options); + return target === file ? identity(stat, options, field, ++reads === 1 ? first : second) : stat; + }); + t.mock.method(fs, 'fstatSync', (fd, options) => + identity(originalFstat(fd, options), options, field, phase === 'open' ? second : first)); + assert.equal(readOwner(root), null); + }); + } + + test(`sibling ${field} collision during injected proof retains root`, (t) => { + const { parent, root, home } = fixture(t); + const original = fs.lstatSync; + let swapped = false; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = original(target, options); + return target === root ? identity(stat, options, field, swapped ? second : first) : stat; + }); + let removals = 0; + const result = collectAbandonedRoots({ tmpdir: parent, homedir: home, log: () => {}, + probes: { alive: () => false, startedAfter: () => false, completeExit: () => { swapped = true; return true; } }, + remove: () => { removals++; }, + }); + assert.equal(removals, 0); + assert.deepEqual(result.removed, []); + assert.match(result.kept[0].reason, /changed/); + assert.ok(fs.existsSync(root)); + }); +} + +for (const field of ['dev', 'ino', 'birthtimeNs']) { + test(`call-owned ${field} collision retains replacement untouched`, (t) => { + const { parent, home } = fixture(t); + const tmpdir = path.join(parent, 'tmp'); + const repoRoot = path.join(home, 'repo'); + fs.mkdirSync(tmpdir); + fs.mkdirSync(repoRoot); + const env = spawnEnv(home); + t.mock.method(os, 'tmpdir', () => tmpdir); + const original = fs.lstatSync; + let swapped = false; + let ownRoot; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = original(target, options); + if (!stat || path.dirname(String(target)) !== tmpdir || !/^ak-suite-[A-Za-z0-9]{6}$/.test(path.basename(String(target)))) return stat; + ownRoot = target; + for (const key of ['dev', 'ino', 'birthtimeNs']) identity(stat, options, key, first); + return identity(stat, options, field, swapped ? second : first); + }); + const messages = []; + const code = runGuarded([], { env, homedir: home, repoRoot, log: (message) => { + messages.push(message); + if (message.startsWith('real-state tripwire:')) { + // Preserve metadata so the ownership/hold checks alone cannot catch this swap. + fs.cpSync(ownRoot, `${ownRoot}-saved`, { recursive: true }); + fs.rmSync(ownRoot, { recursive: true }); + fs.renameSync(`${ownRoot}-saved`, ownRoot); + swapped = true; + } + } }); + assert.equal(code, 4); + assert.match(messages.join('\n'), /directory identity changed/); + assert.ok(fs.existsSync(path.join(ownRoot, OWNER_FILE))); + assert.ok(fs.existsSync(path.join(ownRoot, '.ak-suite-holds'))); + }); +} + +test('unchanged exact owner identity accepts large IDs and preserves numeric attribution', (t) => { + const { root } = fixture(t); + const file = path.join(root, OWNER_FILE); + const lstat = fs.lstatSync; + const fstat = fs.fstatSync; + const patch = (stat, options) => { + for (const field of ['dev', 'ino', 'ctimeNs']) identity(stat, options, field, second); + return stat; + }; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = lstat(target, options); + return target === file ? patch(stat, options) : stat; + }); + t.mock.method(fs, 'fstatSync', (fd, options) => patch(fstat(fd, options), options)); + const owner = readOwner(root); + assert.equal(owner.pid, process.pid); + assert.equal(owner.uid, process.getuid?.() ?? null); + assert.equal(typeof owner.startedAt, 'number'); +}); + +test('exact owner stats reject foreign filesystem UID before opening', (t) => { + if (!process.getuid) return; // Windows has no UID boundary to validate. + const { root } = fixture(t); + const file = path.join(root, OWNER_FILE); + const lstat = fs.lstatSync; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = lstat(target, options); + if (target === file) stat.uid = options?.bigint ? BigInt(process.getuid()) + 1n : process.getuid() + 1; + return stat; + }); + const open = t.mock.method(fs, 'openSync', () => { throw Error('must not open foreign owner'); }); + assert.equal(readOwner(root), null); + assert.equal(open.mock.callCount(), 0); +}); diff --git a/tests/kit/run-tests-runner.test.mjs b/tests/kit/run-tests-runner.test.mjs index 6f7b9a6d..4d7cc9d9 100644 --- a/tests/kit/run-tests-runner.test.mjs +++ b/tests/kit/run-tests-runner.test.mjs @@ -445,7 +445,10 @@ function cleanupProbe(home) { if (own(p)) { ownStats++; if (mode === 'refusal' && ownStats >= 3) stat.isDirectory = () => false; - if (mode === 'identity' && ownStats >= 4) stat.birthtimeMs += 1; + if (mode === 'identity' && ownStats >= 4) { + if (typeof stat.birthtimeNs === 'bigint') stat.birthtimeNs += 1n; + else stat.birthtimeMs += 1; + } } return stat; }; From 44dc4e93158c8afcc07d81d2d84a1d891b2c2177 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 07:02:21 -0700 Subject: [PATCH 52/54] docs(adr): scope self-version retry evidence to checked channels --- ...3-evidence-store-and-refresh-vocabulary.md | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/docs/adr/0063-evidence-store-and-refresh-vocabulary.md b/docs/adr/0063-evidence-store-and-refresh-vocabulary.md index 3f85ff5e..8a0e1752 100644 --- a/docs/adr/0063-evidence-store-and-refresh-vocabulary.md +++ b/docs/adr/0063-evidence-store-and-refresh-vocabulary.md @@ -1,7 +1,7 @@ # ADR-0063 — One evidence store and the refresh vocabulary - **Status:** Accepted -- **Updated:** 2026-09-29 — Branch 6c delivered the dashboard refresh operation and retired GET-started scans +- **Updated:** 2026-09-29 — Branch 6c delivered the dashboard refresh operation and retired GET-started scans; 2026-09-29 V4 A3: self-version retry attempts and last freshness are scoped to checked channels - **Earlier update:** 2026-09-28 — Branch 6b delivered the CLI refresh vocabulary - **Date:** 2026-09-28 - **Deciders:** agentic-kit maintainers @@ -539,25 +539,7 @@ shared evidence envelope. ## Known limitations (recorded, not fixed, by this branch) -1. **Resolved in Branch 6b: the failed-lookup rule is now the same in all four version-drift - functions.** This item originally recorded that `ruvector.mjs`/`ruvnet-brain.mjs`'s `drift()` - could silently drop a known update on a failed forced fetch, unlike `versions.mjs`'s - `driftReport()`/`selfDrift()`. Branch 6b fixed both (`fix(versions): a failed lookup keeps the - cached version and waits one TTL window before retrying`, and its follow-ups), so all four now - share one rule: on a total lookup failure, the cached `latest`/`best`/`installedRelease` value - is kept — never overwritten with `null` — and the TTL stamp (`last`) is restamped, so the next - unforced call waits one more TTL window before retrying (`force` bypasses this and retries - immediately). `observedAt` records the real time a value was last actually observed, not the - time of a failed retry: `ruvector.mjs`'s `drift()` keeps `observedAt: cached.observedAt ?? - cached.last` on failure (`:70`); `ruvnet-brain.mjs`'s `drift()` does the same - (`:288`, `recordedRelease()`); `versions.mjs`'s `driftReport()`'s `lookUpLatest()` restamps - `observedAt` from the prior `last` only for packages that were never individually observed - (`:82`); its `selfDrift()`'s `selfRecord()` restamps on a *total* failure, including one with no - cached candidate at all — only a partial answer (something answered live but did not win), or a - total failure whose cached candidate is unusable (a `next` candidate on a stable install), saves - nothing (`:172-176`). None of the four applies this rule under `record: false` (`ak sync - --dry-run`, ADR-0063's own `record` parameter) or a cache-only read (`cacheOnly: true`, `ak - sync --skip `): both skip the network and the write entirely, by design. +1. **Resolved in Branch 6b, refined in V4 A3: failed version lookups retain recorded evidence without claiming a new observation.** A total failed lookup of managed packages, Brain, or ruvector keeps its cached candidate and restamps `last` for one retry per configured TTL; `observedAt` remains the time that candidate was actually seen. The kit follows that rule when its cached candidate is usable. A partial answer that leaves the kit's cached candidate winning, or a stable install whose cached `next` candidate is unusable, keeps `last`, `observedAt`, and `best` unchanged and separately records `versionCheck.self.attempt` with its time and exact channel tags. Successful and total-failure kit lookups record `lastTags`, the channel scope of `last`; changing from stable to prerelease therefore probes an untried `next` channel even within the prior TTL. Legacy records without `lastTags` are reused for the single `latest` channel or where a `next` winner proves it was checked; a legacy `latest` winner cannot suppress an untried `next`. Malformed or future attempt metadata cannot suppress retries. `force` bypasses freshness. `record: false` permits a lookup without saving its result or attempt, while `cacheOnly: true` performs neither a lookup nor a write. 2. **`globalRoot()`'s `record`-persistence structural fragility** — see "The `npm-global-root` exception" above. 3. **Task 9's dashboard timeout bounds async hangs only** — see "The dashboard poll's two cost From 2915265402b758ddcd73d0dd663db9308637f3b2 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 07:05:27 -0700 Subject: [PATCH 53/54] docs(v4): record bounded C6 checks and accepted work --- docs/host-support.md | 27 ++++++++++++++ docs/plans/2026-09-28-follow-ups-v2.md | 36 +++++++++++++------ .../2026-09-28-remediation-program-v2.md | 5 +++ ...-09-28-remediation-v2-develop-execution.md | 17 +++++++-- .../agentic-dependency-constraints.json | 6 ++-- 5 files changed, 75 insertions(+), 16 deletions(-) diff --git a/docs/host-support.md b/docs/host-support.md index 572d8995..fc445551 100644 --- a/docs/host-support.md +++ b/docs/host-support.md @@ -26,6 +26,24 @@ Claude Code `2.1.222`, Codex CLI `0.146.0`, and OpenCode `1.18.x`. Host and upstream behavior changes quickly; open issues below are a risk snapshot, not a promise that an issue remains open forever. +**2026-09-29 support-window addendum.** Registry metadata at 13:42 UTC listed +Ruflo 3.48.0, Agentic QE 3.14.5, and Codex CLI 0.159.0. In a network-denied, +disposable scan, the installed Ruflo 3.48.0 `security secrets --action scan +--path ` reported one synthetic file scanned and exited 0 without +changing the target. The npm-integrity-verified native Codex 0.159.0 binary +accepted `-s read-only -a never app-server`; an `initialize` request answered +successfully without a provider turn. These are narrow command checks, not +end-to-end host conformance. + +Released AQE 3.14.4 passed disposable live-owner lock checks on macOS and Linux: +the status command and shipped adapter reported `LockHeld` without +`FsyncFailed`, while the holder and storage bytes remained intact. The same +check passed on AQE 3.14.5 on macOS and Linux. Native Windows AQE was not run. +Ruflo 3.48.0 showed CLI-to-MCP and MCP-to-CLI memory visibility on native +Windows with one `memory.db`; the reported backend was sql.js + HNSW with its +native bridge disabled. The earlier Linux result was asymmetric, so these +observations do not establish one cross-platform native-backend guarantee. + The stock OpenCode gateway acceptance test currently covers the stable compatibility window **`>=1.18.18 <1.19.0`**. This is a tested release-line window, not a claim that every future OpenCode release is compatible and not a target for `ak sync` to @@ -144,6 +162,12 @@ The dated upstream risk inventory includes: - hierarchical AgentDB writes can report success without durable persistence ([#2887](https://github.com/ruvnet/ruflo/issues/2887)). +The additional Codex hook-environment fix line remains conditional. At the +2026-09-29 check, [Ruflo #3419](https://github.com/ruvnet/ruflo/issues/3419) +was open with only agentic-kit's Codex source-analysis comment, not a +maintainer-supported answer or a live hook observation. Version tags alone do +not close that evidence gap. + ## Agentic QE support | AQE capability | Claude Code | Codex | OpenCode | @@ -185,6 +209,9 @@ and [exclusive platform initialization](https://github.com/proffesor-for-testing (the 3.14.4 recheck did not exercise `goap_execute`), [RVF recovery loop](https://github.com/proffesor-for-testing/agentic-qe/issues/574), and [local-embedding audit findings](https://github.com/proffesor-for-testing/agentic-qe/issues/615). +The newer 3.14.5 registry version does not establish that the repeated-init +settings rewrite reported in [AQE #778](https://github.com/proffesor-for-testing/agentic-qe/issues/778) +has been fixed; the disposable reproduction used 3.14.4. The Codex QE-Court investigation in [agentic-kit #108](https://github.com/pacphi/agentic-kit/issues/108) is a diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md index 492d40b4..6364424d 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/plans/2026-09-28-follow-ups-v2.md @@ -2,7 +2,21 @@ ## Status -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; B12 is verified and fixed by a test-first unit. B2 and B3 are implemented in the isolated `fix/follow-ups-v2-rest` branch, pending integration; B4 is implemented there, pending independent review. B5's three units are implemented there, pending independent review. C1's released AQE 3.14.4 live-lock probe passed on native macOS and Linux; its exact contention exception is retired in the isolated branch, pending review. Native Windows AQE conformance remains unverified, and the separate Ruflo Windows MCP result remains open. Other rows remain unimplemented. The controller reviews and assigns later rows. One unit commit per row. +**Active, implementation accepted; final V4 gates pending (2026-09-29).** B1 merged in +PR #273. The remaining V4 implementation units are accepted on +`fix/follow-ups-v2-rest` at `3448e25c`, which includes the green `develop@989c5e56` +C4 integration, the reviewed runner identity follow-up `f80bc55e`, and the +independently accepted temporary C1 job removal `257e6940`. V3 dashboard changes +merged in #276; the C3 trace and C4 watch changes merged in #277 and #278. +The A3 ADR handoff and final C6 evidence alignment are being documented in this +branch. B13 required no product fix after the approved conditional check. +B6's extra Codex hook fix line remains deferred pending a Ruflo-supported answer +to #3419. The temporary C1 job has been removed; macOS/Linux AQE live-lock +conformance passed, but native Windows AQE was not run. Native Windows Ruflo +3.48.0 memory visibility was observed with its native bridge disabled. Final +whole-branch review, full local gates, feature PR CI including Windows, and +integration remain pending. This plan stays active; it does not claim main merge, +release, installation, or operational cleanup. The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. @@ -10,26 +24,26 @@ The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-u | --- | --- | --- | | A1 | `bin/agentic-kit.mjs`; `src/commands/usage.mjs`, `models.mjs`, `audit.mjs`, `heal.mjs`, `telemetry.mjs`, `x/host.mjs` | `tests/kit/cli-json-honesty.test.mjs`, `usage-cli.test.mjs`, `models-command.test.mjs`, `telemetry-cli.test.mjs`, `status-command.test.mjs`; include unknown models verb and status positional | | A2 | `src/commands/x/host.mjs`; `bin/agentic-kit.mjs` | `tests/kit/host-dry-run.test.mjs`, `host-cli-migration.test.mjs`; pick refusal, off, reset-routes under `--dry-run --json` | -| A3 | `src/lib/versions.mjs`; `docs/adr/0063-evidence-store-and-refresh-vocabulary.md` | `tests/kit/version-lookup-record.test.mjs`, `drift-freshness.test.mjs`; offline tried-at TTL and ADR wording | +| A3 | `src/lib/versions.mjs`; `docs/adr/0063-evidence-store-and-refresh-vocabulary.md` | Accepted in `175677a6`; `versionCheck.self.attempt` and `lastTags` scope offline retries. ADR-0063 item 1 is amended in this branch; final gates remain pending | | A4 | `src/commands/status.mjs`; `src/lib/refresh.mjs` | `tests/kit/refresh.test.mjs`, `status-version-drift-refresh.test.mjs`; injected `refreshStages` plus `service` builds no collector | | B1 | `src/lib/paths.mjs`; `src/lib/footprint/index.mjs`, `storage.mjs`, `consumers.mjs`, `storage-reclaim-detectors.mjs`, `install.mjs`; `src/lib/host-readiness-local.mjs`, `live/process-sessions.mjs`, `hook-audit/providers/opencode.mjs`, `usage-opencode.mjs`; `src/commands/uninstall.mjs` | `tests/kit/xdg-relative.test.mjs` and specified regressions; exact-head CI gate passed before edit; preserve nullable OpenCode fallback | | B2 | `src/commands/x/daemon-gc.mjs`, `src/commands/x/host.mjs`, `src/commands/setup.mjs` | New `tests/kit/daemon-gc-rerecord.test.mjs`, `setup-host-rerecord.test.mjs`, `host-pick-rerecord.test.mjs`; Branch 9 Task 8 plus deferred host pick; compare `sync-host-repair.test.mjs` | | B3 | `src/lib/ruflo-memory.mjs`, `paths.mjs` | `tests/kit/ruflo-memory-location.test.mjs`, `project-memory-status.test.mjs`; compose both unsuitable reasons and make `inside()` exclude equality | -| B4 | `src/commands/status/sections/project-memory.mjs`; `src/lib/live-check-evidence.mjs`, `live-checks.mjs` | **Implemented, pending review:** distinct `memory-routes` evidence binds installed CLI version and platform; generic `memory` cannot lower the row. Focused evidence, runner, status, and routing tests cover pass, upgrade, failure, timeout, and read-only render. | -| B5 | `src/lib/project-memory.mjs`; `src/commands/status/sections/user-memory.mjs`, `codex-mcp.mjs`; #757 registry entry | **Implemented, pending review:** bounded ordinary dot-folder discovery, read-only AQE home data row, and an AQE-owned init hint. `tests/kit/project-memory.test.mjs`, `project-memory-status.test.mjs`, `ruflo-memory-location.test.mjs`, `status-command.test.mjs` cover the three units. No real store was merged or moved. | -| B6 | `src/commands/status/sections/ruflo-components.mjs` | **Implemented, pending review:** applied-but-unverified keeps its state and meaning in the message and gives one restart/recheck instruction in its manual fix. Rendered-row and neighboring-state tests cover the contract. The Codex-hooks fix line remains conditional on a Ruflo-supported answer to #3419 and a pre-PR recheck. | +| B4 | `src/commands/status/sections/project-memory.mjs`; `src/lib/live-check-evidence.mjs`, `live-checks.mjs` | **Accepted:** distinct `memory-routes` evidence binds installed CLI version and platform; generic `memory` cannot lower the row. Focused evidence, runner, status, and routing tests cover pass, upgrade, failure, timeout, and read-only render. | +| B5 | `src/lib/project-memory.mjs`; `src/commands/status/sections/user-memory.mjs`, `codex-mcp.mjs`; #757 registry entry | **Accepted:** bounded ordinary dot-folder discovery, read-only AQE home data row, and an AQE-owned init hint. `tests/kit/project-memory.test.mjs`, `project-memory-status.test.mjs`, `ruflo-memory-location.test.mjs`, `status-command.test.mjs` cover the three units. No real store was merged or moved. | +| B6 | `src/commands/status/sections/ruflo-components.mjs` | **Accepted:** applied-but-unverified keeps its state and meaning in the message and gives one restart/recheck instruction in its manual fix. Rendered-row and neighboring-state tests cover the contract. The Codex-hooks fix line remains conditional on a Ruflo-supported answer to #3419 and a pre-PR recheck. | | B7 | `src/lib/ruflo-daemon-config.mjs`; `src/commands/sync.mjs`, `sync/plan-versions.mjs` | `tests/kit/sync-daemon-repair.test.mjs`, `sync-dry-run-preview.test.mjs`, `sync-skip-versions.test.mjs`; F6 hidden YAML keys and F7 versions-only preview parity | | B8 | `src/lib/maintenance/discovery/orchestrator.mjs`, `history.mjs` | `tests/kit/maintenance-discovery-orchestrator.test.mjs`, `maintenance-recovery.test.mjs`; restart after pause shows paused history | | B9 | `src/lib/exec.mjs`, `execution/process-tree.mjs` | `tests/kit/process-tree.test.mjs`; abort kills descendants; Windows CI required | | B10 | `src/lib/maintenance/discovery/partitions.mjs`; inventory `src/lib/live/jsonl-tailer.mjs`, `live/transcript-streams.mjs`, `telemetry/store.mjs`, `maintenance/management/service-store.mjs` for additional persisted IDs | `tests/kit/file-identity-bigint.test.mjs`; distinguish IDs above `2^53`; enumerate the exact sites before edit | | B11 | `src/lib/live-checks.mjs` | `tests/kit/live-checks.test.mjs`; skipped deja-vu check says skipped and check-created temp folders are cleaned | | B12 | `src/commands/setup.mjs`; `src/lib/memory-probe-cleanup.mjs` unchanged | **Fixed:** `tests/kit/setup-memory-probe.test.mjs`; Ruflo 3.48.0 seeded reproduction created an unused native side file, and a disposable candidate run confirmed a private mirror leaves no canonical side file or probe row | -| B13 | `src/commands/sync.mjs`; `src/lib/aqe-project-pin.mjs` | `tests/kit/sync-command.test.mjs`, `aqe-project-pin.test.mjs`; only if program §2 step 2 shows sync omitted the AQE pin | -| C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | **Implemented, pending review:** native macOS and Linux live-owner probes on released AQE 3.14.4 omitted `FsyncFailed`; the exact exception is retired. Ordinary `LockHeld` remains busy, while any `FsyncFailed` fails. The temporary CI job remains until the separate Windows Ruflo MCP investigation finishes; remove it before V4 merge. #240 closure waits for the final main PR. No native Windows AQE conformance is claimed. | +| B13 | `src/commands/sync.mjs`; `src/lib/aqe-project-pin.mjs` | Conditional check found the AQE pin converged across all four targets; no B13 product fix was made | +| C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | **Accepted; temporary CI job removed:** native macOS and Linux live-owner probes on released AQE 3.14.4 omitted `FsyncFailed`; the exact exception is retired. Ordinary `LockHeld` remains busy, while any `FsyncFailed` fails. The temporary CI job was removed in `257e6940` after its evidence was reviewed. #240 closure waits for the final main PR. No native Windows AQE conformance is claimed. | | C2 | `docs/host-support.md`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/ruflo-support-window.test.mjs` plus link check; verify AQE 3.14.4 #528/#532/#535 and Ruflo #2356/#420 first | -| C3 | `.github/workflows/nightly.yml`; vidaunited's `trace-ort.mjs` hook (obtain and verify its exact script path before adding) | `tests/kit/upstream-watch-workflow.test.mjs` plus macOS artifact receipt; exact upstream #2885 post text requires user approval | -| C4 | `scripts/upstream-watch/classify.mjs`, `fetch.mjs`, `ledger.mjs`, `dispatch.mjs`, `render.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/upstream-watch-script.test.mjs`, `upstream-watch-record.test.mjs`, `upstream-watch-dispatch.test.mjs`, `upstream-watch-registry.test.mjs`; use ignored `reports/n5-253-deferred-minors.md` §2 for M7/M8/minors 1–12; M10 declined | -| C5 | `src/lib/aqe-guidance.mjs`; `src/commands/setup.mjs`; ignored `.superpowers/sdd/2026-09-28-follow-ups-v2/c5-issue-draft.md` | D-6 **A approved**: controller's isolated AQE init reproduction is evidence handoff; draft issue with command/version/expected/actual, then obtain approval of exact posting text. B0-16 draft only if D-16 B | -| C6 | `docs/host-support.md`; `src/lib/ruflo-support-window.mjs`, `aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/ruflo-support-window.test.mjs`, `aqe-readiness.test.mjs`; pre-PR live checks against newest supported Ruflo `security secrets --path`, Codex read-only app-server flags, and AQE 3.14.x | +| C3 | `.github/workflows/nightly.yml`; `scripts/trace-ort.mjs` | Merged #277; native macOS trace run 36567908852. Exact approved comment posted and body verified at [Ruflo #2885](https://github.com/ruvnet/ruflo/issues/2885#issuecomment-5891510508). The post-heal trace is noncausal; the learning step remains nonblocking even though its outer command exited 1. | +| C4 | `scripts/upstream-watch/classify.mjs`, `fetch.mjs`, `ledger.mjs`, `dispatch.mjs`, `render.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | Merged #278 as `989c5e56`; develop CI 36578593054 passed all 13 jobs. M10 remained declined. | +| C5 | `src/lib/aqe-guidance.mjs`; `src/commands/setup.mjs`; ignored `.superpowers/sdd/2026-09-28-follow-ups-v2/c5-issue-draft.md` | Approved exact AQE repeated-init issue posted as [#778](https://github.com/proffesor-for-testing/agentic-qe/issues/778); the 3.14.4 disposable repro does not establish 3.14.5 behavior | +| C6 | `docs/host-support.md`; `src/lib/ruflo-support-window.mjs`, `aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | 2026-09-29 13:42 UTC registry: Ruflo 3.48.0, AQE 3.14.5, Codex 0.159.0. Narrow disposable Ruflo one-file scan and integrity-verified native Codex read-only App Server initialize passed; AQE 3.14.4/3.14.5 live-lock proof passed on macOS/Linux. No provider turn or native Windows AQE proof. Final V4 gates and PR CI pending | B1 used disposable homes, guarded focused tests, and the ignored B1 report at `.superpowers/sdd/2026-09-28-follow-ups-v2/b1-report.md`. No shared manifests, lockfiles, ADR index, or decision log change belongs to this plan update. The controller owns integration and the whole-branch gate. diff --git a/docs/plans/2026-09-28-remediation-program-v2.md b/docs/plans/2026-09-28-remediation-program-v2.md index f06ef348..4a6b526d 100644 --- a/docs/plans/2026-09-28-remediation-program-v2.md +++ b/docs/plans/2026-09-28-remediation-program-v2.md @@ -10,6 +10,11 @@ Windows timing evidence. #251 is done, and the alpha.60 release commit is on `ma Publication and global installation were not verified in the execution-plan baseline. The historical decision batch and schedule below remain as scope/evidence references; the approved execution section governs wherever their authority or timing differs. +At the 2026-09-29 checkpoint, V3 #276, V5 #274, V4 B1 #273, V4 C3 #277, and +V4 C4 #278 had merged to `develop@989c5e56`. Remaining V4 implementation +units were accepted on an isolated branch; its final gates, whole-branch +review, PR CI and integration remained open. V6 and V7 still have work. The +final `develop` → `main` PR has not been opened. ### Approved execution and precedence diff --git a/docs/plans/2026-09-28-remediation-v2-develop-execution.md b/docs/plans/2026-09-28-remediation-v2-develop-execution.md index 567dec18..bed7dd78 100644 --- a/docs/plans/2026-09-28-remediation-v2-develop-execution.md +++ b/docs/plans/2026-09-28-remediation-v2-develop-execution.md @@ -12,6 +12,16 @@ unit commits, feature PRs into `develop`, and conditional squash integration; th `develop` → `main` PR remains open for human review. Releases, installation, real-data operations and cleanup remain separately gated. Baseline inspected: `main@94890a00`. +**2026-09-29 execution update:** Bootstrap, V3 #276, V5 #274, V4 B1 #273, +V4 C3 #277, and V4 C4 #278 have merged to `develop@989c5e56`; their relevant +CI receipts passed. Remaining V4 implementation units are accepted on the +isolated branch, and its temporary C1 CI job has been removed. V4's ADR/C6 +alignment is underway. The final V4 full gates, whole-branch review, feature +PR CI including Windows, and integration are still pending. V6 and V7 remain +separate work. The final `develop` → `main` PR and operational gates have not +occurred. Historical baseline rows below describe planning-time state, not +current completion. + **Goal:** Complete all remaining v2 remediation through feature PRs into `develop`, then open one aggregate `develop` → `main` PR for human review. @@ -68,9 +78,10 @@ Appendix A/B row and its referenced v1 plans, rulings and evidence. The original program's "not yet started" status and main-only flow are stale. Reconcile them in the bootstrap PR, retaining historical evidence rather than replaying completed work. -ADR-0063 is **Accepted**, updated 2026-09-28, with CLI delivery recorded; V3 completes its -dashboard changes and V4 its offline retry limitation. ADR-0048 is **Accepted**, updated -2026-09-28, with human evaluation gates outstanding; V3 records their approved v5 deferral. +ADR-0063 is **Accepted**, updated 2026-09-29, with CLI and V3 dashboard delivery +recorded; V4 A3 refines its offline retry limitation on the feature branch. +ADR-0048 is **Accepted**, updated 2026-09-28, with human evaluation gates +outstanding; V3 records their approved v5 deferral. ADR-0060 is **Proposed**, updated 2026-09-27, with discovery partly implemented; V6 implements its approved remaining scope and records acceptance and the actual delivered subset. diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index a75d21ab..bac4266e 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -1129,7 +1129,8 @@ { "date": "2026-09-26", "event": "commented", "note": "tested the single-threaded ONNX Runtime mitigation on Apple Silicon: inconclusive, the abort does not reproduce locally (issuecomment-5850382245)" }, { "date": "2026-09-27", "event": "commented", "note": "hosted probe run 36333572972 on Ruflo 3.46.1: 10/10 aborts by default and 10/10 with single-threaded ONNX Runtime sessions; mitigation disproven (issuecomment-5857781254)" }, { "date": "2026-09-27", "event": "commented", "note": "3.47.0 status, transformers 3.x / ONNX Runtime 1.21 lead, fix approaches; 11/11 nightly aborts in the continue-on-error step (issuecomment-5863246672)" }, - { "date": "2026-09-28", "event": "commented", "note": "thanked vidaunited for the trace hook; 12 of 12 macOS nightly aborts on 3.47.0, with a new WASM backend line before the abort (issuecomment-5878044230)" } + { "date": "2026-09-28", "event": "commented", "note": "thanked vidaunited for the trace hook; 12 of 12 macOS nightly aborts on 3.47.0, with a new WASM backend line before the abort (issuecomment-5878044230)" }, + { "date": "2026-09-29", "event": "commented", "note": "posted the approved source-bound native trace on #2885 (issuecomment-5891510508); the failure occurred after heal, the outer command exited 1, and its learning step remained nonblocking for the job; the trace does not establish a cause or released fix" } ] }, { @@ -1525,7 +1526,8 @@ "history": [ { "date": "2026-09-24", "event": "filed" }, { "date": "2026-09-26", "event": "registered" }, - { "date": "2026-09-27", "event": "commented", "note": "answered from the Codex CLI source (issuecomment-5863289657)" } + { "date": "2026-09-27", "event": "commented", "note": "answered from the Codex CLI source (issuecomment-5863289657)" }, + { "date": "2026-09-29", "event": "reviewed", "note": "still open with only ak's source-analysis comment; no maintainer-supported guidance or live hook proof, so the conditional Codex fix line remains deferred" } ] }, { From f19566adf2d0f0d7027bc4ea45f9f990e2b0355e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 07:12:19 -0700 Subject: [PATCH 54/54] docs(remediation): archive verified V4 follow-ups plan --- .../2026-09-28-plan-follow-ups-v2.md} | 44 +++++++++++-------- docs/archive/README.md | 1 + 2 files changed, 26 insertions(+), 19 deletions(-) rename docs/{plans/2026-09-28-follow-ups-v2.md => archive/2026-09-28-plan-follow-ups-v2.md} (76%) diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/archive/2026-09-28-plan-follow-ups-v2.md similarity index 76% rename from docs/plans/2026-09-28-follow-ups-v2.md rename to docs/archive/2026-09-28-plan-follow-ups-v2.md index 6364424d..fc1c8a5e 100644 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ b/docs/archive/2026-09-28-plan-follow-ups-v2.md @@ -1,30 +1,36 @@ # Follow ups v2: V4 branch plan -## Status +## Status at archival -**Active, implementation accepted; final V4 gates pending (2026-09-29).** B1 merged in -PR #273. The remaining V4 implementation units are accepted on -`fix/follow-ups-v2-rest` at `3448e25c`, which includes the green `develop@989c5e56` -C4 integration, the reviewed runner identity follow-up `f80bc55e`, and the -independently accepted temporary C1 job removal `257e6940`. V3 dashboard changes -merged in #276; the C3 trace and C4 watch changes merged in #277 and #278. -The A3 ADR handoff and final C6 evidence alignment are being documented in this -branch. B13 required no product fix after the approved conditional check. -B6's extra Codex hook fix line remains deferred pending a Ruflo-supported answer -to #3419. The temporary C1 job has been removed; macOS/Linux AQE live-lock -conformance passed, but native Windows AQE was not run. Native Windows Ruflo -3.48.0 memory visibility was observed with its native bridge disabled. Final -whole-branch review, full local gates, feature PR CI including Windows, and -integration remain pending. This plan stays active; it does not claim main merge, -release, installation, or operational cleanup. +**Implementation and local verification complete (2026-09-29).** All eight local +gates passed at `2915265402b758ddcd73d0dd663db9308637f3b2`: 6,159 unit tests passed +with seven skips and no failures, 514 browser assertions plus 15 UI tests passed, +and typecheck, lint, complexity, Markdown, build and offline links passed. +Measured coverage was 94.00% lines, 83.36% branches and 93.27% functions. +Independent whole-branch review found no actionable findings; its additional +focused run passed 131 tests with two Windows-only skips. -The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. +B1 merged in PR #273, V3 dashboard changes in #276, C3 trace in #277 and C4 watch +in #278. This branch includes green `develop@989c5e56`, the reviewed exact runner +identity follow-up `f80bc55e`, temporary C1 job removal `257e6940`, A3 ADR amendment +`44dc4e9` and C6 evidence alignment `29152654`. B13 required no product fix after +the approved conditional check. B6's extra Codex hook fix line remains deferred +pending a Ruflo-supported answer to #3419. + +Native macOS/Linux AQE live-lock conformance passed on the named released +artifacts; native Windows AQE was not run. Native Windows Ruflo 3.48.0 memory +visibility was observed with its native bridge disabled. Final-head feature PR +CI, including the corrected Windows identity fixtures, and squash integration +remain pending at capture. This archive does not claim main merge, release, +installation or operational cleanup. + +The [remediation program V4](../plans/2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. | Row | Source or artifact mapping | Focused proof and prerequisite | | --- | --- | --- | | A1 | `bin/agentic-kit.mjs`; `src/commands/usage.mjs`, `models.mjs`, `audit.mjs`, `heal.mjs`, `telemetry.mjs`, `x/host.mjs` | `tests/kit/cli-json-honesty.test.mjs`, `usage-cli.test.mjs`, `models-command.test.mjs`, `telemetry-cli.test.mjs`, `status-command.test.mjs`; include unknown models verb and status positional | | A2 | `src/commands/x/host.mjs`; `bin/agentic-kit.mjs` | `tests/kit/host-dry-run.test.mjs`, `host-cli-migration.test.mjs`; pick refusal, off, reset-routes under `--dry-run --json` | -| A3 | `src/lib/versions.mjs`; `docs/adr/0063-evidence-store-and-refresh-vocabulary.md` | Accepted in `175677a6`; `versionCheck.self.attempt` and `lastTags` scope offline retries. ADR-0063 item 1 is amended in this branch; final gates remain pending | +| A3 | `src/lib/versions.mjs`; `docs/adr/0063-evidence-store-and-refresh-vocabulary.md` | Accepted in `175677a6`; `versionCheck.self.attempt` and `lastTags` scope offline retries. ADR-0063 item 1 is amended; local gates passed at `29152654`, with final PR CI pending | | A4 | `src/commands/status.mjs`; `src/lib/refresh.mjs` | `tests/kit/refresh.test.mjs`, `status-version-drift-refresh.test.mjs`; injected `refreshStages` plus `service` builds no collector | | B1 | `src/lib/paths.mjs`; `src/lib/footprint/index.mjs`, `storage.mjs`, `consumers.mjs`, `storage-reclaim-detectors.mjs`, `install.mjs`; `src/lib/host-readiness-local.mjs`, `live/process-sessions.mjs`, `hook-audit/providers/opencode.mjs`, `usage-opencode.mjs`; `src/commands/uninstall.mjs` | `tests/kit/xdg-relative.test.mjs` and specified regressions; exact-head CI gate passed before edit; preserve nullable OpenCode fallback | | B2 | `src/commands/x/daemon-gc.mjs`, `src/commands/x/host.mjs`, `src/commands/setup.mjs` | New `tests/kit/daemon-gc-rerecord.test.mjs`, `setup-host-rerecord.test.mjs`, `host-pick-rerecord.test.mjs`; Branch 9 Task 8 plus deferred host pick; compare `sync-host-repair.test.mjs` | @@ -44,6 +50,6 @@ The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-u | C3 | `.github/workflows/nightly.yml`; `scripts/trace-ort.mjs` | Merged #277; native macOS trace run 36567908852. Exact approved comment posted and body verified at [Ruflo #2885](https://github.com/ruvnet/ruflo/issues/2885#issuecomment-5891510508). The post-heal trace is noncausal; the learning step remains nonblocking even though its outer command exited 1. | | C4 | `scripts/upstream-watch/classify.mjs`, `fetch.mjs`, `ledger.mjs`, `dispatch.mjs`, `render.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | Merged #278 as `989c5e56`; develop CI 36578593054 passed all 13 jobs. M10 remained declined. | | C5 | `src/lib/aqe-guidance.mjs`; `src/commands/setup.mjs`; ignored `.superpowers/sdd/2026-09-28-follow-ups-v2/c5-issue-draft.md` | Approved exact AQE repeated-init issue posted as [#778](https://github.com/proffesor-for-testing/agentic-qe/issues/778); the 3.14.4 disposable repro does not establish 3.14.5 behavior | -| C6 | `docs/host-support.md`; `src/lib/ruflo-support-window.mjs`, `aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | 2026-09-29 13:42 UTC registry: Ruflo 3.48.0, AQE 3.14.5, Codex 0.159.0. Narrow disposable Ruflo one-file scan and integrity-verified native Codex read-only App Server initialize passed; AQE 3.14.4/3.14.5 live-lock proof passed on macOS/Linux. No provider turn or native Windows AQE proof. Final V4 gates and PR CI pending | +| C6 | `docs/host-support.md`; `src/lib/ruflo-support-window.mjs`, `aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | 2026-09-29 13:42 UTC registry: Ruflo 3.48.0, AQE 3.14.5, Codex 0.159.0. Narrow disposable Ruflo one-file scan and integrity-verified native Codex read-only App Server initialize passed; AQE 3.14.4/3.14.5 live-lock proof passed on macOS/Linux. No provider turn or native Windows AQE proof. Local V4 gates passed at `29152654`; final PR CI pending | B1 used disposable homes, guarded focused tests, and the ignored B1 report at `.superpowers/sdd/2026-09-28-follow-ups-v2/b1-report.md`. No shared manifests, lockfiles, ADR index, or decision log change belongs to this plan update. The controller owns integration and the whole-branch gate. diff --git a/docs/archive/README.md b/docs/archive/README.md index 559d1038..6e65c9bb 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -68,6 +68,7 @@ reconfirmed by this metadata audit. The per-file inventory and limitations are r | File | Original location | What it was | Why it's historical | |---|---|---|---| +| [2026-09-28-plan-follow-ups-v2.md](2026-09-28-plan-follow-ups-v2.md) | `docs/plans/2026-09-28-follow-ups-v2.md` | V4 product, CLI, memory, process-lifecycle and upstream integration follow-ups. | All eight local gates and independent whole-branch review passed at `29152654`; final-head PR CI and squash integration were pending at archival. Conditional Ruflo #3419 guidance remains deferred; native Windows AQE was not tested. | | [2026-09-29-plan-upstream-watch-followups.md](2026-09-29-plan-upstream-watch-followups.md) | `docs/plans/2026-09-29-upstream-watch-followups.md` | V4 C4 execution plan for bounded PR polling, deterministic retry failures and twelve deferred watcher minors. | Implementation and independent review complete; all eight local gates passed at `b75c1e3f`. Final PR CI and merge were pending at archival. Current contract: [Upstream watch](../upstream-watch.md). | | [2026-06-upstream-findings-f1-f6.md](2026-06-upstream-findings-f1-f6.md) | `docs/upstream/ruflo-self-improvement-findings.md` | The F1–F6 findings series: proofs/refutations of ruflo's self-improvement claims (Q-learning persistence, state-encoder collapse, SONA learn→inference wiring, native-training misreporting), with filed upstream issues. | Every finding is now fixed upstream: F2 in 3.10.6 ([#2222](https://github.com/ruvnet/ruflo/issues/2222)), F2b in 3.10.7, F3 in 3.10.11 ([#2239](https://github.com/ruvnet/ruflo/issues/2239)), F4 in `@ruvector/ruvllm` 2.5.6 ([RuVector#519](https://github.com/ruvnet/RuVector/issues/519)), F6 in 3.18.1/3.19.0 + ruvllm 2.5.7 ([#2549](https://github.com/ruvnet/ruflo/issues/2549), closed 2026-07-03). | | [2026-06-token-consumption-incident.md](2026-06-token-consumption-incident.md) | `docs/usage/token-consumption-findings-and-mitigation-2026-06.md` | Root-cause report for the June 2026 token-burn incident: six immortal auto-started daemons consumed ~8.1B tokens over 7 days via headless worker sessions. Produced the opt-in daemon policy, TTL reaper, ⚙ statusline alarm, and `ruflo-token-audit`. | The root cause was fixed upstream in ruflo 3.27/3.28 ([#2661](https://github.com/ruvnet/ruflo/issues/2661)): AI workers are opt-in, launches are governed by a machine-wide budget with telemetry, one supervisor daemon per repo, native daemon TTL. The kit's daemon policy flipped back to default-on (local-only workers) on that baseline; the reapers and token-audit remain as an independent check. |