Skip to main content

orch/
tmux.rs

1//! tmux session manager — create, monitor, and interact with agent sessions.
2//!
3//! Each task gets a tmux session named `orch-{issue_number}`.
4//! The engine creates sessions, monitors them, captures output for streaming,
5//! and cleans them up when tasks complete.
6//!
7//! This module is the foundation for live session streaming to any channel.
8
9use crate::cmd::CommandErrorContext;
10use anyhow::Context;
11use std::collections::HashMap;
12use tokio::process::Command;
13
14/// Info about an active orch tmux session.
15#[derive(Debug, Clone)]
16pub struct Session {
17    pub name: String,
18    /// The task id (preserving internal/external marker), e.g. "internal-21116" or "1775189963034"
19    pub task_id: String,
20    /// Project short name derived from repo slug (owner/repo -> repo)
21    pub project: String,
22    pub created_at: Option<u64>, // Unix timestamp from tmux #{session_created}
23}
24
25/// Manage tmux sessions for agent tasks.
26#[derive(Clone)]
27pub struct TmuxManager {
28    /// Prefix for session names (e.g. "orch-")
29    prefix: String,
30}
31
32impl TmuxManager {
33    pub fn new() -> Self {
34        Self {
35            prefix: "orch-".to_string(),
36        }
37    }
38
39    /// Session name for a task: `orch-{project}-{task_id}`.
40    ///
41    /// The project name is derived from the repo slug (e.g. `owner/repo` → `repo`).
42    /// This prevents collisions between projects with the same issue number.
43    pub fn session_name(&self, project: &str, task_id: &str) -> String {
44        let project_name = project
45            .rsplit('/')
46            .next()
47            .unwrap_or(project)
48            .trim_end_matches(".git");
49        let safe_task_id = task_id.replace(':', "-");
50        format!("{}{project_name}-{safe_task_id}", self.prefix)
51    }
52
53    /// Create a new tmux session for a task and run a command in it.
54    ///
55    /// Environment variables in `env_vars` are injected into the session via
56    /// `tmux new-session -e KEY=VALUE` — they are not written to disk.
57    ///
58    /// The session is detached — the agent runs in the background.
59    /// Returns the session name.
60    ///
61    /// Note: GH_TOKEN is passed here via `-e` like all other env vars. Do NOT
62    /// use `create_session_detached` + `set_env` + `create_window` to avoid
63    /// exposing secrets in process args — that creates a 2-window session which
64    /// breaks all tmux monitoring (completion detection, output capture, silence
65    /// detection) because they target the default window (the idle shell), not
66    /// the agent window. See the revert of PR #2903.
67    pub async fn create_session(
68        &self,
69        repo: &str,
70        task_id: &str,
71        working_dir: &str,
72        command: &str,
73        env_vars: &[(&str, &str)],
74    ) -> anyhow::Result<String> {
75        let name = self.session_name(repo, task_id);
76
77        let mut cmd = Command::new("tmux");
78        cmd.args(["new-session", "-d", "-s", &name, "-c", working_dir]);
79
80        // Suppress oh-my-zsh update prompts that can intercept agent input.
81        cmd.arg("-e").arg("DISABLE_AUTO_UPDATE=true");
82
83        // Inject non-secret environment variables into the new session if provided.
84        // For secrets like GH_TOKEN, callers should use `set_env()` after
85        // session creation to avoid exposing them in process arguments.
86        for (key, value) in env_vars {
87            // tmux accepts -e KEY=VALUE to inject environment into the session.
88            cmd.arg("-e").arg(format!("{}={}", key, value));
89        }
90
91        cmd.arg(command);
92
93        let output = cmd
94            .output_with_context()
95            .await
96            .context("spawning tmux session")?;
97
98        if !output.status.success() {
99            let stderr = String::from_utf8_lossy(&output.stderr);
100            anyhow::bail!("tmux new-session failed: {stderr}");
101        }
102
103        tracing::info!(session = %name, task_id, "created tmux session");
104        Ok(name)
105    }
106
107    /// Check if a session exists.
108    pub async fn session_exists(&self, session: &str) -> bool {
109        Command::new("tmux")
110            .args(["has-session", "-t", session])
111            .output_with_context()
112            .await
113            .map(|o| o.status.success())
114            .unwrap_or(false)
115    }
116
117    /// Kill a session.
118    pub async fn kill_session(&self, session: &str) -> anyhow::Result<()> {
119        let output = Command::new("tmux")
120            .args(["kill-session", "-t", session])
121            .output_with_context()
122            .await?;
123
124        if !output.status.success() {
125            let stderr = String::from_utf8_lossy(&output.stderr);
126            if stderr.contains("can't find session") || stderr.contains("no server running") {
127                tracing::debug!(session, %stderr, "kill-session: session already gone");
128            } else {
129                tracing::warn!(session, %stderr, "kill-session failed unexpectedly");
130            }
131        }
132        Ok(())
133    }
134
135    /// Capture the current pane content (last N lines).
136    ///
137    /// Returns `Ok(String::new())` if the session or pane is not found — this is
138    /// an expected teardown race when the session was cleaned up between the caller's
139    /// `is_session_active()` check and this capture.
140    pub async fn capture_pane(&self, session: &str, lines: i32) -> anyhow::Result<String> {
141        let start = format!("-{lines}");
142        let output = Command::new("tmux")
143            .args(["capture-pane", "-t", session, "-p", "-S", &start])
144            .output_with_context()
145            .await?;
146
147        if !output.status.success() {
148            let stderr = String::from_utf8_lossy(&output.stderr);
149            // "can't find pane" / "can't find session" / "no server running" are expected
150            // teardown races — the session was cleaned up between the caller's activity
151            // check and this call, or the tmux server exited entirely.
152            if stderr.contains("can't find pane")
153                || stderr.contains("can't find session")
154                || stderr.contains("no server running")
155            {
156                tracing::debug!(
157                    session,
158                    "capture_pane: session/pane already gone (teardown race)"
159                );
160                return Ok(String::new());
161            }
162            anyhow::bail!("capture-pane failed for {session}: {stderr}");
163        }
164        Ok(String::from_utf8_lossy(&output.stdout).to_string())
165    }
166
167    /// Check if the process in a session's pane is still running.
168    /// Returns false if the session doesn't exist or the pane has no active process.
169    pub async fn is_session_active(&self, session: &str) -> bool {
170        // Check pane_dead flag
171        let output = Command::new("tmux")
172            .args(["list-panes", "-t", session, "-F", "#{pane_dead}"])
173            .output_with_context()
174            .await;
175
176        match output {
177            Ok(o) if o.status.success() => {
178                let flag = String::from_utf8_lossy(&o.stdout);
179                flag.trim() == "0" // 0 = alive, 1 = dead
180            }
181            _ => false,
182        }
183    }
184
185    /// Check whether a session exists and still has a live pane process.
186    #[cfg(test)]
187    pub async fn session_is_running(&self, session: &str) -> bool {
188        self.session_exists(session).await && self.is_session_active(session).await
189    }
190
191    /// Return whether a session should block a new dispatch.
192    ///
193    /// Dead tmux sessions with the same name are cleaned up eagerly so they do
194    /// not block reclaim/re-dispatch loops or cause `new-session` name collisions.
195    pub async fn session_blocks_dispatch(&self, session: &str) -> bool {
196        if !self.session_exists(session).await {
197            return false;
198        }
199
200        if self.is_session_active(session).await {
201            return true;
202        }
203
204        tracing::warn!(
205            session,
206            "found stale tmux session with dead pane; cleaning up"
207        );
208        if let Err(err) = self.kill_session(session).await {
209            tracing::debug!(session, error = %err, "failed to clean up stale tmux session");
210        }
211
212        false
213    }
214
215    /// Batch variant of `session_blocks_dispatch` that uses a pre-fetched session map.
216    ///
217    /// `session_map` is the result of `batch_session_active()`. Sessions absent from the
218    /// map do not exist (→ no block). Sessions present with `alive=true` block dispatch.
219    /// Sessions present with `alive=false` have a dead pane and are cleaned up eagerly.
220    pub async fn session_blocks_dispatch_from_map(
221        &self,
222        session: &str,
223        session_map: &std::collections::HashMap<String, bool>,
224    ) -> bool {
225        match session_map.get(session).copied() {
226            None => false,      // session does not exist → no block
227            Some(true) => true, // session exists and pane is alive → block
228            Some(false) => {
229                // Session exists but pane is dead — clean up eagerly.
230                tracing::warn!(
231                    session,
232                    "found stale tmux session with dead pane; cleaning up"
233                );
234                if let Err(err) = self.kill_session(session).await {
235                    tracing::debug!(
236                        session,
237                        error = %err,
238                        "failed to clean up stale tmux session"
239                    );
240                }
241                false
242            }
243        }
244    }
245
246    /// Parse a session name into (project, task_id).
247    ///
248    /// Session names follow the format `orch-{project}-{task_id}`, where
249    /// `{project}` may contain hyphens (e.g. `my-repo`) and `{task_id}` is one of:
250    /// - numeric (external): `1234`
251    /// - internal: `internal-{n}`
252    /// - review variant: `{id}-review` or `internal-{n}-review`
253    ///
254    /// Parsing works right-to-left since task_id formats are well-defined,
255    /// avoiding ambiguity when the project name contains hyphens.
256    ///
257    /// Returns `None` if the name does not start with the orch prefix or
258    /// cannot be parsed into a valid (project, task_id) pair.
259    pub(crate) fn parse_session_name(&self, name: &str) -> Option<(String, String)> {
260        let after_prefix = name.strip_prefix(&self.prefix)?;
261
262        // Strip optional "-review" suffix, reattach to the task_id later.
263        let (base, is_review) = match after_prefix.strip_suffix("-review") {
264            Some(stripped) => (stripped, true),
265            None => (after_prefix, false),
266        };
267
268        // Try internal task: {project}-internal-{digits}
269        if let Some(idx) = base.rfind("-internal-") {
270            let project = &base[..idx];
271            let task_base = &base[idx + 1..]; // "internal-{digits}"
272            let after_internal = &task_base["internal-".len()..];
273            if !project.is_empty()
274                && !after_internal.is_empty()
275                && after_internal.chars().all(|c| c.is_ascii_digit())
276            {
277                let task_id = if is_review {
278                    format!("{task_base}-review")
279                } else {
280                    task_base.to_string()
281                };
282                return Some((project.to_string(), task_id));
283            }
284        }
285
286        // External task: {project}-{digits}
287        if let Some(idx) = base.rfind('-') {
288            let project = &base[..idx];
289            let digits = &base[idx + 1..];
290            if !project.is_empty()
291                && !digits.is_empty()
292                && digits.chars().all(|c| c.is_ascii_digit())
293            {
294                let task_id = if is_review {
295                    format!("{digits}-review")
296                } else {
297                    digits.to_string()
298                };
299                return Some((project.to_string(), task_id));
300            }
301        }
302
303        None
304    }
305
306    /// Get pane_dead status for all sessions in a single tmux call.
307    ///
308    /// Returns a map from session name → alive (true = alive, false = dead/missing).
309    /// Uses `list-panes -a` to avoid one subprocess per session.
310    pub async fn batch_session_active(&self) -> HashMap<String, bool> {
311        let mut map = HashMap::new();
312        let o = match Command::new("tmux")
313            .args(["list-panes", "-a", "-F", "#{session_name} #{pane_dead}"])
314            .output_with_context()
315            .await
316        {
317            Ok(o) => o,
318            Err(e) => {
319                tracing::warn!(error = %e, "batch_session_active: failed to spawn tmux");
320                return map;
321            }
322        };
323        if !o.status.success() {
324            let stderr = String::from_utf8_lossy(&o.stderr);
325            tracing::warn!(
326                status = ?o.status,
327                stderr = %stderr.trim(),
328                "batch_session_active: tmux list-panes exited non-zero"
329            );
330            return map;
331        }
332        for line in String::from_utf8_lossy(&o.stdout).lines() {
333            let mut parts = line.trim().splitn(2, ' ');
334            if let (Some(session), Some(dead)) = (parts.next(), parts.next()) {
335                map.insert(session.to_string(), dead.trim() == "0");
336            }
337        }
338        map
339    }
340
341    /// List all orch-prefixed sessions with metadata including creation time.
342    pub async fn list_sessions(&self) -> anyhow::Result<Vec<Session>> {
343        // Get both name and creation timestamp in one call
344        let output = Command::new("tmux")
345            .args(["list-sessions", "-F", "#{session_name} #{session_created}"])
346            .output_with_context()
347            .await?;
348
349        if !output.status.success() {
350            return Ok(vec![]);
351        }
352
353        let mut sessions = Vec::new();
354        for line in String::from_utf8_lossy(&output.stdout).lines() {
355            let parts: Vec<&str> = line.trim().splitn(2, ' ').collect();
356            if parts.is_empty() {
357                continue;
358            }
359            let name = parts[0].to_string();
360            let created_at = parts.get(1).and_then(|s| s.parse::<u64>().ok());
361
362            if let Some((project, task_id)) = self.parse_session_name(&name) {
363                sessions.push(Session {
364                    name,
365                    task_id,
366                    project,
367                    created_at,
368                });
369            }
370        }
371
372        Ok(sessions)
373    }
374
375    /// Kill all orch-prefixed sessions created before a given timestamp.
376    /// Returns the number of sessions killed.
377    pub async fn kill_stale_sessions(&self, before_timestamp: u64) -> anyhow::Result<usize> {
378        let sessions = self.list_sessions().await?;
379        let mut killed = 0;
380
381        for session in sessions {
382            let should_kill = match session.created_at {
383                Some(created) => created < before_timestamp,
384                // If we can't determine creation time, be conservative and don't kill
385                None => false,
386            };
387
388            if should_kill {
389                tracing::info!(
390                    session = %session.name,
391                    created_at = session.created_at,
392                    "killing stale tmux session from previous run"
393                );
394                if let Err(e) = self.kill_session(&session.name).await {
395                    tracing::warn!(session = %session.name, error = %e, "failed to kill stale session");
396                } else {
397                    killed += 1;
398                }
399            }
400        }
401
402        if killed > 0 {
403            tracing::info!(killed, "killed stale tmux sessions on startup");
404        }
405
406        Ok(killed)
407    }
408
409    /// Wait for multiple sessions to be fully dead (no longer exist).
410    /// Polls every `poll_interval` up to `timeout`.
411    /// Returns the number of sessions that were still alive after timeout.
412    pub async fn wait_for_sessions_dead(
413        &self,
414        sessions: &[String],
415        poll_interval: std::time::Duration,
416        timeout: std::time::Duration,
417    ) -> usize {
418        let start = std::time::Instant::now();
419        let mut remaining: std::collections::HashSet<String> = sessions.iter().cloned().collect();
420
421        while !remaining.is_empty() && start.elapsed() < timeout {
422            let mut to_remove = Vec::new();
423            for session in &remaining {
424                if !self.session_exists(session).await {
425                    to_remove.push(session.clone());
426                }
427            }
428            for session in to_remove {
429                remaining.remove(&session);
430            }
431            if !remaining.is_empty() {
432                tokio::time::sleep(poll_interval).await;
433            }
434        }
435
436        let still_alive = remaining.len();
437        if still_alive > 0 {
438            tracing::warn!(
439                still_alive,
440                sessions = ?remaining.iter().collect::<Vec<_>>(),
441                "tmux sessions did not terminate within timeout"
442            );
443        }
444        still_alive
445    }
446
447    /// Wait for a session to finish (pane process exits).
448    /// Returns the captured output from the last N lines.
449    pub async fn wait_for_completion(
450        &self,
451        session: &str,
452        poll_interval: std::time::Duration,
453    ) -> anyhow::Result<String> {
454        loop {
455            if !self.session_exists(session).await {
456                return Ok(String::new());
457            }
458
459            if !self.is_session_active(session).await {
460                // Process finished — capture final output. `capture_pane` returns empty
461                // for "can't find pane/session" teardown races; only unexpected failures
462                // reach this error path and are logged at ERROR level.
463                match self.capture_pane(session, 500).await {
464                    Ok(output) => return Ok(output),
465                    Err(err) => {
466                        tracing::error!(session = %session, error = %err, "failed to capture final pane output");
467                        return Ok(String::new());
468                    }
469                }
470            }
471
472            tokio::time::sleep(poll_interval).await;
473        }
474    }
475
476    /// Snapshot all orch-prefixed sessions — for engine tick monitoring.
477    ///
478    /// Returns a vector of (Session, alive) so callers can operate on the
479    /// actual session name instead of reconstructing one from repo+task_id.
480    ///
481    /// Uses `batch_session_active()` internally to fetch all pane-dead statuses
482    /// in a single subprocess call instead of one call per session (N+1 → 2).
483    pub async fn snapshot(&self) -> Vec<(Session, bool)> {
484        let sessions = match self.list_sessions().await {
485            Ok(s) => s,
486            Err(err) => {
487                tracing::error!(error = %err, "failed to list tmux sessions — returning empty snapshot");
488                Vec::new()
489            }
490        };
491        // Fetch all pane-dead statuses in a single `list-panes -a` call.
492        let active_map = self.batch_session_active().await;
493        sessions
494            .into_iter()
495            .map(|s| {
496                // Sessions absent from the map are treated as dead (pane missing).
497                let active = active_map.get(&s.name).copied().unwrap_or(false);
498                (s, active)
499            })
500            .collect()
501    }
502
503    // ── Environment variable helpers ───────────────────────────────────
504
505    /// Set an environment variable in a tmux session.
506    ///
507    /// This updates the tmux session environment, which will be inherited
508    /// by processes started in new windows/panes within the session.
509    /// Note: does NOT affect the initial pane — use `-e` in `create_session` for that.
510    #[cfg(test)]
511    pub async fn set_env(&self, session: &str, key: &str, value: &str) -> anyhow::Result<()> {
512        let output = Command::new("tmux")
513            .args(["set-environment", "-t", session, key, value])
514            .output_with_context()
515            .await?;
516
517        if !output.status.success() {
518            let stderr = String::from_utf8_lossy(&output.stderr);
519            anyhow::bail!("tmux set-environment failed for {session}: {stderr}");
520        }
521
522        tracing::debug!(session, key, "set tmux environment variable");
523        Ok(())
524    }
525
526    /// Unset (remove) an environment variable from the tmux **global** environment.
527    ///
528    /// Uses: `tmux set-environment -gu <key>`
529    pub async fn unset_global_env(&self, key: &str) -> anyhow::Result<()> {
530        let output = Command::new("tmux")
531            .args(["set-environment", "-gu", key])
532            .output_with_context()
533            .await?;
534
535        if !output.status.success() {
536            let stderr = String::from_utf8_lossy(&output.stderr);
537            anyhow::bail!("tmux unset-global-environment failed for {key}: {stderr}");
538        }
539
540        tracing::debug!(key, "unset tmux global environment variable");
541        Ok(())
542    }
543}
544
545#[cfg(test)]
546pub(crate) fn test_session_name() -> String {
547    format!(
548        "orch-test-{}",
549        std::time::SystemTime::now()
550            .duration_since(std::time::UNIX_EPOCH)
551            .map(|d| d.as_millis())
552            .unwrap_or_else(|_| 0)
553    )
554}
555
556/// RAII guard that kills a tmux session on drop, even if the test panics.
557#[cfg(test)]
558pub(crate) struct SessionGuard(pub(crate) String);
559
560#[cfg(test)]
561impl Drop for SessionGuard {
562    fn drop(&mut self) {
563        let _ = std::process::Command::new("tmux")
564            .args(["kill-session", "-t", &self.0])
565            .output();
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn session_name_internal_task_id_is_sanitized() {
575        let tmux = TmuxManager::new();
576        assert_eq!(
577            tmux.session_name("owner/repo", "internal:8"),
578            "orch-repo-internal-8"
579        );
580    }
581
582    #[test]
583    fn task_id_from_session_name_parses_external_task() {
584        let tmux = TmuxManager::new();
585        // "orch-repo-1234" → project "repo", task_id "1234"
586        assert_eq!(
587            tmux.parse_session_name("orch-repo-1234"),
588            Some(("repo".to_string(), "1234".to_string()))
589        );
590    }
591
592    #[test]
593    fn task_id_from_session_name_parses_internal_task() {
594        let tmux = TmuxManager::new();
595        // "orch-orch-internal-21116" → project "orch", task_id "internal-21116"
596        assert_eq!(
597            tmux.parse_session_name("orch-orch-internal-21116"),
598            Some(("orch".to_string(), "internal-21116".to_string()))
599        );
600    }
601
602    #[test]
603    fn task_id_from_session_name_parses_internal_task_small_id() {
604        let tmux = TmuxManager::new();
605        // "orch-repo-internal-8" → project "repo", task_id "internal-8"
606        assert_eq!(
607            tmux.parse_session_name("orch-repo-internal-8"),
608            Some(("repo".to_string(), "internal-8".to_string()))
609        );
610    }
611
612    #[test]
613    fn task_id_from_session_name_returns_none_for_non_orch_session() {
614        let tmux = TmuxManager::new();
615        assert_eq!(tmux.parse_session_name("some-other-session"), None);
616    }
617
618    #[test]
619    fn parse_session_name_hyphenated_project_external() {
620        let tmux = TmuxManager::new();
621        // "orch-my-repo-1234" → project "my-repo", task_id "1234"
622        assert_eq!(
623            tmux.parse_session_name("orch-my-repo-1234"),
624            Some(("my-repo".to_string(), "1234".to_string()))
625        );
626    }
627
628    #[test]
629    fn parse_session_name_hyphenated_project_internal() {
630        let tmux = TmuxManager::new();
631        // "orch-my-repo-internal-8" → project "my-repo", task_id "internal-8"
632        assert_eq!(
633            tmux.parse_session_name("orch-my-repo-internal-8"),
634            Some(("my-repo".to_string(), "internal-8".to_string()))
635        );
636    }
637
638    #[test]
639    fn parse_session_name_hyphenated_project_review() {
640        let tmux = TmuxManager::new();
641        // "orch-my-repo-1234-review" → project "my-repo", task_id "1234-review"
642        assert_eq!(
643            tmux.parse_session_name("orch-my-repo-1234-review"),
644            Some(("my-repo".to_string(), "1234-review".to_string()))
645        );
646    }
647
648    #[test]
649    fn parse_session_name_multi_hyphen_project() {
650        let tmux = TmuxManager::new();
651        // "orch-my-cool-repo-42" → project "my-cool-repo", task_id "42"
652        assert_eq!(
653            tmux.parse_session_name("orch-my-cool-repo-42"),
654            Some(("my-cool-repo".to_string(), "42".to_string()))
655        );
656    }
657
658    #[test]
659    fn parse_session_name_roundtrip_hyphenated_project() {
660        let tmux = TmuxManager::new();
661        let name = tmux.session_name("owner/my-repo", "1793");
662        assert_eq!(name, "orch-my-repo-1793");
663        assert_eq!(
664            tmux.parse_session_name(&name),
665            Some(("my-repo".to_string(), "1793".to_string()))
666        );
667    }
668
669    /// Verify set_env runs the correct tmux set-environment command.
670    /// This test creates a temporary session, sets an env var, and verifies it was set.
671    #[tokio::test]
672    async fn test_set_env() {
673        let tmux = TmuxManager::new();
674        let session = test_session_name();
675
676        // Create a temporary detached session for testing
677        let create_result = tokio::process::Command::new("tmux")
678            .args(["new-session", "-d", "-s", &session, "-c", "/tmp"])
679            .output()
680            .await;
681
682        // Skip test if tmux is not available or fails to create test session.
683        match create_result {
684            Ok(o) if o.status.success() => {}
685            _ => {
686                eprintln!("Skipping test: tmux not available or failed to create test session");
687                return;
688            }
689        }
690        let _guard = SessionGuard(session.clone());
691
692        // Use our helper to set an environment variable
693        let result = tmux.set_env(&session, "TEST_VAR", "test_value").await;
694        assert!(result.is_ok(), "set_env should succeed");
695
696        // Verify the variable was set by reading it back
697        let check_result = tokio::process::Command::new("tmux")
698            .args(["show-environment", "-t", &session, "TEST_VAR"])
699            .output()
700            .await;
701
702        let output = match check_result {
703            Ok(o) => o,
704            Err(_) => {
705                eprintln!("Skipping test: unable to check tmux environment");
706                return;
707            }
708        };
709        let stdout = String::from_utf8_lossy(&output.stdout);
710        assert!(
711            stdout.contains("TEST_VAR=test_value"),
712            "Expected TEST_VAR=test_value, got: {}",
713            stdout
714        );
715    }
716
717    #[tokio::test]
718    async fn session_blocks_dispatch_keeps_live_session() {
719        let tmux = TmuxManager::new();
720        let session = test_session_name();
721
722        let create_result = tokio::process::Command::new("tmux")
723            .args(["new-session", "-d", "-s", &session, "-c", "/tmp"])
724            .output()
725            .await;
726
727        match create_result {
728            Ok(o) if o.status.success() => {}
729            _ => {
730                eprintln!("Skipping test: tmux not available or failed to create test session");
731                return;
732            }
733        }
734        let _guard = SessionGuard(session.clone());
735
736        assert!(tmux.session_blocks_dispatch(&session).await);
737        assert!(tmux.session_exists(&session).await);
738    }
739
740    #[tokio::test]
741    async fn session_blocks_dispatch_cleans_dead_session() {
742        let tmux = TmuxManager::new();
743        let session = test_session_name();
744
745        let create_result = tokio::process::Command::new("tmux")
746            .args(["new-session", "-d", "-s", &session, "-c", "/tmp"])
747            .output()
748            .await;
749
750        match create_result {
751            Ok(o) if o.status.success() => {}
752            _ => {
753                eprintln!("Skipping test: tmux not available or failed to create test session");
754                return;
755            }
756        }
757        let _guard = SessionGuard(session.clone());
758
759        let set_option_result = tokio::process::Command::new("tmux")
760            .args(["set-option", "-t", &session, "remain-on-exit", "on"])
761            .output()
762            .await;
763        if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
764            eprintln!("Skipping test: unable to set tmux remain-on-exit option");
765            return;
766        }
767
768        let send_exit_result = tokio::process::Command::new("tmux")
769            .args(["send-keys", "-t", &session, "exit", "Enter"])
770            .output()
771            .await;
772        if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
773            eprintln!("Skipping test: unable to exit tmux pane");
774            return;
775        }
776
777        // Poll until pane is dead (up to 2s) — fixed sleep is unreliable under load.
778        for _ in 0..20 {
779            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
780            if !tmux.session_is_running(&session).await {
781                break;
782            }
783        }
784
785        assert!(tmux.session_exists(&session).await);
786        assert!(!tmux.session_is_running(&session).await);
787        assert!(!tmux.session_blocks_dispatch(&session).await);
788        assert!(!tmux.session_exists(&session).await);
789    }
790
791    /// Regression test: capture_pane returns empty string (not an error) when the
792    /// session/pane is already gone — this is an expected teardown race, not an error.
793    /// https://github.com/gabrielkoerich/orch/issues/2083
794    #[tokio::test]
795    async fn capture_pane_returns_empty_for_gone_session() {
796        let tmux = TmuxManager::new();
797        // Call capture_pane on a session that never existed — should return Ok(""), not an error.
798        let result = tmux
799            .capture_pane("orch-nonexistent-session-99999", 100)
800            .await;
801        assert!(
802            result.is_ok(),
803            "capture_pane should return Ok for non-existent session, got: {:?}",
804            result
805        );
806        assert_eq!(
807            result.unwrap(),
808            "",
809            "capture_pane should return empty string for non-existent session"
810        );
811    }
812
813    /// Regression test: wait_for_completion returns empty string (not an error) when
814    /// the session is already gone.
815    /// https://github.com/gabrielkoerich/orch/issues/2083
816    #[tokio::test]
817    async fn wait_for_completion_returns_empty_for_gone_session() {
818        let tmux = TmuxManager::new();
819        // Call wait_for_completion on a session that never existed — should return Ok(""), not an error.
820        let result = tmux
821            .wait_for_completion(
822                "orch-nonexistent-session-99999",
823                std::time::Duration::from_millis(50),
824            )
825            .await;
826        assert!(
827            result.is_ok(),
828            "wait_for_completion should return Ok for non-existent session, got: {:?}",
829            result
830        );
831        assert_eq!(
832            result.unwrap(),
833            "",
834            "wait_for_completion should return empty string for non-existent session"
835        );
836    }
837}