taskplane 0.23.16 → 0.24.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.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
6
6
  import { access as fsAccess, readFile as fsReadFile, stat as fsStat } from "fs/promises";
7
- import { spawnSync, spawn } from "child_process";
7
+ import { spawnSync } from "child_process";
8
8
  import { join, dirname, basename, resolve, relative, delimiter as pathDelimiter } from "path";
9
9
  import { userInfo } from "os";
10
10
 
@@ -117,81 +117,15 @@ function resolveTaskRunnerExtensionPath(repoRoot: string): string {
117
117
  * Find the rpc-wrapper.mjs path for lane sessions.
118
118
  * @see resolveTaskplanePackageFile for resolution order
119
119
  */
120
- export function resolveRpcWrapperPath(repoRoot: string): string {
121
- return resolveTaskplanePackageFile(repoRoot, join("bin", "rpc-wrapper.mjs"));
122
- }
120
+ // resolveRpcWrapperPath removed (TP-120 remediation: legacy session-backend dead code)
123
121
 
124
122
  // ── Telemetry Helpers ────────────────────────────────────────────────
125
123
 
126
- /**
127
- * Resolve the operator ID for telemetry filenames.
128
- *
129
- * Priority: TASKPLANE_OPERATOR_ID env → OS username → "op" fallback.
130
- * Shared by lane and merge telemetry path generators to avoid divergence.
131
- */
132
- export function resolveTelemOpId(): string {
133
- const envOpId = process.env.TASKPLANE_OPERATOR_ID;
134
- if (envOpId?.trim()) {
135
- return envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
136
- }
137
- try {
138
- const username = userInfo().username;
139
- if (username?.trim()) {
140
- return username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
141
- }
142
- } catch { /* userInfo() can throw on some platforms */ }
143
- return "op";
144
- }
145
-
146
- /**
147
- * Sanitize a string for use in telemetry filenames.
148
- */
149
- function sanitizeForFilename(s: string, maxLen: number = 30): string {
150
- return s.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLen);
151
- }
124
+ // resolveTelemOpId removed (TP-120 remediation: only consumer was generateTelemetryPaths)
152
125
 
153
- // ── Telemetry Path Generation ────────────────────────────────────────
126
+ // sanitizeForFilename + generateTelemetryPaths removed (TP-120 remediation: legacy telemetry dead code)
154
127
 
155
- /**
156
- * Generate telemetry file paths for a lane session.
157
- *
158
- * Naming contract from resilience roadmap:
159
- * .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
160
- *
161
- * @param sessionName - TMUX session name (e.g., "orch-lane-1")
162
- * @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi or <repo>/.pi)
163
- * @param taskId - Task identifier (e.g., "TP-049")
164
- * @param batchId - Actual batch ID from batch state (falls back to timestamp)
165
- * @param repoId - Repo ID for workspace mode (falls back to "default")
166
- * @returns { sidecarPath, exitSummaryPath, telemetryDir }
167
- */
168
- export function generateTelemetryPaths(
169
- sessionName: string,
170
- sidecarRoot: string,
171
- taskId?: string,
172
- batchId?: string,
173
- repoId?: string,
174
- ): { sidecarPath: string; exitSummaryPath: string; telemetryDir: string } {
175
- const opId = resolveTelemOpId();
176
- const effectiveBatchId = batchId || String(Date.now());
177
- const effectiveRepoId = repoId || "default";
178
-
179
- // Lane sessions are the task-runner orchestration layer, NOT the worker agent.
180
- // Use "lane" role to avoid filename collisions with worker sidecar files.
181
- const role = "lane";
182
- const laneMatch = sessionName.match(/lane-(\d+)/);
183
- const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
184
-
185
- // Include taskId when available
186
- const taskIdSegment = taskId ? `-${sanitizeForFilename(taskId)}` : "";
187
- const telemetryBasename = `${opId}-${effectiveBatchId}-${effectiveRepoId}${taskIdSegment}${laneSuffix}-${role}`;
188
- const telemetryDir = join(sidecarRoot, "telemetry");
189
- if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
190
- const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
191
- const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
192
-
193
- return { sidecarPath, exitSummaryPath, telemetryDir };
194
- }
128
+ // generateTelemetryPaths removed (TP-120 remediation: legacy telemetry sidecar dead code)
195
129
 
196
130
  // ── Execution Helpers ────────────────────────────────────────────────
197
131
 
@@ -220,55 +154,6 @@ export function execLog(
220
154
  }
221
155
  }
222
156
 
223
- /**
224
- * Check if a TMUX session exists (is alive).
225
- *
226
- * @param sessionName - TMUX session name to check
227
- * @returns true if session exists
228
- */
229
- export function tmuxHasSession(sessionName: string): boolean {
230
- const result = spawnSync("tmux", ["has-session", "-t", sessionName]);
231
- return result.status === 0;
232
- }
233
-
234
- /**
235
- * Kill a TMUX session if it exists.
236
- *
237
- * Idempotent: returns true if session was killed or was already absent.
238
- *
239
- * @param sessionName - TMUX session name to kill
240
- * @returns true if session is now absent
241
- */
242
- export function tmuxKillSession(sessionName: string): boolean {
243
- // Check liveness first so we can distinguish "already gone" from "kill failed".
244
- const wasAlive = tmuxHasSession(sessionName);
245
- if (!wasAlive) {
246
- return true; // Already absent
247
- }
248
-
249
- spawnSync("tmux", ["kill-session", "-t", sessionName]);
250
-
251
- // Consider success only if the session is now absent.
252
- return !tmuxHasSession(sessionName);
253
- }
254
-
255
- /**
256
- * Kill a lane session and its child sessions (worker, reviewer).
257
- *
258
- * Child session names follow the convention:
259
- * - `{sessionName}-worker`
260
- * - `{sessionName}-reviewer`
261
- *
262
- * @param sessionName - Base lane session name (e.g., "orch-lane-1")
263
- */
264
- export function killLaneAndChildren(sessionName: string): void {
265
- // Kill children first (they depend on the parent context)
266
- tmuxKillSession(`${sessionName}-worker`);
267
- tmuxKillSession(`${sessionName}-reviewer`);
268
- // Then kill the parent lane session
269
- tmuxKillSession(sessionName);
270
- }
271
-
272
157
  /**
273
158
  * TP-112: Check if a V2 agent is alive via process registry.
274
159
  * Returns true if the agent's PID is running and status is non-terminal.
@@ -308,120 +193,39 @@ export function setV2LivenessRegistryCache(registry: import("./process-registry.
308
193
 
309
194
  /**
310
195
  * TP-112: Kill V2 lane agents (worker + reviewer) by PID from the registry.
311
- * Used for stall termination on the V2 path.
196
+ *
197
+ * Uses the monitor cache when available for hot-path polling, and can
198
+ * optionally read a fresh registry snapshot for cleanup flows outside monitor.
199
+ *
312
200
  * @since TP-112
313
201
  */
314
- export function killV2LaneAgents(sessionName: string): void {
315
- if (!_v2LivenessRegistryCache) return;
316
- const agents = _v2LivenessRegistryCache.agents;
202
+ export function killV2LaneAgents(
203
+ sessionName: string,
204
+ options?: { stateRoot?: string; batchId?: string; logContext?: string },
205
+ ): void {
206
+ const registry = _v2LivenessRegistryCache ?? (
207
+ options?.stateRoot && options?.batchId
208
+ ? readRegistrySnapshot(options.stateRoot, options.batchId)
209
+ : null
210
+ );
211
+ if (!registry) return;
212
+
213
+ const agents = registry.agents;
214
+ const logContext = options?.logContext ?? "monitor";
317
215
  for (const suffix of ["-worker", "-reviewer", ""]) {
318
216
  const key = `${sessionName}${suffix}`;
319
217
  const manifest = agents[key];
320
218
  if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
321
219
  try {
322
220
  process.kill(manifest.pid, "SIGTERM");
323
- execLog("monitor", key, `killed V2 agent (PID ${manifest.pid}) on stall`);
221
+ execLog(logContext, key, `killed V2 agent (PID ${manifest.pid})`);
324
222
  } catch { /* already dead */ }
325
223
  }
326
224
  }
327
225
  }
328
226
 
329
- // ── Async TMUX Helpers (TP-070) ──────────────────────────────────────
330
-
331
- /**
332
- * Run a tmux command asynchronously, without blocking the event loop.
333
- *
334
- * Wraps `child_process.spawn` in a promise. The process is spawned and
335
- * stdout is collected incrementally; the promise resolves when the process
336
- * exits.
337
- *
338
- * @param args - Arguments to pass to the `tmux` command
339
- * @param timeoutMs - Optional timeout in milliseconds (default: 5000)
340
- * @returns Promise resolving to `{ status, stdout }` where status is the exit code (0 = success)
341
- *
342
- * @since TP-070
343
- */
344
- export function tmuxAsync(args: string[], timeoutMs: number = 5_000): Promise<{ status: number; stdout: string }> {
345
- return new Promise((resolve) => {
346
- const proc = spawn("tmux", args, {
347
- stdio: ["ignore", "pipe", "pipe"],
348
- timeout: timeoutMs,
349
- });
350
-
351
- let stdout = "";
352
-
353
- proc.stdout.on("data", (chunk: Buffer) => {
354
- stdout += chunk.toString("utf-8");
355
- });
227
+ // ── Async File/Status Helpers (TP-070) ───────────────────────────────
356
228
 
357
- proc.on("error", () => {
358
- // Spawn failure — treat as non-zero exit
359
- resolve({ status: 1, stdout: "" });
360
- });
361
-
362
- proc.on("close", (code) => {
363
- resolve({ status: code ?? 1, stdout });
364
- });
365
- });
366
- }
367
-
368
- /**
369
- * Async version of tmuxHasSession — checks if a TMUX session exists
370
- * without blocking the event loop.
371
- *
372
- * @param sessionName - TMUX session name to check
373
- * @returns Promise resolving to true if session exists
374
- *
375
- * @since TP-070
376
- */
377
- export async function tmuxHasSessionAsync(sessionName: string): Promise<boolean> {
378
- const result = await tmuxAsync(["has-session", "-t", sessionName]);
379
- return result.status === 0;
380
- }
381
-
382
- /**
383
- * Async version of tmuxKillSession — kills a TMUX session without
384
- * blocking the event loop.
385
- *
386
- * Idempotent: resolves to true if session was killed or was already absent.
387
- *
388
- * @param sessionName - TMUX session name to kill
389
- * @returns Promise resolving to true if session is now absent
390
- *
391
- * @since TP-070
392
- */
393
- export async function tmuxKillSessionAsync(sessionName: string): Promise<boolean> {
394
- const wasAlive = await tmuxHasSessionAsync(sessionName);
395
- if (!wasAlive) return true;
396
-
397
- await tmuxAsync(["kill-session", "-t", sessionName]);
398
- return !(await tmuxHasSessionAsync(sessionName));
399
- }
400
-
401
- /**
402
- * Async version of captureTmuxPaneTail — captures tail output from a live
403
- * TMUX pane without blocking the event loop.
404
- *
405
- * @param sessionName - TMUX session name
406
- * @param maxLines - Maximum number of lines to return
407
- * @param maxChars - Maximum character count
408
- * @returns Promise resolving to captured text (empty string on failure)
409
- *
410
- * @since TP-070
411
- */
412
- export async function captureTmuxPaneTailAsync(
413
- sessionName: string,
414
- maxLines: number = 40,
415
- maxChars: number = 1200,
416
- ): Promise<string> {
417
- const result = await tmuxAsync(["capture-pane", "-p", "-t", sessionName], 3000);
418
- if (result.status !== 0) return "";
419
- const raw = (result.stdout || "").replace(/\r\n/g, "\n").trim();
420
- if (!raw) return "";
421
- const tail = raw.split("\n").slice(-maxLines).join("\n").trim();
422
- if (!tail) return "";
423
- return tail.length > maxChars ? tail.slice(-maxChars) : tail;
424
- }
425
229
 
426
230
  /**
427
231
  * Async version of readTaskStatusTail — reads STATUS.md tail without
@@ -456,239 +260,15 @@ export async function readTaskStatusTailAsync(
456
260
  }
457
261
 
458
262
  /**
459
- * Build environment variables for a lane task execution.
460
- *
461
- * These env vars tell the task-runner extension inside the TMUX session
462
- * how to behave:
463
- * - TASK_AUTOSTART: relative path to PROMPT.md from worktree root
464
- * - TASK_RUNNER_SPAWN_MODE: "tmux" for TMUX-based worker/reviewer spawning
465
- * - TASK_RUNNER_TMUX_PREFIX: prefix for worker/reviewer session names
466
- *
467
- * @param lane - The allocated lane (provides session name and worktree path)
468
- * @param taskId - Task ID for logging
469
- * @param promptPath - Absolute path to the task's PROMPT.md in the main repo
470
- * @param repoRoot - Absolute path to the main repository root
471
- * @returns Map of env var name → value
472
- */
473
- export function buildLaneEnvVars(
474
- lane: AllocatedLane,
475
- promptPath: string,
476
- repoRoot: string,
477
- workspaceRoot?: string,
478
- ): Record<string, string> {
479
- // TASK_AUTOSTART: resolve the prompt path for the lane session.
480
- //
481
- // In workspace mode, tasks may live in a different repo than the lane's
482
- // worktree (e.g., task PROMPT.md in shared-libs, worker runs in api-service).
483
- // Always use the absolute path — task-runner's resolve(cwd, autoPath) handles
484
- // absolute paths correctly, and this avoids broken relative paths when the
485
- // task folder is outside the lane's repo.
486
- //
487
- // In repo mode (no workspace), we still use relative paths from repoRoot
488
- // because the worktree mirrors the repo structure and the task folder is
489
- // inside the repo.
490
- const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/");
491
- const promptNorm = resolve(promptPath).replace(/\\/g, "/");
492
-
493
- let relativePath: string;
494
- if (workspaceRoot) {
495
- // Workspace mode: use worktree-relative path when the task folder is
496
- // inside the lane's repo. This ensures STATUS.md, .DONE, and git commits
497
- // all operate in the worktree (not the original source directory).
498
- if (promptNorm.startsWith(repoRootNorm + "/")) {
499
- relativePath = promptNorm.slice(repoRootNorm.length + 1);
500
- } else {
501
- // Cross-repo: task files live in a different repo than the worker's
502
- // worktree. Copy the task folder into the worktree so STATUS.md,
503
- // .DONE, and git commits all happen locally.
504
- const taskFolder = dirname(resolve(promptPath));
505
- const taskDirName = basename(taskFolder);
506
- const localTaskDir = join(lane.worktreePath, ".taskplane-tasks", taskDirName);
507
- mkdirSync(localTaskDir, { recursive: true });
508
- // Copy PROMPT.md and STATUS.md into the local task dir
509
- for (const file of ["PROMPT.md", "STATUS.md"]) {
510
- const src = join(taskFolder, file);
511
- const dst = join(localTaskDir, file);
512
- if (existsSync(src) && !existsSync(dst)) {
513
- copyFileSync(src, dst);
514
- }
515
- }
516
- // Create .reviews dir if it exists in source
517
- const reviewsDir = join(taskFolder, ".reviews");
518
- if (existsSync(reviewsDir)) {
519
- mkdirSync(join(localTaskDir, ".reviews"), { recursive: true });
520
- }
521
- relativePath = join(".taskplane-tasks", taskDirName, "PROMPT.md");
522
- }
523
- } else if (promptNorm.startsWith(repoRootNorm + "/")) {
524
- // Repo mode: relative path from repo root (mirrors into worktree)
525
- relativePath = promptNorm.slice(repoRootNorm.length + 1);
526
- } else {
527
- // Fallback: absolute path
528
- relativePath = resolve(promptPath);
529
- }
530
-
531
- const nodePathEntries: string[] = [join(repoRoot, "node_modules")];
532
- if (process.env.NODE_PATH) {
533
- nodePathEntries.push(...process.env.NODE_PATH.split(pathDelimiter).filter(Boolean));
534
- }
535
- const nodePath = [...new Set(nodePathEntries)].join(pathDelimiter);
536
-
537
- const vars: Record<string, string> = {
538
- TASK_AUTOSTART: relativePath,
539
- TASK_RUNNER_SPAWN_MODE: "tmux",
540
- TASK_RUNNER_TMUX_PREFIX: lane.tmuxSessionName,
541
- ORCH_SIDECAR_DIR: join(workspaceRoot || repoRoot, ".pi"),
542
- NODE_PATH: nodePath,
543
- // Pi's TUI (ink/react) hangs silently with TERM=tmux-256color (tmux default).
544
- // Force xterm-256color so pi can render and start execution.
545
- TERM: "xterm-256color",
546
- };
547
-
548
- // In workspace mode, the worktree cwd is inside a repo — not the workspace root.
549
- // The task-runner needs TASKPLANE_WORKSPACE_ROOT to find .pi/ config
550
- // and resolve task area paths from the correct base directory.
551
- // Always set when workspaceRoot is provided (workspace mode), regardless of
552
- // whether it equals repoRoot (it often does — cwd is the workspace root).
553
- if (workspaceRoot) {
554
- vars.TASKPLANE_WORKSPACE_ROOT = workspaceRoot;
555
- }
556
-
557
- return vars;
558
- }
559
-
560
- /**
561
- * Convert a Windows absolute path to a tmux-friendly POSIX-style path.
263
+ * Legacy lane environment-variable helper removed in TP-120.
562
264
  *
563
- * tmux `-c` expects POSIX paths when running under Git Bash/MSYS.
564
- * Passing `C:\...` can silently fall back to HOME, causing TASK_AUTOSTART
565
- * path resolution failures.
265
+ * Runtime V2 lane execution now runs through lane-runner/agent-host and no
266
+ * longer injects task-runner autostart/session env vars from this module.
566
267
  */
567
- export function toTmuxPath(pathValue: string): string {
568
- const normalized = resolve(pathValue).replace(/\\/g, "/");
569
- const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
570
- if (driveMatch) {
571
- return `/${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
572
- }
573
- return normalized;
574
- }
268
+ // buildLaneEnvVars removed (TP-120 remediation: legacy lane-session env var path, dead code)
575
269
 
576
- /**
577
- * Build the tmux new-session command for spawning a lane.
578
- *
579
- * Constructs a properly escaped command that:
580
- * 1. Sets env vars (TASK_AUTOSTART, TASK_RUNNER_SPAWN_MODE, TASK_RUNNER_TMUX_PREFIX)
581
- * 2. Runs `node rpc-wrapper.mjs` to spawn pi with the task-runner extension,
582
- * producing structured telemetry (sidecar JSONL + exit summary JSON).
583
- *
584
- * The RPC wrapper spawns pi in RPC mode with the task-runner extension loaded.
585
- * The extension's TASK_AUTOSTART env var triggers task execution on init.
586
- * A minimal prompt file is created to satisfy the wrapper's --prompt-file requirement.
587
- *
588
- * Shell escaping: env var values are single-quoted to prevent expansion.
589
- * Path args are single-quoted to handle spaces and special characters.
590
- *
591
- * @param sessionName - TMUX session name (e.g., "orch-lane-1")
592
- * @param worktreePath - Absolute path to the lane worktree
593
- * @param repoRoot - Absolute path to main repo (for extension absolute path)
594
- * @param envVars - Environment variables to set
595
- * @param laneLogPath - Optional path to write lane session stdout/stderr
596
- * @param sidecarPath - Path for RPC telemetry sidecar JSONL file
597
- * @param exitSummaryPath - Path for RPC telemetry exit summary JSON file
598
- * @returns Array of arguments for spawnSync("tmux", args)
599
- */
600
- export function buildTmuxSpawnArgs(
601
- sessionName: string,
602
- worktreePath: string,
603
- repoRoot: string,
604
- envVars: Record<string, string>,
605
- laneLogPath?: string,
606
- sidecarPath?: string,
607
- exitSummaryPath?: string,
608
- ): string[] {
609
- // Shell-quote a value for safe embedding in a command string.
610
- // Wraps in single quotes, escaping any internal single quotes.
611
- const shellQuote = (s: string): string => {
612
- if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
613
- return `'${s.replace(/'/g, "'\\''")}'`;
614
- }
615
- return s;
616
- };
617
-
618
- // Build the command string that runs inside the TMUX session.
619
- const envParts = Object.entries(envVars)
620
- .map(([key, val]) => `${key}=${shellQuote(val)}`)
621
- .join(" ");
622
-
623
- const taskRunnerExtPath = resolveTaskRunnerExtensionPath(repoRoot);
624
-
625
- let piCommand: string;
626
-
627
- if (sidecarPath && exitSummaryPath) {
628
- // ── RPC Wrapper mode: structured telemetry ──────────────
629
- // Spawn `node rpc-wrapper.mjs` instead of `pi` directly.
630
- // The wrapper runs pi in RPC mode, captures telemetry to
631
- // sidecar JSONL, and writes exit summary on process exit.
632
- const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
633
-
634
- // Create a minimal prompt file for the RPC wrapper.
635
- // The task-runner extension handles execution via TASK_AUTOSTART;
636
- // this prompt satisfies the wrapper's --prompt-file requirement.
637
- // Written to the sidecar dir (not tmpdir) so it's co-located with
638
- // telemetry artifacts and cleaned up with them after the batch.
639
- const promptId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
640
- const promptDir = dirname(sidecarPath);
641
- if (!existsSync(promptDir)) mkdirSync(promptDir, { recursive: true });
642
- const promptTmpFile = join(promptDir, `lane-prompt-${promptId}.txt`);
643
- writeFileSync(promptTmpFile, "Execute the task as configured by the task-runner extension.");
644
-
645
- piCommand = [
646
- envParts,
647
- "node", shellQuote(rpcWrapperPath),
648
- "--sidecar-path", shellQuote(sidecarPath),
649
- "--exit-summary-path", shellQuote(exitSummaryPath),
650
- "--prompt-file", shellQuote(promptTmpFile),
651
- "--extensions", shellQuote(taskRunnerExtPath),
652
- // Prevent pi from auto-discovering extensions from the worktree CWD.
653
- // Without this, pi loads BOTH the explicit -e extension AND any
654
- // extensions/ in the worktree, causing duplicate tool registration
655
- // and unpredictable behavior (two copies of task-runner compete).
656
- "--", "--no-extensions",
657
- ].filter(Boolean).join(" ");
658
- } else {
659
- // ── Legacy mode: direct pi spawn (no telemetry) ─────────
660
- piCommand = `${envParts} pi --no-session -e ${shellQuote(taskRunnerExtPath)}`;
661
- }
662
-
663
- // TP-095: Capture lane session stderr to a log file (#339).
664
- // When the lane session (rpc-wrapper → pi → task-runner) dies, stderr is
665
- // lost to tmux scrollback. Redirect stderr to a persistent log file
666
- // co-located with telemetry so the supervisor can diagnose lane deaths.
667
- //
668
- // We append stderr to a file using `2>>`. This captures all stderr output
669
- // from rpc-wrapper (which includes pi stderr forwarding, progress display,
670
- // and crash diagnostics). The tmux pane loses live stderr visibility, but
671
- // the dashboard provides live monitoring and the file preserves everything
672
- // for post-mortem analysis.
673
- //
674
- // Appended to piCommand (not the tmux shell wrapper) to target the
675
- // node/rpc-wrapper process specifically. This avoids the fragile shell
676
- // redirection issues that previously caused spawn failures on Windows.
677
- if (sidecarPath) {
678
- // Derive stderr log path from sidecar path:
679
- // .pi/telemetry/{basename}.jsonl → .pi/telemetry/{basename}-stderr.log
680
- const stderrLogPath = sidecarPath.replace(/\.jsonl$/, "-stderr.log");
681
- piCommand = `${piCommand} 2>> ${shellQuote(stderrLogPath)}`;
682
- }
683
-
684
- const tmuxWorktreePath = toTmuxPath(worktreePath);
685
- const wrappedCommand = `cd ${shellQuote(tmuxWorktreePath)} && ${piCommand}`;
686
-
687
- return [
688
- "new-session", "-d",
689
- "-s", sessionName,
690
- wrappedCommand,
691
- ];
270
+ function laneSessionIdOf(lane: Pick<AllocatedLane, "laneSessionId">): string {
271
+ return lane.laneSessionId;
692
272
  }
693
273
 
694
274
  /**
@@ -701,11 +281,11 @@ export function resolveLaneLogPath(
701
281
  lane: AllocatedLane,
702
282
  task: AllocatedTask,
703
283
  ): string {
704
- return join(lane.worktreePath, ".pi", "orch-logs", `${lane.tmuxSessionName}-${task.taskId}.log`);
284
+ return join(lane.worktreePath, ".pi", "orch-logs", `${laneSessionIdOf(lane)}-${task.taskId}.log`);
705
285
  }
706
286
 
707
287
  /**
708
- * Relative lane log path used inside the tmux shell command.
288
+ * Relative lane log path used by the legacy shell-spawn path.
709
289
  *
710
290
  * Relative paths avoid Windows drive-letter parsing issues in shell redirection.
711
291
  */
@@ -713,7 +293,7 @@ export function resolveLaneLogRelativePath(
713
293
  lane: AllocatedLane,
714
294
  task: AllocatedTask,
715
295
  ): string {
716
- return join(".pi", "orch-logs", `${lane.tmuxSessionName}-${task.taskId}.log`).replace(/\\/g, "/");
296
+ return join(".pi", "orch-logs", `${laneSessionIdOf(lane)}-${task.taskId}.log`).replace(/\\/g, "/");
717
297
  }
718
298
 
719
299
  /**
@@ -779,28 +359,6 @@ export async function fileExistsAsync(filePath: string): Promise<boolean> {
779
359
  }
780
360
  }
781
361
 
782
- /**
783
- * Capture tail output from a live TMUX pane for diagnostics.
784
- *
785
- * Works even when lane log redirection is disabled (Windows-safe fallback).
786
- */
787
- export function captureTmuxPaneTail(
788
- sessionName: string,
789
- maxLines: number = 40,
790
- maxChars: number = 1200,
791
- ): string {
792
- const result = spawnSync("tmux", ["capture-pane", "-p", "-t", sessionName], {
793
- encoding: "utf-8",
794
- timeout: 3000,
795
- });
796
- if (result.status !== 0) return "";
797
- const raw = (result.stdout || "").replace(/\r\n/g, "\n").trim();
798
- if (!raw) return "";
799
- const tail = raw.split("\n").slice(-maxLines).join("\n").trim();
800
- if (!tail) return "";
801
- return tail.length > maxChars ? tail.slice(-maxChars) : tail;
802
- }
803
-
804
362
  /**
805
363
  * Read a tail snippet from task STATUS.md for failure diagnostics.
806
364
  */
@@ -954,306 +512,26 @@ export function resolveTaskDonePath(
954
512
  return resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode).donePath;
955
513
  }
956
514
 
957
- /**
958
- * Spawn a TMUX session for a task in a lane.
959
- *
960
- * Handles:
961
- * - Stale session cleanup (kill if session name already exists)
962
- * - Retry on transient spawn failures (up to SESSION_SPAWN_RETRY_MAX)
963
- * - Structured logging
964
- *
965
- * @param lane - Allocated lane with worktree and session info
966
- * @param task - Task to execute
967
- * @param config - Orchestrator configuration
968
- * @param repoRoot - Main repository root
969
- * @throws ExecutionError if spawn fails after retries
970
- */
971
- export function spawnLaneSession(
972
- lane: AllocatedLane,
973
- task: AllocatedTask,
974
- config: OrchestratorConfig,
975
- repoRoot: string,
976
- workspaceRoot?: string,
977
- extraEnvVars?: Record<string, string>,
978
- ): void {
979
- const sessionName = lane.tmuxSessionName;
980
- const laneId = lane.laneId;
981
-
982
- execLog(laneId, task.taskId, "preparing to spawn TMUX session", {
983
- session: sessionName,
984
- worktree: lane.worktreePath,
985
- worktreeTmuxPath: toTmuxPath(lane.worktreePath),
986
- logPath: resolveLaneLogPath(lane, task),
987
- });
988
-
989
- // Pre-check: worktree exists
990
- if (!existsSync(lane.worktreePath)) {
991
- throw new ExecutionError(
992
- "EXEC_WORKTREE_MISSING",
993
- `Worktree path does not exist: ${lane.worktreePath}`,
994
- laneId,
995
- task.taskId,
996
- );
997
- }
998
-
999
- // Build env vars
1000
- const envVars = buildLaneEnvVars(lane, task.task.promptPath, repoRoot, workspaceRoot);
1001
- // ORCH_BATCH_ID is passed via extraEnvVars from executeWave → executeLane → spawnLaneSession.
1002
- // The task-runner reads it to include batchId in lane-state JSON for dashboard filtering.
1003
- if (extraEnvVars) {
1004
- Object.assign(envVars, extraEnvVars);
1005
- }
1006
-
1007
- // Prepare per-task lane log path for post-mortem diagnostics
1008
- const laneLogPath = resolveLaneLogPath(lane, task);
1009
- const laneLogRelativePath = resolveLaneLogRelativePath(lane, task);
1010
- try {
1011
- mkdirSync(dirname(laneLogPath), { recursive: true });
1012
- if (existsSync(laneLogPath)) {
1013
- unlinkSync(laneLogPath); // fresh log per task attempt
1014
- }
1015
- } catch {
1016
- // Best effort — session can still run without log file setup
1017
- }
1018
-
1019
- // Generate telemetry file paths for RPC wrapper sidecar
1020
- const sidecarRoot = join(workspaceRoot || repoRoot, ".pi");
1021
- const telemetry = generateTelemetryPaths(sessionName, sidecarRoot, task.taskId, config.orchestrator?.batchId, lane.repoId);
1022
- execLog(laneId, task.taskId, "telemetry paths generated", {
1023
- sidecar: telemetry.sidecarPath,
1024
- exitSummary: telemetry.exitSummaryPath,
1025
- });
1026
-
1027
- // Build tmux args (with RPC wrapper telemetry)
1028
- const tmuxArgs = buildTmuxSpawnArgs(sessionName, lane.worktreePath, repoRoot, envVars, laneLogRelativePath, telemetry.sidecarPath, telemetry.exitSummaryPath);
1029
-
1030
- // Clean up stale session if exists
1031
- if (tmuxHasSession(sessionName)) {
1032
- execLog(laneId, task.taskId, "killing stale TMUX session", { session: sessionName });
1033
- killLaneAndChildren(sessionName);
1034
- // Brief pause to let tmux clean up
1035
- spawnSync("sleep", ["0.5"], { shell: true, timeout: 3000 });
1036
- }
1037
-
1038
- // Attempt to spawn with retry
1039
- let lastError = "";
1040
- for (let attempt = 1; attempt <= SESSION_SPAWN_RETRY_MAX + 1; attempt++) {
1041
- const result = spawnSync("tmux", tmuxArgs);
1042
-
1043
- if (result.status === 0) {
1044
- execLog(laneId, task.taskId, "TMUX session spawned successfully", {
1045
- session: sessionName,
1046
- attempt,
1047
- });
1048
- return;
1049
- }
1050
-
1051
- lastError = result.stderr?.toString().trim() || "unknown spawn error";
1052
- execLog(laneId, task.taskId, `spawn attempt ${attempt} failed: ${lastError}`, {
1053
- session: sessionName,
1054
- });
1055
-
1056
- if (attempt <= SESSION_SPAWN_RETRY_MAX) {
1057
- // Wait before retry (1s, 2s)
1058
- const delayMs = attempt * 1000;
1059
- spawnSync("sleep", [`${delayMs / 1000}`], { shell: true, timeout: delayMs + 2000 });
1060
- }
1061
- }
1062
-
1063
- throw new ExecutionError(
1064
- "EXEC_SPAWN_FAILED",
1065
- `Failed to create TMUX session '${sessionName}' after ${SESSION_SPAWN_RETRY_MAX + 1} attempts. Last error: ${lastError}`,
1066
- laneId,
1067
- task.taskId,
1068
- );
1069
- }
1070
515
 
1071
- /**
1072
- * Poll until a task completes (or fails).
1073
- *
1074
- * Completion detection logic:
1075
- * 1. Check for .DONE file task succeeded (highest priority)
1076
- * 2. Check TMUX session liveness via `tmux has-session`
1077
- * 3. If session exits without .DONE → wait DONE_GRACE_MS (slow disk flush)
1078
- * 4. After grace period, if still no .DONE → task failed
1079
- *
1080
- * Terminal-state precedence: .DONE found at any point = success,
1081
- * regardless of session state.
1082
- *
1083
- * @param lane - Allocated lane
1084
- * @param task - Task being executed
1085
- * @param config - Orchestrator configuration
1086
- * @param repoRoot - Main repository root
1087
- * @param pauseSignal - Checked each poll cycle; if true, returns early with "skipped"
1088
- * @returns LaneTaskStatus indicating the final state
516
+ /*
517
+ * Removed in TP-120 while decommissioning the legacy session backend.
518
+ *
519
+ * `pollUntilTaskComplete` remains as a test-compatibility stub only.
520
+ * Runtime V2 completion detection now lives in lane-runner + agent-host.
1089
521
  */
522
+ // pollUntilTaskComplete function body removed — was ~170 lines of legacy .DONE polling.
523
+ // @ts-ignore — export kept as stub for test compatibility
1090
524
  export async function pollUntilTaskComplete(
1091
- lane: AllocatedLane,
1092
- task: AllocatedTask,
1093
- config: OrchestratorConfig,
1094
- repoRoot: string,
1095
- pauseSignal: { paused: boolean },
1096
- isWorkspaceMode?: boolean,
525
+ _lane: AllocatedLane,
526
+ _task: AllocatedTask,
527
+ _config: OrchestratorConfig,
528
+ _repoRoot: string,
529
+ _pauseSignal: { paused: boolean },
530
+ _isWorkspaceMode?: boolean,
1097
531
  ): Promise<{ status: LaneTaskStatus; exitReason: string; doneFileFound: boolean }> {
1098
- const sessionName = lane.tmuxSessionName;
1099
- const laneId = lane.laneId;
1100
- const resolved = resolveCanonicalTaskPaths(task.task.taskFolder, lane.worktreePath, repoRoot, isWorkspaceMode);
1101
- const donePath = resolved.donePath;
1102
- const statusPath = resolved.statusPath;
1103
- const laneLogPath = resolveLaneLogPath(lane, task);
1104
-
1105
- execLog(laneId, task.taskId, "polling for completion", {
1106
- session: sessionName,
1107
- donePath,
1108
- statusPath,
1109
- logPath: laneLogPath,
1110
- });
1111
-
1112
- let lastPaneTail = "";
1113
-
1114
- // Abort signal file path — checked each poll cycle.
1115
- // Any process can create this file to trigger abort (belt-and-suspenders
1116
- // alongside the in-memory pauseSignal, since /orch-abort may not be able
1117
- // to run concurrently with the /orch command handler).
1118
- const abortSignalFile = join(repoRoot, ".pi", "orch-abort-signal");
1119
-
1120
- // Main polling loop
1121
- while (true) {
1122
- // Check pause signal
1123
- if (pauseSignal.paused) {
1124
- execLog(laneId, task.taskId, "pause signal detected during poll");
1125
- // Don't kill the session — let the current task-runner checkpoint
1126
- // The calling code will handle marking as skipped
1127
- return {
1128
- status: "skipped",
1129
- exitReason: "Paused by user (/orch-pause)",
1130
- doneFileFound: false,
1131
- };
1132
- }
1133
-
1134
- // Check file-based abort signal (TP-070: async)
1135
- if (await fileExistsAsync(abortSignalFile)) {
1136
- execLog(laneId, task.taskId, "abort signal file detected — killing session and aborting");
1137
- await tmuxKillSessionAsync(sessionName);
1138
- // Also kill child sessions (worker, reviewer)
1139
- await tmuxKillSessionAsync(`${sessionName}-worker`);
1140
- await tmuxKillSessionAsync(`${sessionName}-reviewer`);
1141
- return {
1142
- status: "failed",
1143
- exitReason: "Aborted by signal file (.pi/orch-abort-signal)",
1144
- doneFileFound: false,
1145
- };
1146
- }
1147
-
1148
- // Capture live pane output for diagnostics (best effort) — async to avoid blocking.
1149
- const paneTail = await captureTmuxPaneTailAsync(sessionName);
1150
- if (paneTail) {
1151
- lastPaneTail = paneTail;
1152
- }
1153
-
1154
- // Priority 1: Check for .DONE file (TP-070: async)
1155
- if (await fileExistsAsync(donePath)) {
1156
- execLog(laneId, task.taskId, ".DONE file found — task succeeded", {
1157
- session: sessionName,
1158
- });
1159
- return {
1160
- status: "succeeded",
1161
- exitReason: ".DONE file created by task-runner",
1162
- doneFileFound: true,
1163
- };
1164
- }
1165
-
1166
- // Priority 2: Check if TMUX session is still alive — async to avoid blocking
1167
- if (!(await tmuxHasSessionAsync(sessionName))) {
1168
- // Session exited — start grace period for .DONE file
1169
- execLog(laneId, task.taskId, "TMUX session exited, entering grace period", {
1170
- session: sessionName,
1171
- graceMs: DONE_GRACE_MS,
1172
- });
1173
-
1174
- // Grace period: poll .DONE file at short intervals
1175
- const graceStart = Date.now();
1176
- while (Date.now() - graceStart < DONE_GRACE_MS) {
1177
- await new Promise((r) => setTimeout(r, 500));
1178
-
1179
- if (await fileExistsAsync(donePath)) {
1180
- execLog(laneId, task.taskId, ".DONE file found during grace period — task succeeded", {
1181
- session: sessionName,
1182
- });
1183
- return {
1184
- status: "succeeded",
1185
- exitReason: ".DONE file created (found during grace period)",
1186
- doneFileFound: true,
1187
- };
1188
- }
1189
- }
1190
-
1191
- // Grace period expired — last resort: check the lane BRANCH for .DONE.
1192
- // The worker may have committed .DONE before the session exited, but
1193
- // the worktree filesystem doesn't reflect it (stale checkout, race).
1194
- // This handles the common case where the worker completes all work,
1195
- // commits .DONE, and then the session exits before the poll detects it.
1196
- {
1197
- const relDonePath = donePath.startsWith(lane.worktreePath)
1198
- ? donePath.slice(lane.worktreePath.length).replace(/^[\\/]+/, "").replace(/\\/g, "/")
1199
- : null;
1200
- if (relDonePath) {
1201
- const gitResult = runGit(
1202
- ["show", `${lane.branch}:${relDonePath}`],
1203
- lane.worktreePath,
1204
- );
1205
- if (gitResult.ok) {
1206
- execLog(laneId, task.taskId, ".DONE found on lane branch (not in worktree) — task succeeded", {
1207
- session: sessionName,
1208
- branch: lane.branch,
1209
- });
1210
- return {
1211
- status: "succeeded",
1212
- exitReason: ".DONE committed to lane branch (found via git show after grace period)",
1213
- doneFileFound: true,
1214
- };
1215
- }
1216
- }
1217
- }
1218
-
1219
- // Truly failed — no .DONE on filesystem or branch
1220
- const logTail = await readLaneLogTailAsync(laneLogPath);
1221
- execLog(laneId, task.taskId, "grace period expired, no .DONE on filesystem or branch — task failed", {
1222
- session: sessionName,
1223
- logPath: laneLogPath,
1224
- });
1225
- if (logTail) {
1226
- execLog(laneId, task.taskId, `lane session output (tail):\n${logTail}`);
1227
- }
1228
- const statusTail = await readTaskStatusTailAsync(statusPath);
1229
- const hasLogFile = await fileExistsAsync(laneLogPath);
1230
- const outputForHint = logTail || lastPaneTail || statusTail;
1231
- const logHint = outputForHint
1232
- ? ` Last output: ${outputForHint.replace(/\s+/g, " ").slice(-300)}`
1233
- : "";
1234
- const logLocation = hasLogFile ? ` Lane log: ${laneLogPath}.` : "";
1235
- if (!logTail && lastPaneTail) {
1236
- execLog(laneId, task.taskId, `lane session output from TMUX pane (tail):\n${lastPaneTail}`);
1237
- }
1238
- if (statusTail) {
1239
- execLog(laneId, task.taskId, `task STATUS tail:\n${statusTail}`);
1240
- }
1241
- return {
1242
- status: "failed",
1243
- exitReason:
1244
- `TMUX session '${sessionName}' exited without creating .DONE file ` +
1245
- `(grace period ${DONE_GRACE_MS}ms expired).` +
1246
- `${logLocation}${logHint}`,
1247
- doneFileFound: false,
1248
- };
1249
- }
1250
-
1251
- // Session alive, no .DONE yet — keep polling
1252
- await new Promise((r) => setTimeout(r, EXECUTION_POLL_INTERVAL_MS));
1253
- }
532
+ return { status: "failed", exitReason: "Legacy pollUntilTaskComplete removed — use V2 lane-runner", doneFileFound: false };
1254
533
  }
1255
534
 
1256
-
1257
535
  // ── Post-Task Commit ─────────────────────────────────────────────────
1258
536
 
1259
537
  /**
@@ -1310,189 +588,6 @@ function commitTaskArtifacts(
1310
588
  }
1311
589
 
1312
590
 
1313
- /**
1314
- * Execute all tasks in a lane sequentially.
1315
- *
1316
- * For each task in the lane (in order):
1317
- * 1. Spawn a TMUX session with TASK_AUTOSTART pointing to the task's PROMPT.md
1318
- * 2. Poll until the task completes (or fails)
1319
- * 3. Commit any uncommitted task artifacts (.DONE, STATUS.md) to the lane branch
1320
- * 4. Record the outcome
1321
- * 5. If the task failed, skip remaining tasks in the lane
1322
- *
1323
- * The lane reuses the same worktree and TMUX session name across tasks.
1324
- * Each new task gets a fresh TMUX session (the previous one has exited).
1325
- *
1326
- * Cleanup policy:
1327
- * - On success: session exits naturally, no cleanup needed
1328
- * - On failure: session may have exited already; if alive, leave for debugging
1329
- * - On pause: stop after current task, mark remaining as skipped
1330
- * - On stall: handled by Step 3 (monitoring) — this function just polls
1331
- *
1332
- * @param lane - Fully allocated lane from Step 1
1333
- * @param config - Orchestrator configuration
1334
- * @param repoRoot - Main repository root
1335
- * @param pauseSignal - Shared signal for pause/abort (checked between tasks)
1336
- * @returns LaneExecutionResult with per-task outcomes
1337
- */
1338
- export async function executeLane(
1339
- lane: AllocatedLane,
1340
- config: OrchestratorConfig,
1341
- repoRoot: string,
1342
- pauseSignal: { paused: boolean },
1343
- workspaceRoot?: string,
1344
- isWorkspaceMode?: boolean,
1345
- extraEnvVars?: Record<string, string>,
1346
- ): Promise<LaneExecutionResult> {
1347
- const laneId = lane.laneId;
1348
- const laneStartTime = Date.now();
1349
- const outcomes: LaneTaskOutcome[] = [];
1350
- let shouldSkipRemaining = false;
1351
-
1352
- execLog(laneId, "LANE", `starting execution of ${lane.tasks.length} task(s)`, {
1353
- worktree: lane.worktreePath,
1354
- session: lane.tmuxSessionName,
1355
- });
1356
-
1357
- for (const task of lane.tasks) {
1358
- // Check if remaining tasks should be skipped (prior failure or pause)
1359
- if (shouldSkipRemaining || pauseSignal.paused) {
1360
- const reason = pauseSignal.paused
1361
- ? "Skipped due to pause signal"
1362
- : "Skipped due to prior task failure in lane";
1363
- execLog(laneId, task.taskId, reason);
1364
- outcomes.push({
1365
- taskId: task.taskId,
1366
- status: "skipped",
1367
- startTime: null,
1368
- endTime: null,
1369
- exitReason: reason,
1370
- sessionName: lane.tmuxSessionName,
1371
- doneFileFound: false,
1372
- laneNumber: lane.laneNumber,
1373
- });
1374
- continue;
1375
- }
1376
-
1377
- // Execute this task
1378
- const taskStartTime = Date.now();
1379
- let taskOutcome: LaneTaskOutcome;
1380
-
1381
- try {
1382
- // Spawn TMUX session
1383
- spawnLaneSession(lane, task, config, repoRoot, workspaceRoot, extraEnvVars);
1384
-
1385
- // Poll until completion
1386
- const pollResult = await pollUntilTaskComplete(
1387
- lane,
1388
- task,
1389
- config,
1390
- repoRoot,
1391
- pauseSignal,
1392
- isWorkspaceMode,
1393
- );
1394
-
1395
- taskOutcome = {
1396
- taskId: task.taskId,
1397
- status: pollResult.status,
1398
- startTime: taskStartTime,
1399
- endTime: Date.now(),
1400
- exitReason: pollResult.exitReason,
1401
- sessionName: lane.tmuxSessionName,
1402
- doneFileFound: pollResult.doneFileFound,
1403
- laneNumber: lane.laneNumber,
1404
- };
1405
-
1406
- // After task succeeds, commit any uncommitted artifacts (.DONE, final
1407
- // STATUS.md update) to the lane branch so they survive the merge.
1408
- // The task-runner writes .DONE via writeFileSync but never commits it.
1409
- if (pollResult.status === "succeeded") {
1410
- commitTaskArtifacts(lane, task, laneId);
1411
-
1412
- // Reset worktree to clean state for the next task on this lane.
1413
- // Without this, the next worker sees the previous task's modified
1414
- // files and can get confused about which task it's working on.
1415
- if (lane.tasks.indexOf(task) < lane.tasks.length - 1) {
1416
- execLog(laneId, task.taskId, "resetting worktree for next task");
1417
- const resetResult = runGit(["checkout", "--", "."], lane.worktreePath);
1418
- const cleanResult = runGit(["clean", "-fd"], lane.worktreePath);
1419
- if (!resetResult.ok || !cleanResult.ok) {
1420
- execLog(laneId, task.taskId, "worktree reset warning", {
1421
- resetOk: resetResult.ok,
1422
- cleanOk: cleanResult.ok,
1423
- resetErr: resetResult.stderr,
1424
- cleanErr: cleanResult.stderr,
1425
- });
1426
- }
1427
- }
1428
- }
1429
-
1430
- // If task failed or was paused, skip remaining tasks
1431
- if (pollResult.status === "failed" || pollResult.status === "stalled") {
1432
- shouldSkipRemaining = true;
1433
- }
1434
- if (pollResult.status === "skipped") {
1435
- // Pause was signaled during poll — mark remaining as skipped too
1436
- shouldSkipRemaining = true;
1437
- }
1438
- } catch (err: unknown) {
1439
- // Spawn or polling error
1440
- const errMsg = err instanceof Error ? err.message : String(err);
1441
- execLog(laneId, task.taskId, `execution error: ${errMsg}`);
1442
-
1443
- taskOutcome = {
1444
- taskId: task.taskId,
1445
- status: "failed",
1446
- startTime: taskStartTime,
1447
- endTime: Date.now(),
1448
- exitReason: errMsg,
1449
- sessionName: lane.tmuxSessionName,
1450
- doneFileFound: false,
1451
- laneNumber: lane.laneNumber,
1452
- };
1453
-
1454
- shouldSkipRemaining = true;
1455
- }
1456
-
1457
- const elapsed = Math.round(((taskOutcome.endTime || Date.now()) - taskStartTime) / 1000);
1458
- execLog(laneId, task.taskId, `task ${taskOutcome.status}`, {
1459
- elapsed: `${elapsed}s`,
1460
- doneFile: taskOutcome.doneFileFound,
1461
- });
1462
-
1463
- outcomes.push(taskOutcome);
1464
- }
1465
-
1466
- const laneEndTime = Date.now();
1467
- const succeededCount = outcomes.filter((o) => o.status === "succeeded").length;
1468
- const failedCount = outcomes.filter((o) => o.status === "failed" || o.status === "stalled").length;
1469
-
1470
- let overallStatus: LaneExecutionResult["overallStatus"];
1471
- if (failedCount === 0 && succeededCount === lane.tasks.length) {
1472
- overallStatus = "succeeded";
1473
- } else if (failedCount > 0 && succeededCount > 0) {
1474
- overallStatus = "partial";
1475
- } else {
1476
- overallStatus = "failed";
1477
- }
1478
-
1479
- const totalElapsed = Math.round((laneEndTime - laneStartTime) / 1000);
1480
- execLog(laneId, "LANE", `execution complete: ${overallStatus}`, {
1481
- succeeded: succeededCount,
1482
- failed: failedCount,
1483
- skipped: outcomes.filter((o) => o.status === "skipped").length,
1484
- elapsed: `${totalElapsed}s`,
1485
- });
1486
-
1487
- return {
1488
- laneNumber: lane.laneNumber,
1489
- laneId: lane.laneId,
1490
- tasks: outcomes,
1491
- overallStatus,
1492
- startTime: laneStartTime,
1493
- endTime: laneEndTime,
1494
- };
1495
- }
1496
591
 
1497
592
 
1498
593
  // ── STATUS.md Parsing for Worktree ───────────────────────────────────
@@ -1733,7 +828,7 @@ export async function parseWorktreeStatusMdAsync(
1733
828
  * State-resolution precedence (deterministic):
1734
829
  * 1. `.DONE` file found → "succeeded" (highest priority, always wins)
1735
830
  * 2. Stall timeout reached (mtime unchanged for stall_timeout AND session alive) → "stalled"
1736
- * 3. TMUX session exited without .DONE → "failed"
831
+ * 3. Lane session ended without .DONE → "failed"
1737
832
  * 4. Session alive + recent mtime (within stall_timeout) → "running"
1738
833
  * 5. Session alive + stale mtime but within startup grace → "running" (with no stall timer yet)
1739
834
  * 6. Session alive + no STATUS.md yet but within startup grace → "running"
@@ -1741,7 +836,7 @@ export async function parseWorktreeStatusMdAsync(
1741
836
  *
1742
837
  * @param taskId - Task identifier
1743
838
  * @param donePath - Absolute path to the .DONE file in the worktree
1744
- * @param sessionName - TMUX session name for this lane
839
+ * @param sessionName - Lane session name for this lane
1745
840
  * @param statusResult - Parsed STATUS.md result (may be null)
1746
841
  * @param tracker - Mtime tracker for stall detection
1747
842
  * @param stallTimeoutMs - Stall timeout in milliseconds
@@ -1762,7 +857,7 @@ export async function resolveTaskMonitorState(
1762
857
  // V2: read the lane snapshot file written by lane-runner every second.
1763
858
  // Snapshot status is authoritative — no PID probing needed.
1764
859
  // If snapshot doesn't exist yet, assume alive (lane-runner startup race).
1765
- // Legacy: check TMUX session.
860
+ // Legacy: check lane-session liveness.
1766
861
  let sessionAlive: boolean;
1767
862
  if (runtimeBackend === "v2" && v2Context) {
1768
863
  const snap = readLaneSnapshot(v2Context.stateRoot, v2Context.batchId, v2Context.laneNumber);
@@ -1773,10 +868,8 @@ export async function resolveTaskMonitorState(
1773
868
  } else {
1774
869
  sessionAlive = snap.status === "running";
1775
870
  }
1776
- } else if (runtimeBackend === "v2") {
1777
- sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
1778
871
  } else {
1779
- sessionAlive = await tmuxHasSessionAsync(sessionName);
872
+ sessionAlive = isV2AgentAlive(sessionName, "v2");
1780
873
  }
1781
874
  const doneFileFound = await fileExistsAsync(donePath);
1782
875
 
@@ -1874,11 +967,7 @@ export async function resolveTaskMonitorState(
1874
967
  stallMinutes,
1875
968
  backend: runtimeBackend ?? "legacy",
1876
969
  });
1877
- if (runtimeBackend === "v2") {
1878
- killV2LaneAgents(sessionName);
1879
- } else {
1880
- killLaneAndChildren(sessionName);
1881
- }
970
+ killV2LaneAgents(sessionName);
1882
971
 
1883
972
  return {
1884
973
  taskId,
@@ -1952,10 +1041,10 @@ export type MonitorUpdateCallback = (state: MonitorState) => void;
1952
1041
  * Monitor all lanes in a wave, polling for progress, completion, and stalls.
1953
1042
  *
1954
1043
  * This is the orchestrator's "air traffic control" — it does NOT attach
1955
- * to TMUX sessions. It monitors via filesystem polling:
1044
+ * to lane sessions directly. It monitors via filesystem polling:
1956
1045
  * - STATUS.md in each worktree for step/checkbox progress
1957
1046
  * - .DONE files for task completion
1958
- * - `tmux has-session` for session liveness
1047
+ * - backend liveness probes for session state
1959
1048
  * - STATUS.md mtime for stall detection
1960
1049
  *
1961
1050
  * The monitoring loop runs until all lanes reach terminal states
@@ -2091,7 +1180,7 @@ export async function monitorLanes(
2091
1180
  const snapshot = await resolveTaskMonitorState(
2092
1181
  task.taskId,
2093
1182
  donePath,
2094
- lane.tmuxSessionName,
1183
+ laneSessionIdOf(lane),
2095
1184
  statusResult,
2096
1185
  tracker,
2097
1186
  stallTimeoutMs,
@@ -2141,14 +1230,12 @@ export async function monitorLanes(
2141
1230
  }
2142
1231
 
2143
1232
  // TP-112: Backend-aware lane liveness for snapshot
2144
- const sessionAlive = runtimeBackend === "v2"
2145
- ? isV2AgentAlive(lane.tmuxSessionName, runtimeBackend)
2146
- : await tmuxHasSessionAsync(lane.tmuxSessionName);
1233
+ const sessionAlive = isV2AgentAlive(laneSessionIdOf(lane), "v2");
2147
1234
 
2148
1235
  laneSnapshots.push({
2149
1236
  laneId: lane.laneId,
2150
1237
  laneNumber: lane.laneNumber,
2151
- sessionName: lane.tmuxSessionName,
1238
+ sessionName: laneSessionIdOf(lane),
2152
1239
  sessionAlive,
2153
1240
  currentTaskId,
2154
1241
  currentTaskSnapshot,
@@ -2207,8 +1294,8 @@ export async function monitorLanes(
2207
1294
  const laneSnapshots: LaneMonitorSnapshot[] = lanes.map(lane => ({
2208
1295
  laneId: lane.laneId,
2209
1296
  laneNumber: lane.laneNumber,
2210
- sessionName: lane.tmuxSessionName,
2211
- sessionAlive: false, // Best-effort during pause — don't block with tmux call
1297
+ sessionName: laneSessionIdOf(lane),
1298
+ sessionAlive: false, // Best-effort during pause — don't block on extra liveness probes
2212
1299
  currentTaskId: null,
2213
1300
  currentTaskSnapshot: null,
2214
1301
  completedTasks: [],
@@ -2388,11 +1475,11 @@ export function ensureTaskFilesCommitted(
2388
1475
  * - **stop-wave**: On first failure, pauseSignal is set. In-flight tasks
2389
1476
  * finish their current work, remaining tasks in lanes are skipped.
2390
1477
  * No next wave is started (stoppedEarly=true).
2391
- * - **stop-all**: On first failure, all TMUX sessions are killed immediately.
1478
+ * - **stop-all**: On first failure, all active lane sessions are killed immediately.
2392
1479
  * Returns with aborted status.
2393
1480
  *
2394
1481
  * Concurrency model:
2395
- * - Lane execution promises are NOT cancellable (tmux sessions run externally)
1482
+ * - Lane execution promises are NOT cancellable (lane sessions run externally)
2396
1483
  * - stop-all kills sessions directly; executeLane() detects session death on next poll
2397
1484
  * - Monitoring stops when all lanes reach terminal state or pauseSignal is set
2398
1485
  *
@@ -2413,7 +1500,7 @@ export function ensureTaskFilesCommitted(
2413
1500
  /**
2414
1501
  * Runtime backend selector for lane execution.
2415
1502
  *
2416
- * - `"legacy"`: TMUX-backed path (spawnLaneSession → task-runner TASK_AUTOSTART)
1503
+ * - `"legacy"`: Session-backed path (spawnLaneSession → task-runner TASK_AUTOSTART)
2417
1504
  * - `"v2"`: Direct-child path (lane-runner → agent-host → pi --mode rpc)
2418
1505
  *
2419
1506
  * @since TP-105
@@ -2517,15 +1604,14 @@ export async function executeWave(
2517
1604
  // configPath is .pi/taskplane-workspace.yaml → parent of parent is workspace root.
2518
1605
  const wsRoot = workspaceConfig ? dirname(dirname(workspaceConfig.configPath)) : undefined;
2519
1606
  const isWsMode = !!workspaceConfig;
2520
- const backend = runtimeBackend ?? "legacy";
2521
- if (backend === "v2") {
2522
- execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)");
1607
+ const backend: RuntimeBackend = "v2";
1608
+ if (runtimeBackend && runtimeBackend !== "v2") {
1609
+ execLog("wave", `W${waveIndex}`, `legacy runtime backend '${runtimeBackend}' requested but ignored; using Runtime V2`);
2523
1610
  }
1611
+ execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)");
2524
1612
 
2525
1613
  const lanePromises = lanes.map(lane =>
2526
- backend === "v2"
2527
- ? executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }, onSupervisorAlert)
2528
- : executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }),
1614
+ executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }, onSupervisorAlert),
2529
1615
  );
2530
1616
 
2531
1617
  // Start monitoring as a sibling async loop
@@ -2578,7 +1664,7 @@ export async function executeWave(
2578
1664
  startTime: null,
2579
1665
  endTime: null,
2580
1666
  exitReason: `Lane promise rejected: ${errMsg}`,
2581
- sessionName: lanes[idx].tmuxSessionName,
1667
+ sessionName: laneSessionIdOf(lanes[idx]),
2582
1668
  doneFileFound: false,
2583
1669
  laneNumber: lanes[idx].laneNumber,
2584
1670
  })),
@@ -2695,7 +1781,7 @@ export async function executeWave(
2695
1781
  * Execute lanes with stop-all failure policy.
2696
1782
  *
2697
1783
  * Starts all lanes, then monitors for the first failure.
2698
- * On first failure: kills all TMUX sessions immediately and returns.
1784
+ * On first failure: kills all active lane sessions immediately and returns.
2699
1785
  *
2700
1786
  * Uses a race pattern: wraps each lane promise to signal on failure,
2701
1787
  * then kills all sessions when first failure is detected.
@@ -2748,12 +1834,12 @@ export async function executeWithStopAll(
2748
1834
  })[0];
2749
1835
 
2750
1836
  execLog("wave", `W${waveIndex}`, `stop-all triggered by ${firstFailed?.taskId || "unknown"} in ${lanes[idx].laneId}`, {
2751
- session: lanes[idx].tmuxSessionName,
1837
+ session: laneSessionIdOf(lanes[idx]),
2752
1838
  });
2753
1839
 
2754
1840
  // Kill ALL lane sessions immediately
2755
1841
  for (const lane of lanes) {
2756
- killLaneAndChildren(lane.tmuxSessionName);
1842
+ killV2LaneAgents(laneSessionIdOf(lane));
2757
1843
  }
2758
1844
  }
2759
1845
  }
@@ -2767,7 +1853,7 @@ export async function executeWithStopAll(
2767
1853
  pauseSignal.paused = true;
2768
1854
  execLog("wave", `W${waveIndex}`, `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`);
2769
1855
  for (const lane of lanes) {
2770
- killLaneAndChildren(lane.tmuxSessionName);
1856
+ killV2LaneAgents(laneSessionIdOf(lane));
2771
1857
  }
2772
1858
  }
2773
1859
 
@@ -2781,7 +1867,7 @@ export async function executeWithStopAll(
2781
1867
  startTime: null,
2782
1868
  endTime: null,
2783
1869
  exitReason: `Lane aborted: ${errMsg}`,
2784
- sessionName: lanes[idx].tmuxSessionName,
1870
+ sessionName: laneSessionIdOf(lanes[idx]),
2785
1871
  doneFileFound: false,
2786
1872
  laneNumber: lanes[idx].laneNumber,
2787
1873
  })),
@@ -2816,7 +1902,7 @@ export async function executeWithStopAll(
2816
1902
  //
2817
1903
  // They are additive — existing code paths continue to work.
2818
1904
  // Runtime V2 consumers can start using these to avoid coupling to
2819
- // TMUX naming, cwd-derived paths, or extension lifecycle assumptions.
1905
+ // legacy lane-session naming, cwd-derived paths, or extension lifecycle assumptions.
2820
1906
  // ────────────────────────────────────────────────────────────────────────────
2821
1907
 
2822
1908
  /**
@@ -2893,11 +1979,11 @@ export function buildExecutionUnit(
2893
1979
  /**
2894
1980
  * Build a RuntimeAgentId for a lane's agent from existing naming.
2895
1981
  *
2896
- * Bridges the current TMUX session naming convention into a
1982
+ * Bridges the current lane-session naming convention into a
2897
1983
  * Runtime V2 stable agent ID. The output is compatible with
2898
1984
  * existing supervisor tools and mailbox addressing.
2899
1985
  *
2900
- * @param lane - Allocated lane with TMUX session name
1986
+ * @param lane - Allocated lane with a lane session name
2901
1987
  * @param role - Agent role
2902
1988
  * @param mergeIndex - Merge wave index (only for merge agents)
2903
1989
  * @returns Canonical agent ID
@@ -2909,18 +1995,18 @@ export function buildAgentIdFromLane(
2909
1995
  role: RuntimeAgentRole,
2910
1996
  mergeIndex?: number,
2911
1997
  ): RuntimeAgentId {
2912
- // The current tmuxSessionName is already in the right format
1998
+ // The current laneSessionId is already in the right format
2913
1999
  // (e.g., "orch-henrylach-lane-1"). We derive agent IDs from it
2914
2000
  // by appending the role suffix, matching the existing convention.
2915
2001
  if (role === "merger" && mergeIndex != null) {
2916
2002
  // Merge agents use a different naming pattern
2917
- const prefix = lane.tmuxSessionName.replace(/-lane-\d+$/, "");
2003
+ const prefix = laneSessionIdOf(lane).replace(/-lane-\d+$/, "");
2918
2004
  return `${prefix}-merge-${mergeIndex}`;
2919
2005
  }
2920
2006
  if (role === "lane-runner") {
2921
- return lane.tmuxSessionName;
2007
+ return laneSessionIdOf(lane);
2922
2008
  }
2923
- return `${lane.tmuxSessionName}-${role}`;
2009
+ return `${laneSessionIdOf(lane)}-${role}`;
2924
2010
  }
2925
2011
 
2926
2012
  /**
@@ -3029,14 +2115,14 @@ import { executeTaskV2, type LaneRunnerConfig, type LaneRunnerTaskResult } from
3029
2115
  /**
3030
2116
  * Execute a lane using the Runtime V2 headless backend.
3031
2117
  *
3032
- * This replaces the legacy TMUX-backed `executeLane()` for lanes that
2118
+ * This replaces the legacy session-backed `executeLane()` for lanes that
3033
2119
  * should run on the new direct-child architecture. It uses the
3034
2120
  * lane-runner module which spawns workers via agent-host.ts instead
3035
- * of TMUX sessions.
2121
+ * of terminal-session-backed workers.
3036
2122
  *
3037
2123
  * The function signature is deliberately close to the legacy
3038
2124
  * `executeLane()` to minimize integration churn in the engine.
3039
- * The key difference: no TMUX sessions are created.
2125
+ * The key difference: no legacy lane sessions are created.
3040
2126
  *
3041
2127
  * @since TP-105
3042
2128
  */
@@ -3059,10 +2145,10 @@ export async function executeLaneV2(
3059
2145
  const batchId = config.orchestrator?.batchId || extraEnvVars?.ORCH_BATCH_ID || String(Date.now());
3060
2146
 
3061
2147
  // Build agent ID prefix — must match the wave planner's naming (TP-115).
3062
- // Uses resolveOperatorId() so agent registry keys align with tmuxSessionName.
3063
- const tmuxPrefix = config.orchestrator?.tmux_prefix ?? "orch";
2148
+ // Uses resolveOperatorId() so agent registry keys align with lane session IDs.
2149
+ const sessionPrefix = config.orchestrator?.sessionPrefix ?? "orch";
3064
2150
  const opId = resolveOperatorId(config);
3065
- const agentIdPrefix = `${tmuxPrefix}-${opId}`;
2151
+ const agentIdPrefix = `${sessionPrefix}-${opId}`;
3066
2152
 
3067
2153
  // Load worker agent definition: compose base template + local project guidance.
3068
2154
  // The base template (templates/agents/task-worker.md) contains critical behavioral