mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
- Remove unused validateTokenCounts/validateTokensAndCost exports and PlanPhase type alias - Add execPattern() helper to eliminate 8 repetitive .lastIndex=0 + exec() loops - Centralize 11 magic number constants into config/ai-defaults.ts and config/server-timing.ts - Remove stale src/tui from tsconfig.json exclude - Fix CLAUDE.md inaccuracies (session helpers, app.js line count, module count) - Add 316 new tests: LRUMap (38), StaleExpirationMap (42), BufferAccumulator (33), respawn helpers (142), system-routes expansion (11→61) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { calculateHealthScore, shouldSkipClear, HealthInputs } from '../src/respawn-health.js';
|
|
import type { RespawnAggregateMetrics, CircuitBreakerStatus } from '../src/types/index.js';
|
|
|
|
/**
|
|
* Helper to create a default healthy HealthInputs object.
|
|
* Override specific fields as needed.
|
|
*/
|
|
function createHealthInputs(overrides: Partial<HealthInputs> = {}): HealthInputs {
|
|
return {
|
|
aggregateMetrics: {
|
|
totalCycles: 10,
|
|
successfulCycles: 10,
|
|
stuckRecoveryCycles: 0,
|
|
blockedCycles: 0,
|
|
errorCycles: 0,
|
|
avgCycleDurationMs: 5000,
|
|
avgIdleDetectionMs: 1000,
|
|
p90CycleDurationMs: 8000,
|
|
successRate: 100,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
circuitBreakerStatus: null,
|
|
iterationStallMetrics: null,
|
|
aiCheckerState: { status: 'ready', consecutiveErrors: 0 },
|
|
stuckRecoveryCount: 0,
|
|
maxStuckRecoveries: 5,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createCircuitBreakerStatus(state: 'CLOSED' | 'HALF_OPEN' | 'OPEN'): CircuitBreakerStatus {
|
|
return {
|
|
state,
|
|
consecutiveNoProgress: state === 'OPEN' ? 5 : state === 'HALF_OPEN' ? 2 : 0,
|
|
consecutiveSameError: 0,
|
|
consecutiveTestsFailure: 0,
|
|
lastProgressIteration: 0,
|
|
reason: `State: ${state}`,
|
|
reasonCode: 'no_progress',
|
|
lastTransitionAt: Date.now(),
|
|
lastErrorMessage: null,
|
|
};
|
|
}
|
|
|
|
describe('calculateHealthScore', () => {
|
|
describe('overall score calculation', () => {
|
|
it('should return 100 for perfectly healthy system', () => {
|
|
const result = calculateHealthScore(createHealthInputs());
|
|
|
|
expect(result.score).toBe(100);
|
|
expect(result.status).toBe('excellent');
|
|
expect(result.components.cycleSuccess).toBe(100);
|
|
expect(result.components.circuitBreaker).toBe(100);
|
|
expect(result.components.iterationProgress).toBe(100);
|
|
expect(result.components.aiChecker).toBe(100);
|
|
expect(result.components.stuckRecovery).toBe(100);
|
|
});
|
|
|
|
it('should return excellent for score >= 90', () => {
|
|
const result = calculateHealthScore(createHealthInputs());
|
|
expect(result.score).toBeGreaterThanOrEqual(90);
|
|
expect(result.status).toBe('excellent');
|
|
});
|
|
|
|
it('should return good for score >= 70 and < 90', () => {
|
|
// AI checker disabled = 30 (weight 0.15), everything else 100
|
|
// Score = 100*0.35 + 100*0.2 + 100*0.2 + 30*0.15 + 100*0.1 = 35+20+20+4.5+10 = 89.5 -> 90
|
|
// That's still excellent. Let's use circuitBreaker half-open for 50 (weight 0.2):
|
|
// Score = 100*0.35 + 50*0.2 + 100*0.2 + 100*0.15 + 100*0.1 = 35+10+20+15+10 = 90
|
|
// Still excellent. Use a lower cycleSuccess:
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aggregateMetrics: {
|
|
totalCycles: 10,
|
|
successfulCycles: 8,
|
|
stuckRecoveryCycles: 1,
|
|
blockedCycles: 1,
|
|
errorCycles: 0,
|
|
avgCycleDurationMs: 5000,
|
|
avgIdleDetectionMs: 1000,
|
|
p90CycleDurationMs: 8000,
|
|
successRate: 80,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN'),
|
|
})
|
|
);
|
|
// cycleSuccess=80, circuitBreaker=50, iterationProgress=100, aiChecker=100, stuckRecovery=100
|
|
// 80*0.35 + 50*0.2 + 100*0.2 + 100*0.15 + 100*0.1 = 28+10+20+15+10 = 83
|
|
expect(result.score).toBe(83);
|
|
expect(result.status).toBe('good');
|
|
});
|
|
|
|
it('should return degraded for score >= 50 and < 70', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aggregateMetrics: {
|
|
totalCycles: 10,
|
|
successfulCycles: 5,
|
|
stuckRecoveryCycles: 2,
|
|
blockedCycles: 2,
|
|
errorCycles: 1,
|
|
avgCycleDurationMs: 5000,
|
|
avgIdleDetectionMs: 1000,
|
|
p90CycleDurationMs: 8000,
|
|
successRate: 50,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN'),
|
|
aiCheckerState: { status: 'cooldown', consecutiveErrors: 0 },
|
|
})
|
|
);
|
|
// cycleSuccess=50, circuitBreaker=50, iterationProgress=100, aiChecker=70, stuckRecovery=100
|
|
// 50*0.35 + 50*0.2 + 100*0.2 + 70*0.15 + 100*0.1 = 17.5+10+20+10.5+10 = 68
|
|
expect(result.score).toBe(68);
|
|
expect(result.status).toBe('degraded');
|
|
});
|
|
|
|
it('should return critical for score < 50', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aggregateMetrics: {
|
|
totalCycles: 10,
|
|
successfulCycles: 0,
|
|
stuckRecoveryCycles: 3,
|
|
blockedCycles: 4,
|
|
errorCycles: 3,
|
|
avgCycleDurationMs: 5000,
|
|
avgIdleDetectionMs: 1000,
|
|
p90CycleDurationMs: 8000,
|
|
successRate: 0,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
|
|
iterationStallMetrics: {
|
|
stallDurationMs: 600000,
|
|
warningThresholdMs: 300000,
|
|
criticalThresholdMs: 600000,
|
|
},
|
|
aiCheckerState: { status: 'error', consecutiveErrors: 3 },
|
|
stuckRecoveryCount: 5,
|
|
maxStuckRecoveries: 5,
|
|
})
|
|
);
|
|
// cycleSuccess=0, circuitBreaker=0, iterationProgress=0, aiChecker=50, stuckRecovery=0
|
|
// 0*0.35 + 0*0.2 + 0*0.2 + 50*0.15 + 0*0.1 = 7.5 -> 8
|
|
expect(result.score).toBeLessThan(50);
|
|
expect(result.status).toBe('critical');
|
|
});
|
|
});
|
|
|
|
describe('component: cycleSuccess', () => {
|
|
it('should return 100 when no cycles have been tracked', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aggregateMetrics: {
|
|
totalCycles: 0,
|
|
successfulCycles: 0,
|
|
stuckRecoveryCycles: 0,
|
|
blockedCycles: 0,
|
|
errorCycles: 0,
|
|
avgCycleDurationMs: 0,
|
|
avgIdleDetectionMs: 0,
|
|
p90CycleDurationMs: 0,
|
|
successRate: 100,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
})
|
|
);
|
|
expect(result.components.cycleSuccess).toBe(100);
|
|
});
|
|
|
|
it('should use successRate from aggregate metrics', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aggregateMetrics: {
|
|
totalCycles: 20,
|
|
successfulCycles: 15,
|
|
stuckRecoveryCycles: 3,
|
|
blockedCycles: 1,
|
|
errorCycles: 1,
|
|
avgCycleDurationMs: 5000,
|
|
avgIdleDetectionMs: 1000,
|
|
p90CycleDurationMs: 8000,
|
|
successRate: 75,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
})
|
|
);
|
|
expect(result.components.cycleSuccess).toBe(75);
|
|
});
|
|
});
|
|
|
|
describe('component: circuitBreaker', () => {
|
|
it('should return 100 when circuit breaker is null', () => {
|
|
const result = calculateHealthScore(createHealthInputs({ circuitBreakerStatus: null }));
|
|
expect(result.components.circuitBreaker).toBe(100);
|
|
});
|
|
|
|
it('should return 100 for CLOSED state', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('CLOSED') })
|
|
);
|
|
expect(result.components.circuitBreaker).toBe(100);
|
|
});
|
|
|
|
it('should return 50 for HALF_OPEN state', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN') })
|
|
);
|
|
expect(result.components.circuitBreaker).toBe(50);
|
|
});
|
|
|
|
it('should return 0 for OPEN state', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('OPEN') })
|
|
);
|
|
expect(result.components.circuitBreaker).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('component: iterationProgress', () => {
|
|
it('should return 100 when stall metrics are null', () => {
|
|
const result = calculateHealthScore(createHealthInputs({ iterationStallMetrics: null }));
|
|
expect(result.components.iterationProgress).toBe(100);
|
|
});
|
|
|
|
it('should return 100 when stall duration is below half the warning threshold', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
iterationStallMetrics: {
|
|
stallDurationMs: 10000,
|
|
warningThresholdMs: 300000,
|
|
criticalThresholdMs: 600000,
|
|
},
|
|
})
|
|
);
|
|
expect(result.components.iterationProgress).toBe(100);
|
|
});
|
|
|
|
it('should return 70 when stall duration is at half the warning threshold', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
iterationStallMetrics: {
|
|
stallDurationMs: 150000,
|
|
warningThresholdMs: 300000,
|
|
criticalThresholdMs: 600000,
|
|
},
|
|
})
|
|
);
|
|
expect(result.components.iterationProgress).toBe(70);
|
|
});
|
|
|
|
it('should return 30 when stall duration hits warning threshold', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
iterationStallMetrics: {
|
|
stallDurationMs: 300000,
|
|
warningThresholdMs: 300000,
|
|
criticalThresholdMs: 600000,
|
|
},
|
|
})
|
|
);
|
|
expect(result.components.iterationProgress).toBe(30);
|
|
});
|
|
|
|
it('should return 0 when stall duration hits critical threshold', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
iterationStallMetrics: {
|
|
stallDurationMs: 600000,
|
|
warningThresholdMs: 300000,
|
|
criticalThresholdMs: 600000,
|
|
},
|
|
})
|
|
);
|
|
expect(result.components.iterationProgress).toBe(0);
|
|
});
|
|
|
|
it('should return 0 when stall duration exceeds critical threshold', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
iterationStallMetrics: {
|
|
stallDurationMs: 900000,
|
|
warningThresholdMs: 300000,
|
|
criticalThresholdMs: 600000,
|
|
},
|
|
})
|
|
);
|
|
expect(result.components.iterationProgress).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('component: aiChecker', () => {
|
|
it('should return 100 for ready status with no errors', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({ aiCheckerState: { status: 'ready', consecutiveErrors: 0 } })
|
|
);
|
|
expect(result.components.aiChecker).toBe(100);
|
|
});
|
|
|
|
it('should return 30 for disabled status', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({ aiCheckerState: { status: 'disabled', consecutiveErrors: 0 } })
|
|
);
|
|
expect(result.components.aiChecker).toBe(30);
|
|
});
|
|
|
|
it('should return 70 for cooldown status', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({ aiCheckerState: { status: 'cooldown', consecutiveErrors: 0 } })
|
|
);
|
|
expect(result.components.aiChecker).toBe(70);
|
|
});
|
|
|
|
it('should return 50 for ready status with consecutive errors', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({ aiCheckerState: { status: 'ready', consecutiveErrors: 2 } })
|
|
);
|
|
expect(result.components.aiChecker).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('component: stuckRecovery', () => {
|
|
it('should return 100 when no stuck recoveries', () => {
|
|
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 0, maxStuckRecoveries: 5 }));
|
|
expect(result.components.stuckRecovery).toBe(100);
|
|
});
|
|
|
|
it('should return 0 when stuck recoveries reach max', () => {
|
|
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 5, maxStuckRecoveries: 5 }));
|
|
expect(result.components.stuckRecovery).toBe(0);
|
|
});
|
|
|
|
it('should return proportional score for partial recoveries', () => {
|
|
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 2, maxStuckRecoveries: 5 }));
|
|
// 100 - (2/5) * 100 = 60
|
|
expect(result.components.stuckRecovery).toBe(60);
|
|
});
|
|
|
|
it('should return 0 when stuck recoveries exceed max', () => {
|
|
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 10, maxStuckRecoveries: 5 }));
|
|
expect(result.components.stuckRecovery).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('recommendations', () => {
|
|
it('should recommend no action for healthy system', () => {
|
|
const result = calculateHealthScore(createHealthInputs());
|
|
expect(result.recommendations).toEqual(['System is healthy. No action needed.']);
|
|
});
|
|
|
|
it('should recommend checking cycle success when low', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aggregateMetrics: {
|
|
totalCycles: 10,
|
|
successfulCycles: 5,
|
|
stuckRecoveryCycles: 3,
|
|
blockedCycles: 1,
|
|
errorCycles: 1,
|
|
avgCycleDurationMs: 5000,
|
|
avgIdleDetectionMs: 1000,
|
|
p90CycleDurationMs: 8000,
|
|
successRate: 50,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
})
|
|
);
|
|
expect(result.recommendations).toContainEqual(expect.stringContaining('Cycle success rate is low'));
|
|
});
|
|
|
|
it('should recommend reviewing circuit breaker when open', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
|
|
})
|
|
);
|
|
expect(result.recommendations).toContainEqual(expect.stringContaining('Circuit breaker is open'));
|
|
});
|
|
|
|
it('should recommend checking iteration when stalled', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
iterationStallMetrics: {
|
|
stallDurationMs: 400000,
|
|
warningThresholdMs: 300000,
|
|
criticalThresholdMs: 600000,
|
|
},
|
|
})
|
|
);
|
|
expect(result.recommendations).toContainEqual(expect.stringContaining('Iteration progress has stalled'));
|
|
});
|
|
|
|
it('should recommend checking AI checker when disabled', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aiCheckerState: { status: 'disabled', consecutiveErrors: 3 },
|
|
})
|
|
);
|
|
// aiChecker score = 30 (disabled), which is < 50 -> triggers recommendation
|
|
expect(result.recommendations).toContainEqual(expect.stringContaining('AI idle checker has errors'));
|
|
});
|
|
|
|
it('should recommend adjusting timeouts when stuck recoveries are high', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
stuckRecoveryCount: 4,
|
|
maxStuckRecoveries: 5,
|
|
})
|
|
);
|
|
expect(result.recommendations).toContainEqual(expect.stringContaining('Multiple stuck-state recoveries'));
|
|
});
|
|
|
|
it('should include multiple recommendations when multiple components are degraded', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aggregateMetrics: {
|
|
totalCycles: 10,
|
|
successfulCycles: 3,
|
|
stuckRecoveryCycles: 3,
|
|
blockedCycles: 2,
|
|
errorCycles: 2,
|
|
avgCycleDurationMs: 5000,
|
|
avgIdleDetectionMs: 1000,
|
|
p90CycleDurationMs: 8000,
|
|
successRate: 30,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
|
|
stuckRecoveryCount: 4,
|
|
maxStuckRecoveries: 5,
|
|
})
|
|
);
|
|
expect(result.recommendations.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
});
|
|
|
|
describe('summary', () => {
|
|
it('should include score in summary', () => {
|
|
const result = calculateHealthScore(createHealthInputs());
|
|
expect(result.summary).toContain('100/100');
|
|
});
|
|
|
|
it('should describe excellent status', () => {
|
|
const result = calculateHealthScore(createHealthInputs());
|
|
expect(result.summary).toContain('excellently');
|
|
});
|
|
|
|
it('should describe critical status with lowest component', () => {
|
|
const result = calculateHealthScore(
|
|
createHealthInputs({
|
|
aggregateMetrics: {
|
|
totalCycles: 10,
|
|
successfulCycles: 0,
|
|
stuckRecoveryCycles: 5,
|
|
blockedCycles: 3,
|
|
errorCycles: 2,
|
|
avgCycleDurationMs: 5000,
|
|
avgIdleDetectionMs: 1000,
|
|
p90CycleDurationMs: 8000,
|
|
successRate: 0,
|
|
lastUpdatedAt: Date.now(),
|
|
},
|
|
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
|
|
})
|
|
);
|
|
expect(result.summary).toContain('critical');
|
|
});
|
|
|
|
it('should include calculatedAt timestamp', () => {
|
|
const before = Date.now();
|
|
const result = calculateHealthScore(createHealthInputs());
|
|
const after = Date.now();
|
|
|
|
expect(result.calculatedAt).toBeGreaterThanOrEqual(before);
|
|
expect(result.calculatedAt).toBeLessThanOrEqual(after);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('shouldSkipClear', () => {
|
|
it('should not skip when token count is 0 (unknown)', () => {
|
|
expect(shouldSkipClear(0, 50, 200000)).toBe(false);
|
|
});
|
|
|
|
it('should skip when usage is below threshold', () => {
|
|
// 10000 / 200000 = 5% < 50%
|
|
expect(shouldSkipClear(10000, 50, 200000)).toBe(true);
|
|
});
|
|
|
|
it('should not skip when usage is above threshold', () => {
|
|
// 150000 / 200000 = 75% > 50%
|
|
expect(shouldSkipClear(150000, 50, 200000)).toBe(false);
|
|
});
|
|
|
|
it('should not skip when usage equals threshold', () => {
|
|
// 100000 / 200000 = 50% == 50% -> not less than, so false
|
|
expect(shouldSkipClear(100000, 50, 200000)).toBe(false);
|
|
});
|
|
|
|
it('should handle edge case of very small threshold', () => {
|
|
// 1000 / 200000 = 0.5% < 1%
|
|
expect(shouldSkipClear(1000, 1, 200000)).toBe(true);
|
|
});
|
|
|
|
it('should handle 100% threshold (always skip)', () => {
|
|
// 190000 / 200000 = 95% < 100%
|
|
expect(shouldSkipClear(190000, 100, 200000)).toBe(true);
|
|
});
|
|
|
|
it('should handle 0% threshold (never skip except when unknown)', () => {
|
|
// Any positive count / total > 0% so not < 0%
|
|
expect(shouldSkipClear(1, 0, 200000)).toBe(false);
|
|
});
|
|
});
|