@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.
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +13 -1
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/pain-retry.d.ts +19 -0
- package/dist/commands/pain-retry.d.ts.map +1 -0
- package/dist/commands/pain-retry.js +480 -0
- package/dist/commands/pain-retry.js.map +1 -0
- package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-once.js +54 -8
- package/dist/commands/runtime-internalization-run-once.js.map +1 -1
- package/dist/index.js +22 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/diagnose.ts +15 -1
- package/src/commands/pain-retry.ts +547 -0
- package/src/commands/runtime-internalization-run-once.ts +60 -9
- package/src/index.ts +23 -1
- package/tests/commands/pain-retry.test.ts +767 -0
- package/tests/commands/runtime-internalization-run-once.test.ts +287 -15
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd pain retry command — Retry a failed diagnosis by pain ID.
|
|
3
|
+
*
|
|
4
|
+
* Looks up the diagnostician task for a given painId, validates retry eligibility,
|
|
5
|
+
* then delegates to the existing diagnose run logic.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* pd pain retry --pain-id <painId> --workspace <path> --runtime <kind> [runtime flags] [--json] [--force]
|
|
9
|
+
*
|
|
10
|
+
* IMPORTANT: --runtime is required (no default). test-double would generate fake
|
|
11
|
+
* candidates/ledger in a real workspace, so it must be explicitly requested.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
RuntimeStateManager,
|
|
15
|
+
SqliteHistoryQuery,
|
|
16
|
+
SqliteContextAssembler,
|
|
17
|
+
SqliteDiagnosticianCommitter,
|
|
18
|
+
SqliteTrajectoryLocator,
|
|
19
|
+
SqliteSourceTraceLocator,
|
|
20
|
+
StoreEventEmitter,
|
|
21
|
+
DiagnosticianRunner,
|
|
22
|
+
DefaultDiagnosticianValidator,
|
|
23
|
+
TestDoubleRuntimeAdapter,
|
|
24
|
+
OpenClawCliRuntimeAdapter,
|
|
25
|
+
PiAiRuntimeAdapter,
|
|
26
|
+
PDRuntimeError,
|
|
27
|
+
resolveRuntimeConfig,
|
|
28
|
+
isRuntimeConfigError,
|
|
29
|
+
CandidateIntakeService,
|
|
30
|
+
run as diagnoseRun,
|
|
31
|
+
} from '@principles/core/runtime-v2';
|
|
32
|
+
import type { PDRuntimeAdapter, RuntimeConfig } from '@principles/core/runtime-v2';
|
|
33
|
+
import type { PDTaskStatus } from '@principles/core/runtime-v2';
|
|
34
|
+
import { PrincipleTreeLedgerAdapter } from '../principle-tree-ledger-adapter.js';
|
|
35
|
+
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
36
|
+
import * as path from 'path';
|
|
37
|
+
|
|
38
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
interface PainRetryOptions {
|
|
41
|
+
painId: string;
|
|
42
|
+
workspace?: string;
|
|
43
|
+
json?: boolean;
|
|
44
|
+
force?: boolean;
|
|
45
|
+
runtime?: string;
|
|
46
|
+
openclawLocal?: boolean;
|
|
47
|
+
openclawGateway?: boolean;
|
|
48
|
+
agent?: string;
|
|
49
|
+
provider?: string;
|
|
50
|
+
model?: string;
|
|
51
|
+
apiKeyEnv?: string;
|
|
52
|
+
baseUrl?: string;
|
|
53
|
+
maxRetries?: number;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Allowed task statuses for retry without --force. */
|
|
58
|
+
const RETRYABLE_STATUSES: ReadonlySet<PDTaskStatus> = new Set([
|
|
59
|
+
'retry_wait',
|
|
60
|
+
'failed',
|
|
61
|
+
'needs_human_review',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a painId to a diagnostician taskId.
|
|
66
|
+
*
|
|
67
|
+
* Convention: task_id = `diagnosis_<painId>` (see PainToPrincipleService).
|
|
68
|
+
* If the painId already has the `diagnosis_` prefix, reject to avoid double-prefixing.
|
|
69
|
+
*/
|
|
70
|
+
function resolveTaskIdFromPainId(painId: string): { taskId: string } | { reason: string; nextAction: string } {
|
|
71
|
+
if (painId.startsWith('diagnosis_')) {
|
|
72
|
+
return {
|
|
73
|
+
reason: `painId '${painId}' already has 'diagnosis_' prefix — this looks like a taskId, not a painId`,
|
|
74
|
+
nextAction: `Use the raw painId (without 'diagnosis_' prefix), or use 'pd diagnose run --task-id ${painId}' directly`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { taskId: `diagnosis_${painId}` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Return value as a non-blank string if it is one, otherwise null. */
|
|
81
|
+
function readNonBlankString(value: unknown): string | null {
|
|
82
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Output a refused/not_found result, respecting --json mode. Exits with code 1. */
|
|
86
|
+
function refuseExit(opts: PainRetryOptions, payload: { status?: string; painId: string; taskId?: string; reason: string; message?: string; nextAction: string }): never {
|
|
87
|
+
if (opts.json) {
|
|
88
|
+
console.log(JSON.stringify({
|
|
89
|
+
status: payload.status ?? 'refused',
|
|
90
|
+
painId: payload.painId,
|
|
91
|
+
taskId: payload.taskId ?? null,
|
|
92
|
+
reason: payload.reason,
|
|
93
|
+
...(payload.message ? { message: payload.message } : {}),
|
|
94
|
+
nextAction: payload.nextAction,
|
|
95
|
+
}));
|
|
96
|
+
} else {
|
|
97
|
+
console.error(`error: ${payload.message ?? payload.reason}`);
|
|
98
|
+
console.error(`nextAction: ${payload.nextAction}`);
|
|
99
|
+
}
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Handler ────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
|
|
106
|
+
const workspaceDir = resolveWorkspaceDir(opts.workspace);
|
|
107
|
+
const stateDir = `${workspaceDir}/.state`;
|
|
108
|
+
|
|
109
|
+
// Step 1: Resolve painId → taskId
|
|
110
|
+
const resolution = resolveTaskIdFromPainId(opts.painId);
|
|
111
|
+
if ('reason' in resolution) {
|
|
112
|
+
refuseExit(opts, { painId: opts.painId, reason: resolution.reason, nextAction: resolution.nextAction });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { taskId } = resolution;
|
|
116
|
+
|
|
117
|
+
// Step 2: Look up task and validate
|
|
118
|
+
const stateManager = new RuntimeStateManager({ workspaceDir });
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await stateManager.initialize();
|
|
122
|
+
|
|
123
|
+
const task = await stateManager.getTask(taskId);
|
|
124
|
+
if (!task) {
|
|
125
|
+
refuseExit(opts, {
|
|
126
|
+
status: 'not_found',
|
|
127
|
+
painId: opts.painId,
|
|
128
|
+
taskId,
|
|
129
|
+
reason: 'task_not_found',
|
|
130
|
+
message: `No task found for painId '${opts.painId}' (looked for taskId '${taskId}')`,
|
|
131
|
+
nextAction: `Verify the painId is correct. Use 'pd task list --kind diagnostician' to see all diagnostician tasks.`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (task.taskKind !== 'diagnostician') {
|
|
136
|
+
refuseExit(opts, {
|
|
137
|
+
painId: opts.painId,
|
|
138
|
+
taskId,
|
|
139
|
+
reason: 'wrong_task_kind',
|
|
140
|
+
message: `Task '${taskId}' is not a diagnostician task (taskKind='${task.taskKind}')`,
|
|
141
|
+
nextAction: `pd pain retry only retries diagnostician tasks. Use 'pd diagnose run --task-id ${taskId}' for other task kinds.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const previousTaskStatus = task.status;
|
|
146
|
+
const previousLastError = task.lastError ?? null;
|
|
147
|
+
|
|
148
|
+
if (task.status === 'succeeded' && !opts.force) {
|
|
149
|
+
refuseExit(opts, {
|
|
150
|
+
painId: opts.painId,
|
|
151
|
+
taskId,
|
|
152
|
+
reason: 'already_succeeded',
|
|
153
|
+
message: `Task '${taskId}' already succeeded. Use --force to re-run a succeeded task.`,
|
|
154
|
+
nextAction: `Add --force to retry: pd pain retry --pain-id ${opts.painId} --force`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!RETRYABLE_STATUSES.has(task.status) && task.status !== 'succeeded') {
|
|
159
|
+
refuseExit(opts, {
|
|
160
|
+
painId: opts.painId,
|
|
161
|
+
taskId,
|
|
162
|
+
reason: 'status_not_retryable',
|
|
163
|
+
message: `Task '${taskId}' has status '${task.status}' which is not retryable. Retryable statuses: ${[...RETRYABLE_STATUSES].join(', ')}`,
|
|
164
|
+
nextAction: `Wait for the task to reach a terminal state, or use 'pd diagnose run --task-id ${taskId}' directly.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Step 3: Resolve runtime kind
|
|
169
|
+
// P1 fix: --openclaw-local and --openclaw-gateway are mutually exclusive.
|
|
170
|
+
// Must output JSON when --json is set (CLI operator gate).
|
|
171
|
+
if (opts.openclawLocal && opts.openclawGateway) {
|
|
172
|
+
refuseExit(opts, {
|
|
173
|
+
painId: opts.painId,
|
|
174
|
+
taskId,
|
|
175
|
+
reason: 'conflicting_flags',
|
|
176
|
+
message: '--openclaw-local and --openclaw-gateway are mutually exclusive',
|
|
177
|
+
nextAction: 'Provide exactly one of --openclaw-local or --openclaw-gateway, not both.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// P1 fix: pd pain retry must NOT default to test-double.
|
|
182
|
+
// This command is for real workspace pain fixes — test-double would generate
|
|
183
|
+
// fake candidates/ledger in a real .pd/state.db. Require explicit --runtime
|
|
184
|
+
// or fall back to workflows.yaml config.
|
|
185
|
+
let runtimeKind = opts.runtime;
|
|
186
|
+
if (!runtimeKind) {
|
|
187
|
+
try {
|
|
188
|
+
const configResult = resolveRuntimeConfig(stateDir);
|
|
189
|
+
if (!isRuntimeConfigError(configResult) && configResult.runtimeKind) {
|
|
190
|
+
({ runtimeKind } = configResult);
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Config load failed — fall through to refusal
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!runtimeKind) {
|
|
198
|
+
refuseExit(opts, {
|
|
199
|
+
painId: opts.painId,
|
|
200
|
+
taskId,
|
|
201
|
+
reason: 'missing_runtime',
|
|
202
|
+
message: 'No --runtime specified and no workflows.yaml config found. pd pain retry must not default to test-double to prevent fake data in real workspaces.',
|
|
203
|
+
nextAction: `Specify --runtime explicitly: pd pain retry --pain-id ${opts.painId} --runtime pi-ai --provider <provider> --model <model> --apiKeyEnv <ENV>`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Step 4: Build runtime adapter
|
|
208
|
+
let runtimeAdapter: PDRuntimeAdapter;
|
|
209
|
+
|
|
210
|
+
if (runtimeKind === 'openclaw-cli') {
|
|
211
|
+
const configResult = resolveRuntimeConfig(stateDir, { openclawLocal: opts.openclawLocal, openclawGateway: opts.openclawGateway, requestedRuntimeKind: 'openclaw-cli' });
|
|
212
|
+
if (isRuntimeConfigError(configResult)) {
|
|
213
|
+
refuseExit(opts, { painId: opts.painId, taskId, reason: configResult.reason, message: configResult.message, nextAction: configResult.nextAction });
|
|
214
|
+
}
|
|
215
|
+
const { openclawMode } = configResult;
|
|
216
|
+
if (!openclawMode) {
|
|
217
|
+
refuseExit(opts, {
|
|
218
|
+
painId: opts.painId,
|
|
219
|
+
taskId,
|
|
220
|
+
reason: 'missing_openclaw_mode',
|
|
221
|
+
message: 'runtimeKind is openclaw-cli but no mode resolved',
|
|
222
|
+
nextAction: 'Provide --openclaw-local or --openclaw-gateway',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
runtimeAdapter = new OpenClawCliRuntimeAdapter({
|
|
227
|
+
runtimeMode: openclawMode,
|
|
228
|
+
workspaceDir,
|
|
229
|
+
agentId: opts.agent ?? 'main',
|
|
230
|
+
});
|
|
231
|
+
} else if (runtimeKind === 'test-double') {
|
|
232
|
+
runtimeAdapter = new TestDoubleRuntimeAdapter({
|
|
233
|
+
onPollRun: (_runId: string) => ({
|
|
234
|
+
runId: _runId,
|
|
235
|
+
status: 'succeeded',
|
|
236
|
+
startedAt: new Date().toISOString(),
|
|
237
|
+
endedAt: new Date().toISOString(),
|
|
238
|
+
}),
|
|
239
|
+
onFetchOutput: (_runId: string) => ({
|
|
240
|
+
runId: _runId,
|
|
241
|
+
payload: {
|
|
242
|
+
valid: true,
|
|
243
|
+
diagnosisId: `diag-retry-${Date.now()}`,
|
|
244
|
+
taskId,
|
|
245
|
+
summary: 'CLI retry test diagnosis — validate tool arguments before execution',
|
|
246
|
+
rootCause: 'Test root cause — missing argument validation',
|
|
247
|
+
violatedPrinciples: [],
|
|
248
|
+
evidence: [],
|
|
249
|
+
recommendations: [
|
|
250
|
+
{ kind: 'principle', description: 'Always validate tool arguments before execution to prevent silent failures' },
|
|
251
|
+
{ kind: 'rule', description: 'Use schema validation for external inputs' },
|
|
252
|
+
],
|
|
253
|
+
confidence: 0.9,
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
} else if (runtimeKind === 'pi-ai') {
|
|
258
|
+
let policyConfig: RuntimeConfig | null = null;
|
|
259
|
+
try {
|
|
260
|
+
const configResult = resolveRuntimeConfig(stateDir);
|
|
261
|
+
if (!isRuntimeConfigError(configResult)) {
|
|
262
|
+
policyConfig = configResult;
|
|
263
|
+
} else {
|
|
264
|
+
console.warn(`[pd pain retry] workflows.yaml policy load failed: ${configResult.message}. Using CLI flags if provided.`);
|
|
265
|
+
}
|
|
266
|
+
} catch (err: unknown) {
|
|
267
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
268
|
+
console.warn(`[pd pain retry] workflows.yaml policy load failed: ${detail}. Using CLI flags if provided.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const provider = opts.provider ?? policyConfig?.provider;
|
|
272
|
+
const model = opts.model ?? policyConfig?.model;
|
|
273
|
+
const apiKeyEnv = opts.apiKeyEnv ?? policyConfig?.apiKeyEnv;
|
|
274
|
+
const baseUrl = opts.baseUrl ?? policyConfig?.baseUrl;
|
|
275
|
+
const maxRetries = opts.maxRetries ?? policyConfig?.maxRetries;
|
|
276
|
+
const effectiveTimeoutMs = opts.timeoutMs ?? policyConfig?.timeoutMs;
|
|
277
|
+
|
|
278
|
+
// Validate required string fields: must be non-blank strings
|
|
279
|
+
const missing: string[] = [];
|
|
280
|
+
if (readNonBlankString(provider) === null) missing.push('provider');
|
|
281
|
+
if (readNonBlankString(model) === null) missing.push('model');
|
|
282
|
+
if (readNonBlankString(apiKeyEnv) === null) missing.push('apiKeyEnv');
|
|
283
|
+
if (missing.length > 0) {
|
|
284
|
+
refuseExit(opts, {
|
|
285
|
+
painId: opts.painId,
|
|
286
|
+
taskId,
|
|
287
|
+
reason: `missing_required_config: ${missing.join(', ')}`,
|
|
288
|
+
message: `Missing or blank required pi-ai config: ${missing.join(', ')}`,
|
|
289
|
+
nextAction: `Pass via --flag or add to workflows.yaml. Example: pd pain retry --pain-id ${opts.painId} --runtime pi-ai --provider openrouter --model anthropic/claude-sonnet-4 --apiKeyEnv OPENROUTER_API_KEY`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Validate numeric options: must be finite, integer, non-negative if provided
|
|
294
|
+
const invalidNumeric: string[] = [];
|
|
295
|
+
if (maxRetries !== undefined && maxRetries !== null && !(Number.isFinite(maxRetries) && Number.isInteger(maxRetries) && maxRetries >= 0)) {
|
|
296
|
+
invalidNumeric.push(`maxRetries (got: ${maxRetries})`);
|
|
297
|
+
}
|
|
298
|
+
if (effectiveTimeoutMs !== undefined && effectiveTimeoutMs !== null && !(Number.isFinite(effectiveTimeoutMs) && effectiveTimeoutMs > 0)) {
|
|
299
|
+
invalidNumeric.push(`timeoutMs (got: ${effectiveTimeoutMs})`);
|
|
300
|
+
}
|
|
301
|
+
if (invalidNumeric.length > 0) {
|
|
302
|
+
refuseExit(opts, {
|
|
303
|
+
painId: opts.painId,
|
|
304
|
+
taskId,
|
|
305
|
+
reason: `invalid_numeric_config: ${invalidNumeric.join(', ')}`,
|
|
306
|
+
message: `Invalid numeric pi-ai config: ${invalidNumeric.join(', ')}. maxRetries must be a non-negative integer; timeoutMs must be a positive number.`,
|
|
307
|
+
nextAction: 'Fix the numeric values and retry.',
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// After validation, these are guaranteed non-blank strings.
|
|
312
|
+
const validProvider = readNonBlankString(provider);
|
|
313
|
+
const validModel = readNonBlankString(model);
|
|
314
|
+
const validApiKeyEnv = readNonBlankString(apiKeyEnv);
|
|
315
|
+
if (validProvider === null || validModel === null || validApiKeyEnv === null) {
|
|
316
|
+
refuseExit(opts, {
|
|
317
|
+
painId: opts.painId,
|
|
318
|
+
taskId,
|
|
319
|
+
reason: 'internal_validation_error',
|
|
320
|
+
message: 'Internal error: validated string fields became null after validation.',
|
|
321
|
+
nextAction: 'This should not happen. Please report this bug.',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!process.env[validApiKeyEnv]) {
|
|
326
|
+
refuseExit(opts, {
|
|
327
|
+
painId: opts.painId,
|
|
328
|
+
taskId,
|
|
329
|
+
reason: 'missing_api_key',
|
|
330
|
+
message: `Environment variable '${validApiKeyEnv}' is not set`,
|
|
331
|
+
nextAction: `Set the environment variable: export ${validApiKeyEnv}=<your-api-key>`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
runtimeAdapter = new PiAiRuntimeAdapter({
|
|
336
|
+
provider: validProvider,
|
|
337
|
+
model: validModel,
|
|
338
|
+
apiKeyEnv: validApiKeyEnv,
|
|
339
|
+
baseUrl,
|
|
340
|
+
maxRetries,
|
|
341
|
+
timeoutMs: effectiveTimeoutMs,
|
|
342
|
+
workspace: workspaceDir,
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
refuseExit(opts, {
|
|
346
|
+
painId: opts.painId,
|
|
347
|
+
taskId,
|
|
348
|
+
reason: `unknown_runtime: '${runtimeKind}'`,
|
|
349
|
+
message: `Unknown runtime kind '${runtimeKind}'`,
|
|
350
|
+
nextAction: 'Supported runtimes: openclaw-cli, test-double, pi-ai',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Step 5: Build runner and execute (same as diagnose run)
|
|
355
|
+
const sqliteConn = stateManager.connection;
|
|
356
|
+
const { taskStore } = stateManager;
|
|
357
|
+
const { runStore } = stateManager;
|
|
358
|
+
const historyQuery = new SqliteHistoryQuery(sqliteConn);
|
|
359
|
+
const trajectoryLocator = new SqliteTrajectoryLocator(sqliteConn);
|
|
360
|
+
const sourceTraceLocator = new SqliteSourceTraceLocator(taskStore, trajectoryLocator);
|
|
361
|
+
const contextAssembler = new SqliteContextAssembler(taskStore, historyQuery, runStore, { sourceTraceLocator });
|
|
362
|
+
|
|
363
|
+
const eventEmitter = new StoreEventEmitter();
|
|
364
|
+
const committer = new SqliteDiagnosticianCommitter(sqliteConn);
|
|
365
|
+
const runner = new DiagnosticianRunner(
|
|
366
|
+
{
|
|
367
|
+
stateManager,
|
|
368
|
+
contextAssembler,
|
|
369
|
+
runtimeAdapter,
|
|
370
|
+
eventEmitter,
|
|
371
|
+
validator: new DefaultDiagnosticianValidator(),
|
|
372
|
+
committer,
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
owner: 'pd-cli-pain-retry',
|
|
376
|
+
runtimeKind,
|
|
377
|
+
pollIntervalMs: 100,
|
|
378
|
+
timeoutMs: 300_000,
|
|
379
|
+
agentId: opts.agent,
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
if (!opts.json) {
|
|
384
|
+
console.log(`\nRetrying diagnosis for pain: ${opts.painId}`);
|
|
385
|
+
console.log(` Task ID: ${taskId}`);
|
|
386
|
+
console.log(` Previous: ${previousTaskStatus}${previousLastError ? ` (${previousLastError})` : ''}`);
|
|
387
|
+
console.log(` Runtime: ${runtimeKind}`);
|
|
388
|
+
console.log(` Workspace: ${workspaceDir}\n`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const result = await diagnoseRun({
|
|
392
|
+
taskId,
|
|
393
|
+
stateManager,
|
|
394
|
+
runner,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (result.status !== 'succeeded') {
|
|
398
|
+
if (opts.json) {
|
|
399
|
+
console.log(JSON.stringify({
|
|
400
|
+
status: 'failed',
|
|
401
|
+
painId: opts.painId,
|
|
402
|
+
taskId,
|
|
403
|
+
runId: null,
|
|
404
|
+
runtimeKind,
|
|
405
|
+
previousTaskStatus,
|
|
406
|
+
previousLastError,
|
|
407
|
+
newTaskStatus: result.status,
|
|
408
|
+
errorCategory: result.errorCategory ?? null,
|
|
409
|
+
failureReason: result.failureReason ?? null,
|
|
410
|
+
nextAction: result.errorCategory === 'output_invalid'
|
|
411
|
+
? 'The LLM output failed validation. Try a different model or provider.'
|
|
412
|
+
: 'Check the error category and retry with adjusted parameters.',
|
|
413
|
+
}, null, 2));
|
|
414
|
+
} else {
|
|
415
|
+
console.log(`\nRetry failed:`);
|
|
416
|
+
console.log(` Status: ${result.status}`);
|
|
417
|
+
console.log(` Task ID: ${result.taskId}`);
|
|
418
|
+
if (result.errorCategory) {
|
|
419
|
+
console.log(` Error Category: ${result.errorCategory}`);
|
|
420
|
+
}
|
|
421
|
+
if (result.failureReason) {
|
|
422
|
+
console.log(` Failure Reason: ${result.failureReason}`);
|
|
423
|
+
}
|
|
424
|
+
console.log('');
|
|
425
|
+
}
|
|
426
|
+
process.exit(1);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Step 6: Intake candidates
|
|
431
|
+
const candidates = await stateManager.getCandidatesByTaskId(taskId);
|
|
432
|
+
const intakeResults: { candidateId: string; ledgerEntryId?: string; status: string; error?: string; nextAction?: string }[] = [];
|
|
433
|
+
let intakeFailed = false;
|
|
434
|
+
|
|
435
|
+
const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir: path.join(workspaceDir, '.state') });
|
|
436
|
+
const intakeService = new CandidateIntakeService({ stateManager, ledgerAdapter });
|
|
437
|
+
|
|
438
|
+
for (const candidate of candidates) {
|
|
439
|
+
try {
|
|
440
|
+
const entry = await intakeService.intake(candidate.candidateId);
|
|
441
|
+
if (candidate.status !== 'consumed') {
|
|
442
|
+
await stateManager.updateCandidateStatus(candidate.candidateId, { status: 'consumed' });
|
|
443
|
+
}
|
|
444
|
+
intakeResults.push({
|
|
445
|
+
candidateId: candidate.candidateId,
|
|
446
|
+
ledgerEntryId: entry.id,
|
|
447
|
+
status: 'consumed',
|
|
448
|
+
});
|
|
449
|
+
} catch (intakeErr: unknown) {
|
|
450
|
+
intakeFailed = true;
|
|
451
|
+
const intakeErrorMessage = intakeErr instanceof Error ? intakeErr.message : String(intakeErr);
|
|
452
|
+
intakeResults.push({
|
|
453
|
+
candidateId: candidate.candidateId,
|
|
454
|
+
status: 'intake_failed',
|
|
455
|
+
error: intakeErrorMessage,
|
|
456
|
+
nextAction: `pd candidate intake --candidate-id ${candidate.candidateId} --workspace "${workspaceDir}"`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const candidateIds = candidates.map((c) => c.candidateId);
|
|
462
|
+
const ledgerEntryIds = intakeResults
|
|
463
|
+
.filter((ir): ir is { candidateId: string; ledgerEntryId: string; status: string } => ir.status === 'consumed' && typeof ir.ledgerEntryId === 'string')
|
|
464
|
+
.map((ir) => ir.ledgerEntryId);
|
|
465
|
+
|
|
466
|
+
// Build nextAction: candidates generated but internalization not automatic
|
|
467
|
+
const internalizeNextAction = candidateIds.length > 0
|
|
468
|
+
? `Candidates generated but internalization has NOT started automatically. To begin internalization, run:\n ${candidateIds.map((id) => `pd candidate internalize --candidate-id ${id} --workspace "${workspaceDir}"`).join('\n ')}`
|
|
469
|
+
: 'No candidates were generated from this diagnosis.';
|
|
470
|
+
|
|
471
|
+
if (opts.json) {
|
|
472
|
+
// Strict single JSON object output
|
|
473
|
+
console.log(JSON.stringify({
|
|
474
|
+
status: 'succeeded',
|
|
475
|
+
painId: opts.painId,
|
|
476
|
+
taskId,
|
|
477
|
+
runId: null,
|
|
478
|
+
runtimeKind,
|
|
479
|
+
previousTaskStatus,
|
|
480
|
+
previousLastError,
|
|
481
|
+
newTaskStatus: 'succeeded',
|
|
482
|
+
candidateIds,
|
|
483
|
+
ledgerEntryIds,
|
|
484
|
+
intake: {
|
|
485
|
+
candidates: intakeResults,
|
|
486
|
+
},
|
|
487
|
+
nextAction: internalizeNextAction,
|
|
488
|
+
}, null, 2));
|
|
489
|
+
if (intakeFailed) {
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Text output
|
|
496
|
+
console.log(`\nRetry succeeded:`);
|
|
497
|
+
console.log(` Pain ID: ${opts.painId}`);
|
|
498
|
+
console.log(` Task ID: ${taskId}`);
|
|
499
|
+
console.log(` Previous Status: ${previousTaskStatus}${previousLastError ? ` (${previousLastError})` : ''}`);
|
|
500
|
+
console.log(` New Status: succeeded`);
|
|
501
|
+
console.log(` Candidates: ${candidateIds.length}`);
|
|
502
|
+
if (result.contextHash) {
|
|
503
|
+
console.log(` Context Hash: ${result.contextHash.substring(0, 16)}...`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (intakeResults.length > 0) {
|
|
507
|
+
console.log(`\n Candidate Intake:`);
|
|
508
|
+
for (const ir of intakeResults) {
|
|
509
|
+
if (ir.status === 'consumed') {
|
|
510
|
+
console.log(` ${ir.candidateId}: consumed (ledger: ${ir.ledgerEntryId})`);
|
|
511
|
+
} else if (ir.status === 'intake_failed') {
|
|
512
|
+
console.log(` ${ir.candidateId}: INTAKE FAILED — ${ir.error}`);
|
|
513
|
+
console.log(` Next action: ${ir.nextAction}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
console.log(`\n Next Action:`);
|
|
519
|
+
console.log(` ${internalizeNextAction}`);
|
|
520
|
+
console.log('');
|
|
521
|
+
|
|
522
|
+
if (intakeFailed) {
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
} catch (error: unknown) {
|
|
526
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
527
|
+
let errorCategory = 'execution_failed';
|
|
528
|
+
if (error instanceof PDRuntimeError) {
|
|
529
|
+
errorCategory = error.category;
|
|
530
|
+
}
|
|
531
|
+
if (opts.json) {
|
|
532
|
+
console.log(JSON.stringify({
|
|
533
|
+
status: 'failed',
|
|
534
|
+
painId: opts.painId,
|
|
535
|
+
taskId: `diagnosis_${opts.painId}`,
|
|
536
|
+
errorCategory,
|
|
537
|
+
message,
|
|
538
|
+
nextAction: 'Check the error message and retry with adjusted parameters.',
|
|
539
|
+
}, null, 2));
|
|
540
|
+
} else {
|
|
541
|
+
console.error(`error: ${message} (${errorCategory})`);
|
|
542
|
+
}
|
|
543
|
+
process.exit(1);
|
|
544
|
+
} finally {
|
|
545
|
+
await stateManager.close();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
@@ -63,9 +63,13 @@ interface RunOnceOutput {
|
|
|
63
63
|
conflictReason?: string;
|
|
64
64
|
reason?: string;
|
|
65
65
|
inspectedCount?: number;
|
|
66
|
-
|
|
66
|
+
successorEnqueueAttempted?: boolean;
|
|
67
|
+
successorTasksCreated?: number;
|
|
68
|
+
successorTaskIds?: string[];
|
|
67
69
|
successorKind?: string;
|
|
68
70
|
enqueueDecision?: string;
|
|
71
|
+
enqueueReason?: string;
|
|
72
|
+
nextAction?: string;
|
|
69
73
|
effectiveTimeoutMs?: number;
|
|
70
74
|
timeoutSource?: string;
|
|
71
75
|
}
|
|
@@ -163,14 +167,30 @@ function formatTextOutput(output: RunOnceOutput): string {
|
|
|
163
167
|
lines.push(` reason: ${output.reason}`);
|
|
164
168
|
}
|
|
165
169
|
|
|
166
|
-
if (output.
|
|
167
|
-
lines.push(` successor: ${output.
|
|
170
|
+
if (output.successorTaskIds && output.successorTaskIds.length > 0) {
|
|
171
|
+
lines.push(` successor: ${output.successorTaskIds.join(', ')} (${output.successorKind ?? 'unknown'})`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (output.successorEnqueueAttempted !== undefined) {
|
|
175
|
+
lines.push(` enqueue_attempted: ${output.successorEnqueueAttempted}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (output.successorTasksCreated !== undefined) {
|
|
179
|
+
lines.push(` successors_created: ${output.successorTasksCreated}`);
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
if (output.enqueueDecision) {
|
|
171
183
|
lines.push(` enqueue: ${output.enqueueDecision}`);
|
|
172
184
|
}
|
|
173
185
|
|
|
186
|
+
if (output.enqueueReason) {
|
|
187
|
+
lines.push(` enqueue_reason: ${output.enqueueReason}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (output.nextAction) {
|
|
191
|
+
lines.push(` nextAction: ${output.nextAction}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
174
194
|
if (output.effectiveTimeoutMs) {
|
|
175
195
|
lines.push(` timeout: ${output.effectiveTimeoutMs}ms`);
|
|
176
196
|
}
|
|
@@ -619,12 +639,43 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
|
|
|
619
639
|
}
|
|
620
640
|
}
|
|
621
641
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
642
|
+
// Default: auto-enqueue successor on runner success (unless opted out via --no-enqueue-next)
|
|
643
|
+
const shouldEnqueue = opts.enqueueNext !== false;
|
|
644
|
+
|
|
645
|
+
if (shouldEnqueue && runnerResult?.status === 'succeeded' && wakeResult.decision === 'would_lease') {
|
|
646
|
+
output.successorEnqueueAttempted = true;
|
|
647
|
+
try {
|
|
648
|
+
const commitResult = await orchestrator.commitNextTaskProposal(wakeResult.taskId);
|
|
649
|
+
output.enqueueDecision = commitResult.decision;
|
|
650
|
+
output.successorTasksCreated = 0;
|
|
651
|
+
output.successorTaskIds = [];
|
|
652
|
+
if (commitResult.decision === 'successor_created' || commitResult.decision === 'successor_exists') {
|
|
653
|
+
output.successorKind = commitResult.successorKind;
|
|
654
|
+
output.successorTaskIds.push(commitResult.successorTaskId);
|
|
655
|
+
output.successorTasksCreated = commitResult.decision === 'successor_created' ? 1 : 0;
|
|
656
|
+
} else if (commitResult.decision === 'no_successor') {
|
|
657
|
+
output.enqueueReason = commitResult.reason;
|
|
658
|
+
output.nextAction = 'No successor in job graph for this task kind and channel. This is expected for terminal runners.';
|
|
659
|
+
} else {
|
|
660
|
+
output.enqueueReason = `Unexpected commitNextTaskProposal decision: ${commitResult.decision}`;
|
|
661
|
+
output.nextAction = 'Investigate orchestrator logic; re-run with --no-enqueue-next to skip auto-enqueue.';
|
|
662
|
+
}
|
|
663
|
+
} catch (enqueueErr: unknown) {
|
|
664
|
+
const enqueueMessage = enqueueErr instanceof Error ? enqueueErr.message : String(enqueueErr);
|
|
665
|
+
output.enqueueDecision = 'enqueue_failed';
|
|
666
|
+
output.enqueueReason = enqueueMessage;
|
|
667
|
+
output.successorTasksCreated = 0;
|
|
668
|
+
output.successorTaskIds = [];
|
|
669
|
+
output.nextAction = `Runner succeeded but successor enqueue failed. Run: pd runtime internalization enqueue-successors --workspace ${workspaceDir} --confirm`;
|
|
670
|
+
// Runner success is real; downgrade to partial_success, not error
|
|
671
|
+
if (output.decision === 'would_lease') {
|
|
672
|
+
output.decision = 'partial_success';
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
} else if (!shouldEnqueue) {
|
|
676
|
+
output.successorEnqueueAttempted = false;
|
|
677
|
+
if (runnerResult?.status === 'succeeded' && wakeResult.decision === 'would_lease') {
|
|
678
|
+
output.nextAction = 'Successor auto-enqueue was skipped (--no-enqueue-next). To enqueue: pd runtime internalization enqueue-successors --confirm';
|
|
628
679
|
}
|
|
629
680
|
}
|
|
630
681
|
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { Command } from 'commander';
|
|
10
10
|
import { handlePainRecord } from './commands/pain-record.js';
|
|
11
|
+
import { handlePainRetry } from './commands/pain-retry.js';
|
|
11
12
|
import { handleSamplesList } from './commands/samples-list.js';
|
|
12
13
|
import { handleSamplesReview } from './commands/samples-review.js';
|
|
13
14
|
import { handleEvolutionTasksList } from './commands/evolution-tasks-list.js';
|
|
@@ -76,6 +77,27 @@ painCmd
|
|
|
76
77
|
await handlePainRecord(opts);
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
painCmd
|
|
81
|
+
.command('retry')
|
|
82
|
+
.description('Retry a failed diagnosis by pain ID')
|
|
83
|
+
.requiredOption('-p, --pain-id <painId>', 'Pain ID to retry diagnosis for')
|
|
84
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
85
|
+
.option('-r, --runtime <kind>', "Runtime kind: 'openclaw-cli', 'test-double', 'pi-ai'")
|
|
86
|
+
.option('--openclaw-local', 'Use local OpenClaw (mutually exclusive with --openclaw-gateway)')
|
|
87
|
+
.option('--openclaw-gateway', 'Use gateway OpenClaw (mutually exclusive with --openclaw-local)')
|
|
88
|
+
.option('-a, --agent <agentId>', 'Agent ID to invoke')
|
|
89
|
+
.option('--provider <name>', 'LLM provider (e.g., openrouter) — for pi-ai, falls back to policy')
|
|
90
|
+
.option('--model <id>', 'Model ID (e.g., anthropic/claude-sonnet-4) — for pi-ai, falls back to policy')
|
|
91
|
+
.option('--apiKeyEnv <name>', 'Env var name for API key — for pi-ai, falls back to policy')
|
|
92
|
+
.option('--baseUrl <url>', 'Custom base URL — for pi-ai, falls back to policy')
|
|
93
|
+
.option('--maxRetries <n>', 'Max retry attempts for LLM failures — for pi-ai, falls back to policy', parseInt)
|
|
94
|
+
.option('--timeoutMs <ms>', 'Timeout in milliseconds — for pi-ai, falls back to policy', parseInt)
|
|
95
|
+
.option('--force', 'Allow retry of already-succeeded tasks')
|
|
96
|
+
.option('--json', 'Output raw JSON')
|
|
97
|
+
.action(async (opts) => {
|
|
98
|
+
await handlePainRetry(opts);
|
|
99
|
+
});
|
|
100
|
+
|
|
79
101
|
const samplesCmd = program
|
|
80
102
|
.command('samples')
|
|
81
103
|
.description('Correction sample management');
|
|
@@ -526,7 +548,7 @@ internalizationCmd
|
|
|
526
548
|
.option('--runner <kind>', 'Runner kind to execute (default: dreamer)', 'dreamer')
|
|
527
549
|
.option('--runtime <kind>', 'Runtime adapter kind: config (from workflows.yaml), pi-ai, openclaw-cli, test-double (default: config)', 'config')
|
|
528
550
|
.option('--allow-test-double', 'Acknowledge that test-double runtime will mutate real queue state')
|
|
529
|
-
.option('--enqueue-next', '
|
|
551
|
+
.option('--no-enqueue-next', 'Skip successor enqueue after successful runner (default: auto-enqueue)')
|
|
530
552
|
.option('--timeout-ms <ms>', 'Runner timeout in milliseconds (default: 300000, overrides workflows.yaml)', parseInt)
|
|
531
553
|
.option('--json', 'Output raw JSON')
|
|
532
554
|
.action(async (opts) => {
|