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.
@@ -7,12 +7,12 @@ import { readFile as fsReadFile } from "fs/promises";
7
7
  import { execSync, spawnSync } from "child_process";
8
8
  import { join, dirname, resolve, relative } from "path";
9
9
 
10
- import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, resolveTelemOpId, tmuxHasSession, tmuxHasSessionAsync, tmuxKillSession, tmuxKillSessionAsync, tmuxAsync, toTmuxPath } from "./execution.ts";
10
+ import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts";
11
11
  import { resolveOperatorId } from "./naming.ts";
12
- import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS, MERGE_HEALTH_CAPTURE_LINES, MergeError, VALID_MERGE_STATUSES, buildEngineEventBase } from "./types.ts";
12
+ import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS, MergeError, VALID_MERGE_STATUSES, buildEngineEventBase } from "./types.ts";
13
13
  import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase } from "./types.ts";
14
14
  import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
15
- import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot } from "./process-registry.ts";
15
+ import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot, readRegistrySnapshot } from "./process-registry.ts";
16
16
  import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts";
17
17
  import { getCurrentBranch, runGit } from "./git.ts";
18
18
  import { ORCH_MESSAGES } from "./messages.ts";
@@ -24,47 +24,7 @@ import type { AgentHostOptions, AgentHostResult } from "./agent-host.ts";
24
24
  import type { RuntimeBackend } from "./execution.ts";
25
25
  import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
26
26
 
27
- // ── Merge Telemetry Helpers ───────────────────────────────────────────
28
27
 
29
- /**
30
- * Generate telemetry file paths for a merge agent session.
31
- *
32
- * Uses the shared resolveTelemOpId() from execution.ts to avoid
33
- * opId resolution divergence.
34
- *
35
- * Naming: {opId}-{batchId}-{repoId}[-merge-{N}]-merger.{ext}
36
- * Role is always "merger" to distinguish from worker/reviewer in the dashboard.
37
- *
38
- * @param sessionName - TMUX session name (e.g., "orch-merge-1")
39
- * @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi)
40
- * @param batchId - Actual batch ID from batch state (falls back to timestamp)
41
- * @param repoId - Repo ID for workspace mode (falls back to "default")
42
- * @returns { sidecarPath, exitSummaryPath }
43
- */
44
- function generateMergeTelemetryPaths(
45
- sessionName: string,
46
- sidecarRoot: string,
47
- batchId?: string,
48
- repoId?: string,
49
- ): { sidecarPath: string; exitSummaryPath: string } {
50
- const opId = resolveTelemOpId();
51
- const effectiveBatchId = batchId || String(Date.now());
52
- const effectiveRepoId = repoId || "default";
53
-
54
- // Extract merge-specific info from sessionName (e.g., "orch-merge-1")
55
- const mergeMatch = sessionName.match(/merge-(\d+)/);
56
- const mergeSuffix = mergeMatch ? `-merge-${mergeMatch[1]}` : "";
57
-
58
- const role = "merger";
59
- const telemetryBasename = `${opId}-${effectiveBatchId}-${effectiveRepoId}${mergeSuffix}-${role}`;
60
- const telemetryDir = join(sidecarRoot, "telemetry");
61
- if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
62
-
63
- return {
64
- sidecarPath: join(telemetryDir, `${telemetryBasename}.jsonl`),
65
- exitSummaryPath: join(telemetryDir, `${telemetryBasename}-exit.json`),
66
- };
67
- }
68
28
 
69
29
  // ── Merge Implementation ─────────────────────────────────────────────
70
30
 
@@ -562,153 +522,10 @@ export function buildMergeRequest(
562
522
  return lines.join("\n");
563
523
  }
564
524
 
565
- /**
566
- * Spawn a TMUX session for the merge agent.
567
- *
568
- * Creates a TMUX session in the main repo directory (not a worktree)
569
- * that runs pi with the task-merger agent definition and the merge request.
570
- *
571
- * Handles:
572
- * - Stale session cleanup
573
- * - Retry on transient spawn failures
574
- * - Structured logging
575
- *
576
- * @param sessionName - TMUX session name (e.g., "orch-merge-1")
577
- * @param repoRoot - Main repository root (merge happens here)
578
- * @param mergeRequestPath - Path to the merge request temp file
579
- * @param config - Orchestrator config (for model, tools)
580
- * @param stateRoot - Root for state files (batch state, merge results). Stays at workspace root.
581
- * @param agentRoot - Root for agent prompts. When pointer is resolved, this is the config repo's agent dir. Falls back to `<stateRoot>/.pi/agents/` or `<repoRoot>/.pi/agents/`.
582
- * @throws MergeError if spawn fails after retries
583
- */
584
- export async function spawnMergeAgent(
585
- sessionName: string,
586
- repoRoot: string,
587
- mergeWorkDir: string,
588
- mergeRequestPath: string,
589
- config: OrchestratorConfig,
590
- stateRoot?: string,
591
- agentRoot?: string,
592
- batchId?: string,
593
- ): Promise<void> {
594
- execLog("merge", sessionName, "preparing to spawn merge agent", {
595
- mergeWorkDir,
596
- mergeRequestPath,
597
- });
598
-
599
- // Clean up stale session if exists
600
- if (tmuxHasSession(sessionName)) {
601
- execLog("merge", sessionName, "killing stale merge session");
602
- tmuxKillSession(sessionName);
603
- await sleepAsync(500);
604
- }
605
-
606
- // Build the merge agent command.
607
- // Uses rpc-wrapper.mjs to produce structured telemetry (sidecar JSONL + exit summary).
608
- // The merger agent definition is loaded via --system-prompt-file.
609
- // The merge request is passed as the --prompt-file.
610
- const shellQuote = (s: string): string => {
611
- if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
612
- return `'${s.replace(/'/g, "'\\''")}'`;
613
- }
614
- return s;
615
- };
616
-
617
- // Generate telemetry paths for this merge session
618
- const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
619
- const telemetry = generateMergeTelemetryPaths(sessionName, sidecarRoot);
620
- execLog("merge", sessionName, "telemetry paths generated", {
621
- sidecar: telemetry.sidecarPath,
622
- exitSummary: telemetry.exitSummaryPath,
623
- });
624
-
625
- // Resolve paths
626
- const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
627
-
628
- // Resolve merger agent definition — check existence and fall back gracefully.
629
- // Fresh projects that haven't run `taskplane init` may not have .pi/agents/task-merger.md.
630
- const systemPromptCandidates = [
631
- agentRoot ? join(agentRoot, "task-merger.md") : "",
632
- join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"),
633
- ].filter(Boolean);
634
- let systemPromptPath = systemPromptCandidates.find(p => existsSync(p)) || "";
635
- if (!systemPromptPath) {
636
- execLog("merge", sessionName, "WARNING: merger agent definition not found — merge agent will use default system prompt", {
637
- candidates: systemPromptCandidates,
638
- });
639
- }
640
-
641
- // Build RPC wrapper command
642
- const wrapperParts = [
643
- "TERM=xterm-256color",
644
- "node", shellQuote(rpcWrapperPath),
645
- "--sidecar-path", shellQuote(telemetry.sidecarPath),
646
- "--exit-summary-path", shellQuote(telemetry.exitSummaryPath),
647
- "--prompt-file", shellQuote(mergeRequestPath),
648
- ];
649
-
650
- // Only pass --system-prompt-file when the file exists (fresh projects
651
- // may not have .pi/agents/task-merger.md — rpc-wrapper would crash).
652
- if (systemPromptPath) {
653
- wrapperParts.push("--system-prompt-file", shellQuote(systemPromptPath));
654
- }
655
-
656
- // Add model args if specified
657
- if (config.merge.model) {
658
- wrapperParts.push("--model", shellQuote(config.merge.model));
659
- }
660
-
661
- // Add tools override if specified
662
- if (config.merge.tools) {
663
- wrapperParts.push("--tools", shellQuote(config.merge.tools));
664
- }
665
-
666
- // TP-089: Agent mailbox steering — pass --mailbox-dir when batchId is available.
667
- if (batchId) {
668
- const mailboxDir = join(sidecarRoot, "mailbox", batchId, sessionName);
669
- mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
670
- wrapperParts.push("--mailbox-dir", shellQuote(mailboxDir));
671
- execLog("merge", sessionName, "mailbox enabled", { mailboxDir });
672
- }
673
-
674
- const piCommand = wrapperParts.filter(Boolean).join(" ");
675
-
676
- const tmuxMergeDir = toTmuxPath(mergeWorkDir);
677
- const wrappedCommand = `cd ${shellQuote(tmuxMergeDir)} && ${piCommand}`;
678
- const tmuxArgs = [
679
- "new-session", "-d",
680
- "-s", sessionName,
681
- wrappedCommand,
682
- ];
683
-
684
- // Attempt to spawn with retry
685
- let lastError = "";
686
- for (let attempt = 1; attempt <= MERGE_SPAWN_RETRY_MAX + 1; attempt++) {
687
- const result = spawnSync("tmux", tmuxArgs);
688
-
689
- if (result.status === 0) {
690
- execLog("merge", sessionName, "merge agent session spawned", { attempt });
691
- return;
692
- }
693
-
694
- lastError = result.stderr?.toString().trim() || "unknown spawn error";
695
- execLog("merge", sessionName, `merge spawn attempt ${attempt} failed: ${lastError}`);
696
-
697
- if (attempt <= MERGE_SPAWN_RETRY_MAX) {
698
- await sleepAsync(attempt * 1000);
699
- }
700
- }
701
-
702
- throw new MergeError(
703
- "MERGE_SPAWN_FAILED",
704
- `Failed to create merge TMUX session '${sessionName}' after ` +
705
- `${MERGE_SPAWN_RETRY_MAX + 1} attempts. Last error: ${lastError}`,
706
- );
707
- }
708
525
 
709
526
 
710
527
  /**
711
- * Spawn a merge agent via Runtime V2 direct agent-host (no TMUX).
528
+ * Spawn a merge agent via Runtime V2 direct agent-host (no terminal multiplexer).
712
529
  *
713
530
  * Per Runtime V2 spec (02-runtime-process-model.md §8.3):
714
531
  * "engine spawns merge host directly" — the merge agent runs as a direct
@@ -717,7 +534,7 @@ export async function spawnMergeAgent(
717
534
  *
718
535
  * The merge agent receives the merge request as its prompt and writes
719
536
  * a result JSON file. The caller polls for that result file (same contract
720
- * as the legacy TMUX path via waitForMergeResult).
537
+ * as the legacy session-backed path via waitForMergeResult).
721
538
  *
722
539
  * @param sessionName - Stable agent ID (e.g., "orch-merge-1")
723
540
  * @param repoRoot - Main repository root (merge happens here)
@@ -798,7 +615,7 @@ export async function spawnMergeAgentV2(
798
615
 
799
616
  // Store the kill handle for external cleanup (pause/abort).
800
617
  // The promise runs in background — caller uses waitForMergeResult()
801
- // to poll for the result file, same contract as the TMUX path.
618
+ // to poll for the result file, same contract as the legacy session path.
802
619
  activeMergeAgents.set(sessionName, { promise, kill, stateRoot: stateRoot ?? repoRoot, batchId: bid });
803
620
 
804
621
  // Fire-and-forget: the background promise handles exit logging
@@ -849,7 +666,7 @@ export function killMergeAgentV2(sessionName: string, cleanExit?: boolean): bool
849
666
 
850
667
  /**
851
668
  * Kill ALL active V2 merge agents. Used by abort flow to ensure
852
- * no merge agents survive even when TMUX session list is empty.
669
+ * no merge agents survive even when the legacy session list is empty.
853
670
  * @returns Number of agents killed
854
671
  * @since TP-108
855
672
  */
@@ -896,7 +713,7 @@ const SUCCESSFUL_MERGE_STATUSES = new Set<string>(["SUCCESS", "CONFLICT_RESOLVED
896
713
  *
897
714
  * Polling loop with timeout and session liveness detection:
898
715
  * 1. Check if result file exists → parse and return
899
- * 2. Check if TMUX session is still alive
716
+ * 2. Check if the merge agent session is still alive
900
717
  * 3. If session died without result → grace period → check again → fail
901
718
  * 4. If timeout exceeded → check result before killing:
902
719
  * a. If result exists with SUCCESS/CONFLICT_RESOLVED: accept it
@@ -904,7 +721,7 @@ const SUCCESSFUL_MERGE_STATUSES = new Set<string>(["SUCCESS", "CONFLICT_RESOLVED
904
721
  * b. If result missing or non-success: kill session → fail
905
722
  *
906
723
  * @param resultPath - Path to the expected result JSON file
907
- * @param sessionName - TMUX session name for liveness checking
724
+ * @param sessionName - Merge session name for liveness checking
908
725
  * @param timeoutMs - Maximum wait time (default: MERGE_TIMEOUT_MS)
909
726
  * @returns Validated MergeResult
910
727
  * @throws MergeError on timeout, session death, or invalid result
@@ -941,11 +758,7 @@ export async function waitForMergeResult(
941
758
  timeoutMs,
942
759
  });
943
760
  // Clean up agent (may still be running post-write)
944
- if (isV2) {
945
- killMergeAgentV2(sessionName, true);
946
- } else if (await tmuxHasSessionAsync(sessionName)) {
947
- await tmuxKillSessionAsync(sessionName);
948
- }
761
+ killMergeAgentV2(sessionName, true);
949
762
  return lateResult;
950
763
  }
951
764
  execLog("merge", sessionName, "merge result exists at timeout but non-success — killing", {
@@ -957,11 +770,7 @@ export async function waitForMergeResult(
957
770
  }
958
771
 
959
772
  execLog("merge", sessionName, "merge timeout — killing agent", { elapsed, timeoutMs });
960
- if (isV2) {
961
- killMergeAgentV2(sessionName);
962
- } else {
963
- await tmuxKillSessionAsync(sessionName);
964
- }
773
+ killMergeAgentV2(sessionName);
965
774
 
966
775
  throw new MergeError(
967
776
  "MERGE_TIMEOUT",
@@ -980,11 +789,7 @@ export async function waitForMergeResult(
980
789
  elapsed,
981
790
  });
982
791
  // Clean up agent if still alive
983
- if (isV2) {
984
- killMergeAgentV2(sessionName, true);
985
- } else if (await tmuxHasSessionAsync(sessionName)) {
986
- await tmuxKillSessionAsync(sessionName);
987
- }
792
+ killMergeAgentV2(sessionName, true);
988
793
  return result;
989
794
  } catch (err: unknown) {
990
795
  if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
@@ -997,14 +802,8 @@ export async function waitForMergeResult(
997
802
  }
998
803
 
999
804
  // Check agent liveness — backend-aware
1000
- let agentAlive: boolean;
1001
- if (isV2) {
1002
- // V2: check activeMergeAgents map (process handle)
1003
- agentAlive = activeMergeAgents.has(sessionName);
1004
- } else {
1005
- // Legacy: check TMUX session
1006
- agentAlive = await tmuxHasSessionAsync(sessionName);
1007
- }
805
+ // Runtime V2: check active merge agent handle map (process-owned).
806
+ const agentAlive = activeMergeAgents.has(sessionName);
1008
807
 
1009
808
  if (!agentAlive) {
1010
809
  if (sessionDiedAt === null) {
@@ -1330,7 +1129,7 @@ function runPostMergeVerification(
1330
1129
  * 3. For each lane, sequentially:
1331
1130
  * a. Build merge request content
1332
1131
  * b. Write merge request to temp file
1333
- * c. Spawn merge agent in TMUX session (in main repo)
1132
+ * c. Spawn merge agent session (in main repo)
1334
1133
  * d. Wait for merge result
1335
1134
  * e. Handle result (continue, log, or pause)
1336
1135
  * 4. Return MergeWaveResult
@@ -1370,7 +1169,7 @@ export async function mergeWave(
1370
1169
  runtimeBackend?: RuntimeBackend,
1371
1170
  ): Promise<MergeWaveResult> {
1372
1171
  const startTime = Date.now();
1373
- const tmuxPrefix = config.orchestrator.tmux_prefix;
1172
+ const sessionPrefix = config.orchestrator.sessionPrefix;
1374
1173
  const opId = resolveOperatorId(config);
1375
1174
  const targetBranch = baseBranch;
1376
1175
  const laneResults: MergeLaneResult[] = [];
@@ -1581,7 +1380,7 @@ export async function mergeWave(
1581
1380
  for (const lane of orderedLanes) {
1582
1381
  const laneStart = Date.now();
1583
1382
  const txnStartedAt = new Date().toISOString();
1584
- const sessionName = `${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`;
1383
+ const sessionName = `${sessionPrefix}-${opId}-merge-${lane.laneNumber}`;
1585
1384
  const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.json`;
1586
1385
  const piDir = stateRoot ?? repoRoot;
1587
1386
  const resultFilePath = join(piDir, ".pi", resultFileName);
@@ -1675,25 +1474,13 @@ export async function mergeWave(
1675
1474
  try { unlinkSync(resultFilePath); } catch { /* best effort */ }
1676
1475
  }
1677
1476
 
1678
- // Re-spawn merge agent for the retry
1679
- // TP-108: Kill previous V2 agent to prevent orphan/duplicate
1680
- if (runtimeBackend === "v2") {
1681
- killMergeAgentV2(sessionName);
1682
- await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1683
- } else {
1684
- await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1685
- }
1686
- // TP-056: Re-register with health monitor after respawn (legacy only)
1687
- if (healthMonitor && runtimeBackend !== "v2") healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
1477
+ // Re-spawn merge agent for the retry.
1478
+ // Kill previous V2 agent handle to prevent orphan/duplicate.
1479
+ killMergeAgentV2(sessionName);
1480
+ await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1688
1481
  } else {
1689
- // First attempt: spawn merge agent
1690
- if (runtimeBackend === "v2") {
1691
- await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1692
- } else {
1693
- await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1694
- }
1695
- // TP-056: Register session with health monitor (legacy only — V2 uses process handle)
1696
- if (healthMonitor && runtimeBackend !== "v2") healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
1482
+ // First attempt: spawn merge agent (Runtime V2)
1483
+ await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1697
1484
  }
1698
1485
 
1699
1486
  try {
@@ -1956,12 +1743,8 @@ export async function mergeWave(
1956
1743
  // Best effort
1957
1744
  }
1958
1745
 
1959
- // Kill merge agent if still alive (backend-aware)
1960
- if (runtimeBackend === "v2") {
1961
- killMergeAgentV2(sessionName);
1962
- } else if (tmuxHasSession(sessionName)) {
1963
- tmuxKillSession(sessionName);
1964
- }
1746
+ // Kill merge agent if still alive.
1747
+ killMergeAgentV2(sessionName);
1965
1748
 
1966
1749
  const errMsg = err instanceof Error ? err.message : String(err);
1967
1750
  const errCode = err instanceof MergeError ? err.code : "UNKNOWN";
@@ -2735,81 +2518,13 @@ export function attemptAutoIntegration(
2735
2518
  // ── Merge Health Monitor (TP-056) ────────────────────────────────────
2736
2519
 
2737
2520
  /**
2738
- * Capture the last N lines of a tmux pane for activity detection.
2739
- *
2740
- * Uses `tmux capture-pane` with `-p` (stdout) and `-S -N` (last N lines).
2741
- * Returns null if the session doesn't exist or capture fails.
2521
+ * Classify merge-session health from Runtime V2 liveness and result-file state.
2742
2522
  *
2743
- * @param sessionName - TMUX session name
2744
- * @param lines - Number of lines to capture from the bottom
2745
- * @returns Captured text, or null on failure
2523
+ * Without legacy pane capture, warning/stuck are time-based heuristics from
2524
+ * the session registration timestamp (`lastActivityAt`).
2746
2525
  *
2747
- * @since TP-056
2748
- */
2749
- export function captureMergePaneOutput(
2750
- sessionName: string,
2751
- lines: number = MERGE_HEALTH_CAPTURE_LINES,
2752
- ): string | null {
2753
- try {
2754
- const result = spawnSync("tmux", [
2755
- "capture-pane",
2756
- "-t", sessionName,
2757
- "-p", // print to stdout
2758
- "-S", `-${lines}`, // last N lines
2759
- ], { encoding: "utf-8", timeout: 5_000 });
2760
-
2761
- if (result.status !== 0) {
2762
- return null;
2763
- }
2764
-
2765
- return result.stdout ?? null;
2766
- } catch {
2767
- return null;
2768
- }
2769
- }
2770
-
2771
- /**
2772
- * Async version of captureMergePaneOutput — captures pane output
2773
- * without blocking the event loop.
2774
- *
2775
- * @param sessionName - TMUX session name
2776
- * @param lines - Number of lines to capture from the bottom
2777
- * @returns Promise resolving to captured text, or null on failure
2778
- *
2779
- * @since TP-070
2780
- */
2781
- export async function captureMergePaneOutputAsync(
2782
- sessionName: string,
2783
- lines: number = MERGE_HEALTH_CAPTURE_LINES,
2784
- ): Promise<string | null> {
2785
- try {
2786
- const result = await tmuxAsync([
2787
- "capture-pane",
2788
- "-t", sessionName,
2789
- "-p",
2790
- "-S", `-${lines}`,
2791
- ], 5_000);
2792
-
2793
- if (result.status !== 0) {
2794
- return null;
2795
- }
2796
-
2797
- return result.stdout || null;
2798
- } catch {
2799
- return null;
2800
- }
2801
- }
2802
-
2803
- /**
2804
- * Classify the health of a merge session based on session liveness
2805
- * and pane output activity.
2806
- *
2807
- * Pure function — no side effects. Takes the current state and produces
2808
- * a health classification.
2809
- *
2810
- * @param sessionAlive - Whether the tmux session is alive
2526
+ * @param sessionAlive - Whether the Runtime V2 merge agent is alive
2811
2527
  * @param hasResultFile - Whether the merge result file exists
2812
- * @param currentOutput - Current pane capture (null if session dead or capture failed)
2813
2528
  * @param healthState - Tracked health state for this session
2814
2529
  * @param now - Current epoch ms
2815
2530
  * @returns Updated health status
@@ -2819,40 +2534,24 @@ export async function captureMergePaneOutputAsync(
2819
2534
  export function classifyMergeHealth(
2820
2535
  sessionAlive: boolean,
2821
2536
  hasResultFile: boolean,
2822
- currentOutput: string | null,
2823
2537
  healthState: MergeSessionHealthState,
2824
2538
  now: number,
2825
2539
  ): MergeHealthStatus {
2826
- // Dead session with no result file → immediate detection
2827
2540
  if (!sessionAlive && !hasResultFile) {
2828
2541
  return "dead";
2829
2542
  }
2830
2543
 
2831
- // Session dead but result file exists → merge completed, healthy
2832
2544
  if (!sessionAlive && hasResultFile) {
2833
2545
  return "healthy";
2834
2546
  }
2835
2547
 
2836
- // Session alive check activity
2837
- const lastContent = healthState.lastSnapshot?.content ?? null;
2838
- const outputChanged = currentOutput !== null
2839
- && (lastContent === null || currentOutput !== lastContent);
2840
-
2841
- if (outputChanged) {
2842
- return "healthy";
2843
- }
2844
-
2845
- // No output change — compute stale duration
2846
- const staleDuration = now - healthState.lastActivityAt;
2847
-
2848
- if (staleDuration >= MERGE_HEALTH_STUCK_THRESHOLD_MS) {
2548
+ const elapsedMs = now - healthState.lastActivityAt;
2549
+ if (elapsedMs >= MERGE_HEALTH_STUCK_THRESHOLD_MS) {
2849
2550
  return "stuck";
2850
2551
  }
2851
-
2852
- if (staleDuration >= MERGE_HEALTH_WARNING_THRESHOLD_MS) {
2552
+ if (elapsedMs >= MERGE_HEALTH_WARNING_THRESHOLD_MS) {
2853
2553
  return "warning";
2854
2554
  }
2855
-
2856
2555
  return "healthy";
2857
2556
  }
2858
2557
 
@@ -2917,7 +2616,7 @@ export class MergeHealthMonitor {
2917
2616
  /**
2918
2617
  * Register a merge session for monitoring.
2919
2618
  *
2920
- * @param sessionName - TMUX session name
2619
+ * @param sessionName - Merge session name
2921
2620
  * @param laneNumber - Lane number the session belongs to
2922
2621
  * @param resultPath - Path to the expected merge result file
2923
2622
  */
@@ -2998,51 +2697,44 @@ export class MergeHealthMonitor {
2998
2697
  * Run a single poll cycle across all monitored sessions.
2999
2698
  *
3000
2699
  * Exposed as public for testing — normally called by the interval timer.
3001
- * Async (TP-070) — uses non-blocking tmux calls to avoid event loop stalls.
3002
2700
  */
3003
2701
  async poll(): Promise<void> {
3004
2702
  const now = Date.now();
3005
2703
 
3006
- for (const [sessionName, state] of this.sessions) {
3007
- const sessionAlive = await tmuxHasSessionAsync(sessionName);
3008
- const resultPath = this._resultPaths.get(sessionName) ?? "";
3009
- const hasResultFile = resultPath ? existsSync(resultPath) : false;
3010
-
3011
- // Capture pane output for activity detection — async to avoid blocking
3012
- const currentOutput = sessionAlive
3013
- ? await captureMergePaneOutputAsync(sessionName)
3014
- : null;
3015
-
3016
- // Classify health
3017
- const newStatus = classifyMergeHealth(
3018
- sessionAlive,
3019
- hasResultFile,
3020
- currentOutput,
3021
- state,
3022
- now,
3023
- );
2704
+ try {
2705
+ setV2LivenessRegistryCache(readRegistrySnapshot(this.stateRoot, this.batchId));
2706
+ } catch {
2707
+ setV2LivenessRegistryCache(null);
2708
+ }
3024
2709
 
3025
- // Update snapshot if output changed
3026
- if (currentOutput !== null && (
3027
- state.lastSnapshot === null || currentOutput !== state.lastSnapshot.content
3028
- )) {
3029
- state.lastSnapshot = { content: currentOutput, capturedAt: now };
3030
- state.lastActivityAt = now;
3031
- }
2710
+ try {
2711
+ for (const [sessionName, state] of this.sessions) {
2712
+ const sessionAlive = isV2AgentAlive(sessionName, "v2");
2713
+ const resultPath = this._resultPaths.get(sessionName) ?? "";
2714
+ const hasResultFile = resultPath ? existsSync(resultPath) : false;
2715
+
2716
+ const newStatus = classifyMergeHealth(
2717
+ sessionAlive,
2718
+ hasResultFile,
2719
+ state,
2720
+ now,
2721
+ );
3032
2722
 
3033
- const prevStatus = state.status;
3034
- state.status = newStatus;
2723
+ state.status = newStatus;
3035
2724
 
3036
- // Emit events based on status transitions
3037
- this._emitHealthEvents(state, now);
2725
+ // Emit events based on status transitions
2726
+ this._emitHealthEvents(state, now);
3038
2727
 
3039
- // Signal dead session for early exit
3040
- if (newStatus === "dead" && !state.deadEmitted) {
3041
- state.deadEmitted = true;
3042
- if (this._onDeadSession) {
3043
- this._onDeadSession(sessionName, state.laneNumber);
2728
+ // Signal dead session for early exit
2729
+ if (newStatus === "dead" && !state.deadEmitted) {
2730
+ state.deadEmitted = true;
2731
+ if (this._onDeadSession) {
2732
+ this._onDeadSession(sessionName, state.laneNumber);
2733
+ }
3044
2734
  }
3045
2735
  }
2736
+ } finally {
2737
+ setV2LivenessRegistryCache(null);
3046
2738
  }
3047
2739
  }
3048
2740
 
@@ -3061,7 +2753,7 @@ export class MergeHealthMonitor {
3061
2753
  sessionName: state.sessionName,
3062
2754
  healthStatus: "warning",
3063
2755
  stalledMinutes,
3064
- reason: `Merge agent on lane ${state.laneNumber} may be stalled (no output for ${stalledMinutes} min)`,
2756
+ reason: `Merge agent on lane ${state.laneNumber} may be stalled (${stalledMinutes} min without completion)`,
3065
2757
  };
3066
2758
  emitEngineEvent(this.stateRoot, event);
3067
2759
  execLog("merge-health", state.sessionName, `⚠️ merge session possibly stalled`, {
@@ -3093,7 +2785,7 @@ export class MergeHealthMonitor {
3093
2785
  sessionName: state.sessionName,
3094
2786
  healthStatus: "stuck",
3095
2787
  stalledMinutes,
3096
- reason: `Merge agent on lane ${state.laneNumber} appears stuck (no output for ${stalledMinutes} min). Consider killing and retrying.`,
2788
+ reason: `Merge agent on lane ${state.laneNumber} appears stuck (${stalledMinutes} min without completion). Consider killing and retrying.`,
3097
2789
  };
3098
2790
  emitEngineEvent(this.stateRoot, event);
3099
2791
  execLog("merge-health", state.sessionName, `🔒 merge session stuck`, {
@@ -78,7 +78,7 @@ export const ORCH_MESSAGES = {
78
78
  `⏸️ Pausing batch ${batchId}... lanes will stop after their current tasks complete.`,
79
79
 
80
80
  // /orch-sessions
81
- sessionsNone: () => "No orchestrator TMUX sessions found.",
81
+ sessionsNone: () => "No active orchestrator sessions found.",
82
82
  sessionsHeader: (count: number) => `🖥️ ${count} orchestrator session(s):`,
83
83
 
84
84
  // /orch orphan detection
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Naming contract helpers for team-scale collision resistance.
3
3
  *
4
- * Provides deterministic, human-readable identifiers for TMUX sessions,
4
+ * Provides deterministic, human-readable identifiers for lane session IDs,
5
5
  * worktree directories, git branches, and merge artifacts. All naming
6
6
  * components are sanitized for safe use in filesystem paths, git refs,
7
- * and TMUX session names.
7
+ * and tmux-compatible session IDs.
8
8
  *
9
9
  * @module orch/naming
10
10
  */
@@ -25,7 +25,7 @@ import type { OrchestratorConfig } from "./types.ts";
25
25
  * - Trim leading/trailing hyphens
26
26
  * - Truncate to `maxLen` characters
27
27
  *
28
- * Safe for use in: TMUX session names, git branch refs, filesystem paths.
28
+ * Safe for use in: lane session IDs, git branch refs, filesystem paths.
29
29
  *
30
30
  * @param raw - Raw input string
31
31
  * @param maxLen - Maximum length (default: 16)
@@ -99,7 +99,7 @@ export function resolveOperatorId(
99
99
  * Derive a repo slug from the repository root directory name.
100
100
  *
101
101
  * Provides cross-repo disambiguation when multiple repos share the
102
- * same machine. Used in TMUX session names and worktree paths where
102
+ * same machine. Used in lane session IDs and worktree paths where
103
103
  * names must be globally unique on the machine.
104
104
  *
105
105
  * @param repoRoot - Absolute path to the repository root