pi-harness-runtime 0.10.13 → 0.10.14

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,256 @@
1
+ /**
2
+ * Repair Engine — RFC-0018
3
+ *
4
+ * Converts failures into repair tasks with retry policy and escalation.
5
+ */
6
+ import { appendJsonl } from "../cli.ts";
7
+ // @ts-expect-error - Bun has built-in Node.js types
8
+ import { join, dirname } from "node:path";
9
+ // @ts-expect-error - Bun has built-in Node.js types
10
+ import { mkdirSync } from "node:fs";
11
+ const DEFAULT_RETRY_POLICY = {
12
+ maxRetries: 3,
13
+ backoffMs: 5000,
14
+ backoffMultiplier: 2,
15
+ escalationAfter: 2,
16
+ };
17
+ const FAILURE_PATTERNS = {
18
+ "test failed": "test_failure",
19
+ "assertion failed": "test_failure",
20
+ TypeError: "type_error",
21
+ SyntaxError: "build_error",
22
+ ESLint: "lint_error",
23
+ "build failed": "build_error",
24
+ "compilation error": "build_error",
25
+ quota: "quota_exhausted",
26
+ "rate limit": "quota_exhausted",
27
+ "429": "quota_exhausted",
28
+ "500": "provider_error",
29
+ "502": "provider_error",
30
+ "503": "provider_error",
31
+ connection: "provider_error",
32
+ timeout: "provider_error",
33
+ };
34
+ export class RepairEngine {
35
+ repairTasks = new Map();
36
+ rootDir;
37
+ constructor(rootDir) {
38
+ this.rootDir = rootDir;
39
+ }
40
+ /**
41
+ * Classify a failure type from error message
42
+ */
43
+ classifyFailure(errorMessage) {
44
+ const lower = errorMessage.toLowerCase();
45
+ for (const [pattern, type] of Object.entries(FAILURE_PATTERNS)) {
46
+ if (lower.includes(pattern.toLowerCase())) {
47
+ return type;
48
+ }
49
+ }
50
+ return "unknown";
51
+ }
52
+ /**
53
+ * Create a repair task from a failed task
54
+ */
55
+ createRepairTask(originalTaskId, failureType, errorMessage, options) {
56
+ const id = `repair-${originalTaskId}-${Date.now()}`;
57
+ const retryPolicy = {
58
+ ...DEFAULT_RETRY_POLICY,
59
+ ...options?.retryPolicy,
60
+ };
61
+ const task = {
62
+ id,
63
+ originalTaskId,
64
+ failureType,
65
+ description: `Fix failure in task ${originalTaskId}: ${errorMessage}`,
66
+ attemptedFixes: [],
67
+ status: "pending",
68
+ retryPolicy,
69
+ createdAt: new Date().toISOString(),
70
+ };
71
+ this.repairTasks.set(id, task);
72
+ return task;
73
+ }
74
+ /**
75
+ * Record an attempted fix
76
+ */
77
+ recordAttempt(repairTaskId, description, success, output) {
78
+ const task = this.repairTasks.get(repairTaskId);
79
+ if (!task)
80
+ return;
81
+ const attempt = {
82
+ attempt: task.attemptedFixes.length + 1,
83
+ description,
84
+ success,
85
+ output,
86
+ timestamp: new Date().toISOString(),
87
+ };
88
+ task.attemptedFixes.push(attempt);
89
+ if (success) {
90
+ task.status = "resolved";
91
+ task.resolvedAt = new Date().toISOString();
92
+ }
93
+ else if (attempt.attempt >= task.retryPolicy.maxRetries) {
94
+ // Check if should escalate
95
+ if (task.retryPolicy.escalationAfter &&
96
+ attempt.attempt >= task.retryPolicy.escalationAfter) {
97
+ task.status = "escalated";
98
+ }
99
+ }
100
+ this.repairTasks.set(repairTaskId, task);
101
+ this.saveRepairTask(task);
102
+ }
103
+ /**
104
+ * Get the next retry delay in milliseconds
105
+ */
106
+ getNextRetryDelay(repairTaskId) {
107
+ const task = this.repairTasks.get(repairTaskId);
108
+ if (!task)
109
+ return null;
110
+ const attempt = task.attemptedFixes.length;
111
+ if (attempt >= task.retryPolicy.maxRetries)
112
+ return null;
113
+ const { backoffMs, backoffMultiplier } = task.retryPolicy;
114
+ return backoffMs * backoffMultiplier ** attempt;
115
+ }
116
+ /**
117
+ * Check if repair task should escalate to human
118
+ */
119
+ shouldEscalate(repairTaskId) {
120
+ const task = this.repairTasks.get(repairTaskId);
121
+ if (!task)
122
+ return false;
123
+ return task.status === "escalated";
124
+ }
125
+ /**
126
+ * Get all repair tasks for a job
127
+ */
128
+ getRepairTasks(jobId) {
129
+ // const path = join(this.rootDir, "jobs", jobId, "repair-tasks.jsonl");
130
+ // In practice, this would read from file. For now, return in-memory.
131
+ return Array.from(this.repairTasks.values()).filter((t) => t.originalTaskId.startsWith(`task-${jobId}`));
132
+ }
133
+ /**
134
+ * Get repair summary
135
+ */
136
+ getSummary(jobId) {
137
+ const tasks = this.getRepairTasks(jobId);
138
+ return {
139
+ total: tasks.length,
140
+ pending: tasks.filter((t) => t.status === "pending" || t.status === "in_progress").length,
141
+ resolved: tasks.filter((t) => t.status === "resolved").length,
142
+ escalated: tasks.filter((t) => t.status === "escalated").length,
143
+ };
144
+ }
145
+ /**
146
+ * Analyze a failure and create repair task with guidance
147
+ */
148
+ analyzeAndRepair(originalTaskId, errorMessage, options) {
149
+ const failureType = this.classifyFailure(errorMessage);
150
+ const repairTask = this.createRepairTask(originalTaskId, failureType, errorMessage, options);
151
+ const guidance = this.generateGuidance(repairTask);
152
+ return { repairTask, guidance };
153
+ }
154
+ /**
155
+ * Generate repair guidance based on failure type
156
+ */
157
+ generateGuidance(repairTask) {
158
+ switch (repairTask.failureType) {
159
+ case "test_failure":
160
+ return `Analyze the failing test. Check:
161
+ 1. Is the test correct? (Does it test what it claims?)
162
+ 2. Is the implementation correct? (Does it match the expected behavior?)
163
+ 3. Are there any recent changes that broke the test?
164
+ 4. Are there any environment-specific issues?`;
165
+ case "build_error":
166
+ return `Check the build error details:
167
+ 1. Look at the exact error message and line number
168
+ 2. Check for missing imports or type mismatches
169
+ 3. Verify all dependencies are installed
170
+ 4. Check for circular dependencies`;
171
+ case "type_error":
172
+ return `Fix TypeScript/JavaScript type errors:
173
+ 1. Check the type of each variable
174
+ 2. Ensure type assertions are correct
175
+ 3. Verify interface/type definitions match usage
176
+ 4. Consider adding type guards if needed`;
177
+ case "lint_error":
178
+ return `Fix linting errors:
179
+ 1. Review the linting rules being violated
180
+ 2. Check if code style matches project conventions
181
+ 3. Run linter with --fix if available
182
+ 4. Update eslint/prettier config if needed`;
183
+ case "quota_exhausted":
184
+ return `Quota exhausted. Options:
185
+ 1. Wait for quota reset
186
+ 2. Switch to an alternative provider
187
+ 3. Optimize prompts to use fewer tokens
188
+ 4. Reduce task scope to fit within quota`;
189
+ case "provider_error":
190
+ return `Provider error. Actions:
191
+ 1. Retry with exponential backoff
192
+ 2. Check provider status page
193
+ 3. Switch to fallback provider if available
194
+ 4. Log error details for debugging`;
195
+ case "e2e_failure":
196
+ return `E2E test failed. Check:
197
+ 1. Is the page/app in the expected state?
198
+ 2. Are selectors still valid?
199
+ 3. Is there a timing issue (need to wait longer)?
200
+ 4. Is the test assertion correct?`;
201
+ default:
202
+ return `Unknown failure. Gather more information:
203
+ 1. Collect full error stack trace
204
+ 2. Check system logs
205
+ 3. Reproduce the issue locally
206
+ 4. Consider asking for human assistance`;
207
+ }
208
+ }
209
+ /**
210
+ * Save repair task to file
211
+ */
212
+ saveRepairTask(task) {
213
+ const path = join(this.rootDir, "jobs", task.originalTaskId.split("-")[1] ?? "unknown", "repair-tasks.jsonl");
214
+ mkdirSync(dirname(path), { recursive: true });
215
+ appendJsonl(path, task);
216
+ }
217
+ /**
218
+ * Load repair tasks from file
219
+ */
220
+ loadRepairTasks(_jobId) {
221
+ // const path = join(this.rootDir, "jobs", _jobId, "repair-tasks.jsonl");
222
+ // In practice, this would read from file and populate the map
223
+ }
224
+ /**
225
+ * Export repair report
226
+ */
227
+ exportReport(jobId) {
228
+ const tasks = this.getRepairTasks(jobId);
229
+ const summary = this.getSummary(jobId);
230
+ const lines = [
231
+ `Repair Report for Job ${jobId}`,
232
+ "=".repeat(50),
233
+ `Total: ${summary.total}`,
234
+ `Pending: ${summary.pending}`,
235
+ `Resolved: ${summary.resolved}`,
236
+ `Escalated: ${summary.escalated}`,
237
+ "",
238
+ "Details:",
239
+ "-".repeat(50),
240
+ ];
241
+ for (const task of tasks) {
242
+ lines.push(`\n[${task.status.toUpperCase()}] ${task.id}`);
243
+ lines.push(` Original: ${task.originalTaskId}`);
244
+ lines.push(` Type: ${task.failureType}`);
245
+ lines.push(` Description: ${task.description}`);
246
+ lines.push(` Attempts: ${task.attemptedFixes.length}/${task.retryPolicy.maxRetries}`);
247
+ if (task.attemptedFixes.length > 0) {
248
+ lines.push(" Attempt History:");
249
+ for (const fix of task.attemptedFixes) {
250
+ lines.push(` - [${fix.success ? "✓" : "✗"}] ${fix.description}`);
251
+ }
252
+ }
253
+ }
254
+ return lines.join("\n");
255
+ }
256
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Session Memory — Persistent Knowledge Extraction
3
+ *
4
+ * Extracts key facts and decisions to persistent storage.
5
+ * On resume, re-injects memory as context without re-sending full history.
6
+ *
7
+ * Based on the reference implementation's sessionMemoryCompact pattern.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ // --- Session Memory Manager -------------------------------------------------
12
+ export class SessionMemoryManager {
13
+ memory;
14
+ memoryPath;
15
+ maxFacts = 50;
16
+ maxDecisions = 20;
17
+ constructor(jobId, rootDir) {
18
+ this.memoryPath = join(rootDir ?? join(process.env.HOME ?? ".", ".pi", "harness", jobId), "session-memory.json");
19
+ this.memory = this.load() ?? {
20
+ jobId,
21
+ extractedFacts: [],
22
+ decisions: [],
23
+ filesModified: [],
24
+ testsStatus: [],
25
+ artifacts: {},
26
+ lastUpdated: new Date().toISOString(),
27
+ };
28
+ }
29
+ // --- Public API -----------------------------------------------------------
30
+ /**
31
+ * Extract key facts from recent messages
32
+ */
33
+ extractFacts(messages) {
34
+ for (const msg of messages) {
35
+ const content = msg.content;
36
+ // File creation patterns
37
+ for (const match of content.matchAll(/(?:created|created file|created module):?\s*([^\n.]+)/gi)) {
38
+ if (match[1]) {
39
+ this.addFileReference(match[1].trim(), "created");
40
+ }
41
+ }
42
+ // File modification patterns
43
+ for (const match of content.matchAll(/(?:modified|updated|changed):?\s*([^\n.]+)/gi)) {
44
+ if (match[1] && !/(?:file|module|code)/i.test(match[0])) {
45
+ this.addFileReference(match[1].trim(), "modified");
46
+ }
47
+ }
48
+ // Test result patterns
49
+ for (const match of content.matchAll(/(?:test|spec):?\s*([^\n.]+)\s*(passed|failed|pass|fail)/gi)) {
50
+ if (match[1] && match[2]) {
51
+ this.addTestResult(match[1].trim(), /^pass$/i.test(match[2]));
52
+ }
53
+ }
54
+ // Decision patterns
55
+ for (const match of content.matchAll(/(?:decided|choosing|chose|decision|approach|using):?\s*([^\n.]+)/gi)) {
56
+ if (match[1] && match[1].length > 10) {
57
+ this.recordDecision(match[1].trim());
58
+ }
59
+ }
60
+ // Key facts patterns
61
+ for (const match of content.matchAll(/(?:important|key|note|facts?:)\s*([^\n]+)/gi)) {
62
+ if (match[1] && match[1].length > 20) {
63
+ this.recordFact(match[1].trim());
64
+ }
65
+ }
66
+ }
67
+ this.memory.lastUpdated = new Date().toISOString();
68
+ this.save();
69
+ }
70
+ /**
71
+ * Add a decision to memory
72
+ */
73
+ recordDecision(text, rationale) {
74
+ // Avoid exact duplicates
75
+ if (this.memory.decisions.some((d) => d.text === text)) {
76
+ return;
77
+ }
78
+ this.memory.decisions.push({
79
+ text,
80
+ rationale,
81
+ timestamp: new Date().toISOString(),
82
+ });
83
+ // Trim to max
84
+ if (this.memory.decisions.length > this.maxDecisions) {
85
+ this.memory.decisions = this.memory.decisions.slice(-this.maxDecisions);
86
+ }
87
+ this.save();
88
+ }
89
+ /**
90
+ * Add a fact to memory
91
+ */
92
+ recordFact(fact) {
93
+ // Avoid duplicates
94
+ if (this.memory.extractedFacts.includes(fact)) {
95
+ return;
96
+ }
97
+ this.memory.extractedFacts.push(fact);
98
+ // Trim to max
99
+ if (this.memory.extractedFacts.length > this.maxFacts) {
100
+ this.memory.extractedFacts = this.memory.extractedFacts.slice(-this.maxFacts);
101
+ }
102
+ this.save();
103
+ }
104
+ /**
105
+ * Add a file reference
106
+ */
107
+ addFileReference(path, action) {
108
+ // Avoid exact duplicates
109
+ if (this.memory.filesModified.some((f) => f.path === path)) {
110
+ return;
111
+ }
112
+ this.memory.filesModified.push({
113
+ path,
114
+ action,
115
+ timestamp: new Date().toISOString(),
116
+ });
117
+ this.save();
118
+ }
119
+ /**
120
+ * Add a test result
121
+ */
122
+ addTestResult(name, passed) {
123
+ this.memory.testsStatus.push({
124
+ name,
125
+ passed,
126
+ timestamp: new Date().toISOString(),
127
+ });
128
+ this.save();
129
+ }
130
+ /**
131
+ * Store an artifact (e.g., generated code snippet)
132
+ */
133
+ storeArtifact(key, content) {
134
+ this.memory.artifacts[key] = content;
135
+ this.save();
136
+ }
137
+ /**
138
+ * Get memory for re-injection as context
139
+ */
140
+ getMemoryForContext() {
141
+ const parts = [];
142
+ // Files modified
143
+ if (this.memory.filesModified.length > 0) {
144
+ parts.push("## Files Modified");
145
+ for (const file of this.memory.filesModified.slice(-10)) {
146
+ parts.push(`- [${file.action}] ${file.path}`);
147
+ }
148
+ parts.push("");
149
+ }
150
+ // Key decisions
151
+ if (this.memory.decisions.length > 0) {
152
+ parts.push("## Key Decisions");
153
+ for (const decision of this.memory.decisions.slice(-5)) {
154
+ parts.push(`- ${decision.text}`);
155
+ if (decision.rationale) {
156
+ parts.push(` Reason: ${decision.rationale}`);
157
+ }
158
+ }
159
+ parts.push("");
160
+ }
161
+ // Extracted facts
162
+ if (this.memory.extractedFacts.length > 0) {
163
+ parts.push("## Key Facts");
164
+ for (const fact of this.memory.extractedFacts.slice(-10)) {
165
+ parts.push(`- ${fact}`);
166
+ }
167
+ parts.push("");
168
+ }
169
+ // Test status
170
+ if (this.memory.testsStatus.length > 0) {
171
+ const passed = this.memory.testsStatus.filter((t) => t.passed).length;
172
+ const total = this.memory.testsStatus.length;
173
+ parts.push(`## Tests: ${passed}/${total} passed`);
174
+ parts.push("");
175
+ }
176
+ if (parts.length === 0) {
177
+ return "";
178
+ }
179
+ return ["## Session Memory", "", ...parts.slice(0, -1)].join("\n");
180
+ }
181
+ /**
182
+ * Get compact-worthy content (for session memory compaction)
183
+ */
184
+ getCompactContent() {
185
+ const lines = [];
186
+ if (this.memory.filesModified.length > 0) {
187
+ lines.push("Files modified:");
188
+ for (const file of this.memory.filesModified) {
189
+ lines.push(` ${file.action}: ${file.path}`);
190
+ }
191
+ }
192
+ if (this.memory.decisions.length > 0) {
193
+ lines.push("Decisions:");
194
+ for (const d of this.memory.decisions) {
195
+ lines.push(` - ${d.text}`);
196
+ }
197
+ }
198
+ if (this.memory.extractedFacts.length > 0) {
199
+ lines.push("Facts:");
200
+ for (const f of this.memory.extractedFacts) {
201
+ lines.push(` - ${f}`);
202
+ }
203
+ }
204
+ return lines.join("\n");
205
+ }
206
+ /**
207
+ * Get test summary
208
+ */
209
+ getTestSummary() {
210
+ const passed = this.memory.testsStatus.filter((t) => t.passed).length;
211
+ const total = this.memory.testsStatus.length;
212
+ return `${passed}/${total}`;
213
+ }
214
+ /**
215
+ * Check if all tests passed
216
+ */
217
+ allTestsPassed() {
218
+ return (this.memory.testsStatus.length > 0 &&
219
+ this.memory.testsStatus.every((t) => t.passed));
220
+ }
221
+ /**
222
+ * Clear memory (e.g., after successful task completion)
223
+ */
224
+ clear() {
225
+ this.memory.extractedFacts = [];
226
+ this.memory.decisions = [];
227
+ this.memory.filesModified = [];
228
+ this.memory.testsStatus = [];
229
+ this.memory.artifacts = {};
230
+ this.memory.lastUpdated = new Date().toISOString();
231
+ this.save();
232
+ }
233
+ /**
234
+ * Get raw memory object
235
+ */
236
+ getMemory() {
237
+ return { ...this.memory };
238
+ }
239
+ /**
240
+ * Update from another memory object (for merge on resume)
241
+ */
242
+ merge(other) {
243
+ if (other.extractedFacts) {
244
+ for (const fact of other.extractedFacts) {
245
+ if (!this.memory.extractedFacts.includes(fact)) {
246
+ this.memory.extractedFacts.push(fact);
247
+ }
248
+ }
249
+ }
250
+ if (other.decisions) {
251
+ for (const decision of other.decisions) {
252
+ if (!this.memory.decisions.some((d) => d.text === decision.text)) {
253
+ this.memory.decisions.push(decision);
254
+ }
255
+ }
256
+ }
257
+ if (other.filesModified) {
258
+ for (const file of other.filesModified) {
259
+ if (!this.memory.filesModified.some((f) => f.path === file.path)) {
260
+ this.memory.filesModified.push(file);
261
+ }
262
+ }
263
+ }
264
+ this.memory.lastUpdated = new Date().toISOString();
265
+ this.save();
266
+ }
267
+ // --- Private Methods --------------------------------------------------
268
+ load() {
269
+ if (!existsSync(this.memoryPath)) {
270
+ return null;
271
+ }
272
+ try {
273
+ return JSON.parse(readFileSync(this.memoryPath, "utf-8"));
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ }
279
+ save() {
280
+ const dir = this.memoryPath.substring(0, this.memoryPath.lastIndexOf("/"));
281
+ if (!existsSync(dir)) {
282
+ mkdirSync(dir, { recursive: true });
283
+ }
284
+ writeFileSync(this.memoryPath, JSON.stringify(this.memory, null, 2), "utf-8");
285
+ }
286
+ }
287
+ // --- Factory ----------------------------------------------------------------
288
+ /**
289
+ * Create a SessionMemoryManager for a job
290
+ */
291
+ export function createSessionMemoryManager(jobId, rootDir) {
292
+ return new SessionMemoryManager(jobId, rootDir);
293
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Task Graph Manager — RFC-0028 Phase 3
3
+ *
4
+ * Manages task dependency graph for compact-aware scheduling.
5
+ * Currently a stub — integrates with CompactOrchestrator for
6
+ * dependency-aware compaction prioritization.
7
+ *
8
+ * Future work:
9
+ * - Track task dependencies (blocking/ready tasks)
10
+ * - Prioritize compaction to keep blocking path messages
11
+ * - Track which tasks depend on which checkpoint artifacts
12
+ */
13
+ /**
14
+ * Creates a new TaskGraphManager instance.
15
+ * Stub implementation — returns minimal working graph.
16
+ */
17
+ export function createTaskGraphManager() {
18
+ const nodes = new Map();
19
+ return {
20
+ addTask(task) {
21
+ if (!nodes.has(task.id)) {
22
+ nodes.set(task.id, {
23
+ task,
24
+ dependencies: new Set(),
25
+ dependents: new Set(),
26
+ status: "pending",
27
+ });
28
+ }
29
+ },
30
+ completeTask(taskId) {
31
+ const node = nodes.get(taskId);
32
+ if (!node)
33
+ return;
34
+ node.status = "done";
35
+ // Update dependents to ready
36
+ for (const depId of node.dependents) {
37
+ const depNode = nodes.get(depId);
38
+ if (!depNode)
39
+ continue;
40
+ // Check if all dependencies are met
41
+ const allDepsMet = [...depNode.dependencies].every((depId) => nodes.get(depId)?.status === "done");
42
+ if (allDepsMet) {
43
+ depNode.status = "ready";
44
+ }
45
+ }
46
+ },
47
+ getReadyTasks() {
48
+ return [...nodes.values()]
49
+ .filter((n) => n.status === "ready")
50
+ .map((n) => n.task);
51
+ },
52
+ getBlockedTasks(_taskId) {
53
+ // Stub: return empty array
54
+ return [];
55
+ },
56
+ getPendingTasks() {
57
+ return [...nodes.values()]
58
+ .filter((n) => n.status === "pending")
59
+ .map((n) => n.task);
60
+ },
61
+ getAllTasks() {
62
+ return [...nodes.values()].map((n) => n.task);
63
+ },
64
+ getProgressSummary() {
65
+ const allNodes = [...nodes.values()];
66
+ return {
67
+ total: allNodes.length,
68
+ done: allNodes.filter((n) => n.status === "done").length,
69
+ running: allNodes.filter((n) => n.status === "running").length,
70
+ failed: allNodes.filter((n) => n.status === "blocked").length,
71
+ };
72
+ },
73
+ getCompactPriority() {
74
+ // Stub: keep done tasks, prune pending ones
75
+ const done = [...nodes.values()]
76
+ .filter((n) => n.status === "done")
77
+ .map((n) => n.task.id);
78
+ const pending = [...nodes.values()]
79
+ .filter((n) => n.status === "pending")
80
+ .map((n) => n.task.id);
81
+ return {
82
+ keep: done, // Keep done task context (they're relevant)
83
+ prune: pending, // Prune pending task messages first
84
+ };
85
+ },
86
+ };
87
+ }