Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:02:19 +08:00

1163 lines
39 KiB
TypeScript

/**
* V3 Claude-Flow Load Balancer Unit Tests
*
* London School TDD - Behavior Verification
* Tests load calculation, rebalancing, preview, and imbalance detection
*
* @module v3/claims/tests/load-balancer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMock, type MockedInterface } from '../../testing/src/helpers/create-mock.js';
// =============================================================================
// Domain Types
// =============================================================================
type AgentType = 'coder' | 'reviewer' | 'tester' | 'planner' | 'researcher' | 'queen-coordinator';
type AgentStatus = 'idle' | 'busy' | 'overloaded' | 'offline';
interface AgentLoad {
agentId: string;
agentType: AgentType;
status: AgentStatus;
currentClaims: number;
maxClaims: number;
utilizationPercent: number;
queuedTasks: number;
averageTaskDuration: number;
lastActivityAt: Date;
}
interface ClaimSummary {
issueId: string;
agentId: string;
priority: number;
claimedAt: Date;
estimatedDuration: number;
}
interface RebalanceAction {
type: 'move' | 'reassign' | 'defer';
claim: ClaimSummary;
fromAgent: string;
toAgent: string;
reason: string;
}
interface RebalanceResult {
success: boolean;
actions: RebalanceAction[];
movedClaims: number;
error?: string;
}
interface RebalancePreview {
proposedActions: RebalanceAction[];
expectedImprovement: number;
riskLevel: 'low' | 'medium' | 'high';
warnings: string[];
}
interface ImbalanceReport {
detected: boolean;
severity: 'none' | 'minor' | 'moderate' | 'severe';
overloadedAgents: string[];
underloadedAgents: string[];
imbalanceScore: number;
recommendations: string[];
}
// =============================================================================
// Interfaces (Collaborators)
// =============================================================================
interface IAgentRegistry {
getAgent(agentId: string): Promise<AgentInfo | null>;
listAgents(filter?: AgentFilter): Promise<AgentInfo[]>;
updateAgentStatus(agentId: string, status: AgentStatus): Promise<void>;
}
interface AgentInfo {
id: string;
type: AgentType;
status: AgentStatus;
maxClaims: number;
capabilities: string[];
}
interface AgentFilter {
type?: AgentType;
status?: AgentStatus;
available?: boolean;
}
interface IClaimRepository {
findByAgent(agentId: string): Promise<ClaimSummary[]>;
findAll(): Promise<ClaimSummary[]>;
updateClaimAgent(issueId: string, newAgentId: string): Promise<void>;
}
interface ILoadCalculator {
calculateUtilization(claims: number, maxClaims: number): number;
calculateAverageTaskDuration(claims: ClaimSummary[]): number;
determineStatus(utilization: number): AgentStatus;
}
interface IRebalanceStrategy {
selectClaimsToMove(
overloaded: AgentLoad,
underloaded: AgentLoad[],
claims: ClaimSummary[]
): RebalanceAction[];
prioritizeActions(actions: RebalanceAction[]): RebalanceAction[];
assessRisk(actions: RebalanceAction[]): 'low' | 'medium' | 'high';
}
interface IEventStore {
append(event: LoadBalancerEvent): Promise<void>;
}
interface LoadBalancerEvent {
type: string;
timestamp: Date;
payload: unknown;
}
// =============================================================================
// Configuration
// =============================================================================
interface LoadBalancerConfig {
imbalanceThreshold: number; // Percentage difference that triggers rebalance
maxActionsPerRebalance: number;
minUtilizationForRebalance: number;
cooldownPeriodMs: number;
priorityWeights: {
claimCount: number;
utilization: number;
queueDepth: number;
};
}
const DEFAULT_CONFIG: LoadBalancerConfig = {
imbalanceThreshold: 30, // 30% difference
maxActionsPerRebalance: 10,
minUtilizationForRebalance: 20,
cooldownPeriodMs: 60000,
priorityWeights: {
claimCount: 0.4,
utilization: 0.4,
queueDepth: 0.2,
},
};
// =============================================================================
// Service Under Test
// =============================================================================
class LoadBalancer {
private lastRebalanceAt: Date | null = null;
constructor(
private readonly agentRegistry: IAgentRegistry,
private readonly claimRepository: IClaimRepository,
private readonly loadCalculator: ILoadCalculator,
private readonly rebalanceStrategy: IRebalanceStrategy,
private readonly eventStore: IEventStore,
private readonly config: LoadBalancerConfig = DEFAULT_CONFIG
) {}
async getAgentLoad(agentId: string): Promise<AgentLoad> {
const agent = await this.agentRegistry.getAgent(agentId);
if (!agent) {
throw new Error(`Agent not found: ${agentId}`);
}
const claims = await this.claimRepository.findByAgent(agentId);
const currentClaims = claims.length;
const utilizationPercent = this.loadCalculator.calculateUtilization(
currentClaims,
agent.maxClaims
);
const status = this.loadCalculator.determineStatus(utilizationPercent);
return {
agentId,
agentType: agent.type,
status,
currentClaims,
maxClaims: agent.maxClaims,
utilizationPercent,
queuedTasks: 0, // Would be fetched from task queue
averageTaskDuration: this.loadCalculator.calculateAverageTaskDuration(claims),
lastActivityAt: claims.length > 0
? claims.reduce((latest, c) => c.claimedAt > latest ? c.claimedAt : latest, claims[0].claimedAt)
: new Date(),
};
}
async rebalance(): Promise<RebalanceResult> {
// Check cooldown
if (this.lastRebalanceAt) {
const timeSinceLastRebalance = Date.now() - this.lastRebalanceAt.getTime();
if (timeSinceLastRebalance < this.config.cooldownPeriodMs) {
return {
success: false,
actions: [],
movedClaims: 0,
error: 'Rebalance on cooldown',
};
}
}
// Get all agents and their loads
const agents = await this.agentRegistry.listAgents({ available: true });
const agentLoads: AgentLoad[] = await Promise.all(
agents.map((agent) => this.getAgentLoad(agent.id))
);
// Identify overloaded and underloaded agents
const overloaded = agentLoads.filter((a) => a.status === 'overloaded');
const underloaded = agentLoads.filter(
(a) => a.status === 'idle' || a.utilizationPercent < this.config.minUtilizationForRebalance
);
if (overloaded.length === 0 || underloaded.length === 0) {
this.lastRebalanceAt = new Date(); // Set cooldown even when nothing to rebalance
return {
success: true,
actions: [],
movedClaims: 0,
};
}
// Generate rebalance actions
const allActions: RebalanceAction[] = [];
for (const overloadedAgent of overloaded) {
const claims = await this.claimRepository.findByAgent(overloadedAgent.agentId);
const actions = this.rebalanceStrategy.selectClaimsToMove(
overloadedAgent,
underloaded,
claims
);
allActions.push(...actions);
}
// Prioritize and limit actions
const prioritizedActions = this.rebalanceStrategy.prioritizeActions(allActions);
const limitedActions = prioritizedActions.slice(0, this.config.maxActionsPerRebalance);
// Execute actions
let movedClaims = 0;
for (const action of limitedActions) {
if (action.type === 'move' || action.type === 'reassign') {
await this.claimRepository.updateClaimAgent(action.claim.issueId, action.toAgent);
movedClaims++;
}
}
this.lastRebalanceAt = new Date();
// Emit event
await this.eventStore.append({
type: 'LoadRebalanced',
timestamp: new Date(),
payload: {
actions: limitedActions,
movedClaims,
overloadedCount: overloaded.length,
underloadedCount: underloaded.length,
},
});
return {
success: true,
actions: limitedActions,
movedClaims,
};
}
async previewRebalance(): Promise<RebalancePreview> {
// Get all agents and their loads (read-only)
const agents = await this.agentRegistry.listAgents({ available: true });
const agentLoads: AgentLoad[] = await Promise.all(
agents.map((agent) => this.getAgentLoad(agent.id))
);
// Identify overloaded and underloaded agents
const overloaded = agentLoads.filter((a) => a.status === 'overloaded');
const underloaded = agentLoads.filter(
(a) => a.status === 'idle' || a.utilizationPercent < this.config.minUtilizationForRebalance
);
const warnings: string[] = [];
if (overloaded.length === 0) {
warnings.push('No overloaded agents detected');
}
if (underloaded.length === 0) {
warnings.push('No underloaded agents available');
}
// Generate proposed actions without executing
const proposedActions: RebalanceAction[] = [];
for (const overloadedAgent of overloaded) {
const claims = await this.claimRepository.findByAgent(overloadedAgent.agentId);
const actions = this.rebalanceStrategy.selectClaimsToMove(
overloadedAgent,
underloaded,
claims
);
proposedActions.push(...actions);
}
const prioritizedActions = this.rebalanceStrategy.prioritizeActions(proposedActions);
const limitedActions = prioritizedActions.slice(0, this.config.maxActionsPerRebalance);
// Calculate expected improvement
const currentImbalance = this.calculateImbalanceScore(agentLoads);
const expectedImprovement = Math.min(limitedActions.length * 5, 50); // Simplified calculation
// Assess risk
const riskLevel = this.rebalanceStrategy.assessRisk(limitedActions);
return {
proposedActions: limitedActions,
expectedImprovement,
riskLevel,
warnings,
};
}
async detectImbalance(): Promise<ImbalanceReport> {
const agents = await this.agentRegistry.listAgents({ available: true });
const agentLoads: AgentLoad[] = await Promise.all(
agents.map((agent) => this.getAgentLoad(agent.id))
);
const overloaded = agentLoads.filter((a) => a.status === 'overloaded');
const underloaded = agentLoads.filter(
(a) => a.status === 'idle' || a.utilizationPercent < this.config.minUtilizationForRebalance
);
const imbalanceScore = this.calculateImbalanceScore(agentLoads);
const detected = imbalanceScore > this.config.imbalanceThreshold;
let severity: 'none' | 'minor' | 'moderate' | 'severe' = 'none';
if (imbalanceScore > 60) {
severity = 'severe';
} else if (imbalanceScore > 40) {
severity = 'moderate';
} else if (imbalanceScore > this.config.imbalanceThreshold) {
severity = 'minor';
}
const recommendations: string[] = [];
if (detected) {
if (overloaded.length > 0) {
recommendations.push(`${overloaded.length} agent(s) are overloaded and need relief`);
}
if (underloaded.length > 0) {
recommendations.push(`${underloaded.length} agent(s) have capacity available`);
}
if (severity === 'severe') {
recommendations.push('Immediate rebalancing recommended');
}
}
// Emit event if imbalance detected
if (detected) {
await this.eventStore.append({
type: 'ImbalanceDetected',
timestamp: new Date(),
payload: {
severity,
imbalanceScore,
overloadedAgents: overloaded.map((a) => a.agentId),
underloadedAgents: underloaded.map((a) => a.agentId),
},
});
}
return {
detected,
severity,
overloadedAgents: overloaded.map((a) => a.agentId),
underloadedAgents: underloaded.map((a) => a.agentId),
imbalanceScore,
recommendations,
};
}
private calculateImbalanceScore(loads: AgentLoad[]): number {
if (loads.length === 0) return 0;
const utilizations = loads.map((l) => l.utilizationPercent);
const maxUtilization = Math.max(...utilizations);
const minUtilization = Math.min(...utilizations);
const avgUtilization = utilizations.reduce((a, b) => a + b, 0) / utilizations.length;
// Calculate standard deviation
const variance =
utilizations.reduce((sum, u) => sum + Math.pow(u - avgUtilization, 2), 0) / utilizations.length;
const stdDev = Math.sqrt(variance);
// Score based on range and standard deviation
const rangeScore = maxUtilization - minUtilization;
const deviationScore = stdDev * 2;
return Math.min(100, rangeScore * 0.6 + deviationScore * 0.4);
}
// For testing - reset cooldown
resetCooldown(): void {
this.lastRebalanceAt = null;
}
}
// =============================================================================
// Test Suite
// =============================================================================
describe('LoadBalancer', () => {
let service: LoadBalancer;
let mockAgentRegistry: MockedInterface<IAgentRegistry>;
let mockClaimRepository: MockedInterface<IClaimRepository>;
let mockLoadCalculator: MockedInterface<ILoadCalculator>;
let mockRebalanceStrategy: MockedInterface<IRebalanceStrategy>;
let mockEventStore: MockedInterface<IEventStore>;
const baseDate = new Date('2024-01-15T10:00:00Z');
// Sample agent data
const coderAgent: AgentInfo = {
id: 'coder-1',
type: 'coder',
status: 'busy',
maxClaims: 5,
capabilities: ['coding', 'debugging'],
};
const reviewerAgent: AgentInfo = {
id: 'reviewer-1',
type: 'reviewer',
status: 'idle',
maxClaims: 10,
capabilities: ['review', 'analysis'],
};
const testerAgent: AgentInfo = {
id: 'tester-1',
type: 'tester',
status: 'busy',
maxClaims: 8,
capabilities: ['testing', 'validation'],
};
// Sample claim data
const sampleClaims: ClaimSummary[] = [
{ issueId: 'issue-1', agentId: 'coder-1', priority: 1, claimedAt: baseDate, estimatedDuration: 3600 },
{ issueId: 'issue-2', agentId: 'coder-1', priority: 2, claimedAt: baseDate, estimatedDuration: 7200 },
{ issueId: 'issue-3', agentId: 'coder-1', priority: 1, claimedAt: baseDate, estimatedDuration: 1800 },
{ issueId: 'issue-4', agentId: 'coder-1', priority: 3, claimedAt: baseDate, estimatedDuration: 5400 },
{ issueId: 'issue-5', agentId: 'coder-1', priority: 2, claimedAt: baseDate, estimatedDuration: 2700 },
];
beforeEach(() => {
mockAgentRegistry = createMock<IAgentRegistry>();
mockClaimRepository = createMock<IClaimRepository>();
mockLoadCalculator = createMock<ILoadCalculator>();
mockRebalanceStrategy = createMock<IRebalanceStrategy>();
mockEventStore = createMock<IEventStore>();
// Default mock behaviors
mockLoadCalculator.calculateUtilization.mockImplementation(
(claims, max) => (claims / max) * 100
);
mockLoadCalculator.calculateAverageTaskDuration.mockReturnValue(3600);
mockLoadCalculator.determineStatus.mockImplementation((util) => {
if (util >= 80) return 'overloaded';
if (util >= 50) return 'busy';
return 'idle';
});
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue([]);
mockRebalanceStrategy.prioritizeActions.mockImplementation((actions) => actions);
mockRebalanceStrategy.assessRisk.mockReturnValue('low');
mockEventStore.append.mockResolvedValue(undefined);
service = new LoadBalancer(
mockAgentRegistry,
mockClaimRepository,
mockLoadCalculator,
mockRebalanceStrategy,
mockEventStore
);
});
// ===========================================================================
// getAgentLoad() tests
// ===========================================================================
describe('getAgentLoad', () => {
it('should calculate correct load for agent', async () => {
// Given
mockAgentRegistry.getAgent.mockResolvedValue(coderAgent);
mockClaimRepository.findByAgent.mockResolvedValue(sampleClaims.slice(0, 3)); // 3 claims
// When
const load = await service.getAgentLoad('coder-1');
// Then
expect(load.agentId).toBe('coder-1');
expect(load.agentType).toBe('coder');
expect(load.currentClaims).toBe(3);
expect(load.maxClaims).toBe(5);
expect(load.utilizationPercent).toBe(60); // 3/5 * 100
});
it('should determine correct status based on utilization', async () => {
// Given
mockAgentRegistry.getAgent.mockResolvedValue(coderAgent);
mockClaimRepository.findByAgent.mockResolvedValue(sampleClaims); // 5 claims = 100% utilization
// When
const load = await service.getAgentLoad('coder-1');
// Then
expect(load.status).toBe('overloaded');
expect(mockLoadCalculator.determineStatus).toHaveBeenCalledWith(100);
});
it('should throw error for non-existent agent', async () => {
// Given
mockAgentRegistry.getAgent.mockResolvedValue(null);
// When/Then
await expect(service.getAgentLoad('unknown-agent'))
.rejects.toThrow('Agent not found: unknown-agent');
});
it('should calculate average task duration', async () => {
// Given
mockAgentRegistry.getAgent.mockResolvedValue(coderAgent);
mockClaimRepository.findByAgent.mockResolvedValue(sampleClaims.slice(0, 2));
mockLoadCalculator.calculateAverageTaskDuration.mockReturnValue(5400);
// When
const load = await service.getAgentLoad('coder-1');
// Then
expect(load.averageTaskDuration).toBe(5400);
expect(mockLoadCalculator.calculateAverageTaskDuration).toHaveBeenCalled();
});
it('should use current time for lastActivityAt when no claims', async () => {
// Given
mockAgentRegistry.getAgent.mockResolvedValue(coderAgent);
mockClaimRepository.findByAgent.mockResolvedValue([]);
// When
const load = await service.getAgentLoad('coder-1');
// Then
expect(load.lastActivityAt).toBeDefined();
expect(load.currentClaims).toBe(0);
});
it('should use latest claim date for lastActivityAt', async () => {
// Given
mockAgentRegistry.getAgent.mockResolvedValue(coderAgent);
const claimsWithDates: ClaimSummary[] = [
{ issueId: 'issue-1', agentId: 'coder-1', priority: 1, claimedAt: new Date('2024-01-14T10:00:00Z'), estimatedDuration: 3600 },
{ issueId: 'issue-2', agentId: 'coder-1', priority: 2, claimedAt: new Date('2024-01-15T10:00:00Z'), estimatedDuration: 3600 },
{ issueId: 'issue-3', agentId: 'coder-1', priority: 1, claimedAt: new Date('2024-01-13T10:00:00Z'), estimatedDuration: 3600 },
];
mockClaimRepository.findByAgent.mockResolvedValue(claimsWithDates);
// When
const load = await service.getAgentLoad('coder-1');
// Then
expect(load.lastActivityAt).toEqual(new Date('2024-01-15T10:00:00Z'));
});
});
// ===========================================================================
// rebalance() tests
// ===========================================================================
describe('rebalance', () => {
it('should move work from overloaded to underloaded agents', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims) // Coder has 5 claims (overloaded)
.mockResolvedValueOnce([]) // Reviewer has 0 claims (idle)
.mockResolvedValueOnce(sampleClaims); // For rebalance selection
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
const moveAction: RebalanceAction = {
type: 'move',
claim: sampleClaims[0],
fromAgent: 'coder-1',
toAgent: 'reviewer-1',
reason: 'Load balancing',
};
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue([moveAction]);
mockClaimRepository.updateClaimAgent.mockResolvedValue(undefined);
// When
const result = await service.rebalance();
// Then
expect(result.success).toBe(true);
expect(result.movedClaims).toBe(1);
expect(mockClaimRepository.updateClaimAgent).toHaveBeenCalledWith('issue-1', 'reviewer-1');
});
it('should not rebalance when on cooldown', async () => {
// Given - First rebalance
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent.mockResolvedValue(coderAgent);
mockClaimRepository.findByAgent.mockResolvedValue([]);
mockLoadCalculator.determineStatus.mockReturnValue('idle');
await service.rebalance(); // First rebalance sets cooldown
// When - Second rebalance immediately
const result = await service.rebalance();
// Then
expect(result.success).toBe(false);
expect(result.error).toBe('Rebalance on cooldown');
});
it('should return empty result when no imbalance', async () => {
// Given - All agents balanced
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent.mockResolvedValue([]); // No claims
mockLoadCalculator.determineStatus.mockReturnValue('idle');
// When
const result = await service.rebalance();
// Then
expect(result.success).toBe(true);
expect(result.actions).toHaveLength(0);
expect(result.movedClaims).toBe(0);
});
it('should limit actions to maxActionsPerRebalance', async () => {
// Given
const config: LoadBalancerConfig = {
...DEFAULT_CONFIG,
maxActionsPerRebalance: 2,
};
service = new LoadBalancer(
mockAgentRegistry,
mockClaimRepository,
mockLoadCalculator,
mockRebalanceStrategy,
mockEventStore,
config
);
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(sampleClaims);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
// Return 5 actions, but should be limited to 2
const manyActions: RebalanceAction[] = sampleClaims.map((claim) => ({
type: 'move' as const,
claim,
fromAgent: 'coder-1',
toAgent: 'reviewer-1',
reason: 'Load balancing',
}));
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue(manyActions);
mockClaimRepository.updateClaimAgent.mockResolvedValue(undefined);
// When
const result = await service.rebalance();
// Then
expect(result.actions).toHaveLength(2);
expect(result.movedClaims).toBe(2);
});
it('should emit LoadRebalanced event', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(sampleClaims);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
const moveAction: RebalanceAction = {
type: 'move',
claim: sampleClaims[0],
fromAgent: 'coder-1',
toAgent: 'reviewer-1',
reason: 'Load balancing',
};
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue([moveAction]);
mockClaimRepository.updateClaimAgent.mockResolvedValue(undefined);
// When
await service.rebalance();
// Then
expect(mockEventStore.append).toHaveBeenCalledWith(
expect.objectContaining({
type: 'LoadRebalanced',
payload: expect.objectContaining({
movedClaims: 1,
overloadedCount: 1,
underloadedCount: 1,
}),
})
);
});
it('should only execute move and reassign actions', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(sampleClaims);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
const actions: RebalanceAction[] = [
{ type: 'move', claim: sampleClaims[0], fromAgent: 'coder-1', toAgent: 'reviewer-1', reason: 'Move' },
{ type: 'defer', claim: sampleClaims[1], fromAgent: 'coder-1', toAgent: 'reviewer-1', reason: 'Defer' },
{ type: 'reassign', claim: sampleClaims[2], fromAgent: 'coder-1', toAgent: 'reviewer-1', reason: 'Reassign' },
];
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue(actions);
mockClaimRepository.updateClaimAgent.mockResolvedValue(undefined);
// When
const result = await service.rebalance();
// Then
expect(result.movedClaims).toBe(2); // move + reassign, not defer
expect(mockClaimRepository.updateClaimAgent).toHaveBeenCalledTimes(2);
});
});
// ===========================================================================
// previewRebalance() tests
// ===========================================================================
describe('previewRebalance', () => {
it('should not modify state', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(sampleClaims);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
const moveAction: RebalanceAction = {
type: 'move',
claim: sampleClaims[0],
fromAgent: 'coder-1',
toAgent: 'reviewer-1',
reason: 'Load balancing',
};
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue([moveAction]);
// When
await service.previewRebalance();
// Then
expect(mockClaimRepository.updateClaimAgent).not.toHaveBeenCalled();
expect(mockEventStore.append).not.toHaveBeenCalled();
});
it('should return proposed actions without executing', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(sampleClaims);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
const moveAction: RebalanceAction = {
type: 'move',
claim: sampleClaims[0],
fromAgent: 'coder-1',
toAgent: 'reviewer-1',
reason: 'Load balancing',
};
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue([moveAction]);
// When
const preview = await service.previewRebalance();
// Then
expect(preview.proposedActions).toHaveLength(1);
expect(preview.proposedActions[0]).toEqual(moveAction);
});
it('should include warnings for edge cases', async () => {
// Given - No overloaded agents
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent.mockResolvedValue([]);
mockLoadCalculator.determineStatus.mockReturnValue('idle');
// When
const preview = await service.previewRebalance();
// Then
expect(preview.warnings).toContain('No overloaded agents detected');
});
it('should assess risk level', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(sampleClaims);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
const moveAction: RebalanceAction = {
type: 'move',
claim: sampleClaims[0],
fromAgent: 'coder-1',
toAgent: 'reviewer-1',
reason: 'Load balancing',
};
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue([moveAction]);
mockRebalanceStrategy.assessRisk.mockReturnValue('medium');
// When
const preview = await service.previewRebalance();
// Then
expect(preview.riskLevel).toBe('medium');
expect(mockRebalanceStrategy.assessRisk).toHaveBeenCalled();
});
it('should calculate expected improvement', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(sampleClaims);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
const moveActions: RebalanceAction[] = [
{ type: 'move', claim: sampleClaims[0], fromAgent: 'coder-1', toAgent: 'reviewer-1', reason: 'Move 1' },
{ type: 'move', claim: sampleClaims[1], fromAgent: 'coder-1', toAgent: 'reviewer-1', reason: 'Move 2' },
];
mockRebalanceStrategy.selectClaimsToMove.mockReturnValue(moveActions);
// When
const preview = await service.previewRebalance();
// Then
expect(preview.expectedImprovement).toBeGreaterThan(0);
});
});
// ===========================================================================
// detectImbalance() tests
// ===========================================================================
describe('detectImbalance', () => {
it('should detect imbalance when threshold exceeded', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims) // Coder at 100%
.mockResolvedValueOnce([]); // Reviewer at 0%
mockLoadCalculator.calculateUtilization
.mockReturnValueOnce(100)
.mockReturnValueOnce(0);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
// When
const report = await service.detectImbalance();
// Then
expect(report.detected).toBe(true);
expect(report.overloadedAgents).toContain('coder-1');
expect(report.underloadedAgents).toContain('reviewer-1');
});
it('should classify severity correctly', async () => {
// Given - Severe imbalance
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([]);
mockLoadCalculator.calculateUtilization
.mockReturnValueOnce(100) // Coder at 100%
.mockReturnValueOnce(0); // Reviewer at 0%
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
// When
const report = await service.detectImbalance();
// Then
expect(report.severity).toBe('severe');
});
it('should return no imbalance when balanced', async () => {
// Given - All agents at similar utilization
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent.mockResolvedValue(sampleClaims.slice(0, 2)); // 2 claims each
mockLoadCalculator.calculateUtilization.mockReturnValue(50); // Both at 50%
mockLoadCalculator.determineStatus.mockReturnValue('busy');
// When
const report = await service.detectImbalance();
// Then
expect(report.detected).toBe(false);
expect(report.severity).toBe('none');
expect(report.imbalanceScore).toBeLessThan(DEFAULT_CONFIG.imbalanceThreshold);
});
it('should provide recommendations when imbalanced', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([]);
mockLoadCalculator.calculateUtilization
.mockReturnValueOnce(100)
.mockReturnValueOnce(0);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
// When
const report = await service.detectImbalance();
// Then
expect(report.recommendations.length).toBeGreaterThan(0);
expect(report.recommendations.some((r) => r.includes('overloaded'))).toBe(true);
});
it('should emit ImbalanceDetected event when detected', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims)
.mockResolvedValueOnce([]);
mockLoadCalculator.calculateUtilization
.mockReturnValueOnce(100)
.mockReturnValueOnce(0);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle');
// When
await service.detectImbalance();
// Then
expect(mockEventStore.append).toHaveBeenCalledWith(
expect.objectContaining({
type: 'ImbalanceDetected',
payload: expect.objectContaining({
severity: expect.any(String),
imbalanceScore: expect.any(Number),
overloadedAgents: expect.arrayContaining(['coder-1']),
}),
})
);
});
it('should not emit event when balanced', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent);
mockClaimRepository.findByAgent.mockResolvedValue(sampleClaims.slice(0, 2));
mockLoadCalculator.calculateUtilization.mockReturnValue(50);
mockLoadCalculator.determineStatus.mockReturnValue('busy');
// When
await service.detectImbalance();
// Then
expect(mockEventStore.append).not.toHaveBeenCalled();
});
it('should calculate correct imbalance score', async () => {
// Given - Clear imbalance
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent, reviewerAgent, testerAgent]);
mockAgentRegistry.getAgent
.mockResolvedValueOnce(coderAgent)
.mockResolvedValueOnce(reviewerAgent)
.mockResolvedValueOnce(testerAgent);
mockClaimRepository.findByAgent
.mockResolvedValueOnce(sampleClaims) // Coder at 100%
.mockResolvedValueOnce([]) // Reviewer at 0%
.mockResolvedValueOnce(sampleClaims.slice(0, 4)); // Tester at 50%
mockLoadCalculator.calculateUtilization
.mockReturnValueOnce(100)
.mockReturnValueOnce(0)
.mockReturnValueOnce(50);
mockLoadCalculator.determineStatus
.mockReturnValueOnce('overloaded')
.mockReturnValueOnce('idle')
.mockReturnValueOnce('busy');
// When
const report = await service.detectImbalance();
// Then
expect(report.imbalanceScore).toBeGreaterThan(0);
expect(report.imbalanceScore).toBeLessThanOrEqual(100);
});
});
// ===========================================================================
// Edge cases and error handling
// ===========================================================================
describe('edge cases', () => {
it('should handle empty agent list', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([]);
// When
const result = await service.rebalance();
// Then
expect(result.success).toBe(true);
expect(result.actions).toHaveLength(0);
});
it('should handle single agent', async () => {
// Given
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent]);
mockAgentRegistry.getAgent.mockResolvedValue(coderAgent);
mockClaimRepository.findByAgent.mockResolvedValue(sampleClaims);
mockLoadCalculator.determineStatus.mockReturnValue('overloaded');
// When
const result = await service.rebalance();
// Then
// No rebalance possible with single agent
expect(result.success).toBe(true);
expect(result.movedClaims).toBe(0);
});
it('should reset cooldown correctly', async () => {
// Given - First rebalance
mockAgentRegistry.listAgents.mockResolvedValue([coderAgent]);
mockAgentRegistry.getAgent.mockResolvedValue(coderAgent);
mockClaimRepository.findByAgent.mockResolvedValue([]);
mockLoadCalculator.determineStatus.mockReturnValue('idle');
await service.rebalance();
// Verify cooldown is active
const cooldownResult = await service.rebalance();
expect(cooldownResult.success).toBe(false);
// When - Reset cooldown
service.resetCooldown();
const afterResetResult = await service.rebalance();
// Then
expect(afterResetResult.success).toBe(true);
});
});
});