pi-long-task 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,633 @@
1
+ import { mkdir, readFile, writeFile, appendFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ import type {
6
+ CoordinatorCommitSummary,
7
+ CoordinatorRemainingTask,
8
+ CoordinatorStatus,
9
+ PiLongTaskInput,
10
+ } from "./types.ts";
11
+ import { commitAfterSession, gitDirtyPaths, shouldCommitOutcome, type CommitAfterSessionResult } from "./git.ts";
12
+ import { formatCoordinatorResultMessage } from "./render.ts";
13
+ import { extractResultSummary } from "./result_writer.ts";
14
+ import {
15
+ buildTodoCreationPrompt,
16
+ extractTodoMarkdown,
17
+ todoMarkdownFromString,
18
+ validateTodoMarkdown,
19
+ } from "./todo_generator.ts";
20
+ import { incompleteTasks, markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
21
+ import {
22
+ createIsolatedWorkerSession,
23
+ lastAssistantTextFromMessages,
24
+ runWorkerTask,
25
+ type RunWorkerTaskOptions,
26
+ type SessionOutcome,
27
+ type WorkerSessionFactory,
28
+ type WorkerSessionLike,
29
+ } from "./worker_session.ts";
30
+
31
+ export type { CoordinatorStatus } from "./types.ts";
32
+
33
+ export const DEFAULT_COORDINATOR_OPTIONS = {
34
+ maxAttemptsPerTask: 3,
35
+ taskTimeoutMs: 900_000,
36
+ maxBashTimeoutMs: 300_000,
37
+ taskThinking: "high",
38
+ todoThinking: "xhigh",
39
+ } as const;
40
+
41
+ export type WorkerRunner = (options: RunWorkerTaskOptions) => Promise<SessionOutcome>;
42
+ export type CoordinatorProgressPhase =
43
+ | "planning"
44
+ | "planned"
45
+ | "task_start"
46
+ | "worker_tool"
47
+ | "task_done"
48
+ | "task_blocked"
49
+ | "task_failed"
50
+ | "complete";
51
+
52
+ export interface CoordinatorProgressUpdate {
53
+ message: string;
54
+ phase: CoordinatorProgressPhase;
55
+ runId: string;
56
+ todoPath: string;
57
+ resultPath: string;
58
+ taskId?: string;
59
+ title?: string;
60
+ attempt?: number;
61
+ status?: CoordinatorStatus | string;
62
+ commitHash?: string;
63
+ commitError?: string;
64
+ commitSkipped?: string;
65
+ toolName?: string;
66
+ workerEventType?: string;
67
+ isError?: boolean;
68
+ totalTasks?: number;
69
+ }
70
+
71
+ export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
72
+ export type TodoPlanner = (options: TodoPlannerOptions) => Promise<string>;
73
+
74
+ export interface RunCoordinatorOptions extends PiLongTaskInput {
75
+ cwd?: string;
76
+ runId?: string;
77
+ abortSignal?: AbortSignal;
78
+ workerRunner?: WorkerRunner;
79
+ todoPlanner?: TodoPlanner;
80
+ workerSessionFactory?: WorkerSessionFactory;
81
+ todoSessionFactory?: WorkerSessionFactory;
82
+ maxAttemptsPerTask?: number;
83
+ taskTimeoutMs?: number;
84
+ maxBashTimeoutMs?: number;
85
+ taskThinking?: string;
86
+ todoThinking?: string;
87
+ now?: () => Date;
88
+ onProgress?: CoordinatorProgressHandler;
89
+ }
90
+
91
+ export interface TodoPlannerOptions {
92
+ inputText: string;
93
+ cwd: string;
94
+ runDir: string;
95
+ thinkingLevel: string;
96
+ abortSignal?: AbortSignal;
97
+ sessionFactory?: WorkerSessionFactory;
98
+ }
99
+
100
+ export interface TaskAttemptSummary {
101
+ taskId: string;
102
+ title: string;
103
+ attempt: number;
104
+ reportedStatus: string;
105
+ done: boolean;
106
+ error?: string;
107
+ commitHash?: string;
108
+ commitError?: string;
109
+ commitSkipped?: string;
110
+ }
111
+
112
+ export interface CoordinatorResult {
113
+ status: CoordinatorStatus;
114
+ summary: string;
115
+ message: string;
116
+ runId: string;
117
+ runDir: string;
118
+ todoPath: string;
119
+ resultPath: string;
120
+ taskResultPath: string;
121
+ totalTasks: number;
122
+ completedTasks: number;
123
+ failedTasks: number;
124
+ blockedTasks: number;
125
+ attemptedTasks: number;
126
+ remainingTasks: CoordinatorRemainingTask[];
127
+ outcomes: SessionOutcome[];
128
+ commits: CoordinatorCommitSummary[];
129
+ attempts: TaskAttemptSummary[];
130
+ commit: boolean;
131
+ error?: string;
132
+ }
133
+
134
+ interface RuntimeOptions {
135
+ cwd: string;
136
+ runId: string;
137
+ runDir: string;
138
+ todoPath: string;
139
+ taskResultPath: string;
140
+ maxAttemptsPerTask: number;
141
+ taskTimeoutSeconds: number;
142
+ maxBashTimeoutSeconds: number;
143
+ taskThinking: string;
144
+ todoThinking: string;
145
+ workerRunner: WorkerRunner;
146
+ todoPlanner: TodoPlanner;
147
+ abortSignal?: AbortSignal;
148
+ workerSessionFactory?: WorkerSessionFactory;
149
+ todoSessionFactory?: WorkerSessionFactory;
150
+ now: () => Date;
151
+ onProgress?: CoordinatorProgressHandler;
152
+ }
153
+
154
+ export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
155
+ const runtime = buildRuntimeOptions(options);
156
+ const attempts: TaskAttemptSummary[] = [];
157
+ const outcomes: SessionOutcome[] = [];
158
+ const commits: CoordinatorCommitSummary[] = [];
159
+
160
+ await mkdir(runtime.runDir, { recursive: true });
161
+ await writeFile(runtime.taskResultPath, initialTaskResultMarkdown(runtime.runId), "utf8");
162
+
163
+ try {
164
+ emitProgress(runtime, "Creating TODO plan...", { phase: "planning" });
165
+ let todoMarkdown = await generateOrNormalizeTodoMarkdown(options.inputText, runtime);
166
+ validateTodoMarkdown(todoMarkdown);
167
+ await writeFile(runtime.todoPath, todoMarkdown, "utf8");
168
+ const initialTasks = parseTasks(todoMarkdown);
169
+ emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
170
+ phase: "planned",
171
+ totalTasks: initialTasks.length,
172
+ });
173
+
174
+ const previousAttempts = new Map<string, string[]>();
175
+ let failure: string | undefined;
176
+
177
+ while (!runtime.abortSignal?.aborted) {
178
+ const nextTask = incompleteTasks(todoMarkdown)[0];
179
+ if (!nextTask) {
180
+ break;
181
+ }
182
+
183
+ const attempt = (previousAttempts.get(nextTask.taskId)?.length ?? 0) + 1;
184
+ emitProgress(
185
+ runtime,
186
+ `Running TODO ${nextTask.taskId} — ${nextTask.title}${attempt > 1 ? ` (attempt ${attempt})` : ""}...`,
187
+ {
188
+ phase: "task_start",
189
+ taskId: nextTask.taskId,
190
+ title: nextTask.title,
191
+ attempt,
192
+ },
193
+ );
194
+ const preExistingDirtyPaths = options.commit
195
+ ? await gitDirtyPaths(runtime.cwd, runtime.taskResultPath, runtime.todoPath, runtime.runDir)
196
+ : new Set<string>();
197
+ const outcome = await runtime.workerRunner({
198
+ cwd: runtime.cwd,
199
+ todoPath: runtime.todoPath,
200
+ task: nextTask,
201
+ attempt,
202
+ commitRequested: options.commit,
203
+ previousAttempts: previousAttempts.get(nextTask.taskId)?.join("\n\n---\n\n"),
204
+ globalInstructions: todoGlobalInstructions(todoMarkdown),
205
+ maxBashTimeoutSeconds: runtime.maxBashTimeoutSeconds,
206
+ taskTimeoutSeconds: runtime.taskTimeoutSeconds,
207
+ thinkingLevel: runtime.taskThinking,
208
+ abortSignal: runtime.abortSignal,
209
+ sessionFactory: runtime.workerSessionFactory,
210
+ now: runtime.now,
211
+ onEvent: (event) => emitWorkerEventProgress(runtime, nextTask, attempt, event),
212
+ });
213
+ outcomes.push(outcome);
214
+
215
+ if (outcome.done) {
216
+ todoMarkdown = markTaskDone(todoMarkdown, nextTask.taskId);
217
+ await writeFile(runtime.todoPath, todoMarkdown, "utf8");
218
+ }
219
+
220
+ const attemptDetails: TaskAttemptSummary = {
221
+ taskId: nextTask.taskId,
222
+ title: nextTask.title,
223
+ attempt,
224
+ reportedStatus: outcome.reportedStatus,
225
+ done: outcome.done,
226
+ error: outcome.error,
227
+ };
228
+ attempts.push(attemptDetails);
229
+ await appendTaskResult(runtime.taskResultPath, nextTask, outcome);
230
+
231
+ let taskCommitHash: string | undefined;
232
+ let taskCommitError: string | undefined;
233
+ let taskCommitSkipped: string | undefined;
234
+ if (options.commit) {
235
+ const commitResult = shouldCommitOutcome(outcome)
236
+ ? await commitAfterSession({
237
+ cwd: runtime.cwd,
238
+ resultPath: runtime.taskResultPath,
239
+ todoPath: runtime.todoPath,
240
+ runDir: runtime.runDir,
241
+ outcome,
242
+ preExistingDirtyPaths,
243
+ })
244
+ : ({ skipped: "outcome is not eligible for commit" } satisfies CommitAfterSessionResult);
245
+ attemptDetails.commitHash = commitResult.hash;
246
+ attemptDetails.commitError = commitResult.error;
247
+ attemptDetails.commitSkipped = commitResult.skipped;
248
+ if (commitResult.hash || commitResult.error) {
249
+ commits.push({ taskId: nextTask.taskId, hash: commitResult.hash, error: commitResult.error });
250
+ }
251
+ taskCommitHash = commitResult.hash;
252
+ taskCommitError = commitResult.error;
253
+ taskCommitSkipped = commitResult.skipped;
254
+ await appendCommitNote(runtime.taskResultPath, commitResult);
255
+ }
256
+
257
+ emitTaskOutcomeProgress(runtime, nextTask, outcome, taskCommitHash, taskCommitError, taskCommitSkipped);
258
+
259
+ const attemptSummary = resultTextForPreviousAttempt(outcome);
260
+ previousAttempts.set(nextTask.taskId, [...(previousAttempts.get(nextTask.taskId) ?? []), attemptSummary]);
261
+
262
+ if (outcome.done) {
263
+ continue;
264
+ }
265
+
266
+ if (attempt >= runtime.maxAttemptsPerTask) {
267
+ failure = `TODO ${nextTask.taskId} — ${nextTask.title} did not report done after ${attempt} attempt(s).`;
268
+ break;
269
+ }
270
+ }
271
+
272
+ if (runtime.abortSignal?.aborted && !failure) {
273
+ failure = "Pi Long Task run aborted.";
274
+ }
275
+
276
+ const finalTodoMarkdown = await readFile(runtime.todoPath, "utf8");
277
+ const finalTasks = parseTasks(finalTodoMarkdown);
278
+ const completedTasks = finalTasks.filter((task) => task.done).length;
279
+ const remainingTasks = remainingTaskSummaries(finalTasks, attempts);
280
+ const blockedTasks = remainingTasks.filter((task) => task.status === "blocked").length;
281
+ const failedTasks = remainingTasks.filter(
282
+ (task) => task.status !== "blocked" && task.status !== "not_started",
283
+ ).length;
284
+ const status = deriveCoordinatorStatus({
285
+ failure,
286
+ completedTasks,
287
+ totalTasks: finalTasks.length,
288
+ blockedTasks,
289
+ failedTasks,
290
+ });
291
+ const summary = failure
292
+ ? `Pi Long Task ${status}: ${failure}`
293
+ : `Pi Long Task completed ${completedTasks}/${finalTasks.length} task(s).`;
294
+ const result: CoordinatorResult = {
295
+ status,
296
+ summary,
297
+ message: "",
298
+ runId: runtime.runId,
299
+ runDir: runtime.runDir,
300
+ todoPath: runtime.todoPath,
301
+ resultPath: runtime.taskResultPath,
302
+ taskResultPath: runtime.taskResultPath,
303
+ totalTasks: finalTasks.length,
304
+ completedTasks,
305
+ failedTasks,
306
+ blockedTasks,
307
+ attemptedTasks: attempts.length,
308
+ remainingTasks,
309
+ outcomes,
310
+ commits,
311
+ attempts,
312
+ commit: options.commit,
313
+ error: failure,
314
+ };
315
+ result.message = formatCoordinatorResultMessage(result);
316
+ emitProgress(runtime, `Pi Long Task ${status}.`, {
317
+ phase: "complete",
318
+ status,
319
+ totalTasks: finalTasks.length,
320
+ });
321
+ return result;
322
+ } catch (error) {
323
+ const message = errorMessage(error);
324
+ const summary = `Pi Long Task failed: ${message}`;
325
+ try {
326
+ await appendFile(runtime.taskResultPath, `\n## Pi Long Task failure\n\n${message}\n`, "utf8");
327
+ } catch {
328
+ // Best effort only; the original error is returned below.
329
+ }
330
+
331
+ const result: CoordinatorResult = {
332
+ status: "failed",
333
+ summary,
334
+ message: "",
335
+ runId: runtime.runId,
336
+ runDir: runtime.runDir,
337
+ todoPath: runtime.todoPath,
338
+ resultPath: runtime.taskResultPath,
339
+ taskResultPath: runtime.taskResultPath,
340
+ totalTasks: 0,
341
+ completedTasks: 0,
342
+ failedTasks: 0,
343
+ blockedTasks: 0,
344
+ attemptedTasks: attempts.length,
345
+ remainingTasks: [],
346
+ outcomes,
347
+ commits,
348
+ attempts,
349
+ commit: options.commit,
350
+ error: message,
351
+ };
352
+ result.message = formatCoordinatorResultMessage(result);
353
+ emitProgress(runtime, "Pi Long Task failed.", { phase: "complete", status: "failed" });
354
+ return result;
355
+ }
356
+ }
357
+
358
+ async function generateOrNormalizeTodoMarkdown(inputText: string, runtime: RuntimeOptions): Promise<string> {
359
+ const local = todoMarkdownFromString(inputText);
360
+ if (local) {
361
+ return local;
362
+ }
363
+
364
+ const plannerText = await runtime.todoPlanner({
365
+ inputText,
366
+ cwd: runtime.cwd,
367
+ runDir: runtime.runDir,
368
+ thinkingLevel: runtime.todoThinking,
369
+ abortSignal: runtime.abortSignal,
370
+ sessionFactory: runtime.todoSessionFactory,
371
+ });
372
+ return extractTodoMarkdown(plannerText);
373
+ }
374
+
375
+ export async function runTodoPlanner(options: TodoPlannerOptions): Promise<string> {
376
+ let session: WorkerSessionLike | undefined;
377
+ try {
378
+ const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
379
+ const result = await sessionFactory({
380
+ cwd: options.cwd,
381
+ tools: [],
382
+ thinkingLevel: options.thinkingLevel,
383
+ });
384
+ session = result.session;
385
+
386
+ if (options.abortSignal?.aborted) {
387
+ throw new Error("TODO planner aborted before start");
388
+ }
389
+
390
+ await session.prompt(buildTodoCreationPrompt(options.inputText));
391
+ const direct = session.getLastAssistantText?.();
392
+ const fromMessages = lastAssistantTextFromMessages(session.messages);
393
+ const text = direct || fromMessages;
394
+ if (!text) {
395
+ throw new Error("TODO planner did not return assistant text.");
396
+ }
397
+ return text;
398
+ } finally {
399
+ session?.dispose?.();
400
+ }
401
+ }
402
+
403
+ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
404
+ const cwd = path.resolve(options.cwd ?? process.cwd());
405
+ const runId = sanitizeRunId(options.runId ?? defaultRunId(options.now?.() ?? new Date()));
406
+ const runDir = path.join(cwd, "tmp", "pi-long-task", runId);
407
+
408
+ return {
409
+ cwd,
410
+ runId,
411
+ runDir,
412
+ todoPath: path.join(runDir, "TODO.md"),
413
+ taskResultPath: path.join(runDir, "TASK_RESULT.md"),
414
+ maxAttemptsPerTask: positiveInteger(options.maxAttemptsPerTask, DEFAULT_COORDINATOR_OPTIONS.maxAttemptsPerTask),
415
+ taskTimeoutSeconds: positiveMilliseconds(options.taskTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.taskTimeoutMs) / 1000,
416
+ maxBashTimeoutSeconds:
417
+ positiveMilliseconds(options.maxBashTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.maxBashTimeoutMs) / 1000,
418
+ taskThinking: options.taskThinking ?? DEFAULT_COORDINATOR_OPTIONS.taskThinking,
419
+ todoThinking: options.todoThinking ?? DEFAULT_COORDINATOR_OPTIONS.todoThinking,
420
+ workerRunner: options.workerRunner ?? runWorkerTask,
421
+ todoPlanner: options.todoPlanner ?? runTodoPlanner,
422
+ abortSignal: options.abortSignal,
423
+ workerSessionFactory: options.workerSessionFactory,
424
+ todoSessionFactory: options.todoSessionFactory,
425
+ now: options.now ?? (() => new Date()),
426
+ onProgress: options.onProgress,
427
+ };
428
+ }
429
+
430
+ function emitProgress(
431
+ runtime: RuntimeOptions,
432
+ message: string,
433
+ update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath">,
434
+ ): void {
435
+ runtime.onProgress?.({
436
+ message,
437
+ runId: runtime.runId,
438
+ todoPath: runtime.todoPath,
439
+ resultPath: runtime.taskResultPath,
440
+ ...update,
441
+ });
442
+ }
443
+
444
+ function emitWorkerEventProgress(
445
+ runtime: RuntimeOptions,
446
+ task: Pick<Task, "taskId" | "title">,
447
+ attempt: number,
448
+ event: { type: string; toolName?: string; isError?: boolean },
449
+ ): void {
450
+ if (!event.toolName || (event.type !== "tool_execution_start" && event.type !== "tool_execution_end")) {
451
+ return;
452
+ }
453
+ const action = event.type === "tool_execution_start" ? "started" : event.isError ? "failed" : "finished";
454
+ const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath"> = {
455
+ phase: "worker_tool",
456
+ taskId: task.taskId,
457
+ title: task.title,
458
+ attempt,
459
+ status: action,
460
+ toolName: event.toolName,
461
+ workerEventType: event.type,
462
+ isError: event.isError,
463
+ };
464
+ if (event.isError) {
465
+ update.status = "failed";
466
+ }
467
+ emitProgress(runtime, `TODO ${task.taskId}: worker tool ${event.toolName} ${action}.`, update);
468
+ }
469
+
470
+ function emitTaskOutcomeProgress(
471
+ runtime: RuntimeOptions,
472
+ task: Pick<Task, "taskId" | "title">,
473
+ outcome: SessionOutcome,
474
+ commitHash: string | undefined,
475
+ commitError: string | undefined,
476
+ commitSkipped: string | undefined,
477
+ ): void {
478
+ const commitText = commitHash
479
+ ? `, commit ${commitHash}`
480
+ : commitError
481
+ ? `, commit failed`
482
+ : commitSkipped
483
+ ? `, commit skipped: ${commitSkipped}`
484
+ : "";
485
+ const statusText = outcome.done ? "done" : outcome.reportedStatus;
486
+ const phase: CoordinatorProgressPhase = outcome.done
487
+ ? "task_done"
488
+ : outcome.reportedStatus === "blocked"
489
+ ? "task_blocked"
490
+ : "task_failed";
491
+ const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath"> = {
492
+ phase,
493
+ taskId: task.taskId,
494
+ title: task.title,
495
+ attempt: outcome.attempt,
496
+ status: outcome.reportedStatus,
497
+ };
498
+ if (commitHash) {
499
+ update.commitHash = commitHash;
500
+ }
501
+ if (commitError) {
502
+ update.commitError = commitError;
503
+ }
504
+ if (commitSkipped) {
505
+ update.commitSkipped = commitSkipped;
506
+ }
507
+ emitProgress(runtime, `TODO ${task.taskId} ${statusText}${commitText}.`, update);
508
+ }
509
+
510
+ function initialTaskResultMarkdown(runId: string): string {
511
+ return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
512
+ }
513
+
514
+ async function appendCommitNote(pathname: string, result: CommitAfterSessionResult): Promise<void> {
515
+ const lines = ["", "### Commit note", ""];
516
+ if (result.hash) {
517
+ lines.push(`Committed eligible non-artifact changes as \`${result.hash}\`.`);
518
+ } else if (result.error) {
519
+ lines.push(`Commit error: \`${result.error}\``);
520
+ } else {
521
+ lines.push(`Commit skipped: ${result.skipped ?? "no staged diff"}.`);
522
+ }
523
+ await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
524
+ }
525
+
526
+ async function appendTaskResult(pathname: string, task: Task, outcome: SessionOutcome): Promise<void> {
527
+ const summary = extractResultSummary(outcome.assistantText || "").trim() || "TASK_RESULT:\nstatus: unknown";
528
+ const lines = [
529
+ "",
530
+ `## TODO ${task.taskId} — ${task.title} (attempt ${outcome.attempt})`,
531
+ "",
532
+ `Started: ${outcome.startedAt}`,
533
+ `Ended: ${outcome.endedAt}`,
534
+ `Reported status: ${outcome.reportedStatus}`,
535
+ `Done: ${outcome.done ? "yes" : "no"}`,
536
+ ];
537
+
538
+ if (outcome.sessionId) {
539
+ lines.push(`Session ID: ${outcome.sessionId}`);
540
+ }
541
+ if (outcome.sessionFile) {
542
+ lines.push(`Session file: ${outcome.sessionFile}`);
543
+ }
544
+ if (outcome.error) {
545
+ lines.push(`Worker error: ${outcome.error}`);
546
+ }
547
+ if (outcome.timedOut) {
548
+ lines.push("Timed out: yes");
549
+ }
550
+ if (outcome.aborted) {
551
+ lines.push("Aborted: yes");
552
+ }
553
+ if (outcome.contextObservations.length > 0) {
554
+ lines.push("", "Context observations:", ...outcome.contextObservations.map((item) => `- ${item}`));
555
+ }
556
+ if (outcome.compactionEvents.length > 0) {
557
+ lines.push("", "Compaction events:", ...outcome.compactionEvents.map((item) => `- ${item}`));
558
+ }
559
+
560
+ lines.push("", "```text", summary, "```", "");
561
+ await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
562
+ }
563
+
564
+ function remainingTaskSummaries(tasks: Task[], attempts: TaskAttemptSummary[]): CoordinatorRemainingTask[] {
565
+ const lastAttemptByTask = new Map<string, TaskAttemptSummary>();
566
+ for (const attempt of attempts) {
567
+ lastAttemptByTask.set(attempt.taskId, attempt);
568
+ }
569
+
570
+ return tasks
571
+ .filter((task) => !task.done)
572
+ .map((task) => ({
573
+ taskId: task.taskId,
574
+ title: task.title,
575
+ status: lastAttemptByTask.get(task.taskId)?.reportedStatus ?? "not_started",
576
+ }));
577
+ }
578
+
579
+ function deriveCoordinatorStatus(options: {
580
+ failure: string | undefined;
581
+ completedTasks: number;
582
+ totalTasks: number;
583
+ blockedTasks: number;
584
+ failedTasks: number;
585
+ }): CoordinatorStatus {
586
+ if (!options.failure && options.completedTasks === options.totalTasks) {
587
+ return "done";
588
+ }
589
+ if (options.blockedTasks > 0 && options.failedTasks === 0) {
590
+ return "blocked";
591
+ }
592
+ if (options.completedTasks > 0) {
593
+ return "partial";
594
+ }
595
+ return "failed";
596
+ }
597
+
598
+ function resultTextForPreviousAttempt(outcome: SessionOutcome): string {
599
+ const summary = extractResultSummary(outcome.assistantText || "").trim();
600
+ const header = `Attempt ${outcome.attempt}: status=${outcome.reportedStatus}, done=${outcome.done ? "yes" : "no"}`;
601
+ if (outcome.error) {
602
+ return `${header}, error=${outcome.error}\n\n${summary}`.trim();
603
+ }
604
+ return `${header}\n\n${summary}`.trim();
605
+ }
606
+
607
+ function defaultRunId(now: Date): string {
608
+ const timestamp = now.toISOString().replace(/[:.]/g, "-");
609
+ return `${timestamp}-${randomUUID().slice(0, 8)}`;
610
+ }
611
+
612
+ function sanitizeRunId(runId: string): string {
613
+ const sanitized = runId.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
614
+ return sanitized || defaultRunId(new Date());
615
+ }
616
+
617
+ function positiveInteger(value: number | undefined, fallback: number): number {
618
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
619
+ return Math.floor(value);
620
+ }
621
+ return fallback;
622
+ }
623
+
624
+ function positiveMilliseconds(value: number | undefined, fallback: number): number {
625
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
626
+ return value;
627
+ }
628
+ return fallback;
629
+ }
630
+
631
+ function errorMessage(error: unknown): string {
632
+ return error instanceof Error ? error.message : String(error);
633
+ }