23f7624596
ADR-166 MCP Bridge Security Lock / Static-source security lock (push) Failing after 0s
ADR-166 MCP Bridge Security Lock / Compose default binds loopback + Mongo has auth (push) Failing after 2s
CodeQL Advanced / Analyze (rust) (push) Failing after 0s
ADR-166 MCP Bridge Security Lock / plugin-agent-federation bindHost default (push) Failing after 1s
ADR-166 MCP Bridge Security Lock / Runtime behavior — 401 + terminal gate + fail-closed (push) Failing after 4s
business-pods-smoke / smoke (push) Failing after 1s
all-plugins-smoke / smoke-all (push) Failing after 2s
CI/CD Pipeline / Security & Code Quality (push) Failing after 1s
CI/CD Pipeline / Test Suite (ubuntu-latest) (push) Failing after 1s
CI/CD Pipeline / Build & Package (macos-latest) (push) Has been skipped
CI/CD Pipeline / Build & Package (ubuntu-latest) (push) Has been skipped
CI/CD Pipeline / Build & Package (windows-latest) (push) Has been skipped
CI/CD Pipeline / Documentation & Examples (push) Failing after 1s
Clone Tracker (14-day rolling) / Snapshot clones for ruflo ecosystem (push) Failing after 1s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s
federation-peer-rust / stable-noop (push) Failing after 1s
metaharness-ci / score (push) Failing after 1s
metaharness-ci / router-compat (push) Failing after 0s
metaharness-ci / similarity-tests (push) Failing after 0s
no-agentbbs-smoke / smoke-without-agentbbs (push) Failing after 1s
V3 CI/CD Pipeline / Build V3 (windows-latest) (push) Has been skipped
codex-integration-audit / Codex integration audit (push) Failing after 1s
helpers-manifest-guard / guard (push) Failing after 1s
🔗 Cross-Agent Integration Tests / 🤝 Agent Coordination Tests (push) Has been skipped
🔗 Cross-Agent Integration Tests / 🧠 Memory Sharing Integration (push) Has been skipped
🔗 Cross-Agent Integration Tests / 🛡️ Fault Tolerance Tests (push) Has been skipped
🔗 Cross-Agent Integration Tests / ⚡ Performance Integration Tests (push) Has been skipped
metaharness-ci / mcp-scan (push) Failing after 1s
metaharness-ci / eject-dryrun (push) Failing after 1s
metaharness-ci / metaharness-real-data (push) Failing after 0s
no-cli-optdep-bloat-2561 / guard (push) Failing after 1s
no-metaharness-smoke / smoke-without-metaharness (push) Failing after 1s
no-phantom-agentic-flow-subpath / guard (push) Failing after 1s
🔄 Automated Rollback Manager / 🚨 Failure Detection (push) Failing after 1s
V3 CI/CD Pipeline / Plugin hooks smoke / ubuntu-latest / Node 22 (push) Failing after 1s
V3 CI/CD Pipeline / ruflo-graph-intelligence build + test smoke (#2044, ADR-123) (push) Failing after 1s
CVE Audit Gate / Audit root (critical-blocking) (push) Failing after 2s
cost-tracker-smoke / smoke (push) Failing after 3s
oia-audit-weekly / audit (push) Failing after 2s
ruflo-agent-smoke / ruflo-agent structural smoke (push) Failing after 1s
📊 Status Badges Update / 📊 Update Status Badges (push) Failing after 1s
V3 CI/CD Pipeline / Static regression guards (#2267 YAML + (push) Failing after 1s
V3 CI/CD Pipeline / Test V3 Packages (push) Failing after 0s
V3 CI/CD Pipeline / agent_execute provider routing smoke (#2042) (push) Failing after 0s
CVE Audit Gate / Audit v3 (critical-blocking) (push) Failing after 1s
federation-peer-rust / stable-native (push) Failing after 2s
🔗 Cross-Agent Integration Tests / 🚀 Integration Test Setup (push) Failing after 2s
neural-trader-smoke / runtime-smoke (push) Failing after 1s
V3 CI/CD Pipeline / Build V3 (macos-latest) (push) Has been skipped
V3 CI/CD Pipeline / Build V3 (ubuntu-latest) (push) Has been skipped
V3 CI/CD Pipeline / Type Check V3 (push) Failing after 1s
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / ubuntu-latest / Node 24 (push) Failing after 1s
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / ubuntu-latest / Node 22 (push) Failing after 2s
V3 CI/CD Pipeline / browser rvf create flag smoke (#2015) (push) Failing after 0s
V3 CI/CD Pipeline / Dependency review (#2046) (push) Has been skipped
V3 CI/CD Pipeline / Supply-chain audit (#2046) (push) Failing after 0s
V3 CI/CD Pipeline / witness marker drift smoke (#2021) (push) Failing after 1s
V3 CI/CD Pipeline / neural-trader portfolio CG smoke (#2068, ADR-126 Phase 3) (push) Failing after 1s
V3 CI/CD Pipeline / neural-trader backtest signing smoke (#2068, ADR-126 Phase 4) (push) Failing after 1s
V3 CI/CD Pipeline / kg-extract type-import classification smoke (#2049) (push) Failing after 0s
V3 CI/CD Pipeline / witness verify precondition smoke (#1880) (push) Failing after 2s
V3 CI/CD Pipeline / neural-trader pipeline risk-gate smoke (#2068, ADR-126 Phase 5) (push) Failing after 0s
V3 CI/CD Pipeline / neural-trader feature attribution smoke (#2068, ADR-126 Phase 6) (push) Failing after 0s
V3 CI/CD Pipeline / plugin-registry signature verification smoke (#1922, CWE-347) (push) Failing after 4s
V3 CI/CD Pipeline / memory stats legacy-DB smoke (#2120) (push) Failing after 4s
V3 CI/CD Pipeline / github deprecated actions smoke (#2089, ADR-127 Phase 3) (push) Failing after 1s
V3 CI/CD Pipeline / graph query + pathfinder smoke (ADR-130 P2+P5) (push) Has been skipped
V3 CI/CD Pipeline / graph trajectory hooks smoke (ADR-130 P3) (push) Has been skipped
V3 CI/CD Pipeline / graph plugin adapter smoke (ADR-130 P4) (push) Has been skipped
V3 CI/CD Pipeline / graph benchmark (ADR-130 P6) (push) Has been skipped
V3 CI/CD Pipeline / statusline generator delegation smoke (#2195) (push) Failing after 1s
V3 CI/CD Pipeline / wizard init regression guard (#2206 (push) Failing after 1s
V3 CI/CD Pipeline / memory no-stray-db smoke (ADR-125 P7) (push) Failing after 1s
V3 CI/CD Pipeline / github-safe injection smoke (#2089, ADR-127 Phase 1) (push) Failing after 1s
V3 CI/CD Pipeline / github actions pin smoke (#2089, ADR-127 Phase 1) (push) Failing after 1s
V3 CI/CD Pipeline / github attribution opt-in smoke (#2089, ADR-127 Phase 4) (push) Failing after 1s
V3 CI/CD Pipeline / pre-bash hook safety smoke (#2017) (push) Failing after 1s
V3 CI/CD Pipeline / Memory import smoke / ubuntu-latest (push) Failing after 0s
V3 CI/CD Pipeline / MCP protocol smoke / ubuntu-latest (push) Failing after 2s
V3 CI/CD Pipeline / ruvllm WASM auto-init smoke (#2086) (push) Failing after 4s
V3 CI/CD Pipeline / MCP paired-tool round-trip smoke (#1889) (push) Failing after 1s
V3 CI/CD Pipeline / Plugin package install-safety (#1902/#1903/#1904) (push) Failing after 1s
V3 CI/CD Pipeline / Tool description discoverability (ADR-112) (push) Failing after 3s
V3 CI/CD Pipeline / CLI npx-install smoke (#1147 / (22) (push) Failing after 1s
V3 CI/CD Pipeline / CLI npx-install smoke (#1147 / (24) (push) Failing after 1s
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / ubuntu-latest (push) Failing after 2s
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / ubuntu-latest (push) Failing after 1s
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / ubuntu-latest (push) Failing after 1s
V3 CI/CD Pipeline / Vector-index dimension audit (#1947) (push) Failing after 0s
V3 CI/CD Pipeline / Hook-command install safety (#1921) (push) Failing after 1s
V3 CI/CD Pipeline / ToolOutputGuardrail smoke (ADR-131, (push) Failing after 1s
V3 CI/CD Pipeline / init-bundle invariants smoke (#2095, ADR-128 Phase 5) (push) Failing after 1s
V3 CI/CD Pipeline / wasm provider bridge smoke (ADR-129 P1) (push) Failing after 2s
V3 CI/CD Pipeline / wasm gallery CRUD smoke (ADR-129 P3) (push) Failing after 1s
V3 CI/CD Pipeline / wasm plugin bridge smoke (ADR-129 P4) (push) Failing after 0s
V3 CI/CD Pipeline / wasm compose smoke (ADR-129 P2) (push) Failing after 4s
V3 CI/CD Pipeline / graph schema smoke (ADR-130 P1) (push) Failing after 0s
Validate Marketplace / validate (push) Failing after 1s
🔍 Verification Pipeline / 🚀 Setup Verification (push) Failing after 1s
🔍 Verification Pipeline / 🛡️ Security Verification (push) Has been skipped
🔍 Verification Pipeline / 📝 Code Quality (push) Has been skipped
🔍 Verification Pipeline / 🧪 Test Verification (${{ matrix.os }}, Node ${{ matrix.node }}) (push) Has been skipped
🔍 Verification Pipeline / 🏗️ Build Verification (push) Has been skipped
🔍 Verification Pipeline / 📚 Documentation Verification (push) Has been skipped
CVE Audit Gate / High-severity report (warn only) (push) Has been cancelled
🔄 Automated Rollback Manager / 🔄 Execute Rollback (push) Has been cancelled
🔄 Automated Rollback Manager / ✅ Post-Rollback Verification (push) Has been cancelled
🔄 Automated Rollback Manager / 📊 Rollback Monitoring (push) Has been cancelled
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / windows-latest (push) Has been cancelled
🔄 Automated Rollback Manager / ⏳ Manual Rollback Approval (push) Has been cancelled
V3 CI/CD Pipeline / MCP protocol smoke / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Memory import smoke / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / ubuntu-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Publish to npm (alpha) (push) Has been cancelled
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / macos-latest / Node 22 (push) Has been cancelled
V3 CI/CD Pipeline / Plugin hooks smoke / macos-latest / Node 22 (push) Has been cancelled
CI/CD Pipeline / Deploy & Release (push) Has been cancelled
CI/CD Pipeline / CI Status (push) Has been cancelled
🔗 Cross-Agent Integration Tests / 📊 Integration Test Report (push) Has been cancelled
🔄 Automated Rollback Manager / 🔍 Pre-Rollback Validation (push) Has been cancelled
🔍 Verification Pipeline / ⚡ Performance Verification (push) Has been cancelled
🔍 Verification Pipeline / 📊 Verification Report (push) Has been cancelled
852 lines
33 KiB
TypeScript
852 lines
33 KiB
TypeScript
/**
|
|
* WorkerDaemon Resource Thresholds Tests
|
|
*
|
|
* Validates CPU-proportional defaults, config priority chain,
|
|
* state persistence, resource gating, and input validation.
|
|
*
|
|
* Uses real temp directories for filesystem isolation.
|
|
* All resource gating tests use explicit constructor config
|
|
* to avoid host-machine dependency.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { WorkerDaemon } from '../../src/services/worker-daemon.js';
|
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir, cpus } from 'os';
|
|
|
|
describe('WorkerDaemon resource thresholds', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'worker-daemon-test-'));
|
|
mkdirSync(join(tempDir, '.claude-flow', 'logs'), { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.doUnmock('os');
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
// Clean up signal listeners to prevent MaxListenersExceededWarning
|
|
process.removeAllListeners('SIGTERM');
|
|
process.removeAllListeners('SIGINT');
|
|
process.removeAllListeners('SIGHUP');
|
|
});
|
|
|
|
// =========================================================================
|
|
// Smart CPU-proportional defaults
|
|
// =========================================================================
|
|
describe('smart CPU-proportional defaults', () => {
|
|
it('should compute maxCpuLoad as max(cpuCount * 0.8, 2.0)', () => {
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
const cpuCount = cpus().length || 1;
|
|
const expected = Math.max(cpuCount * 0.8, 2.0);
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeCloseTo(expected, 1);
|
|
});
|
|
|
|
it('should always be at least 2.0 regardless of CPU count', () => {
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThanOrEqual(2.0);
|
|
});
|
|
|
|
it('should scale above 2.0 on multi-core machines', () => {
|
|
const cpuCount = cpus().length;
|
|
if (cpuCount <= 3) return; // skip on small machines
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThan(2.0);
|
|
});
|
|
|
|
it('should use platform-aware default for minFreeMemoryPercent', () => {
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
const expectedMinFreeMem = process.platform === 'darwin' ? 5 : 10;
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBe(expectedMinFreeMem);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Resource gating via canRunWorker
|
|
// =========================================================================
|
|
describe('resource gating', () => {
|
|
it('should allow workers when CPU load is below threshold', async () => {
|
|
// Explicitly set threshold — decoupled from host machine
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
resourceThresholds: { maxCpuLoad: 9.6, minFreeMemoryPercent: 20 },
|
|
});
|
|
|
|
vi.doMock('os', () => ({
|
|
default: {
|
|
loadavg: () => [3.5, 3.0, 2.5],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 8e9, // 50% free
|
|
},
|
|
loadavg: () => [3.5, 3.0, 2.5],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 8e9,
|
|
}));
|
|
|
|
const result = await (daemon as any).canRunWorker();
|
|
expect(result.allowed).toBe(true);
|
|
});
|
|
|
|
it('should block workers when CPU load exceeds threshold', async () => {
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
resourceThresholds: { maxCpuLoad: 2.0, minFreeMemoryPercent: 5 },
|
|
});
|
|
|
|
vi.doMock('os', () => ({
|
|
default: {
|
|
loadavg: () => [5.0, 4.0, 3.0],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 8e9,
|
|
},
|
|
loadavg: () => [5.0, 4.0, 3.0],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 8e9,
|
|
}));
|
|
|
|
const result = await (daemon as any).canRunWorker();
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.reason).toContain('CPU');
|
|
});
|
|
|
|
it('should block workers when free memory is below threshold', async () => {
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
resourceThresholds: { maxCpuLoad: 100, minFreeMemoryPercent: 50 },
|
|
});
|
|
|
|
vi.doMock('os', () => ({
|
|
default: {
|
|
loadavg: () => [0.5, 0.5, 0.5],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 1e9, // ~6% free — below 50% threshold
|
|
},
|
|
loadavg: () => [0.5, 0.5, 0.5],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 1e9,
|
|
}));
|
|
|
|
const result = await (daemon as any).canRunWorker();
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.reason.toLowerCase()).toContain('memory');
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Config file reading
|
|
// =========================================================================
|
|
describe('config.json reading', () => {
|
|
it('should read daemon settings from flat dot-notation keys', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.maxCpuLoad': 10,
|
|
'daemon.resourceThresholds.minFreeMemoryPercent': 25,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBe(10);
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBe(25);
|
|
});
|
|
|
|
it('should read daemon settings from scopes.project', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
scopes: {
|
|
project: {
|
|
'daemon.resourceThresholds.maxCpuLoad': 12,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBe(12);
|
|
});
|
|
|
|
it('should handle malformed config.json gracefully', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, '{ invalid json !!!');
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
const expectedMinFreeMem = process.platform === 'darwin' ? 5 : 10;
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThanOrEqual(2.0);
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBe(expectedMinFreeMem);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Config priority chain
|
|
// =========================================================================
|
|
describe('config priority: constructor arg > config.json > smart default', () => {
|
|
it('should prefer constructor arg over config.json', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.maxCpuLoad': 10,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
resourceThresholds: { maxCpuLoad: 15, minFreeMemoryPercent: 5 },
|
|
});
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBe(15);
|
|
});
|
|
|
|
it('should prefer config.json over smart default', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.maxCpuLoad': 42,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBe(42);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// State persistence
|
|
// =========================================================================
|
|
describe('state persistence', () => {
|
|
it('should restore resourceThresholds from daemon-state.json', () => {
|
|
const stateFile = join(tempDir, '.claude-flow', 'daemon-state.json');
|
|
writeFileSync(stateFile, JSON.stringify({
|
|
running: false,
|
|
workers: {},
|
|
config: {
|
|
resourceThresholds: { maxCpuLoad: 8.0, minFreeMemoryPercent: 15 },
|
|
workers: [],
|
|
},
|
|
savedAt: new Date().toISOString(),
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBe(8.0);
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBe(15);
|
|
});
|
|
|
|
it('should restore maxConcurrent and workerTimeoutMs from state', () => {
|
|
const stateFile = join(tempDir, '.claude-flow', 'daemon-state.json');
|
|
writeFileSync(stateFile, JSON.stringify({
|
|
running: false,
|
|
workers: {},
|
|
config: {
|
|
maxConcurrent: 6,
|
|
workerTimeoutMs: 600000,
|
|
resourceThresholds: { maxCpuLoad: 10.0, minFreeMemoryPercent: 10 },
|
|
workers: [],
|
|
},
|
|
savedAt: new Date().toISOString(),
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.maxConcurrent).toBe(6);
|
|
expect(config.workerTimeoutMs).toBe(600000);
|
|
});
|
|
|
|
it('should reject invalid values from saved state', () => {
|
|
const stateFile = join(tempDir, '.claude-flow', 'daemon-state.json');
|
|
writeFileSync(stateFile, JSON.stringify({
|
|
running: false,
|
|
workers: {},
|
|
config: {
|
|
resourceThresholds: { maxCpuLoad: -10, minFreeMemoryPercent: 200 },
|
|
maxConcurrent: 0,
|
|
workerTimeoutMs: -500,
|
|
workers: [],
|
|
},
|
|
savedAt: new Date().toISOString(),
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThan(0);
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBeLessThanOrEqual(100);
|
|
expect(config.maxConcurrent).toBeGreaterThan(0);
|
|
expect(config.workerTimeoutMs).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Input validation
|
|
// =========================================================================
|
|
describe('input validation', () => {
|
|
it('should ignore non-numeric values in config.json', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.maxCpuLoad': 'not-a-number',
|
|
'daemon.resourceThresholds.minFreeMemoryPercent': null,
|
|
'daemon.maxConcurrent': true,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
const expectedMinFreeMem = process.platform === 'darwin' ? 5 : 10;
|
|
expect(typeof config.resourceThresholds.maxCpuLoad).toBe('number');
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThanOrEqual(2.0);
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBe(expectedMinFreeMem);
|
|
expect(config.maxConcurrent).toBe(2); // default
|
|
});
|
|
|
|
it('should ignore negative values in config.json', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.maxCpuLoad': -5,
|
|
'daemon.maxConcurrent': -1,
|
|
'daemon.workerTimeoutMs': -100,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThan(0);
|
|
expect(config.maxConcurrent).toBeGreaterThan(0);
|
|
expect(config.workerTimeoutMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should reject minFreeMemoryPercent outside 0-100 range', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.minFreeMemoryPercent': 150,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBeLessThanOrEqual(100);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// processPendingWorkers busy-wait fix (WI-1 / PR #1052)
|
|
// =========================================================================
|
|
describe('processPendingWorkers busy-wait prevention', () => {
|
|
it('should not spin when all workers are deferred due to resource pressure', async () => {
|
|
// Set impossibly low thresholds so canRunWorker always rejects
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
maxConcurrent: 2,
|
|
resourceThresholds: { maxCpuLoad: 0.001, minFreeMemoryPercent: 99.99 },
|
|
});
|
|
|
|
// Seed the pending queue with a worker
|
|
const pendingWorkers = (daemon as any).pendingWorkers as string[];
|
|
pendingWorkers.push('map');
|
|
|
|
// Spy on executeWorkerWithConcurrencyControl to count invocations
|
|
const execSpy = vi.spyOn(daemon as any, 'executeWorkerWithConcurrencyControl');
|
|
|
|
await (daemon as any).processPendingWorkers();
|
|
|
|
// Should have been called exactly once — then broke out of the loop
|
|
expect(execSpy.mock.calls.length).toBe(1);
|
|
// The deferred worker should still be on the pending queue (pushed back by executeWorkerWithConcurrencyControl)
|
|
expect(pendingWorkers).toContain('map');
|
|
});
|
|
|
|
it('should schedule a backoff retry when no workers are running and a worker is deferred', async () => {
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
maxConcurrent: 2,
|
|
resourceThresholds: { maxCpuLoad: 0.001, minFreeMemoryPercent: 99.99 },
|
|
});
|
|
|
|
const pendingWorkers = (daemon as any).pendingWorkers as string[];
|
|
pendingWorkers.push('audit');
|
|
|
|
// Ensure no workers are currently running
|
|
expect((daemon as any).runningWorkers.size).toBe(0);
|
|
|
|
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
const callCountBefore = setTimeoutSpy.mock.calls.length;
|
|
|
|
await (daemon as any).processPendingWorkers();
|
|
|
|
// Find the 30-second backoff call among any setTimeout calls
|
|
const backoffCalls = setTimeoutSpy.mock.calls
|
|
.slice(callCountBefore)
|
|
.filter((call) => call[1] === 30_000);
|
|
expect(backoffCalls.length).toBe(1);
|
|
|
|
setTimeoutSpy.mockRestore();
|
|
});
|
|
|
|
it('should not schedule backoff when running workers will drain the queue', async () => {
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
maxConcurrent: 1,
|
|
resourceThresholds: { maxCpuLoad: 0.001, minFreeMemoryPercent: 99.99 },
|
|
});
|
|
|
|
// Simulate a worker already running — its finally block will re-trigger processPendingWorkers
|
|
(daemon as any).runningWorkers.add('optimize');
|
|
|
|
const pendingWorkers = (daemon as any).pendingWorkers as string[];
|
|
pendingWorkers.push('map');
|
|
|
|
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
const callCountBefore = setTimeoutSpy.mock.calls.length;
|
|
|
|
await (daemon as any).processPendingWorkers();
|
|
|
|
// No 30-second backoff should have been scheduled because a running worker exists
|
|
const backoffCalls = setTimeoutSpy.mock.calls
|
|
.slice(callCountBefore)
|
|
.filter((call) => call[1] === 30_000);
|
|
expect(backoffCalls.length).toBe(0);
|
|
|
|
setTimeoutSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Mutation-killing tests (Stryker-inspired)
|
|
// Each test targets a specific code mutation that would survive existing tests.
|
|
// =========================================================================
|
|
describe('mutation killing', () => {
|
|
// MUTANT: Math.max(cpuCount * 0.8, 2.0) → Math.min(cpuCount * 0.8, 2.0)
|
|
// On 12-core: Math.min(9.6, 2.0) = 2.0 — would survive ">=2.0" tests
|
|
it('should NOT use Math.min — multi-core default must exceed 2.0', () => {
|
|
const cpuCount = cpus().length;
|
|
if (cpuCount <= 3) return;
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: Math.max → Math.min mutant
|
|
// Math.min(12*0.8, 2.0) = 2.0, but correct is 9.6
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThan(2.0);
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeLessThan(cpuCount); // sanity
|
|
});
|
|
|
|
// MUTANT: cpuCount * 0.8 → cpuCount * 0 (or cpuCount + 0.8)
|
|
it('should scale proportionally to CPU count, not be constant', () => {
|
|
const cpuCount = cpus().length;
|
|
if (cpuCount <= 3) return;
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: * 0.8 → * 0, + 0.8, - 0.8
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThan(cpuCount * 0.5);
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeLessThan(cpuCount * 1.0);
|
|
});
|
|
|
|
// MUTANT: `>` → `>=` in canRunWorker CPU check
|
|
// loadavg === maxCpuLoad should be ALLOWED (not blocked)
|
|
it('should allow workers when CPU load equals exactly the threshold', async () => {
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
resourceThresholds: { maxCpuLoad: 5.0, minFreeMemoryPercent: 5 },
|
|
});
|
|
|
|
vi.doMock('os', () => ({
|
|
default: {
|
|
loadavg: () => [5.0, 4.0, 3.0], // exactly at threshold
|
|
totalmem: () => 16e9,
|
|
freemem: () => 8e9,
|
|
},
|
|
loadavg: () => [5.0, 4.0, 3.0],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 8e9,
|
|
}));
|
|
|
|
const result = await (daemon as any).canRunWorker();
|
|
// Kills: > → >= mutant (at-threshold should pass)
|
|
expect(result.allowed).toBe(true);
|
|
});
|
|
|
|
// MUTANT: `<` → `<=` in canRunWorker memory check
|
|
// freePercent === minFreeMemoryPercent should be ALLOWED
|
|
it('should allow workers when free memory equals exactly the threshold', async () => {
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
resourceThresholds: { maxCpuLoad: 100, minFreeMemoryPercent: 20 },
|
|
});
|
|
|
|
vi.doMock('os', () => ({
|
|
default: {
|
|
loadavg: () => [0.5, 0.5, 0.5],
|
|
totalmem: () => 100e9,
|
|
freemem: () => 20e9, // exactly 20% = threshold
|
|
},
|
|
loadavg: () => [0.5, 0.5, 0.5],
|
|
totalmem: () => 100e9,
|
|
freemem: () => 20e9,
|
|
}));
|
|
|
|
const result = await (daemon as any).canRunWorker();
|
|
// Kills: < → <= mutant (at-threshold should pass)
|
|
expect(result.allowed).toBe(true);
|
|
});
|
|
|
|
// MUTANT: Remove `|| 1` fallback in getEffectiveCpuCount
|
|
// On bare metal this doesn't matter, but the floor must exist
|
|
it('should return at least 1 from getEffectiveCpuCount', () => {
|
|
const count = WorkerDaemon.getEffectiveCpuCount();
|
|
// Kills: removal of || 1 (result must never be 0)
|
|
expect(count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
// MUTANT: Remove scopes.project fallback in readDaemonConfigFromFile
|
|
// Config only at scopes.project level must still be read
|
|
it('should prefer scopes.project over root when both exist', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.maxCpuLoad': 5,
|
|
scopes: {
|
|
project: {
|
|
'daemon.resourceThresholds.maxCpuLoad': 20,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: removal of scopes.project ?? raw fallback
|
|
// scopes.project should take priority over root
|
|
expect(config.resourceThresholds.maxCpuLoad).toBe(20);
|
|
});
|
|
|
|
// MUTANT: Remove validation `rawCpuLoad > 0` → accept zero
|
|
it('should reject maxCpuLoad of exactly 0 in config.json', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.maxCpuLoad': 0,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: > 0 → >= 0 mutant (zero should be rejected)
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeGreaterThan(0);
|
|
});
|
|
|
|
// MUTANT: Remove validation `rawMinMem <= 100`
|
|
it('should reject minFreeMemoryPercent of exactly 101 in config.json', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.minFreeMemoryPercent': 101,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: <= 100 → < 100 or removal of upper bound check
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBeLessThanOrEqual(100);
|
|
});
|
|
|
|
// MUTANT: minFreeMemoryPercent accepts 0 (boundary — should it?)
|
|
it('should accept minFreeMemoryPercent of 0 in config.json (disable memory check)', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.minFreeMemoryPercent': 0,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: >= 0 → > 0 mutant (zero is valid — disables memory gating)
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBe(0);
|
|
});
|
|
|
|
// MUTANT: Remove `typeof ... === 'number'` check → accept booleans
|
|
it('should reject boolean true as maxCpuLoad (typeof guard)', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.resourceThresholds.maxCpuLoad': true,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: removal of typeof check (true is truthy but not a number)
|
|
// JS: typeof true === 'boolean', not 'number'
|
|
expect(config.resourceThresholds.maxCpuLoad).not.toBe(true);
|
|
expect(typeof config.resourceThresholds.maxCpuLoad).toBe('number');
|
|
});
|
|
|
|
// MUTANT: State restore `rt.maxCpuLoad < 1000` → remove upper bound
|
|
it('should reject absurdly large maxCpuLoad from saved state', () => {
|
|
const stateFile = join(tempDir, '.claude-flow', 'daemon-state.json');
|
|
writeFileSync(stateFile, JSON.stringify({
|
|
running: false,
|
|
workers: {},
|
|
config: {
|
|
resourceThresholds: { maxCpuLoad: 999999, minFreeMemoryPercent: 10 },
|
|
workers: [],
|
|
},
|
|
savedAt: new Date().toISOString(),
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: removal of < 1000 bound in state restoration
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeLessThan(1000);
|
|
});
|
|
|
|
// MUTANT: Swap CPU and memory checks in canRunWorker
|
|
it('should independently check CPU and memory (not conflate them)', async () => {
|
|
// High CPU + plenty of memory → should block on CPU
|
|
const daemon1 = new WorkerDaemon(tempDir, {
|
|
resourceThresholds: { maxCpuLoad: 2.0, minFreeMemoryPercent: 5 },
|
|
});
|
|
|
|
vi.doMock('os', () => ({
|
|
default: {
|
|
loadavg: () => [10.0, 8.0, 6.0], // way over CPU
|
|
totalmem: () => 16e9,
|
|
freemem: () => 15e9, // 93% free — plenty of memory
|
|
},
|
|
loadavg: () => [10.0, 8.0, 6.0],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 15e9,
|
|
}));
|
|
|
|
const result1 = await (daemon1 as any).canRunWorker();
|
|
expect(result1.allowed).toBe(false);
|
|
expect(result1.reason).toContain('CPU');
|
|
expect(result1.reason).not.toContain('emory'); // not memory
|
|
|
|
vi.doUnmock('os');
|
|
|
|
// Low CPU + low memory → should block on memory
|
|
const tempDir2 = mkdtempSync(join(tmpdir(), 'worker-daemon-test-'));
|
|
mkdirSync(join(tempDir2, '.claude-flow', 'logs'), { recursive: true });
|
|
const daemon2 = new WorkerDaemon(tempDir2, {
|
|
resourceThresholds: { maxCpuLoad: 100, minFreeMemoryPercent: 50 },
|
|
});
|
|
|
|
vi.doMock('os', () => ({
|
|
default: {
|
|
loadavg: () => [0.1, 0.1, 0.1], // barely any CPU
|
|
totalmem: () => 16e9,
|
|
freemem: () => 1e9, // 6% free
|
|
},
|
|
loadavg: () => [0.1, 0.1, 0.1],
|
|
totalmem: () => 16e9,
|
|
freemem: () => 1e9,
|
|
}));
|
|
|
|
const result2 = await (daemon2 as any).canRunWorker();
|
|
expect(result2.allowed).toBe(false);
|
|
expect(result2.reason.toLowerCase()).toContain('memory');
|
|
|
|
rmSync(tempDir2, { recursive: true, force: true });
|
|
});
|
|
|
|
// MUTANT: Remove `config?.resourceThresholds?.maxCpuLoad` → skip constructor arg
|
|
it('should use partial constructor resourceThresholds (only maxCpuLoad)', () => {
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
resourceThresholds: { maxCpuLoad: 77, minFreeMemoryPercent: 33 },
|
|
});
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: removal of config?.resourceThresholds?.maxCpuLoad path
|
|
expect(config.resourceThresholds.maxCpuLoad).toBe(77);
|
|
expect(config.resourceThresholds.minFreeMemoryPercent).toBe(33);
|
|
});
|
|
|
|
// MUTANT: readDaemonConfigFromFile returns {} even when file exists
|
|
it('should read maxConcurrent from config.json', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.maxConcurrent': 8,
|
|
'daemon.workerTimeoutMs': 120000,
|
|
}));
|
|
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
// Kills: return {} mutant in readDaemonConfigFromFile
|
|
expect(config.maxConcurrent).toBe(8);
|
|
expect(config.workerTimeoutMs).toBe(120000);
|
|
});
|
|
|
|
// MUTANT: Remove backoff timer value (30_000 → 0)
|
|
it('should use 30s backoff delay, not 0', async () => {
|
|
const daemon = new WorkerDaemon(tempDir, {
|
|
maxConcurrent: 2,
|
|
resourceThresholds: { maxCpuLoad: 0.001, minFreeMemoryPercent: 99.99 },
|
|
});
|
|
|
|
(daemon as any).pendingWorkers.push('map');
|
|
|
|
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
const before = setTimeoutSpy.mock.calls.length;
|
|
|
|
await (daemon as any).processPendingWorkers();
|
|
|
|
const backoffCalls = setTimeoutSpy.mock.calls
|
|
.slice(before)
|
|
.filter((call) => call[1] === 30_000);
|
|
// Kills: 30_000 → 0 mutant (must be exactly 30s)
|
|
expect(backoffCalls.length).toBe(1);
|
|
expect(backoffCalls[0][1]).toBe(30_000);
|
|
|
|
setTimeoutSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Container-aware CPU detection (WI-4)
|
|
// =========================================================================
|
|
describe('container-aware CPU detection', () => {
|
|
it('should expose getEffectiveCpuCount as a static method', () => {
|
|
expect(typeof WorkerDaemon.getEffectiveCpuCount).toBe('function');
|
|
});
|
|
|
|
it('should return a positive integer', () => {
|
|
const count = WorkerDaemon.getEffectiveCpuCount();
|
|
expect(count).toBeGreaterThanOrEqual(1);
|
|
expect(Number.isInteger(count)).toBe(true);
|
|
});
|
|
|
|
it('should match os.cpus().length on bare-metal (no cgroup quota)', () => {
|
|
// On a non-containerised host both cgroup reads will throw and the
|
|
// method falls back to os.cpus().length, so the two must agree.
|
|
const osCpuCount = cpus().length || 1;
|
|
const effectiveCount = WorkerDaemon.getEffectiveCpuCount();
|
|
|
|
// If we ARE inside a container with a quota the values may differ,
|
|
// so we only assert they are both positive — the equality check is
|
|
// a best-effort sanity test for bare-metal CI runners.
|
|
expect(effectiveCount).toBeGreaterThanOrEqual(1);
|
|
expect(effectiveCount).toBeLessThanOrEqual(osCpuCount);
|
|
});
|
|
|
|
it('should be used by the constructor for maxCpuLoad calculation', () => {
|
|
const daemon = new WorkerDaemon(tempDir);
|
|
const config = daemon.getStatus().config;
|
|
|
|
const effectiveCpus = WorkerDaemon.getEffectiveCpuCount();
|
|
const expectedMaxCpuLoad = Math.max(effectiveCpus * 0.8, 2.0);
|
|
|
|
expect(config.resourceThresholds.maxCpuLoad).toBeCloseTo(expectedMaxCpuLoad, 1);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// #2356 — Self-terminating lifecycle (TTL + idle shutdown)
|
|
// Caps how long a forgotten daemon can keep dispatching headless worker
|
|
// sweeps. Precedence: constructor arg > config.json (seconds) > env
|
|
// (RUFLO_DAEMON_TTL_SECS / RUFLO_DAEMON_IDLE_SECS) > built-in default.
|
|
// =========================================================================
|
|
describe('self-terminating lifecycle (ttl/idle)', () => {
|
|
const TTL_ENV = 'RUFLO_DAEMON_TTL_SECS';
|
|
const IDLE_ENV = 'RUFLO_DAEMON_IDLE_SECS';
|
|
let savedTtl: string | undefined;
|
|
let savedIdle: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
savedTtl = process.env[TTL_ENV];
|
|
savedIdle = process.env[IDLE_ENV];
|
|
delete process.env[TTL_ENV];
|
|
delete process.env[IDLE_ENV];
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedTtl === undefined) delete process.env[TTL_ENV]; else process.env[TTL_ENV] = savedTtl;
|
|
if (savedIdle === undefined) delete process.env[IDLE_ENV]; else process.env[IDLE_ENV] = savedIdle;
|
|
});
|
|
|
|
it('defaults ttlMs to 12h and idleShutdownMs to 0 (opt-in)', () => {
|
|
const config = new WorkerDaemon(tempDir).getStatus().config;
|
|
expect(config.ttlMs).toBe(12 * 60 * 60 * 1000);
|
|
expect(config.idleShutdownMs).toBe(0);
|
|
});
|
|
|
|
it('honors RUFLO_DAEMON_TTL_SECS env override (seconds → ms)', () => {
|
|
process.env[TTL_ENV] = '3600';
|
|
const config = new WorkerDaemon(tempDir).getStatus().config;
|
|
expect(config.ttlMs).toBe(3600 * 1000);
|
|
});
|
|
|
|
it('honors an explicit RUFLO_DAEMON_TTL_SECS=0 as "disabled" (not falling back to default)', () => {
|
|
process.env[TTL_ENV] = '0';
|
|
const config = new WorkerDaemon(tempDir).getStatus().config;
|
|
expect(config.ttlMs).toBe(0);
|
|
});
|
|
|
|
it('falls back to default for an invalid/negative env value', () => {
|
|
process.env[TTL_ENV] = '-5';
|
|
const config = new WorkerDaemon(tempDir).getStatus().config;
|
|
expect(config.ttlMs).toBe(12 * 60 * 60 * 1000);
|
|
});
|
|
|
|
it('honors RUFLO_DAEMON_IDLE_SECS env override', () => {
|
|
process.env[IDLE_ENV] = '7200';
|
|
const config = new WorkerDaemon(tempDir).getStatus().config;
|
|
expect(config.idleShutdownMs).toBe(7200 * 1000);
|
|
});
|
|
|
|
it('reads daemon.ttlSecs / daemon.idleSecs from config.json (seconds → ms)', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({
|
|
'daemon.ttlSecs': 1800,
|
|
'daemon.idleSecs': 900,
|
|
}));
|
|
const config = new WorkerDaemon(tempDir).getStatus().config;
|
|
expect(config.ttlMs).toBe(1800 * 1000);
|
|
expect(config.idleShutdownMs).toBe(900 * 1000);
|
|
});
|
|
|
|
it('honors config.json daemon.ttlSecs=0 as disabled', () => {
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({ 'daemon.ttlSecs': 0 }));
|
|
const config = new WorkerDaemon(tempDir).getStatus().config;
|
|
expect(config.ttlMs).toBe(0);
|
|
});
|
|
|
|
it('prefers constructor arg over config.json and env', () => {
|
|
process.env[TTL_ENV] = '3600';
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({ 'daemon.ttlSecs': 1800 }));
|
|
const config = new WorkerDaemon(tempDir, { ttlMs: 60_000 }).getStatus().config;
|
|
expect(config.ttlMs).toBe(60_000);
|
|
});
|
|
|
|
it('prefers config.json over env', () => {
|
|
process.env[TTL_ENV] = '3600';
|
|
const configFile = join(tempDir, '.claude-flow', 'config.json');
|
|
writeFileSync(configFile, JSON.stringify({ 'daemon.ttlSecs': 1800 }));
|
|
const config = new WorkerDaemon(tempDir).getStatus().config;
|
|
expect(config.ttlMs).toBe(1800 * 1000);
|
|
});
|
|
|
|
it('does not arm the lifecycle monitor when both limits are disabled', () => {
|
|
const daemon = new WorkerDaemon(tempDir, { ttlMs: 0, idleShutdownMs: 0 });
|
|
// startLifecycleMonitor is a no-op when both are 0 — no timer is stored.
|
|
(daemon as any).startLifecycleMonitor();
|
|
expect((daemon as any).lifecycleTimer).toBeUndefined();
|
|
});
|
|
|
|
it('arms (and can clear) the lifecycle monitor when a TTL is set', () => {
|
|
const daemon = new WorkerDaemon(tempDir, { ttlMs: 60_000, idleShutdownMs: 0 });
|
|
(daemon as any).startLifecycleMonitor();
|
|
expect((daemon as any).lifecycleTimer).toBeDefined();
|
|
// The monitor timer must be unref'd so it never keeps the process alive.
|
|
clearInterval((daemon as any).lifecycleTimer);
|
|
(daemon as any).lifecycleTimer = undefined;
|
|
});
|
|
});
|
|
});
|