taskplane 0.22.18 → 0.23.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.
@@ -6,7 +6,8 @@ import { existsSync, readdirSync, readFileSync, unlinkSync } from "fs";
6
6
  import { join, resolve } from "path";
7
7
 
8
8
  import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
9
- import { computeTransitiveDependents, execLog, executeLane, executeWave, tmuxKillSession } from "./execution.ts";
9
+ import { computeTransitiveDependents, execLog, executeLane, executeLaneV2, executeWave, tmuxKillSession } from "./execution.ts";
10
+ import type { RuntimeBackend } from "./execution.ts";
10
11
  import type { MonitorUpdateCallback } from "./execution.ts";
11
12
  // classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
12
13
  // from the diagnostic-reports pipeline (populated by assembleDiagnosticInput).
@@ -87,6 +88,7 @@ async function attemptWorkerCrashRetry(
87
88
  onNotify: (message: string, level: "info" | "warning" | "error") => void,
88
89
  stateRoot: string,
89
90
  runnerConfig?: TaskRunnerConfig,
91
+ runtimeBackend?: RuntimeBackend,
90
92
  ): Promise<{ retriedCount: number; succeededRetries: string[]; failedRetries: string[] }> {
91
93
  if (!batchState.resilience) {
92
94
  batchState.resilience = defaultResilienceState();
@@ -221,7 +223,8 @@ async function attemptWorkerCrashRetry(
221
223
  // may be paused due to stop-wave policy, but Tier 0 retry should
222
224
  // attempt recovery before the stop decision takes effect (R002-4).
223
225
  const retryPauseSignal = { paused: false };
224
- const retryResult = await executeLane(
226
+ const retryExecutor = (runtimeBackend === "v2") ? executeLaneV2 : executeLane;
227
+ const retryResult = await retryExecutor(
225
228
  retryLane,
226
229
  orchConfig,
227
230
  repoRoot,
@@ -374,6 +377,7 @@ async function attemptModelFallbackRetry(
374
377
  onNotify: (message: string, level: "info" | "warning" | "error") => void,
375
378
  stateRoot: string,
376
379
  runnerConfig?: TaskRunnerConfig,
380
+ runtimeBackend?: RuntimeBackend,
377
381
  ): Promise<{ retriedCount: number; succeededRetries: string[]; failedRetries: string[] }> {
378
382
  // Short-circuit: if model fallback is disabled, skip entirely
379
383
  const modelFallbackMode = runnerConfig?.model_fallback ?? "inherit";
@@ -487,7 +491,8 @@ async function attemptModelFallbackRetry(
487
491
  // the task-runner to use the session model instead of configured model.
488
492
  // TP-089: Also include ORCH_BATCH_ID so mailbox steering works for retries.
489
493
  const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId };
490
- const retryResult = await executeLane(
494
+ const retryExecutor = (runtimeBackend === "v2") ? executeLaneV2 : executeLane;
495
+ const retryResult = await retryExecutor(
491
496
  retryLane,
492
497
  orchConfig,
493
498
  repoRoot,
@@ -623,6 +628,8 @@ async function attemptStaleWorktreeRecovery(
623
628
  onMonitorUpdate: MonitorUpdateCallback | undefined,
624
629
  onLanesAllocated: (lanes: AllocatedLane[]) => void,
625
630
  stateRoot: string,
631
+ runtimeBackend?: RuntimeBackend,
632
+ onSupervisorAlert?: SupervisorAlertCallback,
626
633
  ): Promise<WaveExecutionResult | null> {
627
634
  // Only attempt recovery for ALLOC_WORKTREE_FAILED
628
635
  if (!waveResult.allocationError || waveResult.allocationError.code !== "ALLOC_WORKTREE_FAILED") {
@@ -722,12 +729,51 @@ async function attemptStaleWorktreeRecovery(
722
729
  onMonitorUpdate,
723
730
  onLanesAllocated,
724
731
  workspaceConfig,
732
+ runtimeBackend,
733
+ onSupervisorAlert,
725
734
  );
726
735
 
727
736
  return retryResult;
728
737
  }
729
738
 
730
739
 
740
+ export interface RuntimeBackendSelection {
741
+ backend: RuntimeBackend;
742
+ isSingleTask: boolean;
743
+ isRepoMode: boolean;
744
+ isDirectPromptTarget: boolean;
745
+ }
746
+
747
+ /**
748
+ * Select execution backend for a batch under the TP-105 scope guard.
749
+ *
750
+ * Runtime V2 is enabled only for a single-task batch in repo mode when
751
+ * the original target is exactly one direct PROMPT.md path.
752
+ */
753
+ export function selectRuntimeBackend(
754
+ args: string,
755
+ rawWaves: string[][],
756
+ workspaceConfig?: WorkspaceConfig | null,
757
+ ): RuntimeBackendSelection {
758
+ const isSingleTask = rawWaves.length === 1 && rawWaves[0]?.length === 1;
759
+ const isRepoMode = !workspaceConfig;
760
+ const argTokens = args.trim().split(/\s+/).filter(Boolean);
761
+ const isDirectPromptTarget =
762
+ argTokens.length === 1 && /PROMPT\.md$/i.test(argTokens[0]);
763
+
764
+ // TP-108: Runtime V2 for all repo-mode batches.
765
+ // TP-109: Workspace mode also uses V2 now that packet-home paths are
766
+ // threaded through execution and resume (worktree-relative .DONE check).
767
+ const backend: RuntimeBackend = "v2";
768
+
769
+ return {
770
+ backend,
771
+ isSingleTask,
772
+ isRepoMode,
773
+ isDirectPromptTarget,
774
+ };
775
+ }
776
+
731
777
  // ── /orch Execution Engine ───────────────────────────────────────────
732
778
 
733
779
  /**
@@ -1049,6 +1095,20 @@ export async function executeOrchBatch(
1049
1095
  // ── TS-009: Persist state on batch start (after wave computation) ──
1050
1096
  persistRuntimeState("batch-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
1051
1097
 
1098
+ // ── TP-105: Runtime V2 backend selection ────────────────────
1099
+ // Use Runtime V2 (no-TMUX lane-runner) when ALL conditions are met:
1100
+ // 1. Exactly one task in the batch
1101
+ // 2. Repo mode (not workspace mode — workspace deferred to TP-109)
1102
+ // 3. The user target is a single direct PROMPT.md path
1103
+ // Otherwise, fall back to the legacy TMUX-backed path.
1104
+ const backendSelection = selectRuntimeBackend(args, rawWaves, workspaceConfig);
1105
+ const selectedBackend = backendSelection.backend;
1106
+
1107
+ if (selectedBackend === "v2") {
1108
+ execLog("batch", batchState.batchId, "Runtime V2 backend selected");
1109
+ onNotify("🚀 Using Runtime V2 backend (no-TMUX direct execution)", "info");
1110
+ }
1111
+
1052
1112
  for (let waveIdx = 0; waveIdx < rawWaves.length; waveIdx++) {
1053
1113
  // Check pause signal before starting each wave
1054
1114
  if (batchState.pauseSignal.paused) {
@@ -1134,6 +1194,8 @@ export async function executeOrchBatch(
1134
1194
  handleWaveMonitorUpdate,
1135
1195
  onLanesAllocatedCb,
1136
1196
  workspaceConfig,
1197
+ selectedBackend,
1198
+ emitAlert,
1137
1199
  );
1138
1200
 
1139
1201
  // ── TP-039: Tier 0 — Stale worktree recovery ────────────
@@ -1153,6 +1215,8 @@ export async function executeOrchBatch(
1153
1215
  handleWaveMonitorUpdate,
1154
1216
  onLanesAllocatedCb,
1155
1217
  stateRoot,
1218
+ selectedBackend,
1219
+ emitAlert,
1156
1220
  );
1157
1221
  if (retryResult) {
1158
1222
  const staleRecovered = !retryResult.allocationError;
@@ -1219,6 +1283,7 @@ export async function executeOrchBatch(
1219
1283
  onNotify,
1220
1284
  stateRoot,
1221
1285
  runnerConfig,
1286
+ selectedBackend,
1222
1287
  );
1223
1288
  if (modelFallbackOutcome.succeededRetries.length > 0) {
1224
1289
  // Recompute blocked tasks after model fallback successes
@@ -1252,6 +1317,8 @@ export async function executeOrchBatch(
1252
1317
  allTaskOutcomes,
1253
1318
  onNotify,
1254
1319
  stateRoot,
1320
+ undefined,
1321
+ selectedBackend,
1255
1322
  );
1256
1323
  if (retryOutcome.succeededRetries.length > 0) {
1257
1324
  // Recompute blockedTaskIds from remaining failures (R002-2).
@@ -1468,6 +1535,8 @@ export async function executeOrchBatch(
1468
1535
  agentRoot,
1469
1536
  runnerConfig.testing_commands,
1470
1537
  mergeHealthMonitor,
1538
+ undefined, // forceMixedOutcome
1539
+ selectedBackend,
1471
1540
  );
1472
1541
  } finally {
1473
1542
  // TP-056: Always stop the health monitor when merge phase ends
@@ -1686,6 +1755,9 @@ export async function executeOrchBatch(
1686
1755
  stateRoot,
1687
1756
  agentRoot,
1688
1757
  runnerConfig.testing_commands,
1758
+ undefined, // healthMonitor
1759
+ undefined, // forceMixedOutcome
1760
+ selectedBackend,
1689
1761
  );
1690
1762
  },
1691
1763
  persist: (trigger) => persistRuntimeState(trigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot),
@@ -9,7 +9,9 @@ import { join, dirname, basename, resolve, relative, delimiter as pathDelimiter
9
9
  import { userInfo } from "os";
10
10
 
11
11
  import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts";
12
- import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
12
+ import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig, ExecutionUnit, PacketPaths, RuntimeAgentId, RuntimeAgentRole, SupervisorAlertCallback } from "./types.ts";
13
+ import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts";
14
+ import { readRegistrySnapshot, isTerminalStatus, isProcessAlive } from "./process-registry.ts";
13
15
  import { allocateLanes } from "./waves.ts";
14
16
  import { runGit } from "./git.ts";
15
17
 
@@ -266,6 +268,63 @@ export function killLaneAndChildren(sessionName: string): void {
266
268
  tmuxKillSession(sessionName);
267
269
  }
268
270
 
271
+ /**
272
+ * TP-112: Check if a V2 agent is alive via process registry.
273
+ * Returns true if the agent's PID is running and status is non-terminal.
274
+ * Returns false if no registry, no entry, terminal status, or dead PID.
275
+ *
276
+ * @param agentIdOrSessionName - Agent ID or session name to look up
277
+ * @param runtimeBackend - Must be "v2" (caller should guard)
278
+ * @returns true if agent is alive
279
+ * @since TP-112
280
+ */
281
+ export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: RuntimeBackend): boolean {
282
+ // Read the registry from the global state root.
283
+ // Since this is a pure liveness check, we scan for matching agentId
284
+ // patterns: direct match, or lane-session + "-worker" suffix.
285
+ if (!_v2LivenessRegistryCache) return false;
286
+ const agents = _v2LivenessRegistryCache.agents;
287
+ // Direct match
288
+ const manifest = agents[agentIdOrSessionName];
289
+ if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) return true;
290
+ // Try worker suffix (monitor uses lane session name, registry uses agentId)
291
+ const workerManifest = agents[`${agentIdOrSessionName}-worker`];
292
+ if (workerManifest && !isTerminalStatus(workerManifest.status) && isProcessAlive(workerManifest.pid)) return true;
293
+ return false;
294
+ }
295
+
296
+ /** Cached registry for V2 liveness checks within a monitor cycle. @since TP-112 */
297
+ let _v2LivenessRegistryCache: import("./process-registry.ts").RuntimeRegistry | null = null;
298
+
299
+ /**
300
+ * Set the V2 liveness registry cache for the current monitor cycle.
301
+ * Called at the start of each monitor poll to avoid re-reading the file per-task.
302
+ * @since TP-112
303
+ */
304
+ export function setV2LivenessRegistryCache(registry: import("./process-registry.ts").RuntimeRegistry | null): void {
305
+ _v2LivenessRegistryCache = registry;
306
+ }
307
+
308
+ /**
309
+ * TP-112: Kill V2 lane agents (worker + reviewer) by PID from the registry.
310
+ * Used for stall termination on the V2 path.
311
+ * @since TP-112
312
+ */
313
+ export function killV2LaneAgents(sessionName: string): void {
314
+ if (!_v2LivenessRegistryCache) return;
315
+ const agents = _v2LivenessRegistryCache.agents;
316
+ for (const suffix of ["-worker", "-reviewer", ""]) {
317
+ const key = `${sessionName}${suffix}`;
318
+ const manifest = agents[key];
319
+ if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
320
+ try {
321
+ process.kill(manifest.pid, "SIGTERM");
322
+ execLog("monitor", key, `killed V2 agent (PID ${manifest.pid}) on stall`);
323
+ } catch { /* already dead */ }
324
+ }
325
+ }
326
+ }
327
+
269
328
  // ── Async TMUX Helpers (TP-070) ──────────────────────────────────────
270
329
 
271
330
  /**
@@ -1692,8 +1751,16 @@ export async function resolveTaskMonitorState(
1692
1751
  tracker: MtimeTracker,
1693
1752
  stallTimeoutMs: number,
1694
1753
  now: number,
1754
+ runtimeBackend?: RuntimeBackend,
1695
1755
  ): Promise<TaskMonitorSnapshot> {
1696
- const sessionAlive = await tmuxHasSessionAsync(sessionName);
1756
+ // TP-112: Backend-aware liveness check.
1757
+ // V2: check process registry (PID liveness). Legacy: check TMUX session.
1758
+ let sessionAlive: boolean;
1759
+ if (runtimeBackend === "v2") {
1760
+ sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
1761
+ } else {
1762
+ sessionAlive = await tmuxHasSessionAsync(sessionName);
1763
+ }
1697
1764
  const doneFileFound = await fileExistsAsync(donePath);
1698
1765
 
1699
1766
  // Build base snapshot from parsed status
@@ -1784,12 +1851,17 @@ export async function resolveTaskMonitorState(
1784
1851
  const stallMinutes = Math.round((now - tracker.stallTimerStart) / 60_000);
1785
1852
  const stallReason = `STATUS.md unchanged for ${stallMinutes} minutes (threshold: ${Math.round(stallTimeoutMs / 60_000)} min)`;
1786
1853
 
1787
- // Kill the session and children
1788
- execLog("monitor", taskId, `stall detected — killing session`, {
1854
+ // Kill the agent (backend-aware)
1855
+ execLog("monitor", taskId, `stall detected — killing agent`, {
1789
1856
  session: sessionName,
1790
1857
  stallMinutes,
1858
+ backend: runtimeBackend ?? "legacy",
1791
1859
  });
1792
- killLaneAndChildren(sessionName);
1860
+ if (runtimeBackend === "v2") {
1861
+ killV2LaneAgents(sessionName);
1862
+ } else {
1863
+ killLaneAndChildren(sessionName);
1864
+ }
1793
1865
 
1794
1866
  return {
1795
1867
  taskId,
@@ -1893,6 +1965,9 @@ export async function monitorLanes(
1893
1965
  waveNumber: number = 1,
1894
1966
  onUpdate?: MonitorUpdateCallback,
1895
1967
  isWorkspaceMode?: boolean,
1968
+ runtimeBackend?: RuntimeBackend,
1969
+ batchId?: string,
1970
+ stateRootForRegistry?: string,
1896
1971
  ): Promise<MonitorState> {
1897
1972
  const pollIntervalMs = (config.monitoring.poll_interval || 5) * 1000;
1898
1973
  const stallTimeoutMs = (config.failure.stall_timeout || 30) * 60_000;
@@ -1941,6 +2016,17 @@ export async function monitorLanes(
1941
2016
  const now = Date.now();
1942
2017
  pollCount++;
1943
2018
 
2019
+ // TP-112: Refresh V2 liveness registry cache once per poll cycle
2020
+ if (runtimeBackend === "v2" && batchId) {
2021
+ try {
2022
+ setV2LivenessRegistryCache(readRegistrySnapshot(stateRootForRegistry ?? repoRoot, batchId));
2023
+ } catch {
2024
+ setV2LivenessRegistryCache(null);
2025
+ }
2026
+ } else {
2027
+ setV2LivenessRegistryCache(null);
2028
+ }
2029
+
1944
2030
  // Check pause signal
1945
2031
  if (pauseSignal.paused) {
1946
2032
  execLog("monitor", "ALL", "pause signal detected — stopping monitoring");
@@ -1993,6 +2079,7 @@ export async function monitorLanes(
1993
2079
  tracker,
1994
2080
  stallTimeoutMs,
1995
2081
  now,
2082
+ runtimeBackend,
1996
2083
  );
1997
2084
 
1998
2085
  currentTaskSnapshot = snapshot;
@@ -2031,7 +2118,10 @@ export async function monitorLanes(
2031
2118
  allTerminal = false;
2032
2119
  }
2033
2120
 
2034
- const sessionAlive = await tmuxHasSessionAsync(lane.tmuxSessionName);
2121
+ // TP-112: Backend-aware lane liveness for snapshot
2122
+ const sessionAlive = runtimeBackend === "v2"
2123
+ ? isV2AgentAlive(lane.tmuxSessionName, runtimeBackend)
2124
+ : await tmuxHasSessionAsync(lane.tmuxSessionName);
2035
2125
 
2036
2126
  laneSnapshots.push({
2037
2127
  laneId: lane.laneId,
@@ -2082,6 +2172,7 @@ export async function monitorLanes(
2082
2172
  total: tasksTotal,
2083
2173
  polls: pollCount,
2084
2174
  });
2175
+ setV2LivenessRegistryCache(null);
2085
2176
  return monitorState;
2086
2177
  }
2087
2178
 
@@ -2103,6 +2194,7 @@ export async function monitorLanes(
2103
2194
  remainingTasks: lane.tasks.map(t => t.taskId),
2104
2195
  }));
2105
2196
 
2197
+ setV2LivenessRegistryCache(null);
2106
2198
  return {
2107
2199
  lanes: laneSnapshots,
2108
2200
  tasksDone: 0,
@@ -2296,6 +2388,16 @@ export function ensureTaskFilesCommitted(
2296
2388
  * @param workspaceConfig - Workspace configuration for repo routing (null/undefined = repo mode)
2297
2389
  * @returns WaveExecutionResult with outcomes and blocked task IDs
2298
2390
  */
2391
+ /**
2392
+ * Runtime backend selector for lane execution.
2393
+ *
2394
+ * - `"legacy"`: TMUX-backed path (spawnLaneSession → task-runner TASK_AUTOSTART)
2395
+ * - `"v2"`: Direct-child path (lane-runner → agent-host → pi --mode rpc)
2396
+ *
2397
+ * @since TP-105
2398
+ */
2399
+ export type RuntimeBackend = "legacy" | "v2";
2400
+
2299
2401
  export async function executeWave(
2300
2402
  waveTasks: string[],
2301
2403
  waveIndex: number,
@@ -2309,6 +2411,8 @@ export async function executeWave(
2309
2411
  onMonitorUpdate?: MonitorUpdateCallback,
2310
2412
  onLanesAllocated?: (lanes: AllocatedLane[]) => void,
2311
2413
  workspaceConfig?: WorkspaceConfig | null,
2414
+ runtimeBackend?: RuntimeBackend,
2415
+ onSupervisorAlert?: SupervisorAlertCallback,
2312
2416
  ): Promise<WaveExecutionResult> {
2313
2417
  const startedAt = Date.now();
2314
2418
  const policy = config.failure.on_task_failure;
@@ -2391,14 +2495,20 @@ export async function executeWave(
2391
2495
  // configPath is .pi/taskplane-workspace.yaml → parent of parent is workspace root.
2392
2496
  const wsRoot = workspaceConfig ? dirname(dirname(workspaceConfig.configPath)) : undefined;
2393
2497
  const isWsMode = !!workspaceConfig;
2498
+ const backend = runtimeBackend ?? "legacy";
2499
+ if (backend === "v2") {
2500
+ execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)");
2501
+ }
2502
+
2394
2503
  const lanePromises = lanes.map(lane =>
2395
- executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, {
2396
- ORCH_BATCH_ID: batchId,
2397
- }),
2504
+ backend === "v2"
2505
+ ? executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }, onSupervisorAlert)
2506
+ : executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }),
2398
2507
  );
2399
2508
 
2400
2509
  // Start monitoring as a sibling async loop
2401
2510
  // Monitor runs concurrently and stops when all lanes are terminal or paused
2511
+ const monitorStateRoot = resolveRuntimeStateRoot(repoRoot, wsRoot);
2402
2512
  const monitorPromise = monitorLanes(
2403
2513
  lanes,
2404
2514
  config,
@@ -2407,6 +2517,9 @@ export async function executeWave(
2407
2517
  waveIndex,
2408
2518
  onMonitorUpdate,
2409
2519
  isWsMode,
2520
+ backend,
2521
+ batchId,
2522
+ monitorStateRoot,
2410
2523
  );
2411
2524
 
2412
2525
  // ── Stage 4: Wait for all lanes + apply policy ───────────────
@@ -2671,5 +2784,286 @@ export async function executeWithStopAll(
2671
2784
  });
2672
2785
  }
2673
2786
 
2787
+ // ── Runtime V2 Bridge Helpers (TP-102) ─────────────────────────────────────
2788
+ //
2789
+ // These helpers bridge between existing legacy data structures
2790
+ // (AllocatedLane, AllocatedTask, resolveCanonicalTaskPaths) and
2791
+ // Runtime V2 contracts (ExecutionUnit, PacketPaths, RuntimeAgentId).
2792
+ //
2793
+ // They are additive — existing code paths continue to work.
2794
+ // Runtime V2 consumers can start using these to avoid coupling to
2795
+ // TMUX naming, cwd-derived paths, or extension lifecycle assumptions.
2796
+ // ────────────────────────────────────────────────────────────────────────────
2797
+
2798
+ /**
2799
+ * Build a Runtime V2 ExecutionUnit from existing legacy structures.
2800
+ *
2801
+ * Translates the current AllocatedLane + AllocatedTask into the new
2802
+ * ExecutionUnit contract with explicit packet-path authority.
2803
+ *
2804
+ * Uses `resolveCanonicalTaskPaths` to derive packet paths through
2805
+ * the existing resolution logic (worktree-relative, cross-repo copy,
2806
+ * archive fallback). This preserves current behavior while surfacing
2807
+ * it through the Runtime V2 contract.
2808
+ *
2809
+ * **Cross-repo packet authority (TP-109):** In workspace mode, when the
2810
+ * task packet home repo differs from the execution repo, the legacy path
2811
+ * copies packet files into the worktree under `.taskplane-tasks/`. The
2812
+ * resolved `packet` paths here point to that execution-local copy.
2813
+ * This is by design: the worker reads/writes STATUS.md and creates .DONE
2814
+ * in the worktree, and resume checks both the worktree-relative path and
2815
+ * the original discovery path for .DONE detection.
2816
+ *
2817
+ * `packetHomeRepoId` identifies the source repo that *owns* the task
2818
+ * (for discovery and routing), while `packet.taskFolder` is the
2819
+ * authoritative *working* location where artifacts are read/written
2820
+ * during execution. Resume reconciliation (TP-109) resolves both paths.
2821
+ *
2822
+ * @param lane - Allocated lane containing worktree and identity info
2823
+ * @param task - Allocated task to build an execution unit for
2824
+ * @param repoRoot - Main repository root
2825
+ * @param isWorkspaceMode - Whether workspace mode is active
2826
+ * @returns A fully-resolved ExecutionUnit
2827
+ *
2828
+ * @since TP-102
2829
+ */
2830
+ export function buildExecutionUnit(
2831
+ lane: AllocatedLane,
2832
+ task: AllocatedTask,
2833
+ repoRoot: string,
2834
+ isWorkspaceMode?: boolean,
2835
+ ): ExecutionUnit {
2836
+ const resolved = resolveCanonicalTaskPaths(
2837
+ task.task.taskFolder,
2838
+ lane.worktreePath,
2839
+ repoRoot,
2840
+ isWorkspaceMode,
2841
+ );
2842
+
2843
+ const executionRepoId = lane.repoId ?? "default";
2844
+ const packetHomeRepoId = task.task.packetRepoId ?? executionRepoId;
2845
+
2846
+ // Build a segment-style ID if this is a segment execution,
2847
+ // otherwise use the plain task ID.
2848
+ const segmentId = task.task.activeSegmentId ?? null;
2849
+ const id = segmentId ?? task.taskId;
2850
+
2851
+ return {
2852
+ id,
2853
+ taskId: task.taskId,
2854
+ segmentId,
2855
+ executionRepoId,
2856
+ packetHomeRepoId,
2857
+ worktreePath: lane.worktreePath,
2858
+ packet: {
2859
+ promptPath: resolved.taskFolderResolved + "/PROMPT.md",
2860
+ statusPath: resolved.statusPath,
2861
+ donePath: resolved.donePath,
2862
+ reviewsDir: resolved.taskFolderResolved + "/.reviews",
2863
+ taskFolder: resolved.taskFolderResolved,
2864
+ },
2865
+ task: task.task,
2866
+ };
2867
+ }
2868
+
2869
+ /**
2870
+ * Build a RuntimeAgentId for a lane's agent from existing naming.
2871
+ *
2872
+ * Bridges the current TMUX session naming convention into a
2873
+ * Runtime V2 stable agent ID. The output is compatible with
2874
+ * existing supervisor tools and mailbox addressing.
2875
+ *
2876
+ * @param lane - Allocated lane with TMUX session name
2877
+ * @param role - Agent role
2878
+ * @param mergeIndex - Merge wave index (only for merge agents)
2879
+ * @returns Canonical agent ID
2880
+ *
2881
+ * @since TP-102
2882
+ */
2883
+ export function buildAgentIdFromLane(
2884
+ lane: AllocatedLane,
2885
+ role: RuntimeAgentRole,
2886
+ mergeIndex?: number,
2887
+ ): RuntimeAgentId {
2888
+ // The current tmuxSessionName is already in the right format
2889
+ // (e.g., "orch-henrylach-lane-1"). We derive agent IDs from it
2890
+ // by appending the role suffix, matching the existing convention.
2891
+ if (role === "merger" && mergeIndex != null) {
2892
+ // Merge agents use a different naming pattern
2893
+ const prefix = lane.tmuxSessionName.replace(/-lane-\d+$/, "");
2894
+ return `${prefix}-merge-${mergeIndex}`;
2895
+ }
2896
+ if (role === "lane-runner") {
2897
+ return lane.tmuxSessionName;
2898
+ }
2899
+ return `${lane.tmuxSessionName}-${role}`;
2900
+ }
2901
+
2902
+ /**
2903
+ * Resolve the Runtime V2 state root from available context.
2904
+ *
2905
+ * The state root is where `.pi/runtime/` artifacts live. In workspace
2906
+ * mode this is the workspace root; in repo mode it's the repo root.
2907
+ *
2908
+ * This centralizes the resolution so Runtime V2 code doesn't need
2909
+ * to repeat the workspace-vs-repo logic.
2910
+ *
2911
+ * @param repoRoot - Main repository root
2912
+ * @param workspaceRoot - Workspace root (undefined in repo mode)
2913
+ * @returns Absolute path to use as the state root for .pi/ artifacts
2914
+ *
2915
+ * @since TP-102
2916
+ */
2917
+ export function resolveRuntimeStateRoot(
2918
+ repoRoot: string,
2919
+ workspaceRoot?: string,
2920
+ ): string {
2921
+ return workspaceRoot ?? repoRoot;
2922
+ }
2923
+
2924
+ // ── Runtime V2 Lane Execution (TP-105) ────────────────────────────
2925
+
2926
+ import { executeTaskV2, type LaneRunnerConfig, type LaneRunnerTaskResult } from "./lane-runner.ts";
2927
+
2928
+ /**
2929
+ * Execute a lane using the Runtime V2 headless backend.
2930
+ *
2931
+ * This replaces the legacy TMUX-backed `executeLane()` for lanes that
2932
+ * should run on the new direct-child architecture. It uses the
2933
+ * lane-runner module which spawns workers via agent-host.ts instead
2934
+ * of TMUX sessions.
2935
+ *
2936
+ * The function signature is deliberately close to the legacy
2937
+ * `executeLane()` to minimize integration churn in the engine.
2938
+ * The key difference: no TMUX sessions are created.
2939
+ *
2940
+ * @since TP-105
2941
+ */
2942
+ export async function executeLaneV2(
2943
+ lane: AllocatedLane,
2944
+ config: OrchestratorConfig,
2945
+ repoRoot: string,
2946
+ pauseSignal: { paused: boolean },
2947
+ workspaceRoot?: string,
2948
+ isWorkspaceMode?: boolean,
2949
+ extraEnvVars?: Record<string, string>,
2950
+ onSupervisorAlert?: SupervisorAlertCallback,
2951
+ ): Promise<LaneExecutionResult> {
2952
+ const laneId = lane.laneId;
2953
+ const laneStartTime = Date.now();
2954
+ const outcomes: LaneTaskOutcome[] = [];
2955
+ let shouldSkipRemaining = false;
2956
+
2957
+ const stateRoot = resolveRuntimeStateRoot(repoRoot, workspaceRoot);
2958
+ const batchId = config.orchestrator?.batchId || extraEnvVars?.ORCH_BATCH_ID || String(Date.now());
2959
+
2960
+ // Build agent ID prefix from orchestrator config
2961
+ const tmuxPrefix = config.orchestrator?.tmux_prefix ?? "orch";
2962
+ const opId = config.orchestrator?.operatorId || "op";
2963
+ const agentIdPrefix = `${tmuxPrefix}-${opId}`;
2964
+
2965
+ // Load worker agent definition for system prompt
2966
+ let workerSystemPrompt = "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
2967
+ try {
2968
+ const agentPath = join(stateRoot, ".pi", "agents", "task-worker.md");
2969
+ if (existsSync(agentPath)) {
2970
+ const raw = readFileSync(agentPath, "utf-8");
2971
+ const fmEnd = raw.indexOf("---", 4);
2972
+ if (fmEnd > 0) {
2973
+ workerSystemPrompt = raw.slice(fmEnd + 3).trim();
2974
+ }
2975
+ }
2976
+ } catch { /* use default */ }
2977
+
2978
+ execLog(laneId, "LANE", `starting Runtime V2 execution of ${lane.tasks.length} task(s)`, {
2979
+ worktree: lane.worktreePath,
2980
+ agentPrefix: agentIdPrefix,
2981
+ });
2982
+
2983
+ for (const task of lane.tasks) {
2984
+ if (shouldSkipRemaining || pauseSignal.paused) {
2985
+ const reason = pauseSignal.paused ? "Skipped due to pause signal" : "Skipped due to prior task failure in lane";
2986
+ outcomes.push({
2987
+ taskId: task.taskId,
2988
+ status: "skipped",
2989
+ startTime: null,
2990
+ endTime: null,
2991
+ exitReason: reason,
2992
+ sessionName: buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"),
2993
+ doneFileFound: false,
2994
+ });
2995
+ continue;
2996
+ }
2997
+
2998
+ // Build execution unit
2999
+ const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode);
3000
+
3001
+ const laneRunnerConfig: LaneRunnerConfig = {
3002
+ batchId,
3003
+ agentIdPrefix,
3004
+ laneNumber: lane.laneNumber,
3005
+ worktreePath: lane.worktreePath,
3006
+ branch: lane.branch,
3007
+ repoId: lane.repoId ?? "default",
3008
+ stateRoot,
3009
+ workerModel: "",
3010
+ workerTools: "read,write,edit,bash,grep,find,ls",
3011
+ workerThinking: "",
3012
+ workerSystemPrompt,
3013
+ maxIterations: 20,
3014
+ noProgressLimit: 3,
3015
+ maxWorkerMinutes: config.failure?.maxWorkerMinutes || 30,
3016
+ warnPercent: 85,
3017
+ killPercent: 95,
3018
+ onSupervisorAlert,
3019
+ };
3020
+
3021
+ try {
3022
+ const result = await executeTaskV2(unit, laneRunnerConfig, pauseSignal);
3023
+ outcomes.push(result.outcome);
3024
+
3025
+ // Commit artifacts after success (same as legacy path)
3026
+ if (result.outcome.status === "succeeded") {
3027
+ commitTaskArtifacts(lane, task, laneId);
3028
+ // Reset worktree for next task
3029
+ if (lane.tasks.indexOf(task) < lane.tasks.length - 1) {
3030
+ runGit(["checkout", "--", "."], lane.worktreePath);
3031
+ runGit(["clean", "-fd"], lane.worktreePath);
3032
+ }
3033
+ }
3034
+
3035
+ if (result.outcome.status === "failed" || result.outcome.status === "stalled") {
3036
+ shouldSkipRemaining = true;
3037
+ }
3038
+ } catch (err: unknown) {
3039
+ const errMsg = err instanceof Error ? err.message : String(err);
3040
+ execLog(laneId, task.taskId, `Runtime V2 execution error: ${errMsg}`);
3041
+ outcomes.push({
3042
+ taskId: task.taskId,
3043
+ status: "failed",
3044
+ startTime: Date.now(),
3045
+ endTime: Date.now(),
3046
+ exitReason: `Runtime V2 execution error: ${errMsg}`,
3047
+ sessionName: buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"),
3048
+ doneFileFound: false,
3049
+ });
3050
+ shouldSkipRemaining = true;
3051
+ }
3052
+ }
3053
+
3054
+ const endTime = Date.now();
3055
+ const succeeded = outcomes.every(o => o.status === "succeeded");
3056
+ const failed = outcomes.some(o => o.status === "failed" || o.status === "stalled");
3057
+
3058
+ return {
3059
+ laneNumber: lane.laneNumber,
3060
+ laneId,
3061
+ tasks: outcomes,
3062
+ overallStatus: succeeded ? "succeeded" : failed ? "failed" : "partial",
3063
+ startTime: laneStartTime,
3064
+ endTime,
3065
+ };
3066
+ }
3067
+
2674
3068
  // ── /orch Command — Full Execution (Step 5) ─────────────────────────
2675
3069