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.
@@ -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
- extractTodoMarkdown,
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(options.inputText, runtime);
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
- const summary = `Pi Long Task failed: ${message}`;
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 appendFile(runtime.taskResultPath, `\n## Pi Long Task failure\n\n${message}\n`, "utf8");
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
- error: message,
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.todoPlanner({
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
- let session: WorkerSessionLike | undefined;
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 sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
444
- const result = await sessionFactory({
445
- cwd: options.cwd,
446
- tools: [],
447
- model: options.model,
448
- thinkingLevel: options.thinkingLevel,
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
- if (options.abortSignal?.aborted) {
453
- throw new Error("TODO planner aborted before start");
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
- await session.prompt(buildTodoCreationPrompt(options.inputText));
457
- const direct = session.getLastAssistantText?.();
458
- const fromMessages = lastAssistantTextFromMessages(session.messages);
459
- const text = direct || fromMessages;
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
+ }