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