Files
Dragan Spiridonov 2d5126a053 feat(hooks): ADR-100 tool-loop circuit breaker — warn at 3, block at 5
Breaks runaway retry loops: when the same Bash command fails consecutively
(normalized signature, cross-process via a small JSON sidecar under
.agentic-qe/), pre-command warns at 3 failures and emits a BLOCK
recommendation with a recovery hint at 5. Advisory by default;
AQE_STRICT_TOOL_LOOP=1 upgrades the block to a PreToolUse deny. A
successful run resets the breaker; an open breaker grants a half-open
probe after 2 minutes; stale entries pruned after 30 minutes.

Mirrors the ADR-064 domain-circuit-breaker semantics at command level.
Sidecar instead of memory.db: pre-command deliberately runs without a DB
open, and each hook call is a fresh process so in-process state is
impossible. Fail-open everywhere (corrupt state file => allow).

11 unit tests green; E2E verified with real aqe hooks pre/post-command:
warn after 3, BLOCK after 5, strict-mode deny envelope, reset on success.

Part of #520 (improvement 3/7, Tier 1 complete).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:48:37 +00:00

136 lines
4.2 KiB
TypeScript

/**
* Unit tests for the tool-loop circuit breaker (ADR-100)
*
* Consecutive failures of the same command: warn at 3, block at 5,
* reset on success, half-open probe after the window, strict-mode env flag.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ToolLoopGuardrail } from '../../../../src/cli/commands/hooks-handlers/tool-loop-guardrail';
describe('ToolLoopGuardrail', () => {
let dir: string;
let guardrail: ToolLoopGuardrail;
const CMD = 'npm run definitely-broken';
const failTimes = (n: number, at: number = 1_000_000) => {
for (let i = 0; i < n; i++) {
guardrail.record(CMD, false, at + i);
}
};
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tool-loop-'));
guardrail = new ToolLoopGuardrail({
statePath: path.join(dir, 'tool-loop-state.json'),
});
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('should allow a command below the warn threshold', () => {
failTimes(2);
const check = guardrail.check(CMD, 1_000_010);
expect(check.verdict).toBe('allow');
expect(check.consecutiveFailures).toBe(2);
});
it('should warn at 3 consecutive failures', () => {
failTimes(3);
const check = guardrail.check(CMD, 1_000_010);
expect(check.verdict).toBe('warn');
expect(check.consecutiveFailures).toBe(3);
expect(check.hint).toContain('3x');
});
it('should block at 5 consecutive failures with a recovery hint', () => {
failTimes(5);
const check = guardrail.check(CMD, 1_000_010);
expect(check.verdict).toBe('block');
expect(check.consecutiveFailures).toBe(5);
expect(check.hint).toContain('change the approach');
});
it('should reset the breaker when the command succeeds', () => {
failTimes(5);
guardrail.record(CMD, true, 1_000_010);
const check = guardrail.check(CMD, 1_000_020);
expect(check.verdict).toBe('allow');
expect(check.consecutiveFailures).toBe(0);
});
it('should track distinct commands independently', () => {
failTimes(5);
guardrail.record('npm run other-task', false, 1_000_010);
expect(guardrail.check('npm run other-task', 1_000_020).verdict).toBe('allow');
expect(guardrail.check(CMD, 1_000_020).verdict).toBe('block');
});
it('should allow a half-open probe after the half-open window elapses', () => {
failTimes(5, 1_000_000);
const check = guardrail.check(CMD, 1_000_004 + 120_000);
expect(check.verdict).toBe('warn');
expect(check.halfOpen).toBe(true);
expect(check.hint).toContain('probe');
});
it('should treat whitespace-variant commands as the same signature', () => {
failTimes(5);
const check = guardrail.check(' npm run definitely-broken ', 1_000_010);
expect(check.verdict).toBe('block');
});
it('should persist state across instances (fresh process per hook call)', () => {
failTimes(5);
const secondProcess = new ToolLoopGuardrail({
statePath: path.join(dir, 'tool-loop-state.json'),
});
expect(secondProcess.check(CMD, 1_000_010).verdict).toBe('block');
});
it('should fail open on a corrupt state file', () => {
fs.writeFileSync(path.join(dir, 'tool-loop-state.json'), '{not json');
const check = guardrail.check(CMD, 1_000_000);
expect(check.verdict).toBe('allow');
});
it('should report strict mode only when AQE_STRICT_TOOL_LOOP is set', () => {
expect(ToolLoopGuardrail.isStrict({} as NodeJS.ProcessEnv)).toBe(false);
expect(ToolLoopGuardrail.isStrict({ AQE_STRICT_TOOL_LOOP: '0' } as unknown as NodeJS.ProcessEnv)).toBe(false);
expect(ToolLoopGuardrail.isStrict({ AQE_STRICT_TOOL_LOOP: '1' } as unknown as NodeJS.ProcessEnv)).toBe(true);
expect(ToolLoopGuardrail.isStrict({ AQE_STRICT_TOOL_LOOP: 'true' } as unknown as NodeJS.ProcessEnv)).toBe(true);
});
it('should prune stale entries on write', () => {
failTimes(5, 1_000_000);
// A later failure of another command triggers a write 31 minutes on,
// pruning the stale CMD entry
guardrail.record('other', false, 1_000_000 + 31 * 60_000);
const check = guardrail.check(CMD, 1_000_000 + 31 * 60_000);
expect(check.verdict).toBe('allow');
});
});