@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,684 @@
1
+ /**
2
+ * Tests for the Agent Execution Learning module.
3
+ *
4
+ * Covers: execution event recording, performance history queries,
5
+ * failure pattern accumulation, healing strategy storage, and the
6
+ * compound processAgentLifecycleEvent function.
7
+ *
8
+ * Uses real SQLite (via brain.db) in a temp directory per test.
9
+ *
10
+ * @module agents/__tests__/execution-learning.test
11
+ * @task T034
12
+ */
13
+
14
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+
19
+ import { getBrainAccessor } from '../../store/brain-accessor.js';
20
+ import { resetBrainDbState } from '../../store/brain-sqlite.js';
21
+ import {
22
+ _getAgentPerformanceHistoryWithAccessor,
23
+ _getSelfHealingSuggestionsWithAccessor,
24
+ _recordAgentExecutionWithAccessor,
25
+ _recordFailurePatternWithAccessor,
26
+ _storeHealingStrategyWithAccessor,
27
+ type AgentExecutionEvent,
28
+ getAgentPerformanceHistory,
29
+ getSelfHealingSuggestions,
30
+ processAgentLifecycleEvent,
31
+ recordAgentExecution,
32
+ recordFailurePattern,
33
+ storeHealingStrategy,
34
+ } from '../execution-learning.js';
35
+
36
+ // ============================================================================
37
+ // Test setup
38
+ // ============================================================================
39
+
40
+ describe('Agent Execution Learning', () => {
41
+ let tempDir: string;
42
+
43
+ beforeEach(async () => {
44
+ tempDir = await mkdtemp(join(tmpdir(), 'cleo-exec-learn-test-'));
45
+ await mkdir(join(tempDir, '.cleo'), { recursive: true });
46
+ await mkdir(join(tempDir, '.cleo', 'backups', 'operational'), { recursive: true });
47
+ });
48
+
49
+ afterEach(async () => {
50
+ resetBrainDbState();
51
+ try {
52
+ const { closeAllDatabases } = await import('../../store/sqlite.js');
53
+ await closeAllDatabases();
54
+ } catch {
55
+ /* module may not be loaded */
56
+ }
57
+ await Promise.race([
58
+ rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 300 }).catch(() => {}),
59
+ new Promise<void>((resolve) => setTimeout(resolve, 8_000)),
60
+ ]);
61
+ });
62
+
63
+ // ==========================================================================
64
+ // recordAgentExecution
65
+ // ==========================================================================
66
+
67
+ describe('recordAgentExecution', () => {
68
+ it('stores a successful execution as a tactical brain decision', async () => {
69
+ const event: AgentExecutionEvent = {
70
+ agentId: 'agt_20260321_abc123',
71
+ agentType: 'executor',
72
+ taskId: 'T001',
73
+ taskType: 'task',
74
+ outcome: 'success',
75
+ };
76
+
77
+ const row = await recordAgentExecution(event, tempDir);
78
+
79
+ expect(row).not.toBeNull();
80
+ expect(row!.id).toMatch(/^AGT-/);
81
+ expect(row!.type).toBe('tactical');
82
+ expect(row!.outcome).toBe('success');
83
+ expect(row!.confidence).toBe('high');
84
+ expect(row!.contextTaskId).toBe('T001');
85
+ expect(row!.decision).toContain('executor');
86
+ expect(row!.decision).toContain('successfully completed');
87
+ });
88
+
89
+ it('stores a failure execution with low confidence and failure outcome', async () => {
90
+ const event: AgentExecutionEvent = {
91
+ agentId: 'agt_20260321_abc123',
92
+ agentType: 'researcher',
93
+ taskId: 'T002',
94
+ taskType: 'epic',
95
+ outcome: 'failure',
96
+ errorMessage: 'ECONNREFUSED',
97
+ errorType: 'retriable',
98
+ };
99
+
100
+ const row = await recordAgentExecution(event, tempDir);
101
+
102
+ expect(row).not.toBeNull();
103
+ expect(row!.outcome).toBe('failure');
104
+ expect(row!.confidence).toBe('low');
105
+ expect(row!.decision).toContain('failed');
106
+ expect(row!.decision).toContain('researcher');
107
+ });
108
+
109
+ it('stores a partial execution with medium confidence and mixed outcome', async () => {
110
+ const event: AgentExecutionEvent = {
111
+ agentId: 'agt_20260321_abc123',
112
+ agentType: 'validator',
113
+ taskId: 'T003',
114
+ taskType: 'subtask',
115
+ outcome: 'partial',
116
+ };
117
+
118
+ const row = await recordAgentExecution(event, tempDir);
119
+
120
+ expect(row).not.toBeNull();
121
+ expect(row!.outcome).toBe('mixed');
122
+ expect(row!.confidence).toBe('medium');
123
+ });
124
+
125
+ it('embeds structured metadata in alternativesJson', async () => {
126
+ const event: AgentExecutionEvent = {
127
+ agentId: 'agt_20260321_abc123',
128
+ agentType: 'orchestrator',
129
+ taskId: 'T010',
130
+ taskType: 'task',
131
+ taskLabels: ['schema', 'core'],
132
+ outcome: 'success',
133
+ sessionId: 'ses_test_abc',
134
+ durationMs: 4200,
135
+ };
136
+
137
+ const row = await recordAgentExecution(event, tempDir);
138
+ expect(row).not.toBeNull();
139
+
140
+ const meta = JSON.parse(row!.alternativesJson ?? '{}') as Record<string, unknown>;
141
+ expect(meta.agentType).toBe('orchestrator');
142
+ expect(meta.taskType).toBe('task');
143
+ expect(meta.taskLabels).toEqual(['schema', 'core']);
144
+ expect(meta.sessionId).toBe('ses_test_abc');
145
+ expect(meta.durationMs).toBe(4200);
146
+ });
147
+
148
+ it('returns null gracefully when brain.db is unavailable (bad path)', async () => {
149
+ // Use a path that will fail — brain.db accessor will throw
150
+ const event: AgentExecutionEvent = {
151
+ agentId: 'agt_test',
152
+ agentType: 'executor',
153
+ taskId: 'T001',
154
+ taskType: 'task',
155
+ outcome: 'success',
156
+ };
157
+
158
+ // Pass a non-existent cwd that will cause sqlite open to fail
159
+ const result = await recordAgentExecution(event, '/nonexistent/path/abc');
160
+ // Should return null without throwing
161
+ expect(result === null || result !== undefined).toBe(true);
162
+ });
163
+ });
164
+
165
+ // ==========================================================================
166
+ // getAgentPerformanceHistory
167
+ // ==========================================================================
168
+
169
+ describe('getAgentPerformanceHistory', () => {
170
+ it('returns empty array when no execution events exist', async () => {
171
+ const summaries = await getAgentPerformanceHistory({}, tempDir);
172
+ expect(summaries).toEqual([]);
173
+ });
174
+
175
+ it('aggregates successes and failures by (agentType, taskType)', async () => {
176
+ const brain = await getBrainAccessor(tempDir);
177
+
178
+ const events: AgentExecutionEvent[] = [
179
+ {
180
+ agentId: 'agt_1',
181
+ agentType: 'executor',
182
+ taskId: 'T001',
183
+ taskType: 'task',
184
+ outcome: 'success',
185
+ },
186
+ {
187
+ agentId: 'agt_1',
188
+ agentType: 'executor',
189
+ taskId: 'T002',
190
+ taskType: 'task',
191
+ outcome: 'success',
192
+ },
193
+ {
194
+ agentId: 'agt_1',
195
+ agentType: 'executor',
196
+ taskId: 'T003',
197
+ taskType: 'task',
198
+ outcome: 'failure',
199
+ errorType: 'retriable',
200
+ },
201
+ {
202
+ agentId: 'agt_2',
203
+ agentType: 'researcher',
204
+ taskId: 'T004',
205
+ taskType: 'epic',
206
+ outcome: 'success',
207
+ },
208
+ ];
209
+
210
+ for (const e of events) {
211
+ await _recordAgentExecutionWithAccessor(e, brain);
212
+ }
213
+
214
+ const summaries = await _getAgentPerformanceHistoryWithAccessor({}, brain);
215
+
216
+ expect(summaries.length).toBe(2); // executor/task + researcher/epic
217
+
218
+ const executorSummary = summaries.find(
219
+ (s) => s.agentType === 'executor' && s.taskType === 'task',
220
+ );
221
+ expect(executorSummary).toBeDefined();
222
+ expect(executorSummary!.totalAttempts).toBe(3);
223
+ expect(executorSummary!.successCount).toBe(2);
224
+ expect(executorSummary!.failureCount).toBe(1);
225
+ expect(executorSummary!.successRate).toBeCloseTo(0.667, 2);
226
+
227
+ const researcherSummary = summaries.find(
228
+ (s) => s.agentType === 'researcher' && s.taskType === 'epic',
229
+ );
230
+ expect(researcherSummary).toBeDefined();
231
+ expect(researcherSummary!.totalAttempts).toBe(1);
232
+ expect(researcherSummary!.successRate).toBe(1.0);
233
+ });
234
+
235
+ it('filters by agentType when specified', async () => {
236
+ const brain = await getBrainAccessor(tempDir);
237
+
238
+ await _recordAgentExecutionWithAccessor(
239
+ {
240
+ agentId: 'a1',
241
+ agentType: 'executor',
242
+ taskId: 'T1',
243
+ taskType: 'task',
244
+ outcome: 'success',
245
+ },
246
+ brain,
247
+ );
248
+ await _recordAgentExecutionWithAccessor(
249
+ {
250
+ agentId: 'a2',
251
+ agentType: 'researcher',
252
+ taskId: 'T2',
253
+ taskType: 'epic',
254
+ outcome: 'success',
255
+ },
256
+ brain,
257
+ );
258
+
259
+ const summaries = await _getAgentPerformanceHistoryWithAccessor(
260
+ { agentType: 'executor' },
261
+ brain,
262
+ );
263
+
264
+ expect(summaries.length).toBe(1);
265
+ expect(summaries[0]!.agentType).toBe('executor');
266
+ });
267
+
268
+ it('filters by taskType when specified', async () => {
269
+ const brain = await getBrainAccessor(tempDir);
270
+
271
+ await _recordAgentExecutionWithAccessor(
272
+ {
273
+ agentId: 'a1',
274
+ agentType: 'executor',
275
+ taskId: 'T1',
276
+ taskType: 'task',
277
+ outcome: 'success',
278
+ },
279
+ brain,
280
+ );
281
+ await _recordAgentExecutionWithAccessor(
282
+ {
283
+ agentId: 'a2',
284
+ agentType: 'executor',
285
+ taskId: 'T2',
286
+ taskType: 'epic',
287
+ outcome: 'failure',
288
+ errorType: 'permanent',
289
+ },
290
+ brain,
291
+ );
292
+
293
+ const summaries = await _getAgentPerformanceHistoryWithAccessor({ taskType: 'epic' }, brain);
294
+
295
+ expect(summaries.length).toBe(1);
296
+ expect(summaries[0]!.taskType).toBe('epic');
297
+ expect(summaries[0]!.failureCount).toBe(1);
298
+ });
299
+
300
+ it('tracks lastOutcome and lastSeenAt', async () => {
301
+ const brain = await getBrainAccessor(tempDir);
302
+
303
+ await _recordAgentExecutionWithAccessor(
304
+ {
305
+ agentId: 'a1',
306
+ agentType: 'executor',
307
+ taskId: 'T1',
308
+ taskType: 'task',
309
+ outcome: 'success',
310
+ },
311
+ brain,
312
+ );
313
+ await _recordAgentExecutionWithAccessor(
314
+ {
315
+ agentId: 'a1',
316
+ agentType: 'executor',
317
+ taskId: 'T2',
318
+ taskType: 'task',
319
+ outcome: 'failure',
320
+ errorType: 'retriable',
321
+ },
322
+ brain,
323
+ );
324
+
325
+ const summaries = await _getAgentPerformanceHistoryWithAccessor({}, brain);
326
+ const s = summaries.find((x) => x.agentType === 'executor');
327
+
328
+ expect(s).toBeDefined();
329
+ expect(s!.lastOutcome).toBe('failure'); // last recorded
330
+ expect(s!.lastSeenAt).not.toBeNull();
331
+ });
332
+ });
333
+
334
+ // ==========================================================================
335
+ // recordFailurePattern
336
+ // ==========================================================================
337
+
338
+ describe('recordFailurePattern', () => {
339
+ it('returns null for non-failure events', async () => {
340
+ const event: AgentExecutionEvent = {
341
+ agentId: 'agt_1',
342
+ agentType: 'executor',
343
+ taskId: 'T001',
344
+ taskType: 'task',
345
+ outcome: 'success',
346
+ };
347
+ const result = await recordFailurePattern(event, tempDir);
348
+ expect(result).toBeNull();
349
+ });
350
+
351
+ it('creates a new failure pattern on first occurrence', async () => {
352
+ const event: AgentExecutionEvent = {
353
+ agentId: 'agt_1',
354
+ agentType: 'executor',
355
+ taskId: 'T001',
356
+ taskType: 'task',
357
+ outcome: 'failure',
358
+ errorType: 'permanent',
359
+ errorMessage: 'Permission denied',
360
+ };
361
+
362
+ const pattern = await recordFailurePattern(event, tempDir);
363
+
364
+ expect(pattern).not.toBeNull();
365
+ expect(pattern!.id).toMatch(/^P-agt-/);
366
+ expect(pattern!.type).toBe('failure');
367
+ expect(pattern!.frequency).toBe(1);
368
+ expect(pattern!.pattern).toContain('executor');
369
+ expect(pattern!.pattern).toContain('task');
370
+ expect(pattern!.pattern).toContain('permanent');
371
+ expect(pattern!.mitigation).toContain('Reassign task');
372
+ });
373
+
374
+ it('increments frequency on repeated failures with same pattern', async () => {
375
+ const brain = await getBrainAccessor(tempDir);
376
+
377
+ const event: AgentExecutionEvent = {
378
+ agentId: 'agt_1',
379
+ agentType: 'researcher',
380
+ taskId: 'T001',
381
+ taskType: 'epic',
382
+ outcome: 'failure',
383
+ errorType: 'retriable',
384
+ };
385
+
386
+ const first = await _recordFailurePatternWithAccessor(event, brain);
387
+ expect(first!.frequency).toBe(1);
388
+
389
+ // Second occurrence — same agentType, taskType, errorType
390
+ const second = await _recordFailurePatternWithAccessor({ ...event, taskId: 'T002' }, brain);
391
+ expect(second!.frequency).toBe(2);
392
+
393
+ // Third occurrence
394
+ const third = await _recordFailurePatternWithAccessor({ ...event, taskId: 'T003' }, brain);
395
+ expect(third!.frequency).toBe(3);
396
+ });
397
+
398
+ it('builds retry suggestion for retriable errors', async () => {
399
+ const brain = await getBrainAccessor(tempDir);
400
+
401
+ const pattern = await _recordFailurePatternWithAccessor(
402
+ {
403
+ agentId: 'agt_1',
404
+ agentType: 'executor',
405
+ taskId: 'T001',
406
+ taskType: 'task',
407
+ outcome: 'failure',
408
+ errorType: 'retriable',
409
+ },
410
+ brain,
411
+ );
412
+
413
+ expect(pattern!.mitigation).toContain('Retry with exponential backoff');
414
+ });
415
+
416
+ it('builds reassign suggestion for permanent errors', async () => {
417
+ const brain = await getBrainAccessor(tempDir);
418
+
419
+ const pattern = await _recordFailurePatternWithAccessor(
420
+ {
421
+ agentId: 'agt_1',
422
+ agentType: 'executor',
423
+ taskId: 'T001',
424
+ taskType: 'task',
425
+ outcome: 'failure',
426
+ errorType: 'permanent',
427
+ },
428
+ brain,
429
+ );
430
+
431
+ expect(pattern!.mitigation).toContain('Reassign task to a different agent type');
432
+ });
433
+ });
434
+
435
+ // ==========================================================================
436
+ // storeHealingStrategy
437
+ // ==========================================================================
438
+
439
+ describe('storeHealingStrategy', () => {
440
+ it('returns null for non-failure events', async () => {
441
+ const event: AgentExecutionEvent = {
442
+ agentId: 'agt_1',
443
+ agentType: 'executor',
444
+ taskId: 'T001',
445
+ taskType: 'task',
446
+ outcome: 'success',
447
+ };
448
+ const result = await storeHealingStrategy(event, 'some strategy', tempDir);
449
+ expect(result).toBeNull();
450
+ });
451
+
452
+ it('stores a change observation with healing narrative', async () => {
453
+ const brain = await getBrainAccessor(tempDir);
454
+
455
+ const event: AgentExecutionEvent = {
456
+ agentId: 'agt_1',
457
+ agentType: 'executor',
458
+ taskId: 'T001',
459
+ taskType: 'task',
460
+ outcome: 'failure',
461
+ errorType: 'permanent',
462
+ sessionId: 'ses_test_abc',
463
+ };
464
+
465
+ const obs = await _storeHealingStrategyWithAccessor(
466
+ event,
467
+ 'Switch to validator agent',
468
+ brain,
469
+ );
470
+
471
+ expect(obs).not.toBeNull();
472
+ expect(obs!.id).toMatch(/^O-agt-/);
473
+ expect(obs!.type).toBe('change');
474
+ expect(obs!.sourceType).toBe('agent');
475
+ expect(obs!.narrative).toBe('Switch to validator agent');
476
+ expect(obs!.title).toContain('executor');
477
+ expect(obs!.title).toContain('task');
478
+ expect(obs!.sourceSessionId).toBe('ses_test_abc');
479
+
480
+ const facts = JSON.parse(obs!.factsJson ?? '[]') as string[];
481
+ expect(facts.some((f) => f.includes('executor'))).toBe(true);
482
+ expect(facts.some((f) => f.includes('Switch to validator agent'))).toBe(true);
483
+
484
+ const concepts = JSON.parse(obs!.conceptsJson ?? '[]') as string[];
485
+ expect(concepts).toContain('self-healing');
486
+ expect(concepts).toContain('executor');
487
+ });
488
+ });
489
+
490
+ // ==========================================================================
491
+ // getSelfHealingSuggestions
492
+ // ==========================================================================
493
+
494
+ describe('getSelfHealingSuggestions', () => {
495
+ it('returns empty array when no failure patterns exist', async () => {
496
+ const suggestions = await getSelfHealingSuggestions('executor', 'task', tempDir);
497
+ expect(suggestions).toEqual([]);
498
+ });
499
+
500
+ it('returns suggestions matching agentType and taskType', async () => {
501
+ const brain = await getBrainAccessor(tempDir);
502
+
503
+ // Record three failures to build up a pattern
504
+ const event: AgentExecutionEvent = {
505
+ agentId: 'agt_1',
506
+ agentType: 'researcher',
507
+ taskId: 'T001',
508
+ taskType: 'epic',
509
+ outcome: 'failure',
510
+ errorType: 'retriable',
511
+ };
512
+
513
+ for (let i = 0; i < 3; i++) {
514
+ await _recordFailurePatternWithAccessor({ ...event, taskId: `T00${i}` }, brain);
515
+ }
516
+
517
+ const suggestions = await _getSelfHealingSuggestionsWithAccessor('researcher', 'epic', brain);
518
+
519
+ expect(suggestions.length).toBeGreaterThanOrEqual(1);
520
+ expect(suggestions[0]!.frequency).toBe(3);
521
+ expect(suggestions[0]!.failurePattern).toContain('researcher');
522
+ expect(suggestions[0]!.failurePattern).toContain('epic');
523
+ expect(suggestions[0]!.suggestion).toBeTruthy();
524
+ expect(suggestions[0]!.confidence).toBeGreaterThan(0);
525
+ expect(suggestions[0]!.confidence).toBeLessThanOrEqual(0.9);
526
+ });
527
+
528
+ it('does not return suggestions for a different agent type', async () => {
529
+ const brain = await getBrainAccessor(tempDir);
530
+
531
+ await _recordFailurePatternWithAccessor(
532
+ {
533
+ agentId: 'agt_1',
534
+ agentType: 'executor',
535
+ taskId: 'T001',
536
+ taskType: 'task',
537
+ outcome: 'failure',
538
+ errorType: 'permanent',
539
+ },
540
+ brain,
541
+ );
542
+
543
+ // Query for a different agent type
544
+ const suggestions = await _getSelfHealingSuggestionsWithAccessor('researcher', 'task', brain);
545
+ expect(suggestions).toEqual([]);
546
+ });
547
+
548
+ it('orders suggestions by frequency descending', async () => {
549
+ const brain = await getBrainAccessor(tempDir);
550
+
551
+ // Build retriable pattern with high frequency
552
+ for (let i = 0; i < 5; i++) {
553
+ await _recordFailurePatternWithAccessor(
554
+ {
555
+ agentId: 'agt_1',
556
+ agentType: 'validator',
557
+ taskId: `T${i}`,
558
+ taskType: 'task',
559
+ outcome: 'failure',
560
+ errorType: 'retriable',
561
+ },
562
+ brain,
563
+ );
564
+ }
565
+
566
+ // Build permanent pattern with lower frequency
567
+ await _recordFailurePatternWithAccessor(
568
+ {
569
+ agentId: 'agt_1',
570
+ agentType: 'validator',
571
+ taskId: 'T99',
572
+ taskType: 'task',
573
+ outcome: 'failure',
574
+ errorType: 'permanent',
575
+ },
576
+ brain,
577
+ );
578
+
579
+ const suggestions = await _getSelfHealingSuggestionsWithAccessor('validator', 'task', brain);
580
+
581
+ expect(suggestions.length).toBe(2);
582
+ // Higher frequency should come first
583
+ expect(suggestions[0]!.frequency).toBeGreaterThan(suggestions[1]!.frequency);
584
+ });
585
+ });
586
+
587
+ // ==========================================================================
588
+ // processAgentLifecycleEvent
589
+ // ==========================================================================
590
+
591
+ describe('processAgentLifecycleEvent', () => {
592
+ it('records decision and returns decisionId for success events', async () => {
593
+ const event: AgentExecutionEvent = {
594
+ agentId: 'agt_1',
595
+ agentType: 'executor',
596
+ taskId: 'T001',
597
+ taskType: 'task',
598
+ outcome: 'success',
599
+ };
600
+
601
+ const result = await processAgentLifecycleEvent(event, tempDir);
602
+
603
+ expect(result.decisionId).toMatch(/^AGT-/);
604
+ expect(result.patternId).toBeNull(); // no failure pattern for success
605
+ expect(result.observationId).toBeNull();
606
+ expect(result.healingSuggestions).toEqual([]);
607
+ });
608
+
609
+ it('records decision + pattern for failure events', async () => {
610
+ const event: AgentExecutionEvent = {
611
+ agentId: 'agt_1',
612
+ agentType: 'researcher',
613
+ taskId: 'T002',
614
+ taskType: 'epic',
615
+ outcome: 'failure',
616
+ errorType: 'permanent',
617
+ };
618
+
619
+ const result = await processAgentLifecycleEvent(event, tempDir);
620
+
621
+ expect(result.decisionId).toMatch(/^AGT-/);
622
+ expect(result.patternId).toMatch(/^P-agt-/);
623
+ expect(result.observationId).toBeNull(); // frequency = 1, threshold not met
624
+ });
625
+
626
+ it('records healing observation once pattern frequency reaches 3', async () => {
627
+ const event: AgentExecutionEvent = {
628
+ agentId: 'agt_1',
629
+ agentType: 'executor',
630
+ taskId: 'T001',
631
+ taskType: 'subtask',
632
+ outcome: 'failure',
633
+ errorType: 'retriable',
634
+ };
635
+
636
+ // First two calls — observation not stored yet
637
+ await processAgentLifecycleEvent({ ...event, taskId: 'T001' }, tempDir);
638
+ const second = await processAgentLifecycleEvent({ ...event, taskId: 'T002' }, tempDir);
639
+ expect(second.observationId).toBeNull();
640
+
641
+ // Third call — threshold reached
642
+ const third = await processAgentLifecycleEvent({ ...event, taskId: 'T003' }, tempDir);
643
+ expect(third.observationId).toMatch(/^O-agt-/);
644
+ });
645
+
646
+ it('returns healing suggestions for failure events when patterns exist', async () => {
647
+ const event: AgentExecutionEvent = {
648
+ agentId: 'agt_1',
649
+ agentType: 'orchestrator',
650
+ taskId: 'T001',
651
+ taskType: 'epic',
652
+ outcome: 'failure',
653
+ errorType: 'unknown',
654
+ };
655
+
656
+ // Build up frequency
657
+ await processAgentLifecycleEvent({ ...event, taskId: 'T001' }, tempDir);
658
+ await processAgentLifecycleEvent({ ...event, taskId: 'T002' }, tempDir);
659
+ const result = await processAgentLifecycleEvent({ ...event, taskId: 'T003' }, tempDir);
660
+
661
+ expect(result.healingSuggestions.length).toBeGreaterThanOrEqual(1);
662
+ expect(result.healingSuggestions[0]!.frequency).toBeGreaterThanOrEqual(3);
663
+ });
664
+
665
+ it('is entirely best-effort — does not throw when brain.db unavailable', async () => {
666
+ // bad cwd -> brain.db open will fail silently
667
+ const event: AgentExecutionEvent = {
668
+ agentId: 'agt_1',
669
+ agentType: 'executor',
670
+ taskId: 'T001',
671
+ taskType: 'task',
672
+ outcome: 'failure',
673
+ errorType: 'retriable',
674
+ };
675
+
676
+ await expect(processAgentLifecycleEvent(event, '/nonexistent/xyz')).resolves.toEqual({
677
+ decisionId: null,
678
+ patternId: null,
679
+ observationId: null,
680
+ healingSuggestions: [],
681
+ });
682
+ });
683
+ });
684
+ });