taskplane 0.19.0 → 0.20.1

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,199 @@ 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, {
944
+ workerData: wkData,
945
+ execArgv: ["--experimental-transform-types", "--no-warnings"],
946
+ });
947
+ } catch (spawnErr: unknown) {
948
+ const errMsg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
949
+ ctx.ui.notify(
950
+ `⚠️ Worker thread spawn failed: ${errMsg}\n Falling back to main-thread execution.`,
951
+ "warning",
952
+ );
953
+ // Construct fallback engine function from workerData and run on main thread
954
+ const wsConfig = wkData.workspaceConfig
955
+ ? deserializeWorkspaceConfig(wkData.workspaceConfig)
956
+ : undefined;
957
+ const fallbackFn = wkData.mode === "resume"
958
+ ? () => resumeOrchBatch(
959
+ wkData.orchConfig,
960
+ wkData.runnerConfig,
961
+ wkData.cwd,
962
+ batchState,
963
+ (msg: string, lvl: "info" | "warning" | "error") => { ctx.ui.notify(msg, lvl); updateWidget(); },
964
+ (monState: import("./types.ts").MonitorState) => { onMonitorUpdate?.(monState); },
965
+ wsConfig,
966
+ wkData.workspaceRoot,
967
+ wkData.agentRoot,
968
+ wkData.force ?? false,
969
+ )
970
+ : () => executeOrchBatch(
971
+ wkData.args ?? "",
972
+ wkData.orchConfig,
973
+ wkData.runnerConfig,
974
+ wkData.cwd,
975
+ batchState,
976
+ (msg: string, lvl: "info" | "warning" | "error") => { ctx.ui.notify(msg, lvl); updateWidget(); },
977
+ (monState: import("./types.ts").MonitorState) => { onMonitorUpdate?.(monState); },
978
+ wsConfig,
979
+ wkData.workspaceRoot,
980
+ wkData.agentRoot,
981
+ );
982
+ startBatchAsync(fallbackFn, batchState, ctx, updateWidget, onTerminal);
983
+ return null;
984
+ }
985
+
986
+ // Terminal settlement guard (R001 §3): ensures onTerminal fires at most once.
987
+ // The worker can emit error + complete messages, followed by exit event.
988
+ // Without this guard, summary/integration/supervisor flows would fire multiple times.
989
+ let settled = false;
990
+ const settle = () => {
991
+ if (settled) return;
992
+ settled = true;
993
+ onTerminal?.();
994
+ };
995
+
996
+ worker.on("message", (msg: WorkerToMainMessage) => {
997
+ switch (msg.type) {
998
+ case "notify":
999
+ ctx.ui.notify(msg.msg, msg.level);
1000
+ updateWidget();
1001
+ break;
1002
+
1003
+ case "monitor-update":
1004
+ onMonitorUpdate?.(msg.state);
1005
+ break;
1006
+
1007
+ case "engine-event":
1008
+ // Engine events are already persisted to events.jsonl by the worker.
1009
+ // No additional handling needed on the main thread.
1010
+ break;
1011
+
1012
+ case "state-sync":
1013
+ applySerializedState(batchState, msg.state);
1014
+ updateWidget();
1015
+ break;
1016
+
1017
+ case "complete":
1018
+ applySerializedState(batchState, msg.state);
1019
+ updateWidget();
1020
+ settle();
1021
+ break;
1022
+
1023
+ case "error":
1024
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1025
+ batchState.phase = "failed";
1026
+ batchState.endedAt = Date.now();
1027
+ batchState.errors.push(`Unhandled engine error: ${msg.message}`);
1028
+ }
1029
+ ctx.ui.notify(
1030
+ `❌ Engine crashed with unhandled error: ${msg.message}\n` +
1031
+ ` Batch ${batchState.batchId} marked as failed.`,
1032
+ "error",
1033
+ );
1034
+ updateWidget();
1035
+ break;
1036
+ }
1037
+ });
1038
+
1039
+ worker.on("error", (err: Error) => {
1040
+ // Worker threw an uncaught exception
1041
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1042
+ batchState.phase = "failed";
1043
+ batchState.endedAt = Date.now();
1044
+ batchState.errors.push(`Worker thread error: ${err.message}`);
1045
+ }
1046
+ ctx.ui.notify(
1047
+ `❌ Engine worker thread error: ${err.message}\n` +
1048
+ ` Batch ${batchState.batchId} marked as failed.`,
1049
+ "error",
1050
+ );
1051
+ updateWidget();
1052
+ settle();
1053
+ });
1054
+
1055
+ worker.on("exit", (code: number) => {
1056
+ if (code !== 0 && !settled) {
1057
+ // Non-zero exit that wasn't handled by 'error' or 'complete'
1058
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1059
+ batchState.phase = "failed";
1060
+ batchState.endedAt = Date.now();
1061
+ batchState.errors.push(`Worker thread exited with code ${code}`);
1062
+ }
1063
+ ctx.ui.notify(
1064
+ `❌ Engine worker thread exited unexpectedly (code ${code}).`,
1065
+ "error",
1066
+ );
1067
+ updateWidget();
1068
+ }
1069
+ // Always settle on exit — ensures cleanup runs even on clean exit
1070
+ settle();
1071
+ });
1072
+
1073
+ return worker;
1074
+ }
1075
+
908
1076
  // ── TP-043 R002-2: Integration Executor Builder ─────────────────────
909
1077
 
910
1078
  /**
@@ -1219,6 +1387,10 @@ export default function (pi: ExtensionAPI) {
1219
1387
  let orchWidgetCtx: ExtensionContext | undefined;
1220
1388
  let latestMonitorState: MonitorState | null = null;
1221
1389
 
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;
1393
+
1222
1394
  // ── Supervisor State (TP-041) ────────────────────────────────────
1223
1395
  let supervisorState = freshSupervisorState();
1224
1396
  let supervisorConfig: SupervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG };
@@ -1620,37 +1792,36 @@ export default function (pi: ExtensionAPI) {
1620
1792
  orchBatchState.startedAt = Date.now();
1621
1793
  updateOrchWidget();
1622
1794
 
1623
- // Non-blocking engine launch
1624
- startBatchAsync(
1625
- () => executeOrchBatch(
1626
- trimmedTarget,
1795
+ // Non-blocking engine launch in worker thread (TP-071)
1796
+ activeWorker = startBatchInWorker(
1797
+ {
1798
+ engineWorker: true,
1799
+ mode: "execute",
1800
+ args: trimmedTarget,
1627
1801
  orchConfig,
1628
1802
  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
- ),
1803
+ cwd: repoRoot,
1804
+ workspaceConfig: execCtx!.workspaceConfig
1805
+ ? serializeWorkspaceConfig(execCtx!.workspaceConfig)
1806
+ : null,
1807
+ workspaceRoot: execCtx!.workspaceRoot,
1808
+ agentRoot: execCtx!.pointer?.agentRoot,
1809
+ },
1651
1810
  orchBatchState,
1652
1811
  ctx,
1653
1812
  updateOrchWidget,
1813
+ (monState: MonitorState) => {
1814
+ const changed = !latestMonitorState ||
1815
+ latestMonitorState.totalDone !== monState.totalDone ||
1816
+ latestMonitorState.totalFailed !== monState.totalFailed ||
1817
+ latestMonitorState.lanes.some((l, i) =>
1818
+ l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
1819
+ l.currentStep !== monState.lanes[i]?.currentStep ||
1820
+ l.completedChecks !== monState.lanes[i]?.completedChecks,
1821
+ );
1822
+ latestMonitorState = monState;
1823
+ if (changed) updateOrchWidget();
1824
+ },
1654
1825
  () => {
1655
1826
  const mode = orchConfig.orchestrator.integration;
1656
1827
  const opId = resolveOperatorId(orchConfig);
@@ -1723,6 +1894,33 @@ export default function (pi: ExtensionAPI) {
1723
1894
  };
1724
1895
  transitionToRoutingMode(pi, supervisorState, postBatchContext);
1725
1896
  },
1897
+ // Fallback: run engine on main thread if worker spawn fails (R001 §1)
1898
+ () => executeOrchBatch(
1899
+ trimmedTarget,
1900
+ orchConfig,
1901
+ runnerConfig,
1902
+ repoRoot,
1903
+ orchBatchState,
1904
+ (message, level) => {
1905
+ ctx.ui.notify(message, level);
1906
+ updateOrchWidget();
1907
+ },
1908
+ (monState: MonitorState) => {
1909
+ const changed = !latestMonitorState ||
1910
+ latestMonitorState.totalDone !== monState.totalDone ||
1911
+ latestMonitorState.totalFailed !== monState.totalFailed ||
1912
+ latestMonitorState.lanes.some((l, i) =>
1913
+ l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
1914
+ l.currentStep !== monState.lanes[i]?.currentStep ||
1915
+ l.completedChecks !== monState.lanes[i]?.completedChecks,
1916
+ );
1917
+ latestMonitorState = monState;
1918
+ if (changed) updateOrchWidget();
1919
+ },
1920
+ execCtx!.workspaceConfig,
1921
+ execCtx!.workspaceRoot,
1922
+ execCtx!.pointer?.agentRoot,
1923
+ ),
1726
1924
  );
1727
1925
 
1728
1926
  // Activate supervisor agent
@@ -1808,13 +2006,15 @@ export default function (pi: ExtensionAPI) {
1808
2006
  return ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId);
1809
2007
  }
1810
2008
  orchBatchState.pauseSignal.paused = true;
2009
+ // TP-071: Forward pause to worker thread (its pauseSignal is separate)
2010
+ activeWorker?.postMessage({ type: "pause" });
1811
2011
  updateOrchWidget();
1812
2012
  return ORCH_MESSAGES.pauseActivated(orchBatchState.batchId);
1813
2013
  }
1814
2014
 
1815
2015
  /**
1816
2016
  * Core logic for orch-resume. Returns an immediate status message.
1817
- * The actual batch resume runs asynchronously via startBatchAsync.
2017
+ * The actual batch resume runs asynchronously via startBatchInWorker (TP-071).
1818
2018
  * Returns null if execCtx is missing (caller must handle).
1819
2019
  */
1820
2020
  function doOrchResume(force: boolean, ctx: ExtensionContext): { message: string; error?: boolean } {
@@ -1841,29 +2041,29 @@ export default function (pi: ExtensionAPI) {
1841
2041
  orchBatchState.startedAt = Date.now();
1842
2042
  updateOrchWidget();
1843
2043
 
1844
- // Fire-and-forget resume via startBatchAsync
1845
- startBatchAsync(
1846
- () => resumeOrchBatch(
2044
+ // Fire-and-forget resume via worker thread (TP-071)
2045
+ activeWorker = startBatchInWorker(
2046
+ {
2047
+ engineWorker: true,
2048
+ mode: "resume",
2049
+ args: "",
1847
2050
  orchConfig,
1848
2051
  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,
2052
+ cwd: execCtx!.repoRoot,
2053
+ workspaceConfig: execCtx!.workspaceConfig
2054
+ ? serializeWorkspaceConfig(execCtx!.workspaceConfig)
2055
+ : null,
2056
+ workspaceRoot: execCtx!.workspaceRoot,
2057
+ agentRoot: execCtx!.pointer?.agentRoot,
1862
2058
  force,
1863
- ),
2059
+ },
1864
2060
  orchBatchState,
1865
2061
  ctx,
1866
2062
  updateOrchWidget,
2063
+ (monState: MonitorState) => {
2064
+ latestMonitorState = monState;
2065
+ updateOrchWidget();
2066
+ },
1867
2067
  () => {
1868
2068
  const mode = orchConfig.orchestrator.integration;
1869
2069
  const opId = resolveOperatorId(orchConfig);
@@ -1936,6 +2136,25 @@ export default function (pi: ExtensionAPI) {
1936
2136
  };
1937
2137
  transitionToRoutingMode(pi, supervisorState, postBatchContext);
1938
2138
  },
2139
+ // Fallback: run resume on main thread if worker spawn fails (R001 §1)
2140
+ () => resumeOrchBatch(
2141
+ orchConfig,
2142
+ runnerConfig,
2143
+ execCtx!.repoRoot,
2144
+ orchBatchState,
2145
+ (message, level) => {
2146
+ ctx.ui.notify(message, level);
2147
+ updateOrchWidget();
2148
+ },
2149
+ (monState: MonitorState) => {
2150
+ latestMonitorState = monState;
2151
+ updateOrchWidget();
2152
+ },
2153
+ execCtx!.workspaceConfig,
2154
+ execCtx!.workspaceRoot,
2155
+ execCtx!.pointer?.agentRoot,
2156
+ force,
2157
+ ),
1939
2158
  );
1940
2159
 
1941
2160
  // Activate supervisor agent on resume
@@ -1973,11 +2192,22 @@ export default function (pi: ExtensionAPI) {
1973
2192
  messages.push(` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`);
1974
2193
  }
1975
2194
 
1976
- // Step 2: Set pause signal
2195
+ // Step 2: Set pause signal and forward to worker
1977
2196
  if (orchBatchState.pauseSignal) {
1978
2197
  orchBatchState.pauseSignal.paused = true;
1979
2198
  messages.push(" ✓ Pause signal set on in-memory batch state");
1980
2199
  }
2200
+ // TP-071: Forward pause to worker and terminate on hard abort
2201
+ if (activeWorker) {
2202
+ activeWorker.postMessage({ type: "pause" });
2203
+ if (hard) {
2204
+ activeWorker.terminate();
2205
+ activeWorker = null;
2206
+ messages.push(" ✓ Engine worker thread terminated (hard abort)");
2207
+ } else {
2208
+ messages.push(" ✓ Pause signal forwarded to engine worker thread");
2209
+ }
2210
+ }
1981
2211
 
1982
2212
  // Step 3: Check what we're aborting
1983
2213
  const hasActiveBatch = orchBatchState.phase !== "idle" &&
@@ -3053,6 +3283,15 @@ export default function (pi: ExtensionAPI) {
3053
3283
  // Ensure supervisor lockfile/heartbeat are cleaned up on normal session exit.
3054
3284
  // This avoids leaving a live-looking lock when the process exits cleanly.
3055
3285
  pi.on("session_end", async () => {
3286
+ // TP-071: Terminate engine worker thread on session exit
3287
+ if (activeWorker) {
3288
+ try {
3289
+ activeWorker.terminate();
3290
+ activeWorker = null;
3291
+ } catch {
3292
+ // Best effort — worker may already be dead
3293
+ }
3294
+ }
3056
3295
  try {
3057
3296
  await deactivateSupervisor(pi, supervisorState);
3058
3297
  } catch {