pi-long-task 0.3.8 → 0.3.10
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 +222 -6
- package/package.json +1 -1
- package/src/coordinator.ts +336 -31
- package/src/coverage_goal.ts +90 -0
- package/src/goal_discovery.ts +739 -0
- package/src/goal_loop.ts +567 -0
- package/src/goal_orchestrator.ts +396 -0
- package/src/goal_review.ts +575 -0
- package/src/goal_spec.ts +670 -0
- package/src/goal_state.ts +227 -0
- package/src/goal_todo_execution.ts +309 -0
- package/src/goal_todo_generation.ts +539 -0
- package/src/index.ts +90 -2
- package/src/input_router.ts +124 -6
- package/src/render.ts +223 -4
- package/src/session_guard.ts +287 -0
- package/src/todo_generator.ts +143 -5
- package/src/types.ts +66 -3
- package/src/worker_session.ts +23 -2
package/src/coordinator.ts
CHANGED
|
@@ -11,18 +11,21 @@ import type {
|
|
|
11
11
|
import { commitAfterSession, gitDirtyPaths, shouldCommitOutcome, type CommitAfterSessionResult } from "./git.ts";
|
|
12
12
|
import { formatCoordinatorResultMessage } from "./render.ts";
|
|
13
13
|
import { extractResultSummary } from "./result_writer.ts";
|
|
14
|
+
import { runGuardedSessionPrompt } from "./session_guard.ts";
|
|
14
15
|
import { parseWorkerRuntimeConfig } from "./worker_config.ts";
|
|
15
16
|
import { buildTaskProgressModel, type TaskProgressModel, type TaskProgressStatus } from "./task_progress.ts";
|
|
16
17
|
import {
|
|
18
|
+
applyGoalInstructionsToTodoMarkdown,
|
|
17
19
|
buildTodoCreationPrompt,
|
|
18
|
-
|
|
20
|
+
buildTodoRepairPrompt,
|
|
21
|
+
extractAndValidateTodoMarkdown,
|
|
22
|
+
TodoGenerationError,
|
|
19
23
|
todoMarkdownFromString,
|
|
20
24
|
validateTodoMarkdown,
|
|
21
25
|
} from "./todo_generator.ts";
|
|
22
26
|
import { markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
|
|
23
27
|
import {
|
|
24
28
|
createIsolatedWorkerSession,
|
|
25
|
-
lastAssistantTextFromMessages,
|
|
26
29
|
runWorkerTask,
|
|
27
30
|
type RunWorkerTaskOptions,
|
|
28
31
|
type SessionOutcome,
|
|
@@ -35,6 +38,8 @@ export type { CoordinatorStatus } from "./types.ts";
|
|
|
35
38
|
export const DEFAULT_COORDINATOR_OPTIONS = {
|
|
36
39
|
maxAttemptsPerTask: 3,
|
|
37
40
|
taskTimeoutMs: 900_000,
|
|
41
|
+
todoTimeoutMs: 300_000,
|
|
42
|
+
todoGracefulShutdownMs: 15_000,
|
|
38
43
|
maxBashTimeoutMs: 300_000,
|
|
39
44
|
taskThinking: "high",
|
|
40
45
|
todoThinking: "xhigh",
|
|
@@ -51,6 +56,18 @@ export type CoordinatorProgressPhase =
|
|
|
51
56
|
| "task_failed"
|
|
52
57
|
| "complete";
|
|
53
58
|
|
|
59
|
+
export type PlannerDiagnosticKind = "timeout" | "abort" | "invalid_output" | "repair_attempt" | "failure";
|
|
60
|
+
|
|
61
|
+
export interface PlannerDiagnostic {
|
|
62
|
+
kind: PlannerDiagnosticKind;
|
|
63
|
+
message: string;
|
|
64
|
+
diagnostics?: string[];
|
|
65
|
+
sessionFile?: string;
|
|
66
|
+
sessionId?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type PlannerDiagnosticHandler = (diagnostic: PlannerDiagnostic) => void;
|
|
70
|
+
|
|
54
71
|
export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
|
|
55
72
|
|
|
56
73
|
export interface CoordinatorProgressTask {
|
|
@@ -82,9 +99,14 @@ export interface CoordinatorProgressUpdate {
|
|
|
82
99
|
isError?: boolean;
|
|
83
100
|
totalTasks?: number;
|
|
84
101
|
workerCostTotal: number;
|
|
102
|
+
goal?: string;
|
|
85
103
|
currentTask?: CoordinatorProgressTask;
|
|
86
104
|
subtasks?: CoordinatorProgressSubtask[];
|
|
87
105
|
taskProgress?: TaskProgressModel;
|
|
106
|
+
plannerDiagnostic?: PlannerDiagnosticKind;
|
|
107
|
+
plannerDiagnostics?: string[];
|
|
108
|
+
plannerSessionFile?: string;
|
|
109
|
+
plannerSessionId?: string;
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
|
|
@@ -102,6 +124,8 @@ export interface RunCoordinatorOptions extends PiLongTaskInput {
|
|
|
102
124
|
workerModelName?: string;
|
|
103
125
|
maxAttemptsPerTask?: number;
|
|
104
126
|
taskTimeoutMs?: number;
|
|
127
|
+
todoTimeoutMs?: number;
|
|
128
|
+
todoGracefulShutdownMs?: number;
|
|
105
129
|
maxBashTimeoutMs?: number;
|
|
106
130
|
taskThinking?: string;
|
|
107
131
|
todoThinking?: string;
|
|
@@ -116,7 +140,11 @@ export interface TodoPlannerOptions {
|
|
|
116
140
|
thinkingLevel: string;
|
|
117
141
|
model?: unknown;
|
|
118
142
|
abortSignal?: AbortSignal;
|
|
143
|
+
timeoutMs?: number;
|
|
144
|
+
gracefulShutdownMs?: number;
|
|
119
145
|
sessionFactory?: WorkerSessionFactory;
|
|
146
|
+
onDiagnostic?: PlannerDiagnosticHandler;
|
|
147
|
+
goal?: string;
|
|
120
148
|
}
|
|
121
149
|
|
|
122
150
|
export interface TaskAttemptSummary {
|
|
@@ -152,6 +180,7 @@ export interface CoordinatorResult {
|
|
|
152
180
|
taskProgress: TaskProgressModel;
|
|
153
181
|
workerCostTotal: number;
|
|
154
182
|
commit: boolean;
|
|
183
|
+
goal?: string;
|
|
155
184
|
error?: string;
|
|
156
185
|
}
|
|
157
186
|
|
|
@@ -173,8 +202,11 @@ interface RuntimeOptions {
|
|
|
173
202
|
maxBashTimeoutSeconds: number;
|
|
174
203
|
workerModel?: unknown;
|
|
175
204
|
workerModelName?: string;
|
|
205
|
+
goal?: string;
|
|
176
206
|
taskThinking: string;
|
|
177
207
|
todoThinking: string;
|
|
208
|
+
todoTimeoutMs: number;
|
|
209
|
+
todoGracefulShutdownMs: number;
|
|
178
210
|
workerRunner: WorkerRunner;
|
|
179
211
|
todoPlanner: TodoPlanner;
|
|
180
212
|
abortSignal?: AbortSignal;
|
|
@@ -183,21 +215,25 @@ interface RuntimeOptions {
|
|
|
183
215
|
now: () => Date;
|
|
184
216
|
onProgress?: CoordinatorProgressHandler;
|
|
185
217
|
workerCostState: WorkerCostState;
|
|
218
|
+
plannerDiagnostics: PlannerDiagnostic[];
|
|
186
219
|
}
|
|
187
220
|
|
|
188
221
|
export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
|
|
189
222
|
const runtime = buildRuntimeOptions(options);
|
|
223
|
+
const inputText = coordinatorInputText(options);
|
|
190
224
|
const attempts: TaskAttemptSummary[] = [];
|
|
191
225
|
const outcomes: SessionOutcome[] = [];
|
|
192
226
|
const commits: CoordinatorCommitSummary[] = [];
|
|
193
227
|
|
|
194
228
|
await mkdir(runtime.runDir, { recursive: true });
|
|
195
229
|
await writeFile(runtime.taskResultPath, initialTaskResultMarkdown(runtime.runId), "utf8");
|
|
230
|
+
let planningComplete = false;
|
|
196
231
|
|
|
197
232
|
try {
|
|
198
233
|
emitProgress(runtime, "Creating TODO plan...", { phase: "planning" });
|
|
199
|
-
let todoMarkdown = await generateOrNormalizeTodoMarkdown(
|
|
234
|
+
let todoMarkdown = await generateOrNormalizeTodoMarkdown(inputText, runtime);
|
|
200
235
|
validateTodoMarkdown(todoMarkdown);
|
|
236
|
+
planningComplete = true;
|
|
201
237
|
await writeFile(runtime.todoPath, todoMarkdown, "utf8");
|
|
202
238
|
const initialTasks = parseTasks(todoMarkdown);
|
|
203
239
|
emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
|
|
@@ -244,6 +280,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
244
280
|
commitRequested: options.commit,
|
|
245
281
|
previousAttempts: previousAttempts.get(nextTask.taskId)?.join("\n\n---\n\n"),
|
|
246
282
|
globalInstructions: todoGlobalInstructions(todoMarkdown),
|
|
283
|
+
goal: runtime.goal,
|
|
247
284
|
maxBashTimeoutSeconds: runtime.maxBashTimeoutSeconds,
|
|
248
285
|
taskTimeoutSeconds: runtime.taskTimeoutSeconds,
|
|
249
286
|
model: runtime.workerModel,
|
|
@@ -367,6 +404,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
367
404
|
taskProgress,
|
|
368
405
|
workerCostTotal: runtime.workerCostState.total,
|
|
369
406
|
commit: options.commit,
|
|
407
|
+
goal: runtime.goal,
|
|
370
408
|
error: failure,
|
|
371
409
|
};
|
|
372
410
|
result.message = formatCoordinatorResultMessage(result);
|
|
@@ -379,9 +417,18 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
379
417
|
return result;
|
|
380
418
|
} catch (error) {
|
|
381
419
|
const message = errorMessage(error);
|
|
382
|
-
|
|
420
|
+
if (!planningComplete) {
|
|
421
|
+
recordPlannerDiagnostic(runtime, {
|
|
422
|
+
kind: "failure",
|
|
423
|
+
message: `TODO planning failed: ${message}`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const resultError = !planningComplete
|
|
427
|
+
? `${message} See ${runtime.taskResultPath} for planner diagnostics.`
|
|
428
|
+
: message;
|
|
429
|
+
const summary = `Pi Long Task failed: ${resultError}`;
|
|
383
430
|
try {
|
|
384
|
-
await
|
|
431
|
+
await appendFailureNote(runtime.taskResultPath, message, !planningComplete ? runtime.plannerDiagnostics : []);
|
|
385
432
|
} catch {
|
|
386
433
|
// Best effort only; the original error is returned below.
|
|
387
434
|
}
|
|
@@ -407,7 +454,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
407
454
|
taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
|
|
408
455
|
workerCostTotal: runtime.workerCostState.total,
|
|
409
456
|
commit: options.commit,
|
|
410
|
-
|
|
457
|
+
goal: runtime.goal,
|
|
458
|
+
error: resultError,
|
|
411
459
|
};
|
|
412
460
|
result.message = formatCoordinatorResultMessage(result);
|
|
413
461
|
emitProgress(runtime, "Pi Long Task failed.", {
|
|
@@ -420,62 +468,253 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
420
468
|
}
|
|
421
469
|
|
|
422
470
|
async function generateOrNormalizeTodoMarkdown(inputText: string, runtime: RuntimeOptions): Promise<string> {
|
|
423
|
-
const local = todoMarkdownFromString(inputText);
|
|
471
|
+
const local = todoMarkdownFromString(inputText, runtime.goal);
|
|
424
472
|
if (local) {
|
|
425
473
|
return local;
|
|
426
474
|
}
|
|
427
475
|
|
|
428
|
-
const plannerText = await runtime
|
|
476
|
+
const plannerText = await requestTodoPlan(inputText, runtime);
|
|
477
|
+
const planned = await extractTodoMarkdownWithOneRepair(
|
|
478
|
+
inputText,
|
|
479
|
+
plannerText,
|
|
480
|
+
(repairPrompt) => requestTodoPlan(repairPrompt, runtime),
|
|
481
|
+
runtime.goal,
|
|
482
|
+
{
|
|
483
|
+
onInvalidOutput: (validationError) =>
|
|
484
|
+
recordPlannerDiagnostic(runtime, {
|
|
485
|
+
kind: "invalid_output",
|
|
486
|
+
message: `TODO planner returned invalid output: ${validationError}`,
|
|
487
|
+
}),
|
|
488
|
+
onRepairAttempt: (validationError) =>
|
|
489
|
+
recordPlannerDiagnostic(runtime, {
|
|
490
|
+
kind: "repair_attempt",
|
|
491
|
+
message: `Asking TODO planner to repair invalid output: ${validationError}`,
|
|
492
|
+
}),
|
|
493
|
+
onFailure: (validationError) =>
|
|
494
|
+
recordPlannerDiagnostic(runtime, {
|
|
495
|
+
kind: "failure",
|
|
496
|
+
message: `TODO planner repair failed: ${validationError}`,
|
|
497
|
+
}),
|
|
498
|
+
},
|
|
499
|
+
);
|
|
500
|
+
return applyGoalInstructionsToTodoMarkdown(planned, runtime.goal);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
interface TodoExtractionRepairHooks {
|
|
504
|
+
onInvalidOutput?: (validationError: string) => void;
|
|
505
|
+
onRepairAttempt?: (validationError: string) => void;
|
|
506
|
+
onFailure?: (validationError: string) => void;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function extractTodoMarkdownWithOneRepair(
|
|
510
|
+
inputText: string,
|
|
511
|
+
plannerText: string,
|
|
512
|
+
requestRepair: (repairPrompt: string) => Promise<string>,
|
|
513
|
+
goal?: string,
|
|
514
|
+
hooks: TodoExtractionRepairHooks = {},
|
|
515
|
+
): Promise<string> {
|
|
516
|
+
try {
|
|
517
|
+
return extractAndValidateTodoMarkdown(plannerText);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
const validationError = errorMessage(error);
|
|
520
|
+
hooks.onInvalidOutput?.(validationError);
|
|
521
|
+
hooks.onRepairAttempt?.(validationError);
|
|
522
|
+
const repairText = await requestRepair(buildTodoRepairPrompt(inputText, plannerText, validationError, goal));
|
|
523
|
+
try {
|
|
524
|
+
return extractAndValidateTodoMarkdown(repairText);
|
|
525
|
+
} catch (repairError) {
|
|
526
|
+
const repairMessage = errorMessage(repairError);
|
|
527
|
+
hooks.onFailure?.(repairMessage);
|
|
528
|
+
throw new TodoGenerationError(
|
|
529
|
+
`TODO planner returned invalid TODO markdown after one repair attempt: ${repairMessage}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function requestTodoPlan(inputText: string, runtime: RuntimeOptions): Promise<string> {
|
|
536
|
+
return runtime.todoPlanner({
|
|
429
537
|
inputText,
|
|
430
538
|
cwd: runtime.cwd,
|
|
431
539
|
runDir: runtime.runDir,
|
|
432
540
|
thinkingLevel: runtime.todoThinking,
|
|
433
541
|
model: runtime.workerModel,
|
|
434
542
|
abortSignal: runtime.abortSignal,
|
|
543
|
+
timeoutMs: runtime.todoTimeoutMs,
|
|
544
|
+
gracefulShutdownMs: runtime.todoGracefulShutdownMs,
|
|
435
545
|
sessionFactory: runtime.todoSessionFactory,
|
|
546
|
+
onDiagnostic: (diagnostic) => recordPlannerDiagnostic(runtime, diagnostic),
|
|
547
|
+
goal: runtime.goal,
|
|
436
548
|
});
|
|
437
|
-
return extractTodoMarkdown(plannerText);
|
|
438
549
|
}
|
|
439
550
|
|
|
551
|
+
// Planner/worker lifecycle differences are audited in docs/planner-worker-lifecycle-audit.md;
|
|
552
|
+
// keep this function's public contract stable while moving shared prompt guarding into a helper.
|
|
440
553
|
export async function runTodoPlanner(options: TodoPlannerOptions): Promise<string> {
|
|
441
|
-
|
|
554
|
+
const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
|
|
555
|
+
const result = await sessionFactory({
|
|
556
|
+
cwd: options.cwd,
|
|
557
|
+
tools: [],
|
|
558
|
+
model: options.model,
|
|
559
|
+
thinkingLevel: options.thinkingLevel,
|
|
560
|
+
});
|
|
561
|
+
const session = result.session;
|
|
562
|
+
const timeoutMs = positiveMilliseconds(options.timeoutMs, DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs);
|
|
563
|
+
const gracefulShutdownMs = options.gracefulShutdownMs ?? DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs;
|
|
564
|
+
|
|
565
|
+
let plannerMarkdown: string | undefined;
|
|
566
|
+
let plannerError: unknown;
|
|
567
|
+
|
|
442
568
|
try {
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
569
|
+
const plannerText = await runTodoPlannerPrompt({
|
|
570
|
+
session,
|
|
571
|
+
prompt: buildTodoCreationPrompt(options.inputText, options.goal),
|
|
572
|
+
abortSignal: options.abortSignal,
|
|
573
|
+
timeoutMs,
|
|
574
|
+
gracefulShutdownMs,
|
|
575
|
+
diagnostics: result.diagnostics,
|
|
576
|
+
onDiagnostic: options.onDiagnostic,
|
|
449
577
|
});
|
|
450
|
-
session = result.session;
|
|
451
578
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
579
|
+
plannerMarkdown = await extractTodoMarkdownWithOneRepair(
|
|
580
|
+
options.inputText,
|
|
581
|
+
plannerText,
|
|
582
|
+
(repairPrompt) =>
|
|
583
|
+
runTodoPlannerPrompt({
|
|
584
|
+
session,
|
|
585
|
+
prompt: repairPrompt,
|
|
586
|
+
abortSignal: options.abortSignal,
|
|
587
|
+
timeoutMs,
|
|
588
|
+
gracefulShutdownMs,
|
|
589
|
+
diagnostics: result.diagnostics,
|
|
590
|
+
onDiagnostic: options.onDiagnostic,
|
|
591
|
+
}),
|
|
592
|
+
options.goal,
|
|
593
|
+
{
|
|
594
|
+
onInvalidOutput: (validationError) =>
|
|
595
|
+
options.onDiagnostic?.({
|
|
596
|
+
kind: "invalid_output",
|
|
597
|
+
message: `TODO planner returned invalid output: ${validationError}`,
|
|
598
|
+
diagnostics: result.diagnostics,
|
|
599
|
+
sessionFile: session.sessionFile,
|
|
600
|
+
sessionId: session.sessionId,
|
|
601
|
+
}),
|
|
602
|
+
onRepairAttempt: (validationError) =>
|
|
603
|
+
options.onDiagnostic?.({
|
|
604
|
+
kind: "repair_attempt",
|
|
605
|
+
message: `Asking TODO planner to repair invalid output: ${validationError}`,
|
|
606
|
+
diagnostics: result.diagnostics,
|
|
607
|
+
sessionFile: session.sessionFile,
|
|
608
|
+
sessionId: session.sessionId,
|
|
609
|
+
}),
|
|
610
|
+
onFailure: (validationError) =>
|
|
611
|
+
options.onDiagnostic?.({
|
|
612
|
+
kind: "failure",
|
|
613
|
+
message: `TODO planner repair failed: ${validationError}`,
|
|
614
|
+
diagnostics: result.diagnostics,
|
|
615
|
+
sessionFile: session.sessionFile,
|
|
616
|
+
sessionId: session.sessionId,
|
|
617
|
+
}),
|
|
618
|
+
},
|
|
619
|
+
);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
plannerError = error;
|
|
622
|
+
}
|
|
455
623
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if (!text) {
|
|
461
|
-
throw new Error("TODO planner did not return assistant text.");
|
|
462
|
-
}
|
|
463
|
-
return text;
|
|
464
|
-
} finally {
|
|
465
|
-
session?.dispose?.();
|
|
624
|
+
try {
|
|
625
|
+
await Promise.resolve(session.dispose?.());
|
|
626
|
+
} catch (error) {
|
|
627
|
+
plannerError = plannerError ?? new TodoGenerationError(`TODO planner dispose failed: ${errorMessage(error)}`);
|
|
466
628
|
}
|
|
629
|
+
|
|
630
|
+
if (plannerError) {
|
|
631
|
+
throw plannerError;
|
|
632
|
+
}
|
|
633
|
+
if (!plannerMarkdown) {
|
|
634
|
+
throw new TodoGenerationError("TODO planner did not return valid TODO markdown.");
|
|
635
|
+
}
|
|
636
|
+
return applyGoalInstructionsToTodoMarkdown(plannerMarkdown, options.goal);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function runTodoPlannerPrompt(options: {
|
|
640
|
+
session: WorkerSessionLike;
|
|
641
|
+
prompt: string;
|
|
642
|
+
abortSignal?: AbortSignal;
|
|
643
|
+
timeoutMs: number;
|
|
644
|
+
gracefulShutdownMs: number;
|
|
645
|
+
diagnostics?: string[];
|
|
646
|
+
onDiagnostic?: PlannerDiagnosticHandler;
|
|
647
|
+
}): Promise<string> {
|
|
648
|
+
const promptResult = await runGuardedSessionPrompt({
|
|
649
|
+
session: options.session,
|
|
650
|
+
prompt: options.prompt,
|
|
651
|
+
abortSignal: options.abortSignal,
|
|
652
|
+
timeoutMs: options.timeoutMs,
|
|
653
|
+
gracefulShutdownMs: options.gracefulShutdownMs,
|
|
654
|
+
gracefulShutdownPrompt: buildTodoPlanningShutdownMessage(),
|
|
655
|
+
diagnostics: options.diagnostics,
|
|
656
|
+
dispose: false,
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
if (promptResult.timedOut) {
|
|
660
|
+
const message = `TODO planner timed out: ${promptResult.error ?? "time budget exceeded"}`;
|
|
661
|
+
options.onDiagnostic?.(plannerPromptDiagnostic("timeout", message, promptResult));
|
|
662
|
+
throw new TodoGenerationError(message);
|
|
663
|
+
}
|
|
664
|
+
if (promptResult.aborted) {
|
|
665
|
+
const message = `TODO planner aborted: ${promptResult.error ?? "outer abort signal"}`;
|
|
666
|
+
options.onDiagnostic?.(plannerPromptDiagnostic("abort", message, promptResult));
|
|
667
|
+
throw new TodoGenerationError(message);
|
|
668
|
+
}
|
|
669
|
+
if (promptResult.error) {
|
|
670
|
+
const message = `TODO planner failed: ${promptResult.error}`;
|
|
671
|
+
options.onDiagnostic?.(plannerPromptDiagnostic("failure", message, promptResult));
|
|
672
|
+
throw new TodoGenerationError(message);
|
|
673
|
+
}
|
|
674
|
+
if (!promptResult.assistantText) {
|
|
675
|
+
const message = "TODO planner did not return assistant text.";
|
|
676
|
+
options.onDiagnostic?.(plannerPromptDiagnostic("failure", message, promptResult));
|
|
677
|
+
throw new TodoGenerationError(message);
|
|
678
|
+
}
|
|
679
|
+
return promptResult.assistantText;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function plannerPromptDiagnostic(
|
|
683
|
+
kind: Extract<PlannerDiagnosticKind, "timeout" | "abort" | "failure">,
|
|
684
|
+
message: string,
|
|
685
|
+
promptResult: {
|
|
686
|
+
diagnostics: string[];
|
|
687
|
+
sessionFile?: string;
|
|
688
|
+
sessionId?: string;
|
|
689
|
+
},
|
|
690
|
+
): PlannerDiagnostic {
|
|
691
|
+
return {
|
|
692
|
+
kind,
|
|
693
|
+
message,
|
|
694
|
+
diagnostics: promptResult.diagnostics,
|
|
695
|
+
sessionFile: promptResult.sessionFile,
|
|
696
|
+
sessionId: promptResult.sessionId,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function buildTodoPlanningShutdownMessage(): string {
|
|
701
|
+
return `Pi Long Task notice: TODO planning has reached its time budget.
|
|
702
|
+
Return the best valid Pi Long Task TODO markdown you can produce now, or stop if that is not possible.`;
|
|
467
703
|
}
|
|
468
704
|
|
|
469
705
|
function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
|
|
470
706
|
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
471
707
|
const runId = sanitizeRunId(options.runId ?? defaultRunId(options.now?.() ?? new Date()));
|
|
472
708
|
const runDir = path.join(cwd, "tmp", "pi-long-task", runId);
|
|
473
|
-
const parsedWorkerConfig = parseWorkerRuntimeConfig(options.inputText);
|
|
709
|
+
const parsedWorkerConfig = parseWorkerRuntimeConfig(options.inputText ?? "");
|
|
474
710
|
const configuredAttempts = options.maxAttemptsPerTask ?? parsedWorkerConfig.maxAttemptsPerTask;
|
|
475
711
|
const configuredTaskTimeoutMs = options.taskTimeoutMs ?? parsedWorkerConfig.taskTimeoutMs;
|
|
712
|
+
const configuredTodoTimeoutMs = options.todoTimeoutMs;
|
|
713
|
+
const configuredTodoGracefulShutdownMs = options.todoGracefulShutdownMs;
|
|
476
714
|
const configuredMaxBashTimeoutMs = options.maxBashTimeoutMs ?? parsedWorkerConfig.maxBashTimeoutMs;
|
|
477
715
|
const workerModelName = options.workerModelName ?? parsedWorkerConfig.modelName;
|
|
478
716
|
const workerModel = workerModelName ? undefined : options.workerModel;
|
|
717
|
+
const goal = normalizeOptionalText(options.goal);
|
|
479
718
|
|
|
480
719
|
return {
|
|
481
720
|
cwd,
|
|
@@ -485,10 +724,16 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
|
|
|
485
724
|
taskResultPath: path.join(runDir, "TASK_RESULT.md"),
|
|
486
725
|
maxAttemptsPerTask: positiveInteger(configuredAttempts, DEFAULT_COORDINATOR_OPTIONS.maxAttemptsPerTask),
|
|
487
726
|
taskTimeoutSeconds: positiveMilliseconds(configuredTaskTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.taskTimeoutMs) / 1000,
|
|
727
|
+
todoTimeoutMs: positiveMilliseconds(configuredTodoTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs),
|
|
728
|
+
todoGracefulShutdownMs: positiveMilliseconds(
|
|
729
|
+
configuredTodoGracefulShutdownMs,
|
|
730
|
+
DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs,
|
|
731
|
+
),
|
|
488
732
|
maxBashTimeoutSeconds:
|
|
489
733
|
positiveMilliseconds(configuredMaxBashTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.maxBashTimeoutMs) / 1000,
|
|
490
734
|
workerModel,
|
|
491
735
|
workerModelName,
|
|
736
|
+
goal,
|
|
492
737
|
taskThinking: options.taskThinking ?? DEFAULT_COORDINATOR_OPTIONS.taskThinking,
|
|
493
738
|
todoThinking: options.todoThinking ?? DEFAULT_COORDINATOR_OPTIONS.todoThinking,
|
|
494
739
|
workerRunner: options.workerRunner ?? runWorkerTask,
|
|
@@ -499,6 +744,7 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
|
|
|
499
744
|
now: options.now ?? (() => new Date()),
|
|
500
745
|
onProgress: options.onProgress,
|
|
501
746
|
workerCostState: createWorkerCostState(),
|
|
747
|
+
plannerDiagnostics: [],
|
|
502
748
|
};
|
|
503
749
|
}
|
|
504
750
|
|
|
@@ -514,6 +760,32 @@ function emitProgress(
|
|
|
514
760
|
resultPath: runtime.taskResultPath,
|
|
515
761
|
workerCostTotal: runtime.workerCostState.total,
|
|
516
762
|
...update,
|
|
763
|
+
goal: runtime.goal,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function recordPlannerDiagnostic(runtime: RuntimeOptions, diagnostic: PlannerDiagnostic): void {
|
|
768
|
+
const normalized: PlannerDiagnostic = {
|
|
769
|
+
kind: diagnostic.kind,
|
|
770
|
+
message: diagnostic.message,
|
|
771
|
+
diagnostics: diagnostic.diagnostics?.filter(Boolean),
|
|
772
|
+
sessionFile: diagnostic.sessionFile,
|
|
773
|
+
sessionId: diagnostic.sessionId,
|
|
774
|
+
};
|
|
775
|
+
const last = runtime.plannerDiagnostics.at(-1);
|
|
776
|
+
if (last?.kind === normalized.kind && last.message === normalized.message) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
runtime.plannerDiagnostics.push(normalized);
|
|
780
|
+
emitProgress(runtime, normalized.message, {
|
|
781
|
+
phase: "planning",
|
|
782
|
+
status: normalized.kind,
|
|
783
|
+
isError: normalized.kind !== "repair_attempt",
|
|
784
|
+
plannerDiagnostic: normalized.kind,
|
|
785
|
+
plannerDiagnostics: normalized.diagnostics,
|
|
786
|
+
plannerSessionFile: normalized.sessionFile,
|
|
787
|
+
plannerSessionId: normalized.sessionId,
|
|
788
|
+
taskProgress: buildTaskProgressModel({ tasks: [] }),
|
|
517
789
|
});
|
|
518
790
|
}
|
|
519
791
|
|
|
@@ -771,6 +1043,30 @@ function initialTaskResultMarkdown(runId: string): string {
|
|
|
771
1043
|
return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
|
|
772
1044
|
}
|
|
773
1045
|
|
|
1046
|
+
async function appendFailureNote(
|
|
1047
|
+
pathname: string,
|
|
1048
|
+
message: string,
|
|
1049
|
+
plannerDiagnostics: readonly PlannerDiagnostic[],
|
|
1050
|
+
): Promise<void> {
|
|
1051
|
+
const lines = ["", "## Pi Long Task failure", "", message];
|
|
1052
|
+
if (plannerDiagnostics.length > 0) {
|
|
1053
|
+
lines.push("", "### Planner diagnostics");
|
|
1054
|
+
for (const diagnostic of plannerDiagnostics) {
|
|
1055
|
+
lines.push("", `- ${diagnostic.kind}: ${diagnostic.message}`);
|
|
1056
|
+
if (diagnostic.sessionId) {
|
|
1057
|
+
lines.push(` - Session ID: ${diagnostic.sessionId}`);
|
|
1058
|
+
}
|
|
1059
|
+
if (diagnostic.sessionFile) {
|
|
1060
|
+
lines.push(` - Session file: ${diagnostic.sessionFile}`);
|
|
1061
|
+
}
|
|
1062
|
+
for (const item of diagnostic.diagnostics ?? []) {
|
|
1063
|
+
lines.push(` - Diagnostic: ${item}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
|
|
1068
|
+
}
|
|
1069
|
+
|
|
774
1070
|
async function appendCommitNote(pathname: string, result: CommitAfterSessionResult): Promise<void> {
|
|
775
1071
|
const lines = ["", "### Commit note", ""];
|
|
776
1072
|
if (result.hash) {
|
|
@@ -874,6 +1170,15 @@ function sanitizeRunId(runId: string): string {
|
|
|
874
1170
|
return sanitized || defaultRunId(new Date());
|
|
875
1171
|
}
|
|
876
1172
|
|
|
1173
|
+
function normalizeOptionalText(value: string | undefined): string | undefined {
|
|
1174
|
+
const trimmed = value?.trim();
|
|
1175
|
+
return trimmed ? trimmed : undefined;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function coordinatorInputText(options: RunCoordinatorOptions): string {
|
|
1179
|
+
return normalizeOptionalText(options.inputText) ?? normalizeOptionalText(options.goal) ?? "";
|
|
1180
|
+
}
|
|
1181
|
+
|
|
877
1182
|
function positiveInteger(value: number | undefined, fallback: number): number {
|
|
878
1183
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
879
1184
|
return Math.floor(value);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export interface CoverageGoal {
|
|
2
|
+
thresholdPercent: number;
|
|
3
|
+
thresholdText: string;
|
|
4
|
+
relation: "above" | "at least";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const PERCENT_RE = /(\d+(?:\.\d+)?)\s*%/;
|
|
8
|
+
const COVERAGE_RE = /\b(?:test(?:ing)?\s+)?(?:line\s+)?coverage\b/i;
|
|
9
|
+
const ABOVE_RE = /(?:\babove\b|\bover\b|\bgreater\s+than\b|\bmore\s+than\b|>)/i;
|
|
10
|
+
const AT_LEAST_RE = /(?:\bat\s+least\b|\bminimum\b|\bmin\b|\bno\s+less\s+than\b|>=)/i;
|
|
11
|
+
|
|
12
|
+
export function parseCoverageGoal(text: string | undefined): CoverageGoal | undefined {
|
|
13
|
+
const trimmed = text?.trim();
|
|
14
|
+
if (!trimmed || !COVERAGE_RE.test(trimmed)) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const percent = PERCENT_RE.exec(trimmed);
|
|
19
|
+
if (!percent?.[1]) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const thresholdPercent = Number.parseFloat(percent[1]);
|
|
24
|
+
if (!Number.isFinite(thresholdPercent) || thresholdPercent < 0 || thresholdPercent > 100) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
thresholdPercent,
|
|
30
|
+
thresholdText: formatPercent(thresholdPercent),
|
|
31
|
+
relation: coverageRelation(trimmed),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function coverageGoalPhrase(goal: CoverageGoal): string {
|
|
36
|
+
return `testing line coverage ${goal.relation} ${goal.thresholdText}%`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function coverageGoalAction(goal: CoverageGoal): string {
|
|
40
|
+
return `Raise or maintain ${coverageGoalPhrase(goal)}.`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function coverageGoalVerification(goal: CoverageGoal): string {
|
|
44
|
+
return `Run the repository's coverage command and confirm line coverage is ${goal.relation} ${goal.thresholdText}%; report the command and resulting line coverage.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function coverageGoalVerifyBullet(goal: CoverageGoal): string {
|
|
48
|
+
return `- ${coverageGoalVerification(goal)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function inferCoverageGoalText(text: string): string | undefined {
|
|
52
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
53
|
+
if (!parseCoverageGoal(normalized)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const coverageIndex = normalized.search(/(?:test(?:ing)?\s+)?(?:line\s+)?coverage/i);
|
|
58
|
+
if (coverageIndex < 0) {
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const prefix = normalized.slice(0, coverageIndex).trimEnd();
|
|
63
|
+
const action = /(have|reach|raise|maintain|keep|increase|get|achieve|hit|ensure)\s*$/i.exec(prefix);
|
|
64
|
+
const start = action?.index ?? coverageIndex;
|
|
65
|
+
return normalizeCoverageGoalText(normalized.slice(start));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeCoverageGoalText(text: string): string | undefined {
|
|
69
|
+
const trimmed = text
|
|
70
|
+
.replace(/^\b(?:to|for|that)\b\s+/i, "")
|
|
71
|
+
.replace(/[.,;:!?]+$/g, "")
|
|
72
|
+
.trim();
|
|
73
|
+
return trimmed || undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function coverageRelation(text: string): CoverageGoal["relation"] {
|
|
77
|
+
const percentIndex = text.search(PERCENT_RE);
|
|
78
|
+
const relationWindow = percentIndex >= 0 ? text.slice(Math.max(0, percentIndex - 40), percentIndex + 8) : text;
|
|
79
|
+
if (AT_LEAST_RE.test(relationWindow)) {
|
|
80
|
+
return "at least";
|
|
81
|
+
}
|
|
82
|
+
if (ABOVE_RE.test(relationWindow)) {
|
|
83
|
+
return "above";
|
|
84
|
+
}
|
|
85
|
+
return "at least";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatPercent(value: number): string {
|
|
89
|
+
return Number.isInteger(value) ? String(value) : String(value).replace(/0+$/g, "").replace(/\.$/, "");
|
|
90
|
+
}
|