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.
@@ -3,50 +3,34 @@ import { Type } from "@mariozechner/pi-ai";
3
3
 
4
4
  import { execSync, execFileSync } from "child_process";
5
5
  import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
6
- import { join } from "path";
7
-
8
- import {
9
- DEFAULT_ORCHESTRATOR_CONFIG,
10
- DEFAULT_TASK_RUNNER_CONFIG,
11
- FATAL_DISCOVERY_CODES,
12
- ORCH_MESSAGES,
13
- StateFileError,
14
- WorkspaceConfigError,
15
- computeIntegrateCleanupResult,
16
- computeWaveAssignments,
17
- createOrchWidget,
18
- deleteBatchState,
19
- deleteStaleBranches,
20
- detectOrphanSessions,
21
- executeLane,
22
- executeOrchBatch,
23
- formatDependencyGraph,
24
- formatDiscoveryResults,
25
- formatOrchSessions,
26
- formatPreflightResults,
27
- formatWavePlan,
28
- freshOrchBatchState,
29
- getCurrentBranch,
30
- hasConfigFiles,
31
- resolveConfigRoot,
32
- listOrchSessions,
33
- listWorktrees,
34
- loadBatchState,
35
- loadOrchestratorConfig,
36
- loadSupervisorConfig,
37
- loadTaskRunnerConfig,
38
- parseOrchSessionNames,
39
- resolveOperatorId,
40
- resolveWorktreeBasePath,
41
- resumeOrchBatch,
42
- runDiscovery,
43
- runGit,
44
- runPreflight,
45
- } from "./index.ts";
6
+ import { join, dirname } from "path";
7
+ import { fileURLToPath } from "url";
8
+ import { Worker } from "worker_threads";
9
+
10
+ // Direct imports — avoid barrel (index.ts) to prevent loading the entire module graph.
11
+ // Each import targets the specific module where the symbol is defined.
12
+ import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG, FATAL_DISCOVERY_CODES, StateFileError, WorkspaceConfigError, freshOrchBatchState } from "./types.ts";
13
+ import type { AbortMode, ExecutionContext, MonitorState, OrchestratorConfig, PersistedBatchState, TaskRunnerConfig } from "./types.ts";
14
+ import { ORCH_MESSAGES, computeIntegrateCleanupResult } from "./messages.ts";
15
+ import type { IntegrateCleanupRepoFindings } from "./messages.ts";
16
+ import { computeWaveAssignments } from "./waves.ts";
17
+ import { createOrchWidget, formatDependencyGraph, formatWavePlan } from "./formatting.ts";
18
+ import { deleteBatchState, loadBatchState, detectOrphanSessions, parseOrchSessionNames } from "./persistence.ts";
19
+ import { deleteStaleBranches, listWorktrees, resolveWorktreeBasePath, formatPreflightResults, runPreflight } from "./worktree.ts";
20
+ import { executeLane } from "./execution.ts";
21
+ import { executeOrchBatch } from "./engine.ts";
22
+ import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
23
+ import { formatOrchSessions, listOrchSessions } from "./sessions.ts";
24
+ import { getCurrentBranch, runGit } from "./git.ts";
25
+ import { hasConfigFiles, resolveConfigRoot, loadOrchestratorConfig, loadSupervisorConfig, loadTaskRunnerConfig } from "./config.ts";
26
+ import { resolveOperatorId } from "./naming.ts";
27
+ import { resumeOrchBatch } from "./resume.ts";
46
28
  import { buildExecutionContext } from "./workspace.ts";
47
29
  import { openSettingsTui } from "./settings-tui.ts";
48
30
  import { loadProjectConfig } from "./config-loader.ts";
49
31
  import { runMigrations } from "./migrations.ts";
32
+ import { serializeWorkspaceConfig, applySerializedState, deserializeWorkspaceConfig } from "./engine-worker.ts";
33
+ import type { EngineWorkerData, WorkerToMainMessage } from "./engine-worker.ts";
50
34
  import { cleanupPostIntegrate, formatPostIntegrateCleanup, sweepStaleArtifacts, formatPreflightSweep, rotateSupervisorLogs, formatLogRotation } from "./cleanup.ts";
51
35
  import {
52
36
  activateSupervisor,
@@ -64,15 +48,6 @@ import {
64
48
  resolveModelFromString,
65
49
  } from "./supervisor.ts";
66
50
  import type { SupervisorConfig, SupervisorRoutingContext, IntegrationExecutor, CiDeps, SummaryDeps } from "./supervisor.ts";
67
- import type {
68
- AbortMode,
69
- ExecutionContext,
70
- IntegrateCleanupRepoFindings,
71
- MonitorState,
72
- OrchestratorConfig,
73
- PersistedBatchState,
74
- TaskRunnerConfig,
75
- } from "./index.ts";
76
51
 
77
52
  // ── Integrate Args Parsing ────────────────────────────────────────────
78
53
 
@@ -905,6 +880,196 @@ export function startBatchAsync(
905
880
  }, 0);
906
881
  }
907
882
 
883
+ // ── TP-071: Engine Worker Thread ─────────────────────────────────────
884
+
885
+ /**
886
+ * Resolve the absolute path to engine-worker.ts for Worker thread spawning.
887
+ *
888
+ * Uses import.meta.url to locate the file relative to this extension module.
889
+ * Falls back to __dirname for environments where import.meta.url is unavailable.
890
+ *
891
+ * @since TP-071
892
+ */
893
+ function resolveEngineWorkerPath(): string {
894
+ let thisDir: string;
895
+ try {
896
+ thisDir = dirname(fileURLToPath(import.meta.url));
897
+ } catch {
898
+ thisDir = __dirname;
899
+ }
900
+ return join(thisDir, "engine-worker.ts");
901
+ }
902
+
903
+ /**
904
+ * Launch the engine batch in a worker thread.
905
+ *
906
+ * Replaces `startBatchAsync()` for the worker-thread execution model (TP-071).
907
+ * The engine runs in a separate V8 isolate, keeping the main thread free
908
+ * for TUI interaction and supervisor agent LLM calls.
909
+ *
910
+ * If the worker fails to spawn (e.g., TypeScript not supported in workers),
911
+ * falls back to main-thread execution via `startBatchAsync()`.
912
+ *
913
+ * Communication:
914
+ * - Worker → Main: postMessage for notify, monitor-update, engine-event, state-sync, complete
915
+ * - Main → Worker: postMessage for pause/unpause control
916
+ *
917
+ * Terminal idempotency: a `settled` flag ensures that only the first terminal
918
+ * path (complete message, error event, or non-zero exit) triggers `onTerminal`.
919
+ * This prevents duplicate summary/integration/supervisor flows (R001 §3).
920
+ *
921
+ * @param wkData Serialized engine configuration and parameters
922
+ * @param batchState Main thread's batch state (updated via state-sync messages)
923
+ * @param ctx Extension context for UI notifications
924
+ * @param updateWidget Widget refresh callback
925
+ * @param onMonitorUpdate Optional callback for dashboard monitor updates
926
+ * @param onTerminal Callback when engine reaches terminal state
927
+ * @returns The Worker instance (for pause/abort control), or null if fallback was used
928
+ *
929
+ * @since TP-071
930
+ */
931
+ export function startBatchInWorker(
932
+ wkData: EngineWorkerData,
933
+ batchState: import("./types.ts").OrchBatchRuntimeState,
934
+ ctx: ExtensionContext,
935
+ updateWidget: () => void,
936
+ onMonitorUpdate?: (state: import("./types.ts").MonitorState) => void,
937
+ onTerminal?: () => void,
938
+ ): Worker | null {
939
+ const workerPath = resolveEngineWorkerPath();
940
+
941
+ let worker: Worker;
942
+ try {
943
+ worker = new Worker(workerPath, { workerData: wkData });
944
+ } catch (spawnErr: unknown) {
945
+ const errMsg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
946
+ ctx.ui.notify(
947
+ `⚠️ Worker thread spawn failed: ${errMsg}\n Falling back to main-thread execution.`,
948
+ "warning",
949
+ );
950
+ // Construct fallback engine function from workerData and run on main thread
951
+ const wsConfig = wkData.workspaceConfig
952
+ ? deserializeWorkspaceConfig(wkData.workspaceConfig)
953
+ : undefined;
954
+ const fallbackFn = wkData.mode === "resume"
955
+ ? () => resumeOrchBatch(
956
+ wkData.orchConfig,
957
+ wkData.runnerConfig,
958
+ wkData.cwd,
959
+ batchState,
960
+ (msg: string, lvl: "info" | "warning" | "error") => { ctx.ui.notify(msg, lvl); updateWidget(); },
961
+ (monState: import("./types.ts").MonitorState) => { onMonitorUpdate?.(monState); },
962
+ wsConfig,
963
+ wkData.workspaceRoot,
964
+ wkData.agentRoot,
965
+ wkData.force ?? false,
966
+ )
967
+ : () => executeOrchBatch(
968
+ wkData.args ?? "",
969
+ wkData.orchConfig,
970
+ wkData.runnerConfig,
971
+ wkData.cwd,
972
+ batchState,
973
+ (msg: string, lvl: "info" | "warning" | "error") => { ctx.ui.notify(msg, lvl); updateWidget(); },
974
+ (monState: import("./types.ts").MonitorState) => { onMonitorUpdate?.(monState); },
975
+ wsConfig,
976
+ wkData.workspaceRoot,
977
+ wkData.agentRoot,
978
+ );
979
+ startBatchAsync(fallbackFn, batchState, ctx, updateWidget, onTerminal);
980
+ return null;
981
+ }
982
+
983
+ // Terminal settlement guard (R001 §3): ensures onTerminal fires at most once.
984
+ // The worker can emit error + complete messages, followed by exit event.
985
+ // Without this guard, summary/integration/supervisor flows would fire multiple times.
986
+ let settled = false;
987
+ const settle = () => {
988
+ if (settled) return;
989
+ settled = true;
990
+ onTerminal?.();
991
+ };
992
+
993
+ worker.on("message", (msg: WorkerToMainMessage) => {
994
+ switch (msg.type) {
995
+ case "notify":
996
+ ctx.ui.notify(msg.msg, msg.level);
997
+ updateWidget();
998
+ break;
999
+
1000
+ case "monitor-update":
1001
+ onMonitorUpdate?.(msg.state);
1002
+ break;
1003
+
1004
+ case "engine-event":
1005
+ // Engine events are already persisted to events.jsonl by the worker.
1006
+ // No additional handling needed on the main thread.
1007
+ break;
1008
+
1009
+ case "state-sync":
1010
+ applySerializedState(batchState, msg.state);
1011
+ updateWidget();
1012
+ break;
1013
+
1014
+ case "complete":
1015
+ applySerializedState(batchState, msg.state);
1016
+ updateWidget();
1017
+ settle();
1018
+ break;
1019
+
1020
+ case "error":
1021
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1022
+ batchState.phase = "failed";
1023
+ batchState.endedAt = Date.now();
1024
+ batchState.errors.push(`Unhandled engine error: ${msg.message}`);
1025
+ }
1026
+ ctx.ui.notify(
1027
+ `❌ Engine crashed with unhandled error: ${msg.message}\n` +
1028
+ ` Batch ${batchState.batchId} marked as failed.`,
1029
+ "error",
1030
+ );
1031
+ updateWidget();
1032
+ break;
1033
+ }
1034
+ });
1035
+
1036
+ worker.on("error", (err: Error) => {
1037
+ // Worker threw an uncaught exception
1038
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1039
+ batchState.phase = "failed";
1040
+ batchState.endedAt = Date.now();
1041
+ batchState.errors.push(`Worker thread error: ${err.message}`);
1042
+ }
1043
+ ctx.ui.notify(
1044
+ `❌ Engine worker thread error: ${err.message}\n` +
1045
+ ` Batch ${batchState.batchId} marked as failed.`,
1046
+ "error",
1047
+ );
1048
+ updateWidget();
1049
+ settle();
1050
+ });
1051
+
1052
+ worker.on("exit", (code: number) => {
1053
+ if (code !== 0 && !settled) {
1054
+ // Non-zero exit that wasn't handled by 'error' or 'complete'
1055
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1056
+ batchState.phase = "failed";
1057
+ batchState.endedAt = Date.now();
1058
+ batchState.errors.push(`Worker thread exited with code ${code}`);
1059
+ }
1060
+ ctx.ui.notify(
1061
+ `❌ Engine worker thread exited unexpectedly (code ${code}).`,
1062
+ "error",
1063
+ );
1064
+ updateWidget();
1065
+ }
1066
+ // Always settle on exit — ensures cleanup runs even on clean exit
1067
+ settle();
1068
+ });
1069
+
1070
+ return worker;
1071
+ }
1072
+
908
1073
  // ── TP-043 R002-2: Integration Executor Builder ─────────────────────
909
1074
 
910
1075
  /**
@@ -1219,6 +1384,10 @@ export default function (pi: ExtensionAPI) {
1219
1384
  let orchWidgetCtx: ExtensionContext | undefined;
1220
1385
  let latestMonitorState: MonitorState | null = null;
1221
1386
 
1387
+ // ── TP-071: Active engine worker thread ──────────────────────────
1388
+ // Tracked so pause/abort can post control messages to the worker.
1389
+ let activeWorker: Worker | null = null;
1390
+
1222
1391
  // ── Supervisor State (TP-041) ────────────────────────────────────
1223
1392
  let supervisorState = freshSupervisorState();
1224
1393
  let supervisorConfig: SupervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG };
@@ -1620,37 +1789,36 @@ export default function (pi: ExtensionAPI) {
1620
1789
  orchBatchState.startedAt = Date.now();
1621
1790
  updateOrchWidget();
1622
1791
 
1623
- // Non-blocking engine launch
1624
- startBatchAsync(
1625
- () => executeOrchBatch(
1626
- trimmedTarget,
1792
+ // Non-blocking engine launch in worker thread (TP-071)
1793
+ activeWorker = startBatchInWorker(
1794
+ {
1795
+ engineWorker: true,
1796
+ mode: "execute",
1797
+ args: trimmedTarget,
1627
1798
  orchConfig,
1628
1799
  runnerConfig,
1629
- repoRoot,
1630
- orchBatchState,
1631
- (message, level) => {
1632
- ctx.ui.notify(message, level);
1633
- updateOrchWidget();
1634
- },
1635
- (monState: MonitorState) => {
1636
- const changed = !latestMonitorState ||
1637
- latestMonitorState.totalDone !== monState.totalDone ||
1638
- latestMonitorState.totalFailed !== monState.totalFailed ||
1639
- latestMonitorState.lanes.some((l, i) =>
1640
- l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
1641
- l.currentStep !== monState.lanes[i]?.currentStep ||
1642
- l.completedChecks !== monState.lanes[i]?.completedChecks,
1643
- );
1644
- latestMonitorState = monState;
1645
- if (changed) updateOrchWidget();
1646
- },
1647
- execCtx!.workspaceConfig,
1648
- execCtx!.workspaceRoot,
1649
- execCtx!.pointer?.agentRoot,
1650
- ),
1800
+ cwd: repoRoot,
1801
+ workspaceConfig: execCtx!.workspaceConfig
1802
+ ? serializeWorkspaceConfig(execCtx!.workspaceConfig)
1803
+ : null,
1804
+ workspaceRoot: execCtx!.workspaceRoot,
1805
+ agentRoot: execCtx!.pointer?.agentRoot,
1806
+ },
1651
1807
  orchBatchState,
1652
1808
  ctx,
1653
1809
  updateOrchWidget,
1810
+ (monState: MonitorState) => {
1811
+ const changed = !latestMonitorState ||
1812
+ latestMonitorState.totalDone !== monState.totalDone ||
1813
+ latestMonitorState.totalFailed !== monState.totalFailed ||
1814
+ latestMonitorState.lanes.some((l, i) =>
1815
+ l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
1816
+ l.currentStep !== monState.lanes[i]?.currentStep ||
1817
+ l.completedChecks !== monState.lanes[i]?.completedChecks,
1818
+ );
1819
+ latestMonitorState = monState;
1820
+ if (changed) updateOrchWidget();
1821
+ },
1654
1822
  () => {
1655
1823
  const mode = orchConfig.orchestrator.integration;
1656
1824
  const opId = resolveOperatorId(orchConfig);
@@ -1723,6 +1891,33 @@ export default function (pi: ExtensionAPI) {
1723
1891
  };
1724
1892
  transitionToRoutingMode(pi, supervisorState, postBatchContext);
1725
1893
  },
1894
+ // Fallback: run engine on main thread if worker spawn fails (R001 §1)
1895
+ () => executeOrchBatch(
1896
+ trimmedTarget,
1897
+ orchConfig,
1898
+ runnerConfig,
1899
+ repoRoot,
1900
+ orchBatchState,
1901
+ (message, level) => {
1902
+ ctx.ui.notify(message, level);
1903
+ updateOrchWidget();
1904
+ },
1905
+ (monState: MonitorState) => {
1906
+ const changed = !latestMonitorState ||
1907
+ latestMonitorState.totalDone !== monState.totalDone ||
1908
+ latestMonitorState.totalFailed !== monState.totalFailed ||
1909
+ latestMonitorState.lanes.some((l, i) =>
1910
+ l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
1911
+ l.currentStep !== monState.lanes[i]?.currentStep ||
1912
+ l.completedChecks !== monState.lanes[i]?.completedChecks,
1913
+ );
1914
+ latestMonitorState = monState;
1915
+ if (changed) updateOrchWidget();
1916
+ },
1917
+ execCtx!.workspaceConfig,
1918
+ execCtx!.workspaceRoot,
1919
+ execCtx!.pointer?.agentRoot,
1920
+ ),
1726
1921
  );
1727
1922
 
1728
1923
  // Activate supervisor agent
@@ -1808,13 +2003,15 @@ export default function (pi: ExtensionAPI) {
1808
2003
  return ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId);
1809
2004
  }
1810
2005
  orchBatchState.pauseSignal.paused = true;
2006
+ // TP-071: Forward pause to worker thread (its pauseSignal is separate)
2007
+ activeWorker?.postMessage({ type: "pause" });
1811
2008
  updateOrchWidget();
1812
2009
  return ORCH_MESSAGES.pauseActivated(orchBatchState.batchId);
1813
2010
  }
1814
2011
 
1815
2012
  /**
1816
2013
  * Core logic for orch-resume. Returns an immediate status message.
1817
- * The actual batch resume runs asynchronously via startBatchAsync.
2014
+ * The actual batch resume runs asynchronously via startBatchInWorker (TP-071).
1818
2015
  * Returns null if execCtx is missing (caller must handle).
1819
2016
  */
1820
2017
  function doOrchResume(force: boolean, ctx: ExtensionContext): { message: string; error?: boolean } {
@@ -1841,29 +2038,29 @@ export default function (pi: ExtensionAPI) {
1841
2038
  orchBatchState.startedAt = Date.now();
1842
2039
  updateOrchWidget();
1843
2040
 
1844
- // Fire-and-forget resume via startBatchAsync
1845
- startBatchAsync(
1846
- () => resumeOrchBatch(
2041
+ // Fire-and-forget resume via worker thread (TP-071)
2042
+ activeWorker = startBatchInWorker(
2043
+ {
2044
+ engineWorker: true,
2045
+ mode: "resume",
2046
+ args: "",
1847
2047
  orchConfig,
1848
2048
  runnerConfig,
1849
- execCtx!.repoRoot,
1850
- orchBatchState,
1851
- (message, level) => {
1852
- ctx.ui.notify(message, level);
1853
- updateOrchWidget();
1854
- },
1855
- (monState: MonitorState) => {
1856
- latestMonitorState = monState;
1857
- updateOrchWidget();
1858
- },
1859
- execCtx!.workspaceConfig,
1860
- execCtx!.workspaceRoot,
1861
- execCtx!.pointer?.agentRoot,
2049
+ cwd: execCtx!.repoRoot,
2050
+ workspaceConfig: execCtx!.workspaceConfig
2051
+ ? serializeWorkspaceConfig(execCtx!.workspaceConfig)
2052
+ : null,
2053
+ workspaceRoot: execCtx!.workspaceRoot,
2054
+ agentRoot: execCtx!.pointer?.agentRoot,
1862
2055
  force,
1863
- ),
2056
+ },
1864
2057
  orchBatchState,
1865
2058
  ctx,
1866
2059
  updateOrchWidget,
2060
+ (monState: MonitorState) => {
2061
+ latestMonitorState = monState;
2062
+ updateOrchWidget();
2063
+ },
1867
2064
  () => {
1868
2065
  const mode = orchConfig.orchestrator.integration;
1869
2066
  const opId = resolveOperatorId(orchConfig);
@@ -1936,6 +2133,25 @@ export default function (pi: ExtensionAPI) {
1936
2133
  };
1937
2134
  transitionToRoutingMode(pi, supervisorState, postBatchContext);
1938
2135
  },
2136
+ // Fallback: run resume on main thread if worker spawn fails (R001 §1)
2137
+ () => resumeOrchBatch(
2138
+ orchConfig,
2139
+ runnerConfig,
2140
+ execCtx!.repoRoot,
2141
+ orchBatchState,
2142
+ (message, level) => {
2143
+ ctx.ui.notify(message, level);
2144
+ updateOrchWidget();
2145
+ },
2146
+ (monState: MonitorState) => {
2147
+ latestMonitorState = monState;
2148
+ updateOrchWidget();
2149
+ },
2150
+ execCtx!.workspaceConfig,
2151
+ execCtx!.workspaceRoot,
2152
+ execCtx!.pointer?.agentRoot,
2153
+ force,
2154
+ ),
1939
2155
  );
1940
2156
 
1941
2157
  // Activate supervisor agent on resume
@@ -1973,11 +2189,22 @@ export default function (pi: ExtensionAPI) {
1973
2189
  messages.push(` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`);
1974
2190
  }
1975
2191
 
1976
- // Step 2: Set pause signal
2192
+ // Step 2: Set pause signal and forward to worker
1977
2193
  if (orchBatchState.pauseSignal) {
1978
2194
  orchBatchState.pauseSignal.paused = true;
1979
2195
  messages.push(" ✓ Pause signal set on in-memory batch state");
1980
2196
  }
2197
+ // TP-071: Forward pause to worker and terminate on hard abort
2198
+ if (activeWorker) {
2199
+ activeWorker.postMessage({ type: "pause" });
2200
+ if (hard) {
2201
+ activeWorker.terminate();
2202
+ activeWorker = null;
2203
+ messages.push(" ✓ Engine worker thread terminated (hard abort)");
2204
+ } else {
2205
+ messages.push(" ✓ Pause signal forwarded to engine worker thread");
2206
+ }
2207
+ }
1981
2208
 
1982
2209
  // Step 3: Check what we're aborting
1983
2210
  const hasActiveBatch = orchBatchState.phase !== "idle" &&
@@ -3053,6 +3280,15 @@ export default function (pi: ExtensionAPI) {
3053
3280
  // Ensure supervisor lockfile/heartbeat are cleaned up on normal session exit.
3054
3281
  // This avoids leaving a live-looking lock when the process exits cleanly.
3055
3282
  pi.on("session_end", async () => {
3283
+ // TP-071: Terminate engine worker thread on session exit
3284
+ if (activeWorker) {
3285
+ try {
3286
+ activeWorker.terminate();
3287
+ activeWorker = null;
3288
+ } catch {
3289
+ // Best effort — worker may already be dead
3290
+ }
3291
+ }
3056
3292
  try {
3057
3293
  await deactivateSupervisor(pi, supervisorState);
3058
3294
  } catch {