@cleocode/core 2026.3.57 → 2026.3.59

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.
Files changed (136) hide show
  1. package/dist/agents/agent-registry.d.ts +206 -0
  2. package/dist/agents/agent-registry.d.ts.map +1 -0
  3. package/dist/agents/agent-schema.d.ts.map +1 -1
  4. package/dist/agents/execution-learning.d.ts +223 -0
  5. package/dist/agents/execution-learning.d.ts.map +1 -0
  6. package/dist/agents/health-monitor.d.ts +161 -0
  7. package/dist/agents/health-monitor.d.ts.map +1 -0
  8. package/dist/agents/index.d.ts +4 -1
  9. package/dist/agents/index.d.ts.map +1 -1
  10. package/dist/agents/retry.d.ts +57 -4
  11. package/dist/agents/retry.d.ts.map +1 -1
  12. package/dist/backfill/index.d.ts +83 -0
  13. package/dist/backfill/index.d.ts.map +1 -0
  14. package/dist/bootstrap.d.ts +1 -1
  15. package/dist/config.d.ts +47 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +6985 -5068
  20. package/dist/index.js.map +4 -4
  21. package/dist/intelligence/adaptive-validation.d.ts +151 -0
  22. package/dist/intelligence/adaptive-validation.d.ts.map +1 -0
  23. package/dist/intelligence/impact.d.ts +34 -1
  24. package/dist/intelligence/impact.d.ts.map +1 -1
  25. package/dist/intelligence/index.d.ts +7 -2
  26. package/dist/intelligence/index.d.ts.map +1 -1
  27. package/dist/intelligence/types.d.ts +60 -0
  28. package/dist/intelligence/types.d.ts.map +1 -1
  29. package/dist/internal.d.ts +8 -4
  30. package/dist/internal.d.ts.map +1 -1
  31. package/dist/lib/index.d.ts +10 -0
  32. package/dist/lib/index.d.ts.map +1 -0
  33. package/dist/lib/retry.d.ts +128 -0
  34. package/dist/lib/retry.d.ts.map +1 -0
  35. package/dist/nexus/sharing/index.d.ts +48 -2
  36. package/dist/nexus/sharing/index.d.ts.map +1 -1
  37. package/dist/sessions/session-enforcement.d.ts.map +1 -1
  38. package/dist/stats/index.d.ts +1 -0
  39. package/dist/stats/index.d.ts.map +1 -1
  40. package/dist/stats/workflow-telemetry.d.ts +89 -0
  41. package/dist/stats/workflow-telemetry.d.ts.map +1 -0
  42. package/dist/store/brain-schema.d.ts.map +1 -1
  43. package/dist/store/converters.d.ts.map +1 -1
  44. package/dist/store/cross-db-cleanup.d.ts +93 -0
  45. package/dist/store/cross-db-cleanup.d.ts.map +1 -0
  46. package/dist/store/db-helpers.d.ts.map +1 -1
  47. package/dist/store/migration-sqlite.d.ts.map +1 -1
  48. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  49. package/dist/store/sqlite.d.ts.map +1 -1
  50. package/dist/store/task-store.d.ts.map +1 -1
  51. package/dist/store/tasks-schema.d.ts +18 -3
  52. package/dist/store/tasks-schema.d.ts.map +1 -1
  53. package/dist/store/validation-schemas.d.ts +32 -0
  54. package/dist/store/validation-schemas.d.ts.map +1 -1
  55. package/dist/tasks/add.d.ts +10 -1
  56. package/dist/tasks/add.d.ts.map +1 -1
  57. package/dist/tasks/complete.d.ts.map +1 -1
  58. package/dist/tasks/enforcement.d.ts +22 -0
  59. package/dist/tasks/enforcement.d.ts.map +1 -0
  60. package/dist/tasks/epic-enforcement.d.ts +199 -0
  61. package/dist/tasks/epic-enforcement.d.ts.map +1 -0
  62. package/dist/tasks/index.d.ts +1 -1
  63. package/dist/tasks/index.d.ts.map +1 -1
  64. package/dist/tasks/pipeline-stage.d.ts +181 -0
  65. package/dist/tasks/pipeline-stage.d.ts.map +1 -0
  66. package/dist/tasks/update.d.ts +2 -0
  67. package/dist/tasks/update.d.ts.map +1 -1
  68. package/migrations/drizzle-brain/20260321000001_t033-brain-indexes/migration.sql +12 -0
  69. package/migrations/drizzle-brain/20260321000001_t033-brain-indexes/snapshot.json +1232 -0
  70. package/migrations/drizzle-tasks/20260321000000_t033-connection-health/migration.sql +518 -0
  71. package/migrations/drizzle-tasks/20260321000000_t033-connection-health/snapshot.json +4312 -0
  72. package/migrations/drizzle-tasks/20260321000002_t060-pipeline-stage-binding/migration.sql +82 -0
  73. package/migrations/drizzle-tasks/20260321000002_t060-pipeline-stage-binding/snapshot.json +9 -0
  74. package/package.json +5 -5
  75. package/schemas/config.schema.json +37 -1547
  76. package/src/__tests__/sharing.test.ts +24 -0
  77. package/src/agents/__tests__/agent-registry.test.ts +351 -0
  78. package/src/agents/__tests__/execution-learning.test.ts +684 -0
  79. package/src/agents/__tests__/health-monitor.test.ts +332 -0
  80. package/src/agents/__tests__/registry.test.ts +30 -2
  81. package/src/agents/agent-registry.ts +394 -0
  82. package/src/agents/agent-schema.ts +5 -0
  83. package/src/agents/execution-learning.ts +675 -0
  84. package/src/agents/health-monitor.ts +279 -0
  85. package/src/agents/index.ts +37 -1
  86. package/src/agents/retry.ts +57 -4
  87. package/src/backfill/index.ts +309 -0
  88. package/src/bootstrap.ts +1 -1
  89. package/src/config.ts +126 -0
  90. package/src/index.ts +8 -1
  91. package/src/intelligence/__tests__/adaptive-validation.test.ts +694 -0
  92. package/src/intelligence/__tests__/impact.test.ts +165 -1
  93. package/src/intelligence/adaptive-validation.ts +764 -0
  94. package/src/intelligence/impact.ts +203 -0
  95. package/src/intelligence/index.ts +19 -0
  96. package/src/intelligence/types.ts +76 -0
  97. package/src/internal.ts +39 -0
  98. package/src/lib/__tests__/retry.test.ts +321 -0
  99. package/src/lib/index.ts +16 -0
  100. package/src/lib/retry.ts +224 -0
  101. package/src/lifecycle/__tests__/chain-store.test.ts +7 -0
  102. package/src/lifecycle/__tests__/tessera-engine.test.ts +52 -0
  103. package/src/nexus/sharing/index.ts +142 -2
  104. package/src/sessions/__tests__/session-edge-cases.test.ts +24 -1
  105. package/src/sessions/session-enforcement.ts +13 -2
  106. package/src/stats/index.ts +7 -0
  107. package/src/stats/workflow-telemetry.ts +502 -0
  108. package/src/store/__tests__/migration-safety.test.ts +3 -0
  109. package/src/store/__tests__/session-store.test.ts +132 -1
  110. package/src/store/__tests__/task-store.test.ts +22 -1
  111. package/src/store/__tests__/test-db-helper.ts +29 -2
  112. package/src/store/brain-schema.ts +4 -1
  113. package/src/store/converters.ts +2 -0
  114. package/src/store/cross-db-cleanup.ts +192 -0
  115. package/src/store/db-helpers.ts +2 -0
  116. package/src/store/migration-sqlite.ts +6 -0
  117. package/src/store/sqlite-data-accessor.ts +20 -28
  118. package/src/store/sqlite.ts +14 -2
  119. package/src/store/task-store.ts +6 -0
  120. package/src/store/tasks-schema.ts +59 -20
  121. package/src/tasks/__tests__/add.test.ts +16 -0
  122. package/src/tasks/__tests__/complete-unblocks.test.ts +10 -1
  123. package/src/tasks/__tests__/complete.test.ts +11 -2
  124. package/src/tasks/__tests__/epic-enforcement.test.ts +909 -0
  125. package/src/tasks/__tests__/minimal-test.test.ts +28 -0
  126. package/src/tasks/__tests__/pipeline-stage.test.ts +403 -0
  127. package/src/tasks/__tests__/update.test.ts +40 -6
  128. package/src/tasks/add.ts +128 -2
  129. package/src/tasks/complete.ts +29 -17
  130. package/src/tasks/enforcement.ts +127 -0
  131. package/src/tasks/epic-enforcement.ts +364 -0
  132. package/src/tasks/index.ts +1 -0
  133. package/src/tasks/pipeline-stage.ts +293 -0
  134. package/src/tasks/update.ts +62 -0
  135. package/templates/config.template.json +34 -111
  136. package/templates/global-config.template.json +24 -40
@@ -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
+ });
@@ -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';
@@ -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
+ }
@@ -191,9 +191,13 @@ describe('WarpChain chain-store', () => {
191
191
 
192
192
  it('enforces DB foreign key for chain instances', async () => {
193
193
  const { getDb } = await import('../../store/sqlite.js');
194
+ const { getNativeTasksDb } = await import('../../store/sqlite.js');
194
195
  const { warpChainInstances } = await import('../../store/chain-schema.js');
195
196
 
196
197
  const db = await getDb(tempDir);
198
+ // Enable FKs for this specific test — it validates FK enforcement
199
+ const nativeDb = getNativeTasksDb();
200
+ nativeDb?.exec('PRAGMA foreign_keys=ON');
197
201
 
198
202
  await expect(
199
203
  db.insert(warpChainInstances).values({
@@ -206,6 +210,9 @@ describe('WarpChain chain-store', () => {
206
210
 
207
211
  const instances = await db.select().from(warpChainInstances);
208
212
  expect(instances).toHaveLength(0);
213
+
214
+ // Restore FKs OFF for remaining tests
215
+ nativeDb?.exec('PRAGMA foreign_keys=OFF');
209
216
  });
210
217
 
211
218
  it('advanceInstance updates currentStage and records gate results', async () => {
@@ -111,6 +111,19 @@ describe('Tessera engine', () => {
111
111
 
112
112
  it('instantiate default RCASD template creates valid instance', async () => {
113
113
  const { buildDefaultTessera, instantiateTessera } = await import('../tessera-engine.js');
114
+ // Insert FK parent task: warp_chain_instances.epic_id -> tasks.id CASCADE.
115
+ const { getDb } = await import('../../store/sqlite.js');
116
+ const { tasks: tasksTable } = await import('../../store/tasks-schema.js');
117
+ const db = await getDb(tempDir);
118
+ db.insert(tasksTable)
119
+ .values({
120
+ id: 'T8888',
121
+ title: 'Epic task',
122
+ status: 'pending',
123
+ priority: 'medium',
124
+ createdAt: new Date().toISOString(),
125
+ })
126
+ .run();
114
127
 
115
128
  const template = buildDefaultTessera();
116
129
  const instance = await instantiateTessera(
@@ -146,6 +159,19 @@ describe('Tessera engine', () => {
146
159
 
147
160
  it('default values applied when optional variable not provided', async () => {
148
161
  const { buildDefaultTessera, instantiateTessera } = await import('../tessera-engine.js');
162
+ // Insert FK parent task: warp_chain_instances.epic_id -> tasks.id CASCADE.
163
+ const { getDb } = await import('../../store/sqlite.js');
164
+ const { tasks: tasksTable } = await import('../../store/tasks-schema.js');
165
+ const db = await getDb(tempDir);
166
+ db.insert(tasksTable)
167
+ .values({
168
+ id: 'T8888',
169
+ title: 'Epic task',
170
+ status: 'pending',
171
+ priority: 'medium',
172
+ createdAt: new Date().toISOString(),
173
+ })
174
+ .run();
149
175
 
150
176
  const template = buildDefaultTessera();
151
177
  const instance = await instantiateTessera(
@@ -219,6 +245,19 @@ describe('Tessera engine', () => {
219
245
 
220
246
  it('positive path still succeeds after invalid-type assertions', async () => {
221
247
  const { instantiateTessera } = await import('../tessera-engine.js');
248
+ // Insert FK parent task: warp_chain_instances.epic_id -> tasks.id CASCADE.
249
+ const { getDb } = await import('../../store/sqlite.js');
250
+ const { tasks: tasksTable } = await import('../../store/tasks-schema.js');
251
+ const db = await getDb(tempDir);
252
+ db.insert(tasksTable)
253
+ .values({
254
+ id: 'T8888',
255
+ title: 'Epic task',
256
+ status: 'pending',
257
+ priority: 'medium',
258
+ createdAt: new Date().toISOString(),
259
+ })
260
+ .run();
222
261
 
223
262
  const instance = await instantiateTessera(
224
263
  buildTypedTemplate(),
@@ -307,6 +346,19 @@ describe('Tessera engine', () => {
307
346
  it('performs deep substitution in nested chain structures', async () => {
308
347
  const { buildDefaultTessera, instantiateTessera } = await import('../tessera-engine.js');
309
348
  const { showChain } = await import('../chain-store.js');
349
+ // Insert FK parent task: warp_chain_instances.epic_id -> tasks.id CASCADE.
350
+ const { getDb } = await import('../../store/sqlite.js');
351
+ const { tasks: tasksTable } = await import('../../store/tasks-schema.js');
352
+ const db = await getDb(tempDir);
353
+ db.insert(tasksTable)
354
+ .values({
355
+ id: 'T8888',
356
+ title: 'Epic task',
357
+ status: 'pending',
358
+ priority: 'medium',
359
+ createdAt: new Date().toISOString(),
360
+ })
361
+ .run();
310
362
 
311
363
  const base = buildDefaultTessera();
312
364
  const template = {