pi-long-task 0.3.9 → 0.3.11

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,228 @@
1
+ import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ GOAL_LOOP_STATE_SCHEMA_VERSION,
6
+ type GoalIterationState,
7
+ type GoalLoopState,
8
+ type GoalLoopTraceEvent,
9
+ validateGoalLoopState,
10
+ } from "./goal_loop.ts";
11
+ import { type GoalSpecification, validateGoalSpecification } from "./goal_spec.ts";
12
+
13
+ export const GOAL_STATE_FILE = "GOAL_STATE.json";
14
+ export const GOAL_TRACE_FILE = "GOAL_TRACE.jsonl";
15
+ export const GOAL_RESULT_FILE = "GOAL_RESULT.md";
16
+ export const GOAL_SPEC_FILE = "GOAL_SPEC.json";
17
+
18
+ export interface GoalStateStoreOptions {
19
+ cwd?: string;
20
+ goalRunId: string;
21
+ goalRunDir?: string;
22
+ }
23
+
24
+ export interface GoalStateStorePaths {
25
+ goalRunId: string;
26
+ goalRunDir: string;
27
+ statePath: string;
28
+ tracePath: string;
29
+ resultPath: string;
30
+ goalSpecPath: string;
31
+ iterationsDir: string;
32
+ }
33
+
34
+ export class GoalStateStore {
35
+ readonly paths: GoalStateStorePaths;
36
+
37
+ constructor(options: GoalStateStoreOptions) {
38
+ const cwd = path.resolve(options.cwd ?? process.cwd());
39
+ const goalRunDir = options.goalRunDir ?? path.join(cwd, "tmp", "pi-goal-task", options.goalRunId);
40
+ this.paths = {
41
+ goalRunId: options.goalRunId,
42
+ goalRunDir,
43
+ statePath: path.join(goalRunDir, GOAL_STATE_FILE),
44
+ tracePath: path.join(goalRunDir, GOAL_TRACE_FILE),
45
+ resultPath: path.join(goalRunDir, GOAL_RESULT_FILE),
46
+ goalSpecPath: path.join(goalRunDir, GOAL_SPEC_FILE),
47
+ iterationsDir: path.join(goalRunDir, "iterations"),
48
+ };
49
+ }
50
+
51
+ async ensureRunDir(): Promise<void> {
52
+ await mkdir(this.paths.iterationsDir, { recursive: true });
53
+ }
54
+
55
+ async saveState(state: GoalLoopState): Promise<void> {
56
+ validateGoalLoopState(state);
57
+ await this.ensureRunDir();
58
+ await atomicWriteFile(this.paths.statePath, `${JSON.stringify(state, null, 2)}\n`);
59
+ }
60
+
61
+ async loadState(): Promise<GoalLoopState> {
62
+ const text = await readFile(this.paths.statePath, "utf8");
63
+ return validateGoalLoopState(JSON.parse(text));
64
+ }
65
+
66
+ async saveGoalSpecification(spec: GoalSpecification): Promise<void> {
67
+ validateGoalSpecification(spec);
68
+ await this.ensureRunDir();
69
+ await atomicWriteFile(this.paths.goalSpecPath, `${JSON.stringify(spec, null, 2)}\n`);
70
+ }
71
+
72
+ async loadGoalSpecification(): Promise<GoalSpecification> {
73
+ const text = await readFile(this.paths.goalSpecPath, "utf8");
74
+ return validateGoalSpecification(JSON.parse(text));
75
+ }
76
+
77
+ async tryLoadGoalSpecification(): Promise<GoalSpecification | undefined> {
78
+ try {
79
+ return await this.loadGoalSpecification();
80
+ } catch (error) {
81
+ if (isNodeErrnoException(error) && error.code === "ENOENT") {
82
+ return undefined;
83
+ }
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ async appendTrace(event: GoalLoopTraceEvent): Promise<void> {
89
+ await this.ensureRunDir();
90
+ await appendFile(this.paths.tracePath, `${JSON.stringify(event)}\n`, "utf8");
91
+ }
92
+
93
+ async appendNewTraceEvents(previousTraceLength: number, state: GoalLoopState): Promise<void> {
94
+ const events = state.trace.slice(Math.max(0, previousTraceLength));
95
+ for (const event of events) {
96
+ await this.appendTrace(event);
97
+ }
98
+ }
99
+
100
+ async initializeResult(state: GoalLoopState): Promise<void> {
101
+ validateGoalLoopState(state);
102
+ await this.ensureRunDir();
103
+ const lines = [
104
+ "# Pi Goal Task Result",
105
+ "",
106
+ `Run: ${state.goalRunId}`,
107
+ `Goal: ${state.goal}`,
108
+ `Started: ${state.startedAt}`,
109
+ `State: ${this.paths.statePath}`,
110
+ `Trace: ${this.paths.tracePath}`,
111
+ `Goal specification: ${this.paths.goalSpecPath}`,
112
+ "",
113
+ "## Safety limits",
114
+ "",
115
+ `- Minimum iterations before completion: ${state.limits.minIterations}`,
116
+ `- Max iterations: ${state.limits.maxIterations}`,
117
+ `- Run timeout: ${state.limits.timeoutMs}ms`,
118
+ `- Iteration timeout: ${state.limits.iterationTimeoutMs}ms`,
119
+ `- Reviewer timeout: ${state.limits.reviewerTimeoutMs}ms`,
120
+ "",
121
+ ];
122
+ await writeFile(this.paths.resultPath, `${lines.join("\n")}\n`, "utf8");
123
+ }
124
+
125
+ async appendIterationResult(iteration: GoalIterationState): Promise<void> {
126
+ await this.ensureRunDir();
127
+ const lines = [
128
+ "",
129
+ `## Iteration ${iteration.iteration}`,
130
+ "",
131
+ `Status: ${iteration.status}`,
132
+ `Started: ${iteration.startedAt}`,
133
+ `Updated: ${iteration.updatedAt}`,
134
+ ];
135
+ if (iteration.deadlineAt) {
136
+ lines.push(`Deadline: ${iteration.deadlineAt}`);
137
+ }
138
+ if (iteration.generatedTodo) {
139
+ lines.push("", "### Generated TODO", "", `Path: ${iteration.generatedTodo.todoPath}`);
140
+ if (iteration.generatedTodo.summary) {
141
+ lines.push(`Summary: ${iteration.generatedTodo.summary}`);
142
+ }
143
+ }
144
+ if (iteration.workerResult) {
145
+ lines.push(
146
+ "",
147
+ "### Worker result",
148
+ "",
149
+ `Status: ${iteration.workerResult.status}`,
150
+ `Summary: ${iteration.workerResult.summary}`,
151
+ );
152
+ if (iteration.workerResult.resultPath) {
153
+ lines.push(`Result path: ${iteration.workerResult.resultPath}`);
154
+ }
155
+ if (iteration.workerResult.todoPath) {
156
+ lines.push(`TODO path: ${iteration.workerResult.todoPath}`);
157
+ }
158
+ if (iteration.workerResult.taskResultPath) {
159
+ lines.push(`Task result path: ${iteration.workerResult.taskResultPath}`);
160
+ }
161
+ if (iteration.workerResult.workerProgressPath) {
162
+ lines.push(`Worker progress log: ${iteration.workerResult.workerProgressPath}`);
163
+ }
164
+ if (iteration.workerResult.error) {
165
+ lines.push(`Error: ${iteration.workerResult.error}`);
166
+ }
167
+ }
168
+ if (iteration.reviewerResult) {
169
+ lines.push(
170
+ "",
171
+ "### Reviewer result",
172
+ "",
173
+ `Decision: ${iteration.reviewerResult.decision}`,
174
+ `Complete: ${iteration.reviewerResult.complete ? "yes" : "no"}`,
175
+ `Summary: ${iteration.reviewerResult.summary}`,
176
+ `Rationale: ${iteration.reviewerResult.rationale}`,
177
+ );
178
+ if (iteration.reviewerResult.remainingWork.length > 0) {
179
+ lines.push("", "Remaining work:", ...iteration.reviewerResult.remainingWork.map((item) => `- ${item}`));
180
+ }
181
+ }
182
+ if (iteration.completion) {
183
+ lines.push(
184
+ "",
185
+ "### Completion",
186
+ "",
187
+ `Status: ${iteration.completion.status}`,
188
+ `Reason: ${iteration.completion.reason}`,
189
+ );
190
+ }
191
+ await appendFile(this.paths.resultPath, `${lines.join("\n")}\n`, "utf8");
192
+ }
193
+
194
+ async writeIterationSnapshot(iteration: GoalIterationState): Promise<string> {
195
+ const iterationDir = this.iterationDir(iteration.iteration);
196
+ await mkdir(iterationDir, { recursive: true });
197
+ const snapshotPath = path.join(iterationDir, "ITERATION_STATE.json");
198
+ await atomicWriteFile(snapshotPath, `${JSON.stringify(iteration, null, 2)}\n`);
199
+ return snapshotPath;
200
+ }
201
+
202
+ iterationDir(iteration: number): string {
203
+ return path.join(this.paths.iterationsDir, String(iteration).padStart(2, "0"));
204
+ }
205
+ }
206
+
207
+ export function goalStatePaths(options: GoalStateStoreOptions): GoalStateStorePaths {
208
+ return new GoalStateStore(options).paths;
209
+ }
210
+
211
+ async function atomicWriteFile(filePath: string, content: string): Promise<void> {
212
+ await mkdir(path.dirname(filePath), { recursive: true });
213
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
214
+ await writeFile(tmpPath, content, "utf8");
215
+ await rename(tmpPath, filePath);
216
+ }
217
+
218
+ export function isGoalLoopState(value: unknown): value is GoalLoopState {
219
+ try {
220
+ return validateGoalLoopState(value).schemaVersion === GOAL_LOOP_STATE_SCHEMA_VERSION;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ function isNodeErrnoException(error: unknown): error is NodeJS.ErrnoException {
227
+ return error instanceof Error && "code" in error;
228
+ }
@@ -0,0 +1,309 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ runCoordinator,
6
+ type CoordinatorProgressUpdate,
7
+ type CoordinatorResult,
8
+ type RunCoordinatorOptions,
9
+ } from "./coordinator.ts";
10
+ import {
11
+ cancelGoalLoop,
12
+ type GoalIterationState,
13
+ type GoalLoopState,
14
+ type GoalWorkerResultState,
15
+ recordWorkerResult,
16
+ } from "./goal_loop.ts";
17
+ import { GoalStateStore } from "./goal_state.ts";
18
+ import { validateTodoMarkdown } from "./todo_generator.ts";
19
+
20
+ export const GOAL_TODO_EXECUTION_PROGRESS_FILE = "WORKER_PROGRESS.jsonl";
21
+
22
+ export type GoalTodoExecutionLongTaskRunner = (options: RunCoordinatorOptions) => Promise<CoordinatorResult>;
23
+
24
+ export interface GoalTodoExecutionOptions {
25
+ state: GoalLoopState;
26
+ cwd?: string;
27
+ store?: GoalStateStore;
28
+ longTaskRunner?: GoalTodoExecutionLongTaskRunner;
29
+ abortSignal?: AbortSignal;
30
+ model?: unknown;
31
+ modelName?: string;
32
+ thinkingLevel?: string;
33
+ maxBashTimeoutMs?: number;
34
+ maxAttemptsPerTask?: number;
35
+ commit?: boolean;
36
+ now?: () => Date;
37
+ onProgress?: (update: CoordinatorProgressUpdate) => void;
38
+ }
39
+
40
+ export interface GoalTodoExecutionResult {
41
+ state: GoalLoopState;
42
+ iteration: GoalIterationState;
43
+ todoMarkdown: string;
44
+ todoPath: string;
45
+ progressLogPath: string;
46
+ childResult: CoordinatorResult;
47
+ }
48
+
49
+ export class GoalTodoExecutionError extends Error {
50
+ readonly state: GoalLoopState | undefined;
51
+ readonly workerResult: GoalWorkerResultState | undefined;
52
+
53
+ constructor(
54
+ message: string,
55
+ options: { cause?: unknown; state?: GoalLoopState; workerResult?: GoalWorkerResultState } = {},
56
+ ) {
57
+ super(message, { cause: options.cause });
58
+ this.name = "GoalTodoExecutionError";
59
+ this.state = options.state;
60
+ this.workerResult = options.workerResult;
61
+ }
62
+ }
63
+
64
+ export async function runGoalTodoExecutionLongTask(
65
+ options: GoalTodoExecutionOptions,
66
+ ): Promise<GoalTodoExecutionResult> {
67
+ const now = options.now ?? (() => new Date());
68
+ let state = options.state;
69
+ let previousTraceLength = state.trace.length;
70
+ const store =
71
+ options.store ?? new GoalStateStore({ cwd: options.cwd, goalRunId: state.goalRunId, goalRunDir: state.goalRunDir });
72
+
73
+ const iteration = currentGeneratedIteration(state);
74
+ const iterationDir = store.iterationDir(iteration.iteration);
75
+ await mkdir(iterationDir, { recursive: true });
76
+ const progressLogPath = path.join(iterationDir, GOAL_TODO_EXECUTION_PROGRESS_FILE);
77
+
78
+ if (options.abortSignal?.aborted) {
79
+ state = cancelGoalLoop(state, "TODO execution was aborted before starting.", { now: now() });
80
+ await persistStateChange(store, previousTraceLength, state);
81
+ await store.writeIterationSnapshot(currentIteration(state, iteration.iteration));
82
+ throw new GoalTodoExecutionError("TODO execution was aborted before starting.", { state });
83
+ }
84
+
85
+ const todoPath = iteration.generatedTodo?.todoPath;
86
+ if (!todoPath) {
87
+ throw new GoalTodoExecutionError(`Goal iteration ${iteration.iteration} does not have a generated TODO path.`, {
88
+ state,
89
+ });
90
+ }
91
+
92
+ const todoMarkdown = await readExecutionTodoOrRecordFailure({
93
+ state,
94
+ iteration,
95
+ todoPath,
96
+ store,
97
+ previousTraceLength,
98
+ now,
99
+ });
100
+ state = todoMarkdown.state;
101
+ previousTraceLength = todoMarkdown.previousTraceLength;
102
+ try {
103
+ validateTodoMarkdown(todoMarkdown.content);
104
+ } catch (error) {
105
+ const failure = await recordExecutionFailure({
106
+ state,
107
+ iteration,
108
+ store,
109
+ previousTraceLength,
110
+ message: `Generated TODO ${todoPath} is not valid Pi Long Task markdown: ${errorMessage(error)}`,
111
+ error,
112
+ now,
113
+ });
114
+ throw new GoalTodoExecutionError(failure.workerResult.summary, {
115
+ cause: error,
116
+ state: failure.state,
117
+ workerResult: failure.workerResult,
118
+ });
119
+ }
120
+
121
+ const progressEvents: CoordinatorProgressUpdate[] = [];
122
+ const workerStartedAt = now();
123
+ let childResult: CoordinatorResult;
124
+ try {
125
+ childResult = await (options.longTaskRunner ?? runCoordinator)({
126
+ inputText: todoMarkdown.content,
127
+ commit: options.commit ?? true,
128
+ goal: state.goal,
129
+ cwd: options.cwd,
130
+ runId: `${state.goalRunId}-todo-worker-${String(iteration.iteration).padStart(2, "0")}`,
131
+ abortSignal: options.abortSignal,
132
+ workerModel: options.model,
133
+ workerModelName: options.modelName,
134
+ taskThinking: options.thinkingLevel,
135
+ taskTimeoutMs: timeoutForIteration(iteration, state, now()),
136
+ maxBashTimeoutMs: options.maxBashTimeoutMs,
137
+ maxAttemptsPerTask: options.maxAttemptsPerTask,
138
+ onProgress: (update) => {
139
+ progressEvents.push(update);
140
+ options.onProgress?.(update);
141
+ },
142
+ });
143
+ } catch (error) {
144
+ await writeProgressLog(progressLogPath, progressEvents);
145
+ const failure = await recordExecutionFailure({
146
+ state,
147
+ iteration,
148
+ store,
149
+ previousTraceLength,
150
+ progressLogPath,
151
+ message: `TODO execution long task failed: ${errorMessage(error)}`,
152
+ error,
153
+ now,
154
+ });
155
+ throw new GoalTodoExecutionError(failure.workerResult.summary, {
156
+ cause: error,
157
+ state: failure.state,
158
+ workerResult: failure.workerResult,
159
+ });
160
+ }
161
+
162
+ await writeProgressLog(progressLogPath, progressEvents);
163
+
164
+ const workerResult = workerResultFromCoordinatorResult(childResult, progressLogPath, workerStartedAt, now());
165
+ state = recordWorkerResult(state, iteration.iteration, workerResult, { now: now() });
166
+ await persistStateChange(store, previousTraceLength, state);
167
+ const updatedIteration = currentIteration(state, iteration.iteration);
168
+ await store.writeIterationSnapshot(updatedIteration);
169
+ await store.appendIterationResult(updatedIteration);
170
+
171
+ return {
172
+ state,
173
+ iteration: updatedIteration,
174
+ todoMarkdown: todoMarkdown.content,
175
+ todoPath,
176
+ progressLogPath,
177
+ childResult,
178
+ };
179
+ }
180
+
181
+ function currentGeneratedIteration(state: GoalLoopState): GoalIterationState {
182
+ const iteration = currentIteration(state, state.currentIteration);
183
+ if (iteration.status !== "todo_generated") {
184
+ throw new GoalTodoExecutionError(
185
+ `Goal iteration ${iteration.iteration} is ${iteration.status}; expected generated TODO execution.`,
186
+ { state },
187
+ );
188
+ }
189
+ return iteration;
190
+ }
191
+
192
+ function currentIteration(state: GoalLoopState, iterationNumber: number): GoalIterationState {
193
+ const iteration = state.iterations.find((item) => item.iteration === iterationNumber);
194
+ if (!iteration) {
195
+ throw new GoalTodoExecutionError(`Goal iteration ${iterationNumber || "<none>"} does not exist.`, { state });
196
+ }
197
+ return iteration;
198
+ }
199
+
200
+ async function readExecutionTodoOrRecordFailure(options: {
201
+ state: GoalLoopState;
202
+ iteration: GoalIterationState;
203
+ todoPath: string;
204
+ store: GoalStateStore;
205
+ previousTraceLength: number;
206
+ now: () => Date;
207
+ }): Promise<{ state: GoalLoopState; previousTraceLength: number; content: string }> {
208
+ try {
209
+ const content = await readFile(options.todoPath, "utf8");
210
+ return { state: options.state, previousTraceLength: options.previousTraceLength, content };
211
+ } catch (error) {
212
+ const failure = await recordExecutionFailure({
213
+ state: options.state,
214
+ iteration: options.iteration,
215
+ store: options.store,
216
+ previousTraceLength: options.previousTraceLength,
217
+ message: `Could not read generated TODO ${options.todoPath}: ${errorMessage(error)}`,
218
+ error,
219
+ now: options.now,
220
+ });
221
+ throw new GoalTodoExecutionError(failure.workerResult.summary, {
222
+ cause: error,
223
+ state: failure.state,
224
+ workerResult: failure.workerResult,
225
+ });
226
+ }
227
+ }
228
+
229
+ async function recordExecutionFailure(options: {
230
+ state: GoalLoopState;
231
+ iteration: GoalIterationState;
232
+ store: GoalStateStore;
233
+ previousTraceLength: number;
234
+ message: string;
235
+ error: unknown;
236
+ now: () => Date;
237
+ progressLogPath?: string;
238
+ }): Promise<{ state: GoalLoopState; workerResult: GoalWorkerResultState }> {
239
+ const timestamp = options.now().toISOString();
240
+ const workerResult: GoalWorkerResultState = {
241
+ status: "failed",
242
+ summary: options.message,
243
+ todoPath: options.iteration.generatedTodo?.todoPath,
244
+ workerProgressPath: options.progressLogPath,
245
+ error: errorMessage(options.error),
246
+ endedAt: timestamp,
247
+ };
248
+ const state = recordWorkerResult(options.state, options.iteration.iteration, workerResult, { now: options.now() });
249
+ await persistStateChange(options.store, options.previousTraceLength, state);
250
+ const updatedIteration = currentIteration(state, options.iteration.iteration);
251
+ await options.store.writeIterationSnapshot(updatedIteration);
252
+ await options.store.appendIterationResult(updatedIteration);
253
+ return { state, workerResult };
254
+ }
255
+
256
+ function workerResultFromCoordinatorResult(
257
+ childResult: CoordinatorResult,
258
+ progressLogPath: string,
259
+ startedAt: Date,
260
+ endedAt: Date,
261
+ ): GoalWorkerResultState {
262
+ return {
263
+ status: childResult.status,
264
+ summary: childResult.summary,
265
+ runId: childResult.runId,
266
+ runDir: childResult.runDir,
267
+ todoPath: childResult.todoPath,
268
+ resultPath: childResult.resultPath,
269
+ taskResultPath: childResult.taskResultPath,
270
+ totalTasks: childResult.totalTasks,
271
+ completedTasks: childResult.completedTasks,
272
+ failedTasks: childResult.failedTasks,
273
+ blockedTasks: childResult.blockedTasks,
274
+ workerCostTotal: childResult.workerCostTotal,
275
+ error: childResult.error,
276
+ workerProgressPath: progressLogPath,
277
+ startedAt: startedAt.toISOString(),
278
+ endedAt: endedAt.toISOString(),
279
+ };
280
+ }
281
+
282
+ async function persistStateChange(
283
+ store: GoalStateStore,
284
+ previousTraceLength: number,
285
+ state: GoalLoopState,
286
+ ): Promise<void> {
287
+ await store.saveState(state);
288
+ await store.appendNewTraceEvents(previousTraceLength, state);
289
+ }
290
+
291
+ async function writeProgressLog(progressLogPath: string, events: CoordinatorProgressUpdate[]): Promise<void> {
292
+ const content = events.map((event) => JSON.stringify(event)).join("\n");
293
+ await writeFile(progressLogPath, content ? `${content}\n` : "", "utf8");
294
+ }
295
+
296
+ function timeoutForIteration(iteration: GoalIterationState, state: GoalLoopState, now: Date): number {
297
+ if (!iteration.deadlineAt) {
298
+ return state.limits.iterationTimeoutMs;
299
+ }
300
+ const remaining = Date.parse(iteration.deadlineAt) - now.getTime();
301
+ if (!Number.isFinite(remaining) || remaining <= 0) {
302
+ return 1_000;
303
+ }
304
+ return Math.min(state.limits.iterationTimeoutMs, Math.max(1_000, Math.floor(remaining)));
305
+ }
306
+
307
+ function errorMessage(error: unknown): string {
308
+ return error instanceof Error ? error.message : String(error);
309
+ }