1mod lifecycle;
7
8pub use lifecycle::{ExecSessionEvent, ExecSessionSnapshot, ExecSessionStatus};
9
10use std::collections::{BTreeMap, VecDeque};
11use std::path::PathBuf;
12use std::process::ExitStatus;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16
17use orchestral_core::agent_protocol::wire::RunId;
18use orchestral_core::tool_protocol::ToolOperationPlan;
19use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
20use tokio::process::{Child, ChildStdin, Command};
21use tokio::sync::{broadcast, Mutex as AsyncMutex, Notify};
22use tokio_util::sync::CancellationToken;
23
24use crate::pty_process::{PtyProcessId, PtyProcessManager, PtyReadOptions, PtySpawnSpec};
25
26mod runtime_temp;
27use runtime_temp::{RuntimeTempDirectory, RuntimeTempRoot};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct ExecSessionId(u64);
31
32impl ExecSessionId {
33 pub fn new(value: u64) -> Result<Self, ExecProcessError> {
34 if value == 0 {
35 return Err(ExecProcessError::Invalid(
36 "exec session ID must be positive".to_owned(),
37 ));
38 }
39 Ok(Self(value))
40 }
41
42 pub fn get(self) -> u64 {
43 self.0
44 }
45}
46
47#[derive(Debug, Clone)]
48pub struct ExecSpawnSpec {
49 pub run_id: RunId,
50 pub program: String,
51 pub args: Vec<String>,
52 pub cwd: PathBuf,
53 pub environment: BTreeMap<String, String>,
54 pub tty: bool,
55 pub backend_starts_new_session: bool,
56 pub operation: ToolOperationPlan,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct ExecPollResult {
62 pub stdout: String,
63 pub stderr: String,
64 pub dropped_bytes: u64,
65 pub alive: bool,
66 pub exit_code: Option<i32>,
67 pub wall_time_seconds: f64,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum ExecWaitMode {
73 Output,
75 Completion,
77}
78
79impl ExecWaitMode {
80 pub fn for_interaction(tty: bool, has_input: bool) -> Self {
82 if tty || has_input {
83 Self::Output
84 } else {
85 Self::Completion
86 }
87 }
88}
89
90#[derive(Debug, Clone)]
92pub struct ExecWaitOptions {
93 pub duration: Duration,
94 pub mode: ExecWaitMode,
95 pub yield_requested: CancellationToken,
97}
98
99#[derive(Debug, thiserror::Error)]
100#[non_exhaustive]
101pub enum ExecProcessError {
102 #[error("invalid exec operation: {0}")]
103 Invalid(String),
104 #[error("exec session was not found in this Run: {0}")]
105 NotFound(u64),
106 #[error("exec process operation was cancelled")]
107 Cancelled,
108 #[error("exec process manager state is unavailable")]
109 Unavailable,
110 #[error("exec process I/O failed: {0}")]
111 Io(String),
112}
113
114type SessionKey = (RunId, ExecSessionId);
115
116#[derive(Clone)]
117enum ManagedProcess {
118 Pipe(Arc<PipeSession>),
119 Pty { process_id: PtyProcessId },
120}
121
122#[derive(Clone)]
123struct ManagedSession {
124 process: ManagedProcess,
125 lifecycle: Arc<SessionLifecycle>,
126 started: Instant,
127 tty: bool,
128 operation: ToolOperationPlan,
129 _runtime_temp: Option<Arc<RuntimeTempDirectory>>,
131}
132
133use lifecycle::SessionLifecycle;
134
135#[derive(Default)]
136struct OutputState {
137 bytes: VecDeque<u8>,
138 dropped_bytes: u64,
139 generation: u64,
140 closed: bool,
141}
142
143struct SharedOutput {
144 state: Mutex<OutputState>,
145 changed: Notify,
146 max_bytes: usize,
147}
148
149impl SharedOutput {
150 fn new(max_bytes: usize) -> Arc<Self> {
151 Arc::new(Self {
152 state: Mutex::new(OutputState::default()),
153 changed: Notify::new(),
154 max_bytes,
155 })
156 }
157
158 fn push(&self, bytes: &[u8]) {
159 if let Ok(mut state) = self.state.lock() {
160 for byte in bytes {
161 if state.bytes.len() == self.max_bytes {
162 state.bytes.pop_front();
163 state.dropped_bytes = state.dropped_bytes.saturating_add(1);
164 }
165 state.bytes.push_back(*byte);
166 }
167 state.generation = state.generation.saturating_add(1);
168 }
169 self.changed.notify_waiters();
170 }
171
172 fn close(&self) {
173 if let Ok(mut state) = self.state.lock() {
174 state.closed = true;
175 state.generation = state.generation.saturating_add(1);
176 }
177 self.changed.notify_waiters();
178 }
179
180 fn snapshot(&self) -> Result<(u64, bool, bool), ExecProcessError> {
181 let state = self
182 .state
183 .lock()
184 .map_err(|_| ExecProcessError::Unavailable)?;
185 Ok((state.generation, state.closed, !state.bytes.is_empty()))
186 }
187
188 fn drain(&self) -> Result<(String, u64), ExecProcessError> {
189 let mut state = self
190 .state
191 .lock()
192 .map_err(|_| ExecProcessError::Unavailable)?;
193 let raw = state.bytes.drain(..).collect::<Vec<_>>();
194 let dropped = std::mem::take(&mut state.dropped_bytes);
195 Ok((String::from_utf8_lossy(&raw).into_owned(), dropped))
196 }
197}
198
199struct PipeSession {
200 child: AsyncMutex<Child>,
201 stdin: AsyncMutex<Option<ChildStdin>>,
202 stdout: Arc<SharedOutput>,
203 stderr: Arc<SharedOutput>,
204 process_group_id: Option<u32>,
205 #[cfg(windows)]
206 job: crate::windows_process_job::ProcessJob,
207}
208
209impl PipeSession {
210 fn spawn(spec: &ExecSpawnSpec, max_output_bytes: usize) -> Result<Arc<Self>, ExecProcessError> {
211 if spec.run_id.is_empty() || spec.program.trim().is_empty() || !spec.cwd.is_absolute() {
212 return Err(ExecProcessError::Invalid(
213 "exec spawn requires run/program/absolute cwd".to_owned(),
214 ));
215 }
216 let mut command = Command::new(&spec.program);
217 command
218 .args(&spec.args)
219 .env_clear()
220 .envs(&spec.environment)
221 .current_dir(&spec.cwd)
222 .stdin(std::process::Stdio::piped())
223 .stdout(std::process::Stdio::piped())
224 .stderr(std::process::Stdio::piped())
225 .kill_on_drop(true);
226 isolate_process_group(&mut command, spec.backend_starts_new_session);
227 let mut child = command
228 .spawn()
229 .map_err(|error| ExecProcessError::Io(error.to_string()))?;
230 #[cfg(windows)]
231 let job = match child
232 .raw_handle()
233 .ok_or_else(|| std::io::Error::other("child process handle unavailable"))
234 .and_then(crate::windows_process_job::ProcessJob::attach)
235 {
236 Ok(job) => job,
237 Err(error) => {
238 let _ = child.start_kill();
239 return Err(ExecProcessError::Io(format!(
240 "could not supervise Windows process tree: {error}"
241 )));
242 }
243 };
244 let process_group_id = child.id();
245 let stdin = child.stdin.take();
246 let stdout = child
247 .stdout
248 .take()
249 .ok_or_else(|| ExecProcessError::Io("exec stdout pipe was not created".to_owned()))?;
250 let stderr = child
251 .stderr
252 .take()
253 .ok_or_else(|| ExecProcessError::Io("exec stderr pipe was not created".to_owned()))?;
254 let stdout_buffer = SharedOutput::new(max_output_bytes);
255 let stderr_buffer = SharedOutput::new(max_output_bytes);
256 spawn_reader(stdout, stdout_buffer.clone());
257 spawn_reader(stderr, stderr_buffer.clone());
258 Ok(Arc::new(Self {
259 child: AsyncMutex::new(child),
260 stdin: AsyncMutex::new(stdin),
261 stdout: stdout_buffer,
262 stderr: stderr_buffer,
263 process_group_id,
264 #[cfg(windows)]
265 job,
266 }))
267 }
268
269 async fn send(&self, input: &str) -> Result<(), ExecProcessError> {
270 if input.is_empty() {
271 return Ok(());
272 }
273 let mut stdin = self.stdin.lock().await;
274 let stdin = stdin
275 .as_mut()
276 .ok_or_else(|| ExecProcessError::Io("exec stdin is closed".to_owned()))?;
277 stdin
278 .write_all(input.as_bytes())
279 .await
280 .map_err(|error| ExecProcessError::Io(error.to_string()))?;
281 stdin
282 .flush()
283 .await
284 .map_err(|error| ExecProcessError::Io(error.to_string()))
285 }
286
287 async fn poll(
288 &self,
289 lifecycle: &SessionLifecycle,
290 started_at: Instant,
291 options: &ExecWaitOptions,
292 cancellation: &CancellationToken,
293 ) -> Result<ExecPollResult, ExecProcessError> {
294 let wait = options.duration;
295 let started = Instant::now();
296 let settle = Duration::from_millis(50).min(wait);
297 let mut observed = (u64::MAX, u64::MAX);
298 let mut last_change = Instant::now();
299 let exit_code = loop {
300 if cancellation.is_cancelled() {
301 return Err(ExecProcessError::Cancelled);
302 }
303 let status = lifecycle.status()?;
304 let exit_code = match &status {
305 ExecSessionStatus::Running => None,
306 ExecSessionStatus::Exited { exit_code } => Some(*exit_code),
307 ExecSessionStatus::Terminated => return Err(ExecProcessError::Cancelled),
308 ExecSessionStatus::Failed { message } => {
309 return Err(ExecProcessError::Io(message.clone()))
310 }
311 };
312 let stdout = self.stdout.snapshot()?;
313 let stderr = self.stderr.snapshot()?;
314 let generation = (stdout.0, stderr.0);
315 if generation != observed {
316 observed = generation;
317 last_change = Instant::now();
318 }
319 if exit_code.is_some() && ((stdout.1 && stderr.1) || last_change.elapsed() >= settle) {
320 break exit_code;
321 }
322 if options.yield_requested.is_cancelled()
323 || started.elapsed() >= wait
324 || (options.mode == ExecWaitMode::Output
325 && (stdout.2 || stderr.2)
326 && last_change.elapsed() >= settle)
327 {
328 break exit_code;
329 }
330 let remaining = wait.saturating_sub(started.elapsed());
331 let pause = remaining.min(Duration::from_millis(20));
332 tokio::select! {
333 _ = cancellation.cancelled() => return Err(ExecProcessError::Cancelled),
334 _ = options.yield_requested.cancelled() => {},
335 _ = self.stdout.changed.notified() => {},
336 _ = self.stderr.changed.notified() => {},
337 _ = lifecycle.changed.notified() => {},
338 _ = tokio::time::sleep(pause) => {},
339 }
340 };
341 let (stdout, stdout_dropped) = self.stdout.drain()?;
342 let (stderr, stderr_dropped) = self.stderr.drain()?;
343 Ok(ExecPollResult {
344 stdout,
345 stderr,
346 dropped_bytes: stdout_dropped.saturating_add(stderr_dropped),
347 alive: exit_code.is_none(),
348 exit_code,
349 wall_time_seconds: started_at.elapsed().as_secs_f64(),
350 })
351 }
352
353 async fn terminate(&self) {
354 self.stdin.lock().await.take();
355 let mut child = self.child.lock().await;
356 terminate_process_group(self.process_group_id);
357 #[cfg(windows)]
358 self.job.terminate();
359 let _ = child.start_kill();
360 let _ = child.wait().await;
361 }
362}
363
364fn spawn_reader<R>(mut reader: R, output: Arc<SharedOutput>)
365where
366 R: AsyncRead + Unpin + Send + 'static,
367{
368 tokio::spawn(async move {
369 let mut chunk = [0_u8; 8192];
370 loop {
371 match reader.read(&mut chunk).await {
372 Ok(0) => break,
373 Ok(count) => output.push(&chunk[..count]),
374 Err(_) => break,
375 }
376 }
377 output.close();
378 });
379}
380
381const PROCESS_EVENT_BUFFER: usize = 256;
382const PROCESS_WATCH_INTERVAL: Duration = Duration::from_millis(20);
383
384pub struct ProcessSupervisor {
386 sessions: Mutex<BTreeMap<SessionKey, ManagedSession>>,
387 next_session_id: AtomicU64,
388 pty: Arc<PtyProcessManager>,
389 max_output_bytes: usize,
390 events: broadcast::Sender<ExecSessionEvent>,
391 runtime_temp_root: Arc<RuntimeTempRoot>,
392 runtime_temps: Mutex<BTreeMap<RunId, Arc<RuntimeTempDirectory>>>,
393}
394
395impl ProcessSupervisor {
396 pub fn new(max_output_bytes: usize) -> Result<Self, ExecProcessError> {
397 Self::with_temp_root(max_output_bytes, RuntimeTempRoot::temporary()?)
398 }
399
400 pub fn new_with_runtime_temp_root(
404 max_output_bytes: usize,
405 root: impl AsRef<std::path::Path>,
406 ) -> Result<Self, ExecProcessError> {
407 Self::with_temp_root(max_output_bytes, RuntimeTempRoot::open(root.as_ref())?)
408 }
409
410 fn with_temp_root(
411 max_output_bytes: usize,
412 runtime_temp_root: RuntimeTempRoot,
413 ) -> Result<Self, ExecProcessError> {
414 if max_output_bytes == 0 {
415 return Err(ExecProcessError::Invalid(
416 "exec output limit must be positive".to_owned(),
417 ));
418 }
419 let pty = PtyProcessManager::new(max_output_bytes, Duration::from_secs(10 * 60))
420 .map_err(|error| ExecProcessError::Io(error.to_string()))?;
421 let (events, _) = broadcast::channel(PROCESS_EVENT_BUFFER);
422 Ok(Self {
423 sessions: Mutex::new(BTreeMap::new()),
424 next_session_id: AtomicU64::new(1),
425 pty: Arc::new(pty),
426 max_output_bytes,
427 events,
428 runtime_temp_root: Arc::new(runtime_temp_root),
429 runtime_temps: Mutex::new(BTreeMap::new()),
430 })
431 }
432
433 pub fn runtime_temp_root(&self) -> &std::path::Path {
436 self.runtime_temp_root.path()
437 }
438
439 pub(crate) fn runtime_temp_path(&self, run_id: &RunId) -> PathBuf {
440 self.runtime_temp_root.run_path(run_id)
441 }
442
443 pub(crate) fn prepare_runtime_temp(
444 self: &Arc<Self>,
445 run_id: &RunId,
446 run_cancellation: CancellationToken,
447 ) -> Result<PathBuf, ExecProcessError> {
448 if run_cancellation.is_cancelled() {
449 return Err(ExecProcessError::Cancelled);
450 }
451 let mut directories = self
452 .runtime_temps
453 .lock()
454 .map_err(|_| ExecProcessError::Unavailable)?;
455 if let Some(directory) = directories.get(run_id) {
456 return Ok(directory.path().to_owned());
457 }
458 let directory = self.runtime_temp_root.create_run(run_id)?;
459 let path = directory.path().to_owned();
460 directories.insert(run_id.clone(), directory);
461 let manager = Arc::downgrade(self);
462 let run_id = run_id.clone();
463 tokio::spawn(async move {
464 run_cancellation.cancelled().await;
465 if let Some(manager) = manager.upgrade() {
466 let _ = manager.close_run(&run_id).await;
467 }
468 });
469 Ok(path)
470 }
471
472 pub fn subscribe(&self) -> broadcast::Receiver<ExecSessionEvent> {
473 self.events.subscribe()
474 }
475
476 pub async fn spawn(&self, spec: ExecSpawnSpec) -> Result<ExecSessionId, ExecProcessError> {
477 spec.operation
478 .validate_shape()
479 .map_err(|error| ExecProcessError::Invalid(error.message))?;
480 let session_id = ExecSessionId::new(self.next_session_id.fetch_add(1, Ordering::Relaxed))?;
481 let lifecycle = SessionLifecycle::running();
482 let started = Instant::now();
483 let process = if spec.tty {
484 let process_id = PtyProcessId::new(format!("exec-{}", session_id.get()))
485 .map_err(|error| ExecProcessError::Invalid(error.to_string()))?;
486 let pty_spec = PtySpawnSpec {
487 run_id: spec.run_id.clone(),
488 process_id: process_id.clone(),
489 program: spec.program.clone(),
490 args: spec.args.clone(),
491 cwd: spec.cwd.clone(),
492 environment: spec.environment.clone(),
493 rows: 24,
494 cols: 120,
495 };
496 let pty = self.pty.clone();
497 tokio::task::spawn_blocking(move || pty.create(pty_spec))
498 .await
499 .map_err(|error| ExecProcessError::Io(error.to_string()))?
500 .map_err(|error| ExecProcessError::Io(error.to_string()))?;
501 ManagedProcess::Pty { process_id }
502 } else {
503 ManagedProcess::Pipe(PipeSession::spawn(&spec, self.max_output_bytes)?)
504 };
505 let session = ManagedSession {
506 process,
507 lifecycle,
508 started,
509 tty: spec.tty,
510 operation: spec.operation,
511 _runtime_temp: self
512 .runtime_temps
513 .lock()
514 .map_err(|_| ExecProcessError::Unavailable)?
515 .get(&spec.run_id)
516 .cloned(),
517 };
518 let key = (spec.run_id, session_id);
519 self.sessions
520 .lock()
521 .map_err(|_| ExecProcessError::Unavailable)?
522 .insert(key.clone(), session.clone());
523 publish_session_event(&self.events, &key, &session);
524 spawn_exit_watcher(key, session, self.pty.clone(), self.events.clone());
525 Ok(session_id)
526 }
527
528 pub async fn write_and_poll(
531 &self,
532 run_id: &RunId,
533 session_id: ExecSessionId,
534 input: Option<&str>,
535 wait: Duration,
536 cancellation: &CancellationToken,
537 ) -> Result<ExecPollResult, ExecProcessError> {
538 let tty = self.snapshot(run_id, session_id)?.tty;
539 self.write_and_poll_with_options(
540 run_id,
541 session_id,
542 input,
543 ExecWaitOptions {
544 duration: wait,
545 mode: ExecWaitMode::for_interaction(tty, input.is_some_and(|v| !v.is_empty())),
546 yield_requested: CancellationToken::new(),
547 },
548 cancellation,
549 )
550 .await
551 }
552
553 pub async fn write_and_poll_with_options(
556 &self,
557 run_id: &RunId,
558 session_id: ExecSessionId,
559 input: Option<&str>,
560 options: ExecWaitOptions,
561 cancellation: &CancellationToken,
562 ) -> Result<ExecPollResult, ExecProcessError> {
563 if options.duration.is_zero() {
564 return Err(ExecProcessError::Invalid(
565 "exec poll duration must be positive".to_owned(),
566 ));
567 }
568 let session = self.session(run_id, session_id)?;
569 let requested_input = input.filter(|input| !input.is_empty());
570 let input_to_deliver = if requested_input.is_some() {
571 match session.lifecycle.status()? {
572 ExecSessionStatus::Running => requested_input,
573 ExecSessionStatus::Exited { .. } => None,
578 ExecSessionStatus::Terminated => return Err(ExecProcessError::Cancelled),
579 ExecSessionStatus::Failed { message } => return Err(ExecProcessError::Io(message)),
580 }
581 } else {
582 None
583 };
584 let result = match session.process.clone() {
585 ManagedProcess::Pipe(process) => {
586 if let Some(input) = input_to_deliver {
587 if let Err(error) = process.send(input).await {
588 if !matches!(
589 session.lifecycle.status()?,
590 ExecSessionStatus::Exited { .. }
591 ) {
592 return Err(error);
593 }
594 }
595 }
596 process
597 .poll(&session.lifecycle, session.started, &options, cancellation)
598 .await?
599 }
600 ManagedProcess::Pty { process_id } => {
601 if let Some(input) = input_to_deliver {
602 let pty = self.pty.clone();
603 let run_id = run_id.clone();
604 let process_id = process_id.clone();
605 let input = input.to_owned();
606 tokio::task::spawn_blocking(move || pty.send(&run_id, &process_id, &input))
607 .await
608 .map_err(|error| ExecProcessError::Io(error.to_string()))?
609 .map_err(|error| ExecProcessError::Io(error.to_string()))?;
610 }
611 let pty = self.pty.clone();
612 let run_id = run_id.clone();
613 let process_id = process_id.clone();
614 let cancellation = cancellation.clone();
615 let read = tokio::task::spawn_blocking(move || {
616 pty.read_with_options(
617 &run_id,
618 &process_id,
619 PtyReadOptions {
620 timeout: options.duration,
621 settle: match options.mode {
622 ExecWaitMode::Output => {
623 Duration::from_millis(50).min(options.duration)
624 }
625 ExecWaitMode::Completion => options.duration,
626 },
627 yield_requested: options.yield_requested,
628 },
629 &cancellation,
630 )
631 })
632 .await
633 .map_err(|error| ExecProcessError::Io(error.to_string()))?
634 .map_err(|error| match error {
635 crate::pty_process::PtyProcessError::Cancelled => ExecProcessError::Cancelled,
636 error => ExecProcessError::Io(error.to_string()),
637 })?;
638 ExecPollResult {
639 stdout: read.output,
640 stderr: String::new(),
641 dropped_bytes: read.dropped_bytes,
642 alive: read.alive,
643 exit_code: read.exit_code,
644 wall_time_seconds: session.started.elapsed().as_secs_f64(),
645 }
646 }
647 };
648 if !result.alive {
649 if let Some(exit_code) = result.exit_code {
650 transition_session(
651 &self.events,
652 &(run_id.clone(), session_id),
653 &session,
654 ExecSessionStatus::Exited { exit_code },
655 )?;
656 }
657 self.remove_finished(run_id, session_id, session).await;
658 }
659 Ok(result)
660 }
661
662 pub async fn close(
663 &self,
664 run_id: &RunId,
665 session_id: ExecSessionId,
666 ) -> Result<(), ExecProcessError> {
667 let session = self
668 .sessions
669 .lock()
670 .map_err(|_| ExecProcessError::Unavailable)?
671 .remove(&(run_id.clone(), session_id))
672 .ok_or(ExecProcessError::NotFound(session_id.get()))?;
673 transition_session(
674 &self.events,
675 &(run_id.clone(), session_id),
676 &session,
677 ExecSessionStatus::Terminated,
678 )?;
679 self.terminate_session(run_id, session).await;
680 Ok(())
681 }
682
683 pub async fn close_run(&self, run_id: &RunId) -> Result<usize, ExecProcessError> {
684 let owned = {
685 let mut sessions = self
686 .sessions
687 .lock()
688 .map_err(|_| ExecProcessError::Unavailable)?;
689 let keys = sessions
690 .keys()
691 .filter(|(owner, _)| owner == run_id)
692 .cloned()
693 .collect::<Vec<_>>();
694 keys.into_iter()
695 .filter_map(|key| sessions.remove(&key).map(|session| (key, session)))
696 .collect::<Vec<_>>()
697 };
698 let count = owned.len();
699 for (key, session) in owned {
700 transition_session(&self.events, &key, &session, ExecSessionStatus::Terminated)?;
701 self.terminate_session(run_id, session).await;
702 }
703 self.runtime_temps
704 .lock()
705 .map_err(|_| ExecProcessError::Unavailable)?
706 .remove(run_id);
707 Ok(count)
708 }
709
710 pub fn list(&self, run_id: &RunId) -> Result<Vec<ExecSessionId>, ExecProcessError> {
711 let sessions = self
712 .sessions
713 .lock()
714 .map_err(|_| ExecProcessError::Unavailable)?;
715 let mut active = Vec::new();
716 for ((owner, session_id), session) in sessions.iter() {
717 if owner == run_id && session.lifecycle.status()? == ExecSessionStatus::Running {
718 active.push(*session_id);
719 }
720 }
721 Ok(active)
722 }
723
724 pub fn snapshot(
725 &self,
726 run_id: &RunId,
727 session_id: ExecSessionId,
728 ) -> Result<ExecSessionSnapshot, ExecProcessError> {
729 let session = self.session(run_id, session_id)?;
730 session_snapshot(&(run_id.clone(), session_id), &session)
731 }
732
733 pub fn operation_plan(
734 &self,
735 run_id: &RunId,
736 session_id: ExecSessionId,
737 ) -> Result<ToolOperationPlan, ExecProcessError> {
738 Ok(self.session(run_id, session_id)?.operation)
739 }
740
741 fn session(
742 &self,
743 run_id: &RunId,
744 session_id: ExecSessionId,
745 ) -> Result<ManagedSession, ExecProcessError> {
746 self.sessions
747 .lock()
748 .map_err(|_| ExecProcessError::Unavailable)?
749 .get(&(run_id.clone(), session_id))
750 .cloned()
751 .ok_or(ExecProcessError::NotFound(session_id.get()))
752 }
753
754 async fn remove_finished(
755 &self,
756 run_id: &RunId,
757 session_id: ExecSessionId,
758 session: ManagedSession,
759 ) {
760 if let Ok(mut sessions) = self.sessions.lock() {
761 sessions.remove(&(run_id.clone(), session_id));
762 }
763 if let ManagedProcess::Pty { process_id } = session.process {
764 let pty = self.pty.clone();
765 let run_id = run_id.clone();
766 let _ = tokio::task::spawn_blocking(move || pty.close(&run_id, &process_id)).await;
767 }
768 }
769
770 async fn terminate_session(&self, run_id: &RunId, session: ManagedSession) {
771 match session {
772 ManagedSession {
773 process: ManagedProcess::Pipe(process),
774 ..
775 } => process.terminate().await,
776 ManagedSession {
777 process: ManagedProcess::Pty { process_id },
778 ..
779 } => {
780 let pty = self.pty.clone();
781 let run_id = run_id.clone();
782 let _ = tokio::task::spawn_blocking(move || pty.close(&run_id, &process_id)).await;
783 }
784 }
785 }
786}
787
788fn session_snapshot(
789 (run_id, session_id): &SessionKey,
790 session: &ManagedSession,
791) -> Result<ExecSessionSnapshot, ExecProcessError> {
792 Ok(ExecSessionSnapshot {
793 run_id: run_id.clone(),
794 session_id: *session_id,
795 tty: session.tty,
796 status: session.lifecycle.status()?,
797 operation: session.operation.clone(),
798 wall_time_seconds: session.started.elapsed().as_secs_f64(),
799 })
800}
801
802fn publish_session_event(
803 events: &broadcast::Sender<ExecSessionEvent>,
804 key: &SessionKey,
805 session: &ManagedSession,
806) {
807 if let Ok(snapshot) = session_snapshot(key, session) {
808 let _ = events.send(ExecSessionEvent { snapshot });
809 }
810}
811
812fn transition_session(
813 events: &broadcast::Sender<ExecSessionEvent>,
814 key: &SessionKey,
815 session: &ManagedSession,
816 status: ExecSessionStatus,
817) -> Result<bool, ExecProcessError> {
818 let changed = session.lifecycle.transition(status)?;
819 if changed {
820 publish_session_event(events, key, session);
821 }
822 Ok(changed)
823}
824
825fn spawn_exit_watcher(
826 key: SessionKey,
827 session: ManagedSession,
828 pty: Arc<PtyProcessManager>,
829 events: broadcast::Sender<ExecSessionEvent>,
830) {
831 tokio::spawn(async move {
832 loop {
833 if session
834 .lifecycle
835 .status()
836 .is_ok_and(|status| status.is_terminal())
837 {
838 return;
839 }
840 let observed = match &session.process {
841 ManagedProcess::Pipe(process) => process
842 .child
843 .lock()
844 .await
845 .try_wait()
846 .map(|status| status.map(|status| exit_status_code(&status)))
847 .map_err(|error| error.to_string()),
848 ManagedProcess::Pty { process_id } => {
849 let pty = pty.clone();
850 let run_id = key.0.clone();
851 let process_id = process_id.clone();
852 match tokio::task::spawn_blocking(move || pty.status(&run_id, &process_id))
853 .await
854 {
855 Ok(result) => result.map_err(|error| error.to_string()),
856 Err(error) => Err(error.to_string()),
857 }
858 }
859 };
860 match observed {
861 Ok(Some(exit_code)) => {
862 let _ = transition_session(
863 &events,
864 &key,
865 &session,
866 ExecSessionStatus::Exited { exit_code },
867 );
868 if let ManagedProcess::Pipe(process) = &session.process {
869 process.stdin.lock().await.take();
870 }
871 return;
872 }
873 Ok(None) => {}
874 Err(message) => {
875 let _ = transition_session(
876 &events,
877 &key,
878 &session,
879 ExecSessionStatus::Failed { message },
880 );
881 match &session.process {
882 ManagedProcess::Pipe(process) => process.terminate().await,
883 ManagedProcess::Pty { process_id } => {
884 let pty = pty.clone();
885 let run_id = key.0.clone();
886 let process_id = process_id.clone();
887 let _ = tokio::task::spawn_blocking(move || {
888 pty.close(&run_id, &process_id)
889 })
890 .await;
891 }
892 }
893 return;
894 }
895 }
896 tokio::time::sleep(PROCESS_WATCH_INTERVAL).await;
897 }
898 });
899}
900
901fn exit_status_code(status: &ExitStatus) -> i32 {
902 status.code().unwrap_or(-1)
903}
904
905#[cfg(unix)]
906fn isolate_process_group(command: &mut Command, backend_starts_new_session: bool) {
907 if !backend_starts_new_session {
908 command.process_group(0);
909 }
910}
911
912#[cfg(not(unix))]
913fn isolate_process_group(_command: &mut Command, _backend_starts_new_session: bool) {}
914
915#[cfg(unix)]
916fn terminate_process_group(process_group_id: Option<u32>) {
917 if let Some(process_group_id) = process_group_id.filter(|id| *id <= i32::MAX as u32) {
918 unsafe {
920 libc::kill(-(process_group_id as i32), libc::SIGKILL);
921 }
922 }
923}
924
925#[cfg(not(unix))]
926fn terminate_process_group(_process_group_id: Option<u32>) {}