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