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.
- package/README.md +117 -158
- package/harness/agent-handoff.ts +189 -0
- package/harness/blackboard.ts +291 -0
- package/harness/context-window-manager.ts +210 -0
- package/harness/e2e/playwright-runner.ts +252 -0
- package/harness/e2e/test-engine.ts +402 -0
- package/harness/job-state-machine.ts +363 -0
- package/harness/loop-runtime.ts +337 -0
- package/harness/master-planner.ts +327 -0
- package/harness/project-detector/detector.ts +328 -0
- package/harness/repair-engine.ts +340 -0
- package/harness/task-graph.ts +336 -0
- package/index.ts +294 -2
- package/package.json +5 -2
- package/packages/checkpoint/README.md +3 -0
- package/packages/checkpoint/src/checkpoint-manager.ts +38 -0
- package/packages/provider-router/src/provider-router.ts +42 -0
- package/packages/providers/README.md +3 -0
- package/packages/providers/adapters.ts +261 -0
- package/packages/quota-manager/README.md +3 -0
- package/packages/quota-manager/quota-manager.ts +328 -0
- package/packages/runtime/README.md +3 -0
- package/packages/scheduler/README.md +3 -0
- package/packages/scheduler/src/scheduler.ts +50 -0
- package/packages/shared-context/README.md +3 -0
- package/packages/shared-context/src/shared-context.ts +42 -0
- package/packages/tui/README.md +3 -0
- package/packages/types/src/runtime-types.ts +439 -0
- package/packages/worktree/README.md +3 -0
- package/packages/worktree/worktree.ts +293 -0
- package/skills/harness-runtime/SKILL.md +186 -72
|
@@ -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
|
+
}
|