Skip to main content

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}