orch/engine/runner/
session.rs1use crate::tmux::TmuxManager;
7use std::path::Path;
8use tokio::time::{timeout, Duration};
9
10use super::{agent, response};
11
12pub struct SessionOutput {
14 pub exit_code: i32,
15 pub raw_stdout: String,
16 pub raw_stderr: String,
17 pub elapsed_secs: Option<u64>,
18 pub sandbox_error: Option<String>,
20}
21
22pub 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 let task_timeout = Duration::from_secs(invocation.timeout_seconds + 120);
36
37 let tmux = TmuxManager::new();
38
39 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 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 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
166pub 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 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 let expected_default = 1800u64;
198
199 assert_eq!(
201 expected_default, 1800,
202 "default session timeout should be 1800 seconds (30 minutes)"
203 );
204
205 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 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 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}