pi-harness-runtime 0.2.0 → 0.3.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,340 @@
1
+ /**
2
+ * Repair Engine — RFC-0018
3
+ *
4
+ * Converts failures into repair tasks with retry policy and escalation.
5
+ */
6
+
7
+ import type {
8
+ RepairTask,
9
+ FailureType,
10
+ AttemptedFix,
11
+ RetryPolicy,
12
+ } from "../packages/types/src/runtime-types.ts";
13
+ import { appendJsonl } from "../cli.ts";
14
+ // @ts-expect-error - Bun has built-in Node.js types
15
+ import { join, dirname } from "node:path";
16
+ // @ts-expect-error - Bun has built-in Node.js types
17
+ import { mkdirSync } from "node:fs";
18
+
19
+ const DEFAULT_RETRY_POLICY: RetryPolicy = {
20
+ maxRetries: 3,
21
+ backoffMs: 5000,
22
+ backoffMultiplier: 2,
23
+ escalationAfter: 2,
24
+ };
25
+
26
+ const FAILURE_PATTERNS: Record<string, FailureType> = {
27
+ "test failed": "test_failure",
28
+ "assertion failed": "test_failure",
29
+ TypeError: "type_error",
30
+ SyntaxError: "build_error",
31
+ ESLint: "lint_error",
32
+ "build failed": "build_error",
33
+ "compilation error": "build_error",
34
+ quota: "quota_exhausted",
35
+ "rate limit": "quota_exhausted",
36
+ "429": "quota_exhausted",
37
+ "500": "provider_error",
38
+ "502": "provider_error",
39
+ "503": "provider_error",
40
+ connection: "provider_error",
41
+ timeout: "provider_error",
42
+ };
43
+
44
+ export interface RepairResult {
45
+ repairTask: RepairTask;
46
+ canRetry: boolean;
47
+ shouldEscalate: boolean;
48
+ }
49
+
50
+ export class RepairEngine {
51
+ private repairTasks: Map<string, RepairTask> = new Map();
52
+ private readonly rootDir: string;
53
+
54
+ constructor(rootDir: string) {
55
+ this.rootDir = rootDir;
56
+ }
57
+
58
+ /**
59
+ * Classify a failure type from error message
60
+ */
61
+ classifyFailure(errorMessage: string): FailureType {
62
+ const lower = errorMessage.toLowerCase();
63
+
64
+ for (const [pattern, type] of Object.entries(FAILURE_PATTERNS)) {
65
+ if (lower.includes(pattern.toLowerCase())) {
66
+ return type;
67
+ }
68
+ }
69
+
70
+ return "unknown";
71
+ }
72
+
73
+ /**
74
+ * Create a repair task from a failed task
75
+ */
76
+ createRepairTask(
77
+ originalTaskId: string,
78
+ failureType: FailureType,
79
+ errorMessage: string,
80
+ options?: { retryPolicy?: Partial<RetryPolicy> },
81
+ ): RepairTask {
82
+ const id = `repair-${originalTaskId}-${Date.now()}`;
83
+ const retryPolicy: RetryPolicy = {
84
+ ...DEFAULT_RETRY_POLICY,
85
+ ...options?.retryPolicy,
86
+ };
87
+
88
+ const task: RepairTask = {
89
+ id,
90
+ originalTaskId,
91
+ failureType,
92
+ description: `Fix failure in task ${originalTaskId}: ${errorMessage}`,
93
+ attemptedFixes: [],
94
+ status: "pending",
95
+ retryPolicy,
96
+ createdAt: new Date().toISOString(),
97
+ };
98
+
99
+ this.repairTasks.set(id, task);
100
+ return task;
101
+ }
102
+
103
+ /**
104
+ * Record an attempted fix
105
+ */
106
+ recordAttempt(
107
+ repairTaskId: string,
108
+ description: string,
109
+ success: boolean,
110
+ output?: string,
111
+ ): void {
112
+ const task = this.repairTasks.get(repairTaskId);
113
+ if (!task) return;
114
+
115
+ const attempt: AttemptedFix = {
116
+ attempt: task.attemptedFixes.length + 1,
117
+ description,
118
+ success,
119
+ output,
120
+ timestamp: new Date().toISOString(),
121
+ };
122
+
123
+ task.attemptedFixes.push(attempt);
124
+
125
+ if (success) {
126
+ task.status = "resolved";
127
+ task.resolvedAt = new Date().toISOString();
128
+ } else if (attempt.attempt >= task.retryPolicy.maxRetries) {
129
+ // Check if should escalate
130
+ if (
131
+ task.retryPolicy.escalationAfter &&
132
+ attempt.attempt >= task.retryPolicy.escalationAfter
133
+ ) {
134
+ task.status = "escalated";
135
+ }
136
+ }
137
+
138
+ this.repairTasks.set(repairTaskId, task);
139
+ this.saveRepairTask(task);
140
+ }
141
+
142
+ /**
143
+ * Get the next retry delay in milliseconds
144
+ */
145
+ getNextRetryDelay(repairTaskId: string): number | null {
146
+ const task = this.repairTasks.get(repairTaskId);
147
+ if (!task) return null;
148
+
149
+ const attempt = task.attemptedFixes.length;
150
+ if (attempt >= task.retryPolicy.maxRetries) return null;
151
+
152
+ const { backoffMs, backoffMultiplier } = task.retryPolicy;
153
+ return backoffMs * backoffMultiplier ** attempt;
154
+ }
155
+
156
+ /**
157
+ * Check if repair task should escalate to human
158
+ */
159
+ shouldEscalate(repairTaskId: string): boolean {
160
+ const task = this.repairTasks.get(repairTaskId);
161
+ if (!task) return false;
162
+ return task.status === "escalated";
163
+ }
164
+
165
+ /**
166
+ * Get all repair tasks for a job
167
+ */
168
+ getRepairTasks(jobId: string): RepairTask[] {
169
+ // const path = join(this.rootDir, "jobs", jobId, "repair-tasks.jsonl");
170
+ // In practice, this would read from file. For now, return in-memory.
171
+ return Array.from(this.repairTasks.values()).filter((t) =>
172
+ t.originalTaskId.startsWith(`task-${jobId}`),
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Get repair summary
178
+ */
179
+ getSummary(jobId: string): {
180
+ total: number;
181
+ pending: number;
182
+ resolved: number;
183
+ escalated: number;
184
+ } {
185
+ const tasks = this.getRepairTasks(jobId);
186
+ return {
187
+ total: tasks.length,
188
+ pending: tasks.filter(
189
+ (t) => t.status === "pending" || t.status === "in_progress",
190
+ ).length,
191
+ resolved: tasks.filter((t) => t.status === "resolved").length,
192
+ escalated: tasks.filter((t) => t.status === "escalated").length,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Analyze a failure and create repair task with guidance
198
+ */
199
+ analyzeAndRepair(
200
+ originalTaskId: string,
201
+ errorMessage: string,
202
+ options?: { retryPolicy?: Partial<RetryPolicy> },
203
+ ): { repairTask: RepairTask; guidance: string } {
204
+ const failureType = this.classifyFailure(errorMessage);
205
+ const repairTask = this.createRepairTask(
206
+ originalTaskId,
207
+ failureType,
208
+ errorMessage,
209
+ options,
210
+ );
211
+
212
+ const guidance = this.generateGuidance(repairTask);
213
+
214
+ return { repairTask, guidance };
215
+ }
216
+
217
+ /**
218
+ * Generate repair guidance based on failure type
219
+ */
220
+ private generateGuidance(repairTask: RepairTask): string {
221
+ switch (repairTask.failureType) {
222
+ case "test_failure":
223
+ return `Analyze the failing test. Check:
224
+ 1. Is the test correct? (Does it test what it claims?)
225
+ 2. Is the implementation correct? (Does it match the expected behavior?)
226
+ 3. Are there any recent changes that broke the test?
227
+ 4. Are there any environment-specific issues?`;
228
+
229
+ case "build_error":
230
+ return `Check the build error details:
231
+ 1. Look at the exact error message and line number
232
+ 2. Check for missing imports or type mismatches
233
+ 3. Verify all dependencies are installed
234
+ 4. Check for circular dependencies`;
235
+
236
+ case "type_error":
237
+ return `Fix TypeScript/JavaScript type errors:
238
+ 1. Check the type of each variable
239
+ 2. Ensure type assertions are correct
240
+ 3. Verify interface/type definitions match usage
241
+ 4. Consider adding type guards if needed`;
242
+
243
+ case "lint_error":
244
+ return `Fix linting errors:
245
+ 1. Review the linting rules being violated
246
+ 2. Check if code style matches project conventions
247
+ 3. Run linter with --fix if available
248
+ 4. Update eslint/prettier config if needed`;
249
+
250
+ case "quota_exhausted":
251
+ return `Quota exhausted. Options:
252
+ 1. Wait for quota reset
253
+ 2. Switch to an alternative provider
254
+ 3. Optimize prompts to use fewer tokens
255
+ 4. Reduce task scope to fit within quota`;
256
+
257
+ case "provider_error":
258
+ return `Provider error. Actions:
259
+ 1. Retry with exponential backoff
260
+ 2. Check provider status page
261
+ 3. Switch to fallback provider if available
262
+ 4. Log error details for debugging`;
263
+
264
+ case "e2e_failure":
265
+ return `E2E test failed. Check:
266
+ 1. Is the page/app in the expected state?
267
+ 2. Are selectors still valid?
268
+ 3. Is there a timing issue (need to wait longer)?
269
+ 4. Is the test assertion correct?`;
270
+
271
+ default:
272
+ return `Unknown failure. Gather more information:
273
+ 1. Collect full error stack trace
274
+ 2. Check system logs
275
+ 3. Reproduce the issue locally
276
+ 4. Consider asking for human assistance`;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Save repair task to file
282
+ */
283
+ private saveRepairTask(task: RepairTask): void {
284
+ const path = join(
285
+ this.rootDir,
286
+ "jobs",
287
+ task.originalTaskId.split("-")[1] ?? "unknown",
288
+ "repair-tasks.jsonl",
289
+ );
290
+ mkdirSync(dirname(path), { recursive: true });
291
+ appendJsonl(path, task);
292
+ }
293
+
294
+ /**
295
+ * Load repair tasks from file
296
+ */
297
+ loadRepairTasks(_jobId: string): void {
298
+ // const path = join(this.rootDir, "jobs", _jobId, "repair-tasks.jsonl");
299
+ // In practice, this would read from file and populate the map
300
+ }
301
+
302
+ /**
303
+ * Export repair report
304
+ */
305
+ exportReport(jobId: string): string {
306
+ const tasks = this.getRepairTasks(jobId);
307
+ const summary = this.getSummary(jobId);
308
+
309
+ const lines = [
310
+ `Repair Report for Job ${jobId}`,
311
+ "=".repeat(50),
312
+ `Total: ${summary.total}`,
313
+ `Pending: ${summary.pending}`,
314
+ `Resolved: ${summary.resolved}`,
315
+ `Escalated: ${summary.escalated}`,
316
+ "",
317
+ "Details:",
318
+ "-".repeat(50),
319
+ ];
320
+
321
+ for (const task of tasks) {
322
+ lines.push(`\n[${task.status.toUpperCase()}] ${task.id}`);
323
+ lines.push(` Original: ${task.originalTaskId}`);
324
+ lines.push(` Type: ${task.failureType}`);
325
+ lines.push(` Description: ${task.description}`);
326
+ lines.push(
327
+ ` Attempts: ${task.attemptedFixes.length}/${task.retryPolicy.maxRetries}`,
328
+ );
329
+
330
+ if (task.attemptedFixes.length > 0) {
331
+ lines.push(" Attempt History:");
332
+ for (const fix of task.attemptedFixes) {
333
+ lines.push(` - [${fix.success ? "✓" : "✗"}] ${fix.description}`);
334
+ }
335
+ }
336
+ }
337
+
338
+ return lines.join("\n");
339
+ }
340
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Task Graph — RFC-0016
3
+ *
4
+ * DAG-based work representation where tasks are READY only when all
5
+ * dependencies are DONE.
6
+ */
7
+
8
+ import type {
9
+ TaskNode,
10
+ TaskGraph,
11
+ TaskStatus,
12
+ } from "../packages/types/src/runtime-types.ts";
13
+ import { writeJson, readJson } from "../cli.ts";
14
+ // @ts-expect-error - Bun has built-in Node.js types
15
+ import { join } from "node:path";
16
+
17
+ export interface TaskGraphOptions {
18
+ jobId: string;
19
+ rootDir?: string;
20
+ }
21
+
22
+ export class TaskGraphManager {
23
+ private graph: TaskGraph;
24
+ private dirty = false;
25
+
26
+ constructor(options: TaskGraphOptions) {
27
+ this.graph = {
28
+ jobId: options.jobId,
29
+ nodes: {},
30
+ topologicalOrder: [],
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Add a task node to the graph
36
+ */
37
+ addTask(
38
+ id: string,
39
+ title: string,
40
+ description: string,
41
+ dependencies: string[] = [],
42
+ acceptanceCriteria?: string[],
43
+ ): TaskNode {
44
+ if (this.graph.nodes[id]) {
45
+ throw new Error(`Task ${id} already exists`);
46
+ }
47
+
48
+ // Validate dependencies exist
49
+ for (const depId of dependencies) {
50
+ if (!this.graph.nodes[depId] && depId !== id) {
51
+ console.warn(
52
+ `Warning: dependency ${depId} does not exist yet (task ${id})`,
53
+ );
54
+ }
55
+ }
56
+
57
+ const now = new Date().toISOString();
58
+ const node: TaskNode = {
59
+ id,
60
+ title,
61
+ description,
62
+ status: dependencies.length === 0 ? "ready" : "pending",
63
+ dependencies,
64
+ dependents: [],
65
+ acceptanceCriteria,
66
+ retryCount: 0,
67
+ maxRetries: 3,
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ };
71
+
72
+ // Update dependents of dependencies
73
+ for (const depId of dependencies) {
74
+ if (this.graph.nodes[depId]) {
75
+ this.graph.nodes[depId].dependents.push(id);
76
+ }
77
+ }
78
+
79
+ this.graph.nodes[id] = node;
80
+ this.recomputeTopologicalOrder();
81
+ this.dirty = true;
82
+
83
+ return node;
84
+ }
85
+
86
+ /**
87
+ * Update task status
88
+ */
89
+ updateTaskStatus(taskId: string, status: TaskStatus): TaskNode | null {
90
+ const node = this.graph.nodes[taskId];
91
+ if (!node) return null;
92
+
93
+ node.status = status;
94
+ node.updatedAt = new Date().toISOString();
95
+
96
+ // If task is done, check if dependents can now be ready
97
+ if (status === "done") {
98
+ this.updateDependentStatuses(taskId);
99
+ }
100
+
101
+ this.recomputeTopologicalOrder();
102
+ this.dirty = true;
103
+ return node;
104
+ }
105
+
106
+ /**
107
+ * Update dependent tasks when a dependency is completed
108
+ */
109
+ private updateDependentStatuses(completedTaskId: string): void {
110
+ for (const dependentId of this.graph.nodes[completedTaskId]?.dependents ??
111
+ []) {
112
+ const dependent = this.graph.nodes[dependentId];
113
+ if (!dependent) continue;
114
+
115
+ // Check if ALL dependencies are done
116
+ const allDepsDone = dependent.dependencies.every(
117
+ (depId) => this.graph.nodes[depId]?.status === "done",
118
+ );
119
+
120
+ if (allDepsDone && dependent.status === "pending") {
121
+ dependent.status = "ready";
122
+ dependent.updatedAt = new Date().toISOString();
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Get all tasks ready for execution (status = "ready")
129
+ */
130
+ getReadyTasks(): TaskNode[] {
131
+ return Object.values(this.graph.nodes).filter((n) => n.status === "ready");
132
+ }
133
+
134
+ /**
135
+ * Get task by ID
136
+ */
137
+ getTask(taskId: string): TaskNode | null {
138
+ return this.graph.nodes[taskId] ?? null;
139
+ }
140
+
141
+ /**
142
+ * Get all tasks
143
+ */
144
+ getAllTasks(): TaskNode[] {
145
+ return Object.values(this.graph.nodes);
146
+ }
147
+
148
+ /**
149
+ * Get topological order of tasks
150
+ */
151
+ getTopologicalOrder(): string[] {
152
+ return this.graph.topologicalOrder;
153
+ }
154
+
155
+ /**
156
+ * Check if all tasks are done
157
+ */
158
+ isComplete(): boolean {
159
+ return Object.values(this.graph.nodes).every((n) => n.status === "done");
160
+ }
161
+
162
+ /**
163
+ * Check if there are any failed tasks
164
+ */
165
+ hasFailedTasks(): boolean {
166
+ return Object.values(this.graph.nodes).some((n) => n.status === "failed");
167
+ }
168
+
169
+ /**
170
+ * Get failed tasks
171
+ */
172
+ getFailedTasks(): TaskNode[] {
173
+ return Object.values(this.graph.nodes).filter((n) => n.status === "failed");
174
+ }
175
+
176
+ /**
177
+ * Increment retry count for a task
178
+ */
179
+ incrementRetry(taskId: string): TaskNode | null {
180
+ const node = this.graph.nodes[taskId];
181
+ if (!node) return null;
182
+ node.retryCount = (node.retryCount ?? 0) + 1;
183
+ node.updatedAt = new Date().toISOString();
184
+ this.dirty = true;
185
+ return node;
186
+ }
187
+
188
+ /**
189
+ * Assign agent to task
190
+ */
191
+ assignAgent(
192
+ taskId: string,
193
+ agentId: string,
194
+ worktreePath?: string,
195
+ ): TaskNode | null {
196
+ const node = this.graph.nodes[taskId];
197
+ if (!node) return null;
198
+ node.assignedAgent = agentId;
199
+ if (worktreePath) node.worktreePath = worktreePath;
200
+ node.updatedAt = new Date().toISOString();
201
+ this.dirty = true;
202
+ return node;
203
+ }
204
+
205
+ /**
206
+ * Unassign agent from task
207
+ */
208
+ unassignAgent(taskId: string): TaskNode | null {
209
+ const node = this.graph.nodes[taskId];
210
+ if (!node) return null;
211
+ node.assignedAgent = undefined;
212
+ node.worktreePath = undefined;
213
+ node.updatedAt = new Date().toISOString();
214
+ this.dirty = true;
215
+ return node;
216
+ }
217
+
218
+ /**
219
+ * Check if task can be retried
220
+ */
221
+ canRetry(taskId: string): boolean {
222
+ const node = this.graph.nodes[taskId];
223
+ if (!node) return false;
224
+ const retries = node.retryCount ?? 0;
225
+ const max = node.maxRetries ?? 3;
226
+ return retries < max;
227
+ }
228
+
229
+ /**
230
+ * Get progress summary
231
+ */
232
+ getProgressSummary(): {
233
+ total: number;
234
+ done: number;
235
+ failed: number;
236
+ pending: number;
237
+ running: number;
238
+ } {
239
+ const tasks = Object.values(this.graph.nodes);
240
+ return {
241
+ total: tasks.length,
242
+ done: tasks.filter((t) => t.status === "done").length,
243
+ failed: tasks.filter((t) => t.status === "failed").length,
244
+ pending: tasks.filter(
245
+ (t) => t.status === "pending" || t.status === "ready",
246
+ ).length,
247
+ running: tasks.filter(
248
+ (t) =>
249
+ t.status === "running" ||
250
+ t.status === "testing" ||
251
+ t.status === "reviewing",
252
+ ).length,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Save graph to file
258
+ */
259
+ async save(rootDir: string): Promise<void> {
260
+ const path = join(rootDir, "jobs", this.graph.jobId, "task-graph.json");
261
+ writeJson(path, this.graph);
262
+ this.dirty = false;
263
+ }
264
+
265
+ /**
266
+ * Load graph from file
267
+ */
268
+ static async load(jobId: string, rootDir: string): Promise<TaskGraphManager> {
269
+ const path = join(rootDir, "jobs", jobId, "task-graph.json");
270
+ const data = readJson(path) as TaskGraph | null;
271
+
272
+ const manager = new TaskGraphManager({ jobId });
273
+ if (data?.nodes) {
274
+ manager.graph = data as TaskGraph;
275
+ }
276
+
277
+ return manager;
278
+ }
279
+
280
+ /**
281
+ * Check if graph has unsaved changes
282
+ */
283
+ isDirty(): boolean {
284
+ return this.dirty;
285
+ }
286
+
287
+ /**
288
+ * Get the full graph
289
+ */
290
+ getGraph(): TaskGraph {
291
+ return this.graph;
292
+ }
293
+
294
+ /**
295
+ * Compute topological order using Kahn's algorithm
296
+ */
297
+ private recomputeTopologicalOrder(): void {
298
+ const order: string[] = [];
299
+ const inDegree: Record<string, number> = {};
300
+ const nodes = this.graph.nodes;
301
+
302
+ // Initialize in-degrees
303
+ for (const id of Object.keys(nodes)) {
304
+ inDegree[id] = nodes[id].dependencies.length;
305
+ }
306
+
307
+ // Start with nodes that have no dependencies
308
+ const queue: string[] = [];
309
+ for (const id of Object.keys(nodes)) {
310
+ if (inDegree[id] === 0) {
311
+ queue.push(id);
312
+ }
313
+ }
314
+
315
+ while (queue.length > 0) {
316
+ const current = queue.shift()!;
317
+ order.push(current);
318
+
319
+ for (const dependent of nodes[current].dependents) {
320
+ inDegree[dependent]--;
321
+ if (inDegree[dependent] === 0) {
322
+ queue.push(dependent);
323
+ }
324
+ }
325
+ }
326
+
327
+ // Check for cycles (shouldn't happen with valid input)
328
+ if (order.length !== Object.keys(nodes).length) {
329
+ console.warn(
330
+ "Warning: topological sort detected a cycle in the task graph",
331
+ );
332
+ }
333
+
334
+ this.graph.topologicalOrder = order;
335
+ }
336
+ }