orch/engine/runner/
session.rs1use 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#[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 pub sandbox_error: Option<String>,
25}
26
27pub 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 let task_timeout = Duration::from_secs(invocation.timeout_seconds + 120);
41
42 let tmux = TmuxManager::new();
43
44 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 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 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
171pub 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 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 let expected_default = 1800u64;
204
205 assert_eq!(
207 expected_default, 1800,
208 "default session timeout should be 1800 seconds (30 minutes)"
209 );
210
211 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 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 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}