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/// Default bounded wait for [`terminate_and_verify`]'s escalation to
83/// `SIGKILL`. A few seconds is long enough for a well-behaved process to
84/// shut down after `SIGTERM`, short enough that an unattended loop is not
85/// stalled indefinitely waiting on one that won't.
86pub const TERMINATE_VERIFY_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
87
88/// Default poll interval while [`terminate_and_verify`] waits for its target
89/// to exit. Callers that need a different ceiling or granularity should pass
90/// their own `wait`/`poll` rather than inventing new constants.
91pub const TERMINATE_VERIFY_POLL: std::time::Duration = std::time::Duration::from_millis(50);
92
93/// Terminate `pid`, escalating to `SIGKILL` if it has not exited within
94/// `wait`, and return a **verified fact** about whether it is dead —
95/// never an assumption.
96///
97/// Sequence: send one `SIGTERM` via [`terminate`]. If that fails to signal
98/// the process at all (already gone, or the pid is invalid), report whether
99/// it is already dead — "could not signal it" and "already dead" are the
100/// same outcome from the caller's perspective. Otherwise poll
101/// [`agent_running`] at `poll` intervals until `wait` elapses, returning
102/// `true` the moment it reports dead. On expiry, escalate with `SIGKILL` and
103/// return the (inverted) liveness check one final time.
104///
105/// **`SIGKILL` escalation is not optional here.** 999.44's 2026-07-27
106/// measurement found 15 of 15 orphaned monitor wrappers surviving `SIGTERM`
107/// — the wrapper installs `trap cleanup TERM INT`, which evidently does not
108/// fire, most likely because the shell is blocked in `wait` on a child it
109/// can never reap. Per 25-RESEARCH.md Open Question 2 and 999.47's own
110/// recorded lesson, this function deliberately does **not** depend on
111/// explaining that mechanism — the escalation works regardless of *why*
112/// `SIGTERM` alone fails. That is accepted unexplained behaviour this code
113/// defends against, not a root cause this function resolves.
114///
115/// A non-positive `pid`, or one that does not fit `libc::pid_t`, returns
116/// `false` immediately and signals nothing — the same wraparound/group-
117/// signal hazard [`agent_running`] and [`terminate`] already guard against.
118pub fn terminate_and_verify(
119 pid: u32,
120 wait: std::time::Duration,
121 poll: std::time::Duration,
122) -> bool {
123 let Ok(signed) = libc::pid_t::try_from(pid) else {
124 return false;
125 };
126 if signed <= 0 {
127 return false;
128 }
129
130 if !terminate(pid) {
131 // Could not be signalled at all — already dead counts as success.
132 return !agent_running(pid);
133 }
134
135 let term_deadline = std::time::Instant::now() + wait;
136 while std::time::Instant::now() < term_deadline {
137 if !agent_running(pid) {
138 return true;
139 }
140 std::thread::sleep(poll);
141 }
142
143 // TERM alone did not clear it within the bounded wait — escalate.
144 unsafe {
145 libc::kill(signed, libc::SIGKILL);
146 }
147 // SIGKILL is uncatchable but not synchronous: the kernel needs a moment
148 // to actually deliver it, so poll again rather than checking exactly
149 // once — a single immediate check can race the kernel and report a
150 // just-killed process as still alive.
151 let kill_deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
152 while std::time::Instant::now() < kill_deadline {
153 if !agent_running(pid) {
154 return true;
155 }
156 std::thread::sleep(poll);
157 }
158 !agent_running(pid)
159}
160
161/// A process's start time — field 22 of `/proc/<pid>/stat`, in clock ticks
162/// since boot.
163///
164/// This is the missing half of process identity. A PID alone is ambiguous:
165/// the kernel reuses it after the process exits, so a stale record naming
166/// pid 1234 may now refer to something entirely unrelated. `(pid, starttime)`
167/// is unique for the life of a boot, because a recycled pid necessarily
168/// starts later than the one it replaced.
169///
170/// Record this alongside a pid whenever the pid will be acted on later —
171/// signalled, killed, reported as a holder — and require BOTH to match
172/// before acting. That is the only check immune to the two ways `/proc`
173/// lies about identity:
174///
175/// * **PID reuse.** cmdline/exe describe whoever holds the pid *now*.
176/// * **The fork/exec window (999.47).** Between `Command::spawn()` returning
177/// a pid and the child completing `execve`, the child is a copy of its
178/// parent: `/proc/<pid>/cmdline` reports the PARENT's argv and
179/// `/proc/<pid>/exe` the parent's binary. A devflow process's freshly
180/// spawned child therefore looks exactly like devflow itself. Confirmed
181/// directly in CI, where container overlayfs widens that window enough to
182/// hit routinely.
183///
184/// `comm` is inherited across `fork` too, so it is no better. There is no
185/// field that distinguishes a mid-`execve` child from its parent — they are
186/// genuinely the same image at that instant. Identity must be *recorded*,
187/// never inferred.
188///
189/// **Granularity caveat, measured not assumed.** The value is in clock ticks
190/// since boot — `USER_HZ`, conventionally 100, so 10ms. Two processes created
191/// within the same tick report the *same* start time; this was observed
192/// directly while testing, where a test binary and a child it spawned
193/// microseconds later were indistinguishable by this field alone.
194///
195/// That does not weaken the pid-recycling guarantee this exists for: for a
196/// pid to be recycled the kernel must exhaust and wrap the pid space, which
197/// takes vastly longer than 10ms. It does mean this must not be used to
198/// distinguish a parent from a child it just spawned — for that, compare
199/// pids, which differ by construction.
200///
201/// Returns `None` when the stat file cannot be read or parsed — the
202/// fail-closed direction, meaning "identity could not be confirmed."
203pub fn process_start_time(pid: u32) -> Option<u64> {
204 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
205 // Field 2 (comm) is parenthesised and may itself contain spaces or
206 // parentheses, so split after the FINAL ')' rather than tokenising the
207 // whole line. After that point, field 3 is index 0, so field 22 is 19.
208 let rest = &stat[stat.rfind(')')? + 1..];
209 rest.split_whitespace().nth(19)?.parse::<u64>().ok()
210}
211
212/// Whether `pid` is the same process instance that recorded `expected_start`.
213///
214/// The identity check `devflow stop` and friends should use. See
215/// [`process_start_time`] for why a pid alone — or any `/proc`-derived
216/// description of it — cannot answer this.
217pub fn is_same_process(pid: u32, expected_start: u64) -> bool {
218 process_start_time(pid) == Some(expected_start)
219}
220
221/// Resolve the kernel's clock tick rate (`USER_HZ`) via
222/// `sysconf(_SC_CLK_TCK)`, rather than assuming the "conventionally 100"
223/// value [`process_start_time`]'s own doc comment names as a convention,
224/// not a guarantee — a wrong divisor would silently scale
225/// [`process_age`]'s result. Returns `None` when the kernel reports a
226/// non-positive value, so the caller can fail closed instead of dividing
227/// by (or trusting) a nonsensical rate.
228fn clock_ticks_per_second() -> Option<i64> {
229 let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
230 (ticks > 0).then_some(ticks)
231}
232
233/// How long `pid` has been running: `/proc/uptime` (seconds since boot)
234/// minus [`process_start_time`] (ticks since boot, converted to seconds
235/// via the kernel's own reported tick rate — never a hardcoded divisor).
236///
237/// This is the primitive `reap_stray_candidates`' age floor (25-12/999.47,
238/// the production half of the defect class) is built on: a process
239/// [`discover_stray_devflow_processes`] catches mid-`execve` is genuinely
240/// the same process with genuinely the same recorded start time as its
241/// parent — [`is_same_process`] cannot distinguish the two — but its age
242/// is sub-millisecond, while a genuine orphan is minutes to hours old.
243/// `reap_stray_candidates` (`devflow-cli::commands`) is the one caller
244/// that consumes this to make a signalling decision; see its own doc
245/// comment for how the separation is used.
246///
247/// Returns `None` when age could not be determined at all: `/proc/uptime`
248/// is unreadable or unparseable, the tick rate cannot be resolved, or
249/// [`process_start_time`] itself returns `None`. Callers MUST treat `None`
250/// as "do not act" — never as "old enough" — matching the fail-closed
251/// posture [`process_start_time`] documents for identity.
252///
253/// A negative difference — the two clocks read microseconds apart — is
254/// clamped to zero rather than treated as an error: it is a rounding
255/// artefact, not evidence of anything, and zero keeps the fail-closed
256/// direction (an age of zero sits below any floor).
257pub fn process_age(pid: u32) -> Option<std::time::Duration> {
258 let uptime_raw = std::fs::read_to_string("/proc/uptime").ok()?;
259 let uptime_secs: f64 = uptime_raw.split_whitespace().next()?.parse().ok()?;
260
261 let ticks_per_sec = clock_ticks_per_second()?;
262 let start_ticks = process_start_time(pid)?;
263 let start_secs = start_ticks as f64 / ticks_per_sec as f64;
264
265 let age_secs = (uptime_secs - start_secs).max(0.0);
266 Some(std::time::Duration::from_secs_f64(age_secs))
267}
268
269/// The age floor `reap_stray_candidates` (`devflow-cli::commands`)
270/// refuses to signal a candidate below. **An age floor, not a
271/// classifier**: it refuses every candidate younger than this in BOTH
272/// directions — a mid-`execve` false positive and a genuine stray younger
273/// than the floor are both refused, because [`process_age`] cannot tell
274/// the two apart and does not try to.
275///
276/// The two populations this separates are six orders of magnitude apart:
277/// a `fork()`->`execve()` window, sub-millisecond even under the 2-core
278/// pinned CI load measured in `25-CI-OBSERVATION.md`, versus a genuine
279/// orphan of a *previous* run — a monitor wrapper lives for the duration
280/// of an agent stage, minutes to hours. No value between those two
281/// populations is contentious.
282///
283/// A candidate refused for youth is not lost: `gate_sweep`
284/// (`devflow-cli::commands`) re-runs discovery after its reaping pass and
285/// reports anything still discoverable, so a false refusal is deferred
286/// cleanup — cleared on the next invocation — never a missed one.
287pub const STRAY_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(2);
288
289/// Which structural layer of a DevFlow-spawned process tree
290/// [`discover_stray_devflow_processes`] matched a candidate against.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum StrayLayer {
293 /// The monitor wrapper shell spawned by `monitor::spawn_monitor` — the
294 /// `sh -c <script>` process that owns the agent and, on exit, runs
295 /// `devflow advance`.
296 MonitorWrapper,
297 /// The trailing `devflow advance` invocation the wrapper's script runs
298 /// as its last command once the agent exits.
299 AdvanceChild,
300}
301
302/// A process discovered by [`discover_stray_devflow_processes`]: its pid,
303/// the start time recorded at discovery time, and which layer matched it.
304///
305/// The recorded `start_time` is what lets a later caller re-confirm this is
306/// still the same process — via [`is_same_process`] — immediately before
307/// acting on it, closing the check-then-act window between discovery and
308/// signalling (999.47's "Related TOCTOU").
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct StrayProcess {
311 /// The discovered process's pid.
312 pub pid: u32,
313 /// The pid's recorded start time (`/proc/<pid>/stat` field 22), captured
314 /// at discovery time for later identity re-confirmation.
315 pub start_time: u64,
316 /// Which structural matcher identified this process.
317 pub layer: StrayLayer,
318}
319
320/// The monitor wrapper's trap-installation line, copied byte-for-byte from
321/// `monitor::spawn_monitor_inner`'s literal script text (see
322/// `crates/devflow-core/src/monitor.rs`) — not paraphrased or reduced to a
323/// single word, so a reader can grep both files for this exact string to
324/// confirm they still agree. If the wrapper script's text ever changes, this
325/// constant must change with it in the same commit.
326const MONITOR_WRAPPER_MARKER: &str = "trap cleanup TERM INT";
327
328/// The devflow CLI binary's name — `crates/devflow-cli/Cargo.toml`'s
329/// `[package].name`, with no `[[bin]]` override, so cargo names the built
330/// binary after the package. Matched against argv[0]'s basename for Layer 2.
331const DEVFLOW_BINARY_NAME: &str = "devflow";
332
333/// The advance subcommand's literal name (`Command::Advance` in
334/// `devflow-cli/src/main.rs`), matched against argv[1] for Layer 2.
335const ADVANCE_SUBCOMMAND: &str = "advance";
336
337/// Census both of DevFlow's orphan-prone process layers directly from the OS
338/// process table — the only remaining discovery surface once a project root
339/// has been deleted off disk, taking every registry entry, lock file and
340/// state file with it (999.44).
341///
342/// This is a pure, read-only survey: it never signals a process. Deciding
343/// whether to act on a result, and re-confirming identity immediately
344/// beforehand, is the caller's job.
345///
346/// Two structural matchers, deliberately narrower than the predicate
347/// 999.47 disproved (which matched ANY argv element whose basename began
348/// with the binary name, so `sleep /tmp/devflow-scratch/x` was a false
349/// positive):
350///
351/// * **Layer 1 — the monitor wrapper.** `argv[0]` is `sh`, `argv[1]` is
352/// `-c`, and `argv[2]` (the script) contains [`MONITOR_WRAPPER_MARKER`]
353/// verbatim.
354/// * **Layer 2 — the trailing advance child.** `argv[0]`'s basename equals
355/// [`DEVFLOW_BINARY_NAME`] AND `argv[1]` equals [`ADVANCE_SUBCOMMAND`].
356///
357/// Neither matcher scans all argv elements or matches a prefix; both check
358/// specific, named positions only.
359///
360/// Two hard constraints on the census, both load-bearing:
361///
362/// 1. **No parentage filter.** These orphans reparent to the user's
363/// per-user service manager, not to the init process — a parent-identity
364/// filter was directly measured against this repository (23-FINDINGS.md)
365/// to report zero orphans while 14 genuinely existed. This function does
366/// not consult parentage at all.
367/// 2. **Never return a process owned by another user.** Each candidate's
368/// owning uid is compared against the caller's effective uid, and
369/// anything that does not match is skipped — the concrete hazard is a
370/// caller later signalling a stranger's process on a shared machine.
371/// 3. **Structural, not exec-confirmed (25-12/999.47, the production half
372/// of the defect class).** This is a **structural** match over
373/// `/proc/<pid>/cmdline` alone. During a process's own
374/// `fork()`->`execve()` window — [`process_start_time`]'s doc comment
375/// is this codebase's authoritative statement of the mechanism —
376/// `/proc/<pid>/cmdline` transiently reports its PARENT's argv, not its
377/// own. A transient child of the monitor wrapper, or of `devflow
378/// advance`, therefore matches Layer 1 or Layer 2 respectively while
379/// being neither. This census does not — and deliberately should not —
380/// filter that case out: a census that guessed at exec status would
381/// also drop genuine strays, and it has no reliable way to distinguish
382/// the two (see [`process_age`]'s own doc comment for why). It is the
383/// **caller's** obligation not to act on an unqualified census result —
384/// and that obligation has TWO parts, bounding two DIFFERENT hazards,
385/// neither of which discharges the other (CR-01, 999.44/DEN-68):
386///
387/// - **The age floor** ([`process_age`]/[`STRAY_MIN_AGE`]) bounds the
388/// fork/exec cmdline-inheritance window above — "is this argv match
389/// even real yet."
390/// - **Registry-reachability** (`commands::unreachable_stray_candidates`,
391/// `devflow-cli::commands`) bounds a different question — "is this
392/// process alive AND OWNED by a live registry entry, lock file, or
393/// state file" — which the age floor says nothing about: a monitor
394/// wrapper minutes old sails straight past it while still being a
395/// live, registered process, not a stray.
396///
397/// `reap_stray_candidates` (`devflow-cli::commands`) is the one caller
398/// with a destructive consequence, and it discharges the first with
399/// [`process_age`] and [`STRAY_MIN_AGE`]; `unreachable_stray_candidates`
400/// (`devflow-cli::commands`), interposed before either `doctor` or
401/// `reap_stray_candidates` acts, discharges the second — never this
402/// function.
403///
404/// Every read failure is tolerated silently (a pid that vanishes between
405/// the directory listing and the cmdline/stat read is normal churn, not an
406/// error), and an unreadable `/proc` returns an empty list rather than
407/// propagating an error.
408pub fn discover_stray_devflow_processes() -> Vec<StrayProcess> {
409 let Ok(entries) = std::fs::read_dir("/proc") else {
410 return Vec::new();
411 };
412
413 let my_uid = unsafe { libc::geteuid() };
414 let mut found = Vec::new();
415
416 for entry in entries.flatten() {
417 let Some(pid) = entry
418 .file_name()
419 .to_str()
420 .and_then(|name| name.parse::<u32>().ok())
421 else {
422 continue;
423 };
424
425 // Shared-machine safety: skip anything not owned by us before
426 // reading anything else about it.
427 let Ok(owner_metadata) = std::fs::metadata(entry.path()) else {
428 continue;
429 };
430 if std::os::unix::fs::MetadataExt::uid(&owner_metadata) != my_uid {
431 continue;
432 }
433
434 let Ok(raw_cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
435 continue;
436 };
437 let args: Vec<String> = raw_cmdline
438 .split(|&byte| byte == 0)
439 .filter(|arg| !arg.is_empty())
440 .map(|arg| String::from_utf8_lossy(arg).into_owned())
441 .collect();
442
443 let Some(layer) = classify_stray_layer(&args) else {
444 continue;
445 };
446
447 // The candidate's identity, recorded now so a caller can
448 // re-confirm it with `is_same_process` right before acting.
449 let Some(start_time) = process_start_time(pid) else {
450 continue; // exited between the directory listing and here
451 };
452
453 found.push(StrayProcess {
454 pid,
455 start_time,
456 layer,
457 });
458 }
459
460 found
461}
462
463/// Basename of an argv element, matching the idiom already used by
464/// [`looks_like_devflow_process`].
465///
466/// `pub(crate)` (widened from private, 25-11/999.47) so
467/// [`crate::test_support::wait_for_exec_visibility`] can reuse this exact
468/// basename idiom instead of growing a second copy of it. Crate-internal
469/// visibility only — not public API of this crate's normal build.
470pub(crate) fn argv_basename(arg: &str) -> Option<&str> {
471 std::path::Path::new(arg)
472 .file_name()
473 .and_then(|n| n.to_str())
474}
475
476/// Which layer (if any) an argv list structurally matches. See
477/// [`discover_stray_devflow_processes`] for the two matchers' exact shape.
478fn classify_stray_layer(args: &[String]) -> Option<StrayLayer> {
479 let is_monitor_wrapper = args.len() >= 3
480 && argv_basename(&args[0]) == Some("sh")
481 && args[1] == "-c"
482 && args[2].contains(MONITOR_WRAPPER_MARKER);
483 if is_monitor_wrapper {
484 return Some(StrayLayer::MonitorWrapper);
485 }
486
487 let is_advance_child = args
488 .first()
489 .and_then(|argv0| argv_basename(argv0))
490 .is_some_and(|name| name == DEVFLOW_BINARY_NAME)
491 && args.get(1).map(String::as_str) == Some(ADVANCE_SUBCOMMAND);
492 if is_advance_child {
493 return Some(StrayLayer::AdvanceChild);
494 }
495
496 None
497}
498
499/// Best-effort, Linux-only identity check for `devflow stop`'s signalling
500/// fallback (T-23-52, PID reuse in a stale lock file): does
501/// `/proc/<pid>/cmdline` name a devflow process? Reads the NUL-separated
502/// argv and reports whether any argument's file-name component starts with
503/// `devflow`. Returns `false` when the file cannot be read (process exited
504/// between the liveness check and this call, non-Linux, permission denied)
505/// — the fail-closed direction. A `false` return means "identity could not
506/// be confirmed," and callers must treat that as "do not signal," never as
507/// "signal anyway."
508///
509/// **UNSOUND ON ITS OWN — see 999.47.** This returns `true` for any freshly
510/// `fork`ed child of a devflow process that has not yet completed `execve`,
511/// because such a child transiently carries its parent's cmdline. It is
512/// retained only as a secondary, advisory signal; prefer
513/// [`is_same_process`] with a recorded start time, which cannot be fooled
514/// this way. Never let this function alone authorise a signal.
515#[deprecated(note = "unsound alone (999.47) -- use is_same_process with a recorded start time")]
516pub fn looks_like_devflow_process(pid: u32) -> bool {
517 let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
518 return false;
519 };
520 cmdline
521 .split(|&byte| byte == 0)
522 .filter(|arg| !arg.is_empty())
523 .any(|arg| {
524 let arg = String::from_utf8_lossy(arg);
525 std::path::Path::new(arg.as_ref())
526 .file_name()
527 .and_then(|name| name.to_str())
528 .is_some_and(|name| name.starts_with("devflow"))
529 })
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn agent_running_detects_self() {
538 // The current process is, by definition, running.
539 assert!(agent_running(std::process::id()));
540 }
541
542 /// A reaped-pending child is dead, not running. `kill(pid, 0)` succeeds
543 /// on a zombie because the pid is still allocated, so the bare POSIX
544 /// check reports it alive — which is how a container with no reaping
545 /// init can make a dead agent look permanently live.
546 #[test]
547 fn agent_running_is_false_for_an_unreaped_zombie() {
548 let mut child = std::process::Command::new("true")
549 .spawn()
550 .expect("spawn true");
551 let pid = child.id();
552
553 // Wait for it to become a zombie WITHOUT reaping it: poll /proc for
554 // State: Z rather than calling wait(), which would clear the pid.
555 let mut became_zombie = false;
556 for _ in 0..200 {
557 if super::is_zombie(pid) {
558 became_zombie = true;
559 break;
560 }
561 std::thread::sleep(std::time::Duration::from_millis(10));
562 }
563 assert!(became_zombie, "child never became an unreaped zombie");
564
565 // The bare POSIX check still says "alive" — that is the trap.
566 assert_eq!(
567 unsafe { libc::kill(pid as libc::pid_t, 0) },
568 0,
569 "kill(pid, 0) is expected to still succeed on a zombie — if this \
570 fails the test is no longer exercising the case it was written for"
571 );
572 assert!(
573 !agent_running(pid),
574 "a zombie has exited and must not be reported as running"
575 );
576
577 let _ = child.wait();
578 }
579
580 #[test]
581 fn agent_running_false_for_dead_pid() {
582 // A PID near the top of the range is essentially never live.
583 assert!(!agent_running(0x7FFF_FFFE));
584 }
585
586 #[test]
587 fn agent_running_rejects_corrupt_pid_values() {
588 // "0" from a truncated PID file: kill(0, 0) would signal our own
589 // process group and report alive.
590 assert!(!agent_running(0));
591 // Above i32::MAX: `as libc::pid_t` would wrap to -1, and
592 // kill(-1, 0) probes every signalable process — almost always "alive".
593 assert!(!agent_running(u32::MAX));
594 assert!(!agent_running(i32::MAX as u32 + 1));
595 }
596
597 #[test]
598 fn terminate_rejects_pid_zero() {
599 // Would target the caller's own process group — never send it.
600 assert!(!terminate(0));
601 }
602
603 #[test]
604 fn terminate_rejects_pid_above_i32_max() {
605 // Would wrap to -1 through the pid_t cast — kill(-1, SIGTERM) hits
606 // every process the caller may signal.
607 assert!(!terminate(u32::MAX));
608 assert!(!terminate(i32::MAX as u32 + 1));
609 }
610
611 #[test]
612 fn terminate_signals_a_live_child_and_it_exits() {
613 let mut child = std::process::Command::new("sleep")
614 .arg("30")
615 .spawn()
616 .expect("spawn sleep");
617 let pid = child.id();
618
619 assert!(terminate(pid), "terminate must report the signal delivered");
620
621 let status = child.wait().expect("wait on the terminated child");
622 assert!(
623 !status.success(),
624 "a SIGTERM'd child must not report a successful exit, got {status:?}"
625 );
626 }
627
628 #[test]
629 fn terminate_and_verify_rejects_pid_zero_and_out_of_range_without_signalling() {
630 // Same wraparound/group-signal hazard `terminate` and `agent_running`
631 // already guard against — never send anything for these values.
632 assert!(!terminate_and_verify(
633 0,
634 std::time::Duration::from_millis(50),
635 std::time::Duration::from_millis(10)
636 ));
637 assert!(!terminate_and_verify(
638 u32::MAX,
639 std::time::Duration::from_millis(50),
640 std::time::Duration::from_millis(10)
641 ));
642 assert!(!terminate_and_verify(
643 i32::MAX as u32 + 1,
644 std::time::Duration::from_millis(50),
645 std::time::Duration::from_millis(10)
646 ));
647 }
648
649 #[test]
650 fn terminate_and_verify_returns_true_immediately_for_a_dead_pid() {
651 // A pid essentially never live: `terminate` fails to signal it at
652 // all, so the function must report "already dead" without waiting
653 // out the ceiling.
654 let start = std::time::Instant::now();
655 let cleared = terminate_and_verify(
656 0x7FFF_FFFE,
657 std::time::Duration::from_secs(5),
658 std::time::Duration::from_millis(20),
659 );
660 let elapsed = start.elapsed();
661
662 assert!(
663 cleared,
664 "a pid that cannot be signalled at all must count as already cleared"
665 );
666 assert!(
667 elapsed < std::time::Duration::from_secs(1),
668 "must not wait out the full ceiling when the signal itself fails, took {elapsed:?}"
669 );
670 }
671
672 #[test]
673 fn terminate_and_verify_clears_a_normal_child_before_the_wait_elapses() {
674 // `sleep` has no TERM handler installed, so the default disposition
675 // (terminate) applies — it must exit promptly, well before
676 // escalation would ever be needed.
677 let mut child = std::process::Command::new("sleep")
678 .arg("30")
679 .spawn()
680 .expect("spawn sleep");
681 let pid = child.id();
682
683 let start = std::time::Instant::now();
684 let cleared = terminate_and_verify(
685 pid,
686 std::time::Duration::from_secs(5),
687 std::time::Duration::from_millis(20),
688 );
689 let elapsed = start.elapsed();
690
691 assert!(cleared, "a TERM-honouring child must be cleared");
692 assert!(
693 elapsed < std::time::Duration::from_secs(2),
694 "clearing an ordinary child must complete well before the 5s wait \
695 ceiling, took {elapsed:?} (SIGKILL escalation should not have \
696 been needed)"
697 );
698
699 let _ = child.wait();
700 }
701
702 #[test]
703 fn terminate_and_verify_escalates_to_kill_for_a_term_ignoring_child() {
704 // D-17's regression test: a child that installs an empty TERM
705 // handler and then sleeps must still be cleared, via the SIGKILL
706 // escalation, within the bounded wait.
707 let mut child = std::process::Command::new("sh")
708 .arg("-c")
709 .arg("trap '' TERM; sleep 30")
710 .spawn()
711 .expect("spawn TERM-ignoring child");
712 let pid = child.id();
713
714 // Give the shell a moment to install its trap before signalling.
715 std::thread::sleep(std::time::Duration::from_millis(100));
716
717 let cleared = terminate_and_verify(
718 pid,
719 std::time::Duration::from_millis(500),
720 std::time::Duration::from_millis(20),
721 );
722
723 assert!(
724 cleared,
725 "a TERM-ignoring child must still be cleared via SIGKILL escalation"
726 );
727 assert!(
728 !agent_running(pid),
729 "child must be verified dead after escalation, not merely assumed"
730 );
731
732 let _ = child.wait();
733 }
734
735 #[test]
736 fn discover_stray_devflow_processes_finds_a_monitor_wrapper() {
737 // A shell invoked with `-c` whose script argument contains the
738 // wrapper's literal marker, verbatim from monitor.rs.
739 let mut child = std::process::Command::new("sh")
740 .arg("-c")
741 .arg("trap cleanup TERM INT; sleep 30")
742 .spawn()
743 .expect("spawn monitor-wrapper-shaped fixture");
744 let pid = child.id();
745
746 // 999.47: cross the exec-visibility barrier before reading the
747 // cmdline-derived census, or this test races the fixture's own
748 // fork()->execve() window (25-11).
749 assert!(
750 crate::test_support::wait_for_exec_visibility(
751 pid,
752 "sh",
753 crate::test_support::EXEC_VISIBILITY_WAIT,
754 crate::test_support::EXEC_VISIBILITY_POLL,
755 ),
756 "pid {pid}: exec visibility timed out before the fixture became discoverable"
757 );
758
759 let found = discover_stray_devflow_processes();
760 let candidate = found.iter().find(|p| p.pid == pid);
761
762 let candidate = candidate.expect("monitor wrapper fixture must be discovered");
763 assert_eq!(candidate.layer, StrayLayer::MonitorWrapper);
764 assert!(
765 is_same_process(pid, candidate.start_time),
766 "the recorded start time must re-confirm identity while the process is alive"
767 );
768
769 let _ = child.kill();
770 let _ = child.wait();
771 }
772
773 #[test]
774 fn discover_stray_devflow_processes_rejects_the_999_47_false_positive_shape() {
775 // The exact false-positive class 999.47 measured: a process that
776 // merely mentions a devflow-looking path as an argument, not
777 // structurally shaped like either layer.
778 let mut child = std::process::Command::new("sh")
779 .arg("-c")
780 .arg("sleep 30")
781 .arg("/tmp/devflow-scratch/looks-like-devflow")
782 .spawn()
783 .expect("spawn 999.47-shaped fixture");
784 let pid = child.id();
785
786 // 999.47/25-11: without this barrier, the assertion below passes
787 // during the fork()->execve() window for a reason unrelated to what
788 // it claims to test — the census is reading the CALLER's (this test
789 // binary's) argv, which matches neither Layer 1 nor Layer 2, so the
790 // NOT-FIND assertion is vacuously true regardless of whether the
791 // fixture's own shape is correctly rejected. Crossing the barrier
792 // first makes the NOT-FIND assertion mean what it claims.
793 assert!(
794 crate::test_support::wait_for_exec_visibility(
795 pid,
796 "sh",
797 crate::test_support::EXEC_VISIBILITY_WAIT,
798 crate::test_support::EXEC_VISIBILITY_POLL,
799 ),
800 "pid {pid}: exec visibility timed out before the fixture became discoverable"
801 );
802
803 let found = discover_stray_devflow_processes();
804
805 let _ = child.kill();
806 let _ = child.wait();
807
808 assert!(
809 !found.iter().any(|p| p.pid == pid),
810 "a process merely mentioning a devflow-looking path must not be discovered"
811 );
812 }
813
814 #[test]
815 fn discover_stray_devflow_processes_rejects_devflow_named_argv0_with_wrong_argv1() {
816 // argv[0]'s basename matches the binary name, but argv[1] is not
817 // the advance subcommand — Layer 2 requires BOTH positions.
818 let mut child = std::process::Command::new("sleep");
819 std::os::unix::process::CommandExt::arg0(&mut child, "devflow");
820 let mut child = child
821 .arg("30")
822 .spawn()
823 .expect("spawn devflow-argv0 fixture");
824 let pid = child.id();
825
826 // 999.47/25-11: same reasoning as the false-positive-shape test
827 // above — without this barrier, the NOT-FIND assertion below passes
828 // vacuously during the fork()->execve() window (the caller's own
829 // argv matches neither layer), which says nothing about whether
830 // THIS fixture's argv[0]==devflow/argv[1]!=advance shape is
831 // correctly rejected once its own exec has actually landed.
832 assert!(
833 crate::test_support::wait_for_exec_visibility(
834 pid,
835 "devflow",
836 crate::test_support::EXEC_VISIBILITY_WAIT,
837 crate::test_support::EXEC_VISIBILITY_POLL,
838 ),
839 "pid {pid}: exec visibility timed out before the fixture became discoverable"
840 );
841
842 let found = discover_stray_devflow_processes();
843
844 let _ = child.kill();
845 let _ = child.wait();
846
847 assert!(
848 !found.iter().any(|p| p.pid == pid),
849 "argv[0]==devflow with argv[1] != advance must not be discovered as Layer 2"
850 );
851 }
852
853 #[test]
854 fn discover_stray_devflow_processes_excludes_an_unrelated_process() {
855 // This test binary's own process is neither the wrapper's `sh -c`
856 // shape nor a `devflow advance` invocation, so it must never be
857 // discovered — proving the census does not match by default and
858 // completes a full /proc scan without error.
859 let self_pid = std::process::id();
860 let found = discover_stray_devflow_processes();
861 assert!(
862 !found.iter().any(|p| p.pid == self_pid),
863 "the test binary itself must never be discovered as a stray process"
864 );
865 }
866
867 #[test]
868 #[allow(deprecated)] // D-13: retained, zero-cost-of-call regression coverage for a deprecated-but-not-removed public fn
869 fn looks_like_devflow_process_is_true_for_the_current_process() {
870 // Cargo names this crate's test binary from its crate name
871 // (`devflow-core` → `devflow_core-<hash>` under target/deps
872 // naming) — a reliable positive fixture with no need to spawn a
873 // real devflow binary.
874 assert!(looks_like_devflow_process(std::process::id()));
875 }
876
877 #[test]
878 fn looks_like_devflow_process_is_false_for_a_non_devflow_process() {
879 // Retargeted (D-13): this test used to assert the deprecated
880 // `looks_like_devflow_process` predicate against a freshly spawned
881 // `sleep`, which raced that child's `execve` and failed
882 // intermittently in CI (999.47, "MECHANISM CONFIRMED 2026-07-26").
883 // It now asserts the `(pid, starttime)` identity guard production
884 // actually uses — `is_same_process` — which needs no `spawn()` and
885 // therefore has no `execve` to race. This is what fixes the flake,
886 // by construction, not by making the old test rarer.
887 let self_pid = std::process::id();
888 let real_start = process_start_time(self_pid)
889 .expect("must be able to read this process's own recorded start time");
890
891 assert!(
892 is_same_process(self_pid, real_start),
893 "the current process must match its own recorded start time"
894 );
895
896 let perturbed_start = real_start.wrapping_add(1);
897 assert!(
898 !is_same_process(self_pid, perturbed_start),
899 "a deliberately wrong start time must not be treated as a match"
900 );
901 }
902
903 #[test]
904 #[allow(deprecated)] // D-13: retained, zero-cost-of-call regression coverage for a deprecated-but-not-removed public fn
905 fn looks_like_devflow_process_is_false_when_proc_cannot_be_read() {
906 // A pid guaranteed not to exist: the fail-closed default must be
907 // false, never true, when identity cannot be confirmed at all.
908 assert!(!looks_like_devflow_process(0x7FFF_FFFE));
909 }
910
911 // 25-12/999.47 (production half): `process_age`'s own test group. Test
912 // names all begin `process_age_` so the count-based acceptance
913 // criterion (`cargo test agent::tests::process_age` -> `3 passed`)
914 // resolves unambiguously.
915
916 #[test]
917 fn process_age_returns_some_for_the_current_process() {
918 // Measured directly (not assumed): `/proc/uptime` and this
919 // process's own recorded start time share the same ~10ms USER_HZ
920 // granularity `process_start_time`'s doc comment already caveats
921 // — a process asked for its own age within one tick of its start
922 // (plausible for a freshly launched, fast-starting test binary)
923 // genuinely reads `Duration::ZERO`, reproduced deterministically
924 // running this test in isolation. Sleep past one tick first so
925 // the assertion below tests "age advances," not "the OS finished
926 // its first tick before this line ran."
927 std::thread::sleep(std::time::Duration::from_millis(20));
928 let age = process_age(std::process::id()).expect("this process's own age must resolve");
929 assert!(
930 age > std::time::Duration::ZERO,
931 "a running process must report nonzero age once at least one tick has elapsed"
932 );
933 assert!(
934 age < std::time::Duration::from_secs(3600),
935 "the test binary has not been running for an hour"
936 );
937 }
938
939 #[test]
940 fn process_age_returns_none_for_a_dead_pid() {
941 // Same guaranteed-not-to-exist pid the fail-closed tests above use.
942 assert_eq!(process_age(0x7FFF_FFFE), None);
943 }
944
945 #[test]
946 fn process_age_is_below_the_floor_for_a_fresh_child_and_grows_monotonically_for_self() {
947 let mut child = std::process::Command::new("sleep")
948 .arg("30")
949 .spawn()
950 .expect("spawn fixture");
951 let pid = child.id();
952
953 let child_age = process_age(pid).expect("a freshly spawned child's age must resolve");
954 assert!(
955 child_age < STRAY_MIN_AGE,
956 "a process spawned microseconds ago must be younger than the floor"
957 );
958
959 let _ = child.kill();
960 let _ = child.wait();
961
962 let self_pid = std::process::id();
963 let first = process_age(self_pid).expect("this process's own age must resolve");
964 std::thread::sleep(std::time::Duration::from_millis(50));
965 let second =
966 process_age(self_pid).expect("this process's own age must resolve after the sleep too");
967 assert!(
968 second >= first,
969 "age must grow monotonically across a sleep, never shrink"
970 );
971 }
972}