Skip to main content

agentd/supervisor/
spawn.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Spawning a subagent process.
3//!
4//! A subagent is the **same binary re-exec'd** with `AGENT_SUBAGENT` set, so
5//! the one artifact is CLI, supervisor, and subagent. Each child is put in its
6//! own **process group** (`setpgid` in `pre_exec`) so the kill ladder can
7//! `killpg` a whole subtree in one call, including grandchildren the subagent
8//! forked itself. The supervisor delivers the [`SpawnPayload`] as the first
9//! control frame; the child's upward [`AgentMsg`]s are read on a dedicated
10//! thread and forwarded — tagged with the child's [`NodeId`] — onto the
11//! reactor's single **merged channel**, which is what lets the reactor stay
12//! single-threaded no matter how many children are live.
13//!
14//! Teardown is **reap-safe**: once the reactor has reaped a child via
15//! `waitpid(-1)` it calls [`Subagent::mark_reaped`], so `Drop` will not signal
16//! a possibly-reused pid.
17
18use crate::json::frame;
19use crate::subagent::protocol::{AgentMsg, ControlMsg, SUBAGENT_ENV, SpawnPayload};
20use crate::supervisor::kill::kill_group;
21use crate::supervisor::tree::NodeId;
22use std::io;
23use std::path::Path;
24use std::process::{Child, ChildStdin, Command, Stdio};
25use std::thread::JoinHandle;
26use std::time::Duration;
27
28/// A handle to a running subagent process and the down side of its control
29/// channel. Upward messages arrive on the reactor's merged channel, not here.
30pub struct Subagent {
31    pub node: NodeId,
32    child: Child,
33    writer: ChildStdin,
34    /// Process-group id for `killpg` (== child pid; the child is its own group
35    /// leader after `setpgid(0, 0)`).
36    pgid: i32,
37    /// Set once the reactor has reaped this child — suppresses Drop signalling.
38    reaped: bool,
39    /// The stdout reader thread. Joinable: after the child exits its pipe EOFs
40    /// and the reader finishes promptly — the reactor joins it before acting on
41    /// a reap, so every frame the child wrote is in the event queue first.
42    reader: Option<JoinHandle<()>>,
43    /// The child's own cgroup leaf (`security.cgroup`), held for its lifetime —
44    /// its Drop writes `cgroup.kill` + removes the leaf (the atomic teardown
45    /// backstop). `None` when cgroups are not configured.
46    _cgroup: Option<crate::supervisor::cgroup::CgroupGuard>,
47}
48
49/// Where a child's upward frames land. The reader thread calls this for every
50/// frame, **directly into the queue the consumer drains** — no intermediate
51/// hop, because ordering with other producers (the reap path) is established
52/// by joining the reader, and a hop thread would break that happens-before.
53/// Return `false` when the consumer is gone (stops the reader).
54pub type FrameSink = std::sync::Arc<dyn Fn(NodeId, AgentMsg) -> bool + Send + Sync>;
55
56/// Spawn a subagent that re-execs `exe` (normally `std::env::current_exe()`),
57/// delivering `payload`. Upward messages are handed to `events` tagged with
58/// `node`.
59pub fn spawn(
60    exe: &Path,
61    payload: &SpawnPayload,
62    node: NodeId,
63    events: FrameSink,
64) -> io::Result<Subagent> {
65    let mut cmd = Command::new(exe);
66    cmd.env(SUBAGENT_ENV, "1")
67        .stdin(Stdio::piped())
68        .stdout(Stdio::piped())
69        // Child telemetry (JSON to its stderr) is inherited into ours; the
70        // control channel is stdout (binary frames).
71        .stderr(Stdio::inherit());
72
73    #[cfg(unix)]
74    {
75        use std::os::unix::process::CommandExt;
76        // Copy the OS caps out of the payload: the pre_exec closure runs
77        // between fork and exec and may only touch plain values.
78        let mem = payload.limits.memory_bytes;
79        let cpu = payload.limits.cpu_seconds;
80        let nice = payload.limits.nice;
81        // SAFETY: only async-signal-safe calls between fork and exec
82        // (setpgid/setrlimit/setpriority all are).
83        unsafe {
84            cmd.pre_exec(move || {
85                // Own process group → the kill ladder can target the subtree.
86                libc::setpgid(0, 0);
87                if let Some(bytes) = mem {
88                    let lim = libc::rlimit {
89                        rlim_cur: bytes as libc::rlim_t,
90                        rlim_max: bytes as libc::rlim_t,
91                    };
92                    if libc::setrlimit(libc::RLIMIT_AS, &lim) != 0 {
93                        return Err(std::io::Error::last_os_error());
94                    }
95                }
96                if let Some(secs) = cpu {
97                    // Soft cap = the declared budget (SIGXCPU); hard cap 5 s
98                    // later (SIGKILL) so a child ignoring SIGXCPU still dies.
99                    let lim = libc::rlimit {
100                        rlim_cur: secs as libc::rlim_t,
101                        rlim_max: secs.saturating_add(5) as libc::rlim_t,
102                    };
103                    if libc::setrlimit(libc::RLIMIT_CPU, &lim) != 0 {
104                        return Err(std::io::Error::last_os_error());
105                    }
106                }
107                if let Some(n) = nice {
108                    // Lowering priority always works; raising needs
109                    // CAP_SYS_NICE — best-effort by design, never an error.
110                    let _ = libc::setpriority(libc::PRIO_PROCESS, 0, n);
111                }
112                Ok(())
113            });
114        }
115    }
116
117    // Spawn, retrying a transient `EAGAIN` — the kernel refusing a `fork` under
118    // process/memory pressure (a wide fan-out starting many subagents at once, or
119    // a CPU-starved host). Bounded (~1s total, short backoff); a genuine error
120    // (ENOENT, EMFILE-persisted, …) still surfaces. Real robustness, not just a
121    // test artifact: a busy agent tree hits the same refusal.
122    let mut child = {
123        let mut attempt = 0u32;
124        loop {
125            match cmd.spawn() {
126                Ok(c) => break c,
127                Err(e)
128                    if attempt < 10
129                        && (e.raw_os_error() == Some(libc::EAGAIN)
130                            || e.kind() == io::ErrorKind::WouldBlock) =>
131                {
132                    attempt += 1;
133                    std::thread::sleep(Duration::from_millis(u64::from(20 * attempt)));
134                }
135                Err(e) => return Err(e),
136            }
137        }
138    };
139    let pgid = child.id() as i32;
140    // Place the child in its own cgroup leaf (best-effort; `None` unless
141    // `security.cgroup` armed the parent). The guard is held on the Subagent so
142    // teardown (`cgroup.kill` + rmdir) fires when the child is reaped.
143    let cgroup = crate::supervisor::cgroup::CgroupGuard::for_run().inspect(|g| {
144        g.place(pgid);
145    });
146    let mut writer = child
147        .stdin
148        .take()
149        .ok_or_else(|| io::Error::other("no child stdin"))?;
150    let stdout = child
151        .stdout
152        .take()
153        .ok_or_else(|| io::Error::other("no child stdout"))?;
154
155    // Deliver the spawn payload as the first control frame.
156    frame::write_frame(&mut writer, &ControlMsg::Spawn(Box::new(payload.clone())))?;
157
158    let reader = std::thread::Builder::new()
159        .name(format!("subagent-events:{}", node.0))
160        .spawn(move || {
161            let mut r = io::BufReader::new(stdout);
162            // Exits on Ok(None) (clean EOF) or Err (child closed stdout/exited).
163            while let Ok(Some(bytes)) = frame::read_frame(&mut r) {
164                match serde_json::from_slice::<AgentMsg>(&bytes) {
165                    Ok(msg) => {
166                        if !events(node, msg) {
167                            break; // reactor dropped the channel
168                        }
169                    }
170                    Err(_) => { /* skip an unparseable frame */ }
171                }
172            }
173        })?;
174
175    Ok(Subagent {
176        node,
177        child,
178        writer,
179        pgid,
180        reaped: false,
181        reader: Some(reader),
182        _cgroup: cgroup,
183    })
184}
185
186impl Subagent {
187    pub fn pid(&self) -> i32 {
188        self.child.id() as i32
189    }
190    /// Wait for the stdout reader to finish (bounded: the pipe has EOF'd once
191    /// the child is reapable). After this, every frame the child ever wrote has
192    /// been forwarded.
193    pub fn join_reader(&mut self) {
194        if let Some(h) = self.reader.take() {
195            let _ = h.join();
196        }
197    }
198    pub fn pgid(&self) -> i32 {
199        self.pgid
200    }
201
202    /// Send a control message down (Ping / Cancel / Inject).
203    pub fn send(&mut self, msg: &ControlMsg) -> io::Result<()> {
204        frame::write_frame(&mut self.writer, msg)
205    }
206
207    /// Mark that the reactor already reaped this child (via `waitpid(-1)`), so
208    /// teardown won't signal a possibly-reused pid.
209    pub fn mark_reaped(&mut self) {
210        self.reaped = true;
211    }
212
213    /// Immediate, unconditional teardown of the whole process group. The
214    /// graceful ladder (cancel → SIGTERM → SIGKILL over time) is driven by the
215    /// reactor via `kill.rs`; this is the backstop.
216    pub fn kill(&mut self) {
217        if !self.reaped {
218            crate::supervisor::reaper::deregister(self.pid());
219            kill_group(self.pgid);
220            let _ = self.child.kill();
221            let _ = self.child.wait();
222            self.reaped = true;
223        }
224    }
225}
226
227impl Drop for Subagent {
228    fn drop(&mut self) {
229        if !self.reaped {
230            // Drop a never-dispatched route (an abandoned run), then tear down +
231            // reap the child ourselves. `child.wait()` tolerates ECHILD if the
232            // global reaper collected it first; deregistering first means it sees
233            // this pid as foreign rather than routing a stale exit.
234            crate::supervisor::reaper::deregister(self.pid());
235            kill_group(self.pgid);
236            let _ = self.child.kill();
237            let _ = self.child.wait();
238        }
239    }
240}