taskplane 0.19.0 → 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 +90 -69
- 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/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
|
}
|
|
@@ -1131,6 +1131,60 @@ function extractVerdict(reviewContent: string): string {
|
|
|
1131
1131
|
return "UNKNOWN";
|
|
1132
1132
|
}
|
|
1133
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 };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1134
1188
|
// ── Subagent Spawner ─────────────────────────────────────────────────
|
|
1135
1189
|
|
|
1136
1190
|
function spawnAgent(opts: {
|
|
@@ -2460,29 +2514,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2460
2514
|
writeLaneState(state);
|
|
2461
2515
|
updateWidgets();
|
|
2462
2516
|
|
|
2463
|
-
// Extract verdict
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
verdict = extractVerdict(reviewContent);
|
|
2468
|
-
if (verdict === "REVISE") {
|
|
2469
|
-
const summaryMatch = reviewContent.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
|
|
2470
|
-
reviseDetails = summaryMatch
|
|
2471
|
-
? summaryMatch[1].trim().slice(0, 500)
|
|
2472
|
-
: "See review file for details.";
|
|
2473
|
-
}
|
|
2474
|
-
} else {
|
|
2475
|
-
verdict = "UNAVAILABLE";
|
|
2476
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2477
|
-
`${reviewType} review — reviewer did not produce output`);
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
|
-
// Log the review in STATUS.md
|
|
2481
|
-
logReview(statusPath, `R${num}`, reviewType, stepNum, verdict,
|
|
2482
|
-
`.reviews/R${num}-${reviewType}-step${stepNum}.md`);
|
|
2483
|
-
logExecution(statusPath, `Review R${num}`,
|
|
2484
|
-
`${reviewType} Step ${stepNum}: ${verdict}`);
|
|
2485
|
-
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
|
+
);
|
|
2486
2521
|
|
|
2487
2522
|
// Set reviewer to idle (NOT clear — persistent session stays alive)
|
|
2488
2523
|
state.reviewerStatus = "idle";
|
|
@@ -2493,18 +2528,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
2493
2528
|
writeLaneState(state);
|
|
2494
2529
|
updateWidgets();
|
|
2495
2530
|
|
|
2496
|
-
// Return verdict to the worker
|
|
2497
|
-
let resultText: string;
|
|
2498
|
-
if (verdict === "APPROVE") {
|
|
2499
|
-
resultText = "APPROVE";
|
|
2500
|
-
} else if (verdict === "REVISE") {
|
|
2501
|
-
resultText = `REVISE: ${reviseDetails}\n\nFull review: .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2502
|
-
} else if (verdict === "RETHINK") {
|
|
2503
|
-
resultText = `RETHINK — reconsider your approach. See .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2504
|
-
} else {
|
|
2505
|
-
resultText = `UNAVAILABLE — reviewer did not produce a usable verdict.`;
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
2531
|
return {
|
|
2509
2532
|
content: [{ type: "text" as const, text: resultText }],
|
|
2510
2533
|
details: undefined,
|
|
@@ -2562,44 +2585,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
2562
2585
|
updateWidgets();
|
|
2563
2586
|
|
|
2564
2587
|
// Extract verdict from fallback review
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
const summaryMatch = review.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
|
|
2572
|
-
reviseDetails = summaryMatch
|
|
2573
|
-
? summaryMatch[1].trim().slice(0, 500)
|
|
2574
|
-
: "See review file for details.";
|
|
2575
|
-
}
|
|
2576
|
-
} else {
|
|
2577
|
-
verdict = "UNAVAILABLE";
|
|
2578
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2579
|
-
`${reviewType} review — fallback reviewer did not produce output`);
|
|
2580
|
-
}
|
|
2581
|
-
|
|
2582
|
-
logReview(statusPath, `R${num}`, reviewType, stepNum, verdict,
|
|
2583
|
-
`.reviews/R${num}-${reviewType}-step${stepNum}.md`);
|
|
2584
|
-
logExecution(statusPath, `Review R${num}`,
|
|
2585
|
-
`${reviewType} Step ${stepNum}: ${verdict} (fallback)`);
|
|
2586
|
-
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
|
+
);
|
|
2587
2594
|
|
|
2588
2595
|
clearReviewerState();
|
|
2589
2596
|
writeLaneState(state);
|
|
2590
2597
|
updateWidgets();
|
|
2591
2598
|
|
|
2592
|
-
let resultText: string;
|
|
2593
|
-
if (verdict === "APPROVE") {
|
|
2594
|
-
resultText = "APPROVE";
|
|
2595
|
-
} else if (verdict === "REVISE") {
|
|
2596
|
-
resultText = `REVISE: ${reviseDetails}\n\nFull review: .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2597
|
-
} else if (verdict === "RETHINK") {
|
|
2598
|
-
resultText = `RETHINK — reconsider your approach. See .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2599
|
-
} else {
|
|
2600
|
-
resultText = `UNAVAILABLE — reviewer did not produce a usable verdict.`;
|
|
2601
|
-
}
|
|
2602
|
-
|
|
2603
2599
|
return {
|
|
2604
2600
|
content: [{ type: "text" as const, text: resultText }],
|
|
2605
2601
|
details: undefined,
|
|
@@ -3021,6 +3017,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
3021
3017
|
: ` - Step ${s.number}: ${s.name} [already complete — skip]`
|
|
3022
3018
|
).join("\n");
|
|
3023
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
|
+
|
|
3024
3045
|
const prompt = [
|
|
3025
3046
|
`Execute all remaining steps for task ${task.taskId}.`,
|
|
3026
3047
|
``,
|
|
@@ -3031,7 +3052,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3031
3052
|
``,
|
|
3032
3053
|
`This is iteration ${state.totalIterations}.`,
|
|
3033
3054
|
`Read STATUS.md FIRST to find where you left off.`,
|
|
3034
|
-
|
|
3055
|
+
iterationNudge,
|
|
3035
3056
|
`Steps:`,
|
|
3036
3057
|
stepListing,
|
|
3037
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
|
+
}
|