@principles/pd-cli 1.98.0 → 1.100.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 (31) hide show
  1. package/dist/commands/diagnose.d.ts.map +1 -1
  2. package/dist/commands/diagnose.js +4 -3
  3. package/dist/commands/diagnose.js.map +1 -1
  4. package/dist/commands/pain-retry.d.ts.map +1 -1
  5. package/dist/commands/pain-retry.js +4 -3
  6. package/dist/commands/pain-retry.js.map +1 -1
  7. package/dist/commands/runtime-internalization-integrity.d.ts.map +1 -1
  8. package/dist/commands/runtime-internalization-integrity.js +2 -0
  9. package/dist/commands/runtime-internalization-integrity.js.map +1 -1
  10. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  11. package/dist/commands/runtime-internalization-run-once.js +58 -39
  12. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  13. package/dist/commands/runtime-recovery-failed-tasks.d.ts +10 -0
  14. package/dist/commands/runtime-recovery-failed-tasks.d.ts.map +1 -0
  15. package/dist/commands/runtime-recovery-failed-tasks.js +164 -0
  16. package/dist/commands/runtime-recovery-failed-tasks.js.map +1 -0
  17. package/dist/commands/task.d.ts.map +1 -1
  18. package/dist/commands/task.js +85 -4
  19. package/dist/commands/task.js.map +1 -1
  20. package/dist/index.js +18 -0
  21. package/dist/index.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/commands/diagnose.ts +4 -3
  24. package/src/commands/pain-retry.ts +4 -3
  25. package/src/commands/runtime-internalization-integrity.ts +1 -0
  26. package/src/commands/runtime-internalization-run-once.ts +70 -54
  27. package/src/commands/runtime-recovery-failed-tasks.ts +181 -0
  28. package/src/commands/task.ts +80 -4
  29. package/src/index.ts +19 -0
  30. package/tests/commands/runtime-recovery-failed-tasks.test.ts +201 -0
  31. package/tests/commands/task.test.ts +145 -0
@@ -575,60 +575,76 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
575
575
  const artifactStore = stateManager.piArtifactStore;
576
576
  const runtimeAdapter = resolveRuntimeAdapter({ runtimeKind, taskId: wakeResult.taskId, workspaceDir, runnerKind, timeoutMs: cliTimeoutMs });
577
577
 
578
- if (runnerKind === 'dreamer') {
579
- const validator = new DefaultDreamerValidator();
580
- const runner = new DreamerRunner(
581
- { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
582
- { owner: OWNER, runtimeKind: RUNTIME_KIND, pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
583
- );
584
- runnerResult = await runner.run(wakeResult.taskId);
585
- } else if (runnerKind === 'philosopher') {
586
- const validator = new DefaultPhilosopherValidator();
587
- const runner = new PhilosopherRunner(
588
- { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
589
- { owner: OWNER, runtimeKind: RUNTIME_KIND, pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
590
- );
591
- runnerResult = await runner.run(wakeResult.taskId);
592
- } else if (runnerKind === 'scribe') {
593
- // PRI-336: Read outputLanguage from workspace config
594
- const outputLangResult = readOutputLanguageFromWorkspace(workspaceDir);
595
- const outputLanguage: OutputLanguage | undefined = outputLangResult.outputLanguage;
596
- const validator = new DefaultScribeValidator();
597
- const runner = new ScribeRunner(
598
- { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
599
- { owner: OWNER, runtimeKind: RUNTIME_KIND, pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs, outputLanguage },
600
- );
601
- runnerResult = await runner.run(wakeResult.taskId);
602
- } else if (runnerKind === 'artificer') {
603
- const validator = new DefaultArtificerValidator();
604
- const runner = new ArtificerRunner(
605
- { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
606
- { owner: OWNER, runtimeKind: RUNTIME_KIND, pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
607
- );
608
- runnerResult = await runner.run(wakeResult.taskId);
609
- } else if (runnerKind === 'evaluator') {
610
- const validator = new DefaultEvaluatorValidator();
611
- const runner = new EvaluatorRunner(
612
- { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
613
- { owner: OWNER, runtimeKind: RUNTIME_KIND, pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
614
- );
615
- runnerResult = await runner.run(wakeResult.taskId);
616
- } else if (runnerKind === 'rollout_reviewer') {
617
- const validator = new DefaultRolloutReviewerValidator();
618
- const runner = new RolloutReviewerRunner(
619
- { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
620
- { owner: OWNER, runtimeKind: RUNTIME_KIND, pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
621
- );
622
- runnerResult = await runner.run(wakeResult.taskId);
623
- } else if (runnerKind === 'trainer') {
624
- const validator = new DefaultTrainerValidator();
625
- const runner = new TrainerRunner(
626
- { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
627
- { owner: OWNER, runtimeKind: RUNTIME_KIND, pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
628
- );
629
- runnerResult = await runner.run(wakeResult.taskId);
630
- } else {
631
- skipReason = 'no_runner_implemented';
578
+ try {
579
+ if (runnerKind === 'dreamer') {
580
+ const validator = new DefaultDreamerValidator();
581
+ const runner = new DreamerRunner(
582
+ { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
583
+ { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
584
+ );
585
+ runnerResult = await runner.run(wakeResult.taskId);
586
+ } else if (runnerKind === 'philosopher') {
587
+ const validator = new DefaultPhilosopherValidator();
588
+ const runner = new PhilosopherRunner(
589
+ { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
590
+ { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
591
+ );
592
+ runnerResult = await runner.run(wakeResult.taskId);
593
+ } else if (runnerKind === 'scribe') {
594
+ // PRI-336: Read outputLanguage from workspace config
595
+ const outputLangResult = readOutputLanguageFromWorkspace(workspaceDir);
596
+ const outputLanguage: OutputLanguage | undefined = outputLangResult.outputLanguage;
597
+ const validator = new DefaultScribeValidator();
598
+ const runner = new ScribeRunner(
599
+ { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
600
+ { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs, outputLanguage },
601
+ );
602
+ runnerResult = await runner.run(wakeResult.taskId);
603
+ } else if (runnerKind === 'artificer') {
604
+ const validator = new DefaultArtificerValidator();
605
+ const runner = new ArtificerRunner(
606
+ { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
607
+ { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
608
+ );
609
+ runnerResult = await runner.run(wakeResult.taskId);
610
+ } else if (runnerKind === 'evaluator') {
611
+ const validator = new DefaultEvaluatorValidator();
612
+ const runner = new EvaluatorRunner(
613
+ { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
614
+ { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
615
+ );
616
+ runnerResult = await runner.run(wakeResult.taskId);
617
+ } else if (runnerKind === 'rollout_reviewer') {
618
+ const validator = new DefaultRolloutReviewerValidator();
619
+ const runner = new RolloutReviewerRunner(
620
+ { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
621
+ { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
622
+ );
623
+ runnerResult = await runner.run(wakeResult.taskId);
624
+ } else if (runnerKind === 'trainer') {
625
+ const validator = new DefaultTrainerValidator();
626
+ const runner = new TrainerRunner(
627
+ { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore },
628
+ { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
629
+ );
630
+ runnerResult = await runner.run(wakeResult.taskId);
631
+ } else {
632
+ skipReason = 'no_runner_implemented';
633
+ }
634
+ } catch (runErr) {
635
+ console.error(`Error: runner crashed: ${runErr instanceof Error ? runErr.message : String(runErr)}`);
636
+ try {
637
+ const task = await stateManager.getTask(wakeResult.taskId);
638
+ const failureReason = `Unhandled runner crash: ${runErr instanceof Error ? runErr.message : String(runErr)}`;
639
+ if (task && stateManager.getRetryPolicy().shouldRetry(task)) {
640
+ await stateManager.markTaskRetryWait(wakeResult.taskId, 'execution_failed', failureReason);
641
+ } else {
642
+ await stateManager.markTaskFailed(wakeResult.taskId, 'execution_failed', failureReason);
643
+ }
644
+ } catch (dbErr) {
645
+ console.error(`Error: failed to update task state in DB: ${String(dbErr)}`);
646
+ }
647
+ throw runErr;
632
648
  }
633
649
  }
634
650
 
@@ -0,0 +1,181 @@
1
+ import * as path from 'path';
2
+ import { createRecoverySweepService, type RecoverySweepServiceHandle } from '@principles/core/runtime-v2';
3
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
4
+
5
+ interface RecoveryFailedTasksOptions {
6
+ workspace?: string;
7
+ dryRun?: boolean;
8
+ confirm?: boolean;
9
+ force?: boolean;
10
+ json?: boolean;
11
+ }
12
+
13
+ interface TaskDetail {
14
+ taskId: string;
15
+ taskKind: string;
16
+ status: string;
17
+ attemptCount: number;
18
+ maxAttempts: number;
19
+ action: string;
20
+ reason: string;
21
+ nextAction: string;
22
+ }
23
+
24
+ export async function handleRuntimeRecoveryFailedTasks(opts: RecoveryFailedTasksOptions): Promise<void> {
25
+ if (opts.dryRun && opts.confirm) {
26
+ if (opts.json) {
27
+ console.log(JSON.stringify({
28
+ ok: false,
29
+ reason: 'Error: --dry-run and --confirm are mutually exclusive',
30
+ nextAction: 'Specify only one of --dry-run or --confirm',
31
+ }, null, 2));
32
+ } else {
33
+ console.error('Error: --dry-run and --confirm are mutually exclusive');
34
+ }
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+ const isConfirm = opts.confirm ?? false;
39
+ const isDryRun = !isConfirm;
40
+
41
+ let serviceHandle: RecoverySweepServiceHandle | null = null;
42
+
43
+ try {
44
+ const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : resolveWorkspaceDir();
45
+ const handle = await createRecoverySweepService({ workspaceDir });
46
+ serviceHandle = handle;
47
+
48
+ const failedTasks = await handle.service.detectFailedTasks();
49
+ const taskDetails: TaskDetail[] = [];
50
+ let recoveredCount = 0;
51
+ let skippedCount = 0;
52
+
53
+ for (const t of failedTasks) {
54
+ if (t.isExhausted && !opts.force) {
55
+ taskDetails.push({
56
+ taskId: t.taskId,
57
+ taskKind: t.taskKind,
58
+ status: t.status,
59
+ attemptCount: t.attemptCount,
60
+ maxAttempts: t.maxAttempts,
61
+ action: 'skipped',
62
+ reason: `Task has exhausted max attempts (${t.attemptCount}/${t.maxAttempts})`,
63
+ nextAction: 'Run with --force to recover this task',
64
+ });
65
+ skippedCount++;
66
+ } else {
67
+ if (isConfirm) {
68
+ const result = await handle.service.recoverFailedTask(t.taskId, opts.force);
69
+ if (result) {
70
+ recoveredCount++;
71
+ taskDetails.push({
72
+ taskId: t.taskId,
73
+ taskKind: t.taskKind,
74
+ status: t.status,
75
+ attemptCount: t.attemptCount,
76
+ maxAttempts: t.maxAttempts,
77
+ action: 'recovered',
78
+ reason: t.isExhausted
79
+ ? `Task exhausted max attempts (${t.attemptCount}/${t.maxAttempts}) but --force specified — reset to pending`
80
+ : `Task failed and attempts remain (${t.attemptCount}/${t.maxAttempts}) — reset to pending`,
81
+ nextAction: 'Task recovered to pending. Run pd runtime internalization run-once to execute.',
82
+ });
83
+ } else {
84
+ skippedCount++;
85
+ taskDetails.push({
86
+ taskId: t.taskId,
87
+ taskKind: t.taskKind,
88
+ status: t.status,
89
+ attemptCount: t.attemptCount,
90
+ maxAttempts: t.maxAttempts,
91
+ action: 'skipped',
92
+ reason: 'Task recovery skipped (task no longer failed or concurrently modified)',
93
+ nextAction: 'Verify task status using task list',
94
+ });
95
+ }
96
+ } else {
97
+ recoveredCount++;
98
+ taskDetails.push({
99
+ taskId: t.taskId,
100
+ taskKind: t.taskKind,
101
+ status: t.status,
102
+ attemptCount: t.attemptCount,
103
+ maxAttempts: t.maxAttempts,
104
+ action: 'would_recover',
105
+ reason: t.isExhausted
106
+ ? `Task exhausted max attempts (${t.attemptCount}/${t.maxAttempts}) but --force specified — reset to pending`
107
+ : `Task failed and attempts remain (${t.attemptCount}/${t.maxAttempts}) — reset to pending`,
108
+ nextAction: 'Run with --confirm to recover this task',
109
+ });
110
+ }
111
+ }
112
+ }
113
+
114
+ const mode = isDryRun ? 'dry_run' : 'confirm';
115
+ let summaryReason = '';
116
+ let summaryNextAction = '';
117
+
118
+ if (isDryRun) {
119
+ if (taskDetails.length === 0) {
120
+ summaryReason = 'No failed internalization tasks found';
121
+ summaryNextAction = 'Nothing to recover';
122
+ } else {
123
+ summaryReason = `Found ${recoveredCount} recoverable and ${skippedCount} exhausted failed tasks`;
124
+ summaryNextAction = `Run with --confirm to recover tasks, and use --force to recover exhausted tasks`;
125
+ }
126
+ } else {
127
+ summaryReason = `Successfully recovered ${recoveredCount} failed tasks, skipped ${skippedCount} tasks`;
128
+ summaryNextAction = recoveredCount > 0
129
+ ? 'Run pd runtime internalization run-once to execute recovered tasks'
130
+ : 'No tasks recovered';
131
+ }
132
+
133
+ if (opts.json) {
134
+ console.log(JSON.stringify({
135
+ ok: true,
136
+ mode,
137
+ recoveredCount,
138
+ skippedCount,
139
+ tasks: taskDetails,
140
+ reason: summaryReason,
141
+ nextAction: summaryNextAction,
142
+ }, null, 2));
143
+ } else {
144
+ console.log(`Failed Tasks Recovery (${mode.toUpperCase()})`);
145
+ console.log(` reason: ${summaryReason}`);
146
+ console.log(` nextAction: ${summaryNextAction}`);
147
+ console.log(` recovered: ${recoveredCount}`);
148
+ console.log(` skipped: ${skippedCount}`);
149
+ console.log('');
150
+ if (taskDetails.length > 0) {
151
+ console.log('Tasks:');
152
+ for (const t of taskDetails) {
153
+ console.log(` - ${t.taskId} (${t.taskKind})`);
154
+ console.log(` action: ${t.action}`);
155
+ console.log(` reason: ${t.reason}`);
156
+ console.log(` nextAction: ${t.nextAction}`);
157
+ }
158
+ console.log('');
159
+ }
160
+ }
161
+
162
+ if (recoveredCount > 0 && isDryRun) {
163
+ process.exitCode = 1;
164
+ }
165
+ } catch (err: unknown) {
166
+ if (opts.json) {
167
+ console.log(JSON.stringify({
168
+ ok: false,
169
+ reason: err instanceof Error ? err.message : String(err),
170
+ nextAction: 'Check workspace path and DB connectivity',
171
+ }, null, 2));
172
+ } else {
173
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
174
+ }
175
+ process.exitCode = 1;
176
+ } finally {
177
+ if (serviceHandle) {
178
+ await serviceHandle.close();
179
+ }
180
+ }
181
+ }
@@ -6,7 +6,7 @@
6
6
  * pd task show <taskId>
7
7
  */
8
8
  import * as path from 'path';
9
- import { RuntimeStateManager } from '@principles/core';
9
+ import { RuntimeStateManager, MalformedRunError, type RunRecord } from '@principles/core';
10
10
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
11
11
 
12
12
  interface TaskListOptions {
@@ -77,14 +77,66 @@ export async function handleTaskShow(opts: TaskShowOptions): Promise<void> {
77
77
  const task = await stateManager.getTask(opts.id);
78
78
 
79
79
  if (!task) {
80
- console.error(`Task not found: ${opts.id}`);
80
+ if (opts.json) {
81
+ console.log(JSON.stringify({
82
+ ok: false,
83
+ reason: `Task not found: ${opts.id}`,
84
+ nextAction: 'Specify a valid taskId',
85
+ }, null, 2));
86
+ } else {
87
+ console.error(`Task not found: ${opts.id}`);
88
+ }
81
89
  process.exit(1);
90
+ return;
82
91
  }
83
92
 
84
- const runs = await stateManager.getRunsByTask(opts.id);
93
+ let runs: RunRecord[] = [];
94
+ let degradedRuns: { runId: string; error: string; rawRow: Record<string, unknown> }[] = [];
95
+ let isDegraded = false;
96
+ let malformedError: MalformedRunError | null = null;
97
+
98
+ try {
99
+ runs = await stateManager.getRunsByTask(opts.id);
100
+ } catch (err: unknown) {
101
+ if (err instanceof MalformedRunError) {
102
+ const { validRuns, degradedRuns: malformedRuns } = err;
103
+ runs = validRuns;
104
+ degradedRuns = malformedRuns;
105
+ isDegraded = true;
106
+ malformedError = err;
107
+ } else {
108
+ const errorMsg = err instanceof Error ? err.message : String(err);
109
+ if (opts.json) {
110
+ console.log(JSON.stringify({
111
+ ok: false,
112
+ reason: errorMsg,
113
+ nextAction: 'Check state.db connection and schema integrity',
114
+ }, null, 2));
115
+ } else {
116
+ console.error(`Error: ${errorMsg}`);
117
+ }
118
+ process.exit(1);
119
+ return;
120
+ }
121
+ }
85
122
 
86
123
  if (opts.json) {
87
- console.log(JSON.stringify({ task, runs }, null, 2));
124
+ if (isDegraded) {
125
+ console.log(JSON.stringify({
126
+ ok: false,
127
+ task,
128
+ runs,
129
+ degradedRuns: degradedRuns.map(dr => ({
130
+ runId: dr.runId,
131
+ error: dr.error,
132
+ })),
133
+ reason: malformedError?.message ?? 'Unknown malformed run schema',
134
+ nextAction: 'Use integrity-repair to clean up or recover malformed runs, or fix runs in DB',
135
+ }, null, 2));
136
+ process.exitCode = 1;
137
+ } else {
138
+ console.log(JSON.stringify({ task, runs }, null, 2));
139
+ }
88
140
  return;
89
141
  }
90
142
 
@@ -124,6 +176,30 @@ export async function handleTaskShow(opts: TaskShowOptions): Promise<void> {
124
176
  }
125
177
  console.log('');
126
178
  }
179
+
180
+ if (isDegraded && degradedRuns.length > 0) {
181
+ console.warn(`WARNING: Task has ${degradedRuns.length} malformed run(s) in database!`);
182
+ console.warn(`Reason: ${malformedError?.message ?? 'Unknown malformed run schema'}`);
183
+ console.warn(`nextAction: Use integrity-repair to clean up or recover malformed runs, or fix runs in DB\n`);
184
+ console.log('Degraded Runs:');
185
+ for (const dr of degradedRuns) {
186
+ console.log(` - Run: ${dr.runId}`);
187
+ console.log(` Error: ${dr.error}`);
188
+ }
189
+ console.log('');
190
+ process.exitCode = 1;
191
+ }
192
+ } catch (err: unknown) {
193
+ if (opts.json) {
194
+ console.log(JSON.stringify({
195
+ ok: false,
196
+ reason: err instanceof Error ? err.message : String(err),
197
+ nextAction: 'Check workspace path and task ID',
198
+ }, null, 2));
199
+ } else {
200
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
201
+ }
202
+ process.exitCode = 1;
127
203
  } finally {
128
204
  await stateManager.close();
129
205
  }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ import { handleRuntimeInternalizationIntegrityRepair } from './commands/runtime-
44
44
  import { handleRuntimeInternalizationEnqueueSuccessors } from './commands/runtime-internalization-enqueue-successors.js';
45
45
  import { handleRuntimeDiagnosticsExport } from './commands/runtime-diagnostics-export.js';
46
46
  import { handleRuntimeRecoverySweep } from './commands/runtime-recovery.js';
47
+ import { handleRuntimeRecoveryFailedTasks } from './commands/runtime-recovery-failed-tasks.js';
47
48
  import { handleRuntimeActivationDispatch } from './commands/runtime-activation.js';
48
49
  import { handleProvenChannelBaseline } from './commands/proven-channel-baseline.js';
49
50
  import { handleDemoStoryA } from './commands/demo-story-a.js';
@@ -645,6 +646,24 @@ recoveryCmd
645
646
  await handleRuntimeRecoverySweep({ workspace: opts.workspace, dryRun: opts.dryRun, confirm: opts.confirm, json: opts.json });
646
647
  });
647
648
 
649
+ recoveryCmd
650
+ .command('failed-tasks')
651
+ .description('Recover failed internalization tasks')
652
+ .option('-w, --workspace <path>', 'Workspace directory')
653
+ .option('--dry-run', 'Report only, no modifications (default)')
654
+ .option('--confirm', 'Actually recover failed tasks')
655
+ .option('--force', 'Force recovery of tasks that exhausted max attempts')
656
+ .option('--json', 'Output raw JSON')
657
+ .action(async (opts) => {
658
+ await handleRuntimeRecoveryFailedTasks({
659
+ workspace: opts.workspace,
660
+ dryRun: opts.dryRun,
661
+ confirm: opts.confirm,
662
+ force: opts.force,
663
+ json: opts.json,
664
+ });
665
+ });
666
+
648
667
  diagnosticsCmd
649
668
  .command('export')
650
669
  .description('Export diagnostic bundle for AI assistant analysis')
@@ -0,0 +1,201 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mockDetectFailedTasks = vi.hoisted(() => vi.fn());
4
+ const mockRecoverFailedTask = vi.hoisted(() => vi.fn());
5
+ const mockServiceClose = vi.hoisted(() => vi.fn());
6
+
7
+ vi.mock('../../src/resolve-workspace.js', () => ({
8
+ resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
9
+ }));
10
+
11
+ vi.mock('@principles/core/runtime-v2', () => ({
12
+ createRecoverySweepService: vi.fn().mockResolvedValue({
13
+ service: {
14
+ detectFailedTasks: mockDetectFailedTasks,
15
+ recoverFailedTask: mockRecoverFailedTask,
16
+ },
17
+ close: mockServiceClose,
18
+ }),
19
+ }));
20
+
21
+ import { handleRuntimeRecoveryFailedTasks } from '../../src/commands/runtime-recovery-failed-tasks.js';
22
+
23
+ describe('pd runtime recovery failed-tasks command contract', () => {
24
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
25
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ mockDetectFailedTasks.mockResolvedValue([]);
30
+ mockRecoverFailedTask.mockResolvedValue({
31
+ taskId: 'task-1',
32
+ previousStatus: 'failed',
33
+ newStatus: 'pending',
34
+ attemptCount: 0,
35
+ maxAttempts: 3,
36
+ forceApplied: false,
37
+ });
38
+ mockServiceClose.mockResolvedValue(undefined);
39
+ process.exitCode = 0;
40
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
41
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
42
+ });
43
+
44
+ it('dry-run JSON does not recover tasks and lists them as would_recover', async () => {
45
+ mockDetectFailedTasks.mockResolvedValue([
46
+ {
47
+ taskId: 'task-1',
48
+ taskKind: 'dreamer',
49
+ attemptCount: 1,
50
+ maxAttempts: 3,
51
+ isExhausted: false,
52
+ status: 'failed',
53
+ },
54
+ ]);
55
+
56
+ await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', dryRun: true, json: true });
57
+
58
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
59
+ expect(output).toMatchObject({
60
+ ok: true,
61
+ mode: 'dry_run',
62
+ recoveredCount: 1,
63
+ skippedCount: 0,
64
+ });
65
+ expect(output.tasks[0]).toMatchObject({
66
+ taskId: 'task-1',
67
+ action: 'would_recover',
68
+ reason: expect.stringContaining('attempts remain'),
69
+ });
70
+ expect(mockRecoverFailedTask).not.toHaveBeenCalled();
71
+ expect(mockServiceClose).toHaveBeenCalledTimes(1);
72
+ expect(process.exitCode).toBe(1); // exitCode 1 on dry-run when tasks are found
73
+ });
74
+
75
+ it('confirm JSON reports recovered tasks and mutates state', async () => {
76
+ mockDetectFailedTasks.mockResolvedValue([
77
+ {
78
+ taskId: 'task-1',
79
+ taskKind: 'dreamer',
80
+ attemptCount: 1,
81
+ maxAttempts: 3,
82
+ isExhausted: false,
83
+ status: 'failed',
84
+ },
85
+ ]);
86
+
87
+ await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', confirm: true, json: true });
88
+
89
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
90
+ expect(output).toMatchObject({
91
+ ok: true,
92
+ mode: 'confirm',
93
+ recoveredCount: 1,
94
+ skippedCount: 0,
95
+ });
96
+ expect(output.tasks[0]).toMatchObject({
97
+ taskId: 'task-1',
98
+ action: 'recovered',
99
+ });
100
+ expect(mockRecoverFailedTask).toHaveBeenCalledWith('task-1', undefined);
101
+ expect(mockServiceClose).toHaveBeenCalledTimes(1);
102
+ expect(process.exitCode).toBe(0);
103
+ });
104
+
105
+ it('dry-run JSON skips exhausted tasks without force', async () => {
106
+ mockDetectFailedTasks.mockResolvedValue([
107
+ {
108
+ taskId: 'task-exhausted',
109
+ taskKind: 'dreamer',
110
+ attemptCount: 3,
111
+ maxAttempts: 3,
112
+ isExhausted: true,
113
+ status: 'failed',
114
+ },
115
+ ]);
116
+
117
+ await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', dryRun: true, json: true });
118
+
119
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
120
+ expect(output).toMatchObject({
121
+ ok: true,
122
+ mode: 'dry_run',
123
+ recoveredCount: 0,
124
+ skippedCount: 1,
125
+ });
126
+ expect(output.tasks[0]).toMatchObject({
127
+ taskId: 'task-exhausted',
128
+ action: 'skipped',
129
+ reason: expect.stringContaining('exhausted max attempts'),
130
+ });
131
+ expect(mockRecoverFailedTask).not.toHaveBeenCalled();
132
+ expect(process.exitCode).toBe(0); // exitCode 0 since recoveredCount is 0
133
+ });
134
+
135
+ it('confirm with force recovers exhausted tasks', async () => {
136
+ mockDetectFailedTasks.mockResolvedValue([
137
+ {
138
+ taskId: 'task-exhausted',
139
+ taskKind: 'dreamer',
140
+ attemptCount: 3,
141
+ maxAttempts: 3,
142
+ isExhausted: true,
143
+ status: 'failed',
144
+ },
145
+ ]);
146
+
147
+ await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', confirm: true, force: true, json: true });
148
+
149
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
150
+ expect(output).toMatchObject({
151
+ ok: true,
152
+ mode: 'confirm',
153
+ recoveredCount: 1,
154
+ skippedCount: 0,
155
+ });
156
+ expect(mockRecoverFailedTask).toHaveBeenCalledWith('task-exhausted', true);
157
+ });
158
+
159
+ it('rejects mutual exclusion of dry-run and confirm in JSON mode', async () => {
160
+ await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', dryRun: true, confirm: true, json: true });
161
+
162
+ expect(mockRecoverFailedTask).not.toHaveBeenCalled();
163
+ expect(mockServiceClose).not.toHaveBeenCalled();
164
+ expect(process.exitCode).toBe(1);
165
+
166
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
167
+ expect(output).toMatchObject({
168
+ ok: false,
169
+ reason: expect.stringContaining('mutually exclusive'),
170
+ });
171
+ });
172
+
173
+ it('confirm JSON handles concurrent task modification gracefully (null result)', async () => {
174
+ mockDetectFailedTasks.mockResolvedValue([
175
+ {
176
+ taskId: 'task-concurrent',
177
+ taskKind: 'dreamer',
178
+ attemptCount: 1,
179
+ maxAttempts: 3,
180
+ isExhausted: false,
181
+ status: 'failed',
182
+ },
183
+ ]);
184
+ mockRecoverFailedTask.mockResolvedValue(null);
185
+
186
+ await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', confirm: true, json: true });
187
+
188
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
189
+ expect(output).toMatchObject({
190
+ ok: true,
191
+ mode: 'confirm',
192
+ recoveredCount: 0,
193
+ skippedCount: 1,
194
+ });
195
+ expect(output.tasks[0]).toMatchObject({
196
+ taskId: 'task-concurrent',
197
+ action: 'skipped',
198
+ reason: expect.stringContaining('no longer failed or concurrently modified'),
199
+ });
200
+ });
201
+ });