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,399 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type { CoordinatorProgressUpdate } from "./coordinator.ts";
4
+ import {
5
+ decideGoalDiscovery,
6
+ runDefaultGoalDiscovery,
7
+ type GoalDiscoveryDecision,
8
+ type GoalDiscoveryEntrypoint,
9
+ type GoalDiscoveryRunner,
10
+ } from "./goal_discovery.ts";
11
+ import type { GoalLoopLimits, GoalLoopStatus } from "./goal_loop.ts";
12
+ import {
13
+ createGoalLoopState,
14
+ goalLoopStopReason,
15
+ startGoalIteration,
16
+ type GoalLoopLimitInput,
17
+ type GoalLoopState,
18
+ } from "./goal_loop.ts";
19
+ import { GoalStateStore } from "./goal_state.ts";
20
+ import type { GoalSpecification } from "./goal_spec.ts";
21
+ import { runGoalReviewSession, type GoalReviewResult, type GoalReviewerRunner } from "./goal_review.ts";
22
+ import {
23
+ runGoalTodoExecutionLongTask,
24
+ type GoalTodoExecutionLongTaskRunner,
25
+ type GoalTodoExecutionResult,
26
+ GoalTodoExecutionError,
27
+ } from "./goal_todo_execution.ts";
28
+ import {
29
+ runGoalTodoGenerationLongTask,
30
+ type GoalTodoGenerationLongTaskRunner,
31
+ type GoalTodoGenerationResult,
32
+ } from "./goal_todo_generation.ts";
33
+
34
+ export type GoalLoopProgressPhase =
35
+ | "goal_start"
36
+ | "discovery_start"
37
+ | "discovery_complete"
38
+ | "todo_generation_start"
39
+ | "todo_generated"
40
+ | "todo_execution_start"
41
+ | "todo_executed"
42
+ | "review_start"
43
+ | "reviewed"
44
+ | "complete";
45
+
46
+ export interface GoalLoopProgressUpdate {
47
+ message: string;
48
+ phase: GoalLoopProgressPhase;
49
+ goalRunId: string;
50
+ goalRunDir: string;
51
+ goal: string;
52
+ status: GoalLoopStatus;
53
+ currentIteration: number;
54
+ totalIterations: number;
55
+ minIterations: number;
56
+ maxIterations: number;
57
+ limits: GoalLoopLimits;
58
+ resultPath: string;
59
+ statePath: string;
60
+ tracePath: string;
61
+ goalSpecPath: string;
62
+ discoveryDecision: GoalDiscoveryDecision;
63
+ iteration?: number;
64
+ reviewerDecision?: string;
65
+ remainingWork?: string[];
66
+ workerStatus?: string;
67
+ workerCostTotal: number;
68
+ reviewerCostTotal: number;
69
+ totalCost: number;
70
+ childProgress?: CoordinatorProgressUpdate;
71
+ }
72
+
73
+ export type GoalLoopProgressHandler = (update: GoalLoopProgressUpdate) => void;
74
+
75
+ export interface RunGoalLoopOptions extends GoalLoopLimitInput {
76
+ goal?: string;
77
+ initialState?: GoalLoopState;
78
+ cwd?: string;
79
+ goalRunId?: string;
80
+ goalRunDir?: string;
81
+ store?: GoalStateStore;
82
+ abortSignal?: AbortSignal;
83
+ todoGenerationRunner?: GoalTodoGenerationLongTaskRunner;
84
+ todoExecutionRunner?: GoalTodoExecutionLongTaskRunner;
85
+ reviewerRunner?: GoalReviewerRunner;
86
+ discoveryRunner?: GoalDiscoveryRunner;
87
+ discoveryEntrypoint?: GoalDiscoveryEntrypoint;
88
+ model?: unknown;
89
+ modelName?: string;
90
+ thinkingLevel?: string;
91
+ maxBashTimeoutMs?: number;
92
+ maxAttemptsPerTask?: number;
93
+ commit?: boolean;
94
+ now?: () => Date;
95
+ onWorkerProgress?: (update: CoordinatorProgressUpdate) => void;
96
+ onProgress?: GoalLoopProgressHandler;
97
+ }
98
+
99
+ export interface GoalLoopRunResult {
100
+ state: GoalLoopState;
101
+ generationResults: GoalTodoGenerationResult[];
102
+ executionResults: GoalTodoExecutionResult[];
103
+ reviewResults: GoalReviewResult[];
104
+ resultPath: string;
105
+ discoveryDecision: GoalDiscoveryDecision;
106
+ goalSpecification?: GoalSpecification;
107
+ }
108
+
109
+ export class GoalLoopOrchestratorError extends Error {
110
+ readonly state: GoalLoopState | undefined;
111
+
112
+ constructor(message: string, options: { cause?: unknown; state?: GoalLoopState } = {}) {
113
+ super(message, { cause: options.cause });
114
+ this.name = "GoalLoopOrchestratorError";
115
+ this.state = options.state;
116
+ }
117
+ }
118
+
119
+ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoopRunResult> {
120
+ const now = options.now ?? (() => new Date());
121
+ let state =
122
+ options.initialState ??
123
+ createGoalLoopState({
124
+ goal: requiredGoal(options.goal),
125
+ cwd: options.cwd,
126
+ goalRunId:
127
+ options.goalRunId ?? `goal-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`,
128
+ goalRunDir: options.goalRunDir,
129
+ minIterations: options.minIterations,
130
+ maxIterations: options.maxIterations,
131
+ timeoutMs: options.timeoutMs,
132
+ iterationTimeoutMs: options.iterationTimeoutMs,
133
+ reviewerTimeoutMs: options.reviewerTimeoutMs,
134
+ now,
135
+ });
136
+ const store =
137
+ options.store ?? new GoalStateStore({ cwd: options.cwd, goalRunId: state.goalRunId, goalRunDir: state.goalRunDir });
138
+ const generationResults: GoalTodoGenerationResult[] = [];
139
+ const executionResults: GoalTodoExecutionResult[] = [];
140
+ const reviewResults: GoalReviewResult[] = [];
141
+ const discoveryDecision = decideGoalDiscovery({
142
+ goal: state.goal,
143
+ entrypoint: options.discoveryEntrypoint ?? "pi_goal_task",
144
+ });
145
+ let goalSpecification: GoalSpecification | undefined = await store.tryLoadGoalSpecification();
146
+
147
+ await store.saveState(state);
148
+ await store.initializeResult(state);
149
+ await store.appendNewTraceEvents(0, state);
150
+ const publish = (phase: GoalLoopProgressPhase, message: string, extra: Partial<GoalLoopProgressUpdate> = {}) => {
151
+ options.onProgress?.({
152
+ message,
153
+ phase,
154
+ goalRunId: state.goalRunId,
155
+ goalRunDir: state.goalRunDir,
156
+ goal: state.goal,
157
+ status: state.status,
158
+ currentIteration: state.currentIteration,
159
+ totalIterations: state.iterations.length,
160
+ minIterations: state.limits.minIterations,
161
+ maxIterations: state.limits.maxIterations,
162
+ limits: state.limits,
163
+ resultPath: store.paths.resultPath,
164
+ statePath: store.paths.statePath,
165
+ tracePath: store.paths.tracePath,
166
+ goalSpecPath: store.paths.goalSpecPath,
167
+ discoveryDecision,
168
+ workerCostTotal: accumulatedWorkerCost(executionResults, generationResults),
169
+ reviewerCostTotal: accumulatedReviewerCost(reviewResults),
170
+ totalCost: accumulatedWorkerCost(executionResults, generationResults) + accumulatedReviewerCost(reviewResults),
171
+ ...extra,
172
+ });
173
+ };
174
+
175
+ publish("goal_start", `Starting goal loop: ${state.goal}`);
176
+
177
+ if (!goalLoopStopReason(state, { now: now(), abortSignal: options.abortSignal })) {
178
+ goalSpecification = await maybeRunGoalDiscovery({
179
+ state,
180
+ store,
181
+ discoveryDecision,
182
+ existingSpecification: goalSpecification,
183
+ options,
184
+ now,
185
+ publish,
186
+ });
187
+ }
188
+
189
+ while (state.status === "running") {
190
+ const stopReason = goalLoopStopReason(state, { now: now(), abortSignal: options.abortSignal });
191
+ if (stopReason) {
192
+ const previousTraceLength = state.trace.length;
193
+ state = startGoalIteration(state, { now: now(), abortSignal: options.abortSignal });
194
+ await persistStateChange(store, previousTraceLength, state);
195
+ break;
196
+ }
197
+
198
+ const nextIteration = state.currentIteration > 0 ? state.currentIteration : state.iterations.length + 1;
199
+ publish("todo_generation_start", `Goal iteration ${nextIteration}: generating TODO markdown.`, {
200
+ iteration: nextIteration,
201
+ });
202
+ const generation = await runGoalTodoGenerationLongTask({
203
+ state,
204
+ cwd: options.cwd,
205
+ store,
206
+ longTaskRunner: options.todoGenerationRunner,
207
+ abortSignal: options.abortSignal,
208
+ model: options.model,
209
+ modelName: options.modelName,
210
+ thinkingLevel: options.thinkingLevel,
211
+ maxBashTimeoutMs: options.maxBashTimeoutMs,
212
+ now,
213
+ goalSpecification,
214
+ });
215
+ generationResults.push(generation);
216
+ state = generation.state;
217
+ publish("todo_generated", `Goal iteration ${state.currentIteration}: generated TODO markdown.`, {
218
+ iteration: state.currentIteration,
219
+ });
220
+
221
+ try {
222
+ publish(
223
+ "todo_execution_start",
224
+ `Goal iteration ${state.currentIteration}: running generated TODO as a long task.`,
225
+ {
226
+ iteration: state.currentIteration,
227
+ },
228
+ );
229
+ const execution = await runGoalTodoExecutionLongTask({
230
+ state,
231
+ cwd: options.cwd,
232
+ store,
233
+ longTaskRunner: options.todoExecutionRunner,
234
+ abortSignal: options.abortSignal,
235
+ model: options.model,
236
+ modelName: options.modelName,
237
+ thinkingLevel: options.thinkingLevel,
238
+ maxBashTimeoutMs: options.maxBashTimeoutMs,
239
+ maxAttemptsPerTask: options.maxAttemptsPerTask,
240
+ commit: options.commit,
241
+ now,
242
+ onProgress: (update) => {
243
+ publish("todo_execution_start", `Goal iteration ${state.currentIteration}: ${update.message}`, {
244
+ iteration: state.currentIteration,
245
+ workerStatus: update.status,
246
+ childProgress: update,
247
+ });
248
+ options.onWorkerProgress?.(update);
249
+ },
250
+ });
251
+ executionResults.push(execution);
252
+ state = execution.state;
253
+ publish(
254
+ "todo_executed",
255
+ `Goal iteration ${state.currentIteration}: worker finished with ${execution.childResult.status}.`,
256
+ {
257
+ iteration: state.currentIteration,
258
+ workerStatus: execution.childResult.status,
259
+ },
260
+ );
261
+ } catch (error) {
262
+ if (error instanceof GoalTodoExecutionError && error.state) {
263
+ state = error.state;
264
+ } else {
265
+ throw new GoalLoopOrchestratorError(`Goal TODO execution failed: ${errorMessage(error)}`, {
266
+ cause: error,
267
+ state,
268
+ });
269
+ }
270
+ }
271
+
272
+ publish("review_start", `Goal iteration ${state.currentIteration}: reviewing goal completion.`, {
273
+ iteration: state.currentIteration,
274
+ });
275
+ const review = await runGoalReviewSession({
276
+ state,
277
+ cwd: options.cwd,
278
+ store,
279
+ reviewerRunner: options.reviewerRunner,
280
+ abortSignal: options.abortSignal,
281
+ model: options.model,
282
+ modelName: options.modelName,
283
+ thinkingLevel: options.thinkingLevel,
284
+ now,
285
+ goalSpecification,
286
+ });
287
+ reviewResults.push(review);
288
+ state = review.state;
289
+ publish(
290
+ "reviewed",
291
+ `Goal iteration ${review.iteration.iteration}: reviewer decided ${review.reviewerResult.decision}.`,
292
+ {
293
+ iteration: review.iteration.iteration,
294
+ reviewerDecision: review.reviewerResult.decision,
295
+ remainingWork: review.reviewerResult.remainingWork,
296
+ },
297
+ );
298
+ }
299
+
300
+ publish("complete", `Goal loop ${state.status}: ${state.completion?.reason ?? "finished"}`);
301
+
302
+ return {
303
+ state,
304
+ generationResults,
305
+ executionResults,
306
+ reviewResults,
307
+ resultPath: store.paths.resultPath,
308
+ discoveryDecision,
309
+ ...(goalSpecification ? { goalSpecification } : {}),
310
+ };
311
+ }
312
+
313
+ async function maybeRunGoalDiscovery(options: {
314
+ state: GoalLoopState;
315
+ store: GoalStateStore;
316
+ discoveryDecision: GoalDiscoveryDecision;
317
+ existingSpecification?: GoalSpecification;
318
+ options: RunGoalLoopOptions;
319
+ now: () => Date;
320
+ publish: (phase: GoalLoopProgressPhase, message: string, extra?: Partial<GoalLoopProgressUpdate>) => void;
321
+ }): Promise<GoalSpecification | undefined> {
322
+ if (options.discoveryDecision.route !== "discovery") {
323
+ return options.existingSpecification;
324
+ }
325
+
326
+ if (options.existingSpecification) {
327
+ options.publish("discovery_complete", "Using persisted goal specification from previous discovery.");
328
+ return options.existingSpecification;
329
+ }
330
+
331
+ options.publish("discovery_start", "Goal is vague; running discovery before implementation TODO generation.");
332
+ try {
333
+ const runner = options.options.discoveryRunner ?? runDefaultGoalDiscovery;
334
+ const spec = await runner({
335
+ state: options.state,
336
+ store: options.store,
337
+ decision: options.discoveryDecision,
338
+ cwd: options.options.cwd,
339
+ abortSignal: options.options.abortSignal,
340
+ model: options.options.model,
341
+ modelName: options.options.modelName,
342
+ thinkingLevel: options.options.thinkingLevel,
343
+ now: options.now,
344
+ });
345
+ await options.store.saveGoalSpecification(spec);
346
+ options.publish(
347
+ "discovery_complete",
348
+ `Goal discovery complete; specification saved to ${options.store.paths.goalSpecPath}.`,
349
+ );
350
+ return spec;
351
+ } catch (error) {
352
+ throw new GoalLoopOrchestratorError(`Goal discovery failed: ${errorMessage(error)}`, {
353
+ cause: error,
354
+ state: options.state,
355
+ });
356
+ }
357
+ }
358
+
359
+ async function persistStateChange(
360
+ store: GoalStateStore,
361
+ previousTraceLength: number,
362
+ state: GoalLoopState,
363
+ ): Promise<void> {
364
+ await store.saveState(state);
365
+ await store.appendNewTraceEvents(previousTraceLength, state);
366
+ }
367
+
368
+ function accumulatedWorkerCost(
369
+ executionResults: GoalTodoExecutionResult[],
370
+ generationResults: GoalTodoGenerationResult[],
371
+ ): number {
372
+ return sumFinite([
373
+ ...executionResults.map((result) => result.childResult.workerCostTotal),
374
+ ...generationResults.map((result) => result.childResult.workerCostTotal),
375
+ ]);
376
+ }
377
+
378
+ function accumulatedReviewerCost(reviewResults: GoalReviewResult[]): number {
379
+ return sumFinite(reviewResults.map((result) => result.sessionResult.reviewerCostTotal));
380
+ }
381
+
382
+ function sumFinite(values: Array<number | undefined>): number {
383
+ return values.reduce<number>(
384
+ (total, value) => total + (typeof value === "number" && Number.isFinite(value) ? value : 0),
385
+ 0,
386
+ );
387
+ }
388
+
389
+ function requiredGoal(goal: string | undefined): string {
390
+ const trimmed = goal?.trim();
391
+ if (!trimmed) {
392
+ throw new GoalLoopOrchestratorError("Goal loop requires a non-empty goal.");
393
+ }
394
+ return trimmed;
395
+ }
396
+
397
+ function errorMessage(error: unknown): string {
398
+ return error instanceof Error ? error.message : String(error);
399
+ }