Skip to main content

team_core/
supervisor.rs

1//! Process supervision.
2//!
3//! The default back-end is a portable `TmuxSupervisor` that works on macOS
4//! and Linux. `SystemdSupervisor` and `LaunchdSupervisor` plug in behind
5//! the same trait when the host supports them.
6
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use anyhow::{Context, Result};
11
12use crate::compose::AgentHandle;
13
14#[derive(Debug, Clone)]
15pub struct AgentSpec {
16    pub project: String,
17    pub agent: String,
18    pub tmux_session: String,
19    pub wrapper: PathBuf,
20    pub cwd: PathBuf,
21    pub env_file: PathBuf,
22}
23
24impl AgentSpec {
25    pub fn from_handle(h: AgentHandle<'_>, root: &Path, tmux_prefix: &str) -> Self {
26        Self {
27            project: h.project.into(),
28            agent: h.agent.into(),
29            tmux_session: format!("{tmux_prefix}{}-{}", h.project, h.agent),
30            wrapper: root.join("bin/agent-wrapper.sh"),
31            cwd: root.to_path_buf(),
32            env_file: crate::render::env_path(root, h.project, h.agent),
33        }
34    }
35}
36
37/// Observed state of an agent's supervising process.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum AgentState {
40    Running,
41    Stopped,
42    Unknown,
43}
44
45pub trait Supervisor {
46    fn up(&self, spec: &AgentSpec) -> Result<()>;
47    fn down(&self, spec: &AgentSpec) -> Result<()>;
48    fn state(&self, spec: &AgentSpec) -> Result<AgentState>;
49}
50
51/// Portable supervisor: one detached `tmux` session per agent.
52pub struct TmuxSupervisor;
53
54impl Supervisor for TmuxSupervisor {
55    fn up(&self, spec: &AgentSpec) -> Result<()> {
56        if matches!(self.state(spec)?, AgentState::Running) {
57            return Ok(());
58        }
59        let cmd = format!(
60            "env $(cat {env}) {wrapper} {project}:{agent}",
61            env = shlex::try_quote(&spec.env_file.display().to_string())?,
62            wrapper = shlex::try_quote(&spec.wrapper.display().to_string())?,
63            project = spec.project,
64            agent = spec.agent,
65        );
66        let status = Command::new("tmux")
67            .args([
68                "new-session",
69                "-d",
70                "-s",
71                &spec.tmux_session,
72                "-c",
73                &spec.cwd.display().to_string(),
74                "sh",
75                "-c",
76                &cmd,
77            ])
78            .status()
79            .context("spawn tmux new-session")?;
80        anyhow::ensure!(status.success(), "tmux new-session exited {status}");
81        Ok(())
82    }
83
84    fn down(&self, spec: &AgentSpec) -> Result<()> {
85        let _ = Command::new("tmux")
86            .args(["kill-session", "-t", &spec.tmux_session])
87            .status();
88        Ok(())
89    }
90
91    fn state(&self, spec: &AgentSpec) -> Result<AgentState> {
92        let out = Command::new("tmux")
93            .args(["has-session", "-t", &spec.tmux_session])
94            .output();
95        Ok(match out {
96            Ok(o) if o.status.success() => AgentState::Running,
97            Ok(_) => AgentState::Stopped,
98            Err(_) => AgentState::Unknown,
99        })
100    }
101}
102
103mod shlex {
104    /// Minimal POSIX shell single-quote escaper so we don't pull a full dep.
105    pub fn try_quote(s: &str) -> anyhow::Result<String> {
106        anyhow::ensure!(!s.contains('\0'), "null byte in shell arg");
107        let escaped = s.replace('\'', r"'\''");
108        Ok(format!("'{escaped}'"))
109    }
110
111    #[cfg(test)]
112    mod tests {
113        use super::*;
114
115        #[test]
116        fn quotes_plain_path() {
117            assert_eq!(try_quote("/a/b.sh").unwrap(), "'/a/b.sh'");
118        }
119
120        #[test]
121        fn escapes_embedded_single_quote() {
122            assert_eq!(try_quote("x'y").unwrap(), r"'x'\''y'");
123        }
124    }
125}