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/// 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}