@sparkleideas/claims 3.5.2-patch.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,840 @@
1
+ /**
2
+ * Load Balancer Service for Claims Module (ADR-016)
3
+ *
4
+ * Balances work across the swarm by:
5
+ * - Tracking agent load and utilization
6
+ * - Detecting overloaded/underloaded agents
7
+ * - Rebalancing work through handoff mechanisms
8
+ *
9
+ * Rebalancing Algorithm:
10
+ * 1. Calculate average load across swarm
11
+ * 2. Identify overloaded agents (>1.5x average utilization)
12
+ * 3. Identify underloaded agents (<0.5x average utilization)
13
+ * 4. Move low-progress (<25%) work from overloaded to underloaded
14
+ * 5. Prefer same agent type for transfers
15
+ * 6. Use handoff mechanism (not direct reassignment)
16
+ *
17
+ * Events Emitted:
18
+ * - SwarmRebalanced: When rebalancing operation completes
19
+ * - AgentOverloaded: When an agent exceeds load threshold
20
+ * - AgentUnderloaded: When an agent is below load threshold
21
+ *
22
+ * @module v3/@sparkleideas/claims/application/load-balancer
23
+ */
24
+
25
+ import { EventEmitter } from 'node:events';
26
+ import {
27
+ Claimant as DomainClaimant,
28
+ ClaimStatus as DomainClaimStatus,
29
+ IssuePriority,
30
+ } from '../domain/types.js';
31
+
32
+ // =============================================================================
33
+ // Load Balancer Specific Types (aligned with ADR-016)
34
+ // =============================================================================
35
+
36
+ /**
37
+ * Claimant type for load balancer operations (ADR-016 format)
38
+ *
39
+ * This is a simplified claimant representation used specifically for
40
+ * load balancing operations. It can represent either a human or an agent.
41
+ */
42
+ export type LoadBalancerClaimant =
43
+ | { type: 'human'; userId: string; name: string }
44
+ | { type: 'agent'; agentId: string; agentType: string };
45
+
46
+ /**
47
+ * Claim status values relevant to load balancing
48
+ */
49
+ export type LoadBalancerClaimStatus =
50
+ | 'active'
51
+ | 'paused'
52
+ | 'handoff-pending'
53
+ | 'review-requested'
54
+ | 'blocked'
55
+ | 'stealable'
56
+ | 'completed';
57
+
58
+ // =============================================================================
59
+ // Load Balancer Types
60
+ // =============================================================================
61
+
62
+ /**
63
+ * Summary of a claim for load calculations
64
+ */
65
+ export interface ClaimSummary {
66
+ issueId: string;
67
+ status: LoadBalancerClaimStatus;
68
+ priority: IssuePriority;
69
+ progress: number; // 0-100
70
+ claimedAt: Date;
71
+ lastActivityAt: Date;
72
+ estimatedRemainingMinutes?: number;
73
+ }
74
+
75
+ /**
76
+ * Load information for a single agent
77
+ */
78
+ export interface AgentLoadInfo {
79
+ agentId: string;
80
+ agentType: string;
81
+ claimCount: number;
82
+ maxClaims: number;
83
+ utilization: number; // 0-1 (claimCount / maxClaims weighted by priority)
84
+ claims: ClaimSummary[];
85
+ avgCompletionTime: number; // Historical average in milliseconds
86
+ currentBlockedCount: number;
87
+ }
88
+
89
+ /**
90
+ * Load overview for an entire swarm
91
+ */
92
+ export interface SwarmLoadInfo {
93
+ swarmId: string;
94
+ totalAgents: number;
95
+ activeAgents: number;
96
+ totalClaims: number;
97
+ avgUtilization: number;
98
+ agents: AgentLoadInfo[];
99
+ overloadedAgents: string[];
100
+ underloadedAgents: string[];
101
+ balanceScore: number; // 0-1, higher is more balanced
102
+ }
103
+
104
+ /**
105
+ * Options for rebalancing operation
106
+ */
107
+ export interface RebalanceOptions {
108
+ /** Only move claims with progress below this threshold (default: 25%) */
109
+ maxProgressToMove: number;
110
+ /** Prefer same agent type for transfers (default: true) */
111
+ preferSameType: boolean;
112
+ /** Threshold multiplier for overloaded detection (default: 1.5x average) */
113
+ overloadThreshold: number;
114
+ /** Threshold multiplier for underloaded detection (default: 0.5x average) */
115
+ underloadThreshold: number;
116
+ /** Maximum claims to move in single rebalance (default: 10) */
117
+ maxMovesPerRebalance: number;
118
+ /** Use handoff mechanism instead of direct reassignment (default: true) */
119
+ useHandoff: boolean;
120
+ }
121
+
122
+ /**
123
+ * Result of a rebalance operation
124
+ */
125
+ export interface RebalanceResult {
126
+ /** Claims that were moved (if useHandoff=false) or handoffs initiated */
127
+ moved: Array<{
128
+ issueId: string;
129
+ from: LoadBalancerClaimant;
130
+ to: LoadBalancerClaimant;
131
+ }>;
132
+ /** Suggested moves that weren't executed (for preview or when useHandoff=true) */
133
+ suggested: Array<{
134
+ issueId: string;
135
+ currentOwner: LoadBalancerClaimant;
136
+ suggestedOwner: LoadBalancerClaimant;
137
+ reason: string;
138
+ }>;
139
+ /** Summary statistics */
140
+ stats: {
141
+ totalMoved: number;
142
+ totalSuggested: number;
143
+ previousBalanceScore: number;
144
+ newBalanceScore: number;
145
+ executionTimeMs: number;
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Report on load imbalance in the swarm
151
+ */
152
+ export interface ImbalanceReport {
153
+ swarmId: string;
154
+ timestamp: Date;
155
+ isBalanced: boolean;
156
+ balanceScore: number;
157
+ avgLoad: number;
158
+ overloaded: Array<{
159
+ agentId: string;
160
+ agentType: string;
161
+ utilization: number;
162
+ excessClaims: number;
163
+ movableClaims: ClaimSummary[];
164
+ }>;
165
+ underloaded: Array<{
166
+ agentId: string;
167
+ agentType: string;
168
+ utilization: number;
169
+ availableCapacity: number;
170
+ }>;
171
+ recommendations: string[];
172
+ }
173
+
174
+ // =============================================================================
175
+ // Load Balancer Interface
176
+ // =============================================================================
177
+
178
+ /**
179
+ * Interface for the Load Balancer service
180
+ */
181
+ export interface ILoadBalancer {
182
+ /**
183
+ * Get load information for a specific agent
184
+ */
185
+ getAgentLoad(agentId: string): Promise<AgentLoadInfo>;
186
+
187
+ /**
188
+ * Get load overview for entire swarm
189
+ */
190
+ getSwarmLoad(swarmId: string): Promise<SwarmLoadInfo>;
191
+
192
+ /**
193
+ * Rebalance work across swarm
194
+ * @param swarmId - The swarm to rebalance
195
+ * @param options - Rebalancing options
196
+ */
197
+ rebalance(swarmId: string, options?: Partial<RebalanceOptions>): Promise<RebalanceResult>;
198
+
199
+ /**
200
+ * Preview rebalance without applying changes
201
+ */
202
+ previewRebalance(swarmId: string, options?: Partial<RebalanceOptions>): Promise<RebalanceResult>;
203
+
204
+ /**
205
+ * Detect overloaded/underloaded agents
206
+ */
207
+ detectImbalance(swarmId: string): Promise<ImbalanceReport>;
208
+ }
209
+
210
+ // =============================================================================
211
+ // Dependencies Interfaces
212
+ // =============================================================================
213
+
214
+ /**
215
+ * Agent metadata for load balancing operations
216
+ */
217
+ export interface AgentMetadata {
218
+ agentId: string;
219
+ agentType: string;
220
+ maxClaims: number;
221
+ swarmId?: string;
222
+ }
223
+
224
+ /**
225
+ * Claim repository interface for load balancing data access
226
+ *
227
+ * This is a specialized interface for load balancing operations.
228
+ * Implementations should adapt from the main IClaimRepository or IIssueClaimRepository.
229
+ */
230
+ export interface ILoadBalancerClaimRepository {
231
+ /**
232
+ * Get all claims held by a specific agent
233
+ */
234
+ getClaimsByAgent(agentId: string): Promise<ClaimSummary[]>;
235
+
236
+ /**
237
+ * Get all claims in a swarm, grouped by agent ID
238
+ */
239
+ getClaimsBySwarm(swarmId: string): Promise<Map<string, ClaimSummary[]>>;
240
+
241
+ /**
242
+ * Get historical completion times for an agent (in milliseconds)
243
+ * Used to calculate average completion time metrics
244
+ */
245
+ getAgentCompletionHistory(agentId: string, limit?: number): Promise<number[]>;
246
+ }
247
+
248
+ /**
249
+ * Agent registry interface for agent metadata
250
+ *
251
+ * Provides access to agent configuration needed for load calculations.
252
+ */
253
+ export interface IAgentRegistry {
254
+ /**
255
+ * Get metadata for a specific agent
256
+ */
257
+ getAgent(agentId: string): Promise<AgentMetadata | null>;
258
+
259
+ /**
260
+ * Get all agents in a swarm
261
+ */
262
+ getAgentsBySwarm(swarmId: string): Promise<AgentMetadata[]>;
263
+ }
264
+
265
+ /**
266
+ * Handoff service interface for initiating claim transfers
267
+ *
268
+ * Load balancer uses handoffs (not direct reassignment) to maintain
269
+ * proper claim lifecycle and audit trail.
270
+ */
271
+ export interface IHandoffService {
272
+ /**
273
+ * Request a handoff from one claimant to another
274
+ * @param issueId - The issue to transfer
275
+ * @param from - Current owner
276
+ * @param to - Proposed new owner
277
+ * @param reason - Reason for the handoff request
278
+ */
279
+ requestHandoff(issueId: string, from: LoadBalancerClaimant, to: LoadBalancerClaimant, reason: string): Promise<void>;
280
+ }
281
+
282
+ // =============================================================================
283
+ // Events
284
+ // =============================================================================
285
+
286
+ /**
287
+ * Event types emitted by the Load Balancer
288
+ */
289
+ export type LoadBalancerEventType =
290
+ | 'swarm:rebalanced'
291
+ | 'agent:overloaded'
292
+ | 'agent:underloaded';
293
+
294
+ export interface SwarmRebalancedEvent {
295
+ type: 'swarm:rebalanced';
296
+ swarmId: string;
297
+ timestamp: Date;
298
+ result: RebalanceResult;
299
+ }
300
+
301
+ export interface AgentOverloadedEvent {
302
+ type: 'agent:overloaded';
303
+ agentId: string;
304
+ agentType: string;
305
+ utilization: number;
306
+ claimCount: number;
307
+ maxClaims: number;
308
+ timestamp: Date;
309
+ }
310
+
311
+ export interface AgentUnderloadedEvent {
312
+ type: 'agent:underloaded';
313
+ agentId: string;
314
+ agentType: string;
315
+ utilization: number;
316
+ claimCount: number;
317
+ maxClaims: number;
318
+ timestamp: Date;
319
+ }
320
+
321
+ // =============================================================================
322
+ // Load Balancer Implementation
323
+ // =============================================================================
324
+
325
+ const DEFAULT_REBALANCE_OPTIONS: RebalanceOptions = {
326
+ maxProgressToMove: 25,
327
+ preferSameType: true,
328
+ overloadThreshold: 1.5,
329
+ underloadThreshold: 0.5,
330
+ maxMovesPerRebalance: 10,
331
+ useHandoff: true,
332
+ };
333
+
334
+ /**
335
+ * Load Balancer Service
336
+ *
337
+ * Balances work across the swarm using the following algorithm:
338
+ * 1. Calculate average load across swarm
339
+ * 2. Identify overloaded agents (>1.5x average utilization)
340
+ * 3. Identify underloaded agents (<0.5x average utilization)
341
+ * 4. Move low-progress (<25%) work from overloaded to underloaded
342
+ * 5. Prefer same agent type for transfers
343
+ * 6. Use handoff mechanism (not direct reassignment)
344
+ */
345
+ export class LoadBalancer extends EventEmitter implements ILoadBalancer {
346
+ private readonly claimRepository: ILoadBalancerClaimRepository;
347
+ private readonly agentRegistry: IAgentRegistry;
348
+ private readonly handoffService: IHandoffService;
349
+
350
+ constructor(
351
+ claimRepository: ILoadBalancerClaimRepository,
352
+ agentRegistry: IAgentRegistry,
353
+ handoffService: IHandoffService
354
+ ) {
355
+ super();
356
+ this.claimRepository = claimRepository;
357
+ this.agentRegistry = agentRegistry;
358
+ this.handoffService = handoffService;
359
+ }
360
+
361
+ /**
362
+ * Get load information for a specific agent
363
+ */
364
+ async getAgentLoad(agentId: string): Promise<AgentLoadInfo> {
365
+ const agent = await this.agentRegistry.getAgent(agentId);
366
+ if (!agent) {
367
+ throw new Error(`Agent not found: ${agentId}`);
368
+ }
369
+
370
+ const claims = await this.claimRepository.getClaimsByAgent(agentId);
371
+ const completionHistory = await this.claimRepository.getAgentCompletionHistory(agentId, 50);
372
+
373
+ const utilization = this.calculateUtilization(claims, agent.maxClaims);
374
+ const blockedCount = claims.filter((c) => c.status === 'blocked').length;
375
+ const avgCompletionTime =
376
+ completionHistory.length > 0
377
+ ? completionHistory.reduce((sum, t) => sum + t, 0) / completionHistory.length
378
+ : 0;
379
+
380
+ return {
381
+ agentId: agent.agentId,
382
+ agentType: agent.agentType,
383
+ claimCount: claims.length,
384
+ maxClaims: agent.maxClaims,
385
+ utilization,
386
+ claims,
387
+ avgCompletionTime,
388
+ currentBlockedCount: blockedCount,
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Get load overview for entire swarm
394
+ */
395
+ async getSwarmLoad(swarmId: string): Promise<SwarmLoadInfo> {
396
+ const agents = await this.agentRegistry.getAgentsBySwarm(swarmId);
397
+ const claimsByAgent = await this.claimRepository.getClaimsBySwarm(swarmId);
398
+
399
+ const agentLoads: AgentLoadInfo[] = [];
400
+ let totalClaims = 0;
401
+ let totalUtilization = 0;
402
+ let activeAgents = 0;
403
+
404
+ for (const agent of agents) {
405
+ const claims = claimsByAgent.get(agent.agentId) || [];
406
+ const completionHistory = await this.claimRepository.getAgentCompletionHistory(
407
+ agent.agentId,
408
+ 50
409
+ );
410
+
411
+ const utilization = this.calculateUtilization(claims, agent.maxClaims);
412
+ const blockedCount = claims.filter((c) => c.status === 'blocked').length;
413
+ const avgCompletionTime =
414
+ completionHistory.length > 0
415
+ ? completionHistory.reduce((sum, t) => sum + t, 0) / completionHistory.length
416
+ : 0;
417
+
418
+ const loadInfo: AgentLoadInfo = {
419
+ agentId: agent.agentId,
420
+ agentType: agent.agentType,
421
+ claimCount: claims.length,
422
+ maxClaims: agent.maxClaims,
423
+ utilization,
424
+ claims,
425
+ avgCompletionTime,
426
+ currentBlockedCount: blockedCount,
427
+ };
428
+
429
+ agentLoads.push(loadInfo);
430
+ totalClaims += claims.length;
431
+ totalUtilization += utilization;
432
+
433
+ if (claims.length > 0) {
434
+ activeAgents++;
435
+ }
436
+ }
437
+
438
+ const avgUtilization = agents.length > 0 ? totalUtilization / agents.length : 0;
439
+
440
+ // Detect overloaded/underloaded
441
+ const overloadedAgents = agentLoads
442
+ .filter((a) => a.utilization > avgUtilization * DEFAULT_REBALANCE_OPTIONS.overloadThreshold)
443
+ .map((a) => a.agentId);
444
+
445
+ const underloadedAgents = agentLoads
446
+ .filter((a) => a.utilization < avgUtilization * DEFAULT_REBALANCE_OPTIONS.underloadThreshold)
447
+ .map((a) => a.agentId);
448
+
449
+ // Calculate balance score (0-1, higher is better)
450
+ const balanceScore = this.calculateBalanceScore(agentLoads);
451
+
452
+ return {
453
+ swarmId,
454
+ totalAgents: agents.length,
455
+ activeAgents,
456
+ totalClaims,
457
+ avgUtilization,
458
+ agents: agentLoads,
459
+ overloadedAgents,
460
+ underloadedAgents,
461
+ balanceScore,
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Rebalance work across swarm
467
+ */
468
+ async rebalance(
469
+ swarmId: string,
470
+ options?: Partial<RebalanceOptions>
471
+ ): Promise<RebalanceResult> {
472
+ const startTime = Date.now();
473
+ const opts = { ...DEFAULT_REBALANCE_OPTIONS, ...options };
474
+
475
+ // Get current state
476
+ const swarmLoad = await this.getSwarmLoad(swarmId);
477
+ const previousBalanceScore = swarmLoad.balanceScore;
478
+
479
+ // Detect imbalance
480
+ const imbalance = await this.detectImbalance(swarmId);
481
+
482
+ const moved: RebalanceResult['moved'] = [];
483
+ const suggested: RebalanceResult['suggested'] = [];
484
+
485
+ // Process overloaded agents
486
+ for (const overloaded of imbalance.overloaded) {
487
+ // Find movable claims (low progress, not blocked)
488
+ const movableClaims = overloaded.movableClaims
489
+ .filter((c) => c.progress < opts.maxProgressToMove && c.status === 'active')
490
+ .sort((a, b) => a.progress - b.progress); // Move lowest progress first
491
+
492
+ for (const claim of movableClaims) {
493
+ if (moved.length + suggested.length >= opts.maxMovesPerRebalance) {
494
+ break;
495
+ }
496
+
497
+ // Find suitable target agent
498
+ const target = this.findBestTarget(
499
+ overloaded.agentType,
500
+ imbalance.underloaded,
501
+ opts.preferSameType
502
+ );
503
+
504
+ if (!target) {
505
+ suggested.push({
506
+ issueId: claim.issueId,
507
+ currentOwner: { type: 'agent', agentId: overloaded.agentId, agentType: overloaded.agentType },
508
+ suggestedOwner: { type: 'agent', agentId: 'none-available', agentType: overloaded.agentType },
509
+ reason: 'No suitable underloaded agent available',
510
+ });
511
+ continue;
512
+ }
513
+
514
+ const from: LoadBalancerClaimant = {
515
+ type: 'agent',
516
+ agentId: overloaded.agentId,
517
+ agentType: overloaded.agentType,
518
+ };
519
+ const to: LoadBalancerClaimant = {
520
+ type: 'agent',
521
+ agentId: target.agentId,
522
+ agentType: target.agentType,
523
+ };
524
+
525
+ if (opts.useHandoff) {
526
+ // Use handoff mechanism
527
+ await this.handoffService.requestHandoff(
528
+ claim.issueId,
529
+ from,
530
+ to,
531
+ `Load balancing: redistributing work across swarm (${overloaded.utilization.toFixed(2)} -> ${target.utilization.toFixed(2)} utilization)`
532
+ );
533
+ moved.push({ issueId: claim.issueId, from, to });
534
+ } else {
535
+ // Just suggest, don't execute
536
+ suggested.push({
537
+ issueId: claim.issueId,
538
+ currentOwner: from,
539
+ suggestedOwner: to,
540
+ reason: `Load balancing: agent ${overloaded.agentId} overloaded at ${(overloaded.utilization * 100).toFixed(0)}%`,
541
+ });
542
+ }
543
+
544
+ // Update target's capacity tracking (in-memory for this operation)
545
+ target.availableCapacity--;
546
+ if (target.availableCapacity <= 0) {
547
+ const idx = imbalance.underloaded.indexOf(target);
548
+ if (idx >= 0) {
549
+ imbalance.underloaded.splice(idx, 1);
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+ // Calculate new balance score
556
+ const newSwarmLoad = await this.getSwarmLoad(swarmId);
557
+ const newBalanceScore = newSwarmLoad.balanceScore;
558
+
559
+ const result: RebalanceResult = {
560
+ moved,
561
+ suggested,
562
+ stats: {
563
+ totalMoved: moved.length,
564
+ totalSuggested: suggested.length,
565
+ previousBalanceScore,
566
+ newBalanceScore,
567
+ executionTimeMs: Date.now() - startTime,
568
+ },
569
+ };
570
+
571
+ // Emit swarm rebalanced event
572
+ this.emit('swarm:rebalanced', {
573
+ type: 'swarm:rebalanced',
574
+ swarmId,
575
+ timestamp: new Date(),
576
+ result,
577
+ } as SwarmRebalancedEvent);
578
+
579
+ return result;
580
+ }
581
+
582
+ /**
583
+ * Preview rebalance without applying changes
584
+ */
585
+ async previewRebalance(
586
+ swarmId: string,
587
+ options?: Partial<RebalanceOptions>
588
+ ): Promise<RebalanceResult> {
589
+ // Force useHandoff to false for preview - we just want suggestions
590
+ return this.rebalance(swarmId, { ...options, useHandoff: false });
591
+ }
592
+
593
+ /**
594
+ * Detect overloaded/underloaded agents
595
+ */
596
+ async detectImbalance(swarmId: string): Promise<ImbalanceReport> {
597
+ const swarmLoad = await this.getSwarmLoad(swarmId);
598
+ const avgLoad = swarmLoad.avgUtilization;
599
+
600
+ const overloaded: ImbalanceReport['overloaded'] = [];
601
+ const underloaded: ImbalanceReport['underloaded'] = [];
602
+ const recommendations: string[] = [];
603
+
604
+ for (const agent of swarmLoad.agents) {
605
+ const isOverloaded =
606
+ agent.utilization > avgLoad * DEFAULT_REBALANCE_OPTIONS.overloadThreshold;
607
+ const isUnderloaded =
608
+ agent.utilization < avgLoad * DEFAULT_REBALANCE_OPTIONS.underloadThreshold;
609
+
610
+ if (isOverloaded) {
611
+ const excessClaims = Math.ceil(
612
+ agent.claimCount - agent.maxClaims * avgLoad
613
+ );
614
+ const movableClaims = agent.claims.filter(
615
+ (c) => c.progress < DEFAULT_REBALANCE_OPTIONS.maxProgressToMove
616
+ );
617
+
618
+ overloaded.push({
619
+ agentId: agent.agentId,
620
+ agentType: agent.agentType,
621
+ utilization: agent.utilization,
622
+ excessClaims: Math.max(0, excessClaims),
623
+ movableClaims,
624
+ });
625
+
626
+ // Emit overloaded event
627
+ this.emit('agent:overloaded', {
628
+ type: 'agent:overloaded',
629
+ agentId: agent.agentId,
630
+ agentType: agent.agentType,
631
+ utilization: agent.utilization,
632
+ claimCount: agent.claimCount,
633
+ maxClaims: agent.maxClaims,
634
+ timestamp: new Date(),
635
+ } as AgentOverloadedEvent);
636
+
637
+ if (movableClaims.length > 0) {
638
+ recommendations.push(
639
+ `Agent ${agent.agentId} (${agent.agentType}) is overloaded at ${(agent.utilization * 100).toFixed(0)}% with ${movableClaims.length} movable claims`
640
+ );
641
+ }
642
+ }
643
+
644
+ if (isUnderloaded) {
645
+ const availableCapacity = agent.maxClaims - agent.claimCount;
646
+
647
+ underloaded.push({
648
+ agentId: agent.agentId,
649
+ agentType: agent.agentType,
650
+ utilization: agent.utilization,
651
+ availableCapacity,
652
+ });
653
+
654
+ // Emit underloaded event
655
+ this.emit('agent:underloaded', {
656
+ type: 'agent:underloaded',
657
+ agentId: agent.agentId,
658
+ agentType: agent.agentType,
659
+ utilization: agent.utilization,
660
+ claimCount: agent.claimCount,
661
+ maxClaims: agent.maxClaims,
662
+ timestamp: new Date(),
663
+ } as AgentUnderloadedEvent);
664
+ }
665
+ }
666
+
667
+ // Generate recommendations
668
+ if (overloaded.length > 0 && underloaded.length > 0) {
669
+ const totalMovable = overloaded.reduce(
670
+ (sum, o) => sum + o.movableClaims.length,
671
+ 0
672
+ );
673
+ const totalCapacity = underloaded.reduce(
674
+ (sum, u) => sum + u.availableCapacity,
675
+ 0
676
+ );
677
+
678
+ recommendations.push(
679
+ `Can redistribute up to ${Math.min(totalMovable, totalCapacity)} claims from ${overloaded.length} overloaded to ${underloaded.length} underloaded agents`
680
+ );
681
+ }
682
+
683
+ if (overloaded.length > 0 && underloaded.length === 0) {
684
+ recommendations.push(
685
+ `${overloaded.length} overloaded agents but no underloaded agents available. Consider spawning more agents.`
686
+ );
687
+ }
688
+
689
+ const isBalanced =
690
+ overloaded.length === 0 ||
691
+ swarmLoad.balanceScore > 0.8;
692
+
693
+ return {
694
+ swarmId,
695
+ timestamp: new Date(),
696
+ isBalanced,
697
+ balanceScore: swarmLoad.balanceScore,
698
+ avgLoad,
699
+ overloaded,
700
+ underloaded,
701
+ recommendations,
702
+ };
703
+ }
704
+
705
+ // =============================================================================
706
+ // Private Helper Methods
707
+ // =============================================================================
708
+
709
+ /**
710
+ * Calculate utilization based on claims and their priorities
711
+ */
712
+ private calculateUtilization(claims: ClaimSummary[], maxClaims: number): number {
713
+ if (maxClaims === 0) return 0;
714
+ if (claims.length === 0) return 0;
715
+
716
+ // Weight claims by priority
717
+ const priorityWeights: Record<string, number> = {
718
+ critical: 2.0,
719
+ high: 1.5,
720
+ medium: 1.0,
721
+ low: 0.5,
722
+ };
723
+
724
+ let weightedCount = 0;
725
+ for (const claim of claims) {
726
+ const weight = priorityWeights[claim.priority] || 1.0;
727
+ // Blocked claims count less toward utilization
728
+ const blockFactor = claim.status === 'blocked' ? 0.5 : 1.0;
729
+ weightedCount += weight * blockFactor;
730
+ }
731
+
732
+ // Normalize to 0-1 range
733
+ return Math.min(1, weightedCount / maxClaims);
734
+ }
735
+
736
+ /**
737
+ * Calculate balance score for the swarm (0-1, higher is better)
738
+ *
739
+ * Uses coefficient of variation: 1 - (stdDev / mean)
740
+ * A perfectly balanced swarm has score = 1
741
+ */
742
+ private calculateBalanceScore(agentLoads: AgentLoadInfo[]): number {
743
+ if (agentLoads.length === 0) return 1;
744
+ if (agentLoads.length === 1) return 1;
745
+
746
+ const utilizations = agentLoads.map((a) => a.utilization);
747
+ const mean = utilizations.reduce((sum, u) => sum + u, 0) / utilizations.length;
748
+
749
+ if (mean === 0) return 1; // No work = perfectly balanced
750
+
751
+ const variance =
752
+ utilizations.reduce((sum, u) => sum + Math.pow(u - mean, 2), 0) /
753
+ utilizations.length;
754
+ const stdDev = Math.sqrt(variance);
755
+
756
+ // Coefficient of variation normalized to 0-1
757
+ const cv = stdDev / mean;
758
+ const score = Math.max(0, Math.min(1, 1 - cv));
759
+
760
+ return score;
761
+ }
762
+
763
+ /**
764
+ * Find the best target agent for receiving a transferred claim
765
+ */
766
+ private findBestTarget(
767
+ sourceAgentType: string,
768
+ candidates: ImbalanceReport['underloaded'],
769
+ preferSameType: boolean
770
+ ): ImbalanceReport['underloaded'][0] | null {
771
+ if (candidates.length === 0) return null;
772
+
773
+ // Sort candidates by preference
774
+ const sorted = [...candidates].sort((a, b) => {
775
+ // Prefer same type if configured
776
+ if (preferSameType) {
777
+ const aMatch = a.agentType === sourceAgentType ? 0 : 1;
778
+ const bMatch = b.agentType === sourceAgentType ? 0 : 1;
779
+ if (aMatch !== bMatch) return aMatch - bMatch;
780
+ }
781
+
782
+ // Then by available capacity (more is better)
783
+ if (a.availableCapacity !== b.availableCapacity) {
784
+ return b.availableCapacity - a.availableCapacity;
785
+ }
786
+
787
+ // Then by utilization (lower is better)
788
+ return a.utilization - b.utilization;
789
+ });
790
+
791
+ return sorted[0] || null;
792
+ }
793
+ }
794
+
795
+ // =============================================================================
796
+ // Factory Function
797
+ // =============================================================================
798
+
799
+ /**
800
+ * Create a LoadBalancer instance with dependencies
801
+ *
802
+ * @param claimRepository - Repository for accessing claim data
803
+ * @param agentRegistry - Registry for agent metadata
804
+ * @param handoffService - Service for initiating claim handoffs
805
+ * @returns A configured LoadBalancer instance
806
+ *
807
+ * @example
808
+ * ```typescript
809
+ * const loadBalancer = createLoadBalancer(
810
+ * claimRepository,
811
+ * agentRegistry,
812
+ * handoffService
813
+ * );
814
+ *
815
+ * // Get swarm load overview
816
+ * const swarmLoad = await loadBalancer.getSwarmLoad('swarm-1');
817
+ *
818
+ * // Detect and report imbalances
819
+ * const imbalance = await loadBalancer.detectImbalance('swarm-1');
820
+ *
821
+ * // Preview rebalancing without applying
822
+ * const preview = await loadBalancer.previewRebalance('swarm-1');
823
+ *
824
+ * // Execute rebalancing with handoffs
825
+ * const result = await loadBalancer.rebalance('swarm-1', {
826
+ * maxProgressToMove: 25,
827
+ * preferSameType: true
828
+ * });
829
+ * ```
830
+ */
831
+ export function createLoadBalancer(
832
+ claimRepository: ILoadBalancerClaimRepository,
833
+ agentRegistry: IAgentRegistry,
834
+ handoffService: IHandoffService
835
+ ): ILoadBalancer {
836
+ return new LoadBalancer(claimRepository, agentRegistry, handoffService);
837
+ }
838
+
839
+ // Alias for backward compatibility with ADR-016 naming
840
+ export type Claimant = LoadBalancerClaimant;