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