pi-long-task 0.3.11 → 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
  }