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}