pi-harness-runtime 0.2.0 → 0.3.1

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,363 @@
1
+ /**
2
+ * Job State Machine — RFC-0015
3
+ *
4
+ * Manages job lifecycle states with:
5
+ * - Defined state transitions
6
+ * - Event emission on every transition
7
+ * - Automatic checkpointing
8
+ * - Transition guards (validity checks)
9
+ *
10
+ * States:
11
+ * created -> planning -> queued -> running -> testing -> e2e_testing ->
12
+ * reviewing -> repairing -> paused_quota -> waiting_human -> ready_for_client -> archived
13
+ */
14
+
15
+ import type {
16
+ JobStatus,
17
+ RuntimeEvent,
18
+ RuntimeCheckpoint,
19
+ } from "../packages/types/src/runtime-types.ts";
20
+
21
+ export interface StateTransition {
22
+ from: JobStatus;
23
+ to: JobStatus;
24
+ event: string;
25
+ message: string;
26
+ }
27
+
28
+ export interface TransitionResult {
29
+ success: boolean;
30
+ checkpoint?: RuntimeCheckpoint;
31
+ error?: string;
32
+ }
33
+
34
+ // Valid transitions map
35
+ const VALID_TRANSITIONS: Record<JobStatus, JobStatus[]> = {
36
+ created: ["planning"],
37
+ planning: ["queued", "cancelled"],
38
+ queued: ["running", "cancelled", "waiting_human"],
39
+ running: [
40
+ "testing",
41
+ "reviewing",
42
+ "repairing",
43
+ "paused_quota",
44
+ "blocked",
45
+ "waiting_human",
46
+ "cancelled",
47
+ ],
48
+ testing: [
49
+ "reviewing",
50
+ "running",
51
+ "repairing",
52
+ "paused_quota",
53
+ "waiting_human",
54
+ "cancelled",
55
+ ],
56
+ e2e_testing: [
57
+ "reviewing",
58
+ "repairing",
59
+ "paused_quota",
60
+ "waiting_human",
61
+ "cancelled",
62
+ ],
63
+ reviewing: [
64
+ "repairing",
65
+ "running",
66
+ "ready_for_client",
67
+ "paused_quota",
68
+ "waiting_human",
69
+ "cancelled",
70
+ ],
71
+ repairing: [
72
+ "running",
73
+ "testing",
74
+ "reviewing",
75
+ "paused_quota",
76
+ "waiting_human",
77
+ "cancelled",
78
+ ],
79
+ paused_quota: ["running", "waiting_human", "cancelled"],
80
+ waiting_human: ["running", "planning", "cancelled"],
81
+ blocked: ["running", "waiting_human", "cancelled"],
82
+ ready_for_client: ["archived", "repairing"],
83
+ cancelled: [],
84
+ archived: [],
85
+ };
86
+
87
+ export interface JobStateMachineOptions {
88
+ checkpointManager: CheckpointManager;
89
+ eventEmitter?: (event: RuntimeEvent) => void;
90
+ }
91
+
92
+ export interface CheckpointManager {
93
+ save(checkpoint: RuntimeCheckpoint): Promise<void>;
94
+ load(jobId: string): Promise<RuntimeCheckpoint | null>;
95
+ appendEvent(jobId: string, event: RuntimeEvent): Promise<void>;
96
+ }
97
+
98
+ export class JobStateMachine {
99
+ private currentCheckpoint: RuntimeCheckpoint | null = null;
100
+ private readonly eventLog: RuntimeEvent[] = [];
101
+
102
+ constructor(private readonly options: JobStateMachineOptions) {}
103
+
104
+ /**
105
+ * Initialize a new job with a requirement
106
+ */
107
+ async createJob(
108
+ jobId: string,
109
+ requirement: string,
110
+ ): Promise<TransitionResult> {
111
+ const now = new Date().toISOString();
112
+ const checkpoint: RuntimeCheckpoint = {
113
+ version: 1,
114
+ jobId,
115
+ status: "created",
116
+ requirement,
117
+ createdAt: now,
118
+ updatedAt: now,
119
+ };
120
+
121
+ const event = this.createEvent(
122
+ jobId,
123
+ "JobCreated",
124
+ `Job ${jobId} created with requirement`,
125
+ { requirement },
126
+ );
127
+
128
+ try {
129
+ await this.options.checkpointManager.save(checkpoint);
130
+ await this.options.checkpointManager.appendEvent(jobId, event);
131
+ this.currentCheckpoint = checkpoint;
132
+ this.eventLog.push(event);
133
+ return { success: true, checkpoint };
134
+ } catch (error) {
135
+ return { success: false, error: String(error) };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Resume a job from checkpoint
141
+ */
142
+ async resumeJob(jobId: string): Promise<TransitionResult> {
143
+ const checkpoint = await this.options.checkpointManager.load(jobId);
144
+ if (!checkpoint) {
145
+ return { success: false, error: `No checkpoint found for job ${jobId}` };
146
+ }
147
+ this.currentCheckpoint = checkpoint;
148
+ return { success: true, checkpoint };
149
+ }
150
+
151
+ /**
152
+ * Transition to a new state
153
+ */
154
+ async transition(
155
+ to: JobStatus,
156
+ data?: Partial<RuntimeCheckpoint>,
157
+ ): Promise<TransitionResult> {
158
+ if (!this.currentCheckpoint) {
159
+ return {
160
+ success: false,
161
+ error: "No active job. Call createJob() or resumeJob() first.",
162
+ };
163
+ }
164
+
165
+ const from = this.currentCheckpoint.status;
166
+
167
+ // Validate transition
168
+ if (!this.isValidTransition(from, to)) {
169
+ return {
170
+ success: false,
171
+ error: `Invalid transition: ${from} -> ${to}. Valid transitions: ${VALID_TRANSITIONS[from].join(", ") || "none"}`,
172
+ };
173
+ }
174
+
175
+ // Create updated checkpoint
176
+ const updated: RuntimeCheckpoint = {
177
+ ...this.currentCheckpoint,
178
+ status: to,
179
+ updatedAt: new Date().toISOString(),
180
+ ...data,
181
+ };
182
+
183
+ // Create event
184
+ const event = this.createEvent(
185
+ this.currentCheckpoint.jobId,
186
+ `StateTransition:${from}->${to}`,
187
+ `Transitioned from ${from} to ${to}`,
188
+ { from, to, ...data },
189
+ );
190
+
191
+ try {
192
+ await this.options.checkpointManager.save(updated);
193
+ await this.options.checkpointManager.appendEvent(
194
+ this.currentCheckpoint.jobId,
195
+ event,
196
+ );
197
+ this.currentCheckpoint = updated;
198
+ this.eventLog.push(event);
199
+ return { success: true, checkpoint: updated };
200
+ } catch (error) {
201
+ return { success: false, error: String(error) };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Check if a transition is valid
207
+ */
208
+ isValidTransition(from: JobStatus, to: JobStatus): boolean {
209
+ return VALID_TRANSITIONS[from]?.includes(to) ?? false;
210
+ }
211
+
212
+ /**
213
+ * Get available next states from current state
214
+ */
215
+ getAvailableTransitions(): JobStatus[] {
216
+ if (!this.currentCheckpoint) return [];
217
+ return VALID_TRANSITIONS[this.currentCheckpoint.status] ?? [];
218
+ }
219
+
220
+ /**
221
+ * Get current checkpoint
222
+ */
223
+ getCheckpoint(): RuntimeCheckpoint | null {
224
+ return this.currentCheckpoint;
225
+ }
226
+
227
+ /**
228
+ * Get event log
229
+ */
230
+ getEventLog(): RuntimeEvent[] {
231
+ return [...this.eventLog];
232
+ }
233
+
234
+ /**
235
+ * Set current task
236
+ */
237
+ async setCurrentTask(taskId: string): Promise<TransitionResult> {
238
+ return this.transition(this.currentCheckpoint!.status, {
239
+ currentTaskId: taskId,
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Set provider
245
+ */
246
+ async setProvider(provider: string): Promise<TransitionResult> {
247
+ return this.transition(this.currentCheckpoint!.status, { provider });
248
+ }
249
+
250
+ /**
251
+ * Set resume time (for quota pause)
252
+ */
253
+ async setResumeTime(resumeAt: string): Promise<TransitionResult> {
254
+ if (!this.currentCheckpoint) {
255
+ return { success: false, error: "No active job" };
256
+ }
257
+ this.currentCheckpoint.resumeAt = resumeAt;
258
+ this.currentCheckpoint.updatedAt = new Date().toISOString();
259
+ try {
260
+ await this.options.checkpointManager.save(this.currentCheckpoint);
261
+ return { success: true, checkpoint: this.currentCheckpoint };
262
+ } catch (error) {
263
+ return { success: false, error: String(error) };
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Record an error
269
+ */
270
+ async recordError(error: string): Promise<TransitionResult> {
271
+ if (!this.currentCheckpoint) {
272
+ return { success: false, error: "No active job" };
273
+ }
274
+ this.currentCheckpoint.lastError = error;
275
+ this.currentCheckpoint.updatedAt = new Date().toISOString();
276
+ try {
277
+ await this.options.checkpointManager.save(this.currentCheckpoint);
278
+ return { success: true, checkpoint: this.currentCheckpoint };
279
+ } catch (error) {
280
+ return { success: false, error: String(error) };
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Check if job is in a terminal state
286
+ */
287
+ isTerminal(): boolean {
288
+ if (!this.currentCheckpoint) return false;
289
+ return ["ready_for_client", "cancelled", "archived"].includes(
290
+ this.currentCheckpoint.status,
291
+ );
292
+ }
293
+
294
+ /**
295
+ * Check if job can be resumed
296
+ */
297
+ canResume(): boolean {
298
+ if (!this.currentCheckpoint) return false;
299
+ return (
300
+ this.currentCheckpoint.status === "paused_quota" ||
301
+ this.currentCheckpoint.status === "blocked"
302
+ );
303
+ }
304
+
305
+ /**
306
+ * Get job status summary
307
+ */
308
+ getStatusSummary(): {
309
+ jobId: string;
310
+ status: JobStatus;
311
+ isTerminal: boolean;
312
+ canResume: boolean;
313
+ } | null {
314
+ if (!this.currentCheckpoint) return null;
315
+ return {
316
+ jobId: this.currentCheckpoint.jobId,
317
+ status: this.currentCheckpoint.status,
318
+ isTerminal: this.isTerminal(),
319
+ canResume: this.canResume(),
320
+ };
321
+ }
322
+
323
+ private createEvent(
324
+ jobId: string,
325
+ type: string,
326
+ message: string,
327
+ data?: Record<string, unknown>,
328
+ ): RuntimeEvent {
329
+ return {
330
+ ts: new Date().toISOString(),
331
+ jobId,
332
+ type,
333
+ message,
334
+ data,
335
+ };
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Factory to create a state machine with a JsonCheckpointManager
341
+ */
342
+ export async function createJobStateMachine(
343
+ rootDir: string,
344
+ jobId?: string,
345
+ ): Promise<{ machine: JobStateMachine; checkpoint: RuntimeCheckpoint | null }> {
346
+ const { JsonCheckpointManager } = await import(
347
+ "../packages/checkpoint/src/checkpoint-manager.ts"
348
+ );
349
+
350
+ const manager = new JsonCheckpointManager(rootDir);
351
+
352
+ if (jobId) {
353
+ const checkpoint = await manager.load(jobId);
354
+ const machine = new JobStateMachine({ checkpointManager: manager });
355
+ if (checkpoint) {
356
+ machine.resumeJob(jobId);
357
+ }
358
+ return { machine, checkpoint };
359
+ }
360
+
361
+ const machine = new JobStateMachine({ checkpointManager: manager });
362
+ return { machine, checkpoint: null };
363
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Loop Runtime — RFC-0001
3
+ *
4
+ * Core execution loop for the harness runtime.
5
+ * Runs the repeated cycle:
6
+ * pick task -> assign model -> code -> run tests -> review diff
7
+ * -> if failed: repair
8
+ * -> if quota_limit: pause and resume later
9
+ * -> if blocked: escalate to human
10
+ */
11
+
12
+ import type {
13
+ LoopConfig,
14
+ LoopState,
15
+ JobStatus,
16
+ RuntimeCheckpoint,
17
+ RuntimeTask,
18
+ } from "../packages/types/src/runtime-types.ts";
19
+ import { JobStateMachine } from "./job-state-machine.ts";
20
+ import { TaskGraphManager } from "./task-graph.ts";
21
+ import { RepairEngine } from "./repair-engine.ts";
22
+ import type { CheckpointManager } from "./job-state-machine.ts";
23
+
24
+ export interface LoopResult {
25
+ success: boolean;
26
+ completed: boolean;
27
+ iterations: number;
28
+ error?: string;
29
+ }
30
+
31
+ export interface LoopCallbacks {
32
+ onTaskStart?: (taskId: string) => Promise<void>;
33
+ onTaskComplete?: (taskId: string, report: unknown) => Promise<void>;
34
+ onTaskFailure?: (taskId: string, error: string) => Promise<void>;
35
+ onQuotaExceeded?: (provider: string) => Promise<void>;
36
+ onHumanEscalation?: (taskId: string, reason: string) => Promise<void>;
37
+ onIteration?: (iteration: number, status: string) => Promise<void>;
38
+ invokeAgent?: (
39
+ task: RuntimeTask,
40
+ context: unknown,
41
+ ) => Promise<{ success: boolean; output?: string; error?: string }>;
42
+ runTests?: (
43
+ taskId: string,
44
+ worktreePath?: string,
45
+ ) => Promise<{ passed: boolean; output?: string }>;
46
+ runReview?: (
47
+ taskId: string,
48
+ diffPath: string,
49
+ ) => Promise<{ approved: boolean; comments?: string }>;
50
+ }
51
+
52
+ export class LoopRuntime {
53
+ private state: LoopState;
54
+ private machine: JobStateMachine;
55
+ private graph: TaskGraphManager;
56
+ private repairEngine: RepairEngine;
57
+ private callbacks: LoopCallbacks;
58
+ private running = false;
59
+ private paused = false;
60
+
61
+ constructor(
62
+ config: LoopConfig,
63
+ checkpointManager: CheckpointManager,
64
+ callbacks: LoopCallbacks,
65
+ ) {
66
+ this.state = {
67
+ jobId: config.jobId,
68
+ iteration: 0,
69
+ status: "running",
70
+ };
71
+
72
+ this.machine = new JobStateMachine({ checkpointManager });
73
+ this.graph = new TaskGraphManager({ jobId: config.jobId });
74
+ this.repairEngine = new RepairEngine(config.jobId);
75
+ this.callbacks = callbacks;
76
+ }
77
+
78
+ /**
79
+ * Initialize the loop with a job
80
+ */
81
+ async init(requirement: string): Promise<void> {
82
+ await this.machine.createJob(this.state.jobId, requirement);
83
+ await this.machine.transition("planning");
84
+ }
85
+
86
+ /**
87
+ * Resume from checkpoint
88
+ */
89
+ async resume(checkpoint: RuntimeCheckpoint): Promise<void> {
90
+ this.state.iteration = 0;
91
+ await this.machine.resumeJob(checkpoint.jobId);
92
+ }
93
+
94
+ /**
95
+ * Run the main loop
96
+ */
97
+ async run(): Promise<LoopResult> {
98
+ this.running = true;
99
+
100
+ try {
101
+ while (this.running) {
102
+ // Check for pause
103
+ if (this.paused) {
104
+ await this.saveCheckpoint();
105
+ break;
106
+ }
107
+
108
+ this.state.iteration++;
109
+
110
+ // Check iteration limit
111
+ if (this.state.iteration > 1000) {
112
+ return {
113
+ success: false,
114
+ completed: false,
115
+ iterations: this.state.iteration,
116
+ error: "Max iterations exceeded",
117
+ };
118
+ }
119
+
120
+ await this.callbacks.onIteration?.(
121
+ this.state.iteration,
122
+ this.state.status,
123
+ );
124
+
125
+ // Check job status
126
+ if (this.machine.isTerminal()) {
127
+ return {
128
+ success: true,
129
+ completed: true,
130
+ iterations: this.state.iteration,
131
+ };
132
+ }
133
+
134
+ // Pick next task
135
+ const nextTask = await this.pickNextTask();
136
+ if (!nextTask) {
137
+ // No more tasks, job is complete
138
+ await this.machine.transition("ready_for_client");
139
+ return {
140
+ success: true,
141
+ completed: true,
142
+ iterations: this.state.iteration,
143
+ };
144
+ }
145
+
146
+ this.state.currentTaskId = nextTask.id;
147
+ this.state.status = "running";
148
+
149
+ // Execute task
150
+ const result = await this.executeTask(nextTask);
151
+
152
+ if (!result.success) {
153
+ // Handle failure
154
+ const { repairTask } = this.repairEngine.analyzeAndRepair(
155
+ nextTask.id,
156
+ result.error ?? "Unknown error",
157
+ );
158
+
159
+ if (this.repairEngine.shouldEscalate(repairTask.id)) {
160
+ await this.machine.transition("waiting_human");
161
+ await this.callbacks.onHumanEscalation?.(
162
+ nextTask.id,
163
+ result.error ?? "Unknown error",
164
+ );
165
+ } else {
166
+ await this.machine.transition("repairing");
167
+ // Continue loop to attempt repair
168
+ }
169
+ } else {
170
+ // Task succeeded
171
+ this.graph.updateTaskStatus(nextTask.id, "done");
172
+ await this.machine.transition("testing");
173
+
174
+ // Run tests
175
+ const testResult = await this.callbacks.runTests?.(
176
+ nextTask.id,
177
+ nextTask.worktreePath,
178
+ );
179
+ if (!testResult?.passed) {
180
+ await this.machine.transition("repairing");
181
+ this.graph.updateTaskStatus(nextTask.id, "failed");
182
+ } else {
183
+ await this.machine.transition("reviewing");
184
+
185
+ // Run review
186
+ const reviewResult = await this.callbacks.runReview?.(
187
+ nextTask.id,
188
+ nextTask.worktreePath ?? "",
189
+ );
190
+ if (!reviewResult?.approved) {
191
+ await this.machine.transition("repairing");
192
+ }
193
+ }
194
+ }
195
+
196
+ await this.saveCheckpoint();
197
+ }
198
+
199
+ return {
200
+ success: true,
201
+ completed: false,
202
+ iterations: this.state.iteration,
203
+ };
204
+ } catch (error) {
205
+ return {
206
+ success: false,
207
+ completed: false,
208
+ iterations: this.state.iteration,
209
+ error: String(error),
210
+ };
211
+ } finally {
212
+ this.running = false;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Pick the next task to execute
218
+ */
219
+ private async pickNextTask(): Promise<RuntimeTask | null> {
220
+ const readyTasks = this.graph.getReadyTasks();
221
+
222
+ // Pick the first ready task that's not assigned
223
+ for (const task of readyTasks) {
224
+ if (!task.assignedAgent) {
225
+ this.graph.assignAgent(task.id, "loop-runtime");
226
+ return task as unknown as RuntimeTask;
227
+ }
228
+ }
229
+
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * Execute a single task
235
+ */
236
+ private async executeTask(task: RuntimeTask): Promise<{
237
+ success: boolean;
238
+ output?: string;
239
+ error?: string;
240
+ }> {
241
+ try {
242
+ await this.callbacks.onTaskStart?.(task.id);
243
+
244
+ const result = await this.callbacks.invokeAgent?.(task, {
245
+ jobId: this.state.jobId,
246
+ requirement: this.machine.getCheckpoint()?.requirement,
247
+ });
248
+
249
+ if (result?.success) {
250
+ await this.callbacks.onTaskComplete?.(task.id, {
251
+ output: result.output,
252
+ });
253
+ return { success: true, output: result.output };
254
+ } else {
255
+ const error = result?.error ?? "Agent returned failure";
256
+ await this.callbacks.onTaskFailure?.(task.id, error);
257
+ return { success: false, error };
258
+ }
259
+ } catch (error) {
260
+ return { success: false, error: String(error) };
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Pause the loop
266
+ */
267
+ pause(): void {
268
+ this.paused = true;
269
+ this.state.status = "paused";
270
+ }
271
+
272
+ /**
273
+ * Resume the loop
274
+ */
275
+ async resumeLoop(): Promise<void> {
276
+ this.paused = false;
277
+ this.state.status = "running";
278
+ }
279
+
280
+ /**
281
+ * Stop the loop
282
+ */
283
+ stop(): void {
284
+ this.running = false;
285
+ this.state.status = "failed";
286
+ }
287
+
288
+ /**
289
+ * Save checkpoint
290
+ */
291
+ private async saveCheckpoint(): Promise<void> {
292
+ const checkpoint = this.machine.getCheckpoint();
293
+ if (checkpoint) {
294
+ await this.machine.transition(checkpoint.status, {
295
+ currentTaskId: this.state.currentTaskId,
296
+ });
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Get current state
302
+ */
303
+ getState(): LoopState {
304
+ return this.state;
305
+ }
306
+
307
+ /**
308
+ * Get job status
309
+ */
310
+ getStatus(): {
311
+ status: JobStatus;
312
+ canResume: boolean;
313
+ isTerminal: boolean;
314
+ } | null {
315
+ const summary = this.machine.getStatusSummary();
316
+ if (!summary) return null;
317
+ return {
318
+ status: summary.status,
319
+ canResume: summary.canResume,
320
+ isTerminal: summary.isTerminal,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Check if loop is running
326
+ */
327
+ isRunning(): boolean {
328
+ return this.running && !this.paused;
329
+ }
330
+
331
+ /**
332
+ * Check if loop is paused
333
+ */
334
+ isPaused(): boolean {
335
+ return this.paused;
336
+ }
337
+ }