@cleocode/core 2026.3.58 → 2026.3.60
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/agents/agent-registry.d.ts +206 -0
- package/dist/agents/agent-registry.d.ts.map +1 -0
- package/dist/agents/agent-registry.js +288 -0
- package/dist/agents/agent-registry.js.map +1 -0
- package/dist/agents/agent-schema.js +5 -0
- package/dist/agents/agent-schema.js.map +1 -1
- package/dist/agents/execution-learning.js +474 -0
- package/dist/agents/execution-learning.js.map +1 -0
- package/dist/agents/health-monitor.d.ts +161 -0
- package/dist/agents/health-monitor.d.ts.map +1 -0
- package/dist/agents/health-monitor.js +217 -0
- package/dist/agents/health-monitor.js.map +1 -0
- package/dist/agents/index.d.ts +3 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +9 -1
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/retry.d.ts +57 -4
- package/dist/agents/retry.d.ts.map +1 -1
- package/dist/agents/retry.js +57 -4
- package/dist/agents/retry.js.map +1 -1
- package/dist/backfill/index.d.ts +27 -0
- package/dist/backfill/index.d.ts.map +1 -1
- package/dist/backfill/index.js +229 -0
- package/dist/backfill/index.js.map +1 -0
- package/dist/bootstrap.d.ts +2 -1
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +135 -28
- package/dist/bootstrap.js.map +1 -1
- package/dist/cleo.d.ts +40 -0
- package/dist/cleo.d.ts.map +1 -1
- package/dist/config.js +83 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1036 -536
- package/dist/index.js.map +4 -4
- package/dist/intelligence/adaptive-validation.js +497 -0
- package/dist/intelligence/adaptive-validation.js.map +1 -0
- package/dist/intelligence/impact.d.ts +34 -1
- package/dist/intelligence/impact.d.ts.map +1 -1
- package/dist/intelligence/impact.js +176 -0
- package/dist/intelligence/impact.js.map +1 -1
- package/dist/intelligence/index.d.ts +2 -2
- package/dist/intelligence/index.d.ts.map +1 -1
- package/dist/intelligence/index.js +6 -1
- package/dist/intelligence/index.js.map +1 -1
- package/dist/intelligence/types.d.ts +60 -0
- package/dist/intelligence/types.d.ts.map +1 -1
- package/dist/internal.d.ts +5 -4
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +11 -2
- package/dist/internal.js.map +1 -1
- package/dist/lib/index.d.ts +10 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +10 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/retry.d.ts +128 -0
- package/dist/lib/retry.d.ts.map +1 -0
- package/dist/lib/retry.js +152 -0
- package/dist/lib/retry.js.map +1 -0
- package/dist/nexus/sharing/index.d.ts +48 -2
- package/dist/nexus/sharing/index.d.ts.map +1 -1
- package/dist/nexus/sharing/index.js +110 -1
- package/dist/nexus/sharing/index.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +22 -2
- package/dist/scaffold.js.map +1 -1
- package/dist/sessions/session-enforcement.js +4 -0
- package/dist/sessions/session-enforcement.js.map +1 -1
- package/dist/stats/index.js +2 -0
- package/dist/stats/index.js.map +1 -1
- package/dist/stats/workflow-telemetry.d.ts +15 -0
- package/dist/stats/workflow-telemetry.d.ts.map +1 -1
- package/dist/stats/workflow-telemetry.js +400 -0
- package/dist/stats/workflow-telemetry.js.map +1 -0
- package/dist/store/brain-schema.js +4 -1
- package/dist/store/brain-schema.js.map +1 -1
- package/dist/store/converters.js +2 -0
- package/dist/store/converters.js.map +1 -1
- package/dist/store/cross-db-cleanup.d.ts +35 -0
- package/dist/store/cross-db-cleanup.d.ts.map +1 -1
- package/dist/store/cross-db-cleanup.js +169 -0
- package/dist/store/cross-db-cleanup.js.map +1 -0
- package/dist/store/db-helpers.js +2 -0
- package/dist/store/db-helpers.js.map +1 -1
- package/dist/store/migration-sqlite.js +5 -0
- package/dist/store/migration-sqlite.js.map +1 -1
- package/dist/store/sqlite-data-accessor.js +20 -28
- package/dist/store/sqlite-data-accessor.js.map +1 -1
- package/dist/store/sqlite.js +13 -2
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/task-store.js +4 -0
- package/dist/store/task-store.js.map +1 -1
- package/dist/store/tasks-schema.js +50 -20
- package/dist/store/tasks-schema.js.map +1 -1
- package/dist/tasks/add.js +87 -3
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +15 -4
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/enforcement.d.ts.map +1 -1
- package/dist/tasks/enforcement.js +8 -1
- package/dist/tasks/enforcement.js.map +1 -1
- package/dist/tasks/epic-enforcement.d.ts +61 -0
- package/dist/tasks/epic-enforcement.d.ts.map +1 -1
- package/dist/tasks/epic-enforcement.js +294 -0
- package/dist/tasks/epic-enforcement.js.map +1 -0
- package/dist/tasks/index.js +1 -1
- package/dist/tasks/index.js.map +1 -1
- package/dist/tasks/pipeline-stage.d.ts +70 -1
- package/dist/tasks/pipeline-stage.d.ts.map +1 -1
- package/dist/tasks/pipeline-stage.js +248 -0
- package/dist/tasks/pipeline-stage.js.map +1 -0
- package/dist/tasks/update.js +28 -0
- package/dist/tasks/update.js.map +1 -1
- package/package.json +5 -5
- package/schemas/config.schema.json +37 -1547
- package/src/__tests__/sharing.test.ts +24 -0
- package/src/agents/__tests__/agent-registry.test.ts +351 -0
- package/src/agents/__tests__/health-monitor.test.ts +332 -0
- package/src/agents/agent-registry.ts +394 -0
- package/src/agents/health-monitor.ts +279 -0
- package/src/agents/index.ts +24 -1
- package/src/agents/retry.ts +57 -4
- package/src/backfill/index.ts +27 -0
- package/src/bootstrap.ts +171 -30
- package/src/cleo.ts +103 -2
- package/src/config.ts +3 -3
- package/src/index.ts +1 -0
- package/src/intelligence/__tests__/impact.test.ts +165 -1
- package/src/intelligence/impact.ts +203 -0
- package/src/intelligence/index.ts +3 -0
- package/src/intelligence/types.ts +76 -0
- package/src/internal.ts +20 -0
- package/src/lib/__tests__/retry.test.ts +321 -0
- package/src/lib/index.ts +16 -0
- package/src/lib/retry.ts +224 -0
- package/src/nexus/sharing/index.ts +142 -2
- package/src/scaffold.ts +24 -2
- package/src/stats/workflow-telemetry.ts +15 -0
- package/src/store/__tests__/session-store.test.ts +43 -7
- package/src/store/__tests__/task-store.test.ts +1 -1
- package/src/store/__tests__/test-db-helper.ts +7 -3
- package/src/store/cross-db-cleanup.ts +35 -0
- package/src/tasks/__tests__/epic-enforcement.test.ts +9 -4
- package/src/tasks/__tests__/minimal-test.test.ts +2 -2
- package/src/tasks/__tests__/update.test.ts +25 -25
- package/src/tasks/complete.ts +11 -6
- package/src/tasks/enforcement.ts +6 -3
- package/src/tasks/epic-enforcement.ts +61 -0
- package/src/tasks/pipeline-stage.ts +70 -1
- package/templates/config.template.json +5 -116
- package/templates/global-config.template.json +2 -44
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared general-purpose retry utility.
|
|
3
|
+
*
|
|
4
|
+
* @module lib/__tests__/retry.test
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { computeDelay, withRetry } from '../retry.js';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// computeDelay
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
describe('computeDelay', () => {
|
|
15
|
+
it('returns baseDelayMs on first retry attempt', () => {
|
|
16
|
+
expect(computeDelay(1, 2_000, 30_000)).toBe(2_000);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('doubles the delay on each subsequent attempt', () => {
|
|
20
|
+
expect(computeDelay(2, 2_000, 30_000)).toBe(4_000);
|
|
21
|
+
expect(computeDelay(3, 2_000, 30_000)).toBe(8_000);
|
|
22
|
+
expect(computeDelay(4, 2_000, 30_000)).toBe(16_000);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('caps delay at maxDelayMs', () => {
|
|
26
|
+
expect(computeDelay(5, 2_000, 30_000)).toBe(30_000); // 32000 → capped
|
|
27
|
+
expect(computeDelay(10, 2_000, 30_000)).toBe(30_000); // way over → capped
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('produces the task-spec schedule with defaults (0 ms / 2000 ms / 4000 ms)', () => {
|
|
31
|
+
// Attempt 1 succeeds → no delay called.
|
|
32
|
+
// Delay before attempt 2 = computeDelay(1, 2000, 30000) = 2000
|
|
33
|
+
expect(computeDelay(1, 2_000, 30_000)).toBe(2_000);
|
|
34
|
+
// Delay before attempt 3 = computeDelay(2, 2000, 30000) = 4000
|
|
35
|
+
expect(computeDelay(2, 2_000, 30_000)).toBe(4_000);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('handles baseDelayMs=0 gracefully', () => {
|
|
39
|
+
expect(computeDelay(1, 0, 30_000)).toBe(0);
|
|
40
|
+
expect(computeDelay(2, 0, 30_000)).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// withRetry — success paths
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
describe('withRetry — success', () => {
|
|
49
|
+
it('returns the value on first attempt', async () => {
|
|
50
|
+
const result = await withRetry(async () => 42);
|
|
51
|
+
expect(result).toBe(42);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns the value when fn succeeds after failures', async () => {
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
let calls = 0;
|
|
57
|
+
|
|
58
|
+
const promise = withRetry(
|
|
59
|
+
async () => {
|
|
60
|
+
calls++;
|
|
61
|
+
if (calls < 3) throw new Error('transient');
|
|
62
|
+
return 'ok';
|
|
63
|
+
},
|
|
64
|
+
{ baseDelayMs: 100, maxDelayMs: 1_000 },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Advance past both retry waits
|
|
68
|
+
await vi.runAllTimersAsync();
|
|
69
|
+
const result = await promise;
|
|
70
|
+
|
|
71
|
+
expect(result).toBe('ok');
|
|
72
|
+
expect(calls).toBe(3);
|
|
73
|
+
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// withRetry — failure paths
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
describe('withRetry — failure', () => {
|
|
83
|
+
it('throws after exhausting all attempts', async () => {
|
|
84
|
+
vi.useFakeTimers();
|
|
85
|
+
let calls = 0;
|
|
86
|
+
|
|
87
|
+
// Attach rejection handler BEFORE advancing timers to avoid unhandled rejection warnings.
|
|
88
|
+
const promise = withRetry(
|
|
89
|
+
async () => {
|
|
90
|
+
calls++;
|
|
91
|
+
throw new Error('always fails');
|
|
92
|
+
},
|
|
93
|
+
{ maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 100 },
|
|
94
|
+
);
|
|
95
|
+
const settled = promise.then(
|
|
96
|
+
(v) => ({ ok: true as const, value: v }),
|
|
97
|
+
(e: unknown) => ({ ok: false as const, error: e }),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await vi.runAllTimersAsync();
|
|
101
|
+
|
|
102
|
+
const result = await settled;
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
expect((result as { ok: false; error: unknown }).error).toBeInstanceOf(Error);
|
|
105
|
+
expect(calls).toBe(3);
|
|
106
|
+
|
|
107
|
+
vi.useRealTimers();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('attaches retry context to the thrown error', async () => {
|
|
111
|
+
vi.useFakeTimers();
|
|
112
|
+
|
|
113
|
+
// Attach rejection handler BEFORE advancing timers.
|
|
114
|
+
const promise = withRetry(
|
|
115
|
+
async () => {
|
|
116
|
+
throw new Error('kaboom');
|
|
117
|
+
},
|
|
118
|
+
{ maxAttempts: 2, baseDelayMs: 50, maxDelayMs: 500 },
|
|
119
|
+
);
|
|
120
|
+
const settled = promise.then(
|
|
121
|
+
(v) => ({ ok: true as const, value: v }),
|
|
122
|
+
(e: unknown) => ({ ok: false as const, error: e }),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await vi.runAllTimersAsync();
|
|
126
|
+
|
|
127
|
+
const result = await settled;
|
|
128
|
+
expect(result.ok).toBe(false);
|
|
129
|
+
const err = (result as { ok: false; error: unknown }).error as Error & {
|
|
130
|
+
attempts?: number;
|
|
131
|
+
totalDelayMs?: number;
|
|
132
|
+
};
|
|
133
|
+
expect(err).toBeInstanceOf(Error);
|
|
134
|
+
expect(err.attempts).toBe(2);
|
|
135
|
+
expect(err.totalDelayMs).toBeGreaterThanOrEqual(0);
|
|
136
|
+
|
|
137
|
+
vi.useRealTimers();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('makes exactly 1 attempt when maxAttempts is 1', async () => {
|
|
141
|
+
let calls = 0;
|
|
142
|
+
await expect(
|
|
143
|
+
withRetry(
|
|
144
|
+
async () => {
|
|
145
|
+
calls++;
|
|
146
|
+
throw new Error('nope');
|
|
147
|
+
},
|
|
148
|
+
{ maxAttempts: 1 },
|
|
149
|
+
),
|
|
150
|
+
).rejects.toThrow('nope');
|
|
151
|
+
expect(calls).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// withRetry — retryableErrors filter
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
describe('withRetry — retryableErrors', () => {
|
|
160
|
+
it('retries when error matches a RegExp', async () => {
|
|
161
|
+
vi.useFakeTimers();
|
|
162
|
+
let calls = 0;
|
|
163
|
+
|
|
164
|
+
const promise = withRetry(
|
|
165
|
+
async () => {
|
|
166
|
+
calls++;
|
|
167
|
+
if (calls < 3) throw new Error('ECONNREFUSED connection failed');
|
|
168
|
+
return 'connected';
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
maxAttempts: 3,
|
|
172
|
+
baseDelayMs: 10,
|
|
173
|
+
maxDelayMs: 100,
|
|
174
|
+
retryableErrors: [/ECONNREFUSED/],
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await vi.runAllTimersAsync();
|
|
179
|
+
const result = await promise;
|
|
180
|
+
|
|
181
|
+
expect(result).toBe('connected');
|
|
182
|
+
expect(calls).toBe(3);
|
|
183
|
+
|
|
184
|
+
vi.useRealTimers();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does not retry when error does NOT match any pattern', async () => {
|
|
188
|
+
let calls = 0;
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
withRetry(
|
|
192
|
+
async () => {
|
|
193
|
+
calls++;
|
|
194
|
+
throw new Error('Permission denied');
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
maxAttempts: 3,
|
|
198
|
+
baseDelayMs: 10,
|
|
199
|
+
retryableErrors: [/ECONNREFUSED/],
|
|
200
|
+
},
|
|
201
|
+
),
|
|
202
|
+
).rejects.toThrow('Permission denied');
|
|
203
|
+
|
|
204
|
+
expect(calls).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('retries when error matches a predicate function', async () => {
|
|
208
|
+
vi.useFakeTimers();
|
|
209
|
+
let calls = 0;
|
|
210
|
+
|
|
211
|
+
const isRateLimit = (err: unknown) =>
|
|
212
|
+
err instanceof Error && err.message.includes('rate limit');
|
|
213
|
+
|
|
214
|
+
const promise = withRetry(
|
|
215
|
+
async () => {
|
|
216
|
+
calls++;
|
|
217
|
+
if (calls < 2) throw new Error('rate limit exceeded');
|
|
218
|
+
return 'done';
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
maxAttempts: 3,
|
|
222
|
+
baseDelayMs: 10,
|
|
223
|
+
maxDelayMs: 100,
|
|
224
|
+
retryableErrors: [isRateLimit],
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await vi.runAllTimersAsync();
|
|
229
|
+
const result = await promise;
|
|
230
|
+
|
|
231
|
+
expect(result).toBe('done');
|
|
232
|
+
expect(calls).toBe(2);
|
|
233
|
+
|
|
234
|
+
vi.useRealTimers();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('retries when any predicate in the list matches', async () => {
|
|
238
|
+
vi.useFakeTimers();
|
|
239
|
+
let calls = 0;
|
|
240
|
+
|
|
241
|
+
const promise = withRetry(
|
|
242
|
+
async () => {
|
|
243
|
+
calls++;
|
|
244
|
+
if (calls < 2) throw new Error('503 Service Unavailable');
|
|
245
|
+
return 'up';
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
maxAttempts: 3,
|
|
249
|
+
baseDelayMs: 10,
|
|
250
|
+
maxDelayMs: 100,
|
|
251
|
+
retryableErrors: [/ECONNREFUSED/, /503/],
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
await vi.runAllTimersAsync();
|
|
256
|
+
const result = await promise;
|
|
257
|
+
|
|
258
|
+
expect(result).toBe('up');
|
|
259
|
+
expect(calls).toBe(2);
|
|
260
|
+
|
|
261
|
+
vi.useRealTimers();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('treats all errors as retryable when retryableErrors is omitted', async () => {
|
|
265
|
+
vi.useFakeTimers();
|
|
266
|
+
let calls = 0;
|
|
267
|
+
|
|
268
|
+
const promise = withRetry(
|
|
269
|
+
async () => {
|
|
270
|
+
calls++;
|
|
271
|
+
if (calls < 2) throw new Error('whatever obscure error');
|
|
272
|
+
return 'ok';
|
|
273
|
+
},
|
|
274
|
+
{ maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 100 },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
await vi.runAllTimersAsync();
|
|
278
|
+
const result = await promise;
|
|
279
|
+
|
|
280
|
+
expect(result).toBe('ok');
|
|
281
|
+
expect(calls).toBe(2);
|
|
282
|
+
|
|
283
|
+
vi.useRealTimers();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ============================================================================
|
|
288
|
+
// withRetry — default schedule (0 ms / 2000 ms / 4000 ms)
|
|
289
|
+
// ============================================================================
|
|
290
|
+
|
|
291
|
+
describe('withRetry — default schedule', () => {
|
|
292
|
+
it('uses the task-spec defaults: 3 attempts, 2000/4000 ms delays', async () => {
|
|
293
|
+
// Verify delay schedule using computeDelay directly — no timer mocking needed.
|
|
294
|
+
// Attempt 1 fails → wait computeDelay(1, 2000, 30000) = 2000 ms
|
|
295
|
+
expect(computeDelay(1, 2_000, 30_000)).toBe(2_000);
|
|
296
|
+
// Attempt 2 fails → wait computeDelay(2, 2000, 30000) = 4000 ms
|
|
297
|
+
expect(computeDelay(2, 2_000, 30_000)).toBe(4_000);
|
|
298
|
+
|
|
299
|
+
// Verify that withRetry makes exactly 3 attempts with default options.
|
|
300
|
+
vi.useFakeTimers();
|
|
301
|
+
let calls = 0;
|
|
302
|
+
|
|
303
|
+
// Attach rejection handler before timer advancement.
|
|
304
|
+
const promise = withRetry(async () => {
|
|
305
|
+
calls++;
|
|
306
|
+
throw new Error('fail');
|
|
307
|
+
});
|
|
308
|
+
const settled = promise.then(
|
|
309
|
+
(v) => ({ ok: true as const, value: v }),
|
|
310
|
+
(e: unknown) => ({ ok: false as const, error: e }),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
await vi.runAllTimersAsync();
|
|
314
|
+
const result = await settled;
|
|
315
|
+
|
|
316
|
+
expect(result.ok).toBe(false);
|
|
317
|
+
expect(calls).toBe(3);
|
|
318
|
+
|
|
319
|
+
vi.useRealTimers();
|
|
320
|
+
});
|
|
321
|
+
});
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility primitives for @cleocode/core.
|
|
3
|
+
*
|
|
4
|
+
* These modules are dependency-free and safe to import from any layer
|
|
5
|
+
* without risking circular dependencies or DB coupling.
|
|
6
|
+
*
|
|
7
|
+
* @module lib
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
computeDelay,
|
|
12
|
+
type RetryablePredicate,
|
|
13
|
+
type RetryContext,
|
|
14
|
+
type RetryOptions,
|
|
15
|
+
withRetry,
|
|
16
|
+
} from './retry.js';
|
package/src/lib/retry.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* General-purpose retry utility with exponential backoff.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a shared, dependency-free retry primitive for use
|
|
5
|
+
* anywhere in the CLEO core. Unlike the agent-specific retry in
|
|
6
|
+
* `agents/retry.ts`, this utility has no database coupling and is safe to
|
|
7
|
+
* import from any layer.
|
|
8
|
+
*
|
|
9
|
+
* Default schedule (3 attempts, task T040 spec):
|
|
10
|
+
* - Attempt 1: immediate (0 ms delay before retry)
|
|
11
|
+
* - Attempt 2: 2 000 ms delay before retry
|
|
12
|
+
* - Attempt 3: 4 000 ms delay before retry
|
|
13
|
+
* - After attempt 3: throw last error
|
|
14
|
+
*
|
|
15
|
+
* @module lib/retry
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A predicate or pattern used to decide whether an error is retryable.
|
|
24
|
+
*
|
|
25
|
+
* - `RegExp` — matched against `error.message` (or `String(error)`)
|
|
26
|
+
* - `(error: unknown) => boolean` — arbitrary predicate function
|
|
27
|
+
*/
|
|
28
|
+
export type RetryablePredicate = RegExp | ((error: unknown) => boolean);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Options that control retry behavior for {@link withRetry}.
|
|
32
|
+
*/
|
|
33
|
+
export interface RetryOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Maximum total number of attempts (initial + retries).
|
|
36
|
+
*
|
|
37
|
+
* @default 3
|
|
38
|
+
*/
|
|
39
|
+
maxAttempts?: number;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Delay before the second attempt in milliseconds.
|
|
43
|
+
* Each subsequent delay is `baseDelayMs * 2^(attempt - 1)`.
|
|
44
|
+
*
|
|
45
|
+
* @default 2000
|
|
46
|
+
*/
|
|
47
|
+
baseDelayMs?: number;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Upper bound on computed delay in milliseconds.
|
|
51
|
+
* Prevents unbounded growth with many retries.
|
|
52
|
+
*
|
|
53
|
+
* @default 30000
|
|
54
|
+
*/
|
|
55
|
+
maxDelayMs?: number;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Explicit list of patterns or predicates that identify retryable errors.
|
|
59
|
+
*
|
|
60
|
+
* When provided, ONLY errors matching at least one entry are retried.
|
|
61
|
+
* Errors that match none of the entries cause immediate failure.
|
|
62
|
+
*
|
|
63
|
+
* When omitted, all errors are treated as retryable (up to `maxAttempts`).
|
|
64
|
+
*/
|
|
65
|
+
retryableErrors?: ReadonlyArray<RetryablePredicate>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Metadata attached to errors thrown after all retry attempts are exhausted.
|
|
70
|
+
*
|
|
71
|
+
* The last error from the final attempt is augmented with these fields so
|
|
72
|
+
* callers can distinguish a retry-exhausted failure from a first-attempt one.
|
|
73
|
+
*/
|
|
74
|
+
export interface RetryContext {
|
|
75
|
+
/** Total number of attempts made (always equal to `maxAttempts`). */
|
|
76
|
+
attempts: number;
|
|
77
|
+
/** Cumulative delay applied across all retry waits in milliseconds. */
|
|
78
|
+
totalDelayMs: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// Public API
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Execute an async function with automatic retry and exponential backoff.
|
|
87
|
+
*
|
|
88
|
+
* @remarks
|
|
89
|
+
* The function is called up to `maxAttempts` times. After the first failure,
|
|
90
|
+
* the utility waits `baseDelayMs` milliseconds, then retries. Each subsequent
|
|
91
|
+
* wait doubles: `baseDelayMs * 2^(attempt - 1)`, capped at `maxDelayMs`.
|
|
92
|
+
*
|
|
93
|
+
* If `retryableErrors` is supplied, only errors matching at least one entry
|
|
94
|
+
* are retried; other errors cause immediate re-throw.
|
|
95
|
+
*
|
|
96
|
+
* On final failure the original error is re-thrown. Use {@link RetryContext}
|
|
97
|
+
* fields (attached to the error) to inspect retry metadata.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* // Basic usage — 3 attempts with 0 ms / 2 000 ms / 4 000 ms delays
|
|
102
|
+
* const data = await withRetry(() => fetchFromApi());
|
|
103
|
+
*
|
|
104
|
+
* // Custom retry window — only on network errors
|
|
105
|
+
* const result = await withRetry(
|
|
106
|
+
* () => db.query(sql),
|
|
107
|
+
* {
|
|
108
|
+
* maxAttempts: 5,
|
|
109
|
+
* baseDelayMs: 500,
|
|
110
|
+
* retryableErrors: [/SQLITE_BUSY/, /database is locked/i],
|
|
111
|
+
* },
|
|
112
|
+
* );
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* @typeParam T - The resolved type of the async function
|
|
116
|
+
* @param fn - Async factory that is called on each attempt.
|
|
117
|
+
* @param options - Optional retry configuration.
|
|
118
|
+
* @returns Resolved value of `fn` on success.
|
|
119
|
+
* @throws The last error thrown by `fn`, augmented with {@link RetryContext}
|
|
120
|
+
* fields (`attempts`, `totalDelayMs`).
|
|
121
|
+
*/
|
|
122
|
+
export async function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T> {
|
|
123
|
+
const maxAttempts = options?.maxAttempts ?? 3;
|
|
124
|
+
const baseDelayMs = options?.baseDelayMs ?? 2_000;
|
|
125
|
+
const maxDelayMs = options?.maxDelayMs ?? 30_000;
|
|
126
|
+
const retryableErrors = options?.retryableErrors;
|
|
127
|
+
|
|
128
|
+
let lastError: unknown;
|
|
129
|
+
let totalDelayMs = 0;
|
|
130
|
+
|
|
131
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
return await fn();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
lastError = err;
|
|
136
|
+
|
|
137
|
+
const isLastAttempt = attempt === maxAttempts;
|
|
138
|
+
if (isLastAttempt) break;
|
|
139
|
+
|
|
140
|
+
// If a filter list is provided, only retry matching errors.
|
|
141
|
+
if (retryableErrors !== undefined && !isRetryable(err, retryableErrors)) break;
|
|
142
|
+
|
|
143
|
+
const delay = computeDelay(attempt, baseDelayMs, maxDelayMs);
|
|
144
|
+
totalDelayMs += delay;
|
|
145
|
+
await sleep(delay);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Augment last error with retry context before re-throwing.
|
|
150
|
+
const context: RetryContext = { attempts: maxAttempts, totalDelayMs };
|
|
151
|
+
augmentError(lastError, context);
|
|
152
|
+
throw lastError;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// Delay helpers (exported for unit testing)
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compute the wait time before the next attempt.
|
|
161
|
+
*
|
|
162
|
+
* @remarks
|
|
163
|
+
* Formula: `min(baseDelayMs * 2^(attempt - 1), maxDelayMs)`.
|
|
164
|
+
* On the first retry (`attempt === 1`) the delay is `baseDelayMs * 1 = baseDelayMs`.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* computeDelay(1, 2000, 30000); // 2000
|
|
169
|
+
* computeDelay(2, 2000, 30000); // 4000
|
|
170
|
+
* computeDelay(3, 2000, 30000); // 8000
|
|
171
|
+
* ```
|
|
172
|
+
*
|
|
173
|
+
* @param attempt - The 1-based attempt number that just failed.
|
|
174
|
+
* @param baseDelayMs - Base delay in milliseconds.
|
|
175
|
+
* @param maxDelayMs - Maximum allowed delay in milliseconds.
|
|
176
|
+
* @returns Delay in milliseconds before the next attempt.
|
|
177
|
+
*/
|
|
178
|
+
export function computeDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
|
|
179
|
+
const exponential = baseDelayMs * 2 ** (attempt - 1);
|
|
180
|
+
return Math.min(exponential, maxDelayMs);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// Internal helpers
|
|
185
|
+
// ============================================================================
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Test whether an error matches at least one retryable predicate.
|
|
189
|
+
*
|
|
190
|
+
* @param err - The caught error.
|
|
191
|
+
* @param predicates - List of `RegExp` or predicate functions.
|
|
192
|
+
* @returns `true` if the error is retryable.
|
|
193
|
+
*/
|
|
194
|
+
function isRetryable(err: unknown, predicates: ReadonlyArray<RetryablePredicate>): boolean {
|
|
195
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
196
|
+
return predicates.some((predicate) => {
|
|
197
|
+
if (predicate instanceof RegExp) return predicate.test(message);
|
|
198
|
+
return predicate(err);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Attach `RetryContext` fields to an error value in-place when possible.
|
|
204
|
+
* Falls back gracefully for non-Error thrown values.
|
|
205
|
+
*
|
|
206
|
+
* @param err - The value to augment.
|
|
207
|
+
* @param context - Retry metadata to attach.
|
|
208
|
+
*/
|
|
209
|
+
function augmentError(err: unknown, context: RetryContext): void {
|
|
210
|
+
if (err instanceof Error) {
|
|
211
|
+
const mutableErr = err as Error & Partial<RetryContext>;
|
|
212
|
+
mutableErr.attempts = context.attempts;
|
|
213
|
+
mutableErr.totalDelayMs = context.totalDelayMs;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Promisified `setTimeout` for testability.
|
|
219
|
+
*
|
|
220
|
+
* @param ms - Duration in milliseconds.
|
|
221
|
+
*/
|
|
222
|
+
function sleep(ms: number): Promise<void> {
|
|
223
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
224
|
+
}
|