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