Files
evomap__evolver/test/cycleHardTimeout.test.js
evolver-publish 8e2e91d456 Release v1.89.18
2026-06-28 22:47:18 +08:00

143 lines
5.0 KiB
JavaScript

// Regression guard for Issue #19 (cross-repo cycle hard timeout).
//
// The daemon main loop in index.js must wrap evolve.run() with a
// Promise.race-based hard timeout so a hung internal call (unclosed
// socket, stuck LLM, etc.) cannot freeze the process for days. See
// the cross-repo timeout plan for context.
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const indexPath = path.resolve(__dirname, '..', 'index.js');
const source = fs.readFileSync(indexPath, 'utf8');
describe('CycleTimeoutError class', () => {
const { CycleTimeoutError } = require('..');
it('exports a real Error subclass with the right code/name', () => {
const err = new CycleTimeoutError(2700000, 'evolve.run', 5372);
assert.ok(err instanceof Error, 'should extend Error');
assert.equal(err.name, 'CycleTimeoutError');
assert.equal(err.code, 'CYCLE_TIMEOUT');
assert.equal(err.timeoutMs, 2700000);
assert.equal(err.phase, 'evolve.run');
assert.equal(err.cycleNum, 5372);
assert.match(err.message, /Cycle hard-timeout exceeded after 2700000ms/);
assert.match(err.message, /cycle=5372/);
assert.match(err.message, /phase=evolve\.run/);
});
});
describe('parseBoolEnv helper', () => {
const { parseBoolEnv } = require('..');
it('returns fallback for null / undefined / empty / whitespace', () => {
assert.equal(parseBoolEnv(undefined, true), true);
assert.equal(parseBoolEnv(null, false), false);
assert.equal(parseBoolEnv('', true), true);
assert.equal(parseBoolEnv(' ', false), false);
});
it('treats common truthy/falsy strings consistently', () => {
for (const v of ['true', 'TRUE', '1', 'on', 'yes', ' Yes ']) {
assert.equal(parseBoolEnv(v, false), true, 'should be truthy: ' + v);
}
for (const v of ['false', 'FALSE', '0', 'off', 'no', ' No ']) {
assert.equal(parseBoolEnv(v, true), false, 'should be falsy: ' + v);
}
});
it('falls back to default for unknown strings (does not throw)', () => {
assert.equal(parseBoolEnv('maybe', true), true);
assert.equal(parseBoolEnv('maybe', false), false);
});
});
describe('index.js daemon loop hard-timeout structure', () => {
it('wraps evolve.run() with Promise.race against a CycleTimeoutError', () => {
assert.match(
source,
/Promise\.race\(\[evolvePromise,\s*timeoutPromise\]\)/,
'expected Promise.race([evolvePromise, timeoutPromise]) in main loop'
);
assert.match(
source,
/new CycleTimeoutError\(\s*cycleTimeoutMs,/,
'expected CycleTimeoutError to be constructed with cycleTimeoutMs'
);
});
it('honors EVOLVER_CYCLE_TIMEOUT_ENABLED env (default ON)', () => {
assert.match(
source,
/parseBoolEnv\(process\.env\.EVOLVER_CYCLE_TIMEOUT_ENABLED,\s*true\)/,
'EVOLVER_CYCLE_TIMEOUT_ENABLED must default to true'
);
});
it('honors EVOLVER_CYCLE_TIMEOUT_MS env with 45 min default', () => {
assert.match(
source,
/parseMs\(process\.env\.EVOLVER_CYCLE_TIMEOUT_MS,\s*2700000\)/,
'EVOLVER_CYCLE_TIMEOUT_MS must default to 2700000 (45 min)'
);
});
it('writes cycle_progress.json on cycle start with phase evolve.run', () => {
assert.match(
source,
/writeCycleProgressAtomic\(cycleProgressPath,\s*\{[\s\S]*?phase:\s*'evolve\.run'/,
'expected progress write at cycle start'
);
});
it('refreshes progress periodically while evolve.run is in flight', () => {
assert.match(
source,
/progressTicker = setInterval\(/,
'expected setInterval-based progress ticker'
);
assert.match(
source,
/progressUpdateMs/,
'progressUpdateMs constant must exist'
);
});
it('clears the timer + ticker in finally (no leaked handles)', () => {
assert.match(
source,
/finally\s*\{[\s\S]*?clearInterval\(progressTicker\)[\s\S]*?clearTimeout\(cycleTimeoutHandle\)/,
'finally block must clear both progressTicker and cycleTimeoutHandle'
);
});
it('on CYCLE_TIMEOUT, delegates timeout policy and respawn exits with code 1', () => {
assert.match(
source,
/error\.code === 'CYCLE_TIMEOUT'[\s\S]*?handleCycleTimeout\(\{[\s\S]*?spawnReplacementFn: spawnReplacementProcess/,
'CYCLE_TIMEOUT branch must delegate policy to handleCycleTimeout'
);
assert.match(
source,
/timeoutAction\.action === 'respawn'[\s\S]*?releaseLock\(\)[\s\S]*?process\.exit\(1\)/,
'respawn action must release lock and exit(1)'
);
});
it('on non-fatal CYCLE_TIMEOUT, waits for original evolve.run() to settle', () => {
assert.match(
source,
/timeoutAction\.action === 'continue'[\s\S]*?await waitForTimedOutEvolvePromise\(evolvePromise\)/,
'continue action must wait for the timed-out evolve.run() before the next cycle'
);
assert.doesNotMatch(
source,
/timeoutAction\.action === 'continue'[\s\S]*?observeTimedOutEvolvePromise\(evolvePromise\)/,
'continue action must not only observe the timed-out evolvePromise'
);
});
});