Skip to main content

orch/engine/runner/
session.rs

1//! Agent session management — spawn, wait for completion, collect output.
2//!
3//! Extracted from `runner/mod.rs`. Handles the tmux session lifecycle:
4//! spawning the agent, waiting for completion, reading output files, and cleanup.
5
6use crate::tmux::TmuxManager;
7use std::path::Path;
8use tokio::time::{timeout, Duration};
9
10use super::{agent, response};
11
12/// Raw output collected from an agent session.
13pub struct SessionOutput {
14    pub exit_code: i32,
15    pub raw_stdout: String,
16    pub raw_stderr: String,
17    pub elapsed_secs: Option<u64>,
18}
19
20/// Spawn the agent in tmux, wait for completion, and collect output.
21///
22/// Returns `(TmuxManager, session_name, output)`. The `TmuxManager` and
23/// session name are needed for cleanup via [`cleanup_session`].
24pub async fn run_agent_session(
25    task_id: &str,
26    invocation: &agent::AgentInvocation,
27    attempt_dir: &Path,
28    orch_home: &Path,
29) -> (TmuxManager, String, SessionOutput) {
30    // Use the pre-computed timeout from the invocation (set by task_init.rs, which
31    // reads workflow.timeout_seconds and applies per-complexity overrides).
32    // Add 120s grace so the shell timeout (in runner.sh) fires before Tokio kills the session.
33    let task_timeout = Duration::from_secs(invocation.timeout_seconds + 120);
34
35    let tmux = TmuxManager::new();
36
37    // Start timing before session setup so elapsed includes spawn/setup time.
38    // This helps distinguish "never started" vs "ran for a while then stopped".
39    let session_start = std::time::Instant::now();
40
41    let session = match agent::spawn_in_tmux(&tmux, invocation).await {
42        Ok(s) => s,
43        Err(e) => {
44            tracing::error!(task_id, error = ?e, "failed to spawn agent in tmux");
45            return (
46                tmux,
47                String::new(),
48                SessionOutput {
49                    exit_code: -1,
50                    raw_stdout: String::new(),
51                    raw_stderr: e.to_string(),
52                    elapsed_secs: None,
53                },
54            );
55        }
56    };
57
58    let poll_interval = Duration::from_secs(5);
59    let wait_result = timeout(
60        task_timeout,
61        tmux.wait_for_completion(&session, poll_interval),
62    )
63    .await;
64
65    let elapsed_since_session_start = session_start.elapsed().as_secs();
66
67    match wait_result {
68        Ok(Ok(_output)) => {
69            tracing::info!(task_id, "agent session completed");
70        }
71        Ok(Err(e)) => {
72            tracing::error!(task_id, ?e, "error waiting for session");
73            let _ = tmux.kill_session(&session).await;
74        }
75        Err(_) => {
76            tracing::error!(
77                task_id,
78                elapsed_secs = elapsed_since_session_start,
79                "agent timed out after {} seconds",
80                elapsed_since_session_start
81            );
82            let _ = tmux.kill_session(&session).await;
83        }
84    }
85
86    let output = collect_output(
87        task_id,
88        invocation,
89        attempt_dir,
90        orch_home,
91        elapsed_since_session_start,
92    )
93    .await;
94    (tmux, session, output)
95}
96
97async fn collect_output(
98    task_id: &str,
99    invocation: &agent::AgentInvocation,
100    attempt_dir: &Path,
101    orch_home: &Path,
102    elapsed_secs: u64,
103) -> SessionOutput {
104    // Compute exit code on blocking pool
105    let attempt_exit = attempt_dir.join("exit.txt");
106    let legacy_exit = crate::home::state_file(&format!("exit-{task_id}.txt"))
107        .unwrap_or_else(|_| orch_home.join("state").join(format!("exit-{task_id}.txt")));
108
109    let exit_code: i32 = match tokio::task::spawn_blocking(move || {
110        std::fs::read_to_string(&attempt_exit)
111            .or_else(|_| std::fs::read_to_string(&legacy_exit))
112            .ok()
113            .and_then(|s| s.trim().parse().ok())
114            .unwrap_or(-1)
115    })
116    .await
117    {
118        Ok(code) => code,
119        Err(e) => {
120            tracing::error!(task_id, err = ?e, "spawn_blocking panicked reading exit code");
121            -1
122        }
123    };
124
125    // Read raw output (offloaded inside read_output_file) and stderr (blocking read offloaded)
126    let raw_stdout =
127        response::read_output_file(task_id, &invocation.output_file, &invocation.repo).await;
128
129    let stderr_path_attempt = attempt_dir.join("stderr.txt");
130    let stderr_path_legacy = crate::home::state_file(&format!("stderr-{task_id}.txt"))
131        .unwrap_or_else(|_| {
132            orch_home
133                .join("state")
134                .join(format!("stderr-{task_id}.txt"))
135        });
136
137    let raw_stderr: String = match tokio::task::spawn_blocking(move || {
138        std::fs::read_to_string(&stderr_path_attempt)
139            .or_else(|_| std::fs::read_to_string(&stderr_path_legacy))
140            .unwrap_or_default()
141    })
142    .await
143    {
144        Ok(s) => s,
145        Err(e) => {
146            tracing::error!(task_id, err = ?e, "spawn_blocking panicked reading stderr");
147            String::new()
148        }
149    };
150
151    SessionOutput {
152        exit_code,
153        raw_stdout,
154        raw_stderr,
155        elapsed_secs: Some(elapsed_secs),
156    }
157}
158
159/// Kill the tmux session if it still exists, and scrub secrets from the
160/// tmux global environment so they never leak across sessions.
161pub async fn cleanup_session(task_id: &str, tmux: &TmuxManager, session: &str) {
162    if !session.is_empty() && tmux.session_exists(session).await {
163        if let Err(e) = tmux.kill_session(session).await {
164            tracing::warn!(task_id, error = ?e, "failed to kill tmux session");
165        }
166    }
167
168    // Scrub GH_TOKEN / GITHUB_TOKEN from the tmux global environment.
169    // Per-session vars vanish when the session dies, but a race or bug
170    // could leave tokens in the global env — clean up defensively.
171    tmux.unset_global_env("GH_TOKEN").await.ok();
172    tmux.unset_global_env("GITHUB_TOKEN").await.ok();
173}
174
175#[cfg(test)]
176mod tests {
177    use tokio::time::Duration;
178
179    #[test]
180    fn session_timeout_default_is_30_minutes() {
181        // Verify that the default timeout is 1800 seconds (30 minutes).
182        // This test ensures that sessions will run for at least 30 minutes before timing out.
183        //
184        // The default is set in the `run_agent_session` function via:
185        //   config::get("workflow.timeout_seconds")
186        //       .ok()
187        //       .and_then(|s| s.parse().ok())
188        //       .unwrap_or(1800)
189        //
190        // See the example config at config.example.yml line 13.
191
192        let expected_default = 1800u64;
193
194        // Verify the default value is documented in the code
195        assert_eq!(
196            expected_default, 1800,
197            "default session timeout should be 1800 seconds (30 minutes)"
198        );
199
200        // Verify this matches the grace-period adjusted timeout
201        let with_grace = expected_default + 120;
202        assert_eq!(
203            with_grace, 1920,
204            "tokio timeout with 120s grace period should be 1920 seconds"
205        );
206    }
207
208    #[test]
209    fn session_timeout_grace_period_is_120_seconds() {
210        // Verify that the grace period is correctly 120 seconds.
211        // This allows the shell timeout (in runner.sh) to fire before Tokio kills the session.
212        //
213        // From run_agent_session:
214        //   let task_timeout = Duration::from_secs(task_timeout_secs + 120);
215
216        const GRACE_PERIOD_SECS: u64 = 120;
217        const DEFAULT_TIMEOUT_SECS: u64 = 1800;
218
219        let tokio_timeout_secs = DEFAULT_TIMEOUT_SECS + GRACE_PERIOD_SECS;
220        let tokio_timeout = Duration::from_secs(tokio_timeout_secs);
221
222        assert_eq!(
223            tokio_timeout.as_secs(),
224            1920,
225            "tokio timeout with grace period should be 1920 seconds"
226        );
227        assert_eq!(GRACE_PERIOD_SECS, 120, "grace period should be 120 seconds");
228    }
229
230    #[test]
231    fn session_timeout_configuration_is_documented() {
232        // Verify that the session timeout configuration is documented.
233        //
234        // The session timeout is configurable via:
235        // 1. workflow.timeout_seconds in config.yml (default: 1800 seconds)
236        // 2. Optional per-complexity overrides via workflow.timeout_by_complexity
237        //
238        // This test documents the expected behavior.
239
240        // Configuration examples from config.example.yml:
241        // timeout_seconds: 1800  # 30 minutes
242        //
243        // Or with per-complexity overrides:
244        // timeout_by_complexity:
245        //   simple: 600       # 10 minutes
246        //   medium: 1800      # 30 minutes
247        //   complex: 3600     # 1 hour
248
249        let default_seconds = 1800u64;
250        let default_minutes = default_seconds / 60;
251
252        assert_eq!(default_minutes, 30, "default timeout should be 30 minutes");
253    }
254}