@principles/pd-cli 1.80.0 → 1.82.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.
@@ -0,0 +1,767 @@
1
+ /**
2
+ * Tests for pd pain retry command.
3
+ *
4
+ * Covers:
5
+ * - Command parser/registration: --pain-id, --json, --force
6
+ * - Success path: retry_wait + last_error → succeeded + last_error cleared
7
+ * - Not found path: JSON single object + reason + nextAction
8
+ * - Already succeeded without --force: refused
9
+ * - Force path: allow retry of succeeded task
10
+ * - Strict JSON: --json stdout exactly one parseable JSON object
11
+ * - No mutation on failed validation: no run/candidate/ledger created when task not found
12
+ * - painId with diagnosis_ prefix: rejected with reason + nextAction
13
+ * - Wrong taskKind: rejected with reason + nextAction
14
+ * - Missing pi-ai config: rejected with reason + nextAction
15
+ */
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+ import { Command } from 'commander';
18
+
19
+ // ── Mocks ──────────────────────────────────────────────────────────────────────
20
+
21
+ const { MockRuntimeStateManager, mockGetTask, mockGetCandidatesByTaskId, mockUpdateCandidateStatus, mockGetRunsByTask } = vi.hoisted(() => {
22
+ const mockGetTask = vi.fn().mockResolvedValue(null);
23
+ const mockGetCandidatesByTaskId = vi.fn().mockResolvedValue([]);
24
+ const mockUpdateCandidateStatus = vi.fn().mockResolvedValue(undefined);
25
+ const mockGetRunsByTask = vi.fn().mockResolvedValue([]);
26
+
27
+ class MockRuntimeStateManager {
28
+ initialize = vi.fn().mockResolvedValue(undefined);
29
+ close = vi.fn().mockResolvedValue(undefined);
30
+ getTask = mockGetTask;
31
+ getCandidatesByTaskId = mockGetCandidatesByTaskId;
32
+ updateCandidateStatus = mockUpdateCandidateStatus;
33
+ getRunsByTask = mockGetRunsByTask;
34
+ connection = {} as Record<string, unknown>;
35
+ taskStore = {};
36
+ runStore = {};
37
+ }
38
+ return { MockRuntimeStateManager, mockGetTask, mockGetCandidatesByTaskId, mockUpdateCandidateStatus, mockGetRunsByTask };
39
+ }, { validateType: true });
40
+
41
+ const { mockIntake, MockCandidateIntakeService } = vi.hoisted(() => {
42
+ const mockIntake = vi.fn();
43
+ function MockCandidateIntakeService(this: any) {
44
+ return { intake: mockIntake };
45
+ }
46
+ MockCandidateIntakeService.prototype = {};
47
+ return { mockIntake, MockCandidateIntakeService };
48
+ });
49
+
50
+ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
51
+ function MockPrincipleTreeLedgerAdapter(this: any) {
52
+ return {};
53
+ }
54
+ MockPrincipleTreeLedgerAdapter.prototype = {};
55
+ return { MockPrincipleTreeLedgerAdapter };
56
+ });
57
+
58
+ const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
59
+ const mockRun = vi.fn().mockResolvedValue({
60
+ status: 'succeeded',
61
+ taskId: 'diagnosis_test-pain-1',
62
+ runId: 'run-retry-1',
63
+ contextHash: 'abc123',
64
+ });
65
+ const mockResolveRuntimeConfig = vi.fn().mockReturnValue({
66
+ runtimeKind: 'pi-ai',
67
+ provider: 'test-provider',
68
+ model: 'test-model',
69
+ apiKeyEnv: 'TEST_KEY',
70
+ timeoutMs: 300000,
71
+ agentId: 'main',
72
+ });
73
+ return { mockRun, mockResolveRuntimeConfig };
74
+ });
75
+
76
+ vi.mock('../../src/resolve-workspace.js', () => ({
77
+ resolveWorkspaceDir: vi.fn().mockReturnValue('/tmp/fake-workspace'),
78
+ }));
79
+
80
+ vi.mock('@principles/core/runtime-v2', () => {
81
+ return {
82
+ RuntimeStateManager: vi.fn().mockImplementation(function () {
83
+ return new MockRuntimeStateManager();
84
+ }),
85
+ SqliteHistoryQuery: vi.fn().mockImplementation(function () { return {}; }),
86
+ SqliteContextAssembler: vi.fn().mockImplementation(function () { return {}; }),
87
+ SqliteDiagnosticianCommitter: vi.fn().mockImplementation(function () { return {}; }),
88
+ SqliteTrajectoryLocator: vi.fn().mockImplementation(function () { return {}; }),
89
+ SqliteSourceTraceLocator: vi.fn().mockImplementation(function () { return {}; }),
90
+ StoreEventEmitter: vi.fn().mockImplementation(function () { return {}; }),
91
+ storeEmitter: { emitTelemetry: vi.fn() },
92
+ DiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
93
+ DefaultDiagnosticianValidator: vi.fn().mockImplementation(function () { return {}; }),
94
+ TestDoubleRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
95
+ OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
96
+ PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
97
+ PDRuntimeError: class PDRuntimeError extends Error {
98
+ constructor(public category: string, message: string) {
99
+ super(message);
100
+ this.name = 'PDRuntimeError';
101
+ }
102
+ },
103
+ CandidateIntakeService: MockCandidateIntakeService,
104
+ resolveRuntimeConfig: mockResolveRuntimeConfig,
105
+ isRuntimeConfigError: vi.fn().mockReturnValue(false),
106
+ run: mockRun,
107
+ status: vi.fn(),
108
+ };
109
+ });
110
+
111
+ vi.mock('../../src/principle-tree-ledger-adapter.js', () => ({
112
+ PrincipleTreeLedgerAdapter: MockPrincipleTreeLedgerAdapter,
113
+ }));
114
+
115
+ import { handlePainRetry } from '../../src/commands/pain-retry.js';
116
+
117
+ // ── Test Data ──────────────────────────────────────────────────────────────────
118
+
119
+ const RETRY_WAIT_TASK = {
120
+ taskId: 'diagnosis_test-pain-1',
121
+ taskKind: 'diagnostician',
122
+ status: 'retry_wait' as const,
123
+ attemptCount: 1,
124
+ maxAttempts: 3,
125
+ lastError: 'output_invalid',
126
+ leaseOwner: null,
127
+ leaseExpiresAt: null,
128
+ resultRef: null,
129
+ createdAt: new Date().toISOString(),
130
+ updatedAt: new Date().toISOString(),
131
+ };
132
+
133
+ const FAILED_TASK = {
134
+ taskId: 'diagnosis_test-pain-failed',
135
+ taskKind: 'diagnostician',
136
+ status: 'failed' as const,
137
+ attemptCount: 3,
138
+ maxAttempts: 3,
139
+ lastError: 'timeout',
140
+ leaseOwner: null,
141
+ leaseExpiresAt: null,
142
+ resultRef: null,
143
+ createdAt: new Date().toISOString(),
144
+ updatedAt: new Date().toISOString(),
145
+ };
146
+
147
+ const SUCCEEDED_TASK = {
148
+ taskId: 'diagnosis_test-pain-succeeded',
149
+ taskKind: 'diagnostician',
150
+ status: 'succeeded' as const,
151
+ attemptCount: 2,
152
+ maxAttempts: 3,
153
+ lastError: null,
154
+ leaseOwner: null,
155
+ leaseExpiresAt: null,
156
+ resultRef: 'commit://abc',
157
+ createdAt: new Date().toISOString(),
158
+ updatedAt: new Date().toISOString(),
159
+ };
160
+
161
+ const NON_DIAGNOSTICIAN_TASK = {
162
+ taskId: 'diagnosis_test-pain-wrong',
163
+ taskKind: 'dreamer',
164
+ status: 'failed' as const,
165
+ attemptCount: 1,
166
+ maxAttempts: 3,
167
+ lastError: 'timeout',
168
+ leaseOwner: null,
169
+ leaseExpiresAt: null,
170
+ resultRef: null,
171
+ createdAt: new Date().toISOString(),
172
+ updatedAt: new Date().toISOString(),
173
+ };
174
+
175
+ // ── Tests ──────────────────────────────────────────────────────────────────────
176
+
177
+ describe('pd pain retry — validation and error paths', () => {
178
+ beforeEach(() => {
179
+ vi.clearAllMocks();
180
+ mockResolveRuntimeConfig.mockReturnValue({
181
+ runtimeKind: 'pi-ai',
182
+ provider: 'test-provider',
183
+ model: 'test-model',
184
+ apiKeyEnv: 'TEST_KEY',
185
+ timeoutMs: 300000,
186
+ agentId: 'main',
187
+ });
188
+ mockGetTask.mockResolvedValue(null);
189
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
190
+ mockUpdateCandidateStatus.mockResolvedValue(undefined);
191
+ mockGetRunsByTask.mockResolvedValue([]);
192
+ mockIntake.mockReset();
193
+ mockRun.mockResolvedValue({
194
+ status: 'succeeded',
195
+ taskId: 'diagnosis_test-pain-1',
196
+ runId: 'run-retry-1',
197
+ contextHash: 'abc123',
198
+ });
199
+ });
200
+
201
+ it('RETRY-01: painId not found — JSON output with reason + nextAction', async () => {
202
+ mockGetTask.mockResolvedValue(null);
203
+
204
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
205
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
206
+
207
+ await handlePainRetry({
208
+ painId: 'nonexistent-pain',
209
+ workspace: '/tmp/fake-workspace',
210
+ runtime: 'test-double',
211
+ json: true,
212
+ });
213
+
214
+ const jsonCall = logSpy.mock.calls.find((call) => {
215
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
216
+ });
217
+ expect(jsonCall).toBeDefined();
218
+ const output = JSON.parse(jsonCall![0] as string);
219
+ expect(output.status).toBe('not_found');
220
+ expect(output.painId).toBe('nonexistent-pain');
221
+ expect(output.reason).toContain('task_not_found');
222
+ expect(output.nextAction).toBeDefined();
223
+ expect(exitSpy).toHaveBeenCalledWith(1);
224
+
225
+ logSpy.mockRestore();
226
+ exitSpy.mockRestore();
227
+ });
228
+
229
+ it('RETRY-02: painId with diagnosis_ prefix — rejected with reason + nextAction', async () => {
230
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
231
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
232
+
233
+ await handlePainRetry({
234
+ painId: 'diagnosis_test-pain-1',
235
+ workspace: '/tmp/fake-workspace',
236
+ json: true,
237
+ });
238
+
239
+ // Find the JSON output (may be mixed with other logs)
240
+ const jsonCall = logSpy.mock.calls.find((call) => {
241
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
242
+ });
243
+ expect(jsonCall).toBeDefined();
244
+ const output = JSON.parse(jsonCall![0] as string);
245
+ expect(output.status).toBe('refused');
246
+ expect(output.reason).toContain('diagnosis_');
247
+ expect(output.nextAction).toContain('pd diagnose run');
248
+ expect(exitSpy).toHaveBeenCalledWith(1);
249
+
250
+ logSpy.mockRestore();
251
+ exitSpy.mockRestore();
252
+ });
253
+
254
+ it('RETRY-03: task is not diagnostician — refused with reason + nextAction', async () => {
255
+ mockGetTask.mockResolvedValue(NON_DIAGNOSTICIAN_TASK);
256
+
257
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
258
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
259
+
260
+ await handlePainRetry({
261
+ painId: 'test-pain-wrong',
262
+ workspace: '/tmp/fake-workspace',
263
+ runtime: 'test-double',
264
+ json: true,
265
+ });
266
+
267
+ const jsonCall = logSpy.mock.calls.find((call) => {
268
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
269
+ });
270
+ expect(jsonCall).toBeDefined();
271
+ const output = JSON.parse(jsonCall![0] as string);
272
+ expect(output.status).toBe('refused');
273
+ expect(output.reason).toContain('wrong_task_kind');
274
+ expect(output.nextAction).toBeDefined();
275
+ expect(exitSpy).toHaveBeenCalledWith(1);
276
+
277
+ logSpy.mockRestore();
278
+ exitSpy.mockRestore();
279
+ });
280
+
281
+ it('RETRY-04: already succeeded without --force — refused', async () => {
282
+ mockGetTask.mockResolvedValue(SUCCEEDED_TASK);
283
+
284
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
285
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
286
+
287
+ await handlePainRetry({
288
+ painId: 'test-pain-succeeded',
289
+ workspace: '/tmp/fake-workspace',
290
+ runtime: 'test-double',
291
+ json: true,
292
+ });
293
+
294
+ const jsonCall = logSpy.mock.calls.find((call) => {
295
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
296
+ });
297
+ expect(jsonCall).toBeDefined();
298
+ const output = JSON.parse(jsonCall![0] as string);
299
+ expect(output.status).toBe('refused');
300
+ expect(output.reason).toContain('already_succeeded');
301
+ expect(output.nextAction).toContain('--force');
302
+ expect(exitSpy).toHaveBeenCalledWith(1);
303
+
304
+ logSpy.mockRestore();
305
+ exitSpy.mockRestore();
306
+ });
307
+
308
+ it('RETRY-05: no mutation on failed validation — run not called when task not found', async () => {
309
+ mockGetTask.mockResolvedValue(null);
310
+
311
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
312
+
313
+ await handlePainRetry({
314
+ painId: 'nonexistent',
315
+ workspace: '/tmp/fake-workspace',
316
+ runtime: 'test-double',
317
+ json: true,
318
+ });
319
+
320
+ expect(mockRun).not.toHaveBeenCalled();
321
+ expect(mockGetCandidatesByTaskId).not.toHaveBeenCalled();
322
+ expect(mockIntake).not.toHaveBeenCalled();
323
+
324
+ exitSpy.mockRestore();
325
+ });
326
+
327
+ it('RETRY-05a: missing --runtime and no config — refused with reason + nextAction (JSON)', async () => {
328
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
329
+ // Make resolveRuntimeConfig return an error so no runtime is resolved from config
330
+ mockResolveRuntimeConfig.mockReturnValueOnce({
331
+ reason: 'config_not_found',
332
+ message: 'No workflows.yaml found',
333
+ nextAction: 'Create workflows.yaml or pass --runtime',
334
+ });
335
+
336
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
337
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
338
+
339
+ await handlePainRetry({
340
+ painId: 'test-pain-1',
341
+ workspace: '/tmp/fake-workspace',
342
+ // No runtime specified
343
+ json: true,
344
+ });
345
+
346
+ const jsonCall = logSpy.mock.calls.find((call) => {
347
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
348
+ });
349
+ expect(jsonCall).toBeDefined();
350
+ const output = JSON.parse(jsonCall![0] as string);
351
+ expect(output.status).toBe('refused');
352
+ expect(output.reason).toContain('missing_runtime');
353
+ expect(output.nextAction).toContain('--runtime');
354
+ expect(exitSpy).toHaveBeenCalledWith(1);
355
+
356
+ logSpy.mockRestore();
357
+ exitSpy.mockRestore();
358
+ });
359
+
360
+ it('RETRY-05b: conflicting flags --openclaw-local + --openclaw-gateway — JSON output', async () => {
361
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
362
+
363
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
364
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
365
+
366
+ await handlePainRetry({
367
+ painId: 'test-pain-1',
368
+ workspace: '/tmp/fake-workspace',
369
+ runtime: 'openclaw-cli',
370
+ openclawLocal: true,
371
+ openclawGateway: true,
372
+ json: true,
373
+ });
374
+
375
+ const jsonCall = logSpy.mock.calls.find((call) => {
376
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
377
+ });
378
+ expect(jsonCall).toBeDefined();
379
+ const output = JSON.parse(jsonCall![0] as string);
380
+ expect(output.status).toBe('refused');
381
+ expect(output.reason).toContain('conflicting_flags');
382
+ expect(output.nextAction).toBeDefined();
383
+ expect(exitSpy).toHaveBeenCalledWith(1);
384
+
385
+ logSpy.mockRestore();
386
+ exitSpy.mockRestore();
387
+ });
388
+
389
+ it('RETRY-05c: blank provider/model/apiKeyEnv — refused with missing_required_config', async () => {
390
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
391
+ // Config returns blank strings for provider/model/apiKeyEnv
392
+ mockResolveRuntimeConfig.mockReturnValueOnce({
393
+ runtimeKind: 'pi-ai',
394
+ provider: '',
395
+ model: ' ',
396
+ apiKeyEnv: '',
397
+ timeoutMs: 300000,
398
+ agentId: 'main',
399
+ });
400
+
401
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
402
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
403
+
404
+ await handlePainRetry({
405
+ painId: 'test-pain-1',
406
+ workspace: '/tmp/fake-workspace',
407
+ runtime: 'pi-ai',
408
+ json: true,
409
+ });
410
+
411
+ const jsonCall = logSpy.mock.calls.find((call) => {
412
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
413
+ });
414
+ expect(jsonCall).toBeDefined();
415
+ const output = JSON.parse(jsonCall![0] as string);
416
+ expect(output.status).toBe('refused');
417
+ expect(output.reason).toContain('missing_required_config');
418
+ expect(output.reason).toContain('provider');
419
+ expect(output.reason).toContain('model');
420
+ expect(output.reason).toContain('apiKeyEnv');
421
+ expect(output.nextAction).toBeDefined();
422
+ expect(exitSpy).toHaveBeenCalledWith(1);
423
+
424
+ logSpy.mockRestore();
425
+ exitSpy.mockRestore();
426
+ });
427
+ });
428
+
429
+ describe('pd pain retry — success paths', () => {
430
+ beforeEach(() => {
431
+ vi.clearAllMocks();
432
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
433
+ mockUpdateCandidateStatus.mockResolvedValue(undefined);
434
+ mockGetRunsByTask.mockResolvedValue([]);
435
+ mockIntake.mockReset();
436
+ mockRun.mockResolvedValue({
437
+ status: 'succeeded',
438
+ taskId: 'diagnosis_test-pain-1',
439
+ runId: 'run-retry-1',
440
+ contextHash: 'abc123',
441
+ });
442
+ });
443
+
444
+ it('RETRY-06: retry_wait + last_error → succeeded — JSON output with previousTaskStatus and previousLastError', async () => {
445
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
446
+ mockGetCandidatesByTaskId.mockResolvedValue([
447
+ { candidateId: 'cand-1', artifactId: 'art-1', taskId: 'diagnosis_test-pain-1', status: 'pending' },
448
+ ]);
449
+ mockIntake.mockResolvedValue({ id: 'ledger-1', title: 'Principle 1', status: 'probation' });
450
+
451
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
452
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
453
+
454
+ await handlePainRetry({
455
+ painId: 'test-pain-1',
456
+ workspace: '/tmp/fake-workspace',
457
+ runtime: 'test-double',
458
+ json: true,
459
+ });
460
+
461
+ expect(logSpy).toHaveBeenCalledTimes(1);
462
+ const output = JSON.parse(logSpy.mock.calls[0][0]);
463
+ expect(output.status).toBe('succeeded');
464
+ expect(output.painId).toBe('test-pain-1');
465
+ expect(output.taskId).toBe('diagnosis_test-pain-1');
466
+ expect(output.previousTaskStatus).toBe('retry_wait');
467
+ expect(output.previousLastError).toBe('output_invalid');
468
+ expect(output.newTaskStatus).toBe('succeeded');
469
+ expect(output.candidateIds).toContain('cand-1');
470
+ expect(output.nextAction).toContain('pd candidate internalize');
471
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
472
+
473
+ logSpy.mockRestore();
474
+ exitSpy.mockRestore();
475
+ });
476
+
477
+ it('RETRY-07: failed task → succeeded — JSON output', async () => {
478
+ mockGetTask.mockResolvedValue(FAILED_TASK);
479
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
480
+
481
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
482
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
483
+
484
+ await handlePainRetry({
485
+ painId: 'test-pain-failed',
486
+ workspace: '/tmp/fake-workspace',
487
+ runtime: 'test-double',
488
+ json: true,
489
+ });
490
+
491
+ expect(logSpy).toHaveBeenCalledTimes(1);
492
+ const output = JSON.parse(logSpy.mock.calls[0][0]);
493
+ expect(output.status).toBe('succeeded');
494
+ expect(output.previousTaskStatus).toBe('failed');
495
+ expect(output.previousLastError).toBe('timeout');
496
+ expect(output.nextAction).toContain('No candidates');
497
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
498
+
499
+ logSpy.mockRestore();
500
+ exitSpy.mockRestore();
501
+ });
502
+
503
+ it('RETRY-08: --force allows retry of succeeded task', async () => {
504
+ mockGetTask.mockResolvedValue(SUCCEEDED_TASK);
505
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
506
+
507
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
508
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
509
+
510
+ await handlePainRetry({
511
+ painId: 'test-pain-succeeded',
512
+ workspace: '/tmp/fake-workspace',
513
+ runtime: 'test-double',
514
+ json: true,
515
+ force: true,
516
+ });
517
+
518
+ expect(logSpy).toHaveBeenCalledTimes(1);
519
+ const output = JSON.parse(logSpy.mock.calls[0][0]);
520
+ expect(output.status).toBe('succeeded');
521
+ expect(output.previousTaskStatus).toBe('succeeded');
522
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
523
+
524
+ logSpy.mockRestore();
525
+ exitSpy.mockRestore();
526
+ });
527
+
528
+ it('RETRY-09: --json outputs exactly one parseable JSON object', async () => {
529
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
530
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
531
+
532
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
533
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
534
+
535
+ await handlePainRetry({
536
+ painId: 'test-pain-1',
537
+ workspace: '/tmp/fake-workspace',
538
+ runtime: 'test-double',
539
+ json: true,
540
+ });
541
+
542
+ expect(logSpy).toHaveBeenCalledTimes(1);
543
+ const rawOutput = logSpy.mock.calls[0][0] as string;
544
+ const parsed = JSON.parse(rawOutput);
545
+ expect(parsed.status).toBe('succeeded');
546
+ expect(parsed.painId).toBe('test-pain-1');
547
+ expect(parsed.nextAction).toBeDefined();
548
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
549
+
550
+ logSpy.mockRestore();
551
+ exitSpy.mockRestore();
552
+ });
553
+
554
+ it('RETRY-10: nextAction mentions internalization is NOT automatic', async () => {
555
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
556
+ mockGetCandidatesByTaskId.mockResolvedValue([
557
+ { candidateId: 'cand-1', artifactId: 'art-1', taskId: 'diagnosis_test-pain-1', status: 'pending' },
558
+ ]);
559
+ mockIntake.mockResolvedValue({ id: 'ledger-1', title: 'Principle 1', status: 'probation' });
560
+
561
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
562
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
563
+
564
+ await handlePainRetry({
565
+ painId: 'test-pain-1',
566
+ workspace: '/tmp/fake-workspace',
567
+ runtime: 'test-double',
568
+ json: true,
569
+ });
570
+
571
+ const output = JSON.parse(logSpy.mock.calls[0][0]);
572
+ expect(output.nextAction).toContain('NOT started automatically');
573
+ expect(output.nextAction).toContain('pd candidate internalize --candidate-id cand-1');
574
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
575
+
576
+ logSpy.mockRestore();
577
+ exitSpy.mockRestore();
578
+ });
579
+
580
+ it('RETRY-11: failed retry — JSON output with errorCategory + nextAction', async () => {
581
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
582
+ mockRun.mockResolvedValueOnce({
583
+ status: 'failed',
584
+ taskId: 'diagnosis_test-pain-1',
585
+ errorCategory: 'output_invalid',
586
+ failureReason: 'LLM output failed validation',
587
+ attemptCount: 2,
588
+ });
589
+
590
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
591
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
592
+
593
+ await handlePainRetry({
594
+ painId: 'test-pain-1',
595
+ workspace: '/tmp/fake-workspace',
596
+ runtime: 'test-double',
597
+ json: true,
598
+ });
599
+
600
+ expect(logSpy).toHaveBeenCalledTimes(1);
601
+ const output = JSON.parse(logSpy.mock.calls[0][0]);
602
+ expect(output.status).toBe('failed');
603
+ expect(output.errorCategory).toBe('output_invalid');
604
+ expect(output.nextAction).toBeDefined();
605
+ expect(exitSpy).toHaveBeenCalledWith(1);
606
+
607
+ logSpy.mockRestore();
608
+ exitSpy.mockRestore();
609
+ });
610
+
611
+ it('RETRY-12: intake failure — JSON output with intake_failed + nextAction', async () => {
612
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
613
+ mockGetCandidatesByTaskId.mockResolvedValue([
614
+ { candidateId: 'cand-fail', artifactId: 'art-1', taskId: 'diagnosis_test-pain-1', status: 'pending' },
615
+ ]);
616
+ mockIntake.mockImplementation(() => { throw new Error('Ledger write failed'); });
617
+
618
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
619
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
620
+
621
+ await handlePainRetry({
622
+ painId: 'test-pain-1',
623
+ workspace: '/tmp/fake-workspace',
624
+ runtime: 'test-double',
625
+ json: true,
626
+ });
627
+
628
+ const output = JSON.parse(logSpy.mock.calls[0][0]);
629
+ expect(output.intake.candidates[0].status).toBe('intake_failed');
630
+ expect(output.intake.candidates[0].nextAction).toContain('pd candidate intake');
631
+ expect(exitSpy).toHaveBeenCalledWith(1);
632
+
633
+ logSpy.mockRestore();
634
+ exitSpy.mockRestore();
635
+ });
636
+ });
637
+
638
+ describe('pd pain retry — human-readable output', () => {
639
+ beforeEach(() => {
640
+ vi.clearAllMocks();
641
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
642
+ mockUpdateCandidateStatus.mockResolvedValue(undefined);
643
+ mockGetRunsByTask.mockResolvedValue([]);
644
+ mockIntake.mockReset();
645
+ mockRun.mockResolvedValue({
646
+ status: 'succeeded',
647
+ taskId: 'diagnosis_test-pain-1',
648
+ runId: 'run-retry-1',
649
+ contextHash: 'abc123',
650
+ });
651
+ });
652
+
653
+ it('RETRY-13: not found — human-readable error with nextAction', async () => {
654
+ mockGetTask.mockResolvedValue(null);
655
+
656
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
657
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
658
+
659
+ await handlePainRetry({
660
+ painId: 'nonexistent',
661
+ workspace: '/tmp/fake-workspace',
662
+ runtime: 'test-double',
663
+ json: false,
664
+ });
665
+
666
+ const allErrors = errorSpy.mock.calls.map(call => call[0]).join('\n');
667
+ expect(allErrors).toContain('nonexistent');
668
+ expect(allErrors).toContain('nextAction');
669
+ expect(exitSpy).toHaveBeenCalledWith(1);
670
+
671
+ errorSpy.mockRestore();
672
+ exitSpy.mockRestore();
673
+ });
674
+
675
+ it('RETRY-14: success — human-readable output shows previous status and nextAction', async () => {
676
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
677
+ mockGetCandidatesByTaskId.mockResolvedValue([
678
+ { candidateId: 'cand-1', artifactId: 'art-1', taskId: 'diagnosis_test-pain-1', status: 'pending' },
679
+ ]);
680
+ mockIntake.mockResolvedValue({ id: 'ledger-1', title: 'Principle 1', status: 'probation' });
681
+
682
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
683
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
684
+
685
+ await handlePainRetry({
686
+ painId: 'test-pain-1',
687
+ workspace: '/tmp/fake-workspace',
688
+ runtime: 'test-double',
689
+ json: false,
690
+ });
691
+
692
+ const allOutput = logSpy.mock.calls.map(call => call[0]).join('\n');
693
+ expect(allOutput).toContain('retry_wait');
694
+ expect(allOutput).toContain('output_invalid');
695
+ expect(allOutput).toContain('succeeded');
696
+ expect(allOutput).toContain('pd candidate internalize');
697
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
698
+
699
+ logSpy.mockRestore();
700
+ exitSpy.mockRestore();
701
+ });
702
+ });
703
+
704
+ describe('Commander wiring for pd pain retry', () => {
705
+ function createPainRetryProgram(): { program: Command; capturedOpts: Record<string, unknown> } {
706
+ const program = new Command();
707
+ program.exitOverride();
708
+ const capturedOpts: Record<string, unknown> = {};
709
+
710
+ program
711
+ .command('pain')
712
+ .command('retry')
713
+ .requiredOption('-p, --pain-id <painId>', 'Pain ID')
714
+ .option('-w, --workspace <path>', 'Workspace directory')
715
+ .option('-r, --runtime <kind>', 'Runtime kind')
716
+ .option('--force', 'Force retry of succeeded task')
717
+ .option('--json', 'Output raw JSON')
718
+ .action(async (opts) => {
719
+ Object.assign(capturedOpts, opts);
720
+ });
721
+
722
+ return { program, capturedOpts };
723
+ }
724
+
725
+ it('CMD-01: --pain-id is required, missing → error', async () => {
726
+ const { program } = createPainRetryProgram();
727
+ await expect(
728
+ program.parseAsync(['node', 'pd', 'pain', 'retry', '--json'])
729
+ ).rejects.toThrow();
730
+ });
731
+
732
+ it('CMD-02: --pain-id sets opts.painId', async () => {
733
+ const { program, capturedOpts } = createPainRetryProgram();
734
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc123']);
735
+ expect(capturedOpts.painId).toBe('abc123');
736
+ });
737
+
738
+ it('CMD-03: -p short form sets opts.painId', async () => {
739
+ const { program, capturedOpts } = createPainRetryProgram();
740
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '-p', 'abc123']);
741
+ expect(capturedOpts.painId).toBe('abc123');
742
+ });
743
+
744
+ it('CMD-04: --force sets opts.force === true', async () => {
745
+ const { program, capturedOpts } = createPainRetryProgram();
746
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--force']);
747
+ expect(capturedOpts.force).toBe(true);
748
+ });
749
+
750
+ it('CMD-05: default (no --force) → opts.force === undefined', async () => {
751
+ const { program, capturedOpts } = createPainRetryProgram();
752
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc']);
753
+ expect(capturedOpts.force).toBeUndefined();
754
+ });
755
+
756
+ it('CMD-06: --json sets opts.json === true', async () => {
757
+ const { program, capturedOpts } = createPainRetryProgram();
758
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--json']);
759
+ expect(capturedOpts.json).toBe(true);
760
+ });
761
+
762
+ it('CMD-07: --runtime sets opts.runtime', async () => {
763
+ const { program, capturedOpts } = createPainRetryProgram();
764
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--runtime', 'pi-ai']);
765
+ expect(capturedOpts.runtime).toBe('pi-ai');
766
+ });
767
+ });