orch/engine/runner/
session.rs1use crate::tmux::TmuxManager;
7use std::path::Path;
8use tokio::time::{Duration, timeout};
9
10use super::{agent, response};
11
12#[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 pub sandbox_error: Option<String>,
21}
22
23pub 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 let task_timeout = Duration::from_secs(invocation.timeout_seconds + 120);
37
38 let tmux = TmuxManager::new();
39
40 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 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 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
167pub 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 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 let expected_default = 1800u64;
200
201 assert_eq!(
203 expected_default, 1800,
204 "default session timeout should be 1800 seconds (30 minutes)"
205 );
206
207 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 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 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}