plugin-agent-orchestrator 1.0.19 → 1.0.21
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/dist/client/hooks/useRunEventStream.d.ts +22 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -1
- package/dist/externalVersion.js +6 -6
- package/dist/server/collections/agent-execution-spans.js +24 -0
- package/dist/server/collections/agent-loop-runs.js +36 -0
- package/dist/server/collections/orchestrator-config.js +14 -0
- package/dist/server/migrations/20260601000000-add-token-fields.d.ts +7 -0
- package/dist/server/migrations/20260601000000-add-token-fields.js +101 -0
- package/dist/server/plugin.js +47 -0
- package/dist/server/resources/agent-loop.js +33 -25
- package/dist/server/resources/tracing.js +5 -8
- package/dist/server/services/AgentHarness.d.ts +2 -0
- package/dist/server/services/AgentHarness.js +56 -90
- package/dist/server/services/AgentLoopController.d.ts +33 -20
- package/dist/server/services/AgentLoopController.js +164 -125
- package/dist/server/services/AgentLoopRepository.js +16 -34
- package/dist/server/services/AgentLoopService.d.ts +28 -18
- package/dist/server/services/AgentLoopService.js +7 -1
- package/dist/server/services/AgentPlannerService.js +5 -25
- package/dist/server/services/AgentRegistryService.d.ts +8 -0
- package/dist/server/services/AgentRegistryService.js +34 -24
- package/dist/server/services/CircuitBreaker.d.ts +40 -0
- package/dist/server/services/CircuitBreaker.js +120 -0
- package/dist/server/services/ContextAggregator.d.ts +45 -0
- package/dist/server/services/ContextAggregator.js +201 -0
- package/dist/server/services/ExecutionSpanService.js +2 -5
- package/dist/server/services/RunEventBus.d.ts +9 -0
- package/dist/server/services/RunEventBus.js +73 -0
- package/dist/server/services/TokenTracker.d.ts +62 -0
- package/dist/server/services/TokenTracker.js +173 -0
- package/dist/server/skill-hub/plugin.js +6 -6
- package/dist/server/skill-hub/tasks/SkillExecutionTask.js +6 -6
- package/dist/server/tools/agent-loop.d.ts +8 -8
- package/dist/server/tools/agent-loop.js +30 -63
- package/dist/server/tools/delegate-task.js +14 -72
- package/dist/server/tools/orchestrator-plan.d.ts +6 -6
- package/dist/server/tools/orchestrator-plan.js +10 -47
- package/dist/server/types.d.ts +47 -0
- package/dist/server/types.js +24 -0
- package/dist/server/utils/ctx-utils.d.ts +30 -0
- package/dist/server/utils/ctx-utils.js +152 -0
- package/dist/server/utils/logging.d.ts +6 -0
- package/dist/server/utils/logging.js +86 -0
- package/package.json +44 -44
- package/src/client/AgentRunsTab.tsx +764 -764
- package/src/client/HarnessProfilesTab.tsx +247 -247
- package/src/client/OrchestratorSettings.tsx +106 -106
- package/src/client/RulesTab.tsx +716 -716
- package/src/client/hooks/useRunEventStream.ts +76 -0
- package/src/client/index.tsx +2 -1
- package/src/client/plugin.tsx +27 -27
- package/src/client/skill-hub/components/LoopSettings.tsx +331 -331
- package/src/client/skill-hub/index.tsx +51 -51
- package/src/client/skill-hub/tools/InteractionSchemasProvider.tsx +99 -99
- package/src/client/skill-hub/tools/SkillHubCard.tsx +109 -109
- package/src/client/skill-hub/tools/loopTemplates.ts +52 -52
- package/src/client/skill-hub/tools/registerSkillLoopCards.ts +58 -58
- package/src/client/tools/PlanApprovalCard.tsx +175 -175
- package/src/client/tools/registerOrchestratorCards.ts +7 -7
- package/src/server/__tests__/agent-loop-controller.test.ts +375 -0
- package/src/server/__tests__/circuit-breaker.test.ts +169 -0
- package/src/server/__tests__/context-aggregator.test.ts +222 -0
- package/src/server/__tests__/parallel-execution.test.ts +318 -0
- package/src/server/__tests__/smoke.test.ts +120 -0
- package/src/server/collections/agent-execution-spans.ts +24 -0
- package/src/server/collections/agent-harness-profiles.ts +59 -59
- package/src/server/collections/agent-loop-events.ts +71 -71
- package/src/server/collections/agent-loop-runs.ts +38 -1
- package/src/server/collections/agent-loop-steps.ts +144 -144
- package/src/server/collections/orchestrator-config.ts +14 -0
- package/src/server/collections/skill-executions.ts +106 -106
- package/src/server/collections/skill-loop-configs.ts +65 -65
- package/src/server/migrations/20260524000000-add-agent-loop-fields-to-skill-executions.ts +30 -30
- package/src/server/migrations/20260524001000-add-plan-approval-and-harness-profiles.ts +142 -142
- package/src/server/migrations/20260601000000-add-token-fields.ts +89 -0
- package/src/server/plugin.ts +53 -0
- package/src/server/resources/agent-loop.ts +21 -12
- package/src/server/resources/tracing.ts +3 -7
- package/src/server/services/AgentHarness.ts +78 -116
- package/src/server/services/AgentLoopController.ts +197 -122
- package/src/server/services/AgentLoopRepository.ts +9 -25
- package/src/server/services/AgentLoopService.ts +13 -1
- package/src/server/services/AgentPlanValidator.ts +73 -73
- package/src/server/services/AgentPlannerService.ts +2 -25
- package/src/server/services/AgentRegistryService.ts +40 -31
- package/src/server/services/CircuitBreaker.ts +116 -0
- package/src/server/services/ContextAggregator.ts +239 -0
- package/src/server/services/ExecutionSpanService.ts +2 -4
- package/src/server/services/RunEventBus.ts +45 -0
- package/src/server/services/TokenTracker.ts +209 -0
- package/src/server/skill-hub/plugin.ts +898 -897
- package/src/server/skill-hub/tasks/SkillExecutionTask.ts +460 -458
- package/src/server/tools/agent-loop.ts +18 -57
- package/src/server/tools/delegate-task.ts +11 -93
- package/src/server/tools/orchestrator-plan.ts +26 -50
- package/src/server/tools/skill-execute.ts +160 -160
- package/src/server/types.ts +55 -0
- package/src/server/utils/ctx-utils.ts +118 -0
- package/src/server/utils/logging.ts +63 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AgentLoopController } from '../services/AgentLoopController';
|
|
3
|
+
|
|
4
|
+
// --- Mocks ---
|
|
5
|
+
function createMockRepository() {
|
|
6
|
+
const runs = new Map<number, any>();
|
|
7
|
+
const steps = new Map<number, any>();
|
|
8
|
+
const events: any[] = [];
|
|
9
|
+
let runIdCounter = 1;
|
|
10
|
+
let stepIdCounter = 1;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
_runs: runs,
|
|
14
|
+
_steps: steps,
|
|
15
|
+
_events: events,
|
|
16
|
+
|
|
17
|
+
requireRun: vi.fn(async (id: number) => {
|
|
18
|
+
const run = runs.get(Number(id));
|
|
19
|
+
if (!run) throw new Error(`Run ${id} not found`);
|
|
20
|
+
return { ...run };
|
|
21
|
+
}),
|
|
22
|
+
|
|
23
|
+
getRun: vi.fn(async (id: number) => {
|
|
24
|
+
const run = runs.get(Number(id));
|
|
25
|
+
return run ? { ...run } : null;
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
createRun: vi.fn(async (values: any) => {
|
|
29
|
+
const id = runIdCounter++;
|
|
30
|
+
const run = { id, ...values, createdAt: new Date(), updatedAt: new Date() };
|
|
31
|
+
runs.set(id, run);
|
|
32
|
+
return { ...run };
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
updateRun: vi.fn(async (id: number, values: any) => {
|
|
36
|
+
const existing = runs.get(Number(id));
|
|
37
|
+
if (existing) {
|
|
38
|
+
const updated = { ...existing, ...values, updatedAt: new Date() };
|
|
39
|
+
runs.set(Number(id), updated);
|
|
40
|
+
}
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
requireStep: vi.fn(async (id: number) => {
|
|
44
|
+
const step = steps.get(Number(id));
|
|
45
|
+
if (!step) throw new Error(`Step ${id} not found`);
|
|
46
|
+
return { ...step };
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
getStep: vi.fn(async (id: number) => {
|
|
50
|
+
const step = steps.get(Number(id));
|
|
51
|
+
return step ? { ...step } : null;
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
createStep: vi.fn(async (values: any) => {
|
|
55
|
+
const id = stepIdCounter++;
|
|
56
|
+
const step = { id, ...values, createdAt: new Date(), updatedAt: new Date() };
|
|
57
|
+
steps.set(id, step);
|
|
58
|
+
return { ...step };
|
|
59
|
+
}),
|
|
60
|
+
|
|
61
|
+
updateStep: vi.fn(async (id: number, values: any) => {
|
|
62
|
+
const existing = steps.get(Number(id));
|
|
63
|
+
if (existing) {
|
|
64
|
+
steps.set(Number(id), { ...existing, ...values, updatedAt: new Date() });
|
|
65
|
+
}
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
getSteps: vi.fn(async (runId: number) => {
|
|
69
|
+
return Array.from(steps.values())
|
|
70
|
+
.filter((s) => s.runId === runId)
|
|
71
|
+
.map((s) => ({ ...s }))
|
|
72
|
+
.sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
createEvent: vi.fn(async (values: any) => {
|
|
76
|
+
const event = { id: events.length + 1, ...values, createdAt: new Date() };
|
|
77
|
+
events.push(event);
|
|
78
|
+
return event;
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
getEvents: vi.fn(async () => []),
|
|
82
|
+
getLinkedSpans: vi.fn(async () => []),
|
|
83
|
+
getLinkedSkillExecutions: vi.fn(async () => []),
|
|
84
|
+
lockRun: vi.fn(async () => true),
|
|
85
|
+
unlockRun: vi.fn(async () => {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createMockServices() {
|
|
90
|
+
return {
|
|
91
|
+
registryService: {
|
|
92
|
+
getHarnessProfile: vi.fn(async () => ({ settings: { allowSubAgents: true, allowToolCalls: true } })),
|
|
93
|
+
},
|
|
94
|
+
plannerService: {
|
|
95
|
+
buildPlan: vi.fn((goal, plan) => plan || [{ title: 'Default step', type: 'skill' }]),
|
|
96
|
+
},
|
|
97
|
+
validator: {
|
|
98
|
+
validate: vi.fn(),
|
|
99
|
+
},
|
|
100
|
+
repository: createMockRepository(),
|
|
101
|
+
harness: {
|
|
102
|
+
executeStep: vi.fn(async (_run: any, step: any) => ({
|
|
103
|
+
summary: `Executed ${step.title}`,
|
|
104
|
+
})),
|
|
105
|
+
},
|
|
106
|
+
tokenTracker: {
|
|
107
|
+
checkBudget: vi.fn(async () => ({ allowed: true })),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createController(mocks = createMockServices()) {
|
|
113
|
+
const controller = new AgentLoopController(
|
|
114
|
+
mocks.registryService as any,
|
|
115
|
+
mocks.plannerService as any,
|
|
116
|
+
mocks.validator as any,
|
|
117
|
+
mocks.repository as any,
|
|
118
|
+
mocks.harness as any,
|
|
119
|
+
mocks.tokenTracker as any,
|
|
120
|
+
);
|
|
121
|
+
return { controller, mocks };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe('AgentLoopController', () => {
|
|
125
|
+
describe('pickNextSteps', () => {
|
|
126
|
+
it('returns empty array for empty steps', () => {
|
|
127
|
+
const { controller } = createController();
|
|
128
|
+
expect(controller.pickNextSteps([])).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns pending steps with no dependencies', () => {
|
|
132
|
+
const { controller } = createController();
|
|
133
|
+
const steps = [
|
|
134
|
+
{ id: 1, planKey: 'step_1', status: 'pending', dependsOn: [], index: 0 },
|
|
135
|
+
{ id: 2, planKey: 'step_2', status: 'succeeded', dependsOn: [], index: 1 },
|
|
136
|
+
];
|
|
137
|
+
const result = controller.pickNextSteps(steps);
|
|
138
|
+
expect(result).toHaveLength(1);
|
|
139
|
+
expect(result[0].id).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns step when dependency is satisfied', () => {
|
|
143
|
+
const { controller } = createController();
|
|
144
|
+
const steps = [
|
|
145
|
+
{ id: 1, planKey: 'step_1', status: 'succeeded', dependsOn: [], index: 0 },
|
|
146
|
+
{ id: 2, planKey: 'step_2', status: 'pending', dependsOn: ['step_1'], index: 1 },
|
|
147
|
+
];
|
|
148
|
+
const result = controller.pickNextSteps(steps);
|
|
149
|
+
expect(result).toHaveLength(1);
|
|
150
|
+
expect(result[0].id).toBe(2);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not return step when dependency is pending', () => {
|
|
154
|
+
const { controller } = createController();
|
|
155
|
+
const steps = [
|
|
156
|
+
{ id: 1, planKey: 'step_1', status: 'pending', dependsOn: [], index: 0 },
|
|
157
|
+
{ id: 2, planKey: 'step_2', status: 'pending', dependsOn: ['step_1'], index: 1 },
|
|
158
|
+
];
|
|
159
|
+
const result = controller.pickNextSteps(steps);
|
|
160
|
+
expect(result).toHaveLength(1);
|
|
161
|
+
expect(result[0].id).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('handles allow_skipped dependency policy', () => {
|
|
165
|
+
const { controller } = createController();
|
|
166
|
+
const steps = [
|
|
167
|
+
{ id: 1, planKey: 'step_1', status: 'skipped', dependsOn: [], index: 0 },
|
|
168
|
+
{
|
|
169
|
+
id: 2,
|
|
170
|
+
planKey: 'step_2',
|
|
171
|
+
status: 'pending',
|
|
172
|
+
dependsOn: ['step_1'],
|
|
173
|
+
dependencyPolicy: 'allow_skipped',
|
|
174
|
+
index: 1,
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
const result = controller.pickNextSteps(steps);
|
|
178
|
+
expect(result).toHaveLength(1);
|
|
179
|
+
expect(result[0].id).toBe(2);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('does not return step when allow_skipped dependency is skipped but policy is require_success', () => {
|
|
183
|
+
const { controller } = createController();
|
|
184
|
+
const steps = [
|
|
185
|
+
{ id: 1, planKey: 'step_1', status: 'skipped', dependsOn: [], index: 0 },
|
|
186
|
+
{
|
|
187
|
+
id: 2,
|
|
188
|
+
planKey: 'step_2',
|
|
189
|
+
status: 'pending',
|
|
190
|
+
dependsOn: ['step_1'],
|
|
191
|
+
dependencyPolicy: 'require_success',
|
|
192
|
+
index: 1,
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
const result = controller.pickNextSteps(steps);
|
|
196
|
+
expect(result).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('returns multiple independent steps', () => {
|
|
200
|
+
const { controller } = createController();
|
|
201
|
+
const steps = [
|
|
202
|
+
{ id: 1, planKey: 'step_1', status: 'pending', dependsOn: [], index: 0 },
|
|
203
|
+
{ id: 2, planKey: 'step_2', status: 'pending', dependsOn: [], index: 1 },
|
|
204
|
+
{ id: 3, planKey: 'step_3', status: 'pending', dependsOn: ['step_1'], index: 2 },
|
|
205
|
+
];
|
|
206
|
+
const result = controller.pickNextSteps(steps);
|
|
207
|
+
expect(result).toHaveLength(2);
|
|
208
|
+
expect(result.map((s: any) => s.id)).toEqual([1, 2]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('returns failed step that is retryable', () => {
|
|
212
|
+
const { controller } = createController();
|
|
213
|
+
const steps = [
|
|
214
|
+
{ id: 1, planKey: 'step_1', status: 'failed', dependsOn: [], index: 0, attempt: 1, maxAttempts: 3 },
|
|
215
|
+
];
|
|
216
|
+
const result = controller.pickNextSteps(steps);
|
|
217
|
+
expect(result).toHaveLength(1);
|
|
218
|
+
expect(result[0].id).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('does not return failed step that exhausted attempts', () => {
|
|
222
|
+
const { controller } = createController();
|
|
223
|
+
const steps = [
|
|
224
|
+
{ id: 1, planKey: 'step_1', status: 'failed', dependsOn: [], index: 0, attempt: 3, maxAttempts: 3 },
|
|
225
|
+
];
|
|
226
|
+
const result = controller.pickNextSteps(steps);
|
|
227
|
+
expect(result).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('createRun', () => {
|
|
232
|
+
it('creates a run with a goal', async () => {
|
|
233
|
+
const { controller, mocks } = createController();
|
|
234
|
+
const snapshot = await controller.createRun({ goal: 'Test goal' });
|
|
235
|
+
expect(snapshot.run).toBeTruthy();
|
|
236
|
+
expect(snapshot.run.goal).toBe('Test goal');
|
|
237
|
+
expect(snapshot.run.status).toBe('planning');
|
|
238
|
+
expect(snapshot.run.rootRunId).toMatch(/^loop_/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('throws for empty goal', async () => {
|
|
242
|
+
const { controller } = createController();
|
|
243
|
+
await expect(controller.createRun({ goal: '' })).rejects.toThrow('goal is required');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('creates steps when plan is provided', async () => {
|
|
247
|
+
const { controller, mocks } = createController();
|
|
248
|
+
const snapshot = await controller.createRun({
|
|
249
|
+
goal: 'Test with plan',
|
|
250
|
+
plan: [
|
|
251
|
+
{ title: 'Step 1', type: 'reasoning' },
|
|
252
|
+
{ title: 'Step 2', type: 'skill', target: 'search' },
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
expect(snapshot.steps).toHaveLength(2);
|
|
256
|
+
expect(snapshot.steps[0].title).toBe('Step 1');
|
|
257
|
+
expect(snapshot.steps[1].title).toBe('Step 2');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('step lifecycle', () => {
|
|
262
|
+
it('completeStep succeeds when step is running', async () => {
|
|
263
|
+
const { controller, mocks } = createController();
|
|
264
|
+
// Create a run and step
|
|
265
|
+
const run = await mocks.repository.createRun({ goal: 'test', rootRunId: 'r1', status: 'running' });
|
|
266
|
+
await mocks.repository.createStep({ runId: run.id, planKey: 'step_1', status: 'running', attempt: 1 });
|
|
267
|
+
|
|
268
|
+
const snapshot = await controller.completeStep(1, { result: 'done' });
|
|
269
|
+
const step = snapshot.steps.find((s: any) => s.id === 1);
|
|
270
|
+
expect(step.status).toBe('succeeded');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('completeStep throws when step is not running', async () => {
|
|
274
|
+
const { controller, mocks } = createController();
|
|
275
|
+
await mocks.repository.createRun({ goal: 'test', rootRunId: 'r2', status: 'running' });
|
|
276
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'pending' });
|
|
277
|
+
|
|
278
|
+
await expect(controller.completeStep(1, {})).rejects.toThrow('cannot complete');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('failStep throws when step is not running', async () => {
|
|
282
|
+
const { controller, mocks } = createController();
|
|
283
|
+
await mocks.repository.createRun({ goal: 'test', rootRunId: 'r3', status: 'running' });
|
|
284
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'pending' });
|
|
285
|
+
|
|
286
|
+
await expect(controller.failStep(1, 'error')).rejects.toThrow('cannot fail');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('skipStep succeeds for pending or running steps', async () => {
|
|
290
|
+
const { controller, mocks } = createController();
|
|
291
|
+
await mocks.repository.createRun({ goal: 'test', rootRunId: 'r4', status: 'running' });
|
|
292
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'pending' });
|
|
293
|
+
|
|
294
|
+
const snapshot = await controller.skipStep(1, 'Not needed');
|
|
295
|
+
const step = snapshot.steps.find((s: any) => s.id === 1);
|
|
296
|
+
expect(step.status).toBe('skipped');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('retryStep resets failed step to pending', async () => {
|
|
300
|
+
const { controller, mocks } = createController();
|
|
301
|
+
await mocks.repository.createRun({
|
|
302
|
+
goal: 'test',
|
|
303
|
+
rootRunId: 'r5',
|
|
304
|
+
status: 'running',
|
|
305
|
+
policy: { maxStepAttempts: 3 },
|
|
306
|
+
});
|
|
307
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'failed', attempt: 1, maxAttempts: 3 });
|
|
308
|
+
|
|
309
|
+
const snapshot = await controller.retryStep(1);
|
|
310
|
+
const step = snapshot.steps.find((s: any) => s.id === 1);
|
|
311
|
+
expect(step.status).toBe('pending');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('retryStep throws when maxAttempts exceeded', async () => {
|
|
315
|
+
const { controller, mocks } = createController();
|
|
316
|
+
await mocks.repository.createRun({ goal: 'test', rootRunId: 'r6', status: 'running' });
|
|
317
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'failed', attempt: 5, maxAttempts: 3 });
|
|
318
|
+
|
|
319
|
+
await expect(controller.retryStep(1)).rejects.toThrow('maxAttempts');
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('approval flow', () => {
|
|
324
|
+
it('requestApproval sets step to waiting_user', async () => {
|
|
325
|
+
const { controller, mocks } = createController();
|
|
326
|
+
await mocks.repository.createRun({ goal: 'test', rootRunId: 'r7', status: 'running' });
|
|
327
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'running' });
|
|
328
|
+
|
|
329
|
+
const snapshot = await controller.requestApproval(1, { prompt: 'OK?' });
|
|
330
|
+
const step = snapshot.steps.find((s: any) => s.id === 1);
|
|
331
|
+
expect(step.status).toBe('waiting_user');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('resumeRun with approved=true resumes to pending', async () => {
|
|
335
|
+
const { controller, mocks } = createController();
|
|
336
|
+
await mocks.repository.createRun({
|
|
337
|
+
id: 1,
|
|
338
|
+
goal: 'test',
|
|
339
|
+
rootRunId: 'r8',
|
|
340
|
+
status: 'waiting_user',
|
|
341
|
+
currentStepId: 1,
|
|
342
|
+
policy: { requireVerification: false },
|
|
343
|
+
});
|
|
344
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'waiting_user', approval: {} });
|
|
345
|
+
|
|
346
|
+
const snapshot = await controller.resumeRun(1, { approved: true });
|
|
347
|
+
expect(snapshot).toBeTruthy();
|
|
348
|
+
// skip further assertions — the run may have attempted execution
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('finishRun', () => {
|
|
353
|
+
it('finishes a succeeded run where all steps complete', async () => {
|
|
354
|
+
const { controller, mocks } = createController();
|
|
355
|
+
await mocks.repository.createRun({
|
|
356
|
+
goal: 'test',
|
|
357
|
+
rootRunId: 'r9',
|
|
358
|
+
status: 'running',
|
|
359
|
+
policy: { requireVerification: false },
|
|
360
|
+
});
|
|
361
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'succeeded', type: 'reasoning' });
|
|
362
|
+
|
|
363
|
+
const snapshot = await controller.finishRun(1, 'All done', { status: 'succeeded' });
|
|
364
|
+
expect(snapshot.run.status).toBe('succeeded');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('throws when succeeded run has unfinished steps', async () => {
|
|
368
|
+
const { controller, mocks } = createController();
|
|
369
|
+
await mocks.repository.createRun({ goal: 'test', rootRunId: 'r10', status: 'running' });
|
|
370
|
+
await mocks.repository.createStep({ runId: 1, planKey: 'step_1', status: 'pending' });
|
|
371
|
+
|
|
372
|
+
await expect(controller.finishRun(1, 'Done', { status: 'succeeded' })).rejects.toThrow('are not complete');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { CircuitBreakerRegistry, getCircuitBreaker } from '../services/CircuitBreaker';
|
|
2
|
+
|
|
3
|
+
describe('CircuitBreakerRegistry', () => {
|
|
4
|
+
let cb: CircuitBreakerRegistry;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
cb = new CircuitBreakerRegistry({ threshold: 3, recoveryTimeout: 30000, halfOpenMaxRequests: 1 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('initial state', () => {
|
|
11
|
+
it('starts closed for any key', () => {
|
|
12
|
+
expect(cb.isAllowed('agent-a')).toBe(true);
|
|
13
|
+
const state = cb.getState('agent-a');
|
|
14
|
+
expect(state).toBeTruthy();
|
|
15
|
+
expect(state!.state).toBe('closed');
|
|
16
|
+
expect(state!.failures).toBe(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns null for unknown key before first check', () => {
|
|
20
|
+
expect(cb.getState('unknown')).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('closed → open transition', () => {
|
|
25
|
+
it('stays closed below threshold', () => {
|
|
26
|
+
cb.recordFailure('agent-a');
|
|
27
|
+
cb.recordFailure('agent-a');
|
|
28
|
+
expect(cb.isAllowed('agent-a')).toBe(true);
|
|
29
|
+
expect(cb.getState('agent-a')!.state).toBe('closed');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('opens after threshold failures', () => {
|
|
33
|
+
cb.recordFailure('agent-a');
|
|
34
|
+
cb.recordFailure('agent-a');
|
|
35
|
+
cb.recordFailure('agent-a');
|
|
36
|
+
expect(cb.getState('agent-a')!.state).toBe('open');
|
|
37
|
+
expect(cb.isAllowed('agent-a')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('tracks failures independently per key', () => {
|
|
41
|
+
cb.recordFailure('agent-a');
|
|
42
|
+
cb.recordFailure('agent-a');
|
|
43
|
+
cb.recordFailure('agent-a');
|
|
44
|
+
cb.recordFailure('agent-b');
|
|
45
|
+
expect(cb.isAllowed('agent-a')).toBe(false);
|
|
46
|
+
expect(cb.isAllowed('agent-b')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('open → half-open transition', () => {
|
|
51
|
+
it('transitions to half-open after recovery timeout', () => {
|
|
52
|
+
cb = new CircuitBreakerRegistry({ threshold: 1, recoveryTimeout: 50, halfOpenMaxRequests: 1 });
|
|
53
|
+
cb.recordFailure('agent-a');
|
|
54
|
+
expect(cb.isAllowed('agent-a')).toBe(false);
|
|
55
|
+
|
|
56
|
+
// After recovery timeout, should become half-open
|
|
57
|
+
return new Promise<void>((resolve) => {
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
expect(cb.isAllowed('agent-a')).toBe(true);
|
|
60
|
+
expect(cb.getState('agent-a')!.state).toBe('half-open');
|
|
61
|
+
resolve();
|
|
62
|
+
}, 60);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('half-open behavior', () => {
|
|
68
|
+
it('recovers to closed on success', () => {
|
|
69
|
+
cb = new CircuitBreakerRegistry({ threshold: 1, recoveryTimeout: 1, halfOpenMaxRequests: 1 });
|
|
70
|
+
cb.recordFailure('agent-a');
|
|
71
|
+
// Manually force to half-open
|
|
72
|
+
const state = cb.getState('agent-a')!;
|
|
73
|
+
state.state = 'half-open';
|
|
74
|
+
state.lastFailureTime = 0;
|
|
75
|
+
|
|
76
|
+
cb.recordSuccess('agent-a');
|
|
77
|
+
expect(cb.getState('agent-a')!.state).toBe('closed');
|
|
78
|
+
expect(cb.getState('agent-a')!.failures).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns to open on probe failure', () => {
|
|
82
|
+
cb = new CircuitBreakerRegistry({ threshold: 1, recoveryTimeout: 1, halfOpenMaxRequests: 1 });
|
|
83
|
+
cb.recordFailure('agent-a');
|
|
84
|
+
const state = cb.getState('agent-a')!;
|
|
85
|
+
state.state = 'half-open';
|
|
86
|
+
|
|
87
|
+
cb.recordFailure('agent-a');
|
|
88
|
+
expect(cb.getState('agent-a')!.state).toBe('open');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('recordSuccess graceful recovery', () => {
|
|
93
|
+
it('decrements failure count in closed state', () => {
|
|
94
|
+
cb.recordFailure('agent-a');
|
|
95
|
+
cb.recordFailure('agent-a');
|
|
96
|
+
expect(cb.getState('agent-a')!.failures).toBe(2);
|
|
97
|
+
cb.recordSuccess('agent-a');
|
|
98
|
+
expect(cb.getState('agent-a')!.failures).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not go below 0', () => {
|
|
102
|
+
cb.recordSuccess('agent-a');
|
|
103
|
+
expect(cb.getState('agent-a')!.failures).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('reset', () => {
|
|
108
|
+
it('resets circuit to closed with zero failures', () => {
|
|
109
|
+
cb.recordFailure('agent-a');
|
|
110
|
+
cb.recordFailure('agent-a');
|
|
111
|
+
cb.recordFailure('agent-a');
|
|
112
|
+
expect(cb.isAllowed('agent-a')).toBe(false);
|
|
113
|
+
|
|
114
|
+
cb.reset('agent-a');
|
|
115
|
+
expect(cb.getState('agent-a')!.state).toBe('closed');
|
|
116
|
+
expect(cb.getState('agent-a')!.failures).toBe(0);
|
|
117
|
+
expect(cb.isAllowed('agent-a')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('getKeys', () => {
|
|
122
|
+
it('returns all tracked keys', () => {
|
|
123
|
+
cb.recordFailure('agent-a');
|
|
124
|
+
cb.recordFailure('agent-b');
|
|
125
|
+
cb.recordFailure('agent-c');
|
|
126
|
+
const keys = cb.getKeys();
|
|
127
|
+
expect(keys).toContain('agent-a');
|
|
128
|
+
expect(keys).toContain('agent-b');
|
|
129
|
+
expect(keys).toContain('agent-c');
|
|
130
|
+
expect(keys.length).toBe(3);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('halfOpenMaxRequests', () => {
|
|
135
|
+
it('allows multiple probes when configured', () => {
|
|
136
|
+
cb = new CircuitBreakerRegistry({ threshold: 1, recoveryTimeout: 1, halfOpenMaxRequests: 3 });
|
|
137
|
+
cb.recordFailure('agent-a');
|
|
138
|
+
const state = cb.getState('agent-a')!;
|
|
139
|
+
state.state = 'half-open';
|
|
140
|
+
|
|
141
|
+
expect(cb.isAllowed('agent-a')).toBe(true);
|
|
142
|
+
expect(cb.isAllowed('agent-a')).toBe(true);
|
|
143
|
+
expect(cb.isAllowed('agent-a')).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('disallows probes when set to 0', () => {
|
|
147
|
+
cb = new CircuitBreakerRegistry({ threshold: 1, recoveryTimeout: 1, halfOpenMaxRequests: 0 });
|
|
148
|
+
cb.recordFailure('agent-a');
|
|
149
|
+
const state = cb.getState('agent-a')!;
|
|
150
|
+
state.state = 'half-open';
|
|
151
|
+
|
|
152
|
+
expect(cb.isAllowed('agent-a')).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('getCircuitBreaker singleton', () => {
|
|
158
|
+
it('returns the same instance across calls', () => {
|
|
159
|
+
const a = getCircuitBreaker();
|
|
160
|
+
const b = getCircuitBreaker();
|
|
161
|
+
expect(a).toBe(b);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('configures with options on first call only', () => {
|
|
165
|
+
// Reset module state by creating fresh instance
|
|
166
|
+
const cb = new CircuitBreakerRegistry({ threshold: 5 });
|
|
167
|
+
expect(cb.threshold).toBe(5);
|
|
168
|
+
});
|
|
169
|
+
});
|