pi-long-task 0.3.12 → 0.3.13

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.
@@ -120,6 +120,23 @@ export async function runGoalTodoExecutionLongTask(
120
120
 
121
121
  const progressEvents: CoordinatorProgressUpdate[] = [];
122
122
  const workerStartedAt = now();
123
+ const childTimeoutMs = timeoutForIteration(iteration, state, workerStartedAt);
124
+ if (childTimeoutMs <= 0) {
125
+ const failure = await recordExecutionFailure({
126
+ state,
127
+ iteration,
128
+ store,
129
+ previousTraceLength,
130
+ progressLogPath,
131
+ message: `Goal iteration ${iteration.iteration} has no time remaining for TODO execution.`,
132
+ error: new Error("iteration deadline exceeded"),
133
+ now,
134
+ });
135
+ throw new GoalTodoExecutionError(failure.workerResult.summary, {
136
+ state: failure.state,
137
+ workerResult: failure.workerResult,
138
+ });
139
+ }
123
140
  let childResult: CoordinatorResult;
124
141
  try {
125
142
  childResult = await (options.longTaskRunner ?? runCoordinator)({
@@ -132,7 +149,7 @@ export async function runGoalTodoExecutionLongTask(
132
149
  workerModel: options.model,
133
150
  workerModelName: options.modelName,
134
151
  taskThinking: options.thinkingLevel,
135
- taskTimeoutMs: timeoutForIteration(iteration, state, now()),
152
+ taskTimeoutMs: childTimeoutMs,
136
153
  maxBashTimeoutMs: options.maxBashTimeoutMs,
137
154
  maxAttemptsPerTask: options.maxAttemptsPerTask,
138
155
  onProgress: (update) => {
@@ -294,14 +311,14 @@ async function writeProgressLog(progressLogPath: string, events: CoordinatorProg
294
311
  }
295
312
 
296
313
  function timeoutForIteration(iteration: GoalIterationState, state: GoalLoopState, now: Date): number {
297
- if (!iteration.deadlineAt) {
298
- return state.limits.iterationTimeoutMs;
299
- }
300
- const remaining = Date.parse(iteration.deadlineAt) - now.getTime();
301
- if (!Number.isFinite(remaining) || remaining <= 0) {
302
- return 1_000;
314
+ const iterationRemaining = iteration.deadlineAt
315
+ ? Date.parse(iteration.deadlineAt) - now.getTime()
316
+ : state.limits.iterationTimeoutMs;
317
+ const overallRemaining = state.deadlineAt ? Date.parse(state.deadlineAt) - now.getTime() : state.limits.timeoutMs;
318
+ if (!Number.isFinite(iterationRemaining) || !Number.isFinite(overallRemaining)) {
319
+ return 0;
303
320
  }
304
- return Math.min(state.limits.iterationTimeoutMs, Math.max(1_000, Math.floor(remaining)));
321
+ return Math.floor(Math.min(state.limits.iterationTimeoutMs, iterationRemaining, overallRemaining));
305
322
  }
306
323
 
307
324
  function errorMessage(error: unknown): string {
@@ -85,6 +85,12 @@ export async function runGoalTodoGenerationLongTask(
85
85
  });
86
86
  const payloadPath = path.join(iterationDir, GOAL_TODO_GENERATION_PAYLOAD_FILE);
87
87
  await writeFile(payloadPath, payload, "utf8");
88
+ const childTimeoutMs = timeoutForIteration(iteration, state, now());
89
+ if (childTimeoutMs <= 0) {
90
+ throw new GoalTodoGenerationError(
91
+ `Goal iteration ${iteration.iteration} has no time remaining for TODO generation.`,
92
+ );
93
+ }
88
94
 
89
95
  const childResult = await (options.longTaskRunner ?? runCoordinator)({
90
96
  inputText: payload,
@@ -96,7 +102,7 @@ export async function runGoalTodoGenerationLongTask(
96
102
  workerModel: options.model,
97
103
  workerModelName: options.modelName,
98
104
  taskThinking: options.thinkingLevel,
99
- taskTimeoutMs: timeoutForIteration(iteration, state, now()),
105
+ taskTimeoutMs: childTimeoutMs,
100
106
  maxBashTimeoutMs: options.maxBashTimeoutMs,
101
107
  });
102
108
 
@@ -125,6 +131,7 @@ export async function runGoalTodoGenerationLongTask(
125
131
  generatorRunDir: childResult.runDir,
126
132
  generatorResultPath: childResult.resultPath,
127
133
  generatorTaskResultPath: childResult.taskResultPath,
134
+ generatorWorkerCostTotal: childResult.workerCostTotal,
128
135
  },
129
136
  { now: now() },
130
137
  );
@@ -467,14 +474,14 @@ function ensureTrailingNewline(value: string): string {
467
474
  }
468
475
 
469
476
  function timeoutForIteration(iteration: GoalIterationState, state: GoalLoopState, now: Date): number {
470
- if (!iteration.deadlineAt) {
471
- return state.limits.iterationTimeoutMs;
472
- }
473
- const remaining = Date.parse(iteration.deadlineAt) - now.getTime();
474
- if (!Number.isFinite(remaining) || remaining <= 0) {
475
- return 1_000;
477
+ const iterationRemaining = iteration.deadlineAt
478
+ ? Date.parse(iteration.deadlineAt) - now.getTime()
479
+ : state.limits.iterationTimeoutMs;
480
+ const overallRemaining = state.deadlineAt ? Date.parse(state.deadlineAt) - now.getTime() : state.limits.timeoutMs;
481
+ if (!Number.isFinite(iterationRemaining) || !Number.isFinite(overallRemaining)) {
482
+ return 0;
476
483
  }
477
- return Math.min(state.limits.iterationTimeoutMs, Math.max(1_000, Math.floor(remaining)));
484
+ return Math.floor(Math.min(state.limits.iterationTimeoutMs, iterationRemaining, overallRemaining));
478
485
  }
479
486
 
480
487
  function buildPreviousIterationContext(state: GoalLoopState): string {
@@ -3,7 +3,7 @@ export const PARTIAL_STATUSES = new Set(["partial", "incomplete", "blocked", "fa
3
3
 
4
4
  const TASK_RESULT_MARKER_RE = /TASK_RESULT\s*:/gi;
5
5
  const STATUS_LINE_RE = /^\s*status\s*:\s*([A-Za-z_-]+)\s*$/im;
6
- const FENCED_BLOCK_RE = /```[^\r\n`]*\r?\n([\s\S]*?)\r?\n```/g;
6
+ const REQUIRED_LIST_FIELDS = ["changes", "verification", "remaining"] as const;
7
7
 
8
8
  export interface TaskResultBlock {
9
9
  marker: "TASK_RESULT";
@@ -11,6 +11,14 @@ export interface TaskResultBlock {
11
11
  fenced: boolean;
12
12
  }
13
13
 
14
+ export interface ParsedTaskResult {
15
+ status: string;
16
+ summary: string;
17
+ changes: string[];
18
+ verification: string[];
19
+ remaining: string[];
20
+ }
21
+
14
22
  export function isDoneStatus(status: string): boolean {
15
23
  return DONE_STATUSES.has(status.trim().toLowerCase());
16
24
  }
@@ -28,6 +36,33 @@ export function hasTaskResultStatus(assistantText: string): boolean {
28
36
  return Boolean(block && STATUS_LINE_RE.test(block.body));
29
37
  }
30
38
 
39
+ export function parseCompleteTaskResult(assistantText: string): ParsedTaskResult | undefined {
40
+ const block = extractTaskResultBlock(assistantText);
41
+ if (!block) {
42
+ return undefined;
43
+ }
44
+
45
+ const fields = parseResultFields(block.body);
46
+ const status = fields.scalars.get("status")?.trim().toLowerCase() ?? "";
47
+ const summary = fields.scalars.get("summary")?.trim() ?? "";
48
+ if (!status || !summary || (!isDoneStatus(status) && !isPartialStatus(status))) {
49
+ return undefined;
50
+ }
51
+
52
+ const lists = Object.fromEntries(
53
+ REQUIRED_LIST_FIELDS.map((field) => [field, fields.lists.get(field) ?? []]),
54
+ ) as Record<(typeof REQUIRED_LIST_FIELDS)[number], string[]>;
55
+ if (REQUIRED_LIST_FIELDS.some((field) => lists[field].length === 0)) {
56
+ return undefined;
57
+ }
58
+
59
+ return { status, summary, ...lists };
60
+ }
61
+
62
+ export function hasCompleteTaskResult(assistantText: string): boolean {
63
+ return parseCompleteTaskResult(assistantText) !== undefined;
64
+ }
65
+
31
66
  export function parseReportedStatus(assistantText: string): string {
32
67
  const block = extractTaskResultBlock(assistantText);
33
68
  const searchText = block ? block.body : assistantText || "";
@@ -56,41 +91,72 @@ export const summarizeAssistantResult = extractResultSummary;
56
91
 
57
92
  export function extractTaskResultBlock(assistantText: string): TaskResultBlock | undefined {
58
93
  const text = assistantText || "";
59
- const fencedBlocks = fencedCodeBlocks(text);
60
- for (let idx = fencedBlocks.length - 1; idx >= 0; idx -= 1) {
61
- const body = taskResultBodyFromText(fencedBlocks[idx]);
62
- if (body !== undefined) {
63
- return { marker: "TASK_RESULT", body, fenced: true };
64
- }
65
- }
66
-
67
- const body = taskResultBodyFromText(text);
68
- if (body === undefined) {
69
- return undefined;
70
- }
71
- return { marker: "TASK_RESULT", body: stripTrailingFence(body), fenced: false };
72
- }
73
-
74
- function taskResultBodyFromText(text: string): string | undefined {
75
94
  TASK_RESULT_MARKER_RE.lastIndex = 0;
76
95
  let match: RegExpExecArray | null;
77
96
  let lastMatch: RegExpExecArray | undefined;
78
97
  while ((match = TASK_RESULT_MARKER_RE.exec(text)) !== null) {
79
98
  lastMatch = match;
80
99
  }
81
-
82
100
  if (!lastMatch) {
83
101
  return undefined;
84
102
  }
85
103
 
86
- return text.slice(lastMatch.index + lastMatch[0].length).trim();
104
+ const markerEnd = lastMatch.index + lastMatch[0].length;
105
+ const fence = enclosingFence(text, lastMatch.index);
106
+ const bodyEnd = fence?.end ?? text.length;
107
+ return {
108
+ marker: "TASK_RESULT",
109
+ body: text.slice(markerEnd, bodyEnd).trim(),
110
+ fenced: Boolean(fence),
111
+ };
87
112
  }
88
113
 
89
- function fencedCodeBlocks(text: string): string[] {
90
- FENCED_BLOCK_RE.lastIndex = 0;
91
- return [...text.matchAll(FENCED_BLOCK_RE)].map((match) => match[1]);
114
+ function enclosingFence(text: string, position: number): { end: number } | undefined {
115
+ const fenceRe = /^(`{3,})[^\r\n`]*\r?\n/gm;
116
+ let match: RegExpExecArray | null;
117
+ while ((match = fenceRe.exec(text)) !== null) {
118
+ const contentStart = match.index + match[0].length;
119
+ const closeRe = new RegExp(`^${match[1]}\\s*$`, "gm");
120
+ closeRe.lastIndex = contentStart;
121
+ const close = closeRe.exec(text);
122
+ if (!close) {
123
+ continue;
124
+ }
125
+ if (position >= contentStart && position < close.index) {
126
+ return { end: close.index };
127
+ }
128
+ fenceRe.lastIndex = close.index + close[0].length;
129
+ }
130
+ return undefined;
92
131
  }
93
132
 
94
- function stripTrailingFence(text: string): string {
95
- return text.replace(/\r?\n```\s*$/g, "").trim();
133
+ function parseResultFields(body: string): {
134
+ scalars: Map<string, string>;
135
+ lists: Map<string, string[]>;
136
+ } {
137
+ const scalars = new Map<string, string>();
138
+ const lists = new Map<string, string[]>();
139
+ let currentList: string | undefined;
140
+
141
+ for (const line of body.replace(/\r\n?/g, "\n").split("\n")) {
142
+ const field = /^\s*([A-Za-z_-]+)\s*:\s*(.*?)\s*$/.exec(line);
143
+ if (field) {
144
+ const name = field[1].toLowerCase();
145
+ currentList = REQUIRED_LIST_FIELDS.includes(name as (typeof REQUIRED_LIST_FIELDS)[number]) ? name : undefined;
146
+ if (currentList) {
147
+ lists.set(currentList, []);
148
+ } else {
149
+ scalars.set(name, field[2]);
150
+ }
151
+ continue;
152
+ }
153
+
154
+ if (currentList) {
155
+ const bullet = /^\s*[-*+]\s+(.+?)\s*$/.exec(line);
156
+ if (bullet?.[1]) {
157
+ lists.get(currentList)?.push(bullet[1]);
158
+ }
159
+ }
160
+ }
161
+ return { scalars, lists };
96
162
  }
@@ -24,6 +24,7 @@ interface ExistingTask {
24
24
  taskId: string;
25
25
  title: string;
26
26
  body: string;
27
+ done: boolean;
27
28
  }
28
29
 
29
30
  export function todoMarkdownFromString(rawInput: string, goal?: string): string | undefined {
@@ -73,9 +74,6 @@ export function validateTodoMarkdown(markdown: string): void {
73
74
  if (!PROGRESS_HEADING_RE.test(trimmed)) {
74
75
  throw new TodoGenerationError("TODO markdown must include a `## Progress` section.");
75
76
  }
76
- if (!/^---\s*$/m.test(trimmed)) {
77
- throw new TodoGenerationError("TODO markdown must include a `---` separator before task sections.");
78
- }
79
77
 
80
78
  let tasks;
81
79
  try {
@@ -91,6 +89,11 @@ export function validateTodoMarkdown(markdown: string): void {
91
89
  throw new TodoGenerationError("TODO markdown must include at least one task section.");
92
90
  }
93
91
 
92
+ const progressEntries = validatedProgressEntries(markdown, tasks[0].startLine - 1);
93
+ if (progressEntries.length !== tasks.length) {
94
+ throw new TodoGenerationError("Progress section must contain exactly one line for every task section.");
95
+ }
96
+
94
97
  tasks.forEach((task, idx) => {
95
98
  const expectedId = String(idx + 1);
96
99
  if (task.taskId !== expectedId) {
@@ -99,9 +102,11 @@ export function validateTodoMarkdown(markdown: string): void {
99
102
  );
100
103
  }
101
104
 
102
- const progressLine = progressLineRegex(task.taskId, task.title);
103
- if (!progressLine.test(markdown)) {
104
- throw new TodoGenerationError(`Progress section must include an unchecked line for TODO ${task.taskId}.`);
105
+ const progressEntry = progressEntries[idx];
106
+ if (progressEntry?.taskId !== task.taskId || progressEntry.title !== task.title) {
107
+ throw new TodoGenerationError(
108
+ `Progress section entry ${idx + 1} must match TODO ${task.taskId} — ${task.title}.`,
109
+ );
105
110
  }
106
111
 
107
112
  if (!/\*\*Goal:\*\*/.test(task.section)) {
@@ -310,7 +315,7 @@ function normalizeExistingTodoMarkdown(input: string): string {
310
315
  }
311
316
 
312
317
  const globalInstructions = extractGlobalInstructions(input);
313
- const progress = tasks.map((task, idx) => `- [ ] TODO ${idx + 1} — ${task.title}`).join("\n");
318
+ const progress = tasks.map((task, idx) => `- [${task.done ? "x" : " "}] TODO ${idx + 1} — ${task.title}`).join("\n");
314
319
  const sections = tasks.map((task, idx) => normalizeTaskSection({ ...task, taskId: String(idx + 1) })).join("\n\n");
315
320
 
316
321
  const globalBlock = globalInstructions ? `\n\n${globalInstructions}` : "";
@@ -318,17 +323,18 @@ function normalizeExistingTodoMarkdown(input: string): string {
318
323
  }
319
324
 
320
325
  function extractExistingTasks(input: string): ExistingTask[] {
321
- TODO_HEADING_RE.lastIndex = 0;
322
- const matches = [...input.matchAll(TODO_HEADING_RE)];
323
- return matches.map((match, idx) => {
324
- const bodyStart = (match.index ?? 0) + match[0].length;
325
- const bodyEnd = idx + 1 < matches.length ? (matches[idx + 1].index ?? input.length) : input.length;
326
- return {
327
- taskId: match[1],
328
- title: cleanTitle(match[2]),
329
- body: input.slice(bodyStart, bodyEnd).trim(),
330
- };
331
- });
326
+ let tasks;
327
+ try {
328
+ tasks = parseTasks(input);
329
+ } catch {
330
+ return [];
331
+ }
332
+ return tasks.map((task) => ({
333
+ taskId: task.taskId,
334
+ title: cleanTitle(task.title),
335
+ body: task.section.replace(/^##\s+TODO\s+\d+\s+[—-]\s+.*(?:\r?\n)?/, "").trim(),
336
+ done: task.done,
337
+ }));
332
338
  }
333
339
 
334
340
  function extractGlobalInstructions(input: string): string {
@@ -424,12 +430,57 @@ function lowercaseFirst(value: string): string {
424
430
  return `${value[0].toLocaleLowerCase()}${value.slice(1)}`;
425
431
  }
426
432
 
427
- function progressLineRegex(taskId: string, title: string): RegExp {
428
- return new RegExp(`^\\s*-\\s+\\[ \\]\\s+TODO\\s+${escapeRegExp(taskId)}\\s+[—-]\\s+${escapeRegExp(title)}\\s*$`, "m");
429
- }
433
+ function validatedProgressEntries(
434
+ markdown: string,
435
+ firstTaskIndex: number,
436
+ ): Array<{ taskId: string; title: string; done: boolean }> {
437
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
438
+ const visible = visibleLineIndexes(lines);
439
+ const progressIndex = visible.find((idx) => idx < firstTaskIndex && /^##\s+Progress\s*$/i.test(lines[idx].trim()));
440
+ if (progressIndex === undefined) {
441
+ throw new TodoGenerationError("TODO markdown must include a `## Progress` section before task sections.");
442
+ }
443
+ const separatorIndex = visible.find(
444
+ (idx) => idx > progressIndex && idx < firstTaskIndex && /^---\s*$/.test(lines[idx].trim()),
445
+ );
446
+ if (separatorIndex === undefined) {
447
+ throw new TodoGenerationError("TODO markdown must include a `---` separator before task sections.");
448
+ }
430
449
 
431
- function escapeRegExp(value: string): string {
432
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
450
+ const entries: Array<{ taskId: string; title: string; done: boolean }> = [];
451
+ for (let idx = progressIndex + 1; idx < separatorIndex; idx += 1) {
452
+ if (!visible.includes(idx)) {
453
+ continue;
454
+ }
455
+ const match = /^\s*-\s+\[([ xX])\]\s+TODO\s+(\d+)\s+[—-]\s+(.+?)\s*$/.exec(lines[idx]);
456
+ if (match) {
457
+ entries.push({ taskId: match[2], title: match[3].trim(), done: match[1].toLowerCase() === "x" });
458
+ } else if (/\bTODO\s+\d+\b/i.test(lines[idx])) {
459
+ throw new TodoGenerationError(`Malformed progress entry: ${lines[idx].trim()}`);
460
+ }
461
+ }
462
+ return entries;
463
+ }
464
+
465
+ function visibleLineIndexes(lines: readonly string[]): number[] {
466
+ const indexes: number[] = [];
467
+ let fence: string | undefined;
468
+ for (let idx = 0; idx < lines.length; idx += 1) {
469
+ const match = /^\s*(`{3,}|~{3,})/.exec(lines[idx]);
470
+ if (match) {
471
+ const marker = match[1];
472
+ if (!fence) {
473
+ fence = marker;
474
+ } else if (marker[0] === fence[0] && marker.length >= fence.length) {
475
+ fence = undefined;
476
+ }
477
+ continue;
478
+ }
479
+ if (!fence) {
480
+ indexes.push(idx);
481
+ }
482
+ }
483
+ return indexes;
433
484
  }
434
485
 
435
486
  function fencedMarkdownBlocks(text: string): string[] {
@@ -25,6 +25,8 @@ export class TodoParseError extends Error {
25
25
  const TASK_HEADING_RE = /^##\s+TODO\s+(\d+)\s+[—-]\s+(.+?)\s*$/;
26
26
  const CHECKBOX_RE = /^(\s*-\s+\[)([ xX])(\].*)$/;
27
27
  const GLOBAL_PROGRESS_HEADING_RE = /^##\s+Progress\s*$/i;
28
+ const FIELD_HEADING_RE = /^\*\*[^*\r\n]+:\*\*\s*$/;
29
+ const FENCE_LINE_RE = /^\s*(`{3,}|~{3,})/;
28
30
 
29
31
  function progressRegexForTask(taskId: string): RegExp {
30
32
  return new RegExp(`^(\\s*-\\s+\\[)([ xX])(\\]\\s+TODO\\s+${escapeRegExp(taskId)}\\b.*)$`);
@@ -54,18 +56,32 @@ interface TaskHeading {
54
56
 
55
57
  function parseTaskHeadings(lines: string[]): TaskHeading[] {
56
58
  const headings: TaskHeading[] = [];
59
+ let fence: string | undefined;
57
60
 
58
61
  lines.forEach((line, idx) => {
59
- const match = TASK_HEADING_RE.exec(stripLineBreaks(line));
60
- if (!match) {
62
+ const stripped = stripLineBreaks(line);
63
+ const fenceMatch = FENCE_LINE_RE.exec(stripped);
64
+ if (fenceMatch) {
65
+ const marker = fenceMatch[1];
66
+ if (!fence) {
67
+ fence = marker;
68
+ } else if (marker[0] === fence[0] && marker.length >= fence.length) {
69
+ fence = undefined;
70
+ }
71
+ return;
72
+ }
73
+ if (fence) {
61
74
  return;
62
75
  }
63
76
 
64
- headings.push({
65
- startIdx: idx,
66
- taskId: match[1],
67
- title: match[2].trim(),
68
- });
77
+ const match = TASK_HEADING_RE.exec(stripped);
78
+ if (match) {
79
+ headings.push({
80
+ startIdx: idx,
81
+ taskId: match[1],
82
+ title: match[2].trim(),
83
+ });
84
+ }
69
85
  });
70
86
 
71
87
  return headings;
@@ -73,9 +89,17 @@ function parseTaskHeadings(lines: string[]): TaskHeading[] {
73
89
 
74
90
  function findProgressDone(lines: string[], taskId: string): boolean | undefined {
75
91
  const regex = progressRegexForTask(taskId);
92
+ const progressStart = lines.findIndex((line) => GLOBAL_PROGRESS_HEADING_RE.test(stripLineBreaks(line).trim()));
93
+ if (progressStart < 0) {
94
+ return undefined;
95
+ }
76
96
 
77
- for (const line of lines) {
78
- const match = regex.exec(stripLineBreaks(line));
97
+ for (let idx = progressStart + 1; idx < lines.length; idx += 1) {
98
+ const stripped = stripLineBreaks(lines[idx]);
99
+ if (/^\s*---\s*$/.test(stripped) || TASK_HEADING_RE.test(stripped)) {
100
+ break;
101
+ }
102
+ const match = regex.exec(stripped);
79
103
  if (match) {
80
104
  return match[2].toLowerCase() === "x";
81
105
  }
@@ -114,7 +138,7 @@ function findStatusItems(lines: string[], startIdx: number, endIdx: number): Tas
114
138
  continue;
115
139
  }
116
140
 
117
- if (seenCheckbox) {
141
+ if (FIELD_HEADING_RE.test(stripped) || seenCheckbox) {
118
142
  break;
119
143
  }
120
144
  }
@@ -152,7 +176,7 @@ function markStatusBlockDone(lines: string[], startIdx: number, endIdx: number):
152
176
  continue;
153
177
  }
154
178
 
155
- if (seenCheckbox) {
179
+ if (FIELD_HEADING_RE.test(stripped) || seenCheckbox) {
156
180
  break;
157
181
  }
158
182
  }
@@ -1,7 +1,13 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import { coverageGoalAction, coverageGoalVerification, parseCoverageGoal } from "./coverage_goal.ts";
4
- import { hasTaskResult, hasTaskResultStatus, isDoneStatus, parseReportedStatus } from "./result_writer.ts";
4
+ import {
5
+ hasCompleteTaskResult,
6
+ hasTaskResult,
7
+ isDoneStatus,
8
+ parseCompleteTaskResult,
9
+ parseReportedStatus,
10
+ } from "./result_writer.ts";
5
11
  import type { Task } from "./todo_parser.ts";
6
12
 
7
13
  export interface WorkerTaskPromptOptions {
@@ -436,6 +442,7 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
436
442
  let messageUsageCostTotal = 0;
437
443
  let hasMessageUsageCost = false;
438
444
  let sessionStatsCostTotal: number | undefined;
445
+ let resolvePromptWait: (() => void) | undefined;
439
446
 
440
447
  const prompt = buildTaskPrompt(options);
441
448
  const taskTimeoutSeconds = options.taskTimeoutSeconds ?? DEFAULT_TASK_TIMEOUT_SECONDS;
@@ -483,38 +490,86 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
483
490
  timers.add(timer);
484
491
  };
485
492
 
486
- const requestGracefulTaskResult = async (message: string, options: { shutdown?: boolean } = {}) => {
493
+ const requestGracefulTaskResult = (message: string, options: { shutdown?: boolean } = {}) => {
487
494
  if (!session || finished || aborted) {
488
495
  return;
489
496
  }
490
497
  if (options.shutdown) {
491
498
  shutdownRequested = true;
492
499
  }
493
- if (session.isBashRunning && session.abortBash) {
494
- session.abortBash();
495
- compactionEvents.push("aborted running bash before graceful shutdown request");
496
- }
497
- if ((session.isStreaming || session.isBashRunning) && session.steer) {
498
- await session.steer(message);
499
- } else {
500
- await session.prompt(message);
500
+ try {
501
+ if (session.isBashRunning && session.abortBash) {
502
+ session.abortBash();
503
+ compactionEvents.push("aborted running bash before graceful shutdown request");
504
+ }
505
+ const request =
506
+ (session.isStreaming || session.isBashRunning) && session.steer
507
+ ? session.steer(message)
508
+ : session.followUp
509
+ ? session.followUp(message)
510
+ : session.steer
511
+ ? session.steer(message)
512
+ : undefined;
513
+ if (!request) {
514
+ compactionEvents.push("graceful TASK_RESULT request skipped: session does not support steer/followUp");
515
+ return;
516
+ }
517
+ void request.catch((exc: unknown) => {
518
+ compactionEvents.push(`graceful TASK_RESULT request failed: ${errorMessage(exc)}`);
519
+ });
520
+ } catch (exc) {
521
+ compactionEvents.push(`graceful TASK_RESULT request failed: ${errorMessage(exc)}`);
501
522
  }
502
523
  };
503
524
 
504
- const abortSession = async (reason: string) => {
525
+ const abortSession = (reason: string) => {
505
526
  if (!session || finished || aborted) {
506
527
  return;
507
528
  }
508
529
  aborted = true;
509
530
  shutdownRequested = true;
510
531
  error = error ?? reason;
511
- await session.abort?.();
532
+ try {
533
+ const abortResult = session.abort?.();
534
+ void Promise.resolve(abortResult).catch((exc: unknown) => {
535
+ compactionEvents.push(`session abort failed: ${errorMessage(exc)}`);
536
+ });
537
+ } catch (exc) {
538
+ compactionEvents.push(`session abort failed: ${errorMessage(exc)}`);
539
+ }
540
+ resolvePromptWait?.();
541
+ resolvePromptWait = undefined;
512
542
  };
513
543
 
514
- const abortListener = () => {
515
- void abortSession("worker session aborted by outer signal").catch((exc: unknown) => {
516
- compactionEvents.push(`abort by outer signal failed: ${errorMessage(exc)}`);
544
+ const waitForPrompt = async (text: string): Promise<void> => {
545
+ if (!session) {
546
+ return;
547
+ }
548
+ let settled = false;
549
+ const completed = new Promise<void>((resolve) => {
550
+ resolvePromptWait = resolve;
517
551
  });
552
+ void session.prompt(text).then(
553
+ () => {
554
+ settled = true;
555
+ resolvePromptWait?.();
556
+ resolvePromptWait = undefined;
557
+ },
558
+ (exc: unknown) => {
559
+ settled = true;
560
+ error = error ?? errorMessage(exc);
561
+ resolvePromptWait?.();
562
+ resolvePromptWait = undefined;
563
+ },
564
+ );
565
+ await completed;
566
+ if (!settled && !aborted) {
567
+ error = error ?? "worker prompt ended without settling";
568
+ }
569
+ };
570
+
571
+ const abortListener = () => {
572
+ abortSession("worker session aborted by outer signal");
518
573
  };
519
574
 
520
575
  try {
@@ -612,12 +667,12 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
612
667
  options.abortSignal?.addEventListener("abort", abortListener, { once: true });
613
668
 
614
669
  if (taskTimeoutSeconds > 0) {
615
- schedule(async () => {
616
- if (finished || hasTaskResultStatus(assistantText)) {
670
+ schedule(() => {
671
+ if (finished || hasCompleteTaskResult(assistantText)) {
617
672
  return;
618
673
  }
619
674
  timedOut = true;
620
- await requestGracefulTaskResult(buildTimeLimitMessage(taskTimeoutSeconds), { shutdown: true });
675
+ requestGracefulTaskResult(buildTimeLimitMessage(taskTimeoutSeconds), { shutdown: true });
621
676
  if (gracefulShutdownSeconds > 0) {
622
677
  schedule(
623
678
  () => abortSession(`task exceeded ${taskTimeoutSeconds.toFixed(0)}s timeout`),
@@ -627,12 +682,14 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
627
682
  }, taskTimeoutSeconds * 1000);
628
683
  }
629
684
 
630
- await session.prompt(prompt);
685
+ await waitForPrompt(prompt);
631
686
  assistantText = latestAssistantText(session, assistantText);
632
687
 
633
- if (!hasTaskResultStatus(assistantText) && !aborted && !options.abortSignal?.aborted) {
634
- contextObservations.push("missing TASK_RESULT status after initial prompt; requested required block once");
635
- await requestGracefulTaskResult(buildMissingTaskResultMessage());
688
+ if (!hasCompleteTaskResult(assistantText) && !error && !aborted && !timedOut && !options.abortSignal?.aborted) {
689
+ contextObservations.push(
690
+ "missing TASK_RESULT status after initial prompt, or required fields were incomplete; requested required block once",
691
+ );
692
+ await waitForPrompt(buildMissingTaskResultMessage());
636
693
  assistantText = latestAssistantText(session, assistantText);
637
694
  }
638
695
  } catch (exc) {
@@ -647,7 +704,11 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
647
704
  sessionFile = session.sessionFile ?? sessionFile;
648
705
  sessionId = session.sessionId ?? sessionId;
649
706
  sessionStatsCostTotal = await workerUsageCostFromSessionStats(session);
650
- session.dispose?.();
707
+ try {
708
+ await Promise.resolve(session.dispose?.());
709
+ } catch (exc) {
710
+ compactionEvents.push(`session dispose failed: ${errorMessage(exc)}`);
711
+ }
651
712
  }
652
713
  }
653
714
 
@@ -655,7 +716,8 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
655
716
  assistantText = buildLongTaskFailureTaskResult(error ?? (timedOut ? "task timed out" : "worker session aborted"));
656
717
  }
657
718
 
658
- const reportedStatus = parseReportedStatus(assistantText);
719
+ const parsedResult = parseCompleteTaskResult(assistantText);
720
+ const reportedStatus = parsedResult?.status ?? parseReportedStatus(assistantText);
659
721
  const capturedWorkerCost = selectWorkerCostTotal({
660
722
  messageCostTotal: hasMessageUsageCost ? messageUsageCostTotal : undefined,
661
723
  statsCostTotal: sessionStatsCostTotal,
@@ -666,7 +728,7 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
666
728
  startedAt,
667
729
  endedAt: now().toISOString(),
668
730
  reportedStatus,
669
- done: isDoneStatus(reportedStatus),
731
+ done: Boolean(parsedResult && isDoneStatus(reportedStatus) && !error && !aborted && !timedOut),
670
732
  assistantText,
671
733
  sessionFile,
672
734
  sessionId,