digital-workers 2.0.2 → 2.1.3
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/.turbo/turbo-build.log +4 -5
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +134 -180
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +1 -0
- package/dist/actions.js.map +1 -1
- package/dist/agent-comms.d.ts +438 -0
- package/dist/agent-comms.d.ts.map +1 -0
- package/dist/agent-comms.js +666 -0
- package/dist/agent-comms.js.map +1 -0
- package/dist/capability-tiers.d.ts +230 -0
- package/dist/capability-tiers.d.ts.map +1 -0
- package/dist/capability-tiers.js +388 -0
- package/dist/capability-tiers.js.map +1 -0
- package/dist/cascade-context.d.ts +523 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +494 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/error-escalation.d.ts +416 -0
- package/dist/error-escalation.d.ts.map +1 -0
- package/dist/error-escalation.js +656 -0
- package/dist/error-escalation.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -1
- package/dist/load-balancing.d.ts +395 -0
- package/dist/load-balancing.d.ts.map +1 -0
- package/dist/load-balancing.js +905 -0
- package/dist/load-balancing.js.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +14 -14
- package/src/actions.js +436 -0
- package/src/actions.ts +9 -8
- package/src/agent-comms.ts +1238 -0
- package/src/approve.js +234 -0
- package/src/ask.js +226 -0
- package/src/capability-tiers.ts +545 -0
- package/src/cascade-context.ts +648 -0
- package/src/decide.js +244 -0
- package/src/do.js +227 -0
- package/src/error-escalation.ts +1135 -0
- package/src/generate.js +298 -0
- package/src/goals.js +205 -0
- package/src/index.js +68 -0
- package/src/index.ts +223 -0
- package/src/is.js +317 -0
- package/src/kpis.js +270 -0
- package/src/load-balancing.ts +1381 -0
- package/src/notify.js +219 -0
- package/src/role.js +110 -0
- package/src/team.js +130 -0
- package/src/transports.js +357 -0
- package/src/types.js +71 -0
- package/src/types.ts +8 -0
- package/test/actions.test.js +401 -0
- package/test/agent-comms.test.ts +1397 -0
- package/test/capability-tiers.test.ts +631 -0
- package/test/cascade-context.test.ts +692 -0
- package/test/error-escalation.test.ts +1205 -0
- package/test/load-balancing-thread-safety.test.ts +464 -0
- package/test/load-balancing.test.ts +1145 -0
- package/test/standalone.test.js +250 -0
- package/test/types.test.js +371 -0
- package/test/types.test.ts +35 -0
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load Balancing and Routing Tests
|
|
3
|
+
*
|
|
4
|
+
* TDD tests for agent coordination with load balancing and routing capabilities.
|
|
5
|
+
* Following Red-Green-Refactor methodology.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi, expectTypeOf } from 'vitest'
|
|
9
|
+
import type {
|
|
10
|
+
LoadBalancer,
|
|
11
|
+
BalancerStrategy,
|
|
12
|
+
AgentInfo,
|
|
13
|
+
RouteResult,
|
|
14
|
+
TaskRequest,
|
|
15
|
+
AgentAvailability,
|
|
16
|
+
RoutingRule,
|
|
17
|
+
RoutingRuleCondition,
|
|
18
|
+
RoutingMetrics,
|
|
19
|
+
CompositeBalancerConfig,
|
|
20
|
+
} from '../src/load-balancing.js'
|
|
21
|
+
import {
|
|
22
|
+
createRoundRobinBalancer,
|
|
23
|
+
createLeastBusyBalancer,
|
|
24
|
+
createCapabilityRouter,
|
|
25
|
+
createPriorityQueueBalancer,
|
|
26
|
+
createAgentAvailabilityTracker,
|
|
27
|
+
createCompositeBalancer,
|
|
28
|
+
createRoutingRuleEngine,
|
|
29
|
+
// Metrics
|
|
30
|
+
collectRoutingMetrics,
|
|
31
|
+
resetRoutingMetrics,
|
|
32
|
+
} from '../src/load-balancing.js'
|
|
33
|
+
import type { Worker, WorkerStatus } from '../src/types.js'
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Test Fixtures
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
const createAgent = (
|
|
40
|
+
id: string,
|
|
41
|
+
skills: string[] = [],
|
|
42
|
+
status: WorkerStatus = 'available',
|
|
43
|
+
currentLoad = 0
|
|
44
|
+
): AgentInfo => ({
|
|
45
|
+
id,
|
|
46
|
+
name: `Agent ${id}`,
|
|
47
|
+
type: 'agent',
|
|
48
|
+
status,
|
|
49
|
+
skills,
|
|
50
|
+
currentLoad,
|
|
51
|
+
maxLoad: 10,
|
|
52
|
+
contacts: { api: `https://agent-${id}.example.com` },
|
|
53
|
+
metadata: {},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const createTask = (
|
|
57
|
+
id: string,
|
|
58
|
+
requiredSkills: string[] = [],
|
|
59
|
+
priority: number = 5
|
|
60
|
+
): TaskRequest => ({
|
|
61
|
+
id,
|
|
62
|
+
name: `Task ${id}`,
|
|
63
|
+
requiredSkills,
|
|
64
|
+
priority,
|
|
65
|
+
metadata: {},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Round-Robin Balancer Tests
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
describe('RoundRobinBalancer', () => {
|
|
73
|
+
let agents: AgentInfo[]
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
agents = [
|
|
77
|
+
createAgent('agent-1', ['code', 'review']),
|
|
78
|
+
createAgent('agent-2', ['code', 'test']),
|
|
79
|
+
createAgent('agent-3', ['code', 'deploy']),
|
|
80
|
+
]
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('route()', () => {
|
|
84
|
+
it('should distribute tasks in round-robin order', () => {
|
|
85
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
86
|
+
|
|
87
|
+
const task1 = createTask('task-1')
|
|
88
|
+
const task2 = createTask('task-2')
|
|
89
|
+
const task3 = createTask('task-3')
|
|
90
|
+
const task4 = createTask('task-4')
|
|
91
|
+
|
|
92
|
+
const result1 = balancer.route(task1)
|
|
93
|
+
const result2 = balancer.route(task2)
|
|
94
|
+
const result3 = balancer.route(task3)
|
|
95
|
+
const result4 = balancer.route(task4)
|
|
96
|
+
|
|
97
|
+
expect(result1.agent.id).toBe('agent-1')
|
|
98
|
+
expect(result2.agent.id).toBe('agent-2')
|
|
99
|
+
expect(result3.agent.id).toBe('agent-3')
|
|
100
|
+
expect(result4.agent.id).toBe('agent-1') // Wraps around
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should skip unavailable agents', () => {
|
|
104
|
+
agents[1].status = 'offline'
|
|
105
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
106
|
+
|
|
107
|
+
const task1 = createTask('task-1')
|
|
108
|
+
const task2 = createTask('task-2')
|
|
109
|
+
const task3 = createTask('task-3')
|
|
110
|
+
|
|
111
|
+
const result1 = balancer.route(task1)
|
|
112
|
+
const result2 = balancer.route(task2)
|
|
113
|
+
const result3 = balancer.route(task3)
|
|
114
|
+
|
|
115
|
+
expect(result1.agent.id).toBe('agent-1')
|
|
116
|
+
expect(result2.agent.id).toBe('agent-3')
|
|
117
|
+
expect(result3.agent.id).toBe('agent-1')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should return null route if no agents available', () => {
|
|
121
|
+
agents.forEach(a => a.status = 'offline')
|
|
122
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
123
|
+
|
|
124
|
+
const result = balancer.route(createTask('task-1'))
|
|
125
|
+
|
|
126
|
+
expect(result.agent).toBeNull()
|
|
127
|
+
expect(result.reason).toBe('no-available-agents')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should include routing metadata in result', () => {
|
|
131
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
132
|
+
const task = createTask('task-1')
|
|
133
|
+
|
|
134
|
+
const result = balancer.route(task)
|
|
135
|
+
|
|
136
|
+
expect(result.strategy).toBe('round-robin')
|
|
137
|
+
expect(result.timestamp).toBeInstanceOf(Date)
|
|
138
|
+
expect(result.task).toBe(task)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('addAgent() / removeAgent()', () => {
|
|
143
|
+
it('should add new agents to the pool', () => {
|
|
144
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
145
|
+
const newAgent = createAgent('agent-4', ['security'])
|
|
146
|
+
|
|
147
|
+
balancer.addAgent(newAgent)
|
|
148
|
+
|
|
149
|
+
// Route through all agents
|
|
150
|
+
balancer.route(createTask('t1'))
|
|
151
|
+
balancer.route(createTask('t2'))
|
|
152
|
+
balancer.route(createTask('t3'))
|
|
153
|
+
const result = balancer.route(createTask('t4'))
|
|
154
|
+
|
|
155
|
+
expect(result.agent.id).toBe('agent-4')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should remove agents from the pool', () => {
|
|
159
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
160
|
+
|
|
161
|
+
balancer.removeAgent('agent-2')
|
|
162
|
+
|
|
163
|
+
const result1 = balancer.route(createTask('t1'))
|
|
164
|
+
const result2 = balancer.route(createTask('t2'))
|
|
165
|
+
const result3 = balancer.route(createTask('t3'))
|
|
166
|
+
|
|
167
|
+
expect(result1.agent.id).toBe('agent-1')
|
|
168
|
+
expect(result2.agent.id).toBe('agent-3')
|
|
169
|
+
expect(result3.agent.id).toBe('agent-1')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('getAgents()', () => {
|
|
174
|
+
it('should return current agent list', () => {
|
|
175
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
176
|
+
|
|
177
|
+
expect(balancer.getAgents()).toHaveLength(3)
|
|
178
|
+
expect(balancer.getAgents().map(a => a.id)).toEqual([
|
|
179
|
+
'agent-1',
|
|
180
|
+
'agent-2',
|
|
181
|
+
'agent-3',
|
|
182
|
+
])
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Least-Busy Balancer Tests
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
describe('LeastBusyBalancer', () => {
|
|
192
|
+
let agents: AgentInfo[]
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
agents = [
|
|
196
|
+
createAgent('agent-1', ['code'], 'available', 5), // 50% load
|
|
197
|
+
createAgent('agent-2', ['code'], 'available', 2), // 20% load
|
|
198
|
+
createAgent('agent-3', ['code'], 'available', 8), // 80% load
|
|
199
|
+
]
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('route()', () => {
|
|
203
|
+
it('should route to agent with lowest load', () => {
|
|
204
|
+
const balancer = createLeastBusyBalancer(agents)
|
|
205
|
+
|
|
206
|
+
const result = balancer.route(createTask('task-1'))
|
|
207
|
+
|
|
208
|
+
expect(result.agent.id).toBe('agent-2') // Lowest load (20%)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should update load tracking after routing', () => {
|
|
212
|
+
const balancer = createLeastBusyBalancer(agents)
|
|
213
|
+
|
|
214
|
+
const result1 = balancer.route(createTask('task-1'))
|
|
215
|
+
expect(result1.agent.id).toBe('agent-2')
|
|
216
|
+
|
|
217
|
+
// After routing, agent-2's load increases
|
|
218
|
+
const result2 = balancer.route(createTask('task-2'))
|
|
219
|
+
// Now agent-2 has load 3, agent-1 has load 5
|
|
220
|
+
expect(result2.agent.id).toBe('agent-2') // Still lowest
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should skip agents at max capacity', () => {
|
|
224
|
+
agents[1].currentLoad = 10 // Max capacity
|
|
225
|
+
const balancer = createLeastBusyBalancer(agents)
|
|
226
|
+
|
|
227
|
+
const result = balancer.route(createTask('task-1'))
|
|
228
|
+
|
|
229
|
+
expect(result.agent.id).toBe('agent-1') // Next lowest
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should calculate load percentage correctly', () => {
|
|
233
|
+
const balancer = createLeastBusyBalancer(agents)
|
|
234
|
+
|
|
235
|
+
const metrics = balancer.getLoadMetrics()
|
|
236
|
+
|
|
237
|
+
expect(metrics['agent-1']).toBe(0.5) // 5/10 = 50%
|
|
238
|
+
expect(metrics['agent-2']).toBe(0.2) // 2/10 = 20%
|
|
239
|
+
expect(metrics['agent-3']).toBe(0.8) // 8/10 = 80%
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('should handle tie-breaking with round-robin', () => {
|
|
243
|
+
agents[0].currentLoad = 2
|
|
244
|
+
agents[1].currentLoad = 2
|
|
245
|
+
const balancer = createLeastBusyBalancer(agents)
|
|
246
|
+
|
|
247
|
+
const result1 = balancer.route(createTask('task-1'))
|
|
248
|
+
const result2 = balancer.route(createTask('task-2'))
|
|
249
|
+
|
|
250
|
+
// Both have same load, should alternate
|
|
251
|
+
expect(result1.agent.id).not.toBe(result2.agent.id)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('releaseLoad()', () => {
|
|
256
|
+
it('should decrease agent load when task completes', () => {
|
|
257
|
+
const balancer = createLeastBusyBalancer(agents)
|
|
258
|
+
|
|
259
|
+
balancer.route(createTask('task-1')) // Increases agent-2 load
|
|
260
|
+
balancer.releaseLoad('agent-2')
|
|
261
|
+
|
|
262
|
+
const metrics = balancer.getLoadMetrics()
|
|
263
|
+
expect(metrics['agent-2']).toBe(0.2) // Back to original
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
describe('setLoad()', () => {
|
|
268
|
+
it('should allow manual load adjustment', () => {
|
|
269
|
+
const balancer = createLeastBusyBalancer(agents)
|
|
270
|
+
|
|
271
|
+
balancer.setLoad('agent-1', 1)
|
|
272
|
+
|
|
273
|
+
const metrics = balancer.getLoadMetrics()
|
|
274
|
+
expect(metrics['agent-1']).toBe(0.1)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// Capability-Based Router Tests
|
|
281
|
+
// ============================================================================
|
|
282
|
+
|
|
283
|
+
describe('CapabilityRouter', () => {
|
|
284
|
+
let agents: AgentInfo[]
|
|
285
|
+
|
|
286
|
+
beforeEach(() => {
|
|
287
|
+
agents = [
|
|
288
|
+
createAgent('code-agent', ['code', 'review', 'test']),
|
|
289
|
+
createAgent('deploy-agent', ['deploy', 'monitor', 'rollback']),
|
|
290
|
+
createAgent('ml-agent', ['ml', 'data', 'train']),
|
|
291
|
+
createAgent('full-stack', ['code', 'deploy', 'ml']),
|
|
292
|
+
]
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
describe('route()', () => {
|
|
296
|
+
it('should route to agent with matching skills', () => {
|
|
297
|
+
const router = createCapabilityRouter(agents)
|
|
298
|
+
|
|
299
|
+
const task = createTask('code-task', ['code'])
|
|
300
|
+
const result = router.route(task)
|
|
301
|
+
|
|
302
|
+
expect(result.agent).not.toBeNull()
|
|
303
|
+
expect(result.agent.skills).toContain('code')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('should route to agent with all required skills', () => {
|
|
307
|
+
const router = createCapabilityRouter(agents)
|
|
308
|
+
|
|
309
|
+
const task = createTask('full-stack-task', ['code', 'deploy'])
|
|
310
|
+
const result = router.route(task)
|
|
311
|
+
|
|
312
|
+
expect(result.agent.id).toBe('full-stack')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('should return null if no agent has required skills', () => {
|
|
316
|
+
const router = createCapabilityRouter(agents)
|
|
317
|
+
|
|
318
|
+
const task = createTask('impossible', ['quantum', 'teleport'])
|
|
319
|
+
const result = router.route(task)
|
|
320
|
+
|
|
321
|
+
expect(result.agent).toBeNull()
|
|
322
|
+
expect(result.reason).toBe('no-matching-capability')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('should prefer agent with closest skill match (avoid over-qualification)', () => {
|
|
326
|
+
const router = createCapabilityRouter(agents, { preferExactMatch: true })
|
|
327
|
+
|
|
328
|
+
const task = createTask('code-only', ['code'])
|
|
329
|
+
const result = router.route(task)
|
|
330
|
+
|
|
331
|
+
// Should prefer code-agent over full-stack (closer match)
|
|
332
|
+
expect(result.agent.id).toBe('code-agent')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('should include skill match score in result', () => {
|
|
336
|
+
const router = createCapabilityRouter(agents)
|
|
337
|
+
|
|
338
|
+
const task = createTask('code-task', ['code', 'review'])
|
|
339
|
+
const result = router.route(task)
|
|
340
|
+
|
|
341
|
+
expect(result.matchScore).toBeDefined()
|
|
342
|
+
expect(result.matchScore).toBeGreaterThan(0)
|
|
343
|
+
expect(result.matchScore).toBeLessThanOrEqual(1)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('findAgentsWithSkills()', () => {
|
|
348
|
+
it('should return all agents with specified skills', () => {
|
|
349
|
+
const router = createCapabilityRouter(agents)
|
|
350
|
+
|
|
351
|
+
const matches = router.findAgentsWithSkills(['code'])
|
|
352
|
+
|
|
353
|
+
expect(matches).toHaveLength(2)
|
|
354
|
+
expect(matches.map(a => a.id)).toContain('code-agent')
|
|
355
|
+
expect(matches.map(a => a.id)).toContain('full-stack')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('should return empty array if no matches', () => {
|
|
359
|
+
const router = createCapabilityRouter(agents)
|
|
360
|
+
|
|
361
|
+
const matches = router.findAgentsWithSkills(['nonexistent'])
|
|
362
|
+
|
|
363
|
+
expect(matches).toHaveLength(0)
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
describe('getSkillCoverage()', () => {
|
|
368
|
+
it('should return skill coverage across all agents', () => {
|
|
369
|
+
const router = createCapabilityRouter(agents)
|
|
370
|
+
|
|
371
|
+
const coverage = router.getSkillCoverage()
|
|
372
|
+
|
|
373
|
+
expect(coverage.code).toBe(2) // 2 agents have 'code'
|
|
374
|
+
expect(coverage.deploy).toBe(2)
|
|
375
|
+
expect(coverage.ml).toBe(2)
|
|
376
|
+
expect(coverage.review).toBe(1)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// ============================================================================
|
|
382
|
+
// Priority Queue Balancer Tests
|
|
383
|
+
// ============================================================================
|
|
384
|
+
|
|
385
|
+
describe('PriorityQueueBalancer', () => {
|
|
386
|
+
let agents: AgentInfo[]
|
|
387
|
+
|
|
388
|
+
beforeEach(() => {
|
|
389
|
+
agents = [
|
|
390
|
+
createAgent('agent-1', ['general']),
|
|
391
|
+
createAgent('agent-2', ['general']),
|
|
392
|
+
]
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
describe('enqueue() / route()', () => {
|
|
396
|
+
it('should process higher priority tasks first', async () => {
|
|
397
|
+
const balancer = createPriorityQueueBalancer(agents)
|
|
398
|
+
|
|
399
|
+
balancer.enqueue(createTask('low', [], 1))
|
|
400
|
+
balancer.enqueue(createTask('high', [], 10))
|
|
401
|
+
balancer.enqueue(createTask('medium', [], 5))
|
|
402
|
+
|
|
403
|
+
const result1 = await balancer.routeNext()
|
|
404
|
+
const result2 = await balancer.routeNext()
|
|
405
|
+
const result3 = await balancer.routeNext()
|
|
406
|
+
|
|
407
|
+
expect(result1.task.id).toBe('high')
|
|
408
|
+
expect(result2.task.id).toBe('medium')
|
|
409
|
+
expect(result3.task.id).toBe('low')
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('should handle equal priority with FIFO', async () => {
|
|
413
|
+
const balancer = createPriorityQueueBalancer(agents)
|
|
414
|
+
|
|
415
|
+
balancer.enqueue(createTask('first', [], 5))
|
|
416
|
+
balancer.enqueue(createTask('second', [], 5))
|
|
417
|
+
balancer.enqueue(createTask('third', [], 5))
|
|
418
|
+
|
|
419
|
+
const result1 = await balancer.routeNext()
|
|
420
|
+
const result2 = await balancer.routeNext()
|
|
421
|
+
const result3 = await balancer.routeNext()
|
|
422
|
+
|
|
423
|
+
expect(result1.task.id).toBe('first')
|
|
424
|
+
expect(result2.task.id).toBe('second')
|
|
425
|
+
expect(result3.task.id).toBe('third')
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('should return null if queue is empty', async () => {
|
|
429
|
+
const balancer = createPriorityQueueBalancer(agents)
|
|
430
|
+
|
|
431
|
+
const result = await balancer.routeNext()
|
|
432
|
+
|
|
433
|
+
expect(result).toBeNull()
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
describe('priority preemption', () => {
|
|
438
|
+
it('should support priority levels (1-10)', () => {
|
|
439
|
+
const balancer = createPriorityQueueBalancer(agents)
|
|
440
|
+
|
|
441
|
+
expect(() => balancer.enqueue(createTask('valid', [], 1))).not.toThrow()
|
|
442
|
+
expect(() => balancer.enqueue(createTask('valid', [], 10))).not.toThrow()
|
|
443
|
+
expect(() => balancer.enqueue(createTask('invalid', [], 0))).toThrow()
|
|
444
|
+
expect(() => balancer.enqueue(createTask('invalid', [], 11))).toThrow()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('should support priority boost for aging tasks', async () => {
|
|
448
|
+
vi.useFakeTimers()
|
|
449
|
+
try {
|
|
450
|
+
const balancer = createPriorityQueueBalancer(agents, {
|
|
451
|
+
enableAging: true,
|
|
452
|
+
agingBoostPerSecond: 1,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
balancer.enqueue(createTask('old', [], 1))
|
|
456
|
+
|
|
457
|
+
// Simulate time passing
|
|
458
|
+
vi.advanceTimersByTime(5000)
|
|
459
|
+
|
|
460
|
+
// Priority should have increased
|
|
461
|
+
const effectivePriority = balancer.getEffectivePriority('old')
|
|
462
|
+
expect(effectivePriority).toBeGreaterThan(1)
|
|
463
|
+
} finally {
|
|
464
|
+
vi.useRealTimers()
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
describe('starvation prevention', () => {
|
|
470
|
+
it('should prevent task starvation with max wait time', async () => {
|
|
471
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
|
|
472
|
+
try {
|
|
473
|
+
const balancer = createPriorityQueueBalancer(agents, {
|
|
474
|
+
maxWaitTime: 10000, // 10 seconds max wait
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
const oldTask = createTask('starving', [], 1)
|
|
478
|
+
balancer.enqueue(oldTask)
|
|
479
|
+
|
|
480
|
+
// Advance time before adding high priority tasks
|
|
481
|
+
vi.advanceTimersByTime(11000)
|
|
482
|
+
|
|
483
|
+
// Keep adding higher priority tasks (these are added AFTER the wait time)
|
|
484
|
+
for (let i = 0; i < 10; i++) {
|
|
485
|
+
balancer.enqueue(createTask(`high-${i}`, [], 10))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Starving task should be promoted due to max wait time exceeded
|
|
489
|
+
const result = await balancer.routeNext()
|
|
490
|
+
expect(result.task.id).toBe('starving')
|
|
491
|
+
} finally {
|
|
492
|
+
vi.useRealTimers()
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
describe('queue management', () => {
|
|
498
|
+
it('should return queue size', () => {
|
|
499
|
+
const balancer = createPriorityQueueBalancer(agents)
|
|
500
|
+
|
|
501
|
+
balancer.enqueue(createTask('t1'))
|
|
502
|
+
balancer.enqueue(createTask('t2'))
|
|
503
|
+
balancer.enqueue(createTask('t3'))
|
|
504
|
+
|
|
505
|
+
expect(balancer.queueSize()).toBe(3)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('should clear the queue', () => {
|
|
509
|
+
const balancer = createPriorityQueueBalancer(agents)
|
|
510
|
+
|
|
511
|
+
balancer.enqueue(createTask('t1'))
|
|
512
|
+
balancer.enqueue(createTask('t2'))
|
|
513
|
+
|
|
514
|
+
balancer.clear()
|
|
515
|
+
|
|
516
|
+
expect(balancer.queueSize()).toBe(0)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('should peek at next task without removing', () => {
|
|
520
|
+
const balancer = createPriorityQueueBalancer(agents)
|
|
521
|
+
|
|
522
|
+
balancer.enqueue(createTask('t1', [], 5))
|
|
523
|
+
balancer.enqueue(createTask('t2', [], 10))
|
|
524
|
+
|
|
525
|
+
const peeked = balancer.peek()
|
|
526
|
+
expect(peeked.id).toBe('t2')
|
|
527
|
+
expect(balancer.queueSize()).toBe(2) // Still 2
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
// ============================================================================
|
|
533
|
+
// Agent Availability Tracker Tests
|
|
534
|
+
// ============================================================================
|
|
535
|
+
|
|
536
|
+
describe('AgentAvailabilityTracker', () => {
|
|
537
|
+
let agents: AgentInfo[]
|
|
538
|
+
|
|
539
|
+
beforeEach(() => {
|
|
540
|
+
agents = [
|
|
541
|
+
createAgent('agent-1'),
|
|
542
|
+
createAgent('agent-2'),
|
|
543
|
+
createAgent('agent-3', [], 'offline'),
|
|
544
|
+
]
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
describe('tracking availability', () => {
|
|
548
|
+
it('should track agent availability status', () => {
|
|
549
|
+
const tracker = createAgentAvailabilityTracker(agents)
|
|
550
|
+
|
|
551
|
+
const availability = tracker.getAvailability('agent-1')
|
|
552
|
+
|
|
553
|
+
expect(availability.status).toBe('available')
|
|
554
|
+
expect(availability.lastSeen).toBeInstanceOf(Date)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('should update agent status', () => {
|
|
558
|
+
const tracker = createAgentAvailabilityTracker(agents)
|
|
559
|
+
|
|
560
|
+
tracker.updateStatus('agent-1', 'busy')
|
|
561
|
+
|
|
562
|
+
const availability = tracker.getAvailability('agent-1')
|
|
563
|
+
expect(availability.status).toBe('busy')
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('should return all available agents', () => {
|
|
567
|
+
const tracker = createAgentAvailabilityTracker(agents)
|
|
568
|
+
|
|
569
|
+
const available = tracker.getAvailableAgents()
|
|
570
|
+
|
|
571
|
+
expect(available).toHaveLength(2)
|
|
572
|
+
expect(available.map(a => a.id)).toContain('agent-1')
|
|
573
|
+
expect(available.map(a => a.id)).toContain('agent-2')
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
describe('heartbeat tracking', () => {
|
|
578
|
+
it('should update lastSeen on heartbeat', () => {
|
|
579
|
+
vi.useFakeTimers()
|
|
580
|
+
try {
|
|
581
|
+
const tracker = createAgentAvailabilityTracker(agents)
|
|
582
|
+
|
|
583
|
+
const before = tracker.getAvailability('agent-1').lastSeen
|
|
584
|
+
|
|
585
|
+
vi.advanceTimersByTime(1000)
|
|
586
|
+
tracker.heartbeat('agent-1')
|
|
587
|
+
|
|
588
|
+
const after = tracker.getAvailability('agent-1').lastSeen
|
|
589
|
+
expect(after.getTime()).toBeGreaterThan(before.getTime())
|
|
590
|
+
} finally {
|
|
591
|
+
vi.useRealTimers()
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('should mark agent offline after timeout', () => {
|
|
596
|
+
vi.useFakeTimers()
|
|
597
|
+
try {
|
|
598
|
+
const tracker = createAgentAvailabilityTracker(agents, {
|
|
599
|
+
heartbeatTimeout: 5000,
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
vi.advanceTimersByTime(6000)
|
|
603
|
+
tracker.checkTimeouts()
|
|
604
|
+
|
|
605
|
+
const availability = tracker.getAvailability('agent-1')
|
|
606
|
+
expect(availability.status).toBe('offline')
|
|
607
|
+
} finally {
|
|
608
|
+
vi.useRealTimers()
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
describe('availability events', () => {
|
|
614
|
+
it('should emit events on status change', () => {
|
|
615
|
+
const tracker = createAgentAvailabilityTracker(agents)
|
|
616
|
+
const handler = vi.fn()
|
|
617
|
+
|
|
618
|
+
tracker.onStatusChange(handler)
|
|
619
|
+
tracker.updateStatus('agent-1', 'busy')
|
|
620
|
+
|
|
621
|
+
expect(handler).toHaveBeenCalledWith({
|
|
622
|
+
agentId: 'agent-1',
|
|
623
|
+
previousStatus: 'available',
|
|
624
|
+
currentStatus: 'busy',
|
|
625
|
+
timestamp: expect.any(Date),
|
|
626
|
+
})
|
|
627
|
+
})
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
describe('capacity tracking', () => {
|
|
631
|
+
it('should track current capacity utilization', () => {
|
|
632
|
+
const tracker = createAgentAvailabilityTracker(agents)
|
|
633
|
+
|
|
634
|
+
tracker.updateLoad('agent-1', 5, 10) // 50% capacity
|
|
635
|
+
tracker.updateLoad('agent-2', 8, 10) // 80% capacity
|
|
636
|
+
|
|
637
|
+
const utilization = tracker.getCapacityUtilization()
|
|
638
|
+
|
|
639
|
+
expect(utilization['agent-1']).toBe(0.5)
|
|
640
|
+
expect(utilization['agent-2']).toBe(0.8)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it('should calculate overall capacity', () => {
|
|
644
|
+
const tracker = createAgentAvailabilityTracker(agents)
|
|
645
|
+
|
|
646
|
+
tracker.updateLoad('agent-1', 5, 10)
|
|
647
|
+
tracker.updateLoad('agent-2', 8, 10)
|
|
648
|
+
|
|
649
|
+
const overall = tracker.getOverallCapacity()
|
|
650
|
+
|
|
651
|
+
expect(overall.total).toBe(20) // 2 available agents * 10 max each
|
|
652
|
+
expect(overall.used).toBe(13) // 5 + 8
|
|
653
|
+
expect(overall.available).toBe(7) // 20 - 13
|
|
654
|
+
expect(overall.utilization).toBeCloseTo(0.65) // 13/20
|
|
655
|
+
})
|
|
656
|
+
})
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
// ============================================================================
|
|
660
|
+
// Routing Rule Engine Tests
|
|
661
|
+
// ============================================================================
|
|
662
|
+
|
|
663
|
+
describe('RoutingRuleEngine', () => {
|
|
664
|
+
let agents: AgentInfo[]
|
|
665
|
+
|
|
666
|
+
beforeEach(() => {
|
|
667
|
+
agents = [
|
|
668
|
+
createAgent('fast-agent', ['code'], 'available', 0),
|
|
669
|
+
createAgent('slow-agent', ['code'], 'available', 5),
|
|
670
|
+
createAgent('secure-agent', ['security'], 'available', 0),
|
|
671
|
+
]
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
describe('rule definition', () => {
|
|
675
|
+
it('should create routing rules', () => {
|
|
676
|
+
const engine = createRoutingRuleEngine(agents)
|
|
677
|
+
|
|
678
|
+
engine.addRule({
|
|
679
|
+
name: 'security-tasks',
|
|
680
|
+
priority: 10,
|
|
681
|
+
condition: (task) => task.requiredSkills.includes('security'),
|
|
682
|
+
action: (task, agents) => agents.find(a => a.id === 'secure-agent'),
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
const rules = engine.getRules()
|
|
686
|
+
expect(rules).toHaveLength(1)
|
|
687
|
+
expect(rules[0].name).toBe('security-tasks')
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('should validate rule structure', () => {
|
|
691
|
+
const engine = createRoutingRuleEngine(agents)
|
|
692
|
+
|
|
693
|
+
expect(() => engine.addRule({
|
|
694
|
+
name: '', // Invalid: empty name
|
|
695
|
+
priority: 1,
|
|
696
|
+
condition: () => true,
|
|
697
|
+
action: () => null,
|
|
698
|
+
})).toThrow()
|
|
699
|
+
|
|
700
|
+
expect(() => engine.addRule({
|
|
701
|
+
name: 'valid',
|
|
702
|
+
priority: -1, // Invalid: negative priority
|
|
703
|
+
condition: () => true,
|
|
704
|
+
action: () => null,
|
|
705
|
+
})).toThrow()
|
|
706
|
+
})
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
describe('rule evaluation', () => {
|
|
710
|
+
it('should evaluate rules in priority order', () => {
|
|
711
|
+
const engine = createRoutingRuleEngine(agents)
|
|
712
|
+
|
|
713
|
+
engine.addRule({
|
|
714
|
+
name: 'low-priority',
|
|
715
|
+
priority: 1,
|
|
716
|
+
condition: () => true,
|
|
717
|
+
action: () => agents[1], // slow-agent
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
engine.addRule({
|
|
721
|
+
name: 'high-priority',
|
|
722
|
+
priority: 10,
|
|
723
|
+
condition: () => true,
|
|
724
|
+
action: () => agents[0], // fast-agent
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
const result = engine.route(createTask('test'))
|
|
728
|
+
|
|
729
|
+
expect(result.agent.id).toBe('fast-agent')
|
|
730
|
+
expect(result.matchedRule).toBe('high-priority')
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it('should skip rules with false conditions', () => {
|
|
734
|
+
const engine = createRoutingRuleEngine(agents)
|
|
735
|
+
|
|
736
|
+
engine.addRule({
|
|
737
|
+
name: 'never-match',
|
|
738
|
+
priority: 10,
|
|
739
|
+
condition: () => false,
|
|
740
|
+
action: () => agents[0],
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
engine.addRule({
|
|
744
|
+
name: 'always-match',
|
|
745
|
+
priority: 1,
|
|
746
|
+
condition: () => true,
|
|
747
|
+
action: () => agents[1],
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
const result = engine.route(createTask('test'))
|
|
751
|
+
|
|
752
|
+
expect(result.agent.id).toBe('slow-agent')
|
|
753
|
+
expect(result.matchedRule).toBe('always-match')
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it('should use default routing if no rules match', () => {
|
|
757
|
+
const engine = createRoutingRuleEngine(agents, {
|
|
758
|
+
defaultStrategy: 'least-busy',
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
engine.addRule({
|
|
762
|
+
name: 'never-match',
|
|
763
|
+
priority: 10,
|
|
764
|
+
condition: () => false,
|
|
765
|
+
action: () => agents[0],
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
const result = engine.route(createTask('test'))
|
|
769
|
+
|
|
770
|
+
// Should use default strategy when no rules match
|
|
771
|
+
// fast-agent and secure-agent both have load 0, so either could be selected
|
|
772
|
+
expect(['fast-agent', 'secure-agent']).toContain(result.agent.id)
|
|
773
|
+
expect(result.matchedRule).toBeNull()
|
|
774
|
+
expect(result.usedDefault).toBe(true)
|
|
775
|
+
})
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
describe('condition types', () => {
|
|
779
|
+
it('should support skill-based conditions', () => {
|
|
780
|
+
const engine = createRoutingRuleEngine(agents)
|
|
781
|
+
|
|
782
|
+
engine.addRule({
|
|
783
|
+
name: 'security-route',
|
|
784
|
+
priority: 5,
|
|
785
|
+
condition: { requiredSkills: { contains: 'security' } },
|
|
786
|
+
action: () => agents.find(a => a.id === 'secure-agent'),
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
const secTask = createTask('sec-task', ['security'])
|
|
790
|
+
const codeTask = createTask('code-task', ['code'])
|
|
791
|
+
|
|
792
|
+
const secResult = engine.route(secTask)
|
|
793
|
+
expect(secResult.agent.id).toBe('secure-agent')
|
|
794
|
+
|
|
795
|
+
// Code task shouldn't match security rule
|
|
796
|
+
const codeResult = engine.route(codeTask)
|
|
797
|
+
expect(codeResult.matchedRule).not.toBe('security-route')
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
it('should support priority-based conditions', () => {
|
|
801
|
+
const engine = createRoutingRuleEngine(agents)
|
|
802
|
+
|
|
803
|
+
engine.addRule({
|
|
804
|
+
name: 'high-priority-route',
|
|
805
|
+
priority: 10,
|
|
806
|
+
condition: { priority: { gte: 8 } },
|
|
807
|
+
action: () => agents[0], // Fast agent for high priority
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
const highPriority = createTask('urgent', [], 9)
|
|
811
|
+
const lowPriority = createTask('normal', [], 3)
|
|
812
|
+
|
|
813
|
+
expect(engine.route(highPriority).matchedRule).toBe('high-priority-route')
|
|
814
|
+
expect(engine.route(lowPriority).matchedRule).not.toBe('high-priority-route')
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('should support metadata-based conditions', () => {
|
|
818
|
+
const engine = createRoutingRuleEngine(agents)
|
|
819
|
+
|
|
820
|
+
engine.addRule({
|
|
821
|
+
name: 'internal-route',
|
|
822
|
+
priority: 5,
|
|
823
|
+
condition: { metadata: { source: 'internal' } },
|
|
824
|
+
action: () => agents[0],
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
const internalTask = createTask('internal-task')
|
|
828
|
+
internalTask.metadata = { source: 'internal' }
|
|
829
|
+
|
|
830
|
+
const externalTask = createTask('external-task')
|
|
831
|
+
externalTask.metadata = { source: 'external' }
|
|
832
|
+
|
|
833
|
+
expect(engine.route(internalTask).matchedRule).toBe('internal-route')
|
|
834
|
+
expect(engine.route(externalTask).matchedRule).not.toBe('internal-route')
|
|
835
|
+
})
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
describe('rule management', () => {
|
|
839
|
+
it('should remove rules by name', () => {
|
|
840
|
+
const engine = createRoutingRuleEngine(agents)
|
|
841
|
+
|
|
842
|
+
engine.addRule({
|
|
843
|
+
name: 'temp-rule',
|
|
844
|
+
priority: 1,
|
|
845
|
+
condition: () => true,
|
|
846
|
+
action: () => agents[0],
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
expect(engine.getRules()).toHaveLength(1)
|
|
850
|
+
|
|
851
|
+
engine.removeRule('temp-rule')
|
|
852
|
+
|
|
853
|
+
expect(engine.getRules()).toHaveLength(0)
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it('should update existing rules', () => {
|
|
857
|
+
const engine = createRoutingRuleEngine(agents)
|
|
858
|
+
|
|
859
|
+
engine.addRule({
|
|
860
|
+
name: 'my-rule',
|
|
861
|
+
priority: 1,
|
|
862
|
+
condition: () => true,
|
|
863
|
+
action: () => agents[0],
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
engine.updateRule('my-rule', { priority: 10 })
|
|
867
|
+
|
|
868
|
+
const rules = engine.getRules()
|
|
869
|
+
expect(rules[0].priority).toBe(10)
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('should enable/disable rules', () => {
|
|
873
|
+
const engine = createRoutingRuleEngine(agents)
|
|
874
|
+
|
|
875
|
+
engine.addRule({
|
|
876
|
+
name: 'toggleable',
|
|
877
|
+
priority: 10,
|
|
878
|
+
condition: () => true,
|
|
879
|
+
action: () => agents[0],
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
engine.disableRule('toggleable')
|
|
883
|
+
expect(engine.route(createTask('test')).matchedRule).not.toBe('toggleable')
|
|
884
|
+
|
|
885
|
+
engine.enableRule('toggleable')
|
|
886
|
+
expect(engine.route(createTask('test')).matchedRule).toBe('toggleable')
|
|
887
|
+
})
|
|
888
|
+
})
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
// ============================================================================
|
|
892
|
+
// Composite Balancer Tests
|
|
893
|
+
// ============================================================================
|
|
894
|
+
|
|
895
|
+
describe('CompositeBalancer', () => {
|
|
896
|
+
let agents: AgentInfo[]
|
|
897
|
+
|
|
898
|
+
beforeEach(() => {
|
|
899
|
+
agents = [
|
|
900
|
+
createAgent('agent-1', ['code'], 'available', 2),
|
|
901
|
+
createAgent('agent-2', ['code', 'review'], 'available', 5),
|
|
902
|
+
createAgent('agent-3', ['deploy'], 'available', 1),
|
|
903
|
+
]
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
describe('strategy composition', () => {
|
|
907
|
+
it('should use first successful strategy', () => {
|
|
908
|
+
const balancer = createCompositeBalancer(agents, {
|
|
909
|
+
strategies: ['capability', 'least-busy'],
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
const task = createTask('code-task', ['code'])
|
|
913
|
+
const result = balancer.route(task)
|
|
914
|
+
|
|
915
|
+
// Capability router finds agent-1 (has 'code' skill)
|
|
916
|
+
expect(result.agent.id).toBe('agent-1')
|
|
917
|
+
// Only includes strategies attempted before success
|
|
918
|
+
expect(result.strategies).toContain('capability')
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
it('should fallback through strategies', () => {
|
|
922
|
+
const balancer = createCompositeBalancer(agents, {
|
|
923
|
+
strategies: ['capability', 'round-robin'],
|
|
924
|
+
fallbackBehavior: 'next-strategy',
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
// Task with no matching skills
|
|
928
|
+
const task = createTask('unknown-task', ['quantum'])
|
|
929
|
+
|
|
930
|
+
// Should fallback to round-robin since no capability match
|
|
931
|
+
const result = balancer.route(task)
|
|
932
|
+
expect(result.agent).not.toBeNull()
|
|
933
|
+
expect(result.usedFallback).toBe(true)
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('should support weighted strategy combination', () => {
|
|
937
|
+
const balancer = createCompositeBalancer(agents, {
|
|
938
|
+
strategies: [
|
|
939
|
+
{ strategy: 'capability', weight: 0.7 },
|
|
940
|
+
{ strategy: 'least-busy', weight: 0.3 },
|
|
941
|
+
],
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
const task = createTask('weighted-task', ['code'])
|
|
945
|
+
const result = balancer.route(task)
|
|
946
|
+
|
|
947
|
+
// When capability succeeds, it records the weight
|
|
948
|
+
expect(result.strategyScores).toBeDefined()
|
|
949
|
+
expect(result.strategyScores.capability).toBe(0.7)
|
|
950
|
+
})
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
describe('custom strategy plugins', () => {
|
|
954
|
+
it('should support custom routing strategies', () => {
|
|
955
|
+
const balancer = createCompositeBalancer(agents, {
|
|
956
|
+
strategies: ['custom'],
|
|
957
|
+
customStrategies: {
|
|
958
|
+
custom: (task, agents) => {
|
|
959
|
+
// Always pick the last agent
|
|
960
|
+
return agents[agents.length - 1]
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
const result = balancer.route(createTask('test'))
|
|
966
|
+
|
|
967
|
+
expect(result.agent.id).toBe('agent-3')
|
|
968
|
+
})
|
|
969
|
+
})
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
// ============================================================================
|
|
973
|
+
// Routing Metrics Tests
|
|
974
|
+
// ============================================================================
|
|
975
|
+
|
|
976
|
+
describe('RoutingMetrics', () => {
|
|
977
|
+
beforeEach(() => {
|
|
978
|
+
resetRoutingMetrics()
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
describe('collectRoutingMetrics()', () => {
|
|
982
|
+
it('should track routing decisions', () => {
|
|
983
|
+
const agents = [createAgent('agent-1')]
|
|
984
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
985
|
+
|
|
986
|
+
balancer.route(createTask('t1'))
|
|
987
|
+
balancer.route(createTask('t2'))
|
|
988
|
+
balancer.route(createTask('t3'))
|
|
989
|
+
|
|
990
|
+
const metrics = collectRoutingMetrics()
|
|
991
|
+
|
|
992
|
+
expect(metrics.totalRouted).toBe(3)
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
it('should track routing latency', () => {
|
|
996
|
+
const agents = [createAgent('agent-1')]
|
|
997
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
998
|
+
|
|
999
|
+
balancer.route(createTask('t1'))
|
|
1000
|
+
|
|
1001
|
+
const metrics = collectRoutingMetrics()
|
|
1002
|
+
|
|
1003
|
+
expect(metrics.averageLatencyMs).toBeGreaterThanOrEqual(0)
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('should track per-agent distribution', () => {
|
|
1007
|
+
const agents = [
|
|
1008
|
+
createAgent('agent-1'),
|
|
1009
|
+
createAgent('agent-2'),
|
|
1010
|
+
]
|
|
1011
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
1012
|
+
|
|
1013
|
+
balancer.route(createTask('t1'))
|
|
1014
|
+
balancer.route(createTask('t2'))
|
|
1015
|
+
balancer.route(createTask('t3'))
|
|
1016
|
+
balancer.route(createTask('t4'))
|
|
1017
|
+
|
|
1018
|
+
const metrics = collectRoutingMetrics()
|
|
1019
|
+
|
|
1020
|
+
expect(metrics.perAgent['agent-1'].routedCount).toBe(2)
|
|
1021
|
+
expect(metrics.perAgent['agent-2'].routedCount).toBe(2)
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
it('should track failed routing attempts', () => {
|
|
1025
|
+
const agents = [createAgent('agent-1', [], 'offline')]
|
|
1026
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
1027
|
+
|
|
1028
|
+
balancer.route(createTask('t1'))
|
|
1029
|
+
|
|
1030
|
+
const metrics = collectRoutingMetrics()
|
|
1031
|
+
|
|
1032
|
+
expect(metrics.failedRoutes).toBe(1)
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
it('should track strategy usage', () => {
|
|
1036
|
+
const agents = [createAgent('agent-1')]
|
|
1037
|
+
const rrBalancer = createRoundRobinBalancer(agents)
|
|
1038
|
+
const lbBalancer = createLeastBusyBalancer(agents)
|
|
1039
|
+
|
|
1040
|
+
rrBalancer.route(createTask('t1'))
|
|
1041
|
+
rrBalancer.route(createTask('t2'))
|
|
1042
|
+
lbBalancer.route(createTask('t3'))
|
|
1043
|
+
|
|
1044
|
+
const metrics = collectRoutingMetrics()
|
|
1045
|
+
|
|
1046
|
+
expect(metrics.strategyUsage['round-robin']).toBe(2)
|
|
1047
|
+
expect(metrics.strategyUsage['least-busy']).toBe(1)
|
|
1048
|
+
})
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
describe('resetRoutingMetrics()', () => {
|
|
1052
|
+
it('should reset all metrics to zero', () => {
|
|
1053
|
+
const agents = [createAgent('agent-1')]
|
|
1054
|
+
const balancer = createRoundRobinBalancer(agents)
|
|
1055
|
+
|
|
1056
|
+
balancer.route(createTask('t1'))
|
|
1057
|
+
|
|
1058
|
+
resetRoutingMetrics()
|
|
1059
|
+
|
|
1060
|
+
const metrics = collectRoutingMetrics()
|
|
1061
|
+
expect(metrics.totalRouted).toBe(0)
|
|
1062
|
+
})
|
|
1063
|
+
})
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
// ============================================================================
|
|
1067
|
+
// Type Definition Tests
|
|
1068
|
+
// ============================================================================
|
|
1069
|
+
|
|
1070
|
+
describe('Type Definitions', () => {
|
|
1071
|
+
it('should have correct LoadBalancer interface', () => {
|
|
1072
|
+
const balancer: LoadBalancer = {
|
|
1073
|
+
route: vi.fn(),
|
|
1074
|
+
addAgent: vi.fn(),
|
|
1075
|
+
removeAgent: vi.fn(),
|
|
1076
|
+
getAgents: vi.fn().mockReturnValue([]),
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
expectTypeOf(balancer.route).toBeFunction()
|
|
1080
|
+
expectTypeOf(balancer.addAgent).toBeFunction()
|
|
1081
|
+
expectTypeOf(balancer.removeAgent).toBeFunction()
|
|
1082
|
+
expectTypeOf(balancer.getAgents).toBeFunction()
|
|
1083
|
+
})
|
|
1084
|
+
|
|
1085
|
+
it('should have correct AgentInfo type', () => {
|
|
1086
|
+
const agent: AgentInfo = {
|
|
1087
|
+
id: 'test',
|
|
1088
|
+
name: 'Test Agent',
|
|
1089
|
+
type: 'agent',
|
|
1090
|
+
status: 'available',
|
|
1091
|
+
skills: [],
|
|
1092
|
+
currentLoad: 0,
|
|
1093
|
+
maxLoad: 10,
|
|
1094
|
+
contacts: {},
|
|
1095
|
+
metadata: {},
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
expectTypeOf(agent).toHaveProperty('id')
|
|
1099
|
+
expectTypeOf(agent).toHaveProperty('skills')
|
|
1100
|
+
expectTypeOf(agent).toHaveProperty('currentLoad')
|
|
1101
|
+
expectTypeOf(agent).toHaveProperty('maxLoad')
|
|
1102
|
+
})
|
|
1103
|
+
|
|
1104
|
+
it('should have correct RouteResult type', () => {
|
|
1105
|
+
const result: RouteResult = {
|
|
1106
|
+
agent: null as any,
|
|
1107
|
+
task: null as any,
|
|
1108
|
+
strategy: 'round-robin',
|
|
1109
|
+
timestamp: new Date(),
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
expectTypeOf(result).toHaveProperty('agent')
|
|
1113
|
+
expectTypeOf(result).toHaveProperty('task')
|
|
1114
|
+
expectTypeOf(result).toHaveProperty('strategy')
|
|
1115
|
+
expectTypeOf(result).toHaveProperty('timestamp')
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
it('should have correct TaskRequest type', () => {
|
|
1119
|
+
const task: TaskRequest = {
|
|
1120
|
+
id: 'test',
|
|
1121
|
+
name: 'Test Task',
|
|
1122
|
+
requiredSkills: [],
|
|
1123
|
+
priority: 5,
|
|
1124
|
+
metadata: {},
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
expectTypeOf(task).toHaveProperty('id')
|
|
1128
|
+
expectTypeOf(task).toHaveProperty('requiredSkills')
|
|
1129
|
+
expectTypeOf(task).toHaveProperty('priority')
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
it('should have correct RoutingRule type', () => {
|
|
1133
|
+
const rule: RoutingRule = {
|
|
1134
|
+
name: 'test-rule',
|
|
1135
|
+
priority: 1,
|
|
1136
|
+
condition: () => true,
|
|
1137
|
+
action: () => null as any,
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
expectTypeOf(rule).toHaveProperty('name')
|
|
1141
|
+
expectTypeOf(rule).toHaveProperty('priority')
|
|
1142
|
+
expectTypeOf(rule).toHaveProperty('condition')
|
|
1143
|
+
expectTypeOf(rule).toHaveProperty('action')
|
|
1144
|
+
})
|
|
1145
|
+
})
|