principles-disciple 1.41.0 → 1.43.0

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 (95) hide show
  1. package/.planning/codebase/ARCHITECTURE.md +157 -0
  2. package/.planning/codebase/CONCERNS.md +145 -0
  3. package/.planning/codebase/CONVENTIONS.md +148 -0
  4. package/.planning/codebase/INTEGRATIONS.md +81 -0
  5. package/.planning/codebase/STACK.md +87 -0
  6. package/.planning/codebase/STRUCTURE.md +193 -0
  7. package/.planning/codebase/TESTING.md +243 -0
  8. package/openclaw.plugin.json +1 -1
  9. package/package.json +1 -1
  10. package/src/commands/archive-impl.ts +5 -3
  11. package/src/commands/context.ts +1 -0
  12. package/src/commands/disable-impl.ts +1 -1
  13. package/src/commands/evolution-status.ts +2 -2
  14. package/src/commands/pain.ts +12 -5
  15. package/src/commands/principle-rollback.ts +1 -1
  16. package/src/commands/promote-impl.ts +13 -7
  17. package/src/commands/rollback.ts +10 -4
  18. package/src/commands/samples.ts +1 -1
  19. package/src/commands/thinking-os.ts +1 -0
  20. package/src/commands/workflow-debug.ts +1 -1
  21. package/src/core/config.ts +1 -0
  22. package/src/core/dictionary.ts +1 -0
  23. package/src/core/event-log.ts +8 -6
  24. package/src/core/evolution-types.ts +33 -1
  25. package/src/core/external-training-contract.ts +1 -1
  26. package/src/core/merge-gate-audit.ts +3 -3
  27. package/src/core/nocturnal-arbiter.ts +1 -1
  28. package/src/core/nocturnal-compliance.ts +21 -21
  29. package/src/core/nocturnal-executability.ts +1 -1
  30. package/src/core/nocturnal-reasoning-deriver.ts +4 -4
  31. package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
  32. package/src/core/nocturnal-snapshot-contract.ts +1 -1
  33. package/src/core/pain-context-extractor.ts +2 -2
  34. package/src/core/path-resolver.ts +1 -0
  35. package/src/core/pd-task-reconciler.ts +1 -0
  36. package/src/core/pd-task-service.ts +1 -1
  37. package/src/core/pd-task-store.ts +1 -0
  38. package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
  39. package/src/core/principle-internalization/principle-lifecycle-service.ts +1 -1
  40. package/src/core/principle-training-state.ts +2 -2
  41. package/src/core/principle-tree-migration.ts +1 -1
  42. package/src/core/replay-engine.ts +1 -0
  43. package/src/core/risk-calculator.ts +2 -1
  44. package/src/core/rule-host.ts +1 -1
  45. package/src/core/session-tracker.ts +1 -0
  46. package/src/core/shadow-observation-registry.ts +1 -1
  47. package/src/core/thinking-models.ts +1 -1
  48. package/src/core/thinking-os-parser.ts +1 -1
  49. package/src/core/trajectory.ts +2 -0
  50. package/src/hooks/bash-risk.ts +2 -2
  51. package/src/hooks/edit-verification.ts +3 -3
  52. package/src/hooks/gate.ts +8 -8
  53. package/src/hooks/gfi-gate.ts +2 -2
  54. package/src/hooks/lifecycle-routing.ts +1 -1
  55. package/src/hooks/message-sanitize.ts +18 -5
  56. package/src/hooks/pain.ts +2 -2
  57. package/src/hooks/progressive-trust-gate.ts +3 -3
  58. package/src/hooks/prompt.ts +17 -4
  59. package/src/hooks/subagent.ts +2 -3
  60. package/src/hooks/thinking-checkpoint.ts +1 -1
  61. package/src/http/principles-console-route.ts +21 -4
  62. package/src/service/central-database.ts +3 -2
  63. package/src/service/central-health-service.ts +2 -1
  64. package/src/service/central-overview-service.ts +3 -2
  65. package/src/service/control-ui-query-service.ts +2 -2
  66. package/src/service/event-log-auditor.ts +2 -2
  67. package/src/service/evolution-query-service.ts +1 -1
  68. package/src/service/evolution-worker.ts +96 -370
  69. package/src/service/health-query-service.ts +11 -10
  70. package/src/service/monitoring-query-service.ts +4 -4
  71. package/src/service/nocturnal-target-selector.ts +2 -2
  72. package/src/service/queue-io.ts +375 -0
  73. package/src/service/queue-migration.ts +122 -0
  74. package/src/service/runtime-summary-service.ts +1 -1
  75. package/src/service/sleep-cycle.ts +157 -0
  76. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +1 -0
  77. package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
  78. package/src/service/subagent-workflow/subagent-error-utils.ts +1 -1
  79. package/src/service/subagent-workflow/workflow-store.ts +3 -2
  80. package/src/service/workflow-watchdog.ts +168 -0
  81. package/src/tools/critique-prompt.ts +1 -1
  82. package/src/tools/deep-reflect.ts +22 -11
  83. package/src/tools/model-index.ts +1 -1
  84. package/src/types/event-payload.ts +80 -0
  85. package/src/types/queue.ts +70 -0
  86. package/src/utils/file-lock.ts +2 -2
  87. package/src/utils/io.ts +11 -3
  88. package/tests/core/evolution-migration.test.ts +325 -1
  89. package/tests/core/queue-purge.test.ts +337 -0
  90. package/tests/fixtures/legacy-queue-v1.json +74 -0
  91. package/tests/queue/async-lock.test.ts +200 -0
  92. package/tests/service/evolution-worker.queue.test.ts +296 -0
  93. package/tests/service/queue-io.test.ts +229 -0
  94. package/tests/service/queue-migration.test.ts +147 -0
  95. package/tests/service/workflow-watchdog.test.ts +372 -0
package/src/utils/io.ts CHANGED
@@ -28,9 +28,17 @@ export function atomicWriteFileSync(filePath: string, data: string): void {
28
28
  if (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES') {
29
29
  if (attempt < RENAME_MAX_RETRIES - 1) {
30
30
  const delay = RENAME_BASE_DELAY_MS * Math.pow(2, attempt);
31
- // Busy-wait is acceptable for very short delays (50-200ms)
32
- const end = Date.now() + delay;
33
- while (Date.now() < end) { /* spin */ }
31
+ // Bounded spin-wait with CPU yield materially different from
32
+ // tight infinite spin; only 50-200ms total across retries.
33
+ const waitUntil = Date.now() + delay;
34
+ let yielded = false;
35
+ while (Date.now() < waitUntil) {
36
+ if (!yielded && Date.now() >= waitUntil - 10) {
37
+ // Last few ms: yield to give other sync code a chance to run
38
+ try { require('fs').accessSync?.(tmpPath); } catch { /* ignore */ }
39
+ yielded = true;
40
+ }
41
+ }
34
42
  }
35
43
  continue;
36
44
  }
@@ -1,8 +1,15 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import { afterEach, describe, expect, it } from 'vitest';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { migrateLegacyEvolutionData } from '../../src/core/evolution-migration.js';
6
+ import {
7
+ migrateToV2,
8
+ isLegacyQueueItem,
9
+ migrateQueueToV2,
10
+ loadEvolutionQueue,
11
+ } from '../../src/service/evolution-worker.js';
12
+ import type { LegacyEvolutionQueueItem, EvolutionQueueItem } from '../../src/service/evolution-worker.js';
6
13
 
7
14
  const tempDirs: string[] = [];
8
15
 
@@ -48,3 +55,320 @@ describe('migrateLegacyEvolutionData', () => {
48
55
  expect(second.importedEvents).toBe(0);
49
56
  });
50
57
  });
58
+
59
+ // ===== migrateToV2 integration tests =====
60
+
61
+ describe('migrateToV2', () => {
62
+ beforeEach(() => {
63
+ vi.useFakeTimers();
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.useRealTimers();
68
+ });
69
+
70
+ it('legacy item without taskKind gets DEFAULT_TASK_KIND (pain_diagnosis)', () => {
71
+ const legacyItem: LegacyEvolutionQueueItem = {
72
+ id: 'test-legacy-001',
73
+ score: 75,
74
+ source: 'tool_failure',
75
+ reason: 'Tool failed',
76
+ timestamp: '2026-04-10T00:00:00.000Z',
77
+ };
78
+
79
+ const migrated = migrateToV2(legacyItem);
80
+
81
+ expect(migrated.taskKind).toBe('pain_diagnosis');
82
+ expect(migrated.priority).toBe('medium');
83
+ expect(migrated.retryCount).toBe(0);
84
+ expect(migrated.maxRetries).toBe(3);
85
+ });
86
+
87
+ it('legacy item without priority gets DEFAULT_PRIORITY (medium)', () => {
88
+ const legacyItem: LegacyEvolutionQueueItem = {
89
+ id: 'test-legacy-002',
90
+ score: 50,
91
+ source: 'gate_block',
92
+ reason: 'Gate blocked',
93
+ timestamp: '2026-04-11T00:00:00.000Z',
94
+ };
95
+
96
+ const migrated = migrateToV2(legacyItem);
97
+
98
+ expect(migrated.priority).toBe('medium');
99
+ });
100
+
101
+ it('migrateToV2 preserves all original fields', () => {
102
+ const legacyItem: LegacyEvolutionQueueItem = {
103
+ id: 'test-legacy-003',
104
+ task: 'Diagnose pain',
105
+ score: 88,
106
+ source: 'runtime_unavailable',
107
+ reason: 'Session timeout',
108
+ timestamp: '2026-04-12T00:00:00.000Z',
109
+ enqueued_at: '2026-04-12T00:00:05.000Z',
110
+ started_at: '2026-04-12T00:01:00.000Z',
111
+ completed_at: '2026-04-12T00:05:00.000Z',
112
+ assigned_session_key: 'session-key-xyz',
113
+ trigger_text_preview: 'Session timed out',
114
+ status: 'completed',
115
+ resolution: 'auto_completed_timeout',
116
+ session_id: 'session-xyz',
117
+ agent_id: 'main',
118
+ traceId: 'trace-xyz',
119
+ resultRef: 'result-xyz',
120
+ };
121
+
122
+ const migrated = migrateToV2(legacyItem);
123
+
124
+ expect(migrated.id).toBe('test-legacy-003');
125
+ expect(migrated.task).toBe('Diagnose pain');
126
+ expect(migrated.score).toBe(88);
127
+ expect(migrated.source).toBe('runtime_unavailable');
128
+ expect(migrated.reason).toBe('Session timeout');
129
+ expect(migrated.timestamp).toBe('2026-04-12T00:00:00.000Z');
130
+ expect(migrated.enqueued_at).toBe('2026-04-12T00:00:05.000Z');
131
+ expect(migrated.started_at).toBe('2026-04-12T00:01:00.000Z');
132
+ expect(migrated.completed_at).toBe('2026-04-12T00:05:00.000Z');
133
+ expect(migrated.assigned_session_key).toBe('session-key-xyz');
134
+ expect(migrated.trigger_text_preview).toBe('Session timed out');
135
+ expect(migrated.status).toBe('completed');
136
+ expect(migrated.resolution).toBe('auto_completed_timeout');
137
+ expect(migrated.session_id).toBe('session-xyz');
138
+ expect(migrated.agent_id).toBe('main');
139
+ expect(migrated.traceId).toBe('trace-xyz');
140
+ expect(migrated.resultRef).toBe('result-xyz');
141
+ });
142
+
143
+ it('migrateQueueToV2 migrates legacy items and passes through V2 items unchanged', () => {
144
+ const mixedQueue = [
145
+ // Legacy item (no taskKind) - should be migrated
146
+ {
147
+ id: 'legacy-001',
148
+ score: 70,
149
+ source: 'tool_failure',
150
+ reason: 'Legacy reason',
151
+ timestamp: '2026-04-10T00:00:00.000Z',
152
+ } as LegacyEvolutionQueueItem,
153
+ // V2 item (has taskKind) - should be passed through
154
+ {
155
+ id: 'v2-001',
156
+ taskKind: 'sleep_reflection' as const,
157
+ priority: 'high' as const,
158
+ score: 60,
159
+ source: 'nocturnal',
160
+ reason: 'V2 reason',
161
+ timestamp: '2026-04-11T00:00:00.000Z',
162
+ status: 'pending' as const,
163
+ retryCount: 0,
164
+ maxRetries: 3,
165
+ } as EvolutionQueueItem,
166
+ ];
167
+
168
+ const result = migrateQueueToV2(mixedQueue as any);
169
+
170
+ expect(result).toHaveLength(2);
171
+ // Legacy item should be migrated
172
+ expect(result[0].id).toBe('legacy-001');
173
+ expect((result[0] as EvolutionQueueItem).taskKind).toBe('pain_diagnosis');
174
+ expect((result[0] as EvolutionQueueItem).priority).toBe('medium');
175
+ // V2 item should be passed through unchanged
176
+ expect(result[1].id).toBe('v2-001');
177
+ expect((result[1] as EvolutionQueueItem).taskKind).toBe('sleep_reflection');
178
+ expect((result[1] as EvolutionQueueItem).priority).toBe('high');
179
+ });
180
+
181
+ it('isLegacyQueueItem returns true only when taskKind is absent', () => {
182
+ const legacyItem = { id: 'test', score: 50, source: 'x', reason: 'y', timestamp: '2026-01-01T00:00:00Z' };
183
+ const v2Item = { id: 'test', taskKind: 'sleep_reflection', score: 50, source: 'x', reason: 'y', timestamp: '2026-01-01T00:00:00Z', priority: 'medium', status: 'pending', retryCount: 0, maxRetries: 3 };
184
+
185
+ expect(isLegacyQueueItem(legacyItem)).toBe(true);
186
+ expect(isLegacyQueueItem(v2Item)).toBe(false);
187
+ });
188
+ });
189
+
190
+ // ===== Queue state transition tests =====
191
+
192
+ describe('Queue migration state transitions', () => {
193
+ beforeEach(() => {
194
+ vi.useFakeTimers();
195
+ });
196
+
197
+ afterEach(() => {
198
+ vi.useRealTimers();
199
+ });
200
+
201
+ it('pending legacy item migrates to V2 with pending status', () => {
202
+ const tempDir = makeTempDir();
203
+ const queuePath = path.join(tempDir, 'evolution_queue.json');
204
+ const legacyPending = {
205
+ id: 'pending-001',
206
+ score: 72,
207
+ source: 'tool_failure',
208
+ reason: 'Write tool failed',
209
+ timestamp: '2026-04-10T08:00:00.000Z',
210
+ enqueued_at: '2026-04-10T08:00:05.000Z',
211
+ started_at: null,
212
+ completed_at: null,
213
+ assigned_session_key: null,
214
+ trigger_text_preview: 'Write failed',
215
+ status: 'pending',
216
+ resolution: null,
217
+ session_id: null,
218
+ agent_id: null,
219
+ traceId: 'trace-pending',
220
+ };
221
+ fs.writeFileSync(queuePath, JSON.stringify([legacyPending]), 'utf8');
222
+
223
+ const result = loadEvolutionQueue(queuePath);
224
+
225
+ expect(result).toHaveLength(1);
226
+ expect(result[0].id).toBe('pending-001');
227
+ expect(result[0].status).toBe('pending');
228
+ expect(result[0].taskKind).toBe('pain_diagnosis');
229
+ expect(result[0].priority).toBe('medium');
230
+ });
231
+
232
+ it('in_progress legacy item migrates to V2 with in_progress status', () => {
233
+ const tempDir = makeTempDir();
234
+ const queuePath = path.join(tempDir, 'evolution_queue.json');
235
+ const legacyInProgress = {
236
+ id: 'inprogress-001',
237
+ score: 65,
238
+ source: 'runtime_unavailable',
239
+ reason: 'Session timeout',
240
+ timestamp: '2026-04-11T14:00:00.000Z',
241
+ enqueued_at: '2026-04-11T14:00:10.000Z',
242
+ started_at: '2026-04-11T14:05:00.000Z',
243
+ completed_at: null,
244
+ assigned_session_key: 'session-key-abc',
245
+ trigger_text_preview: 'Session timeout',
246
+ status: 'in_progress',
247
+ resolution: null,
248
+ session_id: 'session-abc',
249
+ agent_id: 'main',
250
+ traceId: 'trace-inprogress',
251
+ };
252
+ fs.writeFileSync(queuePath, JSON.stringify([legacyInProgress]), 'utf8');
253
+
254
+ const result = loadEvolutionQueue(queuePath);
255
+
256
+ expect(result).toHaveLength(1);
257
+ expect(result[0].id).toBe('inprogress-001');
258
+ expect(result[0].status).toBe('in_progress');
259
+ });
260
+
261
+ it('completed legacy item migrates to V2 with completed status', () => {
262
+ const tempDir = makeTempDir();
263
+ const queuePath = path.join(tempDir, 'evolution_queue.json');
264
+ const legacyCompleted = {
265
+ id: 'completed-001',
266
+ score: 90,
267
+ source: 'gate_block',
268
+ reason: 'Principle score below threshold',
269
+ timestamp: '2026-04-12T09:00:00.000Z',
270
+ enqueued_at: '2026-04-12T09:00:30.000Z',
271
+ started_at: '2026-04-12T09:01:00.000Z',
272
+ completed_at: '2026-04-12T09:05:00.000Z',
273
+ assigned_session_key: 'session-key-def',
274
+ trigger_text_preview: 'Score below threshold',
275
+ status: 'completed',
276
+ resolution: 'late_marker_no_principle',
277
+ session_id: 'session-def',
278
+ agent_id: 'main',
279
+ traceId: 'trace-completed',
280
+ };
281
+ fs.writeFileSync(queuePath, JSON.stringify([legacyCompleted]), 'utf8');
282
+
283
+ const result = loadEvolutionQueue(queuePath);
284
+
285
+ expect(result).toHaveLength(1);
286
+ expect(result[0].id).toBe('completed-001');
287
+ expect(result[0].status).toBe('completed');
288
+ expect(result[0].resolution).toBe('late_marker_no_principle');
289
+ });
290
+
291
+ it('failed legacy item migrates to V2 with failed status', () => {
292
+ const tempDir = makeTempDir();
293
+ const queuePath = path.join(tempDir, 'evolution_queue.json');
294
+ const legacyFailed = {
295
+ id: 'failed-001',
296
+ score: 55,
297
+ source: 'thin_violation',
298
+ reason: 'Rule scoring bias',
299
+ timestamp: '2026-04-13T16:00:00.000Z',
300
+ enqueued_at: '2026-04-13T16:00:15.000Z',
301
+ started_at: '2026-04-13T16:01:00.000Z',
302
+ completed_at: '2026-04-13T16:05:00.000Z',
303
+ assigned_session_key: 'session-key-ghi',
304
+ trigger_text_preview: 'Rule scoring bias',
305
+ status: 'failed',
306
+ resolution: 'failed_max_retries',
307
+ session_id: 'session-ghi',
308
+ agent_id: 'main',
309
+ traceId: 'trace-failed',
310
+ };
311
+ fs.writeFileSync(queuePath, JSON.stringify([legacyFailed]), 'utf8');
312
+
313
+ const result = loadEvolutionQueue(queuePath);
314
+
315
+ expect(result).toHaveLength(1);
316
+ expect(result[0].id).toBe('failed-001');
317
+ expect(result[0].status).toBe('failed');
318
+ expect(result[0].resolution).toBe('failed_max_retries');
319
+ });
320
+
321
+ it('legacy item with missing optional fields retains undefined values', () => {
322
+ const tempDir = makeTempDir();
323
+ const queuePath = path.join(tempDir, 'evolution_queue.json');
324
+ // Minimal legacy item - only required fields
325
+ const minimalLegacy = {
326
+ id: 'minimal-001',
327
+ score: 40,
328
+ source: 'minimal_source',
329
+ reason: 'Minimal test item',
330
+ timestamp: '2026-04-14T00:00:00.000Z',
331
+ };
332
+ fs.writeFileSync(queuePath, JSON.stringify([minimalLegacy]), 'utf8');
333
+
334
+ const result = loadEvolutionQueue(queuePath);
335
+
336
+ expect(result).toHaveLength(1);
337
+ expect(result[0].id).toBe('minimal-001');
338
+ expect(result[0].enqueued_at).toBeUndefined();
339
+ expect(result[0].started_at).toBeUndefined();
340
+ expect(result[0].completed_at).toBeUndefined();
341
+ expect(result[0].assigned_session_key).toBeUndefined();
342
+ expect(result[0].trigger_text_preview).toBeUndefined();
343
+ expect(result[0].session_id).toBeUndefined();
344
+ expect(result[0].agent_id).toBeUndefined();
345
+ expect(result[0].traceId).toBeUndefined();
346
+ expect(result[0].resultRef).toBeUndefined();
347
+ // But defaults are applied for V2-required fields
348
+ expect(result[0].taskKind).toBe('pain_diagnosis');
349
+ expect(result[0].priority).toBe('medium');
350
+ expect(result[0].retryCount).toBe(0);
351
+ expect(result[0].maxRetries).toBe(3);
352
+ expect(result[0].status).toBe('pending'); // default status
353
+ });
354
+
355
+ it('empty queue file returns empty array', () => {
356
+ const tempDir = makeTempDir();
357
+ const queuePath = path.join(tempDir, 'evolution_queue.json');
358
+ fs.writeFileSync(queuePath, JSON.stringify([]), 'utf8');
359
+
360
+ const result = loadEvolutionQueue(queuePath);
361
+
362
+ expect(result).toHaveLength(0);
363
+ });
364
+
365
+ it('corrupted JSON file returns empty array', () => {
366
+ const tempDir = makeTempDir();
367
+ const queuePath = path.join(tempDir, 'evolution_queue.json');
368
+ fs.writeFileSync(queuePath, 'not valid json{ ', 'utf8');
369
+
370
+ const result = loadEvolutionQueue(queuePath);
371
+
372
+ expect(result).toHaveLength(0);
373
+ });
374
+ });
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Unit tests for purgeStaleFailedTasks and hasRecentDuplicateTask.
3
+ * Tests deduplication logic and 24-hour stale task cleanup.
4
+ * Uses vi.useFakeTimers() per D-10.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import {
9
+ purgeStaleFailedTasks,
10
+ hasRecentDuplicateTask,
11
+ } from '../../src/service/evolution-worker.js';
12
+ import type { EvolutionQueueItem } from '../../src/service/evolution-worker.js';
13
+
14
+ describe('purgeStaleFailedTasks', () => {
15
+ let mockLogger: { info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn> };
16
+
17
+ beforeEach(() => {
18
+ vi.useFakeTimers();
19
+ mockLogger = {
20
+ info: vi.fn(),
21
+ warn: vi.fn(),
22
+ error: vi.fn(),
23
+ debug: vi.fn(),
24
+ };
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ });
30
+
31
+ function makeTask(overrides: Partial<EvolutionQueueItem> & { id: string; timestamp: string; status: EvolutionQueueItem['status'] }): EvolutionQueueItem {
32
+ return {
33
+ id: 'default-id',
34
+ taskKind: 'pain_diagnosis',
35
+ priority: 'medium',
36
+ score: 50,
37
+ source: 'tool_failure',
38
+ reason: 'Default reason',
39
+ timestamp: '2026-04-10T00:00:00.000Z',
40
+ enqueued_at: '2026-04-10T00:00:00.000Z',
41
+ started_at: null,
42
+ completed_at: null,
43
+ assigned_session_key: null,
44
+ trigger_text_preview: 'Default',
45
+ status: 'pending',
46
+ resolution: undefined,
47
+ session_id: null,
48
+ agent_id: null,
49
+ traceId: 'trace-default',
50
+ retryCount: 0,
51
+ maxRetries: 3,
52
+ lastError: undefined,
53
+ resultRef: undefined,
54
+ ...overrides,
55
+ } as EvolutionQueueItem;
56
+ }
57
+
58
+ it('purges failed tasks older than 24 hours', () => {
59
+ // 25 hours ago - should be purged
60
+ const staleFailed = makeTask({
61
+ id: 'stale-failed',
62
+ timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
63
+ status: 'failed',
64
+ lastError: 'timeout',
65
+ });
66
+ // 1 hour ago - should be kept
67
+ const recentFailed = makeTask({
68
+ id: 'recent-failed',
69
+ timestamp: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
70
+ status: 'failed',
71
+ lastError: 'timeout',
72
+ });
73
+
74
+ const queue = [staleFailed, recentFailed];
75
+ const result = purgeStaleFailedTasks(queue, mockLogger);
76
+
77
+ expect(result.purged).toBe(1);
78
+ expect(result.remaining).toBe(1);
79
+ expect(result.byReason['timeout']).toBe(1);
80
+ expect(queue).toHaveLength(1);
81
+ expect(queue[0].id).toBe('recent-failed');
82
+ });
83
+
84
+ it('preserves non-failed tasks regardless of age', () => {
85
+ const oldPending = makeTask({
86
+ id: 'old-pending',
87
+ timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
88
+ status: 'pending',
89
+ });
90
+ const oldInProgress = makeTask({
91
+ id: 'old-in-progress',
92
+ timestamp: new Date(Date.now() - 30 * 60 * 60 * 1000).toISOString(),
93
+ status: 'in_progress',
94
+ });
95
+ const oldCompleted = makeTask({
96
+ id: 'old-completed',
97
+ timestamp: new Date(Date.now() - 72 * 60 * 60 * 1000).toISOString(),
98
+ status: 'completed',
99
+ resolution: 'success',
100
+ });
101
+
102
+ const queue = [oldPending, oldInProgress, oldCompleted];
103
+ const result = purgeStaleFailedTasks(queue, mockLogger);
104
+
105
+ expect(result.purged).toBe(0);
106
+ expect(result.remaining).toBe(3);
107
+ expect(result.byReason).toEqual({});
108
+ });
109
+
110
+ it('returns accurate byReason breakdown', () => {
111
+ const failedTimeout = makeTask({
112
+ id: 'fail-timeout',
113
+ timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
114
+ status: 'failed',
115
+ lastError: 'timeout',
116
+ });
117
+ const failedAuth = makeTask({
118
+ id: 'fail-auth',
119
+ timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
120
+ status: 'failed',
121
+ lastError: 'auth_error',
122
+ });
123
+ const failedBoth = makeTask({
124
+ id: 'fail-both',
125
+ timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
126
+ status: 'failed',
127
+ lastError: 'timeout',
128
+ });
129
+
130
+ const queue = [failedTimeout, failedAuth, failedBoth];
131
+ const result = purgeStaleFailedTasks(queue, mockLogger);
132
+
133
+ expect(result.purged).toBe(3);
134
+ expect(result.byReason['timeout']).toBe(2);
135
+ expect(result.byReason['auth_error']).toBe(1);
136
+ });
137
+
138
+ it('handles queue with no failed tasks', () => {
139
+ const pending = makeTask({
140
+ id: 'pending-only',
141
+ timestamp: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
142
+ status: 'pending',
143
+ });
144
+
145
+ const queue = [pending];
146
+ const result = purgeStaleFailedTasks(queue, mockLogger);
147
+
148
+ expect(result.purged).toBe(0);
149
+ expect(result.remaining).toBe(1);
150
+ expect(result.byReason).toEqual({});
151
+ });
152
+
153
+ it('handles empty queue', () => {
154
+ const queue: EvolutionQueueItem[] = [];
155
+ const result = purgeStaleFailedTasks(queue, mockLogger);
156
+
157
+ expect(result.purged).toBe(0);
158
+ expect(result.remaining).toBe(0);
159
+ expect(result.byReason).toEqual({});
160
+ });
161
+
162
+ it('mutates queue in place (splice)', () => {
163
+ const failed = makeTask({
164
+ id: 'failed-to-purge',
165
+ timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
166
+ status: 'failed',
167
+ lastError: 'persist_error',
168
+ });
169
+ const keep = makeTask({
170
+ id: 'keep-me',
171
+ timestamp: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
172
+ status: 'in_progress',
173
+ });
174
+
175
+ const queue = [failed, keep];
176
+ const originalLength = queue.length;
177
+
178
+ purgeStaleFailedTasks(queue, mockLogger);
179
+
180
+ // Queue was mutated in place
181
+ expect(queue.length).toBeLessThan(originalLength);
182
+ expect(queue.find(t => t.id === 'keep-me')).toBeDefined();
183
+ expect(queue.find(t => t.id === 'failed-to-purge')).toBeUndefined();
184
+ });
185
+ });
186
+
187
+ describe('hasRecentDuplicateTask', () => {
188
+ beforeEach(() => {
189
+ vi.useFakeTimers();
190
+ });
191
+
192
+ afterEach(() => {
193
+ vi.useRealTimers();
194
+ });
195
+
196
+ function makeTask(overrides: Partial<EvolutionQueueItem> & { id: string; timestamp: string; status: EvolutionQueueItem['status'] }): EvolutionQueueItem {
197
+ return {
198
+ id: 'default-id',
199
+ taskKind: 'pain_diagnosis',
200
+ priority: 'medium',
201
+ score: 50,
202
+ source: 'tool_failure',
203
+ reason: 'Default reason',
204
+ timestamp: '2026-04-10T00:00:00.000Z',
205
+ enqueued_at: '2026-04-10T00:00:00.000Z',
206
+ started_at: null,
207
+ completed_at: null,
208
+ assigned_session_key: null,
209
+ trigger_text_preview: 'Default',
210
+ status: 'pending',
211
+ resolution: undefined,
212
+ session_id: null,
213
+ agent_id: null,
214
+ traceId: 'trace-default',
215
+ retryCount: 0,
216
+ maxRetries: 3,
217
+ lastError: undefined,
218
+ resultRef: undefined,
219
+ ...overrides,
220
+ } as EvolutionQueueItem;
221
+ }
222
+
223
+ it('returns true for matching source/preview/reason within 30-min window', () => {
224
+ // PAIN_QUEUE_DEDUP_WINDOW_MS = 30 minutes, status must NOT be 'completed'
225
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
226
+ const existing = makeTask({
227
+ id: 'existing-001',
228
+ source: 'tool_failure',
229
+ trigger_text_preview: 'File write failed',
230
+ reason: 'permission denied',
231
+ timestamp: tenMinutesAgo,
232
+ enqueued_at: tenMinutesAgo,
233
+ status: 'pending', // hasRecentDuplicateTask skips 'completed'
234
+ });
235
+
236
+ const queue = [existing];
237
+ const now = Date.now();
238
+
239
+ const result = hasRecentDuplicateTask(queue, 'tool_failure', 'File write failed', now, 'permission denied');
240
+
241
+ expect(result).toBe(true);
242
+ });
243
+
244
+ it('returns false for same source/preview but different reason', () => {
245
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
246
+ const existing = makeTask({
247
+ id: 'existing-002',
248
+ source: 'tool_failure',
249
+ trigger_text_preview: 'File write failed',
250
+ reason: 'permission denied',
251
+ timestamp: tenMinutesAgo,
252
+ enqueued_at: tenMinutesAgo,
253
+ status: 'pending', // hasRecentDuplicateTask skips 'completed'
254
+ });
255
+
256
+ const queue = [existing];
257
+ const now = Date.now();
258
+
259
+ // Same source and preview, but different reason
260
+ const result = hasRecentDuplicateTask(queue, 'tool_failure', 'File write failed', now, 'disk full');
261
+
262
+ expect(result).toBe(false);
263
+ });
264
+
265
+ it('returns false for item older than 30 min window', () => {
266
+ // PAIN_QUEUE_DEDUP_WINDOW_MS = 30 minutes
267
+ const fortyMinutesAgo = new Date(Date.now() - 40 * 60 * 1000).toISOString();
268
+ const oldTask = makeTask({
269
+ id: 'old-duplicate',
270
+ source: 'tool_failure',
271
+ trigger_text_preview: 'File write failed',
272
+ reason: 'permission denied',
273
+ timestamp: fortyMinutesAgo,
274
+ enqueued_at: fortyMinutesAgo,
275
+ status: 'pending',
276
+ });
277
+
278
+ const queue = [oldTask];
279
+ const now = Date.now();
280
+
281
+ const result = hasRecentDuplicateTask(queue, 'tool_failure', 'File write failed', now, 'permission denied');
282
+
283
+ expect(result).toBe(false);
284
+ });
285
+
286
+ it('returns false when queue is empty', () => {
287
+ const queue: EvolutionQueueItem[] = [];
288
+ const now = Date.now();
289
+
290
+ const result = hasRecentDuplicateTask(queue, 'tool_failure', 'File write failed', now, 'permission denied');
291
+
292
+ expect(result).toBe(false);
293
+ });
294
+
295
+ it('normalizes case and whitespace in dedup key', () => {
296
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
297
+ const existing = makeTask({
298
+ id: 'case-test',
299
+ source: 'TOOL_FAILURE',
300
+ trigger_text_preview: ' File write failed ',
301
+ reason: ' PERMISSION DENIED ',
302
+ timestamp: tenMinutesAgo,
303
+ enqueued_at: tenMinutesAgo,
304
+ status: 'pending', // hasRecentDuplicateTask skips 'completed'
305
+ });
306
+
307
+ const queue = [existing];
308
+ const now = Date.now();
309
+
310
+ // Different case/whitespace should still match
311
+ const result = hasRecentDuplicateTask(queue, 'tool_failure', 'File write failed', now, 'permission denied');
312
+
313
+ expect(result).toBe(true);
314
+ });
315
+
316
+ it('returns true when reason parameter matches task reason', () => {
317
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
318
+ const existing = makeTask({
319
+ id: 'reason-test',
320
+ source: 'gate_block',
321
+ trigger_text_preview: 'Score below threshold',
322
+ reason: 'threshold_violation',
323
+ timestamp: tenMinutesAgo,
324
+ enqueued_at: tenMinutesAgo,
325
+ status: 'in_progress', // hasRecentDuplicateTask skips 'completed'
326
+ lastError: 'threshold_violation',
327
+ });
328
+
329
+ const queue = [existing];
330
+ const now = Date.now();
331
+
332
+ // Pass matching reason - should return true
333
+ const result = hasRecentDuplicateTask(queue, 'gate_block', 'Score below threshold', now, 'threshold_violation');
334
+
335
+ expect(result).toBe(true);
336
+ });
337
+ });