devflow_core/agent.rs
1//! Agent process helpers.
2//!
3//! All agents run in non-interactive mode (`claude -p`, `codex exec`) under a
4//! detached monitor that owns the process and its capture files (see
5//! [`crate::monitor`]). The old synchronous launch/capture path
6//! (`launch_agent` + `capture_agent_output`) was removed in 14b — the monitor
7//! is now the single way an agent process is spawned.
8
9/// Check whether a process with the given PID is still running.
10///
11/// The PID typically comes from parsing an on-disk file, so hostile or
12/// corrupted values must be rejected, not reinterpreted: `kill(0, sig)`
13/// signals the caller's own process group (a "0" PID file would read as
14/// permanently alive), and a value above `i32::MAX` would wrap negative
15/// through an `as libc::pid_t` cast — `kill(-1, 0)` probes every process
16/// the caller may signal and virtually always succeeds.
17///
18/// **Zombies are NOT running.** `kill(pid, 0)` succeeds for a process that
19/// has exited but not yet been reaped: the pid stays allocated until its
20/// parent calls `wait`, so the bare POSIX check reports a dead agent as
21/// alive. That is not academic here — when a monitor dies before its agent,
22/// the agent reparents to PID 1, and inside a container PID 1 is whatever
23/// the image runs (cargo, a shell, the test harness), none of which reap
24/// orphans the way an init system does. The zombie then persists for the
25/// life of the container and every liveness check keeps answering "yes".
26///
27/// That is exactly the "monitor over-durability" class Phase 23 exists to
28/// close: an operator, `gate sweep`, or `stop` asking "is this phase still
29/// running?" would be told yes forever about a process that is already dead.
30/// Observed directly in CI (`sigterm_to_monitor_also_kills_the_agent`), where
31/// both monitor and agent were `State=Z` and the agent had reparented to
32/// PPid=1 while the bare check still reported them alive.
33///
34/// Reading `/proc` is Linux-only; where it cannot be read, this falls back to
35/// the `kill(0)` answer rather than inventing one.
36pub fn agent_running(pid: u32) -> bool {
37 // kill(pid, 0) is the standard POSIX way to check process existence
38 // without sending an actual signal.
39 let Ok(signed) = libc::pid_t::try_from(pid) else {
40 return false;
41 };
42 if signed <= 0 || unsafe { libc::kill(signed, 0) } != 0 {
43 return false;
44 }
45 !is_zombie(pid)
46}
47
48/// Whether `pid` has exited but not yet been reaped — `State: Z` in
49/// `/proc/<pid>/status`.
50///
51/// Returns `false` when the status file cannot be read: an unreadable
52/// `/proc` entry means "cannot tell", and the caller has already established
53/// via `kill(0)` that the pid exists, so claiming zombie-hood here would
54/// invent information.
55fn is_zombie(pid: u32) -> bool {
56 let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else {
57 return false;
58 };
59 status
60 .lines()
61 .find(|line| line.starts_with("State:"))
62 .and_then(|line| line.split_whitespace().nth(1))
63 .is_some_and(|state| state == "Z")
64}
65
66/// Send SIGTERM to `pid` — the crate's one process-termination call, used by
67/// `devflow stop`'s signalling fallback (23c). Applies exactly the same
68/// guards [`agent_running`] applies, for reasons that are catastrophic here
69/// rather than merely wrong: signalling pid `0` would target the caller's
70/// own process group (`kill(0, sig)` reaches every process in the group,
71/// including this one), and a value above `i32::MAX` would wrap negative
72/// through the `as libc::pid_t` cast — `kill(-1, sig)` sends the signal to
73/// every process the caller may signal. Returns whether the signal was
74/// delivered.
75pub fn terminate(pid: u32) -> bool {
76 let Ok(pid) = libc::pid_t::try_from(pid) else {
77 return false;
78 };
79 pid > 0 && unsafe { libc::kill(pid, libc::SIGTERM) == 0 }
80}
81
82/// A process's start time — field 22 of `/proc/<pid>/stat`, in clock ticks
83/// since boot.
84///
85/// This is the missing half of process identity. A PID alone is ambiguous:
86/// the kernel reuses it after the process exits, so a stale record naming
87/// pid 1234 may now refer to something entirely unrelated. `(pid, starttime)`
88/// is unique for the life of a boot, because a recycled pid necessarily
89/// starts later than the one it replaced.
90///
91/// Record this alongside a pid whenever the pid will be acted on later —
92/// signalled, killed, reported as a holder — and require BOTH to match
93/// before acting. That is the only check immune to the two ways `/proc`
94/// lies about identity:
95///
96/// * **PID reuse.** cmdline/exe describe whoever holds the pid *now*.
97/// * **The fork/exec window (999.47).** Between `Command::spawn()` returning
98/// a pid and the child completing `execve`, the child is a copy of its
99/// parent: `/proc/<pid>/cmdline` reports the PARENT's argv and
100/// `/proc/<pid>/exe` the parent's binary. A devflow process's freshly
101/// spawned child therefore looks exactly like devflow itself. Confirmed
102/// directly in CI, where container overlayfs widens that window enough to
103/// hit routinely.
104///
105/// `comm` is inherited across `fork` too, so it is no better. There is no
106/// field that distinguishes a mid-`execve` child from its parent — they are
107/// genuinely the same image at that instant. Identity must be *recorded*,
108/// never inferred.
109///
110/// **Granularity caveat, measured not assumed.** The value is in clock ticks
111/// since boot — `USER_HZ`, conventionally 100, so 10ms. Two processes created
112/// within the same tick report the *same* start time; this was observed
113/// directly while testing, where a test binary and a child it spawned
114/// microseconds later were indistinguishable by this field alone.
115///
116/// That does not weaken the pid-recycling guarantee this exists for: for a
117/// pid to be recycled the kernel must exhaust and wrap the pid space, which
118/// takes vastly longer than 10ms. It does mean this must not be used to
119/// distinguish a parent from a child it just spawned — for that, compare
120/// pids, which differ by construction.
121///
122/// Returns `None` when the stat file cannot be read or parsed — the
123/// fail-closed direction, meaning "identity could not be confirmed."
124pub fn process_start_time(pid: u32) -> Option<u64> {
125 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
126 // Field 2 (comm) is parenthesised and may itself contain spaces or
127 // parentheses, so split after the FINAL ')' rather than tokenising the
128 // whole line. After that point, field 3 is index 0, so field 22 is 19.
129 let rest = &stat[stat.rfind(')')? + 1..];
130 rest.split_whitespace().nth(19)?.parse::<u64>().ok()
131}
132
133/// Whether `pid` is the same process instance that recorded `expected_start`.
134///
135/// The identity check `devflow stop` and friends should use. See
136/// [`process_start_time`] for why a pid alone — or any `/proc`-derived
137/// description of it — cannot answer this.
138pub fn is_same_process(pid: u32, expected_start: u64) -> bool {
139 process_start_time(pid) == Some(expected_start)
140}
141
142/// Best-effort, Linux-only identity check for `devflow stop`'s signalling
143/// fallback (T-23-52, PID reuse in a stale lock file): does
144/// `/proc/<pid>/cmdline` name a devflow process? Reads the NUL-separated
145/// argv and reports whether any argument's file-name component starts with
146/// `devflow`. Returns `false` when the file cannot be read (process exited
147/// between the liveness check and this call, non-Linux, permission denied)
148/// — the fail-closed direction. A `false` return means "identity could not
149/// be confirmed," and callers must treat that as "do not signal," never as
150/// "signal anyway."
151///
152/// **UNSOUND ON ITS OWN — see 999.47.** This returns `true` for any freshly
153/// `fork`ed child of a devflow process that has not yet completed `execve`,
154/// because such a child transiently carries its parent's cmdline. It is
155/// retained only as a secondary, advisory signal; prefer
156/// [`is_same_process`] with a recorded start time, which cannot be fooled
157/// this way. Never let this function alone authorise a signal.
158pub fn looks_like_devflow_process(pid: u32) -> bool {
159 let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
160 return false;
161 };
162 cmdline
163 .split(|&byte| byte == 0)
164 .filter(|arg| !arg.is_empty())
165 .any(|arg| {
166 let arg = String::from_utf8_lossy(arg);
167 std::path::Path::new(arg.as_ref())
168 .file_name()
169 .and_then(|name| name.to_str())
170 .is_some_and(|name| name.starts_with("devflow"))
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn agent_running_detects_self() {
180 // The current process is, by definition, running.
181 assert!(agent_running(std::process::id()));
182 }
183
184 /// A reaped-pending child is dead, not running. `kill(pid, 0)` succeeds
185 /// on a zombie because the pid is still allocated, so the bare POSIX
186 /// check reports it alive — which is how a container with no reaping
187 /// init can make a dead agent look permanently live.
188 #[test]
189 fn agent_running_is_false_for_an_unreaped_zombie() {
190 let mut child = std::process::Command::new("true")
191 .spawn()
192 .expect("spawn true");
193 let pid = child.id();
194
195 // Wait for it to become a zombie WITHOUT reaping it: poll /proc for
196 // State: Z rather than calling wait(), which would clear the pid.
197 let mut became_zombie = false;
198 for _ in 0..200 {
199 if super::is_zombie(pid) {
200 became_zombie = true;
201 break;
202 }
203 std::thread::sleep(std::time::Duration::from_millis(10));
204 }
205 assert!(became_zombie, "child never became an unreaped zombie");
206
207 // The bare POSIX check still says "alive" — that is the trap.
208 assert_eq!(
209 unsafe { libc::kill(pid as libc::pid_t, 0) },
210 0,
211 "kill(pid, 0) is expected to still succeed on a zombie — if this \
212 fails the test is no longer exercising the case it was written for"
213 );
214 assert!(
215 !agent_running(pid),
216 "a zombie has exited and must not be reported as running"
217 );
218
219 let _ = child.wait();
220 }
221
222 #[test]
223 fn agent_running_false_for_dead_pid() {
224 // A PID near the top of the range is essentially never live.
225 assert!(!agent_running(0x7FFF_FFFE));
226 }
227
228 #[test]
229 fn agent_running_rejects_corrupt_pid_values() {
230 // "0" from a truncated PID file: kill(0, 0) would signal our own
231 // process group and report alive.
232 assert!(!agent_running(0));
233 // Above i32::MAX: `as libc::pid_t` would wrap to -1, and
234 // kill(-1, 0) probes every signalable process — almost always "alive".
235 assert!(!agent_running(u32::MAX));
236 assert!(!agent_running(i32::MAX as u32 + 1));
237 }
238
239 #[test]
240 fn terminate_rejects_pid_zero() {
241 // Would target the caller's own process group — never send it.
242 assert!(!terminate(0));
243 }
244
245 #[test]
246 fn terminate_rejects_pid_above_i32_max() {
247 // Would wrap to -1 through the pid_t cast — kill(-1, SIGTERM) hits
248 // every process the caller may signal.
249 assert!(!terminate(u32::MAX));
250 assert!(!terminate(i32::MAX as u32 + 1));
251 }
252
253 #[test]
254 fn terminate_signals_a_live_child_and_it_exits() {
255 let mut child = std::process::Command::new("sleep")
256 .arg("30")
257 .spawn()
258 .expect("spawn sleep");
259 let pid = child.id();
260
261 assert!(terminate(pid), "terminate must report the signal delivered");
262
263 let status = child.wait().expect("wait on the terminated child");
264 assert!(
265 !status.success(),
266 "a SIGTERM'd child must not report a successful exit, got {status:?}"
267 );
268 }
269
270 #[test]
271 fn looks_like_devflow_process_is_true_for_the_current_process() {
272 // Cargo names this crate's test binary from its crate name
273 // (`devflow-core` → `devflow_core-<hash>` under target/deps
274 // naming) — a reliable positive fixture with no need to spawn a
275 // real devflow binary.
276 assert!(looks_like_devflow_process(std::process::id()));
277 }
278
279 /// Render `/proc/<pid>/cmdline` readably for failure diagnostics: the
280 /// NUL-separated argv joined with ` | `, or a marker when it cannot be
281 /// read. Test-only; never used in a decision, only in a message.
282 fn debug_cmdline(pid: u32) -> String {
283 match std::fs::read(format!("/proc/{pid}/cmdline")) {
284 Ok(raw) if raw.iter().all(|&byte| byte == 0) => "<empty>".to_string(),
285 Ok(raw) => raw
286 .split(|&byte| byte == 0)
287 .filter(|arg| !arg.is_empty())
288 .map(|arg| String::from_utf8_lossy(arg).into_owned())
289 .collect::<Vec<_>>()
290 .join(" | "),
291 Err(err) => format!("<unreadable: {err}>"),
292 }
293 }
294
295 #[test]
296 fn looks_like_devflow_process_is_false_for_a_non_devflow_process() {
297 let mut child = std::process::Command::new("sleep")
298 .arg("5")
299 .spawn()
300 .expect("spawn sleep");
301 let pid = child.id();
302
303 // This assertion has failed intermittently in CI (first seen
304 // 2026-07-26, on commits touching no Rust source) as a FALSE
305 // POSITIVE: the predicate reported a plain `sleep` as a devflow
306 // process. It does not reproduce locally — 40/40 under CPU load —
307 // and a fork/exec cmdline-inheritance theory was disproved at
308 // 0/3000. A bare `assert!` throws away the one artifact that could
309 // name the mechanism, so bracket the predicate with reads of the
310 // same /proc file and report everything on failure.
311 //
312 // Reading the verdict here rather than inside `assert!` keeps the
313 // diagnostics adjacent to the call they describe, and lets the
314 // child be reaped before any panic unwinds (a panic inside the
315 // assert would otherwise leak the `sleep` for its full duration —
316 // this repo already has an orphan-hygiene problem, see 999.44/46).
317 let cmdline_before = debug_cmdline(pid);
318 let verdict = looks_like_devflow_process(pid);
319 let cmdline_after = debug_cmdline(pid);
320 let exe = std::fs::read_link(format!("/proc/{pid}/exe"))
321 .map(|path| path.display().to_string())
322 .unwrap_or_else(|err| format!("<unreadable: {err}>"));
323 let self_pid = std::process::id();
324 let self_cmdline = debug_cmdline(self_pid);
325
326 let _ = child.kill();
327 let _ = child.wait();
328
329 assert!(
330 !verdict,
331 "looks_like_devflow_process({pid}) returned true for a spawned `sleep`.\n\
332 \x20 child cmdline before: {cmdline_before}\n\
333 \x20 child cmdline after: {cmdline_after}\n\
334 \x20 child /proc/{pid}/exe: {exe}\n\
335 \x20 test process: pid {self_pid} cmdline {self_cmdline}\n\
336 If both child cmdlines name a devflow binary, the pid is not the \
337 `sleep` we spawned (recycled/misattributed pid). If they differ from \
338 each other, the cmdline changed under the predicate. If they name \
339 `sleep`, the predicate's matching logic is at fault."
340 );
341 }
342
343 #[test]
344 fn looks_like_devflow_process_is_false_when_proc_cannot_be_read() {
345 // A pid guaranteed not to exist: the fail-closed default must be
346 // false, never true, when identity cannot be confirmed at all.
347 assert!(!looks_like_devflow_process(0x7FFF_FFFE));
348 }
349}