taskplane 0.18.1 → 0.20.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.
- package/bin/taskplane.mjs +1 -1
- package/dashboard/public/app.js +41 -0
- package/dashboard/public/index.html +5 -2
- package/dashboard/public/style.css +194 -58
- package/dashboard/public/taskplane-word-color.svg +22 -0
- package/dashboard/public/taskplane-word-white.svg +22 -0
- package/dashboard/server.cjs +65 -0
- package/extensions/task-runner.ts +143 -78
- package/extensions/taskplane/engine-worker.ts +289 -0
- package/extensions/taskplane/execution.ts +298 -25
- package/extensions/taskplane/extension.ts +330 -94
- package/extensions/taskplane/merge.ts +242 -19
- package/extensions/taskplane/supervisor-primer.md +1 -1
- package/extensions/taskplane/supervisor.ts +161 -42
- package/package.json +1 -1
- package/skills/create-taskplane-task/references/prompt-template.md +0 -3
- package/templates/agents/local/task-reviewer.md +1 -1
- package/templates/agents/task-reviewer.md +7 -2
- package/templates/agents/task-worker.md +16 -1
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -99
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -98
- package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
package/dashboard/server.cjs
CHANGED
|
@@ -1117,6 +1117,59 @@ function serveStatusMd(req, res, taskId) {
|
|
|
1117
1117
|
res.end(JSON.stringify({ error: "STATUS.md not found" }));
|
|
1118
1118
|
}
|
|
1119
1119
|
|
|
1120
|
+
// ─── Dashboard Preferences ──────────────────────────────────────────────────
|
|
1121
|
+
|
|
1122
|
+
function getPreferencesPath() {
|
|
1123
|
+
return path.join(REPO_ROOT, ".pi", "dashboard-preferences.json");
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function handleGetPreferences(req, res) {
|
|
1127
|
+
const prefsPath = getPreferencesPath();
|
|
1128
|
+
let prefs = { theme: "dark" };
|
|
1129
|
+
try {
|
|
1130
|
+
if (fs.existsSync(prefsPath)) {
|
|
1131
|
+
prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
|
|
1132
|
+
}
|
|
1133
|
+
} catch { /* use defaults */ }
|
|
1134
|
+
res.writeHead(200, {
|
|
1135
|
+
"Content-Type": "application/json",
|
|
1136
|
+
"Access-Control-Allow-Origin": "*",
|
|
1137
|
+
});
|
|
1138
|
+
res.end(JSON.stringify(prefs));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function handlePostPreferences(req, res) {
|
|
1142
|
+
let body = "";
|
|
1143
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
1144
|
+
req.on("end", () => {
|
|
1145
|
+
try {
|
|
1146
|
+
const incoming = JSON.parse(body);
|
|
1147
|
+
const prefsPath = getPreferencesPath();
|
|
1148
|
+
let existing = {};
|
|
1149
|
+
try {
|
|
1150
|
+
if (fs.existsSync(prefsPath)) {
|
|
1151
|
+
existing = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
|
|
1152
|
+
}
|
|
1153
|
+
} catch { /* start fresh */ }
|
|
1154
|
+
const merged = { ...existing, ...incoming };
|
|
1155
|
+
const dir = path.dirname(prefsPath);
|
|
1156
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1157
|
+
fs.writeFileSync(prefsPath, JSON.stringify(merged, null, 2) + "\n");
|
|
1158
|
+
res.writeHead(200, {
|
|
1159
|
+
"Content-Type": "application/json",
|
|
1160
|
+
"Access-Control-Allow-Origin": "*",
|
|
1161
|
+
});
|
|
1162
|
+
res.end(JSON.stringify(merged));
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
res.writeHead(400, {
|
|
1165
|
+
"Content-Type": "application/json",
|
|
1166
|
+
"Access-Control-Allow-Origin": "*",
|
|
1167
|
+
});
|
|
1168
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1120
1173
|
// ─── HTTP Server ────────────────────────────────────────────────────────────
|
|
1121
1174
|
|
|
1122
1175
|
function createServer() {
|
|
@@ -1146,6 +1199,18 @@ function createServer() {
|
|
|
1146
1199
|
} else if (pathname.startsWith("/api/status-md/") && req.method === "GET") {
|
|
1147
1200
|
const taskId = decodeURIComponent(pathname.slice("/api/status-md/".length));
|
|
1148
1201
|
serveStatusMd(req, res, taskId);
|
|
1202
|
+
} else if (pathname === "/api/preferences" && req.method === "GET") {
|
|
1203
|
+
handleGetPreferences(req, res);
|
|
1204
|
+
} else if (pathname === "/api/preferences" && req.method === "POST") {
|
|
1205
|
+
handlePostPreferences(req, res);
|
|
1206
|
+
} else if (req.method === "OPTIONS") {
|
|
1207
|
+
// CORS preflight for POST
|
|
1208
|
+
res.writeHead(204, {
|
|
1209
|
+
"Access-Control-Allow-Origin": "*",
|
|
1210
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
1211
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
1212
|
+
});
|
|
1213
|
+
res.end();
|
|
1149
1214
|
} else {
|
|
1150
1215
|
serveStatic(req, res);
|
|
1151
1216
|
}
|
|
@@ -1111,8 +1111,78 @@ function generateReviewRequest(
|
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
1113
|
function extractVerdict(reviewContent: string): string {
|
|
1114
|
+
// Primary: standard format "### Verdict: APPROVE|REVISE|RETHINK"
|
|
1114
1115
|
const match = reviewContent.match(/###?\s*Verdict[:\s]*(APPROVE|REVISE|RETHINK)/i);
|
|
1115
|
-
|
|
1116
|
+
if (match) return match[1].toUpperCase();
|
|
1117
|
+
|
|
1118
|
+
// TP-068: Tolerate non-standard verdict formats from models that don't
|
|
1119
|
+
// follow the exact template (e.g., "Changes requested", "Needs revision").
|
|
1120
|
+
const lower = reviewContent.toLowerCase();
|
|
1121
|
+
if (/\b(changes?\s+requested|needs?\s+revision|please\s+revise|must\s+revise)\b/.test(lower)) {
|
|
1122
|
+
return "REVISE";
|
|
1123
|
+
}
|
|
1124
|
+
if (/\b(looks?\s+good|no\s+issues?\s+found|approved?)\b/.test(lower)) {
|
|
1125
|
+
return "APPROVE";
|
|
1126
|
+
}
|
|
1127
|
+
if (/\b(fundamentally\s+wrong|rethink|reconsider\s+the\s+approach)\b/.test(lower)) {
|
|
1128
|
+
return "RETHINK";
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return "UNKNOWN";
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Process a review verdict: extract the verdict from review content, log it,
|
|
1136
|
+
* update the status file, and build the result text for the worker.
|
|
1137
|
+
*
|
|
1138
|
+
* Shared by the persistent reviewer path and the fallback fresh-spawn path
|
|
1139
|
+
* in the review_step tool handler.
|
|
1140
|
+
*/
|
|
1141
|
+
function processReviewVerdict(
|
|
1142
|
+
reviewContent: string | null,
|
|
1143
|
+
statusPath: string,
|
|
1144
|
+
num: string,
|
|
1145
|
+
reviewType: string,
|
|
1146
|
+
stepNum: number,
|
|
1147
|
+
reviewCounter: number,
|
|
1148
|
+
suffix?: string,
|
|
1149
|
+
): { verdict: string; resultText: string } {
|
|
1150
|
+
let verdict = "UNKNOWN";
|
|
1151
|
+
let reviseDetails = "";
|
|
1152
|
+
if (reviewContent) {
|
|
1153
|
+
verdict = extractVerdict(reviewContent);
|
|
1154
|
+
if (verdict === "REVISE") {
|
|
1155
|
+
const summaryMatch = reviewContent.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
|
|
1156
|
+
reviseDetails = summaryMatch
|
|
1157
|
+
? summaryMatch[1].trim().slice(0, 500)
|
|
1158
|
+
: "See review file for details.";
|
|
1159
|
+
}
|
|
1160
|
+
} else {
|
|
1161
|
+
verdict = "UNAVAILABLE";
|
|
1162
|
+
const label = suffix ? `${suffix} reviewer` : "reviewer";
|
|
1163
|
+
logExecution(statusPath, `Reviewer R${num}`,
|
|
1164
|
+
`${reviewType} review — ${label} did not produce output`);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const reviewFile = `.reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
1168
|
+
logReview(statusPath, `R${num}`, reviewType, stepNum, verdict, reviewFile);
|
|
1169
|
+
const logSuffix = suffix ? ` (${suffix})` : "";
|
|
1170
|
+
logExecution(statusPath, `Review R${num}`,
|
|
1171
|
+
`${reviewType} Step ${stepNum}: ${verdict}${logSuffix}`);
|
|
1172
|
+
updateStatusField(statusPath, "Review Counter", `${reviewCounter}`);
|
|
1173
|
+
|
|
1174
|
+
let resultText: string;
|
|
1175
|
+
if (verdict === "APPROVE") {
|
|
1176
|
+
resultText = "APPROVE";
|
|
1177
|
+
} else if (verdict === "REVISE") {
|
|
1178
|
+
resultText = `REVISE: ${reviseDetails}\n\nFull review: ${reviewFile}`;
|
|
1179
|
+
} else if (verdict === "RETHINK") {
|
|
1180
|
+
resultText = `RETHINK — reconsider your approach. See ${reviewFile}`;
|
|
1181
|
+
} else {
|
|
1182
|
+
resultText = `UNAVAILABLE — reviewer did not produce a usable verdict.`;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
return { verdict, resultText };
|
|
1116
1186
|
}
|
|
1117
1187
|
|
|
1118
1188
|
// ── Subagent Spawner ─────────────────────────────────────────────────
|
|
@@ -2304,8 +2374,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
2304
2374
|
// Initial prompt tells the reviewer to call wait_for_review
|
|
2305
2375
|
const initialPrompt =
|
|
2306
2376
|
"You are a persistent reviewer for this task. " +
|
|
2307
|
-
"
|
|
2308
|
-
"
|
|
2377
|
+
"Use the `wait_for_review` tool now to receive your first review request. " +
|
|
2378
|
+
"IMPORTANT: `wait_for_review` is a REGISTERED EXTENSION TOOL — call it " +
|
|
2379
|
+
"the same way you call `read`, `write`, `edit`, or `grep`. " +
|
|
2380
|
+
"Do NOT run it via `bash` or any shell command. " +
|
|
2381
|
+
"After writing each review, use `wait_for_review` again for the next one.";
|
|
2309
2382
|
|
|
2310
2383
|
const spawned = spawnAgentTmux({
|
|
2311
2384
|
sessionName,
|
|
@@ -2375,8 +2448,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
2375
2448
|
/**
|
|
2376
2449
|
* Poll for the verdict file to appear (written by the reviewer).
|
|
2377
2450
|
* Same pattern as the original review_step handler.
|
|
2451
|
+
*
|
|
2452
|
+
* Early-exit detection (TP-068): If the reviewer exits within 30s
|
|
2453
|
+
* of spawn without producing a verdict, it likely failed to use the
|
|
2454
|
+
* wait_for_review tool correctly (e.g., called it via bash). This
|
|
2455
|
+
* triggers a faster fallback instead of waiting 30 minutes.
|
|
2378
2456
|
*/
|
|
2379
|
-
async function pollForVerdict(): Promise<string> {
|
|
2457
|
+
async function pollForVerdict(spawnTime?: number): Promise<string> {
|
|
2458
|
+
const EARLY_EXIT_THRESHOLD_MS = 30_000; // 30 seconds
|
|
2380
2459
|
const verdictTimeout = 30 * 60 * 1000; // 30 minutes
|
|
2381
2460
|
const pollStart = Date.now();
|
|
2382
2461
|
while (Date.now() - pollStart < verdictTimeout) {
|
|
@@ -2385,6 +2464,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
2385
2464
|
}
|
|
2386
2465
|
// Also check if persistent reviewer died while we're waiting
|
|
2387
2466
|
if (state.persistentReviewerSession && !isPersistentReviewerAlive()) {
|
|
2467
|
+
// TP-068: Detect early exit as tool compatibility failure
|
|
2468
|
+
if (spawnTime && (Date.now() - spawnTime) < EARLY_EXIT_THRESHOLD_MS) {
|
|
2469
|
+
throw new Error(
|
|
2470
|
+
"Persistent reviewer exited within 30s of spawn without producing a verdict — " +
|
|
2471
|
+
"wait_for_review tool may not be supported by this model (e.g., called via bash instead of as a registered tool)"
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2388
2474
|
throw new Error("Persistent reviewer session died while waiting for verdict");
|
|
2389
2475
|
}
|
|
2390
2476
|
await new Promise(r => setTimeout(r, 2000));
|
|
@@ -2406,7 +2492,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2406
2492
|
state.persistentReviewerSignalNum = 0;
|
|
2407
2493
|
}
|
|
2408
2494
|
|
|
2495
|
+
// Track spawn time for early-exit detection (TP-068)
|
|
2496
|
+
let spawnTime: number | undefined;
|
|
2409
2497
|
if (needsSpawn) {
|
|
2498
|
+
spawnTime = Date.now();
|
|
2410
2499
|
spawnPersistentReviewer();
|
|
2411
2500
|
// Give the reviewer a moment to start and call wait_for_review
|
|
2412
2501
|
await new Promise(r => setTimeout(r, 5000));
|
|
@@ -2415,8 +2504,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2415
2504
|
// Signal the reviewer with the new request
|
|
2416
2505
|
signalPersistentReviewer();
|
|
2417
2506
|
|
|
2418
|
-
// Poll for the verdict file
|
|
2419
|
-
const reviewContent = await pollForVerdict();
|
|
2507
|
+
// Poll for the verdict file (pass spawnTime for early-exit detection)
|
|
2508
|
+
const reviewContent = await pollForVerdict(spawnTime);
|
|
2420
2509
|
|
|
2421
2510
|
// Stop the per-review timer
|
|
2422
2511
|
if (state.reviewerTimer) clearInterval(state.reviewerTimer);
|
|
@@ -2425,29 +2514,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2425
2514
|
writeLaneState(state);
|
|
2426
2515
|
updateWidgets();
|
|
2427
2516
|
|
|
2428
|
-
// Extract verdict
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
verdict = extractVerdict(reviewContent);
|
|
2433
|
-
if (verdict === "REVISE") {
|
|
2434
|
-
const summaryMatch = reviewContent.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
|
|
2435
|
-
reviseDetails = summaryMatch
|
|
2436
|
-
? summaryMatch[1].trim().slice(0, 500)
|
|
2437
|
-
: "See review file for details.";
|
|
2438
|
-
}
|
|
2439
|
-
} else {
|
|
2440
|
-
verdict = "UNAVAILABLE";
|
|
2441
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2442
|
-
`${reviewType} review — reviewer did not produce output`);
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
// Log the review in STATUS.md
|
|
2446
|
-
logReview(statusPath, `R${num}`, reviewType, stepNum, verdict,
|
|
2447
|
-
`.reviews/R${num}-${reviewType}-step${stepNum}.md`);
|
|
2448
|
-
logExecution(statusPath, `Review R${num}`,
|
|
2449
|
-
`${reviewType} Step ${stepNum}: ${verdict}`);
|
|
2450
|
-
updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
|
|
2517
|
+
// Extract verdict and build result
|
|
2518
|
+
const { resultText } = processReviewVerdict(
|
|
2519
|
+
reviewContent, statusPath, num, reviewType, stepNum, state.reviewCounter,
|
|
2520
|
+
);
|
|
2451
2521
|
|
|
2452
2522
|
// Set reviewer to idle (NOT clear — persistent session stays alive)
|
|
2453
2523
|
state.reviewerStatus = "idle";
|
|
@@ -2458,18 +2528,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
2458
2528
|
writeLaneState(state);
|
|
2459
2529
|
updateWidgets();
|
|
2460
2530
|
|
|
2461
|
-
// Return verdict to the worker
|
|
2462
|
-
let resultText: string;
|
|
2463
|
-
if (verdict === "APPROVE") {
|
|
2464
|
-
resultText = "APPROVE";
|
|
2465
|
-
} else if (verdict === "REVISE") {
|
|
2466
|
-
resultText = `REVISE: ${reviseDetails}\n\nFull review: .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2467
|
-
} else if (verdict === "RETHINK") {
|
|
2468
|
-
resultText = `RETHINK — reconsider your approach. See .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2469
|
-
} else {
|
|
2470
|
-
resultText = `UNAVAILABLE — reviewer did not produce a usable verdict.`;
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
2531
|
return {
|
|
2474
2532
|
content: [{ type: "text" as const, text: resultText }],
|
|
2475
2533
|
details: undefined,
|
|
@@ -2527,61 +2585,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
2527
2585
|
updateWidgets();
|
|
2528
2586
|
|
|
2529
2587
|
// Extract verdict from fallback review
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
const summaryMatch = review.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
|
|
2537
|
-
reviseDetails = summaryMatch
|
|
2538
|
-
? summaryMatch[1].trim().slice(0, 500)
|
|
2539
|
-
: "See review file for details.";
|
|
2540
|
-
}
|
|
2541
|
-
} else {
|
|
2542
|
-
verdict = "UNAVAILABLE";
|
|
2543
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2544
|
-
`${reviewType} review — fallback reviewer did not produce output`);
|
|
2545
|
-
}
|
|
2546
|
-
|
|
2547
|
-
logReview(statusPath, `R${num}`, reviewType, stepNum, verdict,
|
|
2548
|
-
`.reviews/R${num}-${reviewType}-step${stepNum}.md`);
|
|
2549
|
-
logExecution(statusPath, `Review R${num}`,
|
|
2550
|
-
`${reviewType} Step ${stepNum}: ${verdict} (fallback)`);
|
|
2551
|
-
updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
|
|
2588
|
+
const fallbackContent = existsSync(outputPath)
|
|
2589
|
+
? readFileSync(outputPath, "utf-8")
|
|
2590
|
+
: null;
|
|
2591
|
+
const { resultText } = processReviewVerdict(
|
|
2592
|
+
fallbackContent, statusPath, num, reviewType, stepNum, state.reviewCounter, "fallback",
|
|
2593
|
+
);
|
|
2552
2594
|
|
|
2553
2595
|
clearReviewerState();
|
|
2554
2596
|
writeLaneState(state);
|
|
2555
2597
|
updateWidgets();
|
|
2556
2598
|
|
|
2557
|
-
let resultText: string;
|
|
2558
|
-
if (verdict === "APPROVE") {
|
|
2559
|
-
resultText = "APPROVE";
|
|
2560
|
-
} else if (verdict === "REVISE") {
|
|
2561
|
-
resultText = `REVISE: ${reviseDetails}\n\nFull review: .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2562
|
-
} else if (verdict === "RETHINK") {
|
|
2563
|
-
resultText = `RETHINK — reconsider your approach. See .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2564
|
-
} else {
|
|
2565
|
-
resultText = `UNAVAILABLE — reviewer did not produce a usable verdict.`;
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
2599
|
return {
|
|
2569
2600
|
content: [{ type: "text" as const, text: resultText }],
|
|
2570
2601
|
details: undefined,
|
|
2571
2602
|
};
|
|
2572
2603
|
} catch (fallbackErr: any) {
|
|
2573
|
-
// Both persistent and fallback failed
|
|
2604
|
+
// Both persistent and fallback failed — TP-068: clear logging
|
|
2574
2605
|
clearInterval(state.reviewerTimer);
|
|
2575
2606
|
clearReviewerState();
|
|
2576
2607
|
state.reviewerStatus = "error";
|
|
2577
2608
|
writeLaneState(state);
|
|
2578
2609
|
updateWidgets();
|
|
2579
2610
|
|
|
2611
|
+
const skipMsg = `⚠️ Reviews skipped for Step ${stepNum} — reviewer model could not process ${reviewType} review request. Both persistent and fallback modes failed.`;
|
|
2612
|
+
console.error(`[task-runner] ${skipMsg}`);
|
|
2580
2613
|
logExecution(statusPath, `Reviewer R${num}`,
|
|
2581
|
-
`${
|
|
2614
|
+
`${skipMsg} Error: ${fallbackErr?.message || fallbackErr}`);
|
|
2615
|
+
|
|
2616
|
+
// TP-068: Ensure shutdown signal is written even on double failure
|
|
2617
|
+
try {
|
|
2618
|
+
const shutdownPath = join(reviewsDir, REVIEWER_SHUTDOWN_SIGNAL);
|
|
2619
|
+
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
|
|
2620
|
+
writeFileSync(shutdownPath, "shutdown");
|
|
2621
|
+
} catch {}
|
|
2582
2622
|
|
|
2583
2623
|
return {
|
|
2584
|
-
content: [{ type: "text" as const, text: `UNAVAILABLE —
|
|
2624
|
+
content: [{ type: "text" as const, text: `UNAVAILABLE — ${skipMsg}` }],
|
|
2585
2625
|
details: undefined,
|
|
2586
2626
|
};
|
|
2587
2627
|
}
|
|
@@ -2977,6 +3017,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
2977
3017
|
: ` - Step ${s.number}: ${s.name} [already complete — skip]`
|
|
2978
3018
|
).join("\n");
|
|
2979
3019
|
|
|
3020
|
+
// TP-073: Build nudge for subsequent iterations (iter > 0)
|
|
3021
|
+
// When the worker exited without completing all steps, the next iteration
|
|
3022
|
+
// gets an explicit nudge listing completed/remaining steps and a warning
|
|
3023
|
+
// not to exit prematurely again.
|
|
3024
|
+
let iterationNudge = "";
|
|
3025
|
+
if (state.totalIterations > 1 && remainingSteps.length > 0) {
|
|
3026
|
+
const completedSteps = task.steps.filter(s => !remainingSet.has(s.number));
|
|
3027
|
+
const completedList = completedSteps.length > 0
|
|
3028
|
+
? completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")
|
|
3029
|
+
: "(none)";
|
|
3030
|
+
const remainingList = remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ");
|
|
3031
|
+
iterationNudge = [
|
|
3032
|
+
``,
|
|
3033
|
+
`IMPORTANT: You exited on your previous iteration without completing all steps.`,
|
|
3034
|
+
`Do NOT repeat this — you must complete all remaining steps before stopping.`,
|
|
3035
|
+
``,
|
|
3036
|
+
`Completed steps (do not redo): ${completedList}`,
|
|
3037
|
+
`Remaining steps (focus here): ${remainingList}`,
|
|
3038
|
+
``,
|
|
3039
|
+
`Your final action MUST be a tool call (update STATUS.md). Do NOT produce a`,
|
|
3040
|
+
`text-only response — that will terminate your session prematurely.`,
|
|
3041
|
+
``,
|
|
3042
|
+
].join("\n");
|
|
3043
|
+
}
|
|
3044
|
+
|
|
2980
3045
|
const prompt = [
|
|
2981
3046
|
`Execute all remaining steps for task ${task.taskId}.`,
|
|
2982
3047
|
``,
|
|
@@ -2987,7 +3052,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2987
3052
|
``,
|
|
2988
3053
|
`This is iteration ${state.totalIterations}.`,
|
|
2989
3054
|
`Read STATUS.md FIRST to find where you left off.`,
|
|
2990
|
-
|
|
3055
|
+
iterationNudge,
|
|
2991
3056
|
`Steps:`,
|
|
2992
3057
|
stepListing,
|
|
2993
3058
|
``,
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine Worker Thread Entry Point (TP-071)
|
|
3
|
+
*
|
|
4
|
+
* This module serves two purposes:
|
|
5
|
+
* 1. Exports types and helpers used by extension.ts (main thread)
|
|
6
|
+
* 2. When executed as a worker_threads Worker, runs the engine in a separate V8 isolate
|
|
7
|
+
*
|
|
8
|
+
* Communication:
|
|
9
|
+
* - Worker → Main: postMessage for notify, monitor-update, engine-event, state-sync, complete, error
|
|
10
|
+
* - Main → Worker: postMessage for pause/resume/abort control
|
|
11
|
+
*
|
|
12
|
+
* @module orch/engine-worker
|
|
13
|
+
*/
|
|
14
|
+
import { parentPort, workerData, isMainThread } from "worker_threads";
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
EngineEvent,
|
|
18
|
+
MonitorState,
|
|
19
|
+
OrchBatchPhase,
|
|
20
|
+
OrchBatchRuntimeState,
|
|
21
|
+
OrchestratorConfig,
|
|
22
|
+
TaskRunnerConfig,
|
|
23
|
+
WorkspaceConfig,
|
|
24
|
+
WorkspaceRepoConfig,
|
|
25
|
+
} from "./types.ts";
|
|
26
|
+
|
|
27
|
+
// ── Types for worker <-> main thread messages ────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Messages sent FROM the worker TO the main thread.
|
|
31
|
+
*/
|
|
32
|
+
export type WorkerToMainMessage =
|
|
33
|
+
| { type: "notify"; msg: string; level: "info" | "warning" | "error" }
|
|
34
|
+
| { type: "monitor-update"; state: MonitorState }
|
|
35
|
+
| { type: "engine-event"; event: EngineEvent }
|
|
36
|
+
| { type: "state-sync"; state: SerializedBatchState }
|
|
37
|
+
| { type: "complete"; state: SerializedBatchState }
|
|
38
|
+
| { type: "error"; message: string };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Messages sent FROM the main thread TO the worker.
|
|
42
|
+
*/
|
|
43
|
+
export type WorkerInMessage =
|
|
44
|
+
| { type: "pause" }
|
|
45
|
+
| { type: "resume" }
|
|
46
|
+
| { type: "abort" };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Serializable form of OrchBatchRuntimeState fields synced to main thread.
|
|
50
|
+
* Only includes fields the main thread needs for display/state tracking.
|
|
51
|
+
*/
|
|
52
|
+
export interface SerializedBatchState {
|
|
53
|
+
phase: OrchBatchPhase;
|
|
54
|
+
batchId: string;
|
|
55
|
+
baseBranch: string;
|
|
56
|
+
orchBranch: string;
|
|
57
|
+
mode: string;
|
|
58
|
+
currentWaveIndex: number;
|
|
59
|
+
totalWaves: number;
|
|
60
|
+
totalTasks: number;
|
|
61
|
+
succeededTasks: number;
|
|
62
|
+
failedTasks: number;
|
|
63
|
+
skippedTasks: number;
|
|
64
|
+
blockedTasks: number;
|
|
65
|
+
startedAt: number;
|
|
66
|
+
endedAt: number | null;
|
|
67
|
+
errors: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Serializable form of WorkspaceConfig (Map → array of entries).
|
|
72
|
+
*/
|
|
73
|
+
export interface SerializedWorkspaceConfig {
|
|
74
|
+
mode: string;
|
|
75
|
+
repos: Array<[string, WorkspaceRepoConfig]>;
|
|
76
|
+
routing: WorkspaceConfig["routing"];
|
|
77
|
+
configPath: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* workerData shape passed from the main thread.
|
|
82
|
+
*/
|
|
83
|
+
export interface EngineWorkerData {
|
|
84
|
+
/** Sentinel flag — distinguishes engine worker from vitest threads */
|
|
85
|
+
engineWorker: true;
|
|
86
|
+
/** "execute" for new batch, "resume" for resume */
|
|
87
|
+
mode: "execute" | "resume";
|
|
88
|
+
/** User arguments (target string) — only for "execute" mode */
|
|
89
|
+
args?: string;
|
|
90
|
+
/** Orchestrator configuration */
|
|
91
|
+
orchConfig: OrchestratorConfig;
|
|
92
|
+
/** Task runner configuration */
|
|
93
|
+
runnerConfig: TaskRunnerConfig;
|
|
94
|
+
/** Repository root (cwd) */
|
|
95
|
+
cwd: string;
|
|
96
|
+
/** Workspace configuration (serialized) — null for repo mode */
|
|
97
|
+
workspaceConfig?: SerializedWorkspaceConfig | null;
|
|
98
|
+
/** Workspace root directory */
|
|
99
|
+
workspaceRoot?: string;
|
|
100
|
+
/** Agent root directory */
|
|
101
|
+
agentRoot?: string;
|
|
102
|
+
/** Force flag for resume */
|
|
103
|
+
force?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Serialization helpers (used by both main thread and worker) ──────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Serialize WorkspaceConfig for cross-thread transfer.
|
|
110
|
+
* Converts the Map to an array of entries.
|
|
111
|
+
*/
|
|
112
|
+
export function serializeWorkspaceConfig(
|
|
113
|
+
config: WorkspaceConfig | null | undefined,
|
|
114
|
+
): SerializedWorkspaceConfig | null {
|
|
115
|
+
if (!config) return null;
|
|
116
|
+
return {
|
|
117
|
+
mode: config.mode,
|
|
118
|
+
repos: [...config.repos.entries()],
|
|
119
|
+
routing: config.routing,
|
|
120
|
+
configPath: config.configPath,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Reconstruct WorkspaceConfig from serialized form.
|
|
126
|
+
*/
|
|
127
|
+
export function deserializeWorkspaceConfig(
|
|
128
|
+
serialized: SerializedWorkspaceConfig | null | undefined,
|
|
129
|
+
): WorkspaceConfig | null {
|
|
130
|
+
if (!serialized) return null;
|
|
131
|
+
return {
|
|
132
|
+
mode: serialized.mode as WorkspaceConfig["mode"],
|
|
133
|
+
repos: new Map(serialized.repos),
|
|
134
|
+
routing: serialized.routing,
|
|
135
|
+
configPath: serialized.configPath,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Extract serializable batch state for sync back to main thread.
|
|
141
|
+
*/
|
|
142
|
+
function serializeBatchState(state: OrchBatchRuntimeState): SerializedBatchState {
|
|
143
|
+
return {
|
|
144
|
+
phase: state.phase,
|
|
145
|
+
batchId: state.batchId,
|
|
146
|
+
baseBranch: state.baseBranch,
|
|
147
|
+
orchBranch: state.orchBranch,
|
|
148
|
+
mode: state.mode,
|
|
149
|
+
currentWaveIndex: state.currentWaveIndex,
|
|
150
|
+
totalWaves: state.totalWaves,
|
|
151
|
+
totalTasks: state.totalTasks,
|
|
152
|
+
succeededTasks: state.succeededTasks,
|
|
153
|
+
failedTasks: state.failedTasks,
|
|
154
|
+
skippedTasks: state.skippedTasks,
|
|
155
|
+
blockedTasks: state.blockedTasks,
|
|
156
|
+
startedAt: state.startedAt,
|
|
157
|
+
endedAt: state.endedAt,
|
|
158
|
+
errors: [...state.errors],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Apply serialized batch state from worker to main-thread batch state.
|
|
164
|
+
*
|
|
165
|
+
* Updates only the fields that the worker thread tracks — preserves
|
|
166
|
+
* main-thread-only fields like pauseSignal, dependencyGraph, etc.
|
|
167
|
+
*/
|
|
168
|
+
export function applySerializedState(
|
|
169
|
+
batchState: OrchBatchRuntimeState,
|
|
170
|
+
serialized: SerializedBatchState,
|
|
171
|
+
): void {
|
|
172
|
+
batchState.phase = serialized.phase;
|
|
173
|
+
batchState.batchId = serialized.batchId;
|
|
174
|
+
batchState.baseBranch = serialized.baseBranch;
|
|
175
|
+
batchState.orchBranch = serialized.orchBranch;
|
|
176
|
+
batchState.mode = serialized.mode as OrchBatchRuntimeState["mode"];
|
|
177
|
+
batchState.currentWaveIndex = serialized.currentWaveIndex;
|
|
178
|
+
batchState.totalWaves = serialized.totalWaves;
|
|
179
|
+
batchState.totalTasks = serialized.totalTasks;
|
|
180
|
+
batchState.succeededTasks = serialized.succeededTasks;
|
|
181
|
+
batchState.failedTasks = serialized.failedTasks;
|
|
182
|
+
batchState.skippedTasks = serialized.skippedTasks;
|
|
183
|
+
batchState.blockedTasks = serialized.blockedTasks;
|
|
184
|
+
batchState.startedAt = serialized.startedAt;
|
|
185
|
+
batchState.endedAt = serialized.endedAt;
|
|
186
|
+
batchState.errors = [...serialized.errors];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Worker main (only runs when loaded as a worker thread) ───────────
|
|
190
|
+
|
|
191
|
+
// Guard: only run worker main when launched as an engine worker (not vitest threads).
|
|
192
|
+
// In vitest --pool=threads, isMainThread=false and parentPort exists, but
|
|
193
|
+
// workerData won't have the engine-specific shape.
|
|
194
|
+
if (!isMainThread && parentPort && workerData?.engineWorker === true) {
|
|
195
|
+
// Dynamic imports — only loaded in worker context to avoid circular
|
|
196
|
+
// dependencies when this module is imported from extension.ts
|
|
197
|
+
const { executeOrchBatch } = await import("./engine.ts");
|
|
198
|
+
const { resumeOrchBatch } = await import("./resume.ts");
|
|
199
|
+
const { freshOrchBatchState } = await import("./types.ts");
|
|
200
|
+
|
|
201
|
+
const data = workerData as EngineWorkerData;
|
|
202
|
+
const port = parentPort;
|
|
203
|
+
|
|
204
|
+
// Create a fresh batch state for this worker
|
|
205
|
+
const batchState: OrchBatchRuntimeState = freshOrchBatchState();
|
|
206
|
+
batchState.phase = "launching";
|
|
207
|
+
batchState.startedAt = Date.now();
|
|
208
|
+
|
|
209
|
+
// Deserialize workspace config
|
|
210
|
+
const wsConfig = deserializeWorkspaceConfig(data.workspaceConfig);
|
|
211
|
+
|
|
212
|
+
// ── Control signal listener ──────────────────────────────────
|
|
213
|
+
// Main thread sends pause/resume/abort signals via postMessage.
|
|
214
|
+
// We apply them to the in-worker batchState.pauseSignal.
|
|
215
|
+
port.on("message", (msg: WorkerInMessage) => {
|
|
216
|
+
switch (msg.type) {
|
|
217
|
+
case "pause":
|
|
218
|
+
batchState.pauseSignal.paused = true;
|
|
219
|
+
break;
|
|
220
|
+
case "resume":
|
|
221
|
+
batchState.pauseSignal.paused = false;
|
|
222
|
+
break;
|
|
223
|
+
case "abort":
|
|
224
|
+
batchState.pauseSignal.paused = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ── Callback factories (replace ctx-dependent callbacks) ─────
|
|
230
|
+
const onNotify = (message: string, level: "info" | "warning" | "error") => {
|
|
231
|
+
port.postMessage({ type: "notify", msg: message, level } satisfies WorkerToMainMessage);
|
|
232
|
+
// Sync batch state on every notify (lightweight — just the summary fields)
|
|
233
|
+
port.postMessage({ type: "state-sync", state: serializeBatchState(batchState) } satisfies WorkerToMainMessage);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const onMonitorUpdate = (state: MonitorState) => {
|
|
237
|
+
port.postMessage({ type: "monitor-update", state } satisfies WorkerToMainMessage);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const onEngineEvent = (event: EngineEvent) => {
|
|
241
|
+
port.postMessage({ type: "engine-event", event } satisfies WorkerToMainMessage);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// ── Execute engine ───────────────────────────────────────────
|
|
245
|
+
const enginePromise = data.mode === "resume"
|
|
246
|
+
? resumeOrchBatch(
|
|
247
|
+
data.orchConfig,
|
|
248
|
+
data.runnerConfig,
|
|
249
|
+
data.cwd,
|
|
250
|
+
batchState,
|
|
251
|
+
onNotify,
|
|
252
|
+
onMonitorUpdate,
|
|
253
|
+
wsConfig,
|
|
254
|
+
data.workspaceRoot,
|
|
255
|
+
data.agentRoot,
|
|
256
|
+
data.force ?? false,
|
|
257
|
+
)
|
|
258
|
+
: executeOrchBatch(
|
|
259
|
+
data.args ?? "",
|
|
260
|
+
data.orchConfig,
|
|
261
|
+
data.runnerConfig,
|
|
262
|
+
data.cwd,
|
|
263
|
+
batchState,
|
|
264
|
+
onNotify,
|
|
265
|
+
onMonitorUpdate,
|
|
266
|
+
wsConfig,
|
|
267
|
+
data.workspaceRoot,
|
|
268
|
+
data.agentRoot,
|
|
269
|
+
onEngineEvent,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
enginePromise
|
|
273
|
+
.then(() => {
|
|
274
|
+
// Final state sync + completion signal
|
|
275
|
+
const finalState = serializeBatchState(batchState);
|
|
276
|
+
port.postMessage({ type: "complete", state: finalState } satisfies WorkerToMainMessage);
|
|
277
|
+
})
|
|
278
|
+
.catch((err: unknown) => {
|
|
279
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
280
|
+
// Ensure batch state reflects the failure
|
|
281
|
+
if (batchState.phase !== "completed" && batchState.phase !== "failed") {
|
|
282
|
+
batchState.phase = "failed";
|
|
283
|
+
batchState.endedAt = Date.now();
|
|
284
|
+
batchState.errors.push(`Unhandled engine error: ${errMsg}`);
|
|
285
|
+
}
|
|
286
|
+
port.postMessage({ type: "state-sync", state: serializeBatchState(batchState) } satisfies WorkerToMainMessage);
|
|
287
|
+
port.postMessage({ type: "error", message: errMsg } satisfies WorkerToMainMessage);
|
|
288
|
+
});
|
|
289
|
+
}
|