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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions bin/quorum-preflight.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn, execFileSync } = require('child_process');
const { resolveCli } = require('./resolve-cli.cjs');
const https = require('https');
const http = require('http');

Expand Down Expand Up @@ -66,9 +67,14 @@ function findProviders() {
} catch (_) {}
for (const p of searchPaths) {
try {
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')).providers;
if (!fs.existsSync(p)) continue;
const providers = JSON.parse(fs.readFileSync(p, 'utf8')).providers;
// Skip empty files (the repo stub is {"providers":[]}) — fall through to the
// next path so a checkout never resolves to a zero-slot fleet (issue #196).
if (Array.isArray(providers) && providers.length > 0) return providers;
} catch (_) {}
}
process.stderr.write('[quorum-preflight] no non-empty providers.json found in any search path\n');
return [];
}

Expand Down Expand Up @@ -305,10 +311,16 @@ async function probeHealth(providers) {
await Promise.all(providers.map(async (p) => {
const isHttp = p.type === 'http';

// Layer 1: binary probe (CLI slots only — HTTP slots have no binary)
// Layer 1: binary probe (CLI slots only — HTTP slots have no binary).
// Resolve the spawn target the same way the dispatcher does (#161/#164/#180/#193):
// providers.json carries cli:null on mainTool-only fleets, so probing p.cli raw would
// spawn(null) and mark the entire healthy fleet dead (issue #196).
const spawnTarget = p.resolvedCli || resolveCli(p.cli || p.mainTool);
const layer1Promise = isHttp
? Promise.resolve({ ok: true, skipped: true, reason: 'HTTP slot — no CLI binary' })
: probeBinary(p.cli, p.health_check_args || []);
: spawnTarget
? probeBinary(spawnTarget, p.health_check_args || [])
: Promise.resolve({ ok: false, reason: 'no CLI configured (cli, resolvedCli, mainTool all empty)' });

// Layer 2: upstream API probe (HTTP slots only)
let layer2Promise;
Expand Down Expand Up @@ -450,7 +462,9 @@ async function main() {
if (mode === '--quorum-active') {
console.log(JSON.stringify(cfg.quorum_active || []));
} else if (mode === '--max-quorum-size') {
console.log(cfg.max_quorum_size ?? 3);
// Write an explicit string — console.log of a Number is ANSI-colorized under
// FORCE_COLOR, which breaks quorum.md's shell integer comparison (issue #196).
process.stdout.write(String(cfg.max_quorum_size ?? 3) + '\n');
} else if (mode === '--team') {
const providers = findProviders();
const active = cfg.quorum_active || [];
Expand Down Expand Up @@ -560,4 +574,4 @@ if (require.main === module) {
main().catch(e => { console.error(e); process.exit(1); });
}

module.exports = { dedupBySlotIdentity };
module.exports = { dedupBySlotIdentity, probeHealth, findProviders };
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"secrets:history": "bash scripts/secret-audit.sh",
"test": "npm run test:ci && npm run test:tui && npm run test:formal",
"test:changed": "node bin/test-changed.cjs",
"test:ci": "node scripts/lint-isolation.js && node scripts/verify-hooks-sync.cjs && node --test hooks/nf-precompact.test.js hooks/nf-context-monitor.test.js hooks/nf-session-start.test.js bin/conformance-schema.test.cjs bin/resolve-cli.test.cjs bin/secrets.test.cjs bin/verify-quorum-health.test.cjs hooks/nf-stop.test.js hooks/config-loader.test.js core/bin/nf-tools.test.cjs hooks/nf-circuit-breaker.test.js hooks/nf-prompt.test.js bin/update-scoreboard.test.cjs hooks/nf-statusline.test.js bin/review-mcp-logs.test.cjs bin/migrate-to-slots.test.cjs bin/validate-traces.test.cjs bin/write-check-result.test.cjs bin/check-results-exit.test.cjs bin/check-trace-redaction.test.cjs bin/check-trace-schema-drift.test.cjs bin/nForma.test.cjs bin/set-secret.test.cjs bin/issue-classifier.test.cjs bin/generate-tla-cfg.test.cjs bin/ccr-secure-config.test.cjs bin/nf-quorum-slot-worker-improvements.test.cjs bin/quorum-improvements-signal.test.cjs bin/quorum-checkpoint.test.cjs bin/claude-md-references.test.cjs hooks/nf-spec-regen.test.js bin/propose-debug-invariants.test.cjs bin/aggregate-requirements.test.cjs bin/validate-requirements-haiku.test.cjs bin/call-quorum-slot-retry.test.cjs bin/provider-mapping.test.cjs bin/provider-concurrency.test.cjs bin/token-dashboard.test.cjs bin/execution-progress.test.cjs bin/memory-store.test.cjs hooks/nf-session-end.test.js bin/skill-extractor.test.cjs bin/learning-extractor.test.cjs bin/continuous-verify.test.cjs bin/context-retriever.test.cjs bin/context-stack.test.cjs hooks/nf-destructive-git-guard.test.js bin/worktree-merge.test.cjs bin/call-quorum-slot-latency.test.cjs bin/nforma-cli.test.cjs bin/solve-trend.test.cjs bin/dedup-changelog.test.cjs bin/oscillation-detector.test.cjs bin/gate-stability.test.cjs bin/predictive-power.test.cjs bin/convergence-report.test.cjs bin/escalation-classifier.test.cjs test/formal-scope-scan-semantic.test.cjs bin/repowise/escape-xml.test.cjs bin/repowise/pack-file.test.cjs bin/repowise/context-packer.test.cjs bin/repowise/hotspot.test.cjs bin/repowise/resolve-hotspot-risk.test.cjs bin/repowise/cochange.test.cjs bin/repowise/inject-cochange-debug.test.cjs bin/repowise/skeleton.test.cjs bin/repowise/budget-compressor.test.cjs test/providers-path-consolidation.test.cjs test/quorum-consensus-gate-cluster.test.cjs",
"test:ci": "node scripts/lint-isolation.js && node scripts/verify-hooks-sync.cjs && node --test hooks/nf-precompact.test.js hooks/nf-context-monitor.test.js hooks/nf-session-start.test.js bin/conformance-schema.test.cjs bin/resolve-cli.test.cjs bin/secrets.test.cjs bin/verify-quorum-health.test.cjs hooks/nf-stop.test.js hooks/config-loader.test.js core/bin/nf-tools.test.cjs hooks/nf-circuit-breaker.test.js hooks/nf-prompt.test.js bin/update-scoreboard.test.cjs hooks/nf-statusline.test.js bin/review-mcp-logs.test.cjs bin/migrate-to-slots.test.cjs bin/validate-traces.test.cjs bin/write-check-result.test.cjs bin/check-results-exit.test.cjs bin/check-trace-redaction.test.cjs bin/check-trace-schema-drift.test.cjs bin/nForma.test.cjs bin/set-secret.test.cjs bin/issue-classifier.test.cjs bin/generate-tla-cfg.test.cjs bin/ccr-secure-config.test.cjs bin/nf-quorum-slot-worker-improvements.test.cjs bin/quorum-improvements-signal.test.cjs bin/quorum-checkpoint.test.cjs bin/claude-md-references.test.cjs hooks/nf-spec-regen.test.js bin/propose-debug-invariants.test.cjs bin/aggregate-requirements.test.cjs bin/validate-requirements-haiku.test.cjs bin/call-quorum-slot-retry.test.cjs bin/provider-mapping.test.cjs bin/provider-concurrency.test.cjs bin/token-dashboard.test.cjs bin/execution-progress.test.cjs bin/memory-store.test.cjs hooks/nf-session-end.test.js bin/skill-extractor.test.cjs bin/learning-extractor.test.cjs bin/continuous-verify.test.cjs bin/context-retriever.test.cjs bin/context-stack.test.cjs hooks/nf-destructive-git-guard.test.js bin/worktree-merge.test.cjs bin/call-quorum-slot-latency.test.cjs bin/nforma-cli.test.cjs bin/solve-trend.test.cjs bin/dedup-changelog.test.cjs bin/oscillation-detector.test.cjs bin/gate-stability.test.cjs bin/predictive-power.test.cjs bin/convergence-report.test.cjs bin/escalation-classifier.test.cjs test/formal-scope-scan-semantic.test.cjs bin/repowise/escape-xml.test.cjs bin/repowise/pack-file.test.cjs bin/repowise/context-packer.test.cjs bin/repowise/hotspot.test.cjs bin/repowise/resolve-hotspot-risk.test.cjs bin/repowise/cochange.test.cjs bin/repowise/inject-cochange-debug.test.cjs bin/repowise/skeleton.test.cjs bin/repowise/budget-compressor.test.cjs test/providers-path-consolidation.test.cjs test/quorum-consensus-gate-cluster.test.cjs test/quorum-preflight-maintool-healthy.test.cjs",
"test:tui": "NF_TEST_MODE=1 node scripts/run-tui-tests.cjs",
"test:install": "node --test test/install-virgin.test.cjs",
"test:formal": "node --test bin/run-tlc.test.cjs bin/run-alloy.test.cjs bin/export-prism-constants.test.cjs bin/generate-petri-net.test.cjs bin/run-breaker-tlc.test.cjs bin/run-oscillation-tlc.test.cjs bin/run-protocol-tlc.test.cjs bin/run-audit-alloy.test.cjs bin/run-transcript-alloy.test.cjs bin/run-installer-alloy.test.cjs bin/run-formal-verify.test.cjs bin/xstate-to-tla.test.cjs bin/run-account-manager-tlc.test.cjs bin/run-account-pool-alloy.test.cjs bin/run-oauth-rotation-prism.test.cjs bin/run-prism.test.cjs bin/check-spec-sync.test.cjs bin/sensitivity-sweep-feedback.test.cjs bin/roadmapper-formal-integration.test.cjs bin/test-formal-integration.test.cjs test/alloy-headless.test.cjs bin/adapters/ir.test.cjs bin/adapters/emitter-tla.test.cjs bin/adapters/detect.test.cjs bin/adapters/scaffold-config.test.cjs bin/adapters/xstate-v5.test.cjs bin/adapters/xstate-v4.test.cjs bin/adapters/jsm.test.cjs bin/adapters/robot.test.cjs bin/adapters/asl.test.cjs bin/adapters/stately.test.cjs bin/adapters/python-transitions.test.cjs bin/adapters/sismic.test.cjs bin/adapters/looplab-fsm.test.cjs bin/adapters/qmuntal-stateless.test.cjs bin/fsm-to-tla.test.cjs",
Expand Down
83 changes: 83 additions & 0 deletions test/quorum-preflight-maintool-healthy.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env node
'use strict';

/**
* Regression test for issue #196: preflight reported the entire healthy fleet as
* dead because probeHealth spawned the raw `cli` field, which is null on
* mainTool-only fleets (every live install). spawn(null) threw → every slot
* "spawn failed ...Received null" → nf-prompt's NF_ALL_SLOTS_DOWN branch → every
* quorum silently ran solo, defeating the min_live_voters floor (#192).
*
* Run: node --test test/quorum-preflight-maintool-healthy.test.cjs
*/

const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');

const SCRIPT = path.join(__dirname, '..', 'bin', 'quorum-preflight.cjs');
const { probeHealth, findProviders } = require(SCRIPT);

describe('quorum-preflight spawn-target fallback (issue #196)', () => {
it('a mainTool-only provider (cli:null) probes HEALTHY, not spawn-failed', async () => {
// `node` is guaranteed present; cli is null exactly like a real install.
const health = await probeHealth([{ name: 't-codex', cli: null, mainTool: 'node' }]);
assert.equal(health['t-codex'].healthy, true,
`mainTool-only slot must be healthy, got: ${JSON.stringify(health['t-codex'])}`);
assert.ok(
!/Received null|spawn failed/i.test(health['t-codex'].layer1.reason || ''),
'layer1 must not be a spawn(null) failure',
);
});

it('resolvedCli takes precedence when present', async () => {
const health = await probeHealth([{ name: 't-pre', cli: null, mainTool: 'definitely-not-a-real-binary-xyz', resolvedCli: process.execPath }]);
assert.equal(health['t-pre'].healthy, true, 'resolvedCli should win over a bogus mainTool');
});

it('a provider with no cli/resolvedCli/mainTool is unhealthy with an explicit reason (no spawn crash)', async () => {
const health = await probeHealth([{ name: 't-none', cli: null, mainTool: null }]);
assert.equal(health['t-none'].healthy, false);
assert.match(health['t-none'].layer1.reason || '', /no CLI configured/i);
});
});

describe('quorum-preflight findProviders empty-file skip (issue #196)', () => {
it('skips the empty repo stub and returns the installed fleet (true regression)', () => {
// The repo stub bin/providers.json (first in the search path) is
// {"providers":[]}. Pre-fix, findProviders returned that empty array as the
// fleet; post-fix it must skip it and fall through to the installed copy at
// $HOME/.claude/nf-bin/providers.json. Force a non-empty installed fleet in a
// temp HOME so the buggy path (→ []) and the fixed path (→ fleet) DIVERGE.
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-preflight-'));
const nfBin = path.join(tmpHome, '.claude', 'nf-bin');
fs.mkdirSync(nfBin, { recursive: true });
const installed = [{ name: 'codex-1', mainTool: 'codex', display_type: 'codex-cli' }];
fs.writeFileSync(path.join(nfBin, 'providers.json'), JSON.stringify({ providers: installed }));
const origHome = process.env.HOME;
process.env.HOME = tmpHome; // findProviders resolves via os.homedir() → $HOME on POSIX
try {
const result = findProviders();
assert.ok(Array.isArray(result), 'returns an array');
assert.equal(result.length, 1, 'must skip the empty stub and return the installed fleet, not []');
assert.equal(result[0].name, 'codex-1', 'returns the installed providers, not the repo stub');
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
});

describe('quorum-preflight numeric stdout is not ANSI-colorized (issue #196)', () => {
it('--max-quorum-size emits a bare integer parseable by shell', () => {
const out = execFileSync('node', [SCRIPT, '--max-quorum-size'], {
encoding: 'utf8',
env: { ...process.env, FORCE_COLOR: '3' },
});
assert.doesNotMatch(out, /\x1b\[/, 'output must contain no ANSI escape codes');
assert.match(out.trim(), /^\d+$/, 'output must be a bare integer');
});
});
Loading