@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.
- package/README.md +261 -0
- package/package.json +84 -0
- package/src/api/cli-commands.ts +1459 -0
- package/src/api/cli-types.ts +154 -0
- package/src/api/index.ts +24 -0
- package/src/api/mcp-tools.ts +1977 -0
- package/src/application/claim-service.ts +753 -0
- package/src/application/index.ts +46 -0
- package/src/application/load-balancer.ts +840 -0
- package/src/application/work-stealing-service.ts +807 -0
- package/src/domain/events.ts +779 -0
- package/src/domain/index.ts +214 -0
- package/src/domain/repositories.ts +239 -0
- package/src/domain/rules.ts +526 -0
- package/src/domain/types.ts +826 -0
- package/src/index.ts +79 -0
- package/src/infrastructure/claim-repository.ts +358 -0
- package/src/infrastructure/event-store.ts +297 -0
- package/src/infrastructure/index.ts +21 -0
|
@@ -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;
|