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.
@@ -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 from review output
2464
- let verdict = "UNKNOWN";
2465
- let reviseDetails = "";
2466
- if (reviewContent) {
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
- let verdict = "UNKNOWN";
2566
- let reviseDetails = "";
2567
- if (existsSync(outputPath)) {
2568
- const review = readFileSync(outputPath, "utf-8");
2569
- verdict = extractVerdict(review);
2570
- if (verdict === "REVISE") {
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
+ }