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}
19
20pub async fn run_agent_session(
25 task_id: &str,
26 invocation: &agent::AgentInvocation,
27 attempt_dir: &Path,
28 orch_home: &Path,
29) -> (TmuxManager, String, SessionOutput) {
30 let task_timeout = Duration::from_secs(invocation.timeout_seconds + 120);
34
35 let tmux = TmuxManager::new();
36
37 let session_start = std::time::Instant::now();
40
41 let session = match agent::spawn_in_tmux(&tmux, invocation).await {
42 Ok(s) => s,
43 Err(e) => {
44 tracing::error!(task_id, error = ?e, "failed to spawn agent in tmux");
45 return (
46 tmux,
47 String::new(),
48 SessionOutput {
49 exit_code: -1,
50 raw_stdout: String::new(),
51 raw_stderr: e.to_string(),
52 elapsed_secs: None,
53 },
54 );
55 }
56 };
57
58 let poll_interval = Duration::from_secs(5);
59 let wait_result = timeout(
60 task_timeout,
61 tmux.wait_for_completion(&session, poll_interval),
62 )
63 .await;
64
65 let elapsed_since_session_start = session_start.elapsed().as_secs();
66
67 match wait_result {
68 Ok(Ok(_output)) => {
69 tracing::info!(task_id, "agent session completed");
70 }
71 Ok(Err(e)) => {
72 tracing::error!(task_id, ?e, "error waiting for session");
73 let _ = tmux.kill_session(&session).await;
74 }
75 Err(_) => {
76 tracing::error!(
77 task_id,
78 elapsed_secs = elapsed_since_session_start,
79 "agent timed out after {} seconds",
80 elapsed_since_session_start
81 );
82 let _ = tmux.kill_session(&session).await;
83 }
84 }
85
86 let output = collect_output(
87 task_id,
88 invocation,
89 attempt_dir,
90 orch_home,
91 elapsed_since_session_start,
92 )
93 .await;
94 (tmux, session, output)
95}
96
97async fn collect_output(
98 task_id: &str,
99 invocation: &agent::AgentInvocation,
100 attempt_dir: &Path,
101 orch_home: &Path,
102 elapsed_secs: u64,
103) -> SessionOutput {
104 let attempt_exit = attempt_dir.join("exit.txt");
106 let legacy_exit = crate::home::state_file(&format!("exit-{task_id}.txt"))
107 .unwrap_or_else(|_| orch_home.join("state").join(format!("exit-{task_id}.txt")));
108
109 let exit_code: i32 = match tokio::task::spawn_blocking(move || {
110 std::fs::read_to_string(&attempt_exit)
111 .or_else(|_| std::fs::read_to_string(&legacy_exit))
112 .ok()
113 .and_then(|s| s.trim().parse().ok())
114 .unwrap_or(-1)
115 })
116 .await
117 {
118 Ok(code) => code,
119 Err(e) => {
120 tracing::error!(task_id, err = ?e, "spawn_blocking panicked reading exit code");
121 -1
122 }
123 };
124
125 let raw_stdout =
127 response::read_output_file(task_id, &invocation.output_file, &invocation.repo).await;
128
129 let stderr_path_attempt = attempt_dir.join("stderr.txt");
130 let stderr_path_legacy = crate::home::state_file(&format!("stderr-{task_id}.txt"))
131 .unwrap_or_else(|_| {
132 orch_home
133 .join("state")
134 .join(format!("stderr-{task_id}.txt"))
135 });
136
137 let raw_stderr: String = match tokio::task::spawn_blocking(move || {
138 std::fs::read_to_string(&stderr_path_attempt)
139 .or_else(|_| std::fs::read_to_string(&stderr_path_legacy))
140 .unwrap_or_default()
141 })
142 .await
143 {
144 Ok(s) => s,
145 Err(e) => {
146 tracing::error!(task_id, err = ?e, "spawn_blocking panicked reading stderr");
147 String::new()
148 }
149 };
150
151 SessionOutput {
152 exit_code,
153 raw_stdout,
154 raw_stderr,
155 elapsed_secs: Some(elapsed_secs),
156 }
157}
158
159pub async fn cleanup_session(task_id: &str, tmux: &TmuxManager, session: &str) {
162 if !session.is_empty() && tmux.session_exists(session).await {
163 if let Err(e) = tmux.kill_session(session).await {
164 tracing::warn!(task_id, error = ?e, "failed to kill tmux session");
165 }
166 }
167
168 tmux.unset_global_env("GH_TOKEN").await.ok();
172 tmux.unset_global_env("GITHUB_TOKEN").await.ok();
173}
174
175#[cfg(test)]
176mod tests {
177 use tokio::time::Duration;
178
179 #[test]
180 fn session_timeout_default_is_30_minutes() {
181 let expected_default = 1800u64;
193
194 assert_eq!(
196 expected_default, 1800,
197 "default session timeout should be 1800 seconds (30 minutes)"
198 );
199
200 let with_grace = expected_default + 120;
202 assert_eq!(
203 with_grace, 1920,
204 "tokio timeout with 120s grace period should be 1920 seconds"
205 );
206 }
207
208 #[test]
209 fn session_timeout_grace_period_is_120_seconds() {
210 const GRACE_PERIOD_SECS: u64 = 120;
217 const DEFAULT_TIMEOUT_SECS: u64 = 1800;
218
219 let tokio_timeout_secs = DEFAULT_TIMEOUT_SECS + GRACE_PERIOD_SECS;
220 let tokio_timeout = Duration::from_secs(tokio_timeout_secs);
221
222 assert_eq!(
223 tokio_timeout.as_secs(),
224 1920,
225 "tokio timeout with grace period should be 1920 seconds"
226 );
227 assert_eq!(GRACE_PERIOD_SECS, 120, "grace period should be 120 seconds");
228 }
229
230 #[test]
231 fn session_timeout_configuration_is_documented() {
232 let default_seconds = 1800u64;
250 let default_minutes = default_seconds / 60;
251
252 assert_eq!(default_minutes, 30, "default timeout should be 30 minutes");
253 }
254}