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 a tmux session.
527    #[allow(dead_code)]
528    pub async fn unset_env(&self, session: &str, key: &str) -> anyhow::Result<()> {
529        let output = Command::new("tmux")
530            .args(["set-environment", "-t", session, "-u", key])
531            .output_with_context()
532            .await?;
533
534        if !output.status.success() {
535            let stderr = String::from_utf8_lossy(&output.stderr);
536            anyhow::bail!("tmux unset-environment failed for {session}: {stderr}");
537        }
538
539        tracing::debug!(session, key, "unset tmux environment variable");
540        Ok(())
541    }
542
543    /// Unset (remove) an environment variable from the tmux **global** environment.
544    ///
545    /// Uses: `tmux set-environment -gu <key>`
546    pub async fn unset_global_env(&self, key: &str) -> anyhow::Result<()> {
547        let output = Command::new("tmux")
548            .args(["set-environment", "-gu", key])
549            .output_with_context()
550            .await?;
551
552        if !output.status.success() {
553            let stderr = String::from_utf8_lossy(&output.stderr);
554            anyhow::bail!("tmux unset-global-environment failed for {key}: {stderr}");
555        }
556
557        tracing::debug!(key, "unset tmux global environment variable");
558        Ok(())
559    }
560}
561
562#[cfg(test)]
563pub(crate) fn test_session_name() -> String {
564    format!(
565        "orch-test-{}",
566        std::time::SystemTime::now()
567            .duration_since(std::time::UNIX_EPOCH)
568            .map(|d| d.as_millis())
569            .unwrap_or_else(|_| 0)
570    )
571}
572
573/// RAII guard that kills a tmux session on drop, even if the test panics.
574#[cfg(test)]
575pub(crate) struct SessionGuard(pub(crate) String);
576
577#[cfg(test)]
578impl Drop for SessionGuard {
579    fn drop(&mut self) {
580        let _ = std::process::Command::new("tmux")
581            .args(["kill-session", "-t", &self.0])
582            .output();
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn session_name_internal_task_id_is_sanitized() {
592        let tmux = TmuxManager::new();
593        assert_eq!(
594            tmux.session_name("owner/repo", "internal:8"),
595            "orch-repo-internal-8"
596        );
597    }
598
599    #[test]
600    fn task_id_from_session_name_parses_external_task() {
601        let tmux = TmuxManager::new();
602        // "orch-repo-1234" → project "repo", task_id "1234"
603        assert_eq!(
604            tmux.parse_session_name("orch-repo-1234"),
605            Some(("repo".to_string(), "1234".to_string()))
606        );
607    }
608
609    #[test]
610    fn task_id_from_session_name_parses_internal_task() {
611        let tmux = TmuxManager::new();
612        // "orch-orch-internal-21116" → project "orch", task_id "internal-21116"
613        assert_eq!(
614            tmux.parse_session_name("orch-orch-internal-21116"),
615            Some(("orch".to_string(), "internal-21116".to_string()))
616        );
617    }
618
619    #[test]
620    fn task_id_from_session_name_parses_internal_task_small_id() {
621        let tmux = TmuxManager::new();
622        // "orch-repo-internal-8" → project "repo", task_id "internal-8"
623        assert_eq!(
624            tmux.parse_session_name("orch-repo-internal-8"),
625            Some(("repo".to_string(), "internal-8".to_string()))
626        );
627    }
628
629    #[test]
630    fn task_id_from_session_name_returns_none_for_non_orch_session() {
631        let tmux = TmuxManager::new();
632        assert_eq!(tmux.parse_session_name("some-other-session"), None);
633    }
634
635    #[test]
636    fn parse_session_name_hyphenated_project_external() {
637        let tmux = TmuxManager::new();
638        // "orch-my-repo-1234" → project "my-repo", task_id "1234"
639        assert_eq!(
640            tmux.parse_session_name("orch-my-repo-1234"),
641            Some(("my-repo".to_string(), "1234".to_string()))
642        );
643    }
644
645    #[test]
646    fn parse_session_name_hyphenated_project_internal() {
647        let tmux = TmuxManager::new();
648        // "orch-my-repo-internal-8" → project "my-repo", task_id "internal-8"
649        assert_eq!(
650            tmux.parse_session_name("orch-my-repo-internal-8"),
651            Some(("my-repo".to_string(), "internal-8".to_string()))
652        );
653    }
654
655    #[test]
656    fn parse_session_name_hyphenated_project_review() {
657        let tmux = TmuxManager::new();
658        // "orch-my-repo-1234-review" → project "my-repo", task_id "1234-review"
659        assert_eq!(
660            tmux.parse_session_name("orch-my-repo-1234-review"),
661            Some(("my-repo".to_string(), "1234-review".to_string()))
662        );
663    }
664
665    #[test]
666    fn parse_session_name_multi_hyphen_project() {
667        let tmux = TmuxManager::new();
668        // "orch-my-cool-repo-42" → project "my-cool-repo", task_id "42"
669        assert_eq!(
670            tmux.parse_session_name("orch-my-cool-repo-42"),
671            Some(("my-cool-repo".to_string(), "42".to_string()))
672        );
673    }
674
675    #[test]
676    fn parse_session_name_roundtrip_hyphenated_project() {
677        let tmux = TmuxManager::new();
678        let name = tmux.session_name("owner/my-repo", "1793");
679        assert_eq!(name, "orch-my-repo-1793");
680        assert_eq!(
681            tmux.parse_session_name(&name),
682            Some(("my-repo".to_string(), "1793".to_string()))
683        );
684    }
685
686    /// Verify set_env runs the correct tmux set-environment command.
687    /// This test creates a temporary session, sets an env var, and verifies it was set.
688    #[tokio::test]
689    async fn test_set_env() {
690        let tmux = TmuxManager::new();
691        let session = test_session_name();
692
693        // Create a temporary detached session for testing
694        let create_result = tokio::process::Command::new("tmux")
695            .args(["new-session", "-d", "-s", &session, "-c", "/tmp"])
696            .output()
697            .await;
698
699        // Skip test if tmux is not available or fails to create test session.
700        match create_result {
701            Ok(o) if o.status.success() => {}
702            _ => {
703                eprintln!("Skipping test: tmux not available or failed to create test session");
704                return;
705            }
706        }
707        let _guard = SessionGuard(session.clone());
708
709        // Use our helper to set an environment variable
710        let result = tmux.set_env(&session, "TEST_VAR", "test_value").await;
711        assert!(result.is_ok(), "set_env should succeed");
712
713        // Verify the variable was set by reading it back
714        let check_result = tokio::process::Command::new("tmux")
715            .args(["show-environment", "-t", &session, "TEST_VAR"])
716            .output()
717            .await;
718
719        let output = match check_result {
720            Ok(o) => o,
721            Err(_) => {
722                eprintln!("Skipping test: unable to check tmux environment");
723                return;
724            }
725        };
726        let stdout = String::from_utf8_lossy(&output.stdout);
727        assert!(
728            stdout.contains("TEST_VAR=test_value"),
729            "Expected TEST_VAR=test_value, got: {}",
730            stdout
731        );
732    }
733
734    /// Verify unset_env runs the correct tmux set-environment -u command.
735    /// This test verifies the function can be called without error.
736    /// Note: Full verification of tmux behavior depends on the environment.
737    #[tokio::test]
738    async fn test_unset_env() {
739        let tmux = TmuxManager::new();
740        let session = test_session_name();
741
742        // Create a temporary detached session for testing
743        let create_result = tokio::process::Command::new("tmux")
744            .args(["new-session", "-d", "-s", &session, "-c", "/tmp"])
745            .output()
746            .await;
747
748        match create_result {
749            Ok(o) if o.status.success() => {}
750            _ => {
751                eprintln!("Skipping test: tmux not available or failed to create test session");
752                return;
753            }
754        }
755        let _guard = SessionGuard(session.clone());
756
757        // First set a variable
758        let set_result = tmux.set_env(&session, "TO_DELETE", "temporary").await;
759        assert!(set_result.is_ok(), "set_env should succeed");
760
761        // Verify it exists
762        let check_before = tokio::process::Command::new("tmux")
763            .args(["show-environment", "-t", &session, "TO_DELETE"])
764            .output()
765            .await;
766
767        let check_before = match check_before {
768            Ok(o) => o,
769            Err(_) => {
770                eprintln!("Skipping test: unable to check tmux environment");
771                return;
772            }
773        };
774
775        assert!(
776            String::from_utf8_lossy(&check_before.stdout).contains("TO_DELETE"),
777            "Variable should exist before unset"
778        );
779
780        // Call unset_session_env - verify it runs without error
781        let unset_result = tmux.unset_env(&session, "TO_DELETE").await;
782        assert!(unset_result.is_ok(), "unset_env should succeed");
783    }
784
785    #[tokio::test]
786    async fn session_blocks_dispatch_keeps_live_session() {
787        let tmux = TmuxManager::new();
788        let session = test_session_name();
789
790        let create_result = tokio::process::Command::new("tmux")
791            .args(["new-session", "-d", "-s", &session, "-c", "/tmp"])
792            .output()
793            .await;
794
795        match create_result {
796            Ok(o) if o.status.success() => {}
797            _ => {
798                eprintln!("Skipping test: tmux not available or failed to create test session");
799                return;
800            }
801        }
802        let _guard = SessionGuard(session.clone());
803
804        assert!(tmux.session_blocks_dispatch(&session).await);
805        assert!(tmux.session_exists(&session).await);
806    }
807
808    #[tokio::test]
809    async fn session_blocks_dispatch_cleans_dead_session() {
810        let tmux = TmuxManager::new();
811        let session = test_session_name();
812
813        let create_result = tokio::process::Command::new("tmux")
814            .args(["new-session", "-d", "-s", &session, "-c", "/tmp"])
815            .output()
816            .await;
817
818        match create_result {
819            Ok(o) if o.status.success() => {}
820            _ => {
821                eprintln!("Skipping test: tmux not available or failed to create test session");
822                return;
823            }
824        }
825        let _guard = SessionGuard(session.clone());
826
827        let set_option_result = tokio::process::Command::new("tmux")
828            .args(["set-option", "-t", &session, "remain-on-exit", "on"])
829            .output()
830            .await;
831        if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
832            eprintln!("Skipping test: unable to set tmux remain-on-exit option");
833            return;
834        }
835
836        let send_exit_result = tokio::process::Command::new("tmux")
837            .args(["send-keys", "-t", &session, "exit", "Enter"])
838            .output()
839            .await;
840        if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
841            eprintln!("Skipping test: unable to exit tmux pane");
842            return;
843        }
844
845        // Poll until pane is dead (up to 2s) — fixed sleep is unreliable under load.
846        for _ in 0..20 {
847            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
848            if !tmux.session_is_running(&session).await {
849                break;
850            }
851        }
852
853        assert!(tmux.session_exists(&session).await);
854        assert!(!tmux.session_is_running(&session).await);
855        assert!(!tmux.session_blocks_dispatch(&session).await);
856        assert!(!tmux.session_exists(&session).await);
857    }
858
859    /// Regression test: capture_pane returns empty string (not an error) when the
860    /// session/pane is already gone — this is an expected teardown race, not an error.
861    /// https://github.com/gabrielkoerich/orch/issues/2083
862    #[tokio::test]
863    async fn capture_pane_returns_empty_for_gone_session() {
864        let tmux = TmuxManager::new();
865        // Call capture_pane on a session that never existed — should return Ok(""), not an error.
866        let result = tmux
867            .capture_pane("orch-nonexistent-session-99999", 100)
868            .await;
869        assert!(
870            result.is_ok(),
871            "capture_pane should return Ok for non-existent session, got: {:?}",
872            result
873        );
874        assert_eq!(
875            result.unwrap(),
876            "",
877            "capture_pane should return empty string for non-existent session"
878        );
879    }
880
881    /// Regression test: wait_for_completion returns empty string (not an error) when
882    /// the session is already gone.
883    /// https://github.com/gabrielkoerich/orch/issues/2083
884    #[tokio::test]
885    async fn wait_for_completion_returns_empty_for_gone_session() {
886        let tmux = TmuxManager::new();
887        // Call wait_for_completion on a session that never existed — should return Ok(""), not an error.
888        let result = tmux
889            .wait_for_completion(
890                "orch-nonexistent-session-99999",
891                std::time::Duration::from_millis(50),
892            )
893            .await;
894        assert!(
895            result.is_ok(),
896            "wait_for_completion should return Ok for non-existent session, got: {:?}",
897            result
898        );
899        assert_eq!(
900            result.unwrap(),
901            "",
902            "wait_for_completion should return empty string for non-existent session"
903        );
904    }
905}