pi-long-task 0.3.8 → 0.3.9
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 +4 -2
- package/package.json +1 -1
- package/src/coordinator.ts +307 -28
- package/src/index.ts +1 -0
- package/src/session_guard.ts +287 -0
- package/src/todo_generator.ts +10 -0
package/README.md
CHANGED
|
@@ -18,11 +18,11 @@ Use it when a coding request is bigger than one focused interaction. Pi Long Tas
|
|
|
18
18
|
When you ask Pi to run a long task, Pi Long Task:
|
|
19
19
|
|
|
20
20
|
1. Recognizes natural-language requests like "run a long task with commits" and routes them to `pi_long_task`.
|
|
21
|
-
2. Creates or cleans up a TODO plan from your request.
|
|
21
|
+
2. Creates or cleans up a TODO plan from your request. Natural-language planning uses a bounded planner session; if generated TODO markdown is invalid, Pi Long Task asks the planner to repair it once before failing the run.
|
|
22
22
|
3. Works through each unfinished TODO task in order using isolated worker sessions.
|
|
23
23
|
4. Registers a Pi TUI sidebar/widget when UI support is available and updates it with the current task, inferred subtask progress, and full task timeline while the run is active.
|
|
24
24
|
5. Retries unfinished tasks up to the configured attempt limit.
|
|
25
|
-
6. Records progress, task artifacts, and final results under `tmp/pi-long-task/<run-id>/`.
|
|
25
|
+
6. Records progress, planner diagnostics, task artifacts, and final results under `tmp/pi-long-task/<run-id>/`.
|
|
26
26
|
7. Returns a summary with completed, failed, blocked, and remaining task counts, plus worker spend when available.
|
|
27
27
|
8. Optionally commits completed work after each task.
|
|
28
28
|
|
|
@@ -230,6 +230,8 @@ That smoke test creates disposable git repos and verifies both `commit: false` a
|
|
|
230
230
|
## Limitations and expectations
|
|
231
231
|
|
|
232
232
|
- Tasks run sequentially, one TODO at a time; Pi Long Task prioritizes isolation, progress tracking, and safe handoff over parallel execution.
|
|
233
|
+
- Natural-language TODO planning has a bounded time budget (five minutes by default, with a short graceful-shutdown request). If planning times out or is aborted before a valid plan exists, the run fails before worker tasks start and records planner diagnostics in `TASK_RESULT.md`.
|
|
234
|
+
- If the planner returns invalid TODO markdown, Pi Long Task makes one repair attempt. A second invalid response fails planning with diagnostics instead of guessing at a plan.
|
|
233
235
|
- Real runs require usable Pi model credentials, such as a working Pi login or API key for the selected model.
|
|
234
236
|
- Worker spend is added to the main Pi `$ spent` total as cost-only usage. Token counts are not merged into the main thread because worker sessions have separate context windows, and merging their token usage would corrupt the main conversation's context statistics.
|
|
235
237
|
- Run artifacts are written under `tmp/pi-long-task/<run-id>/`.
|
package/package.json
CHANGED
package/src/coordinator.ts
CHANGED
|
@@ -11,18 +11,20 @@ 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 {
|
|
17
18
|
buildTodoCreationPrompt,
|
|
18
|
-
|
|
19
|
+
buildTodoRepairPrompt,
|
|
20
|
+
extractAndValidateTodoMarkdown,
|
|
21
|
+
TodoGenerationError,
|
|
19
22
|
todoMarkdownFromString,
|
|
20
23
|
validateTodoMarkdown,
|
|
21
24
|
} from "./todo_generator.ts";
|
|
22
25
|
import { markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
|
|
23
26
|
import {
|
|
24
27
|
createIsolatedWorkerSession,
|
|
25
|
-
lastAssistantTextFromMessages,
|
|
26
28
|
runWorkerTask,
|
|
27
29
|
type RunWorkerTaskOptions,
|
|
28
30
|
type SessionOutcome,
|
|
@@ -35,6 +37,8 @@ export type { CoordinatorStatus } from "./types.ts";
|
|
|
35
37
|
export const DEFAULT_COORDINATOR_OPTIONS = {
|
|
36
38
|
maxAttemptsPerTask: 3,
|
|
37
39
|
taskTimeoutMs: 900_000,
|
|
40
|
+
todoTimeoutMs: 300_000,
|
|
41
|
+
todoGracefulShutdownMs: 15_000,
|
|
38
42
|
maxBashTimeoutMs: 300_000,
|
|
39
43
|
taskThinking: "high",
|
|
40
44
|
todoThinking: "xhigh",
|
|
@@ -51,6 +55,18 @@ export type CoordinatorProgressPhase =
|
|
|
51
55
|
| "task_failed"
|
|
52
56
|
| "complete";
|
|
53
57
|
|
|
58
|
+
export type PlannerDiagnosticKind = "timeout" | "abort" | "invalid_output" | "repair_attempt" | "failure";
|
|
59
|
+
|
|
60
|
+
export interface PlannerDiagnostic {
|
|
61
|
+
kind: PlannerDiagnosticKind;
|
|
62
|
+
message: string;
|
|
63
|
+
diagnostics?: string[];
|
|
64
|
+
sessionFile?: string;
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type PlannerDiagnosticHandler = (diagnostic: PlannerDiagnostic) => void;
|
|
69
|
+
|
|
54
70
|
export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
|
|
55
71
|
|
|
56
72
|
export interface CoordinatorProgressTask {
|
|
@@ -85,6 +101,10 @@ export interface CoordinatorProgressUpdate {
|
|
|
85
101
|
currentTask?: CoordinatorProgressTask;
|
|
86
102
|
subtasks?: CoordinatorProgressSubtask[];
|
|
87
103
|
taskProgress?: TaskProgressModel;
|
|
104
|
+
plannerDiagnostic?: PlannerDiagnosticKind;
|
|
105
|
+
plannerDiagnostics?: string[];
|
|
106
|
+
plannerSessionFile?: string;
|
|
107
|
+
plannerSessionId?: string;
|
|
88
108
|
}
|
|
89
109
|
|
|
90
110
|
export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
|
|
@@ -102,6 +122,8 @@ export interface RunCoordinatorOptions extends PiLongTaskInput {
|
|
|
102
122
|
workerModelName?: string;
|
|
103
123
|
maxAttemptsPerTask?: number;
|
|
104
124
|
taskTimeoutMs?: number;
|
|
125
|
+
todoTimeoutMs?: number;
|
|
126
|
+
todoGracefulShutdownMs?: number;
|
|
105
127
|
maxBashTimeoutMs?: number;
|
|
106
128
|
taskThinking?: string;
|
|
107
129
|
todoThinking?: string;
|
|
@@ -116,7 +138,10 @@ export interface TodoPlannerOptions {
|
|
|
116
138
|
thinkingLevel: string;
|
|
117
139
|
model?: unknown;
|
|
118
140
|
abortSignal?: AbortSignal;
|
|
141
|
+
timeoutMs?: number;
|
|
142
|
+
gracefulShutdownMs?: number;
|
|
119
143
|
sessionFactory?: WorkerSessionFactory;
|
|
144
|
+
onDiagnostic?: PlannerDiagnosticHandler;
|
|
120
145
|
}
|
|
121
146
|
|
|
122
147
|
export interface TaskAttemptSummary {
|
|
@@ -175,6 +200,8 @@ interface RuntimeOptions {
|
|
|
175
200
|
workerModelName?: string;
|
|
176
201
|
taskThinking: string;
|
|
177
202
|
todoThinking: string;
|
|
203
|
+
todoTimeoutMs: number;
|
|
204
|
+
todoGracefulShutdownMs: number;
|
|
178
205
|
workerRunner: WorkerRunner;
|
|
179
206
|
todoPlanner: TodoPlanner;
|
|
180
207
|
abortSignal?: AbortSignal;
|
|
@@ -183,6 +210,7 @@ interface RuntimeOptions {
|
|
|
183
210
|
now: () => Date;
|
|
184
211
|
onProgress?: CoordinatorProgressHandler;
|
|
185
212
|
workerCostState: WorkerCostState;
|
|
213
|
+
plannerDiagnostics: PlannerDiagnostic[];
|
|
186
214
|
}
|
|
187
215
|
|
|
188
216
|
export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
|
|
@@ -193,11 +221,13 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
193
221
|
|
|
194
222
|
await mkdir(runtime.runDir, { recursive: true });
|
|
195
223
|
await writeFile(runtime.taskResultPath, initialTaskResultMarkdown(runtime.runId), "utf8");
|
|
224
|
+
let planningComplete = false;
|
|
196
225
|
|
|
197
226
|
try {
|
|
198
227
|
emitProgress(runtime, "Creating TODO plan...", { phase: "planning" });
|
|
199
228
|
let todoMarkdown = await generateOrNormalizeTodoMarkdown(options.inputText, runtime);
|
|
200
229
|
validateTodoMarkdown(todoMarkdown);
|
|
230
|
+
planningComplete = true;
|
|
201
231
|
await writeFile(runtime.todoPath, todoMarkdown, "utf8");
|
|
202
232
|
const initialTasks = parseTasks(todoMarkdown);
|
|
203
233
|
emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
|
|
@@ -379,9 +409,18 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
379
409
|
return result;
|
|
380
410
|
} catch (error) {
|
|
381
411
|
const message = errorMessage(error);
|
|
382
|
-
|
|
412
|
+
if (!planningComplete) {
|
|
413
|
+
recordPlannerDiagnostic(runtime, {
|
|
414
|
+
kind: "failure",
|
|
415
|
+
message: `TODO planning failed: ${message}`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const resultError = !planningComplete
|
|
419
|
+
? `${message} See ${runtime.taskResultPath} for planner diagnostics.`
|
|
420
|
+
: message;
|
|
421
|
+
const summary = `Pi Long Task failed: ${resultError}`;
|
|
383
422
|
try {
|
|
384
|
-
await
|
|
423
|
+
await appendFailureNote(runtime.taskResultPath, message, !planningComplete ? runtime.plannerDiagnostics : []);
|
|
385
424
|
} catch {
|
|
386
425
|
// Best effort only; the original error is returned below.
|
|
387
426
|
}
|
|
@@ -407,7 +446,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
407
446
|
taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
|
|
408
447
|
workerCostTotal: runtime.workerCostState.total,
|
|
409
448
|
commit: options.commit,
|
|
410
|
-
error:
|
|
449
|
+
error: resultError,
|
|
411
450
|
};
|
|
412
451
|
result.message = formatCoordinatorResultMessage(result);
|
|
413
452
|
emitProgress(runtime, "Pi Long Task failed.", {
|
|
@@ -425,45 +464,228 @@ async function generateOrNormalizeTodoMarkdown(inputText: string, runtime: Runti
|
|
|
425
464
|
return local;
|
|
426
465
|
}
|
|
427
466
|
|
|
428
|
-
const plannerText = await runtime
|
|
467
|
+
const plannerText = await requestTodoPlan(inputText, runtime);
|
|
468
|
+
return extractTodoMarkdownWithOneRepair(
|
|
469
|
+
inputText,
|
|
470
|
+
plannerText,
|
|
471
|
+
(repairPrompt) => requestTodoPlan(repairPrompt, runtime),
|
|
472
|
+
{
|
|
473
|
+
onInvalidOutput: (validationError) =>
|
|
474
|
+
recordPlannerDiagnostic(runtime, {
|
|
475
|
+
kind: "invalid_output",
|
|
476
|
+
message: `TODO planner returned invalid output: ${validationError}`,
|
|
477
|
+
}),
|
|
478
|
+
onRepairAttempt: (validationError) =>
|
|
479
|
+
recordPlannerDiagnostic(runtime, {
|
|
480
|
+
kind: "repair_attempt",
|
|
481
|
+
message: `Asking TODO planner to repair invalid output: ${validationError}`,
|
|
482
|
+
}),
|
|
483
|
+
onFailure: (validationError) =>
|
|
484
|
+
recordPlannerDiagnostic(runtime, {
|
|
485
|
+
kind: "failure",
|
|
486
|
+
message: `TODO planner repair failed: ${validationError}`,
|
|
487
|
+
}),
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
interface TodoExtractionRepairHooks {
|
|
493
|
+
onInvalidOutput?: (validationError: string) => void;
|
|
494
|
+
onRepairAttempt?: (validationError: string) => void;
|
|
495
|
+
onFailure?: (validationError: string) => void;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function extractTodoMarkdownWithOneRepair(
|
|
499
|
+
inputText: string,
|
|
500
|
+
plannerText: string,
|
|
501
|
+
requestRepair: (repairPrompt: string) => Promise<string>,
|
|
502
|
+
hooks: TodoExtractionRepairHooks = {},
|
|
503
|
+
): Promise<string> {
|
|
504
|
+
try {
|
|
505
|
+
return extractAndValidateTodoMarkdown(plannerText);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
const validationError = errorMessage(error);
|
|
508
|
+
hooks.onInvalidOutput?.(validationError);
|
|
509
|
+
hooks.onRepairAttempt?.(validationError);
|
|
510
|
+
const repairText = await requestRepair(buildTodoRepairPrompt(inputText, plannerText, validationError));
|
|
511
|
+
try {
|
|
512
|
+
return extractAndValidateTodoMarkdown(repairText);
|
|
513
|
+
} catch (repairError) {
|
|
514
|
+
const repairMessage = errorMessage(repairError);
|
|
515
|
+
hooks.onFailure?.(repairMessage);
|
|
516
|
+
throw new TodoGenerationError(
|
|
517
|
+
`TODO planner returned invalid TODO markdown after one repair attempt: ${repairMessage}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function requestTodoPlan(inputText: string, runtime: RuntimeOptions): Promise<string> {
|
|
524
|
+
return runtime.todoPlanner({
|
|
429
525
|
inputText,
|
|
430
526
|
cwd: runtime.cwd,
|
|
431
527
|
runDir: runtime.runDir,
|
|
432
528
|
thinkingLevel: runtime.todoThinking,
|
|
433
529
|
model: runtime.workerModel,
|
|
434
530
|
abortSignal: runtime.abortSignal,
|
|
531
|
+
timeoutMs: runtime.todoTimeoutMs,
|
|
532
|
+
gracefulShutdownMs: runtime.todoGracefulShutdownMs,
|
|
435
533
|
sessionFactory: runtime.todoSessionFactory,
|
|
534
|
+
onDiagnostic: (diagnostic) => recordPlannerDiagnostic(runtime, diagnostic),
|
|
436
535
|
});
|
|
437
|
-
return extractTodoMarkdown(plannerText);
|
|
438
536
|
}
|
|
439
537
|
|
|
538
|
+
// Planner/worker lifecycle differences are audited in docs/planner-worker-lifecycle-audit.md;
|
|
539
|
+
// keep this function's public contract stable while moving shared prompt guarding into a helper.
|
|
440
540
|
export async function runTodoPlanner(options: TodoPlannerOptions): Promise<string> {
|
|
441
|
-
|
|
541
|
+
const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
|
|
542
|
+
const result = await sessionFactory({
|
|
543
|
+
cwd: options.cwd,
|
|
544
|
+
tools: [],
|
|
545
|
+
model: options.model,
|
|
546
|
+
thinkingLevel: options.thinkingLevel,
|
|
547
|
+
});
|
|
548
|
+
const session = result.session;
|
|
549
|
+
const timeoutMs = positiveMilliseconds(options.timeoutMs, DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs);
|
|
550
|
+
const gracefulShutdownMs = options.gracefulShutdownMs ?? DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs;
|
|
551
|
+
|
|
552
|
+
let plannerMarkdown: string | undefined;
|
|
553
|
+
let plannerError: unknown;
|
|
554
|
+
|
|
442
555
|
try {
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
556
|
+
const plannerText = await runTodoPlannerPrompt({
|
|
557
|
+
session,
|
|
558
|
+
prompt: buildTodoCreationPrompt(options.inputText),
|
|
559
|
+
abortSignal: options.abortSignal,
|
|
560
|
+
timeoutMs,
|
|
561
|
+
gracefulShutdownMs,
|
|
562
|
+
diagnostics: result.diagnostics,
|
|
563
|
+
onDiagnostic: options.onDiagnostic,
|
|
449
564
|
});
|
|
450
|
-
session = result.session;
|
|
451
565
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
566
|
+
plannerMarkdown = await extractTodoMarkdownWithOneRepair(
|
|
567
|
+
options.inputText,
|
|
568
|
+
plannerText,
|
|
569
|
+
(repairPrompt) =>
|
|
570
|
+
runTodoPlannerPrompt({
|
|
571
|
+
session,
|
|
572
|
+
prompt: repairPrompt,
|
|
573
|
+
abortSignal: options.abortSignal,
|
|
574
|
+
timeoutMs,
|
|
575
|
+
gracefulShutdownMs,
|
|
576
|
+
diagnostics: result.diagnostics,
|
|
577
|
+
onDiagnostic: options.onDiagnostic,
|
|
578
|
+
}),
|
|
579
|
+
{
|
|
580
|
+
onInvalidOutput: (validationError) =>
|
|
581
|
+
options.onDiagnostic?.({
|
|
582
|
+
kind: "invalid_output",
|
|
583
|
+
message: `TODO planner returned invalid output: ${validationError}`,
|
|
584
|
+
diagnostics: result.diagnostics,
|
|
585
|
+
sessionFile: session.sessionFile,
|
|
586
|
+
sessionId: session.sessionId,
|
|
587
|
+
}),
|
|
588
|
+
onRepairAttempt: (validationError) =>
|
|
589
|
+
options.onDiagnostic?.({
|
|
590
|
+
kind: "repair_attempt",
|
|
591
|
+
message: `Asking TODO planner to repair invalid output: ${validationError}`,
|
|
592
|
+
diagnostics: result.diagnostics,
|
|
593
|
+
sessionFile: session.sessionFile,
|
|
594
|
+
sessionId: session.sessionId,
|
|
595
|
+
}),
|
|
596
|
+
onFailure: (validationError) =>
|
|
597
|
+
options.onDiagnostic?.({
|
|
598
|
+
kind: "failure",
|
|
599
|
+
message: `TODO planner repair failed: ${validationError}`,
|
|
600
|
+
diagnostics: result.diagnostics,
|
|
601
|
+
sessionFile: session.sessionFile,
|
|
602
|
+
sessionId: session.sessionId,
|
|
603
|
+
}),
|
|
604
|
+
},
|
|
605
|
+
);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
plannerError = error;
|
|
608
|
+
}
|
|
455
609
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
} finally {
|
|
465
|
-
session?.dispose?.();
|
|
610
|
+
try {
|
|
611
|
+
await Promise.resolve(session.dispose?.());
|
|
612
|
+
} catch (error) {
|
|
613
|
+
plannerError = plannerError ?? new TodoGenerationError(`TODO planner dispose failed: ${errorMessage(error)}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (plannerError) {
|
|
617
|
+
throw plannerError;
|
|
466
618
|
}
|
|
619
|
+
if (!plannerMarkdown) {
|
|
620
|
+
throw new TodoGenerationError("TODO planner did not return valid TODO markdown.");
|
|
621
|
+
}
|
|
622
|
+
return plannerMarkdown;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function runTodoPlannerPrompt(options: {
|
|
626
|
+
session: WorkerSessionLike;
|
|
627
|
+
prompt: string;
|
|
628
|
+
abortSignal?: AbortSignal;
|
|
629
|
+
timeoutMs: number;
|
|
630
|
+
gracefulShutdownMs: number;
|
|
631
|
+
diagnostics?: string[];
|
|
632
|
+
onDiagnostic?: PlannerDiagnosticHandler;
|
|
633
|
+
}): Promise<string> {
|
|
634
|
+
const promptResult = await runGuardedSessionPrompt({
|
|
635
|
+
session: options.session,
|
|
636
|
+
prompt: options.prompt,
|
|
637
|
+
abortSignal: options.abortSignal,
|
|
638
|
+
timeoutMs: options.timeoutMs,
|
|
639
|
+
gracefulShutdownMs: options.gracefulShutdownMs,
|
|
640
|
+
gracefulShutdownPrompt: buildTodoPlanningShutdownMessage(),
|
|
641
|
+
diagnostics: options.diagnostics,
|
|
642
|
+
dispose: false,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
if (promptResult.timedOut) {
|
|
646
|
+
const message = `TODO planner timed out: ${promptResult.error ?? "time budget exceeded"}`;
|
|
647
|
+
options.onDiagnostic?.(plannerPromptDiagnostic("timeout", message, promptResult));
|
|
648
|
+
throw new TodoGenerationError(message);
|
|
649
|
+
}
|
|
650
|
+
if (promptResult.aborted) {
|
|
651
|
+
const message = `TODO planner aborted: ${promptResult.error ?? "outer abort signal"}`;
|
|
652
|
+
options.onDiagnostic?.(plannerPromptDiagnostic("abort", message, promptResult));
|
|
653
|
+
throw new TodoGenerationError(message);
|
|
654
|
+
}
|
|
655
|
+
if (promptResult.error) {
|
|
656
|
+
const message = `TODO planner failed: ${promptResult.error}`;
|
|
657
|
+
options.onDiagnostic?.(plannerPromptDiagnostic("failure", message, promptResult));
|
|
658
|
+
throw new TodoGenerationError(message);
|
|
659
|
+
}
|
|
660
|
+
if (!promptResult.assistantText) {
|
|
661
|
+
const message = "TODO planner did not return assistant text.";
|
|
662
|
+
options.onDiagnostic?.(plannerPromptDiagnostic("failure", message, promptResult));
|
|
663
|
+
throw new TodoGenerationError(message);
|
|
664
|
+
}
|
|
665
|
+
return promptResult.assistantText;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function plannerPromptDiagnostic(
|
|
669
|
+
kind: Extract<PlannerDiagnosticKind, "timeout" | "abort" | "failure">,
|
|
670
|
+
message: string,
|
|
671
|
+
promptResult: {
|
|
672
|
+
diagnostics: string[];
|
|
673
|
+
sessionFile?: string;
|
|
674
|
+
sessionId?: string;
|
|
675
|
+
},
|
|
676
|
+
): PlannerDiagnostic {
|
|
677
|
+
return {
|
|
678
|
+
kind,
|
|
679
|
+
message,
|
|
680
|
+
diagnostics: promptResult.diagnostics,
|
|
681
|
+
sessionFile: promptResult.sessionFile,
|
|
682
|
+
sessionId: promptResult.sessionId,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function buildTodoPlanningShutdownMessage(): string {
|
|
687
|
+
return `Pi Long Task notice: TODO planning has reached its time budget.
|
|
688
|
+
Return the best valid Pi Long Task TODO markdown you can produce now, or stop if that is not possible.`;
|
|
467
689
|
}
|
|
468
690
|
|
|
469
691
|
function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
|
|
@@ -473,6 +695,8 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
|
|
|
473
695
|
const parsedWorkerConfig = parseWorkerRuntimeConfig(options.inputText);
|
|
474
696
|
const configuredAttempts = options.maxAttemptsPerTask ?? parsedWorkerConfig.maxAttemptsPerTask;
|
|
475
697
|
const configuredTaskTimeoutMs = options.taskTimeoutMs ?? parsedWorkerConfig.taskTimeoutMs;
|
|
698
|
+
const configuredTodoTimeoutMs = options.todoTimeoutMs;
|
|
699
|
+
const configuredTodoGracefulShutdownMs = options.todoGracefulShutdownMs;
|
|
476
700
|
const configuredMaxBashTimeoutMs = options.maxBashTimeoutMs ?? parsedWorkerConfig.maxBashTimeoutMs;
|
|
477
701
|
const workerModelName = options.workerModelName ?? parsedWorkerConfig.modelName;
|
|
478
702
|
const workerModel = workerModelName ? undefined : options.workerModel;
|
|
@@ -485,6 +709,11 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
|
|
|
485
709
|
taskResultPath: path.join(runDir, "TASK_RESULT.md"),
|
|
486
710
|
maxAttemptsPerTask: positiveInteger(configuredAttempts, DEFAULT_COORDINATOR_OPTIONS.maxAttemptsPerTask),
|
|
487
711
|
taskTimeoutSeconds: positiveMilliseconds(configuredTaskTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.taskTimeoutMs) / 1000,
|
|
712
|
+
todoTimeoutMs: positiveMilliseconds(configuredTodoTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs),
|
|
713
|
+
todoGracefulShutdownMs: positiveMilliseconds(
|
|
714
|
+
configuredTodoGracefulShutdownMs,
|
|
715
|
+
DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs,
|
|
716
|
+
),
|
|
488
717
|
maxBashTimeoutSeconds:
|
|
489
718
|
positiveMilliseconds(configuredMaxBashTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.maxBashTimeoutMs) / 1000,
|
|
490
719
|
workerModel,
|
|
@@ -499,6 +728,7 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
|
|
|
499
728
|
now: options.now ?? (() => new Date()),
|
|
500
729
|
onProgress: options.onProgress,
|
|
501
730
|
workerCostState: createWorkerCostState(),
|
|
731
|
+
plannerDiagnostics: [],
|
|
502
732
|
};
|
|
503
733
|
}
|
|
504
734
|
|
|
@@ -517,6 +747,31 @@ function emitProgress(
|
|
|
517
747
|
});
|
|
518
748
|
}
|
|
519
749
|
|
|
750
|
+
function recordPlannerDiagnostic(runtime: RuntimeOptions, diagnostic: PlannerDiagnostic): void {
|
|
751
|
+
const normalized: PlannerDiagnostic = {
|
|
752
|
+
kind: diagnostic.kind,
|
|
753
|
+
message: diagnostic.message,
|
|
754
|
+
diagnostics: diagnostic.diagnostics?.filter(Boolean),
|
|
755
|
+
sessionFile: diagnostic.sessionFile,
|
|
756
|
+
sessionId: diagnostic.sessionId,
|
|
757
|
+
};
|
|
758
|
+
const last = runtime.plannerDiagnostics.at(-1);
|
|
759
|
+
if (last?.kind === normalized.kind && last.message === normalized.message) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
runtime.plannerDiagnostics.push(normalized);
|
|
763
|
+
emitProgress(runtime, normalized.message, {
|
|
764
|
+
phase: "planning",
|
|
765
|
+
status: normalized.kind,
|
|
766
|
+
isError: normalized.kind !== "repair_attempt",
|
|
767
|
+
plannerDiagnostic: normalized.kind,
|
|
768
|
+
plannerDiagnostics: normalized.diagnostics,
|
|
769
|
+
plannerSessionFile: normalized.sessionFile,
|
|
770
|
+
plannerSessionId: normalized.sessionId,
|
|
771
|
+
taskProgress: buildTaskProgressModel({ tasks: [] }),
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
520
775
|
function createWorkerCostState(): WorkerCostState {
|
|
521
776
|
return {
|
|
522
777
|
total: 0,
|
|
@@ -771,6 +1026,30 @@ function initialTaskResultMarkdown(runId: string): string {
|
|
|
771
1026
|
return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
|
|
772
1027
|
}
|
|
773
1028
|
|
|
1029
|
+
async function appendFailureNote(
|
|
1030
|
+
pathname: string,
|
|
1031
|
+
message: string,
|
|
1032
|
+
plannerDiagnostics: readonly PlannerDiagnostic[],
|
|
1033
|
+
): Promise<void> {
|
|
1034
|
+
const lines = ["", "## Pi Long Task failure", "", message];
|
|
1035
|
+
if (plannerDiagnostics.length > 0) {
|
|
1036
|
+
lines.push("", "### Planner diagnostics");
|
|
1037
|
+
for (const diagnostic of plannerDiagnostics) {
|
|
1038
|
+
lines.push("", `- ${diagnostic.kind}: ${diagnostic.message}`);
|
|
1039
|
+
if (diagnostic.sessionId) {
|
|
1040
|
+
lines.push(` - Session ID: ${diagnostic.sessionId}`);
|
|
1041
|
+
}
|
|
1042
|
+
if (diagnostic.sessionFile) {
|
|
1043
|
+
lines.push(` - Session file: ${diagnostic.sessionFile}`);
|
|
1044
|
+
}
|
|
1045
|
+
for (const item of diagnostic.diagnostics ?? []) {
|
|
1046
|
+
lines.push(` - Diagnostic: ${item}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
|
|
1051
|
+
}
|
|
1052
|
+
|
|
774
1053
|
async function appendCommitNote(pathname: string, result: CommitAfterSessionResult): Promise<void> {
|
|
775
1054
|
const lines = ["", "### Commit note", ""];
|
|
776
1055
|
if (result.hash) {
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assistantTextFromEvent,
|
|
3
|
+
lastAssistantTextFromEvents,
|
|
4
|
+
lastAssistantTextFromMessages,
|
|
5
|
+
type WorkerSessionLike,
|
|
6
|
+
} from "./worker_session.ts";
|
|
7
|
+
|
|
8
|
+
export interface GuardedSessionPromptOptions {
|
|
9
|
+
session: WorkerSessionLike;
|
|
10
|
+
prompt: string;
|
|
11
|
+
promptOptions?: Record<string, unknown>;
|
|
12
|
+
abortSignal?: AbortSignal;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
gracefulShutdownMs?: number;
|
|
15
|
+
gracefulShutdownPrompt?: string;
|
|
16
|
+
diagnostics?: string[];
|
|
17
|
+
onEvent?: (event: unknown) => void;
|
|
18
|
+
dispose?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GuardedSessionPromptResult {
|
|
22
|
+
assistantText: string;
|
|
23
|
+
timedOut: boolean;
|
|
24
|
+
aborted: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
diagnostics: string[];
|
|
27
|
+
events: unknown[];
|
|
28
|
+
sessionFile?: string;
|
|
29
|
+
sessionId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runGuardedSessionPrompt(
|
|
33
|
+
options: GuardedSessionPromptOptions,
|
|
34
|
+
): Promise<GuardedSessionPromptResult> {
|
|
35
|
+
const session = options.session;
|
|
36
|
+
const diagnostics = [...(options.diagnostics ?? [])];
|
|
37
|
+
const events: unknown[] = [];
|
|
38
|
+
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
39
|
+
let assistantText = "";
|
|
40
|
+
let timedOut = false;
|
|
41
|
+
let aborted = false;
|
|
42
|
+
let error: string | undefined;
|
|
43
|
+
let promptSettled = false;
|
|
44
|
+
let finished = false;
|
|
45
|
+
let unsubscribe: (() => void) | undefined;
|
|
46
|
+
let complete: (() => void) | undefined;
|
|
47
|
+
|
|
48
|
+
const completed = new Promise<void>((resolve) => {
|
|
49
|
+
complete = resolve;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const resolveCompleted = () => {
|
|
53
|
+
complete?.();
|
|
54
|
+
complete = undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const clearTimers = () => {
|
|
58
|
+
for (const timer of timers) {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
timers.clear();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const schedule = (fn: () => void, ms: number) => {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
timers.delete(timer);
|
|
67
|
+
fn();
|
|
68
|
+
}, ms);
|
|
69
|
+
timers.add(timer);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const abortSession = (reason: string) => {
|
|
73
|
+
if (finished || aborted) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
aborted = true;
|
|
77
|
+
error = error ?? reason;
|
|
78
|
+
try {
|
|
79
|
+
const abortResult = session.abort?.();
|
|
80
|
+
if (isPromiseLike(abortResult)) {
|
|
81
|
+
void abortResult.catch((exc: unknown) => {
|
|
82
|
+
diagnostics.push(`session abort failed: ${errorMessage(exc)}`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} catch (exc) {
|
|
86
|
+
diagnostics.push(`session abort failed: ${errorMessage(exc)}`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const requestGracefulShutdown = () => {
|
|
91
|
+
const message = options.gracefulShutdownPrompt?.trim();
|
|
92
|
+
if (!message || finished || promptSettled || aborted) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (session.isBashRunning && session.abortBash) {
|
|
98
|
+
session.abortBash();
|
|
99
|
+
diagnostics.push("aborted running bash before graceful shutdown request");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let request: Promise<unknown> | undefined;
|
|
103
|
+
if ((session.isStreaming || session.isBashRunning) && session.steer) {
|
|
104
|
+
request = session.steer(message);
|
|
105
|
+
} else if (session.followUp) {
|
|
106
|
+
request = session.followUp(message);
|
|
107
|
+
} else if (session.steer) {
|
|
108
|
+
request = session.steer(message);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!request) {
|
|
112
|
+
diagnostics.push("graceful shutdown request skipped: session does not support steer/followUp");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
void request.catch((exc: unknown) => {
|
|
117
|
+
diagnostics.push(`graceful shutdown request failed: ${errorMessage(exc)}`);
|
|
118
|
+
});
|
|
119
|
+
} catch (exc) {
|
|
120
|
+
diagnostics.push(`graceful shutdown request failed: ${errorMessage(exc)}`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const triggerTimeout = () => {
|
|
125
|
+
if (finished || promptSettled || timedOut) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
timedOut = true;
|
|
129
|
+
diagnostics.push(`session prompt timed out after ${formatMilliseconds(timeoutMs(options.timeoutMs))}`);
|
|
130
|
+
requestGracefulShutdown();
|
|
131
|
+
|
|
132
|
+
const graceMs = nonNegativeMilliseconds(options.gracefulShutdownMs);
|
|
133
|
+
const hardAbort = () => {
|
|
134
|
+
if (finished || promptSettled) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
abortSession(`session prompt exceeded ${formatMilliseconds(timeoutMs(options.timeoutMs))} timeout`);
|
|
138
|
+
resolveCompleted();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (graceMs > 0) {
|
|
142
|
+
schedule(hardAbort, graceMs);
|
|
143
|
+
} else {
|
|
144
|
+
hardAbort();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const abortListener = () => {
|
|
149
|
+
if (finished || promptSettled) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
abortSession(abortReason(options.abortSignal, "session prompt aborted by outer signal"));
|
|
153
|
+
resolveCompleted();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
if (options.abortSignal?.aborted) {
|
|
158
|
+
aborted = true;
|
|
159
|
+
error = abortReason(options.abortSignal, "session prompt aborted before start");
|
|
160
|
+
} else {
|
|
161
|
+
unsubscribe = session.subscribe((event: unknown) => {
|
|
162
|
+
events.push(event);
|
|
163
|
+
const text = assistantTextFromEvent(event);
|
|
164
|
+
if (text) {
|
|
165
|
+
assistantText = text;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
options.onEvent?.(event);
|
|
169
|
+
} catch (exc) {
|
|
170
|
+
diagnostics.push(`event listener failed: ${errorMessage(exc)}`);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
options.abortSignal?.addEventListener("abort", abortListener, { once: true });
|
|
175
|
+
|
|
176
|
+
const promptPromise = session.prompt(options.prompt, options.promptOptions).then(
|
|
177
|
+
() => {
|
|
178
|
+
promptSettled = true;
|
|
179
|
+
resolveCompleted();
|
|
180
|
+
},
|
|
181
|
+
(exc: unknown) => {
|
|
182
|
+
promptSettled = true;
|
|
183
|
+
error = error ?? errorMessage(exc);
|
|
184
|
+
resolveCompleted();
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
void promptPromise;
|
|
188
|
+
|
|
189
|
+
const limitMs = timeoutMs(options.timeoutMs);
|
|
190
|
+
if (limitMs > 0) {
|
|
191
|
+
schedule(triggerTimeout, limitMs);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await completed;
|
|
195
|
+
}
|
|
196
|
+
} catch (exc) {
|
|
197
|
+
error = error ?? errorMessage(exc);
|
|
198
|
+
} finally {
|
|
199
|
+
finished = true;
|
|
200
|
+
clearTimers();
|
|
201
|
+
options.abortSignal?.removeEventListener("abort", abortListener);
|
|
202
|
+
unsubscribe?.();
|
|
203
|
+
assistantText = latestAssistantText(session, events, assistantText);
|
|
204
|
+
if (options.dispose !== false) {
|
|
205
|
+
try {
|
|
206
|
+
const disposeResult = (session.dispose as (() => unknown) | undefined)?.();
|
|
207
|
+
if (isPromiseLike(disposeResult)) {
|
|
208
|
+
await disposeResult;
|
|
209
|
+
}
|
|
210
|
+
} catch (exc) {
|
|
211
|
+
const message = `session dispose failed: ${errorMessage(exc)}`;
|
|
212
|
+
diagnostics.push(message);
|
|
213
|
+
error = error ?? message;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return buildResult(session, events, assistantText, timedOut, aborted, error, diagnostics);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildResult(
|
|
222
|
+
session: WorkerSessionLike,
|
|
223
|
+
events: unknown[],
|
|
224
|
+
assistantText: string,
|
|
225
|
+
timedOut: boolean,
|
|
226
|
+
aborted: boolean,
|
|
227
|
+
error: string | undefined,
|
|
228
|
+
diagnostics: string[],
|
|
229
|
+
): GuardedSessionPromptResult {
|
|
230
|
+
return {
|
|
231
|
+
assistantText: latestAssistantText(session, events, assistantText),
|
|
232
|
+
timedOut,
|
|
233
|
+
aborted,
|
|
234
|
+
error,
|
|
235
|
+
diagnostics: [...diagnostics],
|
|
236
|
+
events: [...events],
|
|
237
|
+
sessionFile: session.sessionFile,
|
|
238
|
+
sessionId: session.sessionId,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function latestAssistantText(session: WorkerSessionLike, events: unknown[], fallback: string): string {
|
|
243
|
+
const direct = session.getLastAssistantText?.();
|
|
244
|
+
if (direct) {
|
|
245
|
+
return direct;
|
|
246
|
+
}
|
|
247
|
+
const fromMessages = lastAssistantTextFromMessages(session.messages);
|
|
248
|
+
if (fromMessages) {
|
|
249
|
+
return fromMessages;
|
|
250
|
+
}
|
|
251
|
+
const fromEvents = lastAssistantTextFromEvents(events);
|
|
252
|
+
return fromEvents || fallback;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function timeoutMs(value: number | undefined): number {
|
|
256
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
return Math.max(0, value);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function nonNegativeMilliseconds(value: number | undefined): number {
|
|
263
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
return Math.max(0, value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function formatMilliseconds(ms: number): string {
|
|
270
|
+
return `${(ms / 1000).toFixed(3)}s`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function abortReason(signal: AbortSignal | undefined, fallback: string): string {
|
|
274
|
+
const reason = signal?.reason;
|
|
275
|
+
if (reason === undefined) {
|
|
276
|
+
return fallback;
|
|
277
|
+
}
|
|
278
|
+
return errorMessage(reason);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function errorMessage(error: unknown): string {
|
|
282
|
+
return error instanceof Error ? error.message : String(error);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
286
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
287
|
+
}
|
package/src/todo_generator.ts
CHANGED
|
@@ -120,6 +120,16 @@ export function buildTodoCreationPrompt(rawInput: string): string {
|
|
|
120
120
|
return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.\n\nRequirements:\n- Output only markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n\nRaw input:\n\n${rawInput.trim()}\n`;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
export function buildTodoRepairPrompt(rawInput: string, invalidOutput: string, validationError: string): string {
|
|
124
|
+
return `Your previous response was not valid Pi Long Task TODO markdown. Correct it now.\n\nValidation/extraction error:\n${validationError.trim() || "Unknown validation error."}\n\nRequirements:\n- Output only corrected markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n\nOriginal raw input:\n\n${rawInput.trim()}\n\nPrevious invalid output:\n\n${invalidOutput.trim()}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function extractAndValidateTodoMarkdown(assistantText: string): string {
|
|
128
|
+
const markdown = extractTodoMarkdown(assistantText);
|
|
129
|
+
validateTodoMarkdown(markdown);
|
|
130
|
+
return markdown;
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
export function extractTodoMarkdown(assistantText: string): string {
|
|
124
134
|
for (const block of fencedMarkdownBlocks(assistantText)) {
|
|
125
135
|
const candidate = normalizeCandidate(block);
|