taskplane 0.23.16 → 0.24.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.
@@ -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
- });
356
-
357
- proc.on("error", () => {
358
- // Spawn failure — treat as non-zero exit
359
- resolve({ status: 1, stdout: "" });
360
- });
227
+ // ── Async File/Status Helpers (TP-070) ───────────────────────────────
361
228
 
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
- }
575
-
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
- }
268
+ // buildLaneEnvVars removed (TP-120 remediation: legacy lane-session env var path, dead code)
683
269
 
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
515
 
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
-
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
@@ -1758,25 +853,31 @@ export async function resolveTaskMonitorState(
1758
853
  runtimeBackend?: RuntimeBackend,
1759
854
  v2Context?: { stateRoot: string; batchId: string; laneNumber: number },
1760
855
  ): Promise<TaskMonitorSnapshot> {
1761
- // TP-115: Backend-aware liveness check.
856
+ // TP-115/TP-127: Backend-aware liveness check.
1762
857
  // V2: read the lane snapshot file written by lane-runner every second.
1763
- // Snapshot status is authoritative — no PID probing needed.
1764
858
  // If snapshot doesn't exist yet, assume alive (lane-runner startup race).
1765
- // Legacy: check TMUX session.
859
+ // If snapshot belongs to a different task, it's stale transition data from
860
+ // the previous wave/task and should be treated like startup grace (alive).
861
+ // Legacy: check lane-session liveness.
1766
862
  let sessionAlive: boolean;
1767
863
  if (runtimeBackend === "v2" && v2Context) {
1768
864
  const snap = readLaneSnapshot(v2Context.stateRoot, v2Context.batchId, v2Context.laneNumber);
1769
- if (snap == null) {
1770
- // Snapshot not written yet lane-runner is still starting up.
1771
- // Assume alive to avoid false "failed" from monitor racing lane startup.
1772
- sessionAlive = true;
865
+ if (snap == null || snap.taskId !== taskId) {
866
+ // Snapshot not written yet OR snapshot still points to a prior task.
867
+ // Assume alive initially, but if stale for >30s consult the registry
868
+ // to avoid indefinite false "running" if the lane-runner died.
869
+ const staleMs = snap?.updatedAt ? (now - snap.updatedAt) : 0;
870
+ if (staleMs > 30_000) {
871
+ // Snapshot hasn't been updated for 30s+ — check registry as fallback
872
+ sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
873
+ } else {
874
+ sessionAlive = true;
875
+ }
1773
876
  } else {
1774
877
  sessionAlive = snap.status === "running";
1775
878
  }
1776
- } else if (runtimeBackend === "v2") {
1777
- sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
1778
879
  } else {
1779
- sessionAlive = await tmuxHasSessionAsync(sessionName);
880
+ sessionAlive = isV2AgentAlive(sessionName, "v2");
1780
881
  }
1781
882
  const doneFileFound = await fileExistsAsync(donePath);
1782
883
 
@@ -1874,11 +975,7 @@ export async function resolveTaskMonitorState(
1874
975
  stallMinutes,
1875
976
  backend: runtimeBackend ?? "legacy",
1876
977
  });
1877
- if (runtimeBackend === "v2") {
1878
- killV2LaneAgents(sessionName);
1879
- } else {
1880
- killLaneAndChildren(sessionName);
1881
- }
978
+ killV2LaneAgents(sessionName);
1882
979
 
1883
980
  return {
1884
981
  taskId,
@@ -1952,10 +1049,10 @@ export type MonitorUpdateCallback = (state: MonitorState) => void;
1952
1049
  * Monitor all lanes in a wave, polling for progress, completion, and stalls.
1953
1050
  *
1954
1051
  * This is the orchestrator's "air traffic control" — it does NOT attach
1955
- * to TMUX sessions. It monitors via filesystem polling:
1052
+ * to lane sessions directly. It monitors via filesystem polling:
1956
1053
  * - STATUS.md in each worktree for step/checkbox progress
1957
1054
  * - .DONE files for task completion
1958
- * - `tmux has-session` for session liveness
1055
+ * - backend liveness probes for session state
1959
1056
  * - STATUS.md mtime for stall detection
1960
1057
  *
1961
1058
  * The monitoring loop runs until all lanes reach terminal states
@@ -2091,7 +1188,7 @@ export async function monitorLanes(
2091
1188
  const snapshot = await resolveTaskMonitorState(
2092
1189
  task.taskId,
2093
1190
  donePath,
2094
- lane.tmuxSessionName,
1191
+ laneSessionIdOf(lane),
2095
1192
  statusResult,
2096
1193
  tracker,
2097
1194
  stallTimeoutMs,
@@ -2141,14 +1238,12 @@ export async function monitorLanes(
2141
1238
  }
2142
1239
 
2143
1240
  // TP-112: Backend-aware lane liveness for snapshot
2144
- const sessionAlive = runtimeBackend === "v2"
2145
- ? isV2AgentAlive(lane.tmuxSessionName, runtimeBackend)
2146
- : await tmuxHasSessionAsync(lane.tmuxSessionName);
1241
+ const sessionAlive = isV2AgentAlive(laneSessionIdOf(lane), "v2");
2147
1242
 
2148
1243
  laneSnapshots.push({
2149
1244
  laneId: lane.laneId,
2150
1245
  laneNumber: lane.laneNumber,
2151
- sessionName: lane.tmuxSessionName,
1246
+ sessionName: laneSessionIdOf(lane),
2152
1247
  sessionAlive,
2153
1248
  currentTaskId,
2154
1249
  currentTaskSnapshot,
@@ -2207,8 +1302,8 @@ export async function monitorLanes(
2207
1302
  const laneSnapshots: LaneMonitorSnapshot[] = lanes.map(lane => ({
2208
1303
  laneId: lane.laneId,
2209
1304
  laneNumber: lane.laneNumber,
2210
- sessionName: lane.tmuxSessionName,
2211
- sessionAlive: false, // Best-effort during pause — don't block with tmux call
1305
+ sessionName: laneSessionIdOf(lane),
1306
+ sessionAlive: false, // Best-effort during pause — don't block on extra liveness probes
2212
1307
  currentTaskId: null,
2213
1308
  currentTaskSnapshot: null,
2214
1309
  completedTasks: [],
@@ -2388,11 +1483,11 @@ export function ensureTaskFilesCommitted(
2388
1483
  * - **stop-wave**: On first failure, pauseSignal is set. In-flight tasks
2389
1484
  * finish their current work, remaining tasks in lanes are skipped.
2390
1485
  * No next wave is started (stoppedEarly=true).
2391
- * - **stop-all**: On first failure, all TMUX sessions are killed immediately.
1486
+ * - **stop-all**: On first failure, all active lane sessions are killed immediately.
2392
1487
  * Returns with aborted status.
2393
1488
  *
2394
1489
  * Concurrency model:
2395
- * - Lane execution promises are NOT cancellable (tmux sessions run externally)
1490
+ * - Lane execution promises are NOT cancellable (lane sessions run externally)
2396
1491
  * - stop-all kills sessions directly; executeLane() detects session death on next poll
2397
1492
  * - Monitoring stops when all lanes reach terminal state or pauseSignal is set
2398
1493
  *
@@ -2413,7 +1508,7 @@ export function ensureTaskFilesCommitted(
2413
1508
  /**
2414
1509
  * Runtime backend selector for lane execution.
2415
1510
  *
2416
- * - `"legacy"`: TMUX-backed path (spawnLaneSession → task-runner TASK_AUTOSTART)
1511
+ * - `"legacy"`: Session-backed path (spawnLaneSession → task-runner TASK_AUTOSTART)
2417
1512
  * - `"v2"`: Direct-child path (lane-runner → agent-host → pi --mode rpc)
2418
1513
  *
2419
1514
  * @since TP-105
@@ -2517,15 +1612,14 @@ export async function executeWave(
2517
1612
  // configPath is .pi/taskplane-workspace.yaml → parent of parent is workspace root.
2518
1613
  const wsRoot = workspaceConfig ? dirname(dirname(workspaceConfig.configPath)) : undefined;
2519
1614
  const isWsMode = !!workspaceConfig;
2520
- const backend = runtimeBackend ?? "legacy";
2521
- if (backend === "v2") {
2522
- execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)");
1615
+ const backend: RuntimeBackend = "v2";
1616
+ if (runtimeBackend && runtimeBackend !== "v2") {
1617
+ execLog("wave", `W${waveIndex}`, `legacy runtime backend '${runtimeBackend}' requested but ignored; using Runtime V2`);
2523
1618
  }
1619
+ execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)");
2524
1620
 
2525
1621
  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 }),
1622
+ executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }, onSupervisorAlert),
2529
1623
  );
2530
1624
 
2531
1625
  // Start monitoring as a sibling async loop
@@ -2578,7 +1672,7 @@ export async function executeWave(
2578
1672
  startTime: null,
2579
1673
  endTime: null,
2580
1674
  exitReason: `Lane promise rejected: ${errMsg}`,
2581
- sessionName: lanes[idx].tmuxSessionName,
1675
+ sessionName: laneSessionIdOf(lanes[idx]),
2582
1676
  doneFileFound: false,
2583
1677
  laneNumber: lanes[idx].laneNumber,
2584
1678
  })),
@@ -2695,7 +1789,7 @@ export async function executeWave(
2695
1789
  * Execute lanes with stop-all failure policy.
2696
1790
  *
2697
1791
  * Starts all lanes, then monitors for the first failure.
2698
- * On first failure: kills all TMUX sessions immediately and returns.
1792
+ * On first failure: kills all active lane sessions immediately and returns.
2699
1793
  *
2700
1794
  * Uses a race pattern: wraps each lane promise to signal on failure,
2701
1795
  * then kills all sessions when first failure is detected.
@@ -2748,12 +1842,12 @@ export async function executeWithStopAll(
2748
1842
  })[0];
2749
1843
 
2750
1844
  execLog("wave", `W${waveIndex}`, `stop-all triggered by ${firstFailed?.taskId || "unknown"} in ${lanes[idx].laneId}`, {
2751
- session: lanes[idx].tmuxSessionName,
1845
+ session: laneSessionIdOf(lanes[idx]),
2752
1846
  });
2753
1847
 
2754
1848
  // Kill ALL lane sessions immediately
2755
1849
  for (const lane of lanes) {
2756
- killLaneAndChildren(lane.tmuxSessionName);
1850
+ killV2LaneAgents(laneSessionIdOf(lane));
2757
1851
  }
2758
1852
  }
2759
1853
  }
@@ -2767,7 +1861,7 @@ export async function executeWithStopAll(
2767
1861
  pauseSignal.paused = true;
2768
1862
  execLog("wave", `W${waveIndex}`, `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`);
2769
1863
  for (const lane of lanes) {
2770
- killLaneAndChildren(lane.tmuxSessionName);
1864
+ killV2LaneAgents(laneSessionIdOf(lane));
2771
1865
  }
2772
1866
  }
2773
1867
 
@@ -2781,7 +1875,7 @@ export async function executeWithStopAll(
2781
1875
  startTime: null,
2782
1876
  endTime: null,
2783
1877
  exitReason: `Lane aborted: ${errMsg}`,
2784
- sessionName: lanes[idx].tmuxSessionName,
1878
+ sessionName: laneSessionIdOf(lanes[idx]),
2785
1879
  doneFileFound: false,
2786
1880
  laneNumber: lanes[idx].laneNumber,
2787
1881
  })),
@@ -2816,7 +1910,7 @@ export async function executeWithStopAll(
2816
1910
  //
2817
1911
  // They are additive — existing code paths continue to work.
2818
1912
  // Runtime V2 consumers can start using these to avoid coupling to
2819
- // TMUX naming, cwd-derived paths, or extension lifecycle assumptions.
1913
+ // legacy lane-session naming, cwd-derived paths, or extension lifecycle assumptions.
2820
1914
  // ────────────────────────────────────────────────────────────────────────────
2821
1915
 
2822
1916
  /**
@@ -2893,11 +1987,11 @@ export function buildExecutionUnit(
2893
1987
  /**
2894
1988
  * Build a RuntimeAgentId for a lane's agent from existing naming.
2895
1989
  *
2896
- * Bridges the current TMUX session naming convention into a
1990
+ * Bridges the current lane-session naming convention into a
2897
1991
  * Runtime V2 stable agent ID. The output is compatible with
2898
1992
  * existing supervisor tools and mailbox addressing.
2899
1993
  *
2900
- * @param lane - Allocated lane with TMUX session name
1994
+ * @param lane - Allocated lane with a lane session name
2901
1995
  * @param role - Agent role
2902
1996
  * @param mergeIndex - Merge wave index (only for merge agents)
2903
1997
  * @returns Canonical agent ID
@@ -2909,18 +2003,18 @@ export function buildAgentIdFromLane(
2909
2003
  role: RuntimeAgentRole,
2910
2004
  mergeIndex?: number,
2911
2005
  ): RuntimeAgentId {
2912
- // The current tmuxSessionName is already in the right format
2006
+ // The current laneSessionId is already in the right format
2913
2007
  // (e.g., "orch-henrylach-lane-1"). We derive agent IDs from it
2914
2008
  // by appending the role suffix, matching the existing convention.
2915
2009
  if (role === "merger" && mergeIndex != null) {
2916
2010
  // Merge agents use a different naming pattern
2917
- const prefix = lane.tmuxSessionName.replace(/-lane-\d+$/, "");
2011
+ const prefix = laneSessionIdOf(lane).replace(/-lane-\d+$/, "");
2918
2012
  return `${prefix}-merge-${mergeIndex}`;
2919
2013
  }
2920
2014
  if (role === "lane-runner") {
2921
- return lane.tmuxSessionName;
2015
+ return laneSessionIdOf(lane);
2922
2016
  }
2923
- return `${lane.tmuxSessionName}-${role}`;
2017
+ return `${laneSessionIdOf(lane)}-${role}`;
2924
2018
  }
2925
2019
 
2926
2020
  /**
@@ -3029,14 +2123,14 @@ import { executeTaskV2, type LaneRunnerConfig, type LaneRunnerTaskResult } from
3029
2123
  /**
3030
2124
  * Execute a lane using the Runtime V2 headless backend.
3031
2125
  *
3032
- * This replaces the legacy TMUX-backed `executeLane()` for lanes that
2126
+ * This replaces the legacy session-backed `executeLane()` for lanes that
3033
2127
  * should run on the new direct-child architecture. It uses the
3034
2128
  * lane-runner module which spawns workers via agent-host.ts instead
3035
- * of TMUX sessions.
2129
+ * of terminal-session-backed workers.
3036
2130
  *
3037
2131
  * The function signature is deliberately close to the legacy
3038
2132
  * `executeLane()` to minimize integration churn in the engine.
3039
- * The key difference: no TMUX sessions are created.
2133
+ * The key difference: no legacy lane sessions are created.
3040
2134
  *
3041
2135
  * @since TP-105
3042
2136
  */
@@ -3059,10 +2153,10 @@ export async function executeLaneV2(
3059
2153
  const batchId = config.orchestrator?.batchId || extraEnvVars?.ORCH_BATCH_ID || String(Date.now());
3060
2154
 
3061
2155
  // 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";
2156
+ // Uses resolveOperatorId() so agent registry keys align with lane session IDs.
2157
+ const sessionPrefix = config.orchestrator?.sessionPrefix ?? "orch";
3064
2158
  const opId = resolveOperatorId(config);
3065
- const agentIdPrefix = `${tmuxPrefix}-${opId}`;
2159
+ const agentIdPrefix = `${sessionPrefix}-${opId}`;
3066
2160
 
3067
2161
  // Load worker agent definition: compose base template + local project guidance.
3068
2162
  // The base template (templates/agents/task-worker.md) contains critical behavioral