Skip to main content

orchestral_runtime/
exec_process.rs

1//! Run-scoped process sessions behind the model-visible unified exec Tools.
2//!
3//! Pipe and PTY are execution details. Both are addressed by one integer
4//! session ID and remain strictly scoped to the owning Agent Run.
5
6mod 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    /// Exact Host-derived authority under which subsequent input executes.
57    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/// Determines whether ordinary process output ends a wait early.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum ExecWaitMode {
73    /// Return after a short pause in output, suitable for interactive prompts.
74    Output,
75    /// Aggregate output until exit, the wait deadline, or a Host yield request.
76    Completion,
77}
78
79impl ExecWaitMode {
80    /// Preserve prompt responsiveness for TTY sessions and delivered input.
81    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/// One observation window. Ending this window never terminates the process.
91#[derive(Debug, Clone)]
92pub struct ExecWaitOptions {
93    pub duration: Duration,
94    pub mode: ExecWaitMode,
95    /// Host request to return the current observation without cancelling work.
96    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    // Keep scratch files until this process and its exit watcher release them.
130    _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
384/// Run-scoped owner and observer for pipe and PTY execution resources.
385pub 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    /// Use a stable, private Host directory so execution policy identities can
401    /// survive Host restart. The root must be outside all workspace roots.
402    /// Its parent must already exist; Run children are exclusively created.
403    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    /// Host policy and exec Tool restrictions must explicitly grant read/write
434    /// access to this root. Each dispatched sandbox receives only its Run child.
435    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    /// Use output waits for TTY/input interactions and completion waits for
529    /// empty pipe observations. The duration bounds this observation only.
530    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    /// Observe one existing session with an explicit wait strategy. Yielding
554    /// preserves both the process and its Run-scoped authority.
555    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                // A process can exit after exec_command reports it as alive but
574                // before the model's follow-up write arrives. Preserve the
575                // terminal observation instead of turning that normal race into
576                // a Tool failure.
577                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        // SAFETY: the child is the leader of a fresh process group/session.
919        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>) {}