/** * 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; }); }); });