taskplane 0.20.2 → 0.20.3

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.
@@ -1,18 +1,20 @@
1
1
  /**
2
- * Engine Worker Thread Entry Point (TP-071)
2
+ * Engine Child Process Entry Point (TP-071)
3
3
  *
4
4
  * This module serves two purposes:
5
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
6
+ * 2. When forked as a child process, runs the engine in a separate Node.js process
7
+ *
8
+ * Uses child_process.fork() instead of worker_threads because Node v25's
9
+ * default --experimental-strip-types rejects .ts files inside node_modules.
10
+ * Fork creates a new process where --experimental-transform-types takes effect.
7
11
  *
8
12
  * Communication:
9
- * - WorkerMain: postMessage for notify, monitor-update, engine-event, state-sync, complete, error
10
- * - MainWorker: postMessage for pause/resume/abort control
13
+ * - ChildParent: process.send() for notify, monitor-update, engine-event, state-sync, complete, error
14
+ * - ParentChild: child.send() for init, pause, resume, abort
11
15
  *
12
16
  * @module orch/engine-worker
13
17
  */
14
- import { parentPort, workerData, isMainThread } from "worker_threads";
15
-
16
18
  import type {
17
19
  EngineEvent,
18
20
  MonitorState,
@@ -186,104 +188,111 @@ export function applySerializedState(
186
188
  batchState.errors = [...serialized.errors];
187
189
  }
188
190
 
189
- // ── Worker main (only runs when loaded as a worker thread) ───────────
191
+ // ── Engine main (runs when launched as a forked child process) ───────
190
192
 
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");
193
+ // Guard: only run engine main when launched via fork() with the sentinel env var.
194
+ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "function") {
195
+ const send = (msg: WorkerToMainMessage) => process.send!(msg);
200
196
 
201
- const data = workerData as EngineWorkerData;
202
- const port = parentPort;
197
+ // Wait for the init message carrying workerData, then start the engine.
198
+ process.once("message", async (initMsg: { type: string; data: EngineWorkerData }) => {
199
+ if (initMsg?.type !== "init") return;
203
200
 
204
- // Create a fresh batch state for this worker
205
- const batchState: OrchBatchRuntimeState = freshOrchBatchState();
206
- batchState.phase = "launching";
207
- batchState.startedAt = Date.now();
201
+ // Dynamic imports only loaded in engine context to avoid circular
202
+ // dependencies when this module is imported from extension.ts
203
+ const { executeOrchBatch } = await import("./engine.ts");
204
+ const { resumeOrchBatch } = await import("./resume.ts");
205
+ const { freshOrchBatchState } = await import("./types.ts");
208
206
 
209
- // Deserialize workspace config
210
- const wsConfig = deserializeWorkspaceConfig(data.workspaceConfig);
207
+ const data = initMsg.data;
211
208
 
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
- });
209
+ // Create a fresh batch state for this process
210
+ const batchState: OrchBatchRuntimeState = freshOrchBatchState();
211
+ batchState.phase = "launching";
212
+ batchState.startedAt = Date.now();
228
213
 
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
- };
214
+ // Deserialize workspace config
215
+ const wsConfig = deserializeWorkspaceConfig(data.workspaceConfig);
235
216
 
236
- const onMonitorUpdate = (state: MonitorState) => {
237
- port.postMessage({ type: "monitor-update", state } satisfies WorkerToMainMessage);
238
- };
217
+ // ── Control signal listener ──────────────────────────────────
218
+ // Main process sends pause/resume/abort signals via IPC.
219
+ // We apply them to the in-process batchState.pauseSignal.
220
+ process.on("message", (msg: WorkerInMessage) => {
221
+ switch (msg.type) {
222
+ case "pause":
223
+ batchState.pauseSignal.paused = true;
224
+ break;
225
+ case "resume":
226
+ batchState.pauseSignal.paused = false;
227
+ break;
228
+ case "abort":
229
+ batchState.pauseSignal.paused = true;
230
+ break;
231
+ }
232
+ });
239
233
 
240
- const onEngineEvent = (event: EngineEvent) => {
241
- port.postMessage({ type: "engine-event", event } satisfies WorkerToMainMessage);
242
- };
234
+ // ── Callback factories (replace ctx-dependent callbacks) ─────
235
+ const onNotify = (message: string, level: "info" | "warning" | "error") => {
236
+ send({ type: "notify", msg: message, level });
237
+ // Sync batch state on every notify (lightweight — just the summary fields)
238
+ send({ type: "state-sync", state: serializeBatchState(batchState) });
239
+ };
243
240
 
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
- );
241
+ const onMonitorUpdate = (state: MonitorState) => {
242
+ send({ type: "monitor-update", state });
243
+ };
271
244
 
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
- });
245
+ const onEngineEvent = (event: EngineEvent) => {
246
+ send({ type: "engine-event", event });
247
+ };
248
+
249
+ // ── Execute engine ───────────────────────────────────────────
250
+ const enginePromise = data.mode === "resume"
251
+ ? resumeOrchBatch(
252
+ data.orchConfig,
253
+ data.runnerConfig,
254
+ data.cwd,
255
+ batchState,
256
+ onNotify,
257
+ onMonitorUpdate,
258
+ wsConfig,
259
+ data.workspaceRoot,
260
+ data.agentRoot,
261
+ data.force ?? false,
262
+ )
263
+ : executeOrchBatch(
264
+ data.args ?? "",
265
+ data.orchConfig,
266
+ data.runnerConfig,
267
+ data.cwd,
268
+ batchState,
269
+ onNotify,
270
+ onMonitorUpdate,
271
+ wsConfig,
272
+ data.workspaceRoot,
273
+ data.agentRoot,
274
+ onEngineEvent,
275
+ );
276
+
277
+ enginePromise
278
+ .then(() => {
279
+ // Final state sync + completion signal
280
+ const finalState = serializeBatchState(batchState);
281
+ send({ type: "complete", state: finalState });
282
+ // Disconnect IPC so the child process can exit cleanly
283
+ process.disconnect?.();
284
+ })
285
+ .catch((err: unknown) => {
286
+ const errMsg = err instanceof Error ? err.message : String(err);
287
+ // Ensure batch state reflects the failure
288
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
289
+ batchState.phase = "failed";
290
+ batchState.endedAt = Date.now();
291
+ batchState.errors.push(`Unhandled engine error: ${errMsg}`);
292
+ }
293
+ send({ type: "state-sync", state: serializeBatchState(batchState) });
294
+ send({ type: "error", message: errMsg });
295
+ process.disconnect?.();
296
+ });
297
+ });
289
298
  }
@@ -5,7 +5,7 @@ import { execSync, execFileSync } from "child_process";
5
5
  import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
6
6
  import { join, dirname } from "path";
7
7
  import { fileURLToPath } from "url";
8
- import { Worker } from "worker_threads";
8
+ import { fork, type ChildProcess } from "child_process";
9
9
 
10
10
  // Direct imports — avoid barrel (index.ts) to prevent loading the entire module graph.
11
11
  // Each import targets the specific module where the symbol is defined.
@@ -897,7 +897,7 @@ function resolveEngineWorkerPath(): string {
897
897
  } catch {
898
898
  thisDir = __dirname;
899
899
  }
900
- return join(thisDir, "engine-worker-entry.mjs");
900
+ return join(thisDir, "engine-worker.ts");
901
901
  }
902
902
 
903
903
  /**
@@ -935,19 +935,20 @@ export function startBatchInWorker(
935
935
  updateWidget: () => void,
936
936
  onMonitorUpdate?: (state: import("./types.ts").MonitorState) => void,
937
937
  onTerminal?: () => void,
938
- ): Worker | null {
938
+ ): ChildProcess | null {
939
939
  const workerPath = resolveEngineWorkerPath();
940
940
 
941
- let worker: Worker;
941
+ let child: ChildProcess;
942
942
  try {
943
- worker = new Worker(workerPath, {
944
- workerData: wkData,
943
+ child = fork(workerPath, [], {
945
944
  execArgv: ["--experimental-transform-types", "--no-warnings"],
945
+ env: { ...process.env, TASKPLANE_ENGINE_FORK: "1" },
946
+ serialization: "advanced",
946
947
  });
947
948
  } catch (spawnErr: unknown) {
948
949
  const errMsg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
949
950
  ctx.ui.notify(
950
- `⚠️ Worker thread spawn failed: ${errMsg}\n Falling back to main-thread execution.`,
951
+ `⚠️ Engine process spawn failed: ${errMsg}\n Falling back to main-thread execution.`,
951
952
  "warning",
952
953
  );
953
954
  // Construct fallback engine function from workerData and run on main thread
@@ -983,8 +984,11 @@ export function startBatchInWorker(
983
984
  return null;
984
985
  }
985
986
 
987
+ // Send workerData as first IPC message (fork doesn't have workerData)
988
+ child.send({ type: "init", data: wkData });
989
+
986
990
  // Terminal settlement guard (R001 §3): ensures onTerminal fires at most once.
987
- // The worker can emit error + complete messages, followed by exit event.
991
+ // The child can emit error + complete messages, followed by exit event.
988
992
  // Without this guard, summary/integration/supervisor flows would fire multiple times.
989
993
  let settled = false;
990
994
  const settle = () => {
@@ -993,7 +997,7 @@ export function startBatchInWorker(
993
997
  onTerminal?.();
994
998
  };
995
999
 
996
- worker.on("message", (msg: WorkerToMainMessage) => {
1000
+ child.on("message", (msg: WorkerToMainMessage) => {
997
1001
  switch (msg.type) {
998
1002
  case "notify":
999
1003
  ctx.ui.notify(msg.msg, msg.level);
@@ -1005,7 +1009,7 @@ export function startBatchInWorker(
1005
1009
  break;
1006
1010
 
1007
1011
  case "engine-event":
1008
- // Engine events are already persisted to events.jsonl by the worker.
1012
+ // Engine events are already persisted to events.jsonl by the child.
1009
1013
  // No additional handling needed on the main thread.
1010
1014
  break;
1011
1015
 
@@ -1036,15 +1040,15 @@ export function startBatchInWorker(
1036
1040
  }
1037
1041
  });
1038
1042
 
1039
- worker.on("error", (err: Error) => {
1040
- // Worker threw an uncaught exception
1043
+ child.on("error", (err: Error) => {
1044
+ // Child process encountered an error
1041
1045
  if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1042
1046
  batchState.phase = "failed";
1043
1047
  batchState.endedAt = Date.now();
1044
- batchState.errors.push(`Worker thread error: ${err.message}`);
1048
+ batchState.errors.push(`Engine process error: ${err.message}`);
1045
1049
  }
1046
1050
  ctx.ui.notify(
1047
- `❌ Engine worker thread error: ${err.message}\n` +
1051
+ `❌ Engine process error: ${err.message}\n` +
1048
1052
  ` Batch ${batchState.batchId} marked as failed.`,
1049
1053
  "error",
1050
1054
  );
@@ -1052,16 +1056,16 @@ export function startBatchInWorker(
1052
1056
  settle();
1053
1057
  });
1054
1058
 
1055
- worker.on("exit", (code: number) => {
1059
+ child.on("exit", (code: number | null) => {
1056
1060
  if (code !== 0 && !settled) {
1057
1061
  // Non-zero exit that wasn't handled by 'error' or 'complete'
1058
1062
  if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1059
1063
  batchState.phase = "failed";
1060
1064
  batchState.endedAt = Date.now();
1061
- batchState.errors.push(`Worker thread exited with code ${code}`);
1065
+ batchState.errors.push(`Engine process exited with code ${code}`);
1062
1066
  }
1063
1067
  ctx.ui.notify(
1064
- `❌ Engine worker thread exited unexpectedly (code ${code}).`,
1068
+ `❌ Engine process exited unexpectedly (code ${code}).`,
1065
1069
  "error",
1066
1070
  );
1067
1071
  updateWidget();
@@ -1387,9 +1391,9 @@ export default function (pi: ExtensionAPI) {
1387
1391
  let orchWidgetCtx: ExtensionContext | undefined;
1388
1392
  let latestMonitorState: MonitorState | null = null;
1389
1393
 
1390
- // ── TP-071: Active engine worker thread ──────────────────────────
1391
- // Tracked so pause/abort can post control messages to the worker.
1392
- let activeWorker: Worker | null = null;
1394
+ // ── TP-071: Active engine child process ──────────────────────────
1395
+ // Tracked so pause/abort can send control messages to the engine.
1396
+ let activeWorker: ChildProcess | null = null;
1393
1397
 
1394
1398
  // ── Supervisor State (TP-041) ────────────────────────────────────
1395
1399
  let supervisorState = freshSupervisorState();
@@ -2006,8 +2010,8 @@ export default function (pi: ExtensionAPI) {
2006
2010
  return ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId);
2007
2011
  }
2008
2012
  orchBatchState.pauseSignal.paused = true;
2009
- // TP-071: Forward pause to worker thread (its pauseSignal is separate)
2010
- activeWorker?.postMessage({ type: "pause" });
2013
+ // TP-071: Forward pause to engine process (its pauseSignal is separate)
2014
+ activeWorker?.send({ type: "pause" });
2011
2015
  updateOrchWidget();
2012
2016
  return ORCH_MESSAGES.pauseActivated(orchBatchState.batchId);
2013
2017
  }
@@ -2197,15 +2201,15 @@ export default function (pi: ExtensionAPI) {
2197
2201
  orchBatchState.pauseSignal.paused = true;
2198
2202
  messages.push(" ✓ Pause signal set on in-memory batch state");
2199
2203
  }
2200
- // TP-071: Forward pause to worker and terminate on hard abort
2204
+ // TP-071: Forward pause to engine and kill on hard abort
2201
2205
  if (activeWorker) {
2202
- activeWorker.postMessage({ type: "pause" });
2206
+ activeWorker.send({ type: "pause" });
2203
2207
  if (hard) {
2204
- activeWorker.terminate();
2208
+ activeWorker.kill();
2205
2209
  activeWorker = null;
2206
- messages.push(" ✓ Engine worker thread terminated (hard abort)");
2210
+ messages.push(" ✓ Engine process killed (hard abort)");
2207
2211
  } else {
2208
- messages.push(" ✓ Pause signal forwarded to engine worker thread");
2212
+ messages.push(" ✓ Pause signal forwarded to engine process");
2209
2213
  }
2210
2214
  }
2211
2215
 
@@ -3283,13 +3287,13 @@ export default function (pi: ExtensionAPI) {
3283
3287
  // Ensure supervisor lockfile/heartbeat are cleaned up on normal session exit.
3284
3288
  // This avoids leaving a live-looking lock when the process exits cleanly.
3285
3289
  pi.on("session_end", async () => {
3286
- // TP-071: Terminate engine worker thread on session exit
3290
+ // TP-071: Kill engine process on session exit
3287
3291
  if (activeWorker) {
3288
3292
  try {
3289
- activeWorker.terminate();
3293
+ activeWorker.kill();
3290
3294
  activeWorker = null;
3291
3295
  } catch {
3292
- // Best effort — worker may already be dead
3296
+ // Best effort — process may already be dead
3293
3297
  }
3294
3298
  }
3295
3299
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,10 +0,0 @@
1
- /**
2
- * Thin entry point for the engine worker thread.
3
- *
4
- * Node's Worker constructor rejects .ts files inside node_modules when
5
- * --experimental-strip-types is active (the default in Node v25+).
6
- * This .mjs wrapper avoids the restriction — Node loads .mjs files
7
- * without TypeScript processing, then --experimental-transform-types
8
- * (passed via execArgv) handles subsequent .ts imports.
9
- */
10
- import "./engine-worker.ts";