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.
- package/CHANGELOG.md +19 -0
- package/README.md +13 -7
- package/package.json +6 -2
- package/src/coordinator.ts +68 -20
- package/src/goal_loop.ts +30 -0
- package/src/goal_orchestrator.ts +219 -128
- package/src/goal_review.ts +91 -73
- package/src/goal_state.ts +25 -0
- package/src/goal_todo_execution.ts +25 -8
- package/src/goal_todo_generation.ts +15 -8
- package/src/result_writer.ts +90 -24
- package/src/todo_generator.ts +74 -23
- package/src/todo_parser.ts +35 -11
- package/src/worker_session.ts +87 -25
|
@@ -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:
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
if (!Number.isFinite(
|
|
302
|
-
return
|
|
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,
|
|
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:
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
if (!Number.isFinite(
|
|
475
|
-
return
|
|
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,
|
|
484
|
+
return Math.floor(Math.min(state.limits.iterationTimeoutMs, iterationRemaining, overallRemaining));
|
|
478
485
|
}
|
|
479
486
|
|
|
480
487
|
function buildPreviousIterationContext(state: GoalLoopState): string {
|
package/src/result_writer.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
95
|
-
|
|
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
|
}
|
package/src/todo_generator.ts
CHANGED
|
@@ -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
|
|
103
|
-
if (
|
|
104
|
-
throw new TodoGenerationError(
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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[] {
|
package/src/todo_parser.ts
CHANGED
|
@@ -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
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 (
|
|
78
|
-
const
|
|
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
|
}
|
package/src/worker_session.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
494
|
-
session.abortBash
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
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(
|
|
616
|
-
if (finished ||
|
|
670
|
+
schedule(() => {
|
|
671
|
+
if (finished || hasCompleteTaskResult(assistantText)) {
|
|
617
672
|
return;
|
|
618
673
|
}
|
|
619
674
|
timedOut = true;
|
|
620
|
-
|
|
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
|
|
685
|
+
await waitForPrompt(prompt);
|
|
631
686
|
assistantText = latestAssistantText(session, assistantText);
|
|
632
687
|
|
|
633
|
-
if (!
|
|
634
|
-
contextObservations.push(
|
|
635
|
-
|
|
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
|
-
|
|
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
|
|
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,
|